代碼隨想錄算法訓練營第70天圖論9[1]

代碼隨想錄算法訓練營第70天:圖論9

?

拓撲排序精講

卡碼網:117. 軟件構建(opens new window)

題目描述:

某個大型軟件項目的構建系統擁有 N 個文件,文件編號從 0 到 N - 1,在這些文件中,某些文件依賴于其他文件的內容,這意味著如果文件 A 依賴于文件 B,則必須在處理文件 A 之前處理文件 B (0 <= A, B <= N - 1)。請編寫一個算法,用于確定文件處理的順序。

輸入描述:

第一行輸入兩個正整數 M, N。表示 N 個文件之間擁有 M 條依賴關系。

后續 M 行,每行兩個正整數 S 和 T,表示 T 文件依賴于 S 文件。

輸出描述:

輸出共一行,如果能處理成功,則輸出文件順序,用空格隔開。

如果不能成功處理(相互依賴),則輸出 -1。

輸入示例:

5 4
0 1
0 2
1 3
2 4

輸出示例:

0 1 2 3 4

提示信息:

文件依賴關系如下:

??

所以,文件處理的順序除了示例中的順序,還存在

0 2 4 1 3

0 2 1 3 4

等等合法的順序。

數據范圍:

  • 0 <= N <= 10 ^ 5
  • 1 <= M <= 10 ^ 9

#拓撲排序的背景

本題是拓撲排序的經典題目。

一聊到 拓撲排序,一些錄友可能會想這是排序,不會想到這是圖論算法。

其實拓撲排序是經典的圖論問題。

先說說 拓撲排序的應用場景。

大學排課,例如 先上A課,才能上B課,上了B課才能上C課,上了A課才能上D課,等等一系列這樣的依賴順序。 問給規劃出一條 完整的上課順序。

拓撲排序在文件處理上也有應用,我們在做項目安裝文件包的時候,經常發現 復雜的文件依賴關系, A依賴B,B依賴C,B依賴D,C依賴E 等等。

如果給出一條線性的依賴順序來下載這些文件呢?

有錄友想上面的例子都很簡單啊,我一眼能給排序出來。

那如果上面的依賴關系是一百對呢,一千對甚至上萬個依賴關系,這些依賴關系中可能還有循環依賴,你如何發現循環依賴呢,又如果排出線性順序呢。

所以 拓撲排序就是專門解決這類問題的。

概括來說,給出一個 有向圖,把這個有向圖轉成線性的排序 就叫拓撲排序

當然拓撲排序也要檢測這個有向圖 是否有環,即存在循環依賴的情況,因為這種情況是不能做線性排序的。

所以拓撲排序也是圖論中判斷有向無環圖的常用方法


#拓撲排序的思路

拓撲排序指的是一種 解決問題的大體思路, 而具體算法,可能是廣搜也可能是深搜。

大家可能發現 各式各樣的解法,糾結哪個是拓撲排序?

其實只要能在把 有向無環圖 進行線性排序 的算法 都可以叫做 拓撲排序。

實現拓撲排序的算法有兩種:卡恩算法(BFS)和DFS

卡恩1962年提出這種解決拓撲排序的思路

一般來說我們只需要掌握 BFS (廣度優先搜索)就可以了,清晰易懂,如果還想多了解一些,可以再去學一下 DFS 的思路,但 DFS 不是本篇重點。

接下來我們來講解BFS的實現思路。

以題目中示例為例如圖:

??

做拓撲排序的話,如果肉眼去找開頭的節點,一定能找到 節點0 吧,都知道要從節點0 開始。

但為什么我們能找到 節點0呢,因為我們肉眼看著 這個圖就是從 節點0出發的。

作為出發節點,它有什么特征?

你看節點0 的入度 為0 出度為2, 也就是 沒有邊指向它,而它有兩條邊是指出去的。

節點的入度表示 有多少條邊指向它,節點的出度表示有多少條邊 從該節點出發。

所以當我們做拓撲排序的時候,應該優先找 入度為 0 的節點,只有入度為0,它才是出發節點。 理解以上內容很重要

接下來我給出 拓撲排序的過程,其實就兩步:

  1. 找到入度為0 的節點,加入結果集
  2. 將該節點從圖中移除

循環以上兩步,直到 所有節點都在圖中被移除了。

結果集的順序,就是我們想要的拓撲排序順序 (結果集里順序可能不唯一)

#模擬過程

用本題的示例來模擬這一過程:

1、找到入度為0 的節點,加入結果集

??

2、將該節點從圖中移除

??


1、找到入度為0 的節點,加入結果集

??

這里大家會發現,節點1 和 節點2 入度都為0, 選哪個呢?

選哪個都行,所以這也是為什么拓撲排序的結果是不唯一的。

2、將該節點從圖中移除

??


1、找到入度為0 的節點,加入結果集

??

節點2 和 節點3 入度都為0,選哪個都行,這里選節點2

2、將該節點從圖中移除

??


后面的過程一樣的,節點3 和 節點4,入度都為0,選哪個都行。

最后結果集為: 0 1 2 3 4 。當然結果不唯一的。

#判斷有環

如果有 有向環怎么辦呢?例如這個圖:

??

這個圖,我們只能將入度為0 的節點0 接入結果集。

之后,節點1、2、3、4 形成了環,找不到入度為0 的節點了,所以此時結果集里只有一個元素。

那么如果我們發現結果集元素個數 不等于 圖中節點個數,我們就可以認定圖中一定有 有向環!

這也是拓撲排序判斷有向環的方法。

通過以上過程的模擬大家會發現這個拓撲排序好像不難,還有點簡單。

#寫代碼

理解思想后,確實不難,但代碼寫起來也不容易。

為了每次可以找到所有節點的入度信息,我們要在初始話的時候,就把每個節點的入度 和 每個節點的依賴關系做統計。

代碼如下:

cin >> n >> m;
vector<int> inDegree(n, 0); // 記錄每個文件的入度
vector<int> result; // 記錄結果
unordered_map<int, vector<int>> umap; // 記錄文件依賴關系while (m--) {
// s->t,先有s才能有t
cin >> s >> t;
inDegree[t]++; // t的入度加一
umap[s].push_back(t); // 記錄s指向哪些文件
}

找入度為0 的節點,我們需要用一個隊列放存放。

因為每次尋找入度為0的節點,不一定只有一個節點,可能很多節點入度都為0,所以要將這些入度為0的節點放到隊列里,依次去處理。

代碼如下:


queue<int> que;
for (int i = 0; i < n; i++) {
// 入度為0的節點,可以作為開頭,先加入隊列
if (inDegree[i] == 0) que.push(i);
}

開始從隊列里遍歷入度為0 的節點,將其放入結果集。


while (que.size()) {
int  cur = que.front(); // 當前選中的節點
que.pop();
result.push_back(cur);
// 將該節點從圖中移除 }

這里面還有一個很重要的過程,如何把這個入度為0的節點從圖中移除呢?

首先我們為什么要把節點從圖中移除?

為的是將 該節點作為出發點所連接的邊刪掉。

刪掉的目的是什么呢?

要把 該節點作為出發點所連接的節點的 入度 減一。

如果這里不理解,看上面的模擬過程第一步:

??

這事節點1 和 節點2 的入度為 1。

將節點0刪除后,圖為這樣:

??

那么 節點0 作為出發點 所連接的節點的入度 就都做了 減一 的操作。

此時 節點1 和 節點 2 的入度都為0, 這樣才能作為下一輪選取的節點。

所以,我們在代碼實現的過程中,本質是要將 該節點作為出發點所連接的節點的 入度 減一 就可以了,這樣好能根據入度找下一個節點,不用真在圖里把這個節點刪掉。

該過程代碼如下:


while (que.size()) {
int  cur = que.front(); // 當前選中的節點
que.pop();
result.push_back(cur);
// 將該節點從圖中移除 
vector<int> files = umap[cur]; //獲取cur指向的節點
if (files.size()) { // 如果cur有指向的節點
for (int i = 0; i < files.size(); i++) { // 遍歷cur指向的節點
inDegree[files[i]] --; // cur指向的節點入度都做減一操作
// 如果指向的節點減一之后,入度為0,說明是我們要選取的下一個節點,放入隊列。
if(inDegree[files[i]] == 0) que.push(files[i]); 
}
}}

最后代碼如下:

#include <iostream>
#include <vector>
#include <queue>
#include <unordered_map>
using namespace std;
int main() {
int m, n, s, t;
cin >> n >> m;
vector<int> inDegree(n, 0); // 記錄每個文件的入度unordered_map<int, vector<int>> umap;// 記錄文件依賴關系
vector<int> result; // 記錄結果while (m--) {
// s->t,先有s才能有t
cin >> s >> t;
inDegree[t]++; // t的入度加一
umap[s].push_back(t); // 記錄s指向哪些文件
}
queue<int> que;
for (int i = 0; i < n; i++) {
// 入度為0的文件,可以作為開頭,先加入隊列
if (inDegree[i] == 0) que.push(i);
//cout << inDegree[i] << endl;
}
// int count = 0;
while (que.size()) {
int  cur = que.front(); // 當前選中的文件
que.pop();
//count++;
result.push_back(cur);
vector<int> files = umap[cur]; //獲取該文件指向的文件
if (files.size()) { // cur有后續文件
for (int i = 0; i < files.size(); i++) {
inDegree[files[i]] --; // cur的指向的文件入度-1
if(inDegree[files[i]] == 0) que.push(files[i]);
}
}
}
if (result.size() == n) {
for (int i = 0; i < n - 1; i++) cout << result[i] << " ";
cout << result[n - 1];
} else cout << -1 << endl;}

dijkstra(樸素版)精講

卡碼網:47. 參加科學大會(opens new window)

【題目描述】

小明是一位科學家,他需要參加一場重要的國際科學大會,以展示自己的最新研究成果。

小明的起點是第一個車站,終點是最后一個車站。然而,途中的各個車站之間的道路狀況、交通擁堵程度以及可能的自然因素(如天氣變化)等不同,這些因素都會影響每條路徑的通行時間。

小明希望能選擇一條花費時間最少的路線,以確保他能夠盡快到達目的地。

【輸入描述】

第一行包含兩個正整數,第一個正整數 N 表示一共有 N 個公共汽車站,第二個正整數 M 表示有 M 條公路。

接下來為 M 行,每行包括三個整數,S、E 和 V,代表了從 S 車站可以單向直達 E 車站,并且需要花費 V 單位的時間。

【輸出描述】

輸出一個整數,代表小明從起點到終點所花費的最小時間。

輸入示例

7 9
1 2 1
1 3 4
2 3 2
2 4 5
3 4 2
4 5 3
2 6 4
5 7 4
6 7 9

輸出示例:12

【提示信息】

能夠到達的情況:

如下圖所示,起始車站為 1 號車站,終點車站為 7 號車站,綠色路線為最短的路線,路線總長度為 12,則輸出 12。

??

不能到達的情況:

如下圖所示,當從起始車站不能到達終點車站時,則輸出 -1。

?外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳?

數據范圍:

1 <= N <= 500; 1 <= M <= 5000;

#思路

本題就是求最短路,最短路是圖論中的經典問題即:給出一個有向圖,一個起點,一個終點,問起點到終點的最短路徑。

接下來,我們來詳細講解最短路算法中的 dijkstra 算法。

dijkstra算法:在有權圖(權值非負數)中求從起點到其他節點的最短路徑算法。

需要注意兩點:

  • dijkstra 算法可以同時求 起點到所有節點的最短路徑
  • 權值不能為負數

(這兩點后面我們會講到)

如本題示例中的圖:

?外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳?

起點(節點1)到終點(節點7) 的最短路徑是 圖中 標記綠線的部分。

最短路徑的權值為12。

其實 dijkstra 算法 和 我們之前講解的prim算法思路非常接近,如果大家認真學過prim算法,那么理解 Dijkstra 算法會相對容易很多。(這也是我要先講prim再講dijkstra的原因)

dijkstra 算法 同樣是貪心的思路,不斷尋找距離 源點最近的沒有訪問過的節點。

這里我也給出 dijkstra三部曲

  1. 第一步,選源點到哪個節點近且該節點未被訪問過
  2. 第二步,該最近節點被標記訪問過
  3. 第三步,更新非訪問節點到源點的距離(即更新minDist數組)

大家此時已經會發現,這和prim算法 怎么這么像呢。

我在prim算法講解中也給出了三部曲。 prim 和 dijkstra 確實很像,思路也是類似的,這一點我在后面還會詳細來講。

在dijkstra算法中,同樣有一個數組很重要,起名為:minDist。

minDist數組 用來記錄 每一個節點距離源點的最小距離

理解這一點很重要,也是理解 dijkstra 算法的核心所在。

大家現在看著可能有點懵,不知道什么意思。

沒關系,先讓大家有一個印象,對理解后面講解有幫助。

我們先來畫圖看一下 dijkstra 的工作過程,以本題示例為例: (以下為樸素版dijkstra的思路)

示例中節點編號是從1開始,所以為了讓大家看的不暈,minDist數組下標我也從 1 開始計數,下標0 就不使用了,這樣 下標和節點標號就可以對應上了,避免大家搞混

#樸素版dijkstra

#模擬過程


0、初始化

minDist數組數值初始化為int最大值。

這里在強點一下 minDist數組的含義:記錄所有節點到源點的最短路徑,那么初始化的時候就應該初始為最大值,這樣才能在后續出現最短路徑的時候及時更新。

?外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳?

(圖中,max 表示默認值,節點0 不做處理,統一從下標1 開始計算,這樣下標和節點數值統一, 方便大家理解,避免搞混)

源點(節點1) 到自己的距離為0,所以 minDist[1] = 0

此時所有節點都沒有被訪問過,所以 visited數組都為0


以下為dijkstra 三部曲

1、選源點到哪個節點近且該節點未被訪問過

源點距離源點最近,距離為0,且未被訪問。

2、該最近節點被標記訪問過

標記源點訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

更新 minDist數組,即:源點(節點1) 到 節點2 和 節點3的距離。

  • 源點到節點2的最短距離為1,小于原minDist[2]的數值max,更新minDist[2] = 1
  • 源點到節點3的最短距離為4,小于原minDist[3]的數值max,更新minDist[4] = 4

可能有錄友問:為啥和 minDist[2] 比較?

再強調一下 minDist[2] 的含義,它表示源點到節點2的最短距離,那么目前我們得到了 源點到節點2的最短距離為1,小于默認值max,所以更新。 minDist[3]的更新同理


1、選源點到哪個節點近且該節點未被訪問過

未訪問過的節點中,源點到節點2距離最近,選節點2

2、該最近節點被標記訪問過

節點2被標記訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

更新 minDist數組,即:源點(節點1) 到 節點6 、 節點3 和 節點4的距離。

為什么更新這些節點呢? 怎么不更新其他節點呢

因為 源點(節點1)通過 已經計算過的節點(節點2) 可以鏈接到的節點 有 節點3,節點4和節點6.

更新 minDist數組:

  • 源點到節點6的最短距離為5,小于原minDist[6]的數值max,更新minDist[6] = 5
  • 源點到節點3的最短距離為3,小于原minDist[3]的數值4,更新minDist[3] = 3
  • 源點到節點4的最短距離為6,小于原minDist[4]的數值max,更新minDist[4] = 6

1、選源點到哪個節點近且該節點未被訪問過

未訪問過的節點中,源點距離哪些節點最近,怎么算的?

其實就是看 minDist數組里的數值,minDist 記錄了 源點到所有節點的最近距離,結合visited數組篩選出未訪問的節點就好。

從 上面的圖,或者 從minDist數組中,我們都能看出 未訪問過的節點中,源點(節點1)到節點3距離最近。

2、該最近節點被標記訪問過

節點3被標記訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

由于節點3的加入,那么源點可以有新的路徑鏈接到節點4 所以更新minDist數組:

更新 minDist數組:

  • 源點到節點4的最短距離為5,小于原minDist[4]的數值6,更新minDist[4] = 5

1、選源點到哪個節點近且該節點未被訪問過

距離源點最近且沒有被訪問過的節點,有節點4 和 節點6,距離源點距離都是 5 (minDist[4] = 5,minDist[6] = 5) ,選哪個節點都可以。

2、該最近節點被標記訪問過

節點4被標記訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

由于節點4的加入,那么源點可以鏈接到節點5 所以更新minDist數組:

  • 源點到節點5的最短距離為8,小于原minDist[5]的數值max,更新minDist[5] = 8

1、選源點到哪個節點近且該節點未被訪問過

距離源點最近且沒有被訪問過的節點,是節點6,距離源點距離是 5 (minDist[6] = 5)

2、該最近節點被標記訪問過

節點6 被標記訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

由于節點6的加入,那么源點可以鏈接到節點7 所以 更新minDist數組:

  • 源點到節點7的最短距離為14,小于原minDist[7]的數值max,更新minDist[7] = 14

1、選源點到哪個節點近且該節點未被訪問過

距離源點最近且沒有被訪問過的節點,是節點5,距離源點距離是 8 (minDist[5] = 8)

2、該最近節點被標記訪問過

節點5 被標記訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

由于節點5的加入,那么源點有新的路徑可以鏈接到節點7 所以 更新minDist數組:

  • 源點到節點7的最短距離為12,小于原minDist[7]的數值14,更新minDist[7] = 12

1、選源點到哪個節點近且該節點未被訪問過

距離源點最近且沒有被訪問過的節點,是節點7(終點),距離源點距離是 12 (minDist[7] = 12)

2、該最近節點被標記訪問過

節點7 被標記訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

節點7加入,但節點7到節點7的距離為0,所以 不用更新minDist數組


最后我們要求起點(節點1) 到終點 (節點7)的距離。

再來回顧一下minDist數組的含義:記錄 每一個節點距離源點的最小距離。

那么起到(節點1)到終點(節點7)的最短距離就是 minDist[7] ,按上面舉例講解來說,minDist[7] = 12,節點1 到節點7的最短路徑為 12。

路徑如圖:

??

在上面的講解中,每一步 我都是按照 dijkstra 三部曲來講解的,理解了這三部曲,代碼也就好懂的。

#代碼實現

本題代碼如下,里面的 三部曲 我都做了注釋,大家按照我上面的講解 來看如下代碼:

#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
int n, m, p1, p2, val;
cin >> n >> m;vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2] = val;
}int start = 1;
int end = n;// 存儲從源點到每個節點的最短距離
std::vector<int> minDist(n + 1, INT_MAX);// 記錄頂點是否被訪問過
std::vector<bool> visited(n + 1, false);minDist[start] = 0;  // 起始點到自身的距離為0for (int i = 1; i <= n; i++) { // 遍歷所有節點int minVal = INT_MAX;
int cur = 1;// 1、選距離源點最近且未訪問過的節點
for (int v = 1; v <= n; ++v) {
if (!visited[v] && minDist[v] < minVal) {
minVal = minDist[v];
cur = v;
}
}visited[cur] = true;  // 2、標記該節點已被訪問// 3、第三步,更新非訪問節點到源點的距離(即更新minDist數組)
for (int v = 1; v <= n; v++) {
if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
minDist[v] = minDist[cur] + grid[cur][v];
}
}}if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到達終點
else cout << minDist[end] << endl; // 到達終點最短路徑}
  • 時間復雜度:O(n^2)
  • 空間復雜度:O(n^2)

#debug方法

寫這種題目難免會有各種各樣的問題,我們如何發現自己的代碼是否有問題呢?

最好的方式就是打日志,本題的話,就是將 minDist 數組打印出來,就可以很明顯發現 哪里出問題了。

每次選擇節點后,minDist數組的變化是否符合預期 ,是否和我上面講的邏輯是對應的。

例如本題,如果想debug的話,打印日志可以這樣寫:

#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
int n, m, p1, p2, val;
cin >> n >> m;vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2] = val;
}int start = 1;
int end = n;std::vector<int> minDist(n + 1, INT_MAX);std::vector<bool> visited(n + 1, false);minDist[start] = 0;
for (int i = 1; i <= n; i++) {int minVal = INT_MAX;
int cur = 1;for (int v = 1; v <= n; ++v) {
if (!visited[v] && minDist[v] < minVal) {
minVal = minDist[v];
cur = v;
}
}visited[cur] = true;for (int v = 1; v <= n; v++) {
if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
minDist[v] = minDist[cur] + grid[cur][v];
}
}// 打印日志:
cout << "select:" << cur << endl;
for (int v = 1; v <= n; v++) cout <<  v << ":" << minDist[v] << " ";
cout << endl << endl;;}
if (minDist[end] == INT_MAX) cout << -1 << endl;
else cout << minDist[end] << endl;}

打印后的結果:

select:1
1:0 2:1 3:4 4:2147483647 5:2147483647 6:2147483647 7:2147483647select:2
1:0 2:1 3:3 4:6 5:2147483647 6:5 7:2147483647select:3
1:0 2:1 3:3 4:5 5:2147483647 6:5 7:2147483647select:4
1:0 2:1 3:3 4:5 5:8 6:5 7:2147483647select:6
1:0 2:1 3:3 4:5 5:8 6:5 7:14select:5
1:0 2:1 3:3 4:5 5:8 6:5 7:12select:7
1:0 2:1 3:3 4:5 5:8 6:5 7:12

打印日志可以和上面我講解的過程進行對比,每一步的結果是完全對應的。

所以如果大家如果代碼有問題,打日志來debug是最好的方法

#如何求路徑

如果題目要求把最短路的路徑打印出來,應該怎么辦呢?

這里還是有一些“坑”的,本題打印路徑和 prim 打印路徑是一樣的,我在 prim算法精講 【拓展】中 已經詳細講解了。

在這里就不再贅述。

打印路徑只需要添加 幾行代碼, 打印路徑的代碼我都加上的日志,如下:

#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
int n, m, p1, p2, val;
cin >> n >> m;vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2] = val;
}int start = 1;
int end = n;std::vector<int> minDist(n + 1, INT_MAX);std::vector<bool> visited(n + 1, false);minDist[start] = 0; //加上初始化
vector<int> parent(n + 1, -1);for (int i = 1; i <= n; i++) {int minVal = INT_MAX;
int cur = 1;for (int v = 1; v <= n; ++v) {
if (!visited[v] && minDist[v] < minVal) {
minVal = minDist[v];
cur = v;
}
}visited[cur] = true;for (int v = 1; v <= n; v++) {
if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
minDist[v] = minDist[cur] + grid[cur][v];
parent[v] = cur; // 記錄邊
}
}}// 輸出最短情況
for (int i = 1; i <= n; i++) {
cout << parent[i] << "->" << i << endl;
}
}

打印結果:

-1->1
1->2
2->3
3->4
4->5
2->6
5->7

對應如圖:

??

#出現負數

如果圖中邊的權值為負數,dijkstra 還合適嗎?

看一下這個圖: (有負權值)

??

節點1 到 節點5 的最短路徑 應該是 節點1 -> 節點2 -> 節點3 -> 節點4 -> 節點5

那我們來看dijkstra 求解的路徑是什么樣的,繼續dijkstra 三部曲來模擬 :(dijkstra模擬過程上面已經詳細講過,以下只模擬重要過程,例如如何初始化就省略講解了)


初始化:

??


1、選源點到哪個節點近且該節點未被訪問過

源點距離源點最近,距離為0,且未被訪問。

2、該最近節點被標記訪問過

標記源點訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

更新 minDist數組,即:源點(節點1) 到 節點2 和 節點3的距離。

  • 源點到節點2的最短距離為100,小于原minDist[2]的數值max,更新minDist[2] = 100
  • 源點到節點3的最短距離為1,小于原minDist[3]的數值max,更新minDist[4] = 1

1、選源點到哪個節點近且該節點未被訪問過

源點距離節點3最近,距離為1,且未被訪問。

2、該最近節點被標記訪問過

標記節點3訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

由于節點3的加入,那么源點可以有新的路徑鏈接到節點4 所以更新minDist數組:

  • 源點到節點4的最短距離為2,小于原minDist[4]的數值max,更新minDist[4] = 2

1、選源點到哪個節點近且該節點未被訪問過

源點距離節點4最近,距離為2,且未被訪問。

2、該最近節點被標記訪問過

標記節點4訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

由于節點4的加入,那么源點可以有新的路徑鏈接到節點5 所以更新minDist數組:

  • 源點到節點5的最短距離為3,小于原minDist[5]的數值max,更新minDist[5] = 5

1、選源點到哪個節點近且該節點未被訪問過

源點距離節點5最近,距離為3,且未被訪問。

2、該最近節點被標記訪問過

標記節點5訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??

節點5的加入,而節點5 沒有鏈接其他節點, 所以不用更新minDist數組,僅標記節點5被訪問過了


1、選源點到哪個節點近且該節點未被訪問過

源點距離節點2最近,距離為100,且未被訪問。

2、該最近節點被標記訪問過

標記節點2訪問過

3、更新非訪問節點到源點的距離(即更新minDist數組) ,如圖:

??


至此dijkstra的模擬過程就結束了,根據最后的minDist數組,我們求 節點1 到 節點5 的最短路徑的權值總和為 3,路徑: 節點1 -> 節點3 -> 節點4 -> 節點5

通過以上的過程模擬,我們可以發現 之所以 沒有走有負權值的最短路徑 是因為 在 訪問 節點 2 的時候,節點 3 已經訪問過了,就不會再更新了。

那有錄友可能會想: 我可以改代碼邏輯啊,訪問過的節點,也讓它繼續訪問不就好了?

那么訪問過的節點還能繼續訪問會不會有死循環的出現呢?控制邏輯不讓其死循環?那特殊情況自己能都想清楚嗎?(可以試試,實踐出真知)

對于負權值的出現,大家可以針對某一個場景 不斷去修改 dijkstra 的代碼,但最終會發現只是 拆了東墻補西墻,對dijkstra的補充邏輯只能滿足某特定場景最短路求解。

對于求解帶有負權值的最短路問題,可以使用 Bellman-Ford 算法 ,我在后序會詳細講解。

#dijkstra與prim算法的區別

這里再次提示,需要先看我的 prim算法精講 ,否則可能不知道我下面講的是什么。

大家可以發現 dijkstra的代碼看上去 怎么和 prim算法這么像呢。

其實代碼大體不差,唯一區別在 三部曲中的 第三步: 更新minDist數組

因為prim是求 非訪問節點到最小生成樹的最小距離,而 dijkstra是求 非訪問節點到源點的最小距離

prim 更新 minDist數組的寫法:

for (int j = 1; j <= v; j++) {
if (!isInTree[j] && grid[cur][j] < minDist[j]) {
minDist[j] = grid[cur][j];
}
}

因為 minDist表示 節點到最小生成樹的最小距離,所以 新節點cur的加入,只需要 使用 grid[cur][j] ,grid[cur][j] 就表示 cur 加入生成樹后,生成樹到 節點j 的距離。

dijkstra 更新 minDist數組的寫法:

for (int v = 1; v <= n; v++) {
if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
minDist[v] = minDist[cur] + grid[cur][v];
}
}

因為 minDist表示 節點到源點的最小距離,所以 新節點 cur 的加入,需要使用 源點到cur的距離 (minDist[cur]) + cur 到 節點 v 的距離 (grid[cur][v]),才是 源點到節點v的距離。

此時大家可能不禁要想 prim算法 可以有負權值嗎?

當然可以!

錄友們可以自己思考思考一下,這是為什么?

這里我提示一下:prim算法只需要將節點以最小權值和鏈接在一起,不涉及到單一路徑。

#總結

本篇,我們深入講解的dijkstra算法,詳細模擬其工作的流程。

這里我給出了 dijkstra 三部曲 來 幫助大家理解 該算法,不至于 每次寫 dijkstra 都是黑盒操作,沒有框架沒有章法。

在給出的代碼中,我也按照三部曲的邏輯來給大家注釋,只要理解這三部曲,即使 過段時間 對 dijkstra 算法有些遺忘,依然可以寫出一個框架出來,然后再去調試細節。

對于圖論算法,一般代碼都比較長,很難寫出代碼直接可以提交通過,都需要一個debug的過程,所以 學習如何debug 非常重要

這也是我為什么 在本文中 單獨用來講解 debug方法。

本題求的是最短路徑和是多少,同時我們也要掌握 如何把最短路徑打印出來

我還寫了大篇幅來講解 負權值的情況, 只有畫圖帶大家一步一步去 看 出現負權值 dijkstra的求解過程,才能幫助大家理解,問題出在哪里。

如果我直接講:是因為訪問過的節點 不能再訪問,導致錯過真正的最短路,我相信大家都不知道我在說啥。

最后我還講解了 dijkstra 和 prim 算法的 相同 與 不同之處, 我在圖論的講解安排中 先講 prim算法 再講 dijkstra 是有目的的, 理解這兩個算法的相同與不同之處 有助于大家學習的更深入

而不是 學了 dijkstra 就只看 dijkstra, 算法之間 都是有聯系的,多去思考 算法之間的相互聯系,會幫助大家思考的更深入,掌握的更徹底。

本篇寫了這么長,我也只講解了 樸素版dijkstra,關于 堆優化dijkstra,我會在下一篇再來給大家詳細講解

加油

dijkstra(堆優化版)精講

卡碼網:47. 參加科學大會(opens new window)

【題目描述】

小明是一位科學家,他需要參加一場重要的國際科學大會,以展示自己的最新研究成果。

小明的起點是第一個車站,終點是最后一個車站。然而,途中的各個車站之間的道路狀況、交通擁堵程度以及可能的自然因素(如天氣變化)等不同,這些因素都會影響每條路徑的通行時間。

小明希望能選擇一條花費時間最少的路線,以確保他能夠盡快到達目的地。

【輸入描述】

第一行包含兩個正整數,第一個正整數 N 表示一共有 N 個公共汽車站,第二個正整數 M 表示有 M 條公路。

接下來為 M 行,每行包括三個整數,S、E 和 V,代表了從 S 車站可以單向直達 E 車站,并且需要花費 V 單位的時間。

【輸出描述】

輸出一個整數,代表小明從起點到終點所花費的最小時間。

輸入示例

7 9
1 2 1
1 3 4
2 3 2
2 4 5
3 4 2
4 5 3
2 6 4
5 7 4
6 7 9

輸出示例:12

【提示信息】

能夠到達的情況:

如下圖所示,起始車站為 1 號車站,終點車站為 7 號車站,綠色路線為最短的路線,路線總長度為 12,則輸出 12。

??

不能到達的情況:

如下圖所示,當從起始車站不能到達終點車站時,則輸出 -1。

??

數據范圍:

1 <= N <= 500; 1 <= M <= 5000;

#思路

本篇我們來講解 堆優化版dijkstra,看本篇之前,一定要先看 我講解的 樸素版dijkstra,否則本篇會有部分內容看不懂。

在上一篇中,我們講解了樸素版的dijkstra,該解法的時間復雜度為 O(n^2),可以看出時間復雜度 只和 n (節點數量)有關系。

如果n很大的話,我們可以換一個角度來優先性能。

在 講解 最小生成樹的時候,我們 講了兩個算法,prim算法(從點的角度來求最小生成樹)、Kruskal算法(從邊的角度來求最小生成樹)

這么在n 很大的時候,也有另一個思考維度,即:從邊的數量出發。

當 n 很大,邊 的數量 也很多的時候(稠密圖),那么 上述解法沒問題。

但 n 很大,邊 的數量 很小的時候(稀疏圖),是不是可以換成從邊的角度來求最短路呢?

畢竟邊的數量少。

有的錄友可能會想,n (節點數量)很大,邊不就多嗎? 怎么會邊的數量少呢?

別忘了,誰也沒有規定 節點之間一定要有邊連接著,例如有一萬個節點,只有一條邊,這也是一張圖。

了解背景之后,再來看 解法思路。

#圖的存儲

首先是 圖的存儲。

關于圖的存儲 主流有兩種方式: 鄰接矩陣和鄰接表

#鄰接矩陣

鄰接矩陣 使用 二維數組來表示圖結構。 鄰接矩陣是從節點的角度來表示圖,有多少節點就申請多大的二維數組。

例如: grid[2][5] = 6,表示 節點 2 鏈接 節點5 為有向圖,節點2 指向 節點5,邊的權值為6 (套在題意里,可能是距離為6 或者 消耗為6 等等)

如果想表示無向圖,即:grid[2][5] = 6,grid[5][2] = 6,表示節點2 與 節點5 相互連通,權值為6。

如圖:

??

在一個 n (節點數)為8 的圖中,就需要申請 8 * 8 這么大的空間,有一條雙向邊,即:grid[2][5] = 6,grid[5][2] = 6

這種表達方式(鄰接矩陣) 在 邊少,節點多的情況下,會導致申請過大的二維數組,造成空間浪費。

而且在尋找節點鏈接情況的時候,需要遍歷整個矩陣,即 n * n 的時間復雜度,同樣造成時間浪費。

鄰接矩陣的優點:

  • 表達方式簡單,易于理解
  • 檢查任意兩個頂點間是否存在邊的操作非常快
  • 適合稠密圖,在邊數接近頂點數平方的圖中,鄰接矩陣是一種空間效率較高的表示方法。

缺點:

  • 遇到稀疏圖,會導致申請過大的二維數組造成空間浪費 且遍歷 邊 的時候需要遍歷整個n * n矩陣,造成時間浪費
#鄰接表

鄰接表 使用 數組 + 鏈表的方式來表示。 鄰接表是從邊的數量來表示圖,有多少邊 才會申請對應大小的鏈表。

鄰接表的構造如圖:

??

這里表達的圖是:

  • 節點1 指向 節點3 和 節點5
  • 節點2 指向 節點4、節點3、節點5
  • 節點3 指向 節點4,節點4指向節點1。

有多少邊 鄰接表才會申請多少個對應的鏈表節點。

從圖中可以直觀看出 使用 數組 + 鏈表 來表達 邊的鏈接情況 。

鄰接表的優點:

  • 對于稀疏圖的存儲,只需要存儲邊,空間利用率高
  • 遍歷節點鏈接情況相對容易

缺點:

  • 檢查任意兩個節點間是否存在邊,效率相對低,需要 O(V)時間,V表示某節點鏈接其他節點的數量。
  • 實現相對復雜,不易理解
#本題圖的存儲

接下來我們繼續按照稀疏圖的角度來分析本題。

在第一個版本的實現思路中,我們提到了三部曲:

  1. 第一步,選源點到哪個節點近且該節點未被訪問過
  2. 第二步,該最近節點被標記訪問過
  3. 第三步,更新非訪問節點到源點的距離(即更新minDist數組)

在第一個版本的代碼中,這三部曲是套在一個 for 循環里,為什么?

因為我們是從節點的角度來解決問題。

三部曲中第一步(選源點到哪個節點近且該節點未被訪問過),這個操作本身需要for循環遍歷 minDist 來尋找最近的節點。

同時我們需要 遍歷所有 未訪問過的節點,所以 我們從 節點角度出發,代碼會有兩層for循環,代碼是這樣的: (注意代碼中的注釋,標記兩層for循環的用處)


for (int i = 1; i <= n; i++) { // 遍歷所有節點,第一層for循環 int minVal = INT_MAX;
int cur = 1;// 1、選距離源點最近且未訪問過的節點 , 第二層for循環
for (int v = 1; v <= n; ++v) {
if (!visited[v] && minDist[v] < minVal) {
minVal = minDist[v];
cur = v;
}
}visited[cur] = true;  // 2、標記該節點已被訪問// 3、第三步,更新非訪問節點到源點的距離(即更新minDist數組)
for (int v = 1; v <= n; v++) {
if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
minDist[v] = minDist[cur] + grid[cur][v];
}
}}

那么當從 邊 的角度出發, 在處理 三部曲里的第一步(選源點到哪個節點近且該節點未被訪問過)的時候 ,我們可以不用去遍歷所有節點了。

而且 直接把 邊(帶權值)加入到 小頂堆(利用堆來自動排序),那么每次我們從 堆頂里 取出 邊 自然就是 距離源點最近的節點所在的邊。

這樣我們就不需要兩層for循環來尋找最近的節點了。

了解了大體思路,我們再來看代碼實現。

首先是 如何使用 鄰接表來表述圖結構,這是擺在很多錄友面前的第一個難題。

鄰接表用 數組+鏈表 來表示,代碼如下:(C++中 vector 為數組,list 為鏈表, 定義了 n+1 這么大的數組空間)

vector<list<int>> grid(n + 1);

不少錄友,不知道 如何定義的數據結構,怎么表示鄰接表的,我來給大家畫一個圖:

??

圖中鄰接表表示:

  • 節點1 指向 節點3 和 節點5
  • 節點2 指向 節點4、節點3、節點5
  • 節點3 指向 節點4
  • 節點4 指向 節點1

大家發現圖中的邊沒有權值,而本題中 我們的邊是有權值的,權值怎么表示?在哪里表示?

所以 在vector<list<int>> grid(n + 1);? 中 就不能使用int了,而是需要一個鍵值對 來存兩個數字,一個數表示節點,一個數表示 指向該節點的這條邊的權值。

那么 代碼可以改成這樣: (pair 為鍵值對,可以存放兩個int)

vector<list<pair<int,int>>> grid(n + 1);

舉例來給大家展示 該代碼表達的數據 如下:

??

  • 節點1 指向 節點3 權值為 1
  • 節點1 指向 節點5 權值為 2
  • 節點2 指向 節點4 權值為 7
  • 節點2 指向 節點3 權值為 6
  • 節點2 指向 節點5 權值為 3
  • 節點3 指向 節點4 權值為 3
  • 節點5 指向 節點1 權值為 10

這樣 我們就把圖中權值表示出來了。

但是在代碼中 使用 pair<int, int>? 很容易讓我們搞混了,第一個int 表示什么,第二個int表示什么,導致代碼可讀性很差,或者說別人看你的代碼看不懂。

那么 可以 定一個類 來取代 pair<int, int>?

類(或者說是結構體)定義如下:

struct Edge {
int to;  // 鄰接頂點
int val; // 邊的權重Edge(int t, int w): to(t), val(w) {}  // 構造函數
};

這個類里有兩個成員變量,有對應的命名,這樣不容易搞混 兩個int的含義。

所以 本題中鄰接表的定義如下:

struct Edge {
int to;  // 鏈接的節點
int val; // 邊的權重Edge(int t, int w): to(t), val(w) {}  // 構造函數
};vector<list<Edge>> grid(n + 1); // 鄰接表

(我們在下面的講解中會直接使用這個鄰接表的代碼表示方式)

#堆優化細節

其實思路依然是 dijkstra 三部曲:

  1. 第一步,選源點到哪個節點近且該節點未被訪問過
  2. 第二步,該最近節點被標記訪問過
  3. 第三步,更新非訪問節點到源點的距離(即更新minDist數組)

只不過之前是 通過遍歷節點來遍歷邊,通過兩層for循環來尋找距離源點最近節點。 這次我們直接遍歷邊,且通過堆來對邊進行排序,達到直接選擇距離源點最近節點。

先來看一下針對這三部曲,如果用 堆來優化。

那么三部曲中的第一步(選源點到哪個節點近且該節點未被訪問過),我們如何選?

我們要選擇距離源點近的節點(即:該邊的權值最小),所以 我們需要一個 小頂堆 來幫我們對邊的權值排序,每次從小頂堆堆頂 取邊就是權值最小的邊。

C++定義小頂堆,可以用優先級隊列實現,代碼如下:

// 小頂堆
class mycomparison {
public:
bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
return lhs.second > rhs.second;
}
};
// 優先隊列中存放 pair<節點編號,源點到該節點的權值> 
priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;

pair<int, int>?中 第二個int 為什么要存 源點到該節點的權值,因為 這個小頂堆需要按照權值來排序)

有了小頂堆自動對邊的權值排序,那我們只需要直接從 堆里取堆頂元素(小頂堆中,最小的權值在上面),就可以取到離源點最近的節點了 (未訪問過的節點,不會加到堆里進行排序)

所以三部曲中的第一步,我們不用 for循環去遍歷,直接取堆頂元素:

// pair<節點編號,源點到該節點的權值>
pair<int, int> cur = pq.top(); pq.pop();

第二步(該最近節點被標記訪問過) 這個就是將 節點做訪問標記,和 樸素dijkstra 一樣 ,代碼如下:

// 2. 第二步,該最近節點被標記訪問過
visited[cur.first] = true;

cur.first? 是指取 pair<int, int>? 里的第一個int,即節點編號 )

第三步(更新非訪問節點到源點的距離),這里的思路 也是 和樸素dijkstra一樣的。

但很多錄友對這里是最懵的,主要是因為兩點:

  • 沒有理解透徹 dijkstra 的思路
  • 沒有理解 鄰接表的表達方式

我們來回顧一下 樸素dijkstra 在這一步的代碼和思路(如果沒看過我講解的樸素版dijkstra,這里會看不懂)


// 3、第三步,更新非訪問節點到源點的距離(即更新minDist數組)
for (int v = 1; v <= n; v++) {
if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
minDist[v] = minDist[cur] + grid[cur][v];
}
}

其中 for循環是用來做什么的? 是為了 找到 節點cur 鏈接指向了哪些節點,因為使用鄰接矩陣的表達方式 所以把所有節點遍歷一遍。

而在鄰接表中,我們可以以相對高效的方式知道一個節點鏈接指向哪些節點。

再回顧一下鄰接表的構造(數組 + 鏈表):

??

假如 加入的cur 是節點 2, 那么 grid[2] 表示的就是圖中第二行鏈表。 (grid數組的構造我們在 上面 「圖的存儲」中講過)

所以在鄰接表中,我們要獲取 節點cur 鏈接指向哪些節點,就是遍歷 grid[cur節點編號] 這個鏈表。

這個遍歷方式,C++代碼如下:

for (Edge edge : grid[cur.first]) 

(如果不知道 Edge 是什么,看上面「圖的存儲」中鄰接表的講解)

?cur.first? 就是cur節點編號, 參考上面pair的定義: pair<節點編號,源點到該節點的權值>

接下來就是更新 非訪問節點到源點的距離,代碼實現和 樸素dijkstra 是一樣的,代碼如下:

// 3. 第三步,更新非訪問節點到源點的距離(即更新minDist數組)
for (Edge edge : grid[cur.first]) { // 遍歷 cur指向的節點,cur指向的節點為 edge
// cur指向的節點edge.to,這條邊的權值為 edge.val
if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
minDist[edge.to] = minDist[cur.first] + edge.val;
pq.push(pair<int, int>(edge.to, minDist[edge.to]));
}
}

但為什么思路一樣,有的錄友能寫出樸素dijkstra,但堆優化這里的邏輯就是寫不出來呢?

主要就是因為對鄰接表的表達方式不熟悉

以上代碼中,cur 鏈接指向的節點編號 為 edge.to, 這條邊的權值為 edge.val ,如果對這里模糊的就再回顧一下 Edge的定義:

struct Edge {
int to;  // 鄰接頂點
int val; // 邊的權重Edge(int t, int w): to(t), val(w) {}  // 構造函數
};

確定該節點沒有被訪問過,!visited[edge.to]? , 目前 源點到cur.first的最短距離(minDist) + cur.first 到 edge.to 的距離 (edge.val) 是否 小于 minDist已經記錄的 源點到 edge.to 的距離 (minDist[edge.to])

如果是的話,就開始更新操作。

即:

if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
minDist[edge.to] = minDist[cur.first] + edge.val;
pq.push(pair<int, int>(edge.to, minDist[edge.to])); // 由于cur節點的加入,而新鏈接的邊,加入到優先級隊里中
}

同時,由于cur節點的加入,源點又有可以新鏈接到的邊,將這些邊加入到優先級隊里中。

以上代碼思路 和 樸素版dijkstra 是一樣一樣的,主要區別是兩點:

  • 鄰接表的表示方式不同
  • 使用優先級隊列(小頂堆)來對新鏈接的邊排序

#代碼實現

堆優化dijkstra完整代碼如下:

#include <iostream>
#include <vector>
#include <list>
#include <queue>
#include <climits>
using namespace std; 
// 小頂堆
class mycomparison {
public:
bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
return lhs.second > rhs.second;
}
};
// 定義一個結構體來表示帶權重的邊
struct Edge {
int to;  // 鄰接頂點
int val; // 邊的權重Edge(int t, int w): to(t), val(w) {}  // 構造函數
};int main() {
int n, m, p1, p2, val;
cin >> n >> m;vector<list<Edge>> grid(n + 1);for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val; 
// p1 指向 p2,權值為 val
grid[p1].push_back(Edge(p2, val));}int start = 1;  // 起點
int end = n;    // 終點// 存儲從源點到每個節點的最短距離
std::vector<int> minDist(n + 1, INT_MAX);// 記錄頂點是否被訪問過
std::vector<bool> visited(n + 1, false); // 優先隊列中存放 pair<節點,源點到該節點的權值>
priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;// 初始化隊列,源點到源點的距離為0,所以初始為0
pq.push(pair<int, int>(start, 0)); minDist[start] = 0;  // 起始點到自身的距離為0while (!pq.empty()) {
// 1. 第一步,選源點到哪個節點近且該節點未被訪問過 (通過優先級隊列來實現)
// <節點, 源點到該節點的距離>
pair<int, int> cur = pq.top(); pq.pop();if (visited[cur.first]) continue;// 2. 第二步,該最近節點被標記訪問過
visited[cur.first] = true;// 3. 第三步,更新非訪問節點到源點的距離(即更新minDist數組)
for (Edge edge : grid[cur.first]) { // 遍歷 cur指向的節點,cur指向的節點為 edge
// cur指向的節點edge.to,這條邊的權值為 edge.val
if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
minDist[edge.to] = minDist[cur.first] + edge.val;
pq.push(pair<int, int>(edge.to, minDist[edge.to]));
}
}}if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到達終點
else cout << minDist[end] << endl; // 到達終點最短路徑
}
  • 時間復雜度:O(ElogE) E 為邊的數量
  • 空間復雜度:O(N + E) N 為節點的數量

堆優化的時間復雜度 只和邊的數量有關 和節點數無關,在 優先級隊列中 放的也是邊。

以上代碼中,while (!pq.empty())? 里套了 for (Edge edge : grid[cur.first])?

?for? 里 遍歷的是 當前節點 cur 所連接邊。

那 當前節點cur 所連接的邊 也是不固定的, 這就讓大家分不清,這時間復雜度究竟是多少?

其實 for (Edge edge : grid[cur.first])? 里最終的數據走向 是 給隊列里添加邊。

那么跳出局部代碼,整個隊列 一定是 所有邊添加了一次,同時也彈出了一次。

所以邊添加一次時間復雜度是 O(E), while (!pq.empty())? 里每次都要彈出一個邊來進行操作,在優先級隊列(小頂堆)中 彈出一個元素的時間復雜度是 O(logE) ,這是堆排序的時間復雜度。

(當然小頂堆里 是 添加元素的時候 排序,還是 取數元素的時候排序,這個無所謂,時間復雜度都是O(E),總之是一定要排序的,而小頂堆里也不會滯留元素,有多少元素添加 一定就有多少元素彈出)

所以 該算法整體時間復雜度為 O(ElogE)

網上的不少分析 會把 n (節點的數量)算進來,這個分析是有問題的,舉一個極端例子,在n 為 10000,且是有一條邊的 圖里,以上代碼,大家感覺執行了多少次?

?while (!pq.empty())? 中的 pq 存的是邊,其實只執行了一次。

所以該算法時間復雜度 和 節點沒有關系。

至于空間復雜度,鄰接表是 數組 + 鏈表 數組的空間 是 N ,有E條邊 就申請對應多少個鏈表節點,所以是 復雜度是 N + E

#拓展

當然也有錄友可能想 堆優化dijkstra 中 我為什么一定要用鄰接表呢,我就用鄰接矩陣 行不行 ?

也行的。

但 正是因為稀疏圖,所以我們使用堆優化的思路, 如果我們還用 鄰接矩陣 去表達這個圖的話,就是 一個高效的算法 使用了低效的數據結構,那么 整體算法效率 依然是低的

如果還不清楚為什么要使用 鄰接表,可以再看看上面 我在 「圖的存儲」標題下的講解。

這里我也給出 鄰接矩陣版本的堆優化dijkstra代碼:

#include <iostream>
#include <vector>
#include <list>
#include <climits>
using namespace std;
// 小頂堆
class mycomparison {
public:
bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
return lhs.second > rhs.second;
}
};int main() {
int n, m, p1, p2, val;
cin >> n >> m;vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
// p1 指向 p2,權值為 val
grid[p1][p2] = val;
}int start = 1;  // 起點
int end = n;    // 終點// 存儲從源點到每個節點的最短距離
std::vector<int> minDist(n + 1, INT_MAX);// 記錄頂點是否被訪問過
std::vector<bool> visited(n + 1, false);// 優先隊列中存放 pair<節點,源點到該節點的距離>
priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;// 初始化隊列,源點到源點的距離為0,所以初始為0
pq.push(pair<int, int>(start, 0));minDist[start] = 0;  // 起始點到自身的距離為0while (!pq.empty()) {
// <節點, 源點到該節點的距離>
// 1、選距離源點最近且未訪問過的節點
pair<int, int> cur = pq.top(); pq.pop();if (visited[cur.first]) continue;visited[cur.first] = true; // 2、標記該節點已被訪問// 3、第三步,更新非訪問節點到源點的距離(即更新minDist數組)
for (int j = 1; j <= n; j++) {
if (!visited[j] && grid[cur.first][j] != INT_MAX && (minDist[cur.first] + grid[cur.first][j] < minDist[j])) {
minDist[j] = minDist[cur.first] + grid[cur.first][j];
pq.push(pair<int, int>(j, minDist[j]));
}
}
}if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到達終點
else cout << minDist[end] << endl; // 到達終點最短路徑}
  • 時間復雜度:O(E * (N + logE)) E為邊的數量,N為節點數量
  • 空間復雜度:O(log(N^2))

?while (!pq.empty())? 時間復雜度為 E ,while 里面 每次取元素 時間復雜度 為 logE,和 一個for循環 時間復雜度 為 N 。

所以整體是 E * (N + logE)

#總結

在學習一種優化思路的時候,首先就要知道為什么要優化,遇到了什么問題。

正如我在開篇就給大家交代清楚 堆優化方式的背景。

堆優化的整體思路和 樸素版是大體一樣的,區別是 堆優化從邊的角度出發且利用堆來排序。

很多錄友別說寫堆優化 就是看 堆優化的代碼也看的很懵。

主要是因為兩點:

  • 不熟悉鄰接表的表達方式
  • 對dijkstra的實現思路還是不熟

這是我為什么 本篇花了大力氣來講解 圖的存儲,就是為了讓大家徹底理解鄰接表以及鄰接表的代碼寫法。

至于 dijkstra的實現思路 ,樸素版 和 堆優化版本 都是 按照 dijkstra 三部曲來的。

理解了三部曲,dijkstra 的思路就是清晰的。

針對鄰接表版本代碼 我做了詳細的 時間復雜度分析,也讓錄友們清楚,相對于 樸素版,時間都優化到哪了。

最后 我也給出了 鄰接矩陣的版本代碼,分析了這一版本的必要性以及時間復雜度。

至此通過 兩篇dijkstra的文章,終于把 dijkstra 講完了,如果大家對我講解里所涉及的內容都吃透的話,詳細對 dijkstra 算法也就理解到位了。

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:
http://www.pswp.cn/diannao/40692.shtml
繁體地址,請注明出處:http://hk.pswp.cn/diannao/40692.shtml
英文地址,請注明出處:http://en.pswp.cn/diannao/40692.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

5款軟件讓電腦更方便,更快,更好看

? 你有沒有想過&#xff0c;有些軟件能讓你的電腦用起來更方便&#xff0c;更快&#xff0c;更好看&#xff1f; 1. 屏幕動畫創作——Screen To Gif ? Screen To Gif是一款功能強大的屏幕錄制軟件&#xff0c;專注于將屏幕上的動態內容轉換為高質量的GIF動畫。它不僅支持自…

《ClipCap》論文筆記(下)

原文出處 [2111.09734] ClipCap: CLIP Prefix for Image Captioning (arxiv.org) 原文翻譯 接上篇 《ClipCap》論文筆記&#xff08;上&#xff09;-CSDN博客 4. Results Datasets.我們使用 COCO-captions [7,22]、nocaps [1] 和 Conceptual Captions [33] 數據集。我們根…

自動化設備上位機設計 一

目錄 一 設計原型 二 后臺代碼 一 設計原型 二 后臺代碼 namespace 自動化上位機設計 {public partial class Form1 : Form{public Form1(){InitializeComponent();}private void Form1_Load(object sender, EventArgs e){}} }namespace 自動化上位機設計 {partial class Fo…

Pyqt5中如何讓label里面的圖片進行更換,避免出現黑圖

在Pyqt5的界面開發過程中&#xff0c;發現一個label的圖片怎么都添加不上&#xff0c;而且出現黑色&#xff0c;主要原因就是在進行顯示的時候需要加一行清除的代碼&#xff1a; label.clear()如果不加這行代碼&#xff0c;當里面的圖片發生變化時&#xff0c;顯示出來的就是黑…

miniprogram-to-uniapp-微信小程序轉換成uniapp項目

文章目錄 參考:miniprogram-to-uniapp使用指南第一步第二步第三步第四步【miniprogram-to-uniapp】轉換微信小程序”項目為uni-app項目(新版本工具已經支持各種小程序轉換) 參考: 小程序技能樹 uni-app基礎知識總結 miniprogram-to-uniapp使用指南 第一步 win + R 輸入…

Openwrt路由器部分ipv6公網地址無法訪問的問題

路由器是Openwrt&#xff0c;終端訪問ipv6地址經常有些能訪問&#xff0c;有些不能訪問&#xff0c;一開始以為是運營商問題&#xff0c;后面ssh到openwrt發現所有訪問都正常。 查閱資料后才知道是MTU設置問題&#xff0c;Openwrt 默認MTU是1492&#xff0c;使用IPV6應減少60個…

微信小程序遮罩層顯示

效果展示&#xff1a; wxml頁面&#xff1a; <view classmodal-mask wx:if{{showModal}}><view class"modal-container"><view classmodal-content></view><view classmodal-footer bindtap"closeImage">//這個/images/ind…

Java 基礎查漏補缺

1.深入解讀&#xff1a;JDK與JRE的區別 JDK提供了完整的Java開發工具和資源&#xff0c;包括編譯器、調試器和其他開發工具&#xff0c;滿足開發人員的各種需求。 JRE則相對更為基礎&#xff0c;它只提供了Java程序運行所需的環境&#xff0c;包含了Java虛擬機&#xff08;JVM&…

數字類型<整數、復數>

Python 中&#xff0c;數字類型 Number&#xff0c; 包括整數 int、浮點 float 數和復數 complex 三個子類型。 用來表示程序中不同的數字類型的數據。 整數 整數類型&#xff1a;用來表示整數數值&#xff0c;即沒有小數部分的數值&#xff0c;在 Python 中&#xff0c;沒有…

Nettyの網絡聊天室擴展序列化算法

1、網絡聊天室綜合案例 客戶端初始代碼&#xff1a; Slf4j public class ChatClient {public static void main(String[] args) {NioEventLoopGroup group new NioEventLoopGroup();LoggingHandler LOGGING_HANDLER new LoggingHandler(LogLevel.DEBUG);MessageCodecSharabl…

使用c++函數式編程實現Qt信號槽機制

問題背景 在下面的代碼中&#xff0c;Input輸入器 輸入數據&#xff0c;希望A和B 接收數據。但使用的賦值&#xff0c;導致in.a和a只是拷貝數據&#xff0c;而不是同一個對象&#xff0c;使得數據不同步。 #include <iostream> struct A {int age 32; }; struct B {int …

searchForm自適應布局 + 按鈕插槽

收起 展開 代碼&#xff1a; useResizeObserverHooks.js import { useEffect, useLayoutEffect } from "react";export const useResizeObserver (containerDom, domClass, callback) > {useLayoutEffect(() > {let resizeObserver null;let dom null;if …

Qt Json詳細介紹

一.概念介紹 JSON&#xff08;JavaScript Object Notation&#xff09;是一種輕量級的數據交換格式&#xff0c;常用于前后端數據傳輸和存儲。它具有以下特點&#xff1a; 易讀性&#xff1a;JSON 使用人類可讀的文本格式表示數據&#xff0c;采用鍵值對的方式組織數據&#x…

eth0設備繁忙

當您遇到 ifconfig eth0 hw ether 20:24:07:04:18:00 命令執行后顯示 ifconfig: SIOCSIFHWADDR: Device or resource busy 錯誤時&#xff0c;這意味著您嘗試更改的網絡設備&#xff08;在這個例子中是 eth0&#xff09;目前正被占用&#xff0c;無法進行硬件地址的更改。 為了…

Map Set(Java篇詳解)

&#x1f341; 個人主頁&#xff1a;愛編程的Tom&#x1f4ab; 本篇博文收錄專欄&#xff1a;Java專欄&#x1f449; 目前其它專欄&#xff1a;c系列小游戲 c語言系列--萬物的開始_ 等 &#x1f389; 歡迎 &#x1f44d;點贊?評論?收藏&#x1f496;三連支持…

【每日一練】python列表

1、輸入一個整數列表&#xff0c;將列表中的元素按照逆序輸出。 list1[5,4,5,6] list1.reverse() print(list1)[6, 5, 4, 5]2、輸入一個字符串列表&#xff0c;輸出其中長度大于等于5的字符串&#xff0c;并且將它們轉換為大寫形式。 list1[hello,lol,ak47,aliang] for i in …

211.xv6——3(page tables)

在本實驗室中&#xff0c;您將探索頁表并對其進行修改&#xff0c;以簡化將數據從用戶空間復制到內核空間的函數。 開始編碼之前&#xff0c;請閱讀xv6手冊的第3章和相關文件&#xff1a; kernel/memlayout.h&#xff0c;它捕獲了內存的布局。kernel/vm.c&#xff0c;其中包含…

代謝組數據分析(十二):嶺回歸、Lasso回歸、彈性網絡回歸構建預測模型

歡迎大家關注全網生信學習者系列: WX公zhong號:生信學習者Xiao hong書:生信學習者知hu:生信學習者CDSN:生信學習者2介紹 在代謝物預測模型的構建中,我們采用了三種主流的回歸分析方法:嶺回歸、Lasso回歸以及彈性網絡回歸。這三種方法各有其獨特的原理和適用場景,因此在…

WPS操作技巧:制作可以打對勾的方框,只需簡單幾步!沈陽wps辦公軟件培訓

日常工作中&#xff0c;我們經常需要在表格中添加復選框&#xff0c;比如【性別選擇】、【任務完成狀態】等等&#xff0c;通過打對勾來確定狀態。今天就分別從WPS的Excel表格和Word文檔2種場景&#xff0c;介紹制作可以打對勾的復選框的方法技巧&#xff0c;掌握技巧&#xff…

25、PHP 實現兩個鏈表的第一個公共結點(含源碼)

題目&#xff1a; PHP 實現兩個鏈表的第一個公共結點 描述&#xff1a; 輸入兩個鏈表&#xff0c;找出它們的第一個公共結點。 <?php /*class ListNode{var $val;var $next NULL;function __construct($x){$this->val $x;} }*/ function FindFirstCommonNode($pHead…