[Leetcode]数据结构之图——python版本

本文深入探讨了图论的基础概念,包括邻接表、图的遍历框架、所有可能路径、拓扑排序以及有向图环的判断。此外,还详细介绍了如何判断一个图是否为二分图,使用DFS和BFS两种方法。同时,文章涵盖了并查集算法及其在动态连通性问题中的应用,如最小生成树的Kruskal算法。最后,讨论了Dijkstra最短路径算法及其在解决网络延迟时间和最小体力消耗路径问题中的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本篇文章根据labuladong的算法小抄汇总图的常见算法,采用python3实现

图论基础

img image-20211229162759020

邻接表的特点:占用空间少,但无法快速判断两个节点是否相邻。

图的遍历框架:

def traverse(graph,s):
    if visited[s]:
        return
    visited[s] = True
    onPath[s] = True
    for neighbor in graph.neighbor(s):
        traverse(graph,neighbor)
    onPath[s] = False

所有可能路径,T797

def allPathsSourceTarget(graph):
    def traverse(graph,s,path):
        path.append(s)
        n = len(graph)
        if s == n-1:
            res.append(path[:])
        for v in graph[s]:
            traverse(graph,v,path)
        path.pop()
            
    path = [] #递归过程中经过的路径
    res = [] #记录目标路径
    traverse(graph,0,path)
    return res

(课程表)判断有向图是否存在环,T207

image-20211229172741275

依赖问题一般转化为有向图,存在环就说明存在循环依赖

def canFinish(numCourses,prerequisites):
    #构图
    def buildGraph(numCourses,prerequisites):
        graph = [[] for i in range(numCourses)]
        for edge in prerequisites:
            from_ = edge[1]
            to = edge[0]
            graph[from_].append(to)
        return graph
    def traverse(graph,s):
        if onPath[s]:
            self.hasCycle = True
        if visited[s] or hasCycle:
            return
        visited[s] = True
        onPath[s] = True
        for t in graph[s]:
            traverse(graph,t)
        onPath[s] = False
    graph = buildGraph(numCourses,prerequisites)
    visited = [False for i in range(numCourses)]
    onPath = [False for i in range(numCourses)]
    self.hasCycle = False
    for i in range(numCourses):
        traverse(graph,i)
    return not self.hasCycle

(课程表)拓扑排序,T210

拓扑排序:img

实际上就是把一幅图拉平,使所有箭头方向一致。显然,如果有向图中有环,则无法进行拓扑排序。拓扑排序只针对有向无环图。

如果把课程抽象成结点,依赖关系抽象成有向边,则图的拓扑排序结果就是上课顺序。

而将后序遍历的结果反转,就是拓扑排序的结果。

def findOrder(numCourses,prerequisites):
    def buildGraph(numCourses,prerequisites):
        graph = [[] for i in range(numCourses)]
        for x in prerequisites:
            from_ = x[1]
            to = x[0]
            graph[from_].append(to)
        return graph
    def traverse(graph,s):
        if onPath[s]:
            self.hasCycle = True
            return
        if visited[s]:
            return
        onPath[s] = True
        visited[s] = True
        for v in graph[s]:
            traverse(graph,v)
        postorder.append(s)
        onPath[s] = False
    graph = buildGraph(numCourses,prerequisites)
    self.hasCycle = False
    visited = [False for i in range(numCourses)]
    onPath = [False for i in range(numCourses)]
    postorder = []
    for i in range(numCourses):
        traverse(graph,i)
    if self.hasCycle:
        return []
    postOrder.reverse()
    return postOrder

二分图

二分图的顶点集可分割为两个互不相交的子集,图中每条边依附的两个顶点都分属于这两个子集,且两个子集内的顶点不相邻。

从简单实用的角度看,二分图结构在某些场景可以更高效地存储数据。

判断二分图

主要思想:遍历一遍图,一边遍历一边染色,看能不能用两种颜色给所有节点染色,且相邻节点的颜色都不相同。

这里用DFS算法解决。

#二叉树遍历框架
def traverse(root):
    if not root:
        return
    traverse(root.left)
    traverse(root.right)
    
#多叉树遍历框架
def traverse(root):
    if not root:
        return
    for child in root.children:
        traverse(child)
    
#图遍历框架
visited = [False for i in range(len(graph))]
def traverse(graph,v):
    if visited(v):
        return
    #前序遍历位置
    visited = True
    for neighbor in graph.neighbors(v):
        traverse(graph,neighbor)

判定二分图的代码逻辑:

def traverse(graoh,visited,v):
    visited[v] = True
    #遍历v的所有相邻节点
    for neighbor in graph.neighbors(v):
        if not visited[neighbor]:
            #如果没有访问过,就涂上和v不同的颜色
            traverse(graph,visited,neighbor)
        else:
            #如果访问过,就比较neighbor和v的颜色,若相同则不是二分图

DFS判断二分图,T785

def isBipartite(graph):
    def traverse(graph,v):
        if not ok:
            return
        visited[v] = True
        for w in graph[v]:
            if not visited[w]:
                color[w] = not color[v]
                traverse(graph,w)
            else:
                if color[w] == color[v]:
                    ok = False
                    return
    ok = True #是否为二分图
    n = len(graph)
    color = [False for i in range(n)] #记录节点颜色,True和False代表两种不同颜色
    visited = [False for i in range(n)] #记录节点是否被访问过
    for v in range(n):
        if not visited[v]:
            traverse(graph,v)
    return ok    

BFS判断二分图,T785

def isBipartite(graph):
    
    def bfs(graph,start):
        q = []
        visited[start] = True
        q.append(start)
        while (len(q) != 0) and ok:
            v = q.pop(0)
            for w in graph[v]:
                if not visited[w]:
                    color[w] = not color[v]
                visited[w] = True
                q.append(w)
                else:
                    if color[w] == color[v]:
                        ok = False
    n = len(graph)
    ok = True
    color = [False for i in range(n)]
    visited = [False for i in range(n)]
    for i in range(n):
        if not visited[i]:
            bfs(graph,i)
    return ok

可能的二分法,T886

把N个人(1,…,N)分进两组,每个人都可能不喜欢其他人,如果这样,那他们不属于同一组,问是否可以实现

def possibleBipartition(n,dislikes):
    
    def buildGraph(n,dislikes):
        graph = [[] for i in range(n+1)]
        for x in dislikes:
            a = x[0]
            b = x[1]
            graph[a].append(b)
            graph[b].append(a)
        return graph
        
    def traverse(graph,s):
        if not self.ok:
            return
        visited[s] = True
        for w in graph[s]:
            if not visited[w]:
                color[w] = not color[s]
                traverse(graph,w)
            elif color[w] == color[s]:
                self.ok = False
                return
        
    color = [False for i in range(n+1)]
    visited = [False for i in range(n+1)]
    graph = buildGraph(n,dislikes)
    self.ok = True
    for v in range(1,n+1):
        if not visited[v]:
            traverse(graph,v)
    return self.ok

Union-Find并查集算法

动态连通性

并查集算法主要是解决图论中的动态连通性问题。

动态连通性可以看作给一幅图连线。

img

并查集算法主要实现以下API:

class UF:
    #连接p和q
    def union(p,q)
    #判断p和q是否连通
    def connected(p,q)
    #返回途中有多少连通分量
    def count()

其中连通是指一种等价关系,具有自反性(p和p连通),对称性(p和q连通则q和p连通),传递性(如果p和q连通,q和r连通,那么p和r也连通)。

我们使用森林(若干树)来表示图的动态连通性,用数组来实现这个森林。

对于森林,设定树的每个节点有个指针指向其父节点,如果是根节点,则指针指向自己。

如果两个节点被连通,则让其中一个节点的根节点接到另一个节点的根节点上。

class UF:
    def __init__(self,n):
        self.count = n
        self.parent = [i for i in range(n)]
    #返回节点的根节点
    def find(x):
        while self.parent[x] != x:
            x = self.parent[x]
        return x
    def union(p,q):
        rootP = find(p)
        rootQ = find(q)
        if rootP == rootQ:
            return
        parent[rootP] = rootQ
        self.count -= 1
    def connected(p,q):
        rootP = find(p)
        rootQ = find(q)
        return rootP == rootQ
    def count():
        return self.count

find,connected,union的复杂度都是O(N),而变为平衡的二叉树就是O(logN)。

我们希望将小一些的树接到大一些的树下面,可以避免头重脚轻,要平衡一些。解决方法是额外使用一个size数组,记录每棵树包含的节点数,称之为重量。

class UF:
    def __init__(self,n):
        self.count = n #记录连通分量个数
        self.parent = [i for i in range(n)] #记录若干棵树
        self.size = [1 for i in range(n)] #记录树的重量
    #返回节点的根节点
    def find(self,x):
        while self.parent[x] != x:
            x = self.parent[x]
        return x
    def union(self,p,q):
        rootP = self.find(p)
        rootQ = self.find(q)
        if rootP == rootQ:
            return
        if self.size[rootP] > self.size[rootQ]:
            self.parent[rootQ] = rootP
            self.size[rootP] += self.size[rootQ]
        else:
        	self.parent[rootP] = rootQ
            self.size[rootQ] += self.size[rootP]
        self.count -= 1
    def connected(p,q):
        rootP = self.find(p)
        rootQ = self.find(q)
        return rootP == rootQ
    def count():
        return self.count

这样使得树的生长相对平衡,树的高度大致在logN左右。

路径压缩,进一步压缩每棵树的高度,使树高始终保持为常数。这样find,connected和union复杂度都降为O(1)。

只需将find改为:

def find(x):
    while parent[x] != x:
        parent[x] = parent[parent[x]]
        x = parent[x]
    return x

可以在调用find时,顺手将树高缩短,最终所有树高都不会超过3。

class UF:
    def __init__(self,n):
        self.count = n #记录连通分量个数
        self.parent = [i for i in range(n)] #记录若干棵树
        self.size = [1 for i in range(n)] #记录树的重量
    #返回节点的根节点
    def find(self,x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]
            x = self.parent[x]
        return x
    def union(self,p,q):
        rootP = self.find(p)
        rootQ = self.find(q)
        if rootP == rootQ:
            return
        if self.size[rootP] > self.size[rootQ]:
            self.parent[rootQ] = rootP
            self.size[rootP] += self.size[rootQ]
        else:
        	self.parent[rootP] = rootQ
            self.size[rootQ] += self.size[rootP]
        self.count -= 1
    def connected(p,q):
        rootP = self.find(p)
        rootQ = self.find(q)
        return rootP == rootQ
    def count():
        return self.count

DFS的替代方案

被围绕的区域,T130

给定一个M*N的二维矩阵,包含X和O,让你找到矩阵中四面被X围住的O,并且把他们替换成X。

可以发现,边角上的O一定不会被围,与边角上的O相连的O也不会被围。

可以把不需要被替换的O看作一个门派,有个共同的祖师爷dummy,这些O和dummy互相连通,而需要被替换的O与dummy不连通。

二维坐标(x,y)可以映射到一维,转换成x*n+y(m为棋盘行数,n为棋盘列数)

[ 0 , . . . , m ∗ n − 1 ] [0,...,m*n-1] [0,...,mn1]是棋盘内坐标,让dummy占据 m ∗ n m*n mn

def solve(board):
    if len(board) == 0:
        return
    m = len(board)
    n = len(board[0])
    uf = UF(m * n + 1)
    dummy = m * n
    #将首列和末列的O与dummy连通
    for i in range(m):
        if board[i][0] == 'O':
            uf.union(i * n, dummy)
        if board[i][n-1] == 'O':
            uf.union(i * n + n - 1,dummy)
    #将首列和末列的O与dummy连通
    for j in range(n):
        if board[0][j] == 'O':
            uf.union(j,dummy)
        if board[m-1][j] == 'O':
            uf.union(n * (m - 1) + j,dummy)
    #方向数组d是上下左右搜索的常用手法
    d = [[1,0],[0,1],[0,-1],[-1,0]]
    for i in range(1,m-1):
        for j in range(1,n-1):
            if board[i][j] == 'O':
                for k in range(4):
                    #将此O与上下左右的O连通
                    x = i + d[k][0]
                    y = j + d[k][1]
                    if board[x][y] == 'O':
                        uf.union(x * n + y, i * n + j)
    #所有不和dummy连通的O都替换
    for i in range(1,m-1):
        for j in range(1,n-1):
            if not uf.connected(dummy,i * n + j):
                board[i][j] = 'X'

等式方程的可满足性,T990

将equations中的算式根据和!=分为两部分,先处理,使他们连通;再处理!=,检查是否破坏相等关系的连通性。

class Solution:
    class UnionFind:
        def __init__(self):
            self.parent = list(range(26))
        def find(self,x):
            while x!= self.parent[x]:
                self.parent[x] = self.parent[self.parent[x]]
                x = self.parent[x]
            return x
        def union(self,x,y):
            self.parent[self.find(x)] = self.find(y)
        
	def equationsPossible(equations):
        uf = Solution.UnionFind()
        #建立连通
        for eq in quations:
            if eq[1] == '=':
                x = ord(eq[0]) - ord("a")
                y = ord(eq[3]) - ord("a")
                uf.union(x,y)
        for eq in equations:
            if eq[1] == '!':
                x = ord(eq[0]) - ord("a")
                y = ord(eq[3]) - ord("a")
                if uf.find(x) == uf.find(y):
                    return False
        return True

Kruskal最小生成树算法

最小生成树主要有Prim算法和Kruskal算法。

树和图的区别:树不包含环,图可以包含环

图的生成树:在图中找一棵包含图中所有结点的树。生成树是含有图中所有顶点的无环连通子图。

最小生成树:所有可能的生成树中,权重和最小的那棵生成树。

可以用Union-Find算法保证最小生成树的合法性。

以图判树,T261

问题给你编号从0到n-1的n个结点,和一个无向边列表edges,请你判断输入的这些边组成的结构是否是一棵树。

那么什么情况下加入一条边会使得树变成图呢?对于添加的这条边,如果该边的两个节点本来就在同一连通分量里,那么添加这条边就会产生环;否则不会。

def validTree(n,edges):
    uf = UF(n)
    for edge in edges:
        u = edge[0]
        v = edge[1]
        if uf.connected(u,v):
            return False
        uf.union(u,v)
    return uf.count() == 1

最低成本联通所有城市,T1135

所谓最小生成树,就是图中若干边的集合(mst),满足:

  1. 包含图中所有结点
  2. 形成的结构是树结构(不存在环)
  3. 权重和最小 → 贪心思路

贪心思路:将所有边按权重从小到大排序,从权中最小的边开始遍历,如果这条边和mst中其它边不会形成环,则这条边是最小生成树的一部分,将它加入mst集合;否则,这条边不是最小生成树的一部分,不要把它加入mst集合。

def minimumCost(n,connections):
    #城市编号为1,,,n
    uf = UF(n+1)
    connections.sort(key = lambda a: a[2])
    mst = 0
    for edge in connections:
        u = edge[0]
        v = edge[1]
        weight = edge[2]
        if uf.connected(u,v):
            continue
        mst += weight
        uf.union(u,v)
    #0没有被连通
    return mst if uf.count() == 2 else -1

连接所有点的最小费用,T1584

给定points数组,表示平面上的点,连接两点的费用为他们的曼哈顿距离,返回连接所有点的最小费用

def minCostConnectPoints(points):
    
    #首先要生成边和权重
    n = len(points)
    edges = []
    for i in range(n):
        xi = points[i][0]
        yi = points[i][1]
        for j in range(i+1,n):
            xj = points[j][0]
            yj = points[j][1]
            edges.append([i,j,abs(xi-xj)+abs(yi-yj)])
    
    #排序
    edges.sort(key = lambda a : a[2])
    
    #Kruskal算法
    mst = 0
    uf = UF(n)
    for edge in edges:
        u = edge[0]
        v = edge[1]
        weight = edge[2]
        if uf.connected(u,v):
            continue
        mst += weight
        uf.union(u,v)
    return mst

Dijkstra最短路径算法

Dijkstra条件:加权有向图;没有负权重边

Dijkstra算法框架

Dijkstra可以理解成一个带dp数组或备忘录的BFS算法,伪码如下:

from queue import PriorityQueue
class State:
    def __init__(self,distFromStart,id):
        self.id = id
        self.distFromStart = distFromStart
#返回from_到to的边的权重
def weight(from_,to)

#s的相邻节点
def adj(s)

#计算start到其它结点的最短距离
def dijkstra(start,graph):
    #结点个数
    V = len(graph)
    #distTo[i]记录start到达i的最短路径权重
    distTo = [float("inf") for i in range(V)]
    distTo[start] = 0
    #建立一个优先级队列,distFromStart小的排前面
    pq = queue.PriorityQueue()
    #从起点开始BFS
    pq.put([0,start])
    while not pq.empty():
        curState = pq.pop()
        curNodeID = curState[1]
        curDistFromStart = curState[0]
        if curDistFromStart > distTo[curNodeID]:
            continue
        for nextNodeID in adj(curNodeID):
            distToNextNode = distTo[curNodeID] + weight(curNodeID,nextNodeID)
            if distTo[nextNodeID] > distToNextNode:
                distTo[nextNodeID] = distToNextNode
                pq.put(distToNextNode,nextNodeID)

网络延迟时间,T743

#从k出发,传遍n个结点(从1开始),至少需要多久
def networkDelayTime(times,n,k):
    #建图
    graph = [[] for i in range(n+1)]
    for from_,to,weight in times:
        graph[from_].append([to,weight])
    #k到其他节点的最短路径
    distTo = dijkstra(k,graph)
    #找到最长的最短路径
    res = 0
    for x in distTo:
        if x == float("inf"):
            return -1
        res = max(res,x)
    return res

class State:
    def __init__(self,id,distFromStart):
        self.id = id
        self.distFromStart = distFromStart
        
def dijstra(start,graph):
    distTo = [float("inf") for i in range(len(graph))]
    distTo[start] = 0
    pq = queue.PriorityQueue()
    pq.put(State(start,0))
    while not pq.empty():
        curState = pq.pop()
        curNodeID = curState.id
        curDistFromStart = curState.distFromStart
        if curDistFromStart > distTo[curNodeID]:
            continue
        for neighbor in graph[curNodeID]:
            nextNodeID = neighbor[0]
            distToNextNode = distTo[curNodeID] + neighbor[1]
            if distToNextNode < distTo[nextNodeID]:
                distTo[nextNodeID] = distTonextNode
                pq.put(State(nextNodeID,distToNextNode))
    return distTo

官方解法:

import heapq
def networkDelayTime(times,n,k):
    #建图
    graph = [[] for i in range(n)]
    for from_,to,weight in times:
        graph[from_-1].append([to-1,weight])
    #dijkstra
    dist = [float("inf")] * n
    dist[k-1] = 0
    #起点(权重,编号)
    q = [(0,k-1)]
    while q:
        weight,id = heapq.heappop(q)
        if dist[id] < weight:
            continue
        for y,time in graph[id]:
            d = dist[id] + time
            if d < dist[y]:
                dist[y] = d
                heapq.heappush(q,(d,y))
    ans = max(dist)
    return ans if ans < float('inf') else -1

最小体力消耗路径,T1631

从矩阵左上角(0,0)到右下角(rows-1,columns-1),每次可以上下左右动,找到耗费体力最小的路径。

体力耗费是路径上相邻各自之间高度差绝对值的最大值决定的。

class Solution:
    def minimumEffortPath(self, heights: List[List[int]]) -> int:
        m = len(heights)
        n = len(heights[0])
        dist = [0] + [float('inf')] * (m * n - 1)
        q = [(0,0,0)]
        seen = set()
        while q:
            weight,x,y = heapq.heappop(q)
            if x * n + y in seen:
                continue
            if weight > dist[x * n + y]:
                continue
            if (x,y) == (m - 1,n - 1):
                break
            seen.add(x*n+y)
            for nx,ny in [(x+1,y),(x-1,y),(x,y+1),(x,y-1)]:
                if (0 <= nx < m) and (0 <= ny < n) and (max(weight,abs(heights[x][y] - heights[nx][ny])) <= dist[nx * n + ny]):
                    dist[nx * n + ny] = max(weight,abs(heights[x][y] - heights[nx][ny]))
                    heapq.heappush(q,(dist[nx*n+ny],nx,ny))
        return dist[m * n - 1]

概率最大的路径,T1514

class Solution:
    def maxProbability(self, n: int, edges: List[List[int]], succProb: List[float], start: int, end: int) -> float:
        graph = collections.defaultdict(list)
        for i, (x, y) in enumerate(edges):
            graph[x].append((succProb[i], y))
            graph[y].append((succProb[i], x))
        
        que = [(-1.0, start)]
        prob = [0.0] * n
        prob[start] = 1.0

        while que:
            pr, node = heapq.heappop(que)
            pr = -pr
            if pr < prob[node]:
                continue
            for prNext, nodeNext in graph[node]:
                if prob[nodeNext] < prob[node] * prNext:
                    prob[nodeNext] = prob[node] * prNext
                    heapq.heappush(que, (-prob[nodeNext], nodeNext))
        
        return prob[end]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值