从多叉树到图
多叉树的递归及其遍历
我们看下二叉树和多叉树的结构
#二叉树
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
#多叉树
class Node:
def __init__(self, val: int):
self.val = val
self.children = []
森林
森林就是多个多叉树的集合,在并查集算法中,我们会同时持有多棵多叉树的根节点,那么这些根节点的集合就是一个森林。
DFS
二叉树与多叉树遍历的唯一的区别是,多叉树没有了中序位置,因为可能有多个节点,所谓的中序位置就没意义了。
# 二叉树的遍历框架
def traverse_binary_tree(root):
if root is None:
return
# 前序位置
traverse_binary_tree(root.left)
# 中序位置
traverse_binary_tree(root.right)
# 后序位置
# N 叉树的遍历框架
def traverse_n_ary_tree(root):
if root is None:
return
# 前序位置
for child in root.children:
traverse_n_ary_tree(child)
# 后序位置
BFS
多叉树的层序遍历和 二叉树的层序遍历 一样,都是用队列来实现。也对应三种写法:
写法一
from collections import deque
def level_order_traverse(root):
if root is None:
return
q = deque()
q.append(root)
while q:
cur = q.popleft()
print(cur.val)
for child in cur.childern:
q.append(child)
写法二
加入一个变量记录当前遍历的深度
from collections import deque
def level_order_traverse(root):
if root is None:
return
q = deque()
q.append(root)
depth = 1
while q:
size = len(q)
for i in range(size):
cur = q.popleft()
print(f"depth = {depth}, val = {cur.val}")
for child in cur.childern:
q.append(child)
depth+=1
写法三
在节点中加入state,能够适配不同权重边的写法.depth可根据实际情况换为需要记录的参数。
class State:
def __init__(self, node, depth):
self.node = node
self.depth = depth
def levelOrderTraverse(root):
if root is None:
return
q = deque()
# 记录当前遍历到的层数(根节点视为第 1 层)
q.append(State(root, 1))
while q:
state = q.popleft()
cur = state.node
depth = state.depth
# 访问 cur 节点,同时知道它所在的层数
print(f"depth = {depth}, val = {cur.val}")
for child in cur.children:
q.append(State(child, depth + 1))
图结构
图结构就是 多叉树结构 的延伸。图结构逻辑上由若干节点(Vertex)和边(Edge)构成,我们一般用邻接表、邻接矩阵等方式来存储图。
在树结构中,只允许父节点指向子节点,不存在子节点指向父节点的情况,子节点之间也不会互相连接;而图中没有那么多限制,节点之间可以相互指向,形成复杂的网络结构。
经典的图算法有:
- 二分图算法、
- 拓扑排序、
- 最短路径算法、
- 最小生成树算法 等
图的逻辑结构
这里的图结构是「逻辑上的」,具体实现上,我们很少用这个 Vertex 类,而是用邻接表、邻接矩阵来实现图结构。
# 图节点的逻辑结构
class Vertex:
def __init__(self, id: int):
self.id = id
self.neighbors = []
# 基本的 N 叉树节点
class TreeNode:
def __init__(self, val=0, children=None):
self.val = val
self.children = children if children is not None else []
适用于树的 DFS/BFS 遍历算法,全部适用于图。
度的概念
- 无向图中,度就是每个节点相连的边的条数。
- 有向图中,每个节点的度被细分为入度 (indegree,指向它的边的个数)和出度(outdegree,它指出的边的个数)。
邻接表和邻接矩阵
邻接表很直观,我把每个节点 x 的邻居都存到一个列表里,然后把 x 和这个列表映射起来,这样就可以通过一个节点 x 找到它的所有相邻节点。
邻接矩阵则是一个二维布尔数组,我们权且称为 matrix,如果节点 x 和 y 是相连的,那么就把 matrix[x][y]
设为 true
(上图中的方格1代表 true)。如果想找节点 x 的邻居,去扫一圈 matrix[x][..]
就行。
# 邻接表
# graph[x] 存储 x 的所有邻居节点
graph: List[List[int]] = []
# 邻接矩阵
# matrix[x][y] 记录 x 是否有一条指向 y 的边
matrix: List[List[bool]] = []
节点类型不是 int 怎么办
额外使用一个哈希表,把实际节点和整数 id 映射起来,然后就可以用邻接表和邻接矩阵存储整数 id 了。
邻接表和邻接矩阵的使用场景
注意分析两种存储方式的空间复杂度,对于一幅有 V 个节点,E 条边的图,邻接表的空间复杂度是 O ( V + E ) O(V+E) O(V+E),而邻接矩阵的空间复杂度是 O ( V 2 ) O(V^2) O(V2)。
所以如果一幅图的 E 远小于 V 2 V^2 V2(稀疏图),那么邻接表会比邻接矩阵节省空间,反之,如果 E 接近 V 2 V^2 V2(稠密图),二者就差不多了。在后面的图算法和习题中,大多都是稀疏图,所以你会看到邻接表的使用更多一些。
邻接矩阵的最大优势在于,矩阵是一个强有力的数学工具,图的一些隐晦性质可以借助精妙的矩阵运算展现出来。
不过本文不准备引入数学内容,所以有兴趣的读者可以自行搜索学习。
这也是为什么一定要把图节点类型转换成整数 id 的原因,不然的话你怎么用矩阵运算呢?
有向加权图的实现
# 邻接表
# graph[x] 存储 x 的所有邻居节点以及对应的权重
# 具体实现不一定非得这样,可以参考后面的通用实现
class Edge:
def __init__(self, to: int, weight: int):
self.to = to
self.weight = weight
graph: list[list[Edge]] = []
# 邻接矩阵
# matrix[x][y] 记录 x 指向 y 的边的权重,0 表示不相邻
matrix: list[list[int]] = []
图的实现
图的接口
class Graph(ABC):
@abstractmethod
def addEdge(self, from_: int, to: int, weight: int):
# 添加一条边(带权重)
pass
@abstractmethod
def removeEdge(self, from_: int, to: int):
# 删除一条边
pass
@abstractmethod
def hasEdge(self, from_: int, to: int) -> bool:
# 判断两个节点是否相邻
pass
@abstractmethod
def weight(self, from_: int, to: int) -> int:
# 返回一条边的权重
pass
@abstractmethod
def neighbors(self, v: int) -> List[Tuple[int, int]]:
# 返回某个节点的所有邻居节点和对应权重
pass
@abstractmethod
def size(self) -> int:
# 返回节点总数
pass
有向加权图(邻接表)
# 加权有向图的通用实现(邻接表)
class WeightedDigraph:
# 存储相邻节点及边的权重
class Edge:
def __init__(self, to: int, weight: int):
self.to = to
self.weight = weight
def __init__(self, n: int):
# 我们这里简单起见,建图时要传入节点总数,这其实可以优化
# 比如把 graph 设置为 Map<Integer, List<Edge>>,就可以动态添加新节点了
self.graph = [[] for _ in range(n)]
# 增,添加一条带权重的有向边,复杂度 O(1)
def addEdge(self, from_: int, to: int, weight: int):
self.graph[from_].append(self.Edge(to, weight))
# 删,删除一条有向边,复杂度 O(V)
def removeEdge(self, from_: int, to: int):
self.graph[from_] = [e for e in self.graph[from_] if e.to != to]
# 查,判断两个节点是否相邻,复杂度 O(V)
def hasEdge(self, from_: int, to: int) -> bool:
for e in self.graph[from_]:
if e.to == to:
return True
return False
# 查,返回一条边的权重,复杂度 O(V)
def weight(self, from_: int, to: int) -> int:
for e in self.graph[from_]:
if e.to == to:
return e.weight
raise ValueError("No such edge")
# 上面的 hasEdge、removeEdge、weight 方法遍历 List 的行为是可以优化的
# 比如用 Map<Integer, Map<Integer, Integer>> 存储邻接表
# 这样就可以避免遍历 List,复杂度就能降到 O(1)
# 查,返回某个节点的所有邻居节点,复杂度 O(1)
def neighbors(self, v: int):
return self.graph[v]
if __name__ == "__main__":
graph = WeightedDigraph(3)
graph.addEdge(0, 1, 1)
graph.addEdge(1, 2, 2)
graph.addEdge(2, 0, 3)
graph.addEdge(2, 1, 4)
print(graph.hasEdge(0, 1)) # true
print(graph.hasEdge(1, 0)) # false
for edge in graph.neighbors(2):
print(f"{2} -> {edge.to}, weight: {edge.weight}")
# 2 -> 0, weight: 3
# 2 -> 1, weight: 4
graph.removeEdge(0, 1)
print(graph.hasEdge(0, 1)) # false
有向加权图(邻接矩阵实现)
class WeightedDigraph:
# 存储相邻节点及边的权重
class Edge:
def __init__(self, to, weight):
self.to = to
self.weight = weight
def __init__(self, n):
# 邻接矩阵,matrix[from][to] 存储从节点 from 到节点 to 的边的权重
# 0 表示没有连接
self.matrix = [[0] * n for _ in range(n)]
# 增,添加一条带权重的有向边,复杂度 O(1)
def addEdge(self, from_node, to, weight):
self.matrix[from_node][to] = weight
# 删,删除一条有向边,复杂度 O(1)
def removeEdge(self, from_node, to):
self.matrix[from_node][to] = 0
# 查,判断两个节点是否相邻,复杂度 O(1)
def hasEdge(self, from_node, to):
return self.matrix[from_node][to] != 0
# 查,返回一条边的权重,复杂度 O(1)
def weight(self, from_node, to):
return self.matrix[from_node][to]
# 查,返回某个节点的所有邻居节点,复杂度 O(V)
def neighbors(self, v):
res = []
for i in range(len(self.matrix[v])):
if self.matrix[v][i] > 0:
res.append(self.Edge(i, self.matrix[v][i]))
return res
if __name__ == "__main__":
graph = WeightedDigraph(3)
graph.addEdge(0, 1, 1)
graph.addEdge(1, 2, 2)
graph.addEdge(2, 0, 3)
graph.addEdge(2, 1, 4)
print(graph.hasEdge(0, 1)) # True
print(graph.hasEdge(1, 0)) # False
for edge in graph.neighbors(2):
print(f"{2} -> {edge.to}, weight: {edge.weight}")
# 2 -> 0, weight: 3
# 2 -> 1, weight: 4
graph.removeEdge(0, 1)
print(graph.hasEdge(0, 1)) # False
有向无权图(邻接表/邻接矩阵实现)
直接复用上面的 WeightedDigraph 类就行,把 addEdge 方法的权重参数默认设置为 1 就行
无向加权图(邻接表/邻接矩阵实现)
无向加权图就等同于双向的有向加权图,所以直接复用上面用邻接表/领接矩阵实现的 WeightedDigraph 类就行了,只是在增加/删减边的时候,要同时添加/删除两条边:
# 无向加权图的通用实现
class WeightedUndigraph:
def __init__(self, n):
self.graph = WeightedDigraph(n)
# 增,添加一条带权重的无向边
def addEdge(self, frm, to, weight):
self.graph.addEdge(frm, to, weight)
self.graph.addEdge(to, frm, weight)
# 删,删除一条无向边
def removeEdge(self, frm, to):
self.graph.removeEdge(frm, to)
self.graph.removeEdge(to, frm)
# 查,判断两个节点是否相邻
def hasEdge(self, frm, to):
return self.graph.hasEdge(frm, to)
# 查,返回一条边的权重
def weight(self, frm, to):
return self.graph.weight(frm, to)
# 查,返回某个节点的所有邻居节点
def neighbors(self, v):
return self.graph.neighbors(v)
if __name__ == "__main__":
graph = WeightedUndigraph(3)
graph.addEdge(0, 1, 1)
graph.addEdge(1, 2, 2)
graph.addEdge(2, 0, 3)
graph.addEdge(2, 1, 4)
print(graph.hasEdge(0, 1)) # true
print(graph.hasEdge(1, 0)) # true
for edge in graph.neighbors(2):
print(f"{2} <-> {edge.to}, weight: {edge.weight}")
# 2 <-> 0, weight: 3
# 2 <-> 1, weight: 4
graph.removeEdge(0, 1)
print(graph.hasEdge(0, 1)) # false
print(graph.hasEdge(1, 0)) # false
无向无权图(邻接表/邻接矩阵实现)
直接复用上面的 WeightedUndigraph 类就行,把 addEdge 方法的权重参数默认设置为 1 就行
图结构的遍历 DFS/BFS
图的遍历就是 多叉树遍历 的延伸,主要遍历方式还是深度优先搜索(DFS)和广度优先搜索(BFS)。
唯一的区别是,树结构中不存在环,而图结构中可能存在环,所以我们需要标记遍历过的节点,避免遍历函数在环中死循环。
遍历图的「节点」和「路径」略有不同,遍历「节点」时,需要 visited 数组在前序位置标记节点;遍历图的所有「路径」时,需要 onPath 数组在前序位置标记节点,在后序位置撤销标记。
DFS
遍历所有节点
# 多叉树节点
class Node:
def __init__(self, val=0, children=None):
self.val = val
self.children = children if children is not None else []
# 多叉树的遍历框架
def traverse(root):
# base case
if root is None:
return
# 前序位置
print(f"visit {root.val}")
for child in root.children:
traverse(child)
# 后序位置
# 图节点
class Vertex:
def __init__(self, id=0, neighbors=None):
self.id = id
self.neighbors = neighbors if neighbors is not None else []
# 图的遍历框架
# 需要一个 visited 数组记录被遍历过的节点
# 避免走回头路陷入死循环
def traverse_graph(s, visited):
# base case
if s is None:
return
if visited.get(s.id, False):
# 防止死循环
return
# 前序位置
visited[s.id] = True
print(f"visit {s.id}")
for neighbor in s.neighbors:
traverse_graph(neighbor)
# 后序位置
为什么成环会导致死循环
举个最简单的成环场景,有一条 1 -> 2 的边,同时有一条 2 -> 1 的边,节点 1, 2 就形成了一个环:>1 <=> 2
如果我们不标记遍历过的节点,那么从 1 开始遍历,会走到 2,再走到 1,再走到 2,再走到 1,如此 1->2->1->2->… 无限递归循环下去。如果有了 visited 数组,第一次遍历到 1 时,会标记 1 为已访问,出现 1->2->1 这种情况时,发现 1 已经被访问过,就会直接返回,从而终止递归,避免了死循环。
# 遍历图的所有节点
def traverse(graph, s, visited):
# base case
if s < 0 or s >= len(graph):
return
if visited[s]:
# 防止死循环
return
# 前序位置
visited[s] = True
print("visit", s)
for e in graph.neighbors(s):
traverse(graph, e.to, visited)
# 后序位置
由于 visited 数组的剪枝作用,这个遍历函数会遍历一次图中的所有节点,并尝试遍历一次所有边,所以算法的时间复杂度是 O(E+V),其中 E 是边的总数,V 是节点的总数。
其实二叉树/多叉树的遍历函数,也要算上边的数量,只不过对于树结构来说,边的数量和节点的数量是近似相等的,所以时间复杂度还是 O(N+N)=O(N)。
树结构中的边只能由父节点指向子节点,所以除了根节点,你可以把每个节点和它上面那条来自父节点的边配成一对儿,这样就可以比较直观地看出边的数量和节点的数量是近似相等的。
而对于图结构来说,任意两个节点之间都可以连接一条边,边的数量和节点的数量不再有特定的关系,所以我们要说图的遍历函数时间复杂度是 O(E+V)。
遍历所有路径
对于树结构来说,只能由父节点指向子节点,所以从根节点 root 出发,到任意一个节点 targetNode 的路径都是唯一的。换句话说,我遍历一遍树结构的所有节点之后,必然可以找到 root 到 targetNode 的唯一路径:
# 多叉树的遍历框架,寻找从根节点到目标节点的路径
path = []
def traverse(root, targetNode):
# base case
if root is None:
return
# 前序位置
path.append(root)
if root.val == targetNode.val:
print("find path:", path)
for child in root.children:
traverse(child, targetNode)
# 后序位置
path.pop()
对于图结构来说,由起点 src 到目标节点 dest 的路径可能不止一条。我们需要一个 onPath 数组,在进入节点时(前序位置)标记为正在访问,退出节点时(后序位置)撤销标记,这样才能遍历图中的所有路径,从而找到 src 到 dest 的所有路径:
# 下面的算法代码可以遍历图的所有路径,寻找从 src 到 dest 的所有路径
# onPath 和 path 记录当前递归路径上的节点
on_path = [False] * len(graph)
path = []
def traverse(graph, src, dest):
# base case
if src < 0 or src >= len(graph):
return
if on_path[src]:
# 防止死循环(成环)
return
# 前序位置
on_path[src] = True
path.append(src)
if src == dest:
print(f"find path: {path}")
for e in graph.neighbors(src):
traverse(graph, e.to, dest)
# 后序位置
path.pop()
on_path[src] = False
同时使用 visited 和 onPath 数组
按照上面的分析,visited 数组和 onPath 分别用于遍历所有节点和遍历所有路径。那么它们两个是否可能会同时出现呢?答案是可能的。
遍历所有路径的算法复杂度较高,大部分情况下我们可能并不需要穷举完所有路径,而是仅需要找到某一条符合条件的路径。这种场景下,我们可能会借助 visited 数组进行剪枝,提前排除一些不符合条件的路径,从而降低复杂度。
比如拓扑排序 中会讲到如何判定图是否成环,就会同时利用 visited 和 onPath 数组来进行剪枝。
比方说判定成环的场景,在遍历所有路径的过程中,如果发现一个节点 s 被标记为 visited,那么说明从 s 这个起点出发的所有路径在之前都已经遍历过了。如果之前遍历的时候都没有找到环,我现在再去遍历一次,肯定也不会找到环,所以这里可以直接剪枝,不再继续遍历节点 s。
完全不用 visited 和 onPath 数组
visited 和 onPath 主要的作用就是处理成环的情况,避免死循环。那如果题目告诉你输入的图结构不包含环,那么你就不需要考虑成环的情况了
BFS
写法一
# 多叉树的层序遍历
def levelOrderTraverse(root):
if root is None:
return
from collections import deque
q = deque([root])
while q:
cur = q.popleft()
# 访问 cur 节点
print(cur.val)
# 把 cur 的所有子节点加入队列
for child in cur.children:
q.append(child)
# 图结构的 BFS 遍历,从节点 s 开始进行 BFS
def bfs(graph, s):
visited = [False] * len(graph)
from collections import deque
q = deque([s])
visited[s] = True
while q:
cur = q.popleft()
print(f"visit {cur}")
for e in graph.neighbors(cur):
if not visited[e.to]:
q.append(e.to)
visited[e.to] = True
写法二
from collections import deque
# 多叉树的层序遍历
def levelOrderTraverse(root):
if root is None:
return
q = deque([root])
# 记录当前遍历到的层数(根节点视为第 1 层)
depth = 1
while q:
sz = len(q)
for _ in range(sz):
cur = q.popleft()
# 访问 cur 节点,同时知道它所在的层数
print(f"depth = {depth}, val = {cur.val}")
for child in cur.children:
q.append(child)
depth += 1
# 从 s 开始 BFS 遍历图的所有节点,且记录遍历的步数
def bfs(graph, s):
visited = [False] * len(graph)
q = deque([s])
visited[s] = True
# 记录从 s 开始走到当前节点的步数
step = 0
while q:
sz = len(q)
for i in range(sz):
cur = q.popleft()
print(f"visit {cur} at step {step}")
for e in graph.neighbors(cur):
if not visited[e.to]:
q.append(e.to)
visited[e.to] = True
step += 1
写法三
# 多叉树的层序遍历
# 每个节点自行维护 State 类,记录深度等信息
class State:
def __init__(self, node, depth):
self.node = node
self.depth = depth
def levelOrderTraverse(root):
if root is None:
return
from collections import deque
q = deque([State(root, 1)])
while q:
state = q.popleft()
cur = state.node
depth = state.depth
# 访问 cur 节点,同时知道它所在的层数
print(f"depth = {depth}, val = {cur.val}")
for child in cur.children:
q.append(State(child, depth + 1))
# 图结构的 BFS 遍历,从节点 s 开始进行 BFS,且记录路径的权重和
# 每个节点自行维护 State 类,记录从 s 走来的权重和
class State:
def __init__(self, node, weight):
# 当前节点 ID
self.node = node
# 从起点 s 到当前节点的权重和
self.weight = weight
def bfs(graph, s):
visited = [False] * len(graph)
from collections import deque
q = deque([State(s, 0)])
visited[s] = True
while q:
state = q.popleft()
cur = state.node
weight = state.weight
print(f"visit {cur} with path weight {weight}")
for e in graph.neighbors(cur):
if not visited[e.to]:
q.append(State(e.to, weight + e.weight))
visited[e.to] = True