从多叉树到图

多叉树的递归及其遍历

我们看下二叉树和多叉树的结构

#二叉树
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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值