【算法與數據結構】之圖論

【總結】

圖的表示方法:
鄰接矩陣:適合表示稠密圖
鄰接表:適合表示稀疏圖

最短路徑問題:

  • 無向圖——廣度優先遍歷即可實現,
  • 有向圖
    (1)無負權邊時:Dijkstra算法
    (2)有負權邊但無負權環時:Bellman-ford算法,Floyed算法
    (3)無環圖:拓撲排序

最長路徑問題:Bell-Ford算法

 

一. 圖的遍歷算法

1. 深度優先遍歷

本博客均以下圖爲例,給定無向圖,和其鄰接表adj = {0:[1,2,5,6],1:[0],2:[0],3:[4,5],4:[3,5,6],5:[0,3,4],6:[0,4]},據此寫出遍歷算法。

圖的深度優先遍歷複雜度:
稀疏圖(鄰接表):O(V+E)
稠密圖(鄰接矩陣):O(V^2)

深度優先遍歷類似與樹的前序遍歷,是遞歸的,也可以使用非遞歸(利用棧)來實現。下面給出遞歸版本和非遞歸版本的深度優先遍歷算法(參考
深度優先遍歷結果:[0, 1, 2, 5, 3, 4, 6]

#### ----------圖的深度優先遍歷(遞歸版)--------------
def dfs_recursively(adj, start, visited = None):
    if visited is None:
        visited = [start]
    elif start not in visited:
        visited.append(start)

    for i in adj[start]:
        if i not in visited:
            visited.append(i)
            dfs_recursively(adj, i, visited)
    return visited


#### -----------圖的深度優先遍歷(迭代版)-------------
def dfs_iteratively(adj, start):
    stack = []
    visited = [start]
    stack.append([start, 0]) # 把start節點的第一個鄰接節點push進去

    while stack:
        v, next_child_idx = stack[-1] # 獲取當前節點及其鄰接節點的序號
        if v not in adj or next_child_idx >= len(adj[v]):
            stack.pop()
            continue
        next_child = adj[v][next_child_idx] # 獲取鄰接節點
        stack[-1][1] += 1
        if next_child in visited: # 如果鄰接節點已經遍歷過了,直接continue
            continue

        visited.append(next_child) # 將該鄰接節點加入visited中
        stack.append([next_child, 0]) # 將該鄰接節點加入棧中,下次循環遍歷該節點的第一個鄰接節點
    return visited
    
adj = {0:[1,2,5,6],1:[0],2:[0],3:[4,5],4:[3,5,6],5:[0,3,4],6:[0,4]} # adj就是鄰接表的意思
print(dfs_recursively(adj, 0))
print(dfs_iteratively(adj, 0))

 

2. 廣度優先遍歷

使用BFS(廣度優先遍歷)解決最短路徑問題僅適用於無權圖,對於有權圖的最短路徑問題,需要使用專門的算法,比如迪杰特斯拉算法。

類似與樹的層序遍歷,先遍歷到的節點距離起始點的距離更近,可以利用隊列來實現。
廣度優先遍歷最有用的性質是可以遍歷一次就生成中心結點到所遍歷結點的最短路徑,這一點在求無權圖的最短路徑時非常有用。
圖的廣度優先遍歷結果:[0, 1, 2, 5, 6, 3, 4]

#### -------------圖的廣度優先遍歷-------------
def bfs(adj, start):
    visit = [start]
    queue = [start]
    while queue:
        cur_node = queue.pop(0)
        for next_node in adj[cur_node]:
            if next_node not in visit: # 如果當前節點的鄰接節點沒訪問過
                visit.append(next_node)
                queue.append(next_node)
    return visit

 

二、尋路算法

使用深度優先遍歷,完成三個問題,1.兩個點之間有沒有路徑,2. 路徑具體是什麼,3. 展示路徑。

#### ----------圖的深度優先遍歷(遞歸版)--------------
class FindPath:
    def __init__(self, adj):
        self.graph, self.V = self.initGraph_adj(adj) # 獲取圖和圖的所有頂點
        self.visited = [False] * len(self.graph)
        self.parent = [-1] * len(self.graph) # 這個節點的父節點(這個節點是由誰遍歷得到的)


    def initGraph(self, adj): # 初始化成稠密圖
        graph = [[0] * len(adj) for _ in range(len(adj))]
        V = set()
        for nodes, child_nodes in adj.items():
            V.add(nodes)
            for child_node in child_nodes:
                graph[nodes][child_node] = 1
        return graph, V


    def initGraph_adj(self, adj): #初始化成稀疏圖
        graph = adj
        V = set(graph.keys())
        return graph, V


    # 圖的深度優先遍歷(遞歸版),dfs後,visited數組全部爲1,返回值res爲深度優先遍歷序列
    def dfs_recursively(self, start, res = None):
        self.visited[start] = True
        if not res:
            res = []
        res.append(start)
        for node in self.graph[start]:
            if not self.visited[node]: # 如果start節點的鄰接節點沒有訪問到,則dfs
                self.parent[node] = start # 維護parent數組,如果node沒有被訪問過,則它的parent就是start
                self.dfs_recursively(node, res)
        return res


    # 判斷節點start到w之間有沒有路徑
    def hasPath(self, start, w):
        assert w < len(self.V)
        self.dfs_recursively(start)
        return self.visited[w] # 如果visited爲True,說明dfs從start訪問到了w,說明兩個節點之間有路


    # 輸出由start到w的路徑(使用parent數組一步步倒推回去)
    def Path(self, start, w):
        assert w <= len(self.V)
        path = []
        while w != -1:
            path.append(w) # path中存的是路徑的倒序,因此使用reversed函數倒序,或者使用stack。
            w = self.parent[w]
        return list(reversed(path))



adj = {0:[1,2,5,6],1:[0],2:[0],3:[4,5],4:[3,5,6],5:[0,3,4],6:[0,4]} # adj就是鄰接表的意思
S = FindPath(adj)
print(S.V)
res = S.hasPath(0, 6)
print(S.visited)
print(S.parent)
print(S.Path(0,6))

三、最小生成樹

最小生成樹問題:使用n-1條邊,將圖的所有n個節點連接起來,並且使得這n-1條邊的總和最小。最經典的是Prim和Kruskal算法。
針對帶權無向圖,針對連通圖。

切分定理

1. Prim算法(藉助最小索引堆)

不斷尋找橫切邊中最短的那條邊,並將邊的節點加入進來。

2. Kruskal算法(藉助並查集)

先對所有邊排序(複雜度O(ElogE),E爲邊數),按順序遍歷,只要加入最短的邊後不構成環,則這條邊就是最小生成樹中的邊。
如何判斷加入了一條邊之後,是否能夠構成環?——並查集:

  • 在將一條邊加入最小生成樹時,需要對這條邊的兩個節點進行一次union操作,使這兩個節點的根變成相同的,
  • 在判斷加入了一條邊後能夠構成環,isConnect操作,

使用並查集快速判斷環

複雜度比較:(V爲節點數,E爲邊數)

Lazy Prim——O(ElogE)
Prim——O(ElogV)
Kruskal——O(ElogE)
Kruskal算法比Prim算法效率略差,但是思路簡單,易於實現。

如果橫切變中有相等的邊:
則根據算法的具體實現,每次選擇一個邊,此時,圖存在多個最小生成樹,但是按照上面的兩種方法,最終只能找到一個最小生成樹。(如果橫切邊中不含相等的邊,則得到的最小生成樹是唯一的)

對於一個圖,有多少個最小生成樹

3. Vyssotsky算法

思想:將邊逐漸地添加到生成樹中,一旦形成環,刪除環中權值最大的邊。
但是由於目前沒有支持該算法的數據結構,因此不常用。

 

四、最短路徑問題

https://blog.csdn.net/wzy_2017/article/details/78910697
最短路徑問題:從某個源點開始,到其餘各頂點的最短路徑。
從某一個節點開始,進行廣度優先遍歷,其實就是求了一個最短路徑。
最短路徑問題和最小生成樹問題的區別:

最小生成樹是說,所有的邊的權值總和是最小的,
最短路徑問題是,所有的點,到起始頂點的距離是最小的(起始頂點是固定的),其實形成了一個以該節點爲根的樹,這其實是一個特殊的最小生成樹,加了一個限制條件——限制了頂點,這棵樹稱爲最短路徑樹,求解最短路徑樹的問題稱爲——單源最短路徑問題(單源的意思是:單一起始點)。

這裏求解帶權圖的最短路徑問題。核心操作是:鬆弛操作。

1. Dijkstra單源最短路徑算法(無負權邊,O(Elog(V)))

Dijkstra算法的前提(也是侷限):圖中不能有負權邊!

複雜度O(Elog(V)),藉助最小索引堆,每次插入和更新複雜度爲O(logV),遍歷所有節點的複雜度爲O(n)。

【Dijkstra思想】:
每次找到離源點(如1號結點)最近的一個頂點,然後以該頂點爲中心進行鬆弛操作,最終得到源點到其餘所有點的最短路徑。

【前期準備】:

  1. 使用distTo數組,記錄每一個節點到源點的距離,初始值除源點設爲0外,其餘都設爲無窮大
  2. 使用marked數組,記錄該節點是否已經是最小路徑了,初始值除源點外,其餘都設爲False
  3. 使用from數組,記錄最短路徑中,每個節點的前驅結點,初始化全部爲False
  4. 使用最小索引堆ipq,記錄所有節點到源點的路徑值,那麼每次獲取最小值時只需要O(1)複雜度。

【基本步驟】:

  1. 將源點的所有鄰邊加入distTo數組中,並且將這些邊同時加入最小索引堆;
  2. 如果ipq不爲空,則取出離源點最近的節點v,將marked[v]標記爲True(Dijkstra算法的前提,沒有負權邊,當前源點s到v的路徑是最小的,則認爲這個距離就是源點到v的最短路徑,)並且以v爲中心進行鬆弛操作;
  3. 重複第2步操作,直至ipq爲空。

【鬆弛操作的含義】:遍歷v的每一個鄰邊w,判斷從源點到v的最短路徑,加上從v到w的路徑之和,是否小於直接從源點s到w的路徑,如果是,則更新distTo[w]爲更短的路徑,更新w的前驅結點爲v,更新ipq中從源點s到w的最短路徑;

代碼實現(寫了一個比較完整的版本,想看代碼簡化版的,看這裏):

class Dijkstra:
    def __init__(self, adj, s): # 源點
        self.ipq = IndexMinHeap()  # 最小索引堆,只需要開闢節點個數那麼多的空間就足夠了
        self.graph = adj
        self.s = s
        self.distTo = [float('inf')] * len(self.graph)  # 源點s到每一個頂點的最短距離
        self.marked = [False] * len(self.graph) # 已經找到了最小路徑的頂點進行標記
        self.from_ = [False] * len(self.graph) # 用來記錄最短路徑中,每個頂點的前驅結點

    def Dijkstra(self):
        self.distTo[self.s] = 0
        self.marked[self.s] = True
        for node in self.graph[self.s]: # 將源點的所有鄰邊加入distTo數組和最小索引堆ipq
            self.distTo[node] = self.graph[self.s][node]
            self.ipq.insert(self.s, node, self.distTo[node])
        while not self.ipq.isEmpty():
            s, v, distance = self.ipq.pop() # 提取當前離源點s路徑最短的節點,進行鬆弛操作
            self.marked[v] = True
            for w in self.graph[v]: # 遍歷v的所有臨邊
                if not self.marked[w]:
                    if not self.from_ or self.distTo[v] + self.graph[v][w] < self.distTo[w]: # 如果w節點還沒有訪問過,或者從v過去更短,則更新路徑
                        self.distTo[w] = self.distTo[v] + self.graph[v][w]
                        self.from_[w] = v
                        if not self.ipq.change(s, s, w, self.distTo[w]): # 更新最小索引堆,源點到w點的最短路徑
                            self.ipq.insert(s, w, self.distTo[w])
        return self.distTo

    def shortestPathTo(self, w): # 頂點s到某一個點w的權重是多少
        return self.distTo[w]

    def hasPathTo(self, w): # 判斷源點能夠到達該點(判斷是否連通)
        return self.marked[w]

    def showShortestPath(self): # 輸出到各節點的最短路徑的具體過程
        for i in range(len(self.graph)):
            res = [i]
            e = self.from_[i]
            while e != self.s:
                res.insert(0, e)
                e = self.from_[e]
            res.insert(0, self.s)
            print('from s to {}: {}'.format(i, res))

class IndexMinHeap: #### 維護一個最小索引堆
    def __init__(self):
        self.data = []
        self.count = 0
        self.indexes = []

    def isEmpty(self):
        return self.count == 0

    def insert(self, start_node, end_node, weight):
        self.data.append([start_node, end_node, weight])
        self.count += 1
        self.indexes.append(self.count - 1)
        self.ShiftUp(self.count - 1)


    def ShiftUp(self, k):
        while (k - 1) // 2 >= 0:  # 如果存在父節點
            if self.data[self.indexes[k]][-1] < self.data[self.indexes[(k - 1) //2]][-1]:
                self.indexes[k], self.indexes[(k - 1)//2] = self.indexes[(k - 1)//2], self.indexes[k]
            k = (k -1) //2

    def pop(self):
        assert self.count > 0
        tmp_index = self.indexes[0]  #記錄彈出的索引號,大於這個索引的序號,依次減一
        self.indexes[0], self.indexes[-1] = self.indexes[-1], self.indexes[0]
        res = self.data[self.indexes.pop()]
        self.data.remove(res)
        self.count -= 1

        for i in range(len(self.indexes)):
            if self.indexes[i] > tmp_index:
                self.indexes[i] -= 1
        self.ShiftDown(0) # 彈出元素後,一定要維護最小堆
        return res

    def ShiftDown(self, index):
        while 2 * index + 1 < self.count: # 如果包含左孩子
            left = 2 * index + 1
            if left + 1 < self.count and self.data[self.indexes[left]][-1] > self.data[self.indexes[left + 1]][-1]:
                left = left + 1
            if self.data[self.indexes[index]][-1] < self.data[self.indexes[left]][-1]:
                break
            self.indexes[index], self.indexes[left] = self.indexes[left], self.indexes[index]
            index = left

    # 將[old_start, end_node,weight]的一條邊替換成[new_start, end_node, item],其中item < weight
    def change(self, old_start, new_start, end_node, item):
        ### 找到self.indexes[j] = i, j表示data[i]在堆中的位置
        # 對j分別進行ShiftUp & ShiftDown
        flag = True
        for j in range(self.count):
            if self.data[j][0] == old_start and self.data[j][1] == end_node:
                flag = True
                self.data[j][0] = new_start
                self.data[j][-1] = item
                self.ShiftUp(j)
                self.ShiftDown(j)
        return True if flag else False


adj = {0:{1 : 5, 2 : 2, 3 : 6},1:{4 : 1}, 2:{1 : 1, 3: 3, 4: 5}, 3:{4: 2}, 4:{}}
S = Dijkstra(adj, 0)
print(S.Dijkstra())
S.showShortestPath()

2. Bellman-Ford單源最短路徑算法(無負權環,O(EV))

前提:圖中可以有負權邊,但不能有負權環(因爲擁有負權環的圖,沒有最短路徑),Bellman-Ford算法可以判斷圖中是否有負權環。

Ford算法比Dijkstra算法處理的範圍更廣,代價是複雜度更高,Ford的複雜度是O(EV),Dijkstra的複雜度是O(ElogV)(E爲邊數,V爲頂點數)

【Ford算法的思想】:
從源點出發,對所有的點進行V-1次鬆弛操作(Dijkstra每次只拿出最短的邊來進行鬆弛操作),每經過一次鬆弛操作,找到經過這個點的另外一條路徑,多一條邊,權值更小。當經過了V-1次鬆弛操作後,理論上就找到了從源點到其他所有點的最短路徑,如果還可以繼續鬆弛,說明圖中有負權環。

如果一個圖沒有負權環,從一點到另外一點的最短路徑,最多經過所有的V的頂點,有V-1條邊;

鬆弛操作的理解:能不能從一個點出發,經由另外一個點,再回到找另外一個點,

代碼實現:

class Bellman_Ford:
    def __init__(self, adj, s): # s表示源點
        self.graph = adj
        self.s = s
        self.distTo = [float('inf')] * len(self.graph)
        self.from_ = [False] * len(self.graph)
        self.hasNegativeCycle = False # 檢測是否有負權環

    def BellmanFord(self):
        self.distTo[self.s] = 0
        self.from_[self.s] = self.s
        # 三重循環,對所有節點進行V-1輪鬆弛操作
        for p in range(1, len(self.graph)): # 最外層的V-1輪
            for v in range(len(self.graph)):  # 所有節點
                for w in self.graph[v]:
                    if self.distTo[v] + self.graph[v][w] < self.distTo[w]:
                        self.distTo[w] = self.distTo[v] + self.graph[v][w]
                        self.from_[w] = v
        self.hasNegativeCycle = self.detectNegativeCycle()
        if self.hasNegativeCycle: # 如果有負權環,則不存在最短路徑
            return "The graph contains negative cycle!"
        return self.distTo

    # 檢測負權環
    def detectNegativeCycle(self): # 再進行一輪鬆弛操作,如果出現了更短的路徑,說明存在負權環
        for v in range(len(self.graph)):  # 所有節點
            for w in self.graph[v]:
                if self.distTo[v] + self.graph[v][w] < self.distTo[w]: # 如果出現了更短路徑,返回True
                    return True
        return False

    def showShortestPath(self): # 輸出到各節點的最短路徑的具體過程
        for i in range(len(self.graph)):
            res = [i]
            e = self.from_[i]
            while e != self.s:
                res.insert(0, e)
                e = self.from_[e]
            res.insert(0, self.s)
            print('from s to {}: {}'.format(i, res))

adj = {0:{1 : 5, 2 : 2, 3 : 6},1:{2 : -4, 4 : 2}, 2:{3: 3, 4: 5}, 3:{}, 4:{3: -3}}
S = Bellman_Ford(adj, 0)
print(S.BellmanFord())
S.showShortestPath()

Bellman-Ford算法的優化方法——利用隊列數據結構,queue-based bellman-ford算法,優化的複雜度依舊是O(EV)

有向無環圖:限制條件更多,不能處理有環圖,不能處理無向圖。

3. 所有對最短路徑算法

單源最短路徑算法的侷限:用戶需要提前指定這個“源”究竟是誰,而所有對最短路徑算法可以解決:任何兩個點之間的最短路徑。

4. Floyed算法——處理無負權環的圖,O(V^3)

動態規劃思想。

五、最長路徑算法

最長路徑問題不能有正權環;
無權圖的最長路徑問題是指數級難度的;
對於有權圖,不能使用Dijkstra求最長路徑問題;
可以使用Bellman-Ford算法。
有向無環圖DAG的拓撲排序算法,以及用索引算法處理所有對的最短路徑算法,也可以通過改造,來解決最長路徑問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章