第七章 树
7.1 图的概率
- 图Gragh是比树更为一般的结构,也是由节点和边构成
- 图可以用来表示现实世界种很多食物
7.2 术语表
-
顶点Vertex(也称‘节点Node’)
- 是图的基础组成部分。我们称作“键Key”。顶点也可以带有附加信息,我们称作“有效载荷pyload”。
-
边Edge (也称‘弧Arc’ )
- 作为两个顶点之间关系的表示。边既可以是无向的,也可以是有向的。相应的图称为无向图和有向图
-
权重Weight
- 为了表达从一个顶点到另一个顶点的‘代价’,可以给边赋权重;例如公交网络种两个站点的‘距离’ 、‘通行时间’
-
一个图G可以定义为G =(V,E)
- 顶点V和边E的集合G,E中的每条边e=(v,w),v,w都是V中的顶点
- 如果是有权图,则可以在e中添加权重子图:V和E的子集
- 路径Path
- 图中的路径,是由边依次连接起来的顶点序列无权路径的长度为边的数量;带权路径的长度为所有边权重的和。
-
圈Cycle
- 首尾顶点相同的路径,没有环的图被称为无环图,没有环的有向图被称为有向无环图
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²|
7.3.1 邻接表 adjacency list
- 为图对象的所有顶点保存一个主列表,同时为每一个顶点对象都维护一个列表,其中记录了与它相连的顶点。
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
-
首先将所有的单词作为顶点加入图中,在设法建立顶点间的边
-
如果两个单词之间只相差一个字母,就在它们之间设一条边
假设列表中有 5110 个单词,将一个单词与列表中的其他所有单词进行222 第 7 章 图及其算法比较,时间复杂度为O (n²),对于 5110 个单词来说,这意味着要进行 2600 多万次比较。改进方法如下:
-
创建大量的桶,每一个桶可以存放若干单词
-
通标记是去掉一个字母的单词,通配符_ 占空单词
-
所有匹配标记的单词都放入这个桶
-
所有单词就位后,再在同一个桶的单词之间建立边
-
字典的键就是桶上的标签,值就是对应的单词列表
-
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标记为黑色
- 从队列中弹出队首元素POOL,直接邻接点有2个[FOOL,POLL],FOOL为已经探索的黑色顶点,跳过,POLL标记尚未发现的灰色顶点,POOL加入队尾排在COOL的后面,FOOL标记为黑色
- 随后弹出FOIL...逐步探索指导探索完所有顶点,队列为空
-最后可得出两点之间是否有路径,或者最短路径
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次“周游”
解决方法:
-
采用图搜索算法
-
首先将走棋次序表示为一个图
- 将棋盘格作为顶点
- 按照“马走日”规则的走棋步骤作为连接边
- 建立每一个棋盘格的所有合法走棋步骤能够到达的棋盘格关系图
-
采用图搜索算法搜寻一个长度为(行×列-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
-
对每一个可能的分支路径深入到不能再深入为止,而且每个节点只能访问一次。
-
遍历顺序是“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
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)
算法分析
- dfs有两个循环,每次都是|V|次,为O(|V|)
- 由于 dfsvisit只有在顶点是白色时被递归调用,因此循环最多会对图中的每一条边执行一次,也就是O(|E|)。
- DFS 和 BFS 时间复杂度都为O(|V|+|E|)
7.6 拓扑排序
从工作流程图得到工作次序排序的算法称为"拓扑排序"
-
几乎所有问题都可以转换成图问题,让我们来考虑如何制作一批松饼
- 以动作为顶点,以先后次序作为有向边
-
拓扑排序主要处理一个有向无圈图(DAG),输出顶点的线性序列
- 使得两个顶点v,w,如果G中有(v,w)边,在线性序列中v就出现在w之前
-
拓扑排序广泛应用在依赖时间的排期上,还可以用在项目管理、数据库查询优化和矩阵乘法的次序优化上
DFS可以很好的实现拓扑排序
- 将工作流程建立为图,工作项是节点,依赖关系是有向边;
- 工作流程是一个有向无圈图
- 对有向无圈图调用DFS算法,得到每个顶点的“结束时间”
- 按照每个顶点的“结束时间”从大到小排序输出这个次序下的顶点列表
以结束时间进行倒序:
7.7 强连通单元
简化:
图的转置:
强连通分支算法:Kosaraju算法思路
- 首先,对图G调用DFS算法,为每一个顶点计算"结束时间"
- 对图进行转置
- 对转置图调用DFS算法,但在函数中对每个顶点的结束时间采用倒序的顺序来搜索
- 所得到的深度优先森林中的每一棵树就是一个强连接单元
7.8 最短路径问题
7.8.1 Dijkstra算法
- 采用贪心算法的策略,将所有顶点分为已标记点和未标记点两个集合,从起始点开始,不断在未标记点中寻找距离起始点路径最短的顶点,并将其标记,直到所有顶点都被标记为止。
- 顶点访问次序有一个优先队列来控制,队列中作为优先级的是顶点的dist属性
- 起初,只有开始顶点 dist设置为0,其他所有顶点dist设为sys.maxsize(最大整数),全部加入优先队列
- 随着队列中每个最低dist顶点率先出队,并计算它和邻接顶点的权重
- 更新dist优先级后再依次出队
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必然是一棵树。我们称这样的树为生成树,我们称求取该生成树的问题为最小生成树问题。
如下图描述的是一个连通图及其最小生成树的例子:
(在图中,属于最小生成树的边加上了阴影,图中所示的生成树的总权重为37,不过,该最小生成树并不是唯一的,删除边( b , c ) (b,c)(b,c),然后加入边( a , h ) (a,h)(a,h),将形成另一颗权重也是37的最小生成树。 )
7.9.1 Prim算法
Prim算法解决的问题是连通无向有权图中最小生成树问题
算法步骤:
- 随机选择一个节点初始化最小树
- 对所有的连接该树和新节点的边,选择最小权重的边
- 重复上述2,直到包含所有的节点
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)
7.8.2 Kruskal算法
Kruskal算法解决的问题也是连通无向有权图中最小生成树问题 算法步骤:
- 对所有的边根据权重进行从小到大排序,将每个顶点独立视为根节点,产生n个树
- 每次选择最小的边加入到树中,如果新增加的边导致树中有环,则丢弃该条边
- 重复上面2增加边的操作,直到树包含了所有的节点
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[] 即表示待排序对象按第多少维度进行排序 这里对第二维进行排序 即边权重
#将每个顶点视为一棵节点树,可以用字典表示,键表示顶点,键值表示顶点所在树的节点
ori_trees = dict()
for i in vertices:
ori_trees[i] = i
print(ori_trees)
#根据边的两个顶点的根节点是否相同考虑是否合并
#寻找根节点
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
7.8.2 对比
最小生成树问题的两个经典算法:
在Kruskal算法中,集合A是一个森林,其节点就是给定图的节点,每次加入到集合A 中的安全边永远是权重最小的连接两个不同分量的边;
在Prim算法里,集合A则是一棵树,每次加入到A中的安全边永远是连接A和A之外某个节点的边中权重最小的边。