Python数据结构与算法分析 第七章-图及其算法

437 阅读18分钟

第七章   树

7.1 图的概率

  • 图Gragh是比树更为一般的结构,也是由节点和边构成
  • 图可以用来表示现实世界种很多食物

1666924488958.png

7.2 术语表

  • 顶点Vertex(也称‘节点Node’)

    • 是图的基础组成部分。我们称作“键Key”。顶点也可以带有附加信息,我们称作“有效载荷pyload”。
  • 边Edge (也称‘弧Arc’ )

    • 作为两个顶点之间关系的表示。边既可以是无向的,也可以是有向的。相应的图称为无向图有向图
  • 权重Weight

    • 为了表达从一个顶点到另一个顶点的‘代价’,可以给边赋权重;例如公交网络种两个站点的‘距离’ 、‘通行时间’

1666924960869.png

  • 一个图G可以定义G =(V,E)

    • 顶点V和边E的集合G,E中的每条边e=(v,w),v,w都是V中的顶点
    • 如果是有权图,则可以在e中添加权重子图:V和E的子集

1666925342602.png

  • 路径Path
    • 图中的路径,是由边依次连接起来的顶点序列无权路径的长度为边的数量带权路径的长度所有边权重的和

1666926127165.png

  • 圈Cycle

    • 首尾顶点相同的路径,没有环的图被称为无环图,没有环的有向图被称为有向无环图

1666926422743.png


7.3 图的抽象数据类型

  • Graph() 新建一个空图

  • addVertex(vert) 向图中添加一个顶点

  • addEdge(fromVert, toVert) 向图中添加一条有向边,用于连接顶点 fromVert 和 toVert。

  • addEdge(fromVert, toVert, weight) 向图中添加一条带权重 weight 的有向边,用于连接顶点 fromVert 和 toVert。

  • getVertex(vertKey) 在图中找到名为 vertKey 的顶点

  • getVertices() 以列表形式返回图中所有顶点

  • in 通过 vertex in graph 这样的语句,在顶点存在时返回 True,否则返回 False。

7.3.1 邻接矩阵 adjacency matrix

  • 在矩阵实现中,每一行和每一列都表示图中的一个顶点

  • 第 v 行和第 w 列交叉的格子中的值表示从顶点 v 到顶点 w 的边的权重,无权边有边相连标注1或者0

  • 如果两个顶点被一条边连接起来,就称它们是相邻的。

    • 优点:实现简单
    • 缺点:由于大多数图都是稀疏的,会称为稀疏矩阵,边远远少于|V²|

1666927088162.png

7.3.1 邻接表 adjacency list

  • 为图对象的所有顶点保存一个主列表,同时为每一个顶点对象都维护一个列表,其中记录了与它相连的顶点。

1666927451106.png


7.3.3 图的python实现

class Vertex:  #使用字典 connectedTo 来记录与其相连的顶点,以及每一条边的权重。
    def __init__(self,key): #
        self.id = key #顶点id V0,V1
        self.connectedTo = {} #邻接列表存储连接边 空字典

    def addNeighbor(self,nbr,weight=0): #添加从一个顶点到另一个顶点的边连接 self自己,nbr邻居
        self.connectedTo[nbr] = weight #nbr为邻居的顶点 以及两点之间的权重

    def __str__(self):
        return str(self.id) + "connectedTO:" +str([x.id for x in self.connectedTo]) #打印顶点字符串格式

    def  getConnections(self): #返回邻接表本顶点所连接的所有顶点
        return self.connectedTo.keys()

    def getId(self):  #返回Key
        return self.id

    def getWeight(self,nbr):  #返回与邻接点nbr之间的权重
        return self.connectedTo[nbr]

class Graph:  #图    包含所有顶点的主表
    def __init__(self):
        self.vertList = {}  #初始化顶点列表为空
        self.numVerticesc = 0  #顶点个数初始化为0

    def addVertex(self,key): #添加新顶点
        self.numVerticesc = self.numVerticesc+1 #顶点个数+1
        newVertex = Vertex(key) #添加对应的顶点对象
        self.vertList[key] = newVertex
        return  newVertex

    def getVertex(self,n): #通过key查找顶点
        if n in self.vertList:
            return self.vertList[n]
        else:
            return None

    def __contains__(self, n):
        return n in self.vertList

    def addEdge(self,f,t,cost=0): #添加边
        if f not in self.vertList :#起点不在顶点列表内
            nv =  self.addVertex(f) #添加新顶点f到图
        if t not in self.vertList: #终点不在顶点列表内
            nv  = self.addVertex(t) #添加新顶点t
        self.vertList[f].addNeighbor(self.vertList[t],cost) #f和t是邻接点

    def getVertices(self): #图中所有顶点以列表返回
        return self.vertList.keys()

    def __iter__(self): #迭代器  能够根据顶点名或者顶点对象本身遍历图中的所有顶点
        return iter(self.vertList.values())

7.4 广度优先队列

7.4.1 词梯问题

  • 从一个单词演变到另一个单词,其中的过程可以经过多个中间单词

    • 要求事相邻两个单词之间差异只能是1个字母,如FOOL变SAGE:
    • F**OOL >> POOL >> POLL >> POLE >> PALE >> SALE >> SAGE **
  • 目标是找到最短的单词变换序列

    • 将可能的单词之间的演变过程表达为
    • 采用广度优先队列,来搜寻从开始单词到结束单词的所有有效路径
    • 选择其中最快到达目标单词的路径

7.4.2 构建单词关系图

  • 将单词作为顶点的标识Key

  • 首先将所有的单词作为顶点加入图中,在设法建立顶点间的边

  • 如果两个单词之间只相差一个字母,就在它们之间设一条边 1667100148076.png  假设列表中有 5110 个单词,将一个单词与列表中的其他所有单词进行222 第 7 章 图及其算法比较,时间复杂度为O (n²),对于 5110 个单词来说,这意味着要进行 2600 多万次比较。改进方法如下

  • 创建大量的桶,每一个桶可以存放若干单词

    • 通标记是去掉一个字母的单词,通配符_ 占空单词

    • 所有匹配标记的单词都放入这个桶

    • 所有单词就位后,再在同一个桶的单词之间建立边

    • 字典的就是桶上的标签就是对应的单词列表

1667100422899.png

def bulidGraph(wordFile): #从wordFile 文件中读取所有单词
    d = {}
    g = Graph() #空图
    wfile = open(wordFile,'r')
    # 创建词桶
    for line in wfile:
        word =  line[:-1] #key
        for i in range(len(word)):
            bucket = word[:-i] + "_" + word[i+1:] #4个单词属于4个桶
            if bucket in d:
                d[bucket].append(word)
            else:
                d[bucket] = [word]
    # 为同一个桶中的单词添加顶点和边
    for bucket in d.keys():
        for word1 in d[bucket]:
            for word2 in d[bucket]:
                if word1 != word2: #两者不相等建立边  value
                    g.addEdge(word1,word2)
    return g

7.4.3 实现广度优先搜索BFS

解决最短路径问题的算法被称为广度优先搜索

  • 广度优先搜索可回答两类问题。

第一类问题:从节点A出发,前往节点B的路径吗?

第二类问题:从节点A出发,前往节点B的哪条路径最短

  • 给定图G,以及开始搜索的起始点s
    • BFS搜索所有从s可以到达顶点的边
    • 在到达更远的距离k+1的顶点之前,BFS会找到全部距离为k的顶点

  • 为了跟踪顶点的加入过程,并避免重复顶点,要为顶点增加3个属性

    • 距离distance :从起始顶点到此顶点路径长度
    • 前驱顶点 precessor :可反向追溯到起点
    • 颜色color:标识了此顶点是尚未发现(白色)、已经发现(灰色)、完成搜索(黑色)
  • 建立以队列Queue对已经发现的顶点进行排列,决定下一个要探索的顶点(队首顶点)


算法过程

  • 从起始点s开始,作为刚发现的顶点标注为灰色,距离为0,前驱为None,加入队列,接下来是个循环迭代过程

    • 从队首取出一个顶点作为当前顶点
    • 遍历当前顶点的邻接顶点,如果是尚未发现的白色顶点,则将其颜色改为灰色(已发现)。距离加1,前驱顶点为当前顶点,加入队列中。
    • 遍历完成后,将当前顶点设置为已经探索的黑色顶点,循环回到步骤1的队首取当前顶点

  • 以FOOL为起始顶点,直接邻接顶点有4个[pool,foil,foul,cool],标记为灰色,加入队列,FOOL标记为黑色

1667119385544.png

1667119577118.png

  • 从队列中弹出队首元素POOL,直接邻接点有2个[FOOL,POLL],FOOL为已经探索的黑色顶点,跳过,POLL标记尚未发现的灰色顶点,POOL加入队尾排在COOL的后面,FOOL标记为黑色

1667119929909.png

  • 随后弹出FOIL...逐步探索指导探索完所有顶点,队列为空

1667119973701.png

-最后可得出两点之间是否有路径,或者最短路径

from part_3 import Queue

def bfs(g,start):
    start.setDistance() #距离
    start.setPred(None) #前驱
    vertQuene = Queue() #建立空队列
    vertQuene.enquene(start)  #在队列的尾部添加一个元素
    while (vertQuene.size() > 0) : #队列不为空
        currentVert = vertQuene.dequene() #当前节点为出队的队首元素作为当前顶点 删除头部元素self.items.pop()
    for nbr in currentVert.getConnections(): #遍历邻接顶点
        if (nbr.setColor()== "white"): #若邻接点为尚未发现的白色顶点
            nbr.setColor("gray") #将其设置为已经发现的灰色顶点
            nbr.setDistance(currentVert.getDistance) #设置当前顶点与该邻接顶点的距离
            nbr.setPred(currentVert) #当前顶点为邻接顶点的前驱
            vertQuene.enquene(nbr) #邻接顶点入队
    currentVert.setColor("blacl") #当前顶点标注为已经探索的黑色顶点

  • 在以FOOL为起始顶点,遍历了所有顶点,并为每个顶点着色、赋距离和前驱后,即可以通过一个回溯追溯函数 traverse 来确定FOOL到任何单词顶点的最短词梯
#回途追溯
def traverse(y):
    x = y
    while(x.getPred()):
        print(x.getId())
        x = x.getPred()
    print(x.getId)

wordgraph = bulidGraph("fourletterwords.txt")
bfs(wordgraph,wordgraph.getVertex("FOOL"))
traverse(wordgraph.getVertex("SAGE"))

算法分析

  • BFS算法主体是两个循环的嵌套

    • while循环对每个顶点访问一次,所以是O(|V|)
    • 每个顶点最多出队1次,边最多被检查1次,一共是O(|E|)
    • 综合起来BFS的时间复杂度O(|V|+|E|)

7.5 深度优先搜索 DFS

  • 深度优先搜索可以回答三类问题

  寻找所有路径的问题

  寻找所有排列的问题。

  寻找所有组合的问题。

7.5.1 骑士周游问题

  • 在国际象棋棋盘上,一个棋子“🐎”,按照“马走日”的规则,从一个格子出发,要走遍所以棋盘格恰好一次。

    • 把一个这样的走棋序列称为1次“周游

1667133763135.png

解决方法

  • 采用图搜索算法

    • 首先将走棋次序表示为一个图

      • 将棋盘格作为顶点
      • 按照“马走日”规则的走棋步骤作为连接边
      • 建立每一个棋盘格的所有合法走棋步骤能够到达的棋盘格关系图
    • 采用图搜索算法搜寻一个长度为(行×列-1)的路径,路径上包含每个顶点恰好一次

# 深度优先搜索:骑士周游

def genLegalMoves(x,y,bdsize): #马当前坐标默认(0,0) 棋盘大小
    newMoves = []
    moveOffsets = [(-1,-2),(-1,2),(-2,-1),(-2,1),
                   (1,-2),(1,2),(2,-1),(2,1)]  #马走日的8个格子
    for i in moveOffsets: #走棋
        newX = x + i[0]
        newY = y + i[0]
        if legalCoord(newX,bdsize) and legalCoord(newY,bdsize): #没有超过棋盘
            newMoves.append((newX,newY)) #将所有走棋的位置加入列表
    return newMoves
def legalCoord(x,bdsize):
    if x >=0 and x < bdsize:
        return True
    else:
        return False

## 构建走棋关系图
def knightGraph(bdsize):#构建棋盘大小
    ktGraph = Graph()#建立空图
    for row in  range(bdsize):
        for col in range(bdsize): #遍历横纵坐标
            nodeId = posToNodeId(row,col,bdsize) #给每一个格子标号
            newPositions = genLegalMoves(row,col,bdsize) #获取合法走棋 马可以走8个位置放 newPositions
            for e in newPositions:#遍历 newPositions 的每一个位置
                nid = posToNodeId(e[0],e[1],bdsize)
                ktGraph.addEdge(nodeId,nid) #把所有可以走到的顶点和边 加入ktGraph
    return ktGraph

def posToNodeId(row,col,bdsize):
    return row*bdsize+col

7.5.2 实现骑士周游算法

  • 用于解决骑士周游问题的搜索算法叫做深度优先搜索DFS

    • 对每一个可能的分支路径深入到不能再深入为止,而且每个节点只能访问一次。

image.png

遍历顺序是“1-2-3-4-5-6-7-8-9-10-11-12”

  • 首先从根节点“1”出发,这里我们假设优先遍历左边节点“2”。此时“2”仍有子节点,所以应继续向下遍历,下一个节点是“3”,然后是“4”。到了“4”之后,没有子节点了,接着我们应该回溯,应该回到“4”的父节点,也就是“3”。因为“3”还有一个子节点“5”没有遍历,所以下一个我们应该遍历的是“5”。遍历完“5”之后又发现一条路到头了,再次回溯依然回溯到其父节点“3”,此时“3”的所有子节点都已经遍历完了,因该接着回溯到“3”的父节点“2”,然后检查“2”是否有没有遍历完的子节点,按照次顺序遍历所有节点

  • 使用深度优先搜索解决骑士周游问题

    • 如果沿着单支无法继续深入(所有单支上合法的路线已经被走过)
    • 清除颜色标记,回溯上一层父节点
    • 换一个分支继续深入
    • 直达所有节点被走过
  • 引入一个来记录路径并实施返回上一层的回溯操作

#利用深度优先搜索实现骑士周游问题
def knightTour(n,path,u,limit): #n:层数,path:路径,u:当前节点,limit:搜索总深度
    u.setColor("gray") #将当前顶点设置为尚未发现的灰色顶点
    path.append(u) #将当前顶点加入路径
    if n < limit: #在总深度内
        nbrList = list(u.getConnection()) #对这条单支上的合法节点逐一深入
        i = 0
        done = False
        while i < len(nbrList) and not done:#与i相连而且合法顶点
            if nbrList[i].getColor == "white":#如果该节点是没有发现的白色节点
                done = knightTour(n+1,path,nbrList[i],limit) #向下一层递归深入
            i = i+1 #层数+1

        if not done :
            path.pop()
            u.setColor("white") #单支已经无法深入,把当前顶点弹出,回溯寻找与山一层相连得其他节点,将当前顶点设置为没有探索得白色节点
        else:
            done = True
        return done

1667187988510.png


7.5.4 改进骑士周游算法 - Warnsdorff算法

  • 初始算法中nbrList,直接以原始顺序来确定优先搜索的分支次序

  • 新算法,只修改了遍历下一格的次序

    • 将u的合法移动目标棋盘格排序为:具有最少合法移动目标的格子优先搜索
def orderByAvail(n):
   resList = [] #马走日的8个坐标顺序 不指定 随机
   for v in n.getConnections():
       if v.getColor() == "white":
           c = 0
           for w in v.getConnections():
               if w.getColor() == "white":
                   c = c+1
               resList.append((c,v))
       resList.sort(key=lambda x:x[0]) #对可以继续深入的最少位置 排序 x[0] 指的是没有探索的白色顶点 f
       return  [y[1] for y in resList]

 采用先验的知识来改进算法性能的做法称作为“启发式规则heuristic

7.5.5 实现通用深度优先搜索

  • 带有DFS算法的图实现为Graph的子类

    • 顶点Vertex增加了成员Discovery及Finish
    • 图Graph增加了成员time来记录算法执行步骤的数目
class DFSGraph(Graph):#Graph的子类
   def __init__(self):
       super().__init__()
       self.time = 0

   def dfs(self):
       for aVertex in self: #遍历所有顶点
           aVertex.setColor("white") #设置为没有探索的白色顶点
           aVertex.setPred(-1) #前驱为-1 无前驱
       for aVertex in self:
           if aVertex.getColor() == "white":
               self.dfsvisit(aVertex) #遍历所有顶点 所谓没有探索的白色顶点 则以aVertex为开始顶点建立单支的树

   def dfsvisit(self,startVertex): #以startVertex作为根节点进行单支搜索
       startVertex.setColor("gray") #把开始顶点设为尚未发现的灰色
**        **self.time +=1 #时间设置为当前时间
       startVertex.setDiscovery(self.time)
       for nextVertex in startVertex.getConnections(): #从当前顶点所连接的所有顶点进行遍历 只有是没有探索的白色顶点
           if nextVertex.getColor() == "white":
               nextVertex.setPred(startVertex) #将当前顶点设置为邻接顶点的前驱
               self.dfsvisit(nextVertex) #递归调用以白色顶点(nextVertex)作为之后探索的开始顶点
       startVertex.setColor("black")#将开始顶点设置为已经探索的黑色顶点
       self.time +=1#时间加1
       startVertex.setFinish(self.time)

1667200666967.png


算法分析

  • dfs有两个循环,每次都是|V|次,为O(|V|)
  • 由于 dfsvisit只有在顶点是白色时被递归调用,因此循环最多会对图中的每一条边执行一次,也就是O(|E|)
  • DFS 和 BFS 时间复杂度都为O(|V|+|E|)

7.6 拓扑排序

工作流程图得到工作次序排序的算法称为"拓扑排序"

  • 几乎所有问题都可以转换成问题,让我们来考虑如何制作一批松饼

    • 以动作为顶点,以先后次序作为有向边

1667201207205.png

  • 拓扑排序主要处理一个有向无圈图(DAG),输出顶点的线性序列

    • 使得两个顶点v,w,如果G中有(v,w)边,在线性序列中v就出现在w之前
  • 拓扑排序广泛应用在依赖时间的排期上,还可以用在项目管理数据库查询优化矩阵乘法的次序优化


DFS可以很好的实现拓扑排序

  • 将工作流程建立为图,工作项节点依赖关系有向边
  • 工作流程是一个有向无圈图
  • 对有向无圈图调用DFS算法,得到每个顶点的“结束时间”
  • 按照每个顶点的“结束时间”从大到小排序输出这个次序下的顶点列表

1667203404374.png

以结束时间进行倒序:

1667203586574.png


7.7 强连通单元

1667203734093.png

1667203789152.png 简化:

1667203922505.png


图的转置:

1667203966430.png


强连通分支算法:Kosaraju算法思路

  • 首先,对图G调用DFS算法,为每一个顶点计算"结束时间"
  • 对图进行转置
  • 对转置图调用DFS算法,但在函数中对每个顶点的结束时间采用倒序的顺序来搜索
  • 所得到的深度优先森林中的每一棵树就是一个强连接单元

1667204237114.png

1667204260737.png


7.8 最短路径问题

7.8.1 Dijkstra算法

  • 采用贪心算法的策略,将所有顶点分为已标记点和未标记点两个集合,从起始点开始,不断在未标记点中寻找距离起始点路径最短的顶点,并将其标记,直到所有顶点都被标记为止。
    • 顶点访问次序有一个优先队列来控制,队列中作为优先级的是顶点的dist属性
    • 起初,只有开始顶点 dist设置为0,其他所有顶点dist设为sys.maxsize(最大整数),全部加入优先队列
    • 随着队列中每个最低dist顶点率先出队,并计算它和邻接顶点的权重
    • 更新dist优先级后再依次出队

1667220919068.png

def dijkstra(aGraph,start):
    pq = PriorityQueue()
    start.setDistance(0) #开始距离为0
    pq.bulidHeap([(v.getDistance(),v)for v in aGraph]) #建堆
    while not  pq.isEmpty(): #只要优先队列中有顶点
        currentVert = pq.delMin() #出队
        for nextVert in currentVert.getConnections():#遍历与当前顶点所有的邻接点
            newDist = currentVert.getDistance() + currentVert.getWeight(nextVert) #更新两点之间距离 当前距离加上邻接顶点的权重
            if newDist < nextVert.getDistance(): #如果新距离值比以前的距离小
                nextVert.setDistance(newDist) #更新距离
                nextVert.setPred(currentVert)  #更新前驱
                pq.decreaseKey(nextVert,newDist)  #优先队列重排
  • Dijkstra算法只能处理大于0的权重

算法分析

  • 首先,所有顶点加入优先队列并建堆,为O(|V|)
  • 每个顶点只出队1次,delMin花费O(log|V|),一共就是O(|V|log|V|)
  • 每个边关联到顶点做一次decreaseKey操作O(log|V|),一共是O(|E|log|V|)
  • 最后为O((|V|+|E|)log|V|)

7.9 最小生成树问题

 假设一个连通无向图图G(V,E),其中每条边(u,v)属于E,我们为其赋予权重w(u,v),我们希望找到一个无环子集T包含E,即包含所有顶点V,并且边权重之和最小,T必然是一棵树。我们称这样的树为生成树,我们称求取该生成树的问题为最小生成树问题。

如下图描述的是一个连通图及其最小生成树的例子

image.png

(在图中,属于最小生成树的边加上了阴影,图中所示的生成树的总权重为37,不过,该最小生成树并不是唯一的,删除边( b , c ) (b,c)(b,c),然后加入边( a , h ) (a,h)(a,h),将形成另一颗权重也是37的最小生成树。 )

7.9.1 Prim算法

Prim算法解决的问题是连通无向有权图中最小生成树问题

算法步骤:

  • 随机选择一个节点初始化最小树
  • 对所有的连接该树和新节点的边,选择最小权重的边
  • 重复上述2,直到包含所有的节点

image.png

def prim(G, start):
    pq = PriorityQueue()
    for v in G:
        v.setDistance(sys.maxsize)
        v.setPred(None)
    start.setDistance(0)
    pq.buildHeap([(v.getDistance(), v) for v in G])
    while not pq.isEmpty():
        currentVert = pq.delMin()
        for nextVert in currentVert.getConnections():
            newCost = currentVert.getWeight(nextVert) \
                       + currentVert.getDistance()
            if v in pq and newCost < nextVert.getDistance():
                 nextVert.setPred(currentVert)
                 nextVert.setDistance(newCost)
                 pq.decreaseKey(nextVert, newCost)

image.png

7.8.2 Kruskal算法

Kruskal算法解决的问题也是连通无向有权图中最小生成树问题 算法步骤:

  • 对所有的边根据权重进行从小到大排序,将每个顶点独立视为根节点,产生n个树
  • 每次选择最小的边加入到树中,如果新增加的边导致树中有,则丢弃该条边
  • 重复上面2增加边的操作,直到树包含了所有的节点

image.png

import numpy as np #二维数组
#定义顶点
vertices = list("ABCDEFG")

#定义边并按照边权重排序

edges = [("A","B",5),("A","G",7),
         ("B","F",1),("C","F",4),
         ("C","D",3),("C","E",7),
         ("E","F",6),("D","E",4),
         ("E","G",12),("F","G",12)]

edges.sort(key=lambda x:x[2]) #key=lambda x:x[] 即表示待排序对象按第多少维度进行排序 这里对第二维进行排序 即边权重

1667271732530.png

#将每个顶点视为一棵节点树,可以用字典表示,键表示顶点,键值表示顶点所在树的节点

ori_trees = dict()
for i in vertices:
    ori_trees[i] = i
print(ori_trees)

1667271784375.png

#根据边的两个顶点的根节点是否相同考虑是否合并

#寻找根节点

def find_node(x):
    if ori_trees[x]!=x: #不为同一棵树
        ori_trees[x] = find_node(ori_trees[x])
    return ori_trees[x]

#定义最小生成树
mst = []

#定义循环次数,n为需要添加的边树= 顶点个数-1
n = len(vertices)-1

#循环

for edge in  edges:
    v1,v2,_ = edge
    if find_node(v1) != find_node(v2):#不为同一棵树
        ori_trees[find_node(v2)] = find_node(v1) #终点变为起点
        mst.append(edge) #把这条边添加到最小生成树列表
        print('添加第'+str(7-n)+'条边后:')
        n -=1 #总边数-1
        print(ori_trees)
        print(mst)
        if n == 0:#所有顶点都添加到列表中 边数为0
            break

1667272585029.png

7.8.2 对比

最小生成树问题的两个经典算法:

Kruskal算法中,集合A是一个森林,其节点就是给定图的节点,每次加入到集合A 中的安全边永远是权重最小连接两个不同分量的边;

Prim算法里,集合A则是一棵树,每次加入到A中的安全边永远是连接A和A之外某个节点的边中权重最小的边