Basic Graph
什麼是圖論?
千里之行始於足下
七橋問題:
如果上圖每一座橋都只能經過一次,
是否存在一種方法走遍這 7 座橋
圖的定義
- 一個圖 Graph 由
- 一些頂點 Vertices
- 連接兩頂點的邊 Edges
構成
-
邊可能有方向
-
如果所有邊都沒有標記方向
則稱之為 無向圖
-
如果有方向規定,則為有向圖
- 無向圖相當於所有邊都雙向通行的有向圖
-
如果所有邊都沒有標記方向
B
C
A
D
A
B
A
B
圖論:討論點邊關係的學問
圖的定義
- 一個圖 Graph 由
- 一些頂點 Vertices
- 連接兩頂點的邊 Edges
構成
- 邊可能有方向
- 如果所有邊都沒有標記方向
則稱之為 無向圖
- 如果有方向規定,則為有向圖
- 無向圖相當於所有邊都雙向通行的有向圖
- 如果所有邊都沒有標記方向
B
C
A
D
A
B
A
B
圖的定義
B
C
A
D
點的 度數 (degree)
一個點 \(v\) 被幾條邊連接
左圖中
\(\deg(A) = 3\)
\(\deg(B) = 5\)
圖的定義
B
C
A
D
點的 有向度數 (directed degree)
出度 Out degree 是一個點 \(v\) 伸出幾條邊
入度 In degree 是一個點 \(v\) 有多少邊指向他
A
B
\(\deg_{in}(A)=0\)
\(\deg_{out}(A)=1\)
\(\deg_{in}(B)=1\)
\(\deg_{out}(B)=0\)
一筆畫問題
B
C
A
D
給定義一張連通無向圖,從一個頂點開始行走,是否存在一種方法
使得每條邊只經過一次的狀況下,
經過每一條邊
A
B
C
D
E
連通:任兩點都能透過邊互相抵達
一筆畫問題
A
B
C
D
E
- 把頂點分成兩類:
- 起點 / 終點
- 其他
如果頂點 \(v_x\) 不是起點也不是終點
若要能一筆畫
那 \(\deg(v_x)\) 一定要是偶數 (why?)
\(\Rightarrow\) 最多只有兩個點的 degree 是奇數,分別為起點與終點
\(\Rightarrow\) 如果沒有頂點 degree 是奇數,每個點都能當起點 (起點與終點是同一個點)
note. 不存在 只有 \(1\) 個點的 degree 是奇數的圖 (why?)
練習
zerojudge
b924: kevin 愛畫畫
Hint
- 圖論題目,要檢查
- 邊有沒有方向
- 圖是不是連通的
極大部分的圖論題目輸入都長這樣
V E // 幾個點, 幾個邊
// 接下來有 E 行
v1 v2 // v1 v2 之間有邊
...
...
...
vn vm
輸入通常很大,要注意 IO 時間
#include <bits/stdc++.h>
using namespace std;
int deg[10001];
int main() {
int N, M;
scanf("%d%d", &N, &M);
while (M--) {
int a, b;
scanf("%d%d", &a, &b);
deg[a]++;
deg[b]++;
}
int odds = 0;
for(int i=1;i<=N;++i)
if (deg[i]%2==1)
odds ++;
if (odds > 2) cout <<"NO\n";
else cout <<"YES\n";
}
圖的走訪
連通性 (Connectivity)
- 一張圖中,如果一個點 \(a\) 可以透過一些邊走到 \(b\),就說
存在路徑 (walk) \(a\) 到 \(b\)
A
B
C
D
E
無向圖中,如果 \(a\) 能到 \(b\),那 \(b\) 也能到 \(a\)
有向圖則不一定
連通圖
- 如果一個無向圖任兩點之間都有路徑相連,那這一張圖就是連通圖
A
B
C
D
A
B
C
D
連通
不連通
連通圖判定
- 給定一個無向圖,請輸出他是不是連通圖
A
B
C
D
A
B
C
D
連通
不連通
連通的性質
對於一個無向圖
- 如果 \(a\) 與 \(b\) 連通,那 \(b\) 與 \(a\) 也連通
- 如果 \(a\) 與 \(b\) 連通, \(a\) 與 \(c\) 連通,那 \(b\) 與 \(c\) 也連通
A
B
C
D
連通
因此檢查一張圖是否連通
只需要選擇隨意的一個起點
看看能不能由起點走到其他所有的頂點就行了
圖的紀錄方法
- 為了讓演算法能夠方便運作,需要選擇適當的方法來記錄點與邊
- 常見有三種方法
- 鄰接矩陣
- 鄰接串列
- 邊陣列
最常使用的為第二種 - 鄰接串列
每種都有不同的使用情境,會在遇到時介紹
使用情境出現位置 : 鄰接矩陣 : 全點對最短路徑、圖論的數學計算 / 邊陣列 : 最小生成樹、網路流
鄰接串列
對於每一個點 \(v\),用一個 vector 紀錄他的鄰居有誰
A
B
C
D
vec[A] = {B, C}
vec[B] = {A, C}
vec[C] = {A, B, D}
vec[D] = {C}
vector : C++ 內建可以自動長大的陣列 @
vector<int> V[100];
// 輸入一條邊
cin >> s >> e;
// s 的鄰居們加入 e
V[s].empalce_back(e);
// 無向圖中要記得反過來的路也要蓋!
V[e].empalce_back(s);
圖的走訪 - 迴圈版
- 從一個起點出發,找到所有可以透過邊走到的所有點 !
Key point : 用一個陣列紀錄有哪一些點我們可以用
vector<int> todo; // 紀錄有那一些點等待我們去走
todo.emplace_back(S); // 一開始只知道起點 !
圖的走訪 - 迴圈版 (有bug)
vector<int> todo; // 紀錄有那一些點等待我們去走
todo.emplace_back(S); // 一開始只知道起點 !
while (!todo.empty()) // 如果還有點沒有去看他
{
int v = todo.back(); // 挑一個的資料出來
todo.pop_back(); // 拿出來的記得刪掉!
for(int u:V[v]) // 找 v 的所有鄰居
todo.emplace_back(u); // 放到清單中
}
透過一個點的鄰居,可以讓我們知道還有哪一些點沒有被走過!
A
B
C
A->B->C->A->B->C...
無窮迴圈 !?
圖的走訪 - 迴圈版
vector<int> todo;
vector<bool> used(V); // 紀錄一個點有沒有被走過
todo.emplace_back(S);
used[S] = true;
while (!todo.empty())
{
int v = todo.back();
todo.pop_back();
for(int u:V[v])
if (!used[u]) { // 如果 u 還沒看過
todo.emplace_back(u);
used[u] = true; // 紀錄為已看過
}
}
一個點只要被看過一次,要再用一個陣列紀錄有沒有看過
不然會有無窮迴圈的問題 !
A
B
C
D
起點 : A
todo = {A}
A
B
C
D
起點 : A
todo = {}
拿出 A
A
B
C
D
起點 : A
todo = {B,C}
拿出 A
把 A 的鄰居放到 todo
A
B
C
D
起點 : A
todo = {B}
拿出 A
把 A 的鄰居放到 todo
拿出 C
Question : 第三步可以改拿 B 嗎 ?
A
B
C
D
起點 : A
todo = {B,D}
拿出 A
把 A 的鄰居放到 todo
拿出 C
把 C 的鄰居放到 todo
A
B
C
D
起點 : A
todo = {B}
拿出 A
把 A 的鄰居放到 todo
拿出 C
把 C 的鄰居放到 todo
拿出 D
A
B
C
D
起點 : A
todo = {B}
拿出 A
把 A 的鄰居放到 todo
拿出 C
把 C 的鄰居放到 todo
拿出 D
把 D 的鄰居放到 todo
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居放到 todo
拿出 C
把 C 的鄰居放到 todo
拿出 D
把 D 的鄰居放到 todo
拿出 B
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居放到 todo
拿出 C
把 C 的鄰居放到 todo
拿出 D
把 D 的鄰居放到 todo
拿出 B
把 B 的鄰居放到 todo
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居放到 todo
拿出 C
把 C 的鄰居放到 todo
拿出 D
把 D 的鄰居放到 todo
拿出 B
把 B 的鄰居放到 todo
todo 沒東西了,演算法結束
我們找出了所有從起點 A 出發能到達的點!
練習題
- ZJ a290 新手訓練系列 ~ 圖論
#include <bits/stdc++.h>
using namespace std;
vector<int> V[801];
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int N, M;
while (~scanf("%d%d", &N, &M))
{
for(int i=1;i<=N;++i)
V[i].clear();
while (M--)
{
int a, b;
scanf("%d%d", &a, &b);
V[a].emplace_back(b);
}
int S, E;
scanf("%d%d", &S, &E);
vector<int> todo;
vector<int> used(N+1);
todo.emplace_back(S);
used[S] = true;
while (!todo.empty()) {
int v = todo.back();
todo.pop_back();
for(int u:V[v])
if (!used[u]) {
used[u] = true;
todo.emplace_back(u);
}
}
if (used[E]) cout << "Yes!!!\n";
else cout << "No!!!\n";
}
}
圖的走訪 - 遞迴版
透過遞迴不斷的往前行走,來找到所有可以到達的點 !
void dfs(int v) { // 現在走到 v 號點
used[v] = true; // 走過了一樣要標記!
}
圖的走訪 - 遞迴版
透過遞迴不斷的往前行走,來找到所有可以到達的點 !
void dfs(int v) { // 現在走到 v 號點
used[v] = true; // 走過了一樣要標記!
for (int u:V[v]) // 看 v 的所有鄰居
if (!used[u]) // 如果沒走過
dfs(u); // 去看看他
}
試著改寫前面的程式碼~
#include <bits/stdc++.h>
using namespace std;
vector<int> V[801];
bool used[801];
void dfs(int v) {
used[v] = true;
for (int u:V[v])
if (!used[u])
dfs(u);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int N, M;
while (~scanf("%d%d", &N, &M))
{
for(int i=1;i<=N;++i) {
V[i].clear();
used[i] = false;
}
while (M--)
{
int a, b;
scanf("%d%d", &a, &b);
V[a].emplace_back(b);
}
int S, E;
scanf("%d%d", &S, &E);
dfs(S);
if (used[E]) cout << "Yes!!!\n";
else cout << "No!!!\n";
}
}
迴圈 vs 遞迴
- 迴圈寫起來程式碼比較長
- 迴圈容易改寫成不同形式的搜尋
- 遞迴程式碼精簡
- 搜尋方法較單純
實務上兩種方法都會用到,都要學
練習題
UVa 10004 Bicoloring
給一張圖,問可不可以剛好用兩種顏色圖滿整張圖,使得相鄰不同色
網格圖的走訪技巧
練習題
ZJ b689: 2. 棕櫚迷宮
根據題目的變化,圖不一定要是普通的點線
也可能是用陣列 "畫出來" 的
練習題
ZJ b689: 2. 棕櫚迷宮
根據題目的變化,圖不一定要是普通的點線
也可能是用陣列 "畫出來" 的
網格圖
我們通常把這種座標轉換的圖稱之為網格圖
網格圖通常可以利用座標當作點的編號,來做圖論的處理
不需要轉換為一般圖的形式
網格圖走訪 - 迴圈
把之前得程式碼做一點修改 ! 使用座標當作點的編號
char map[50][50]; // 把地圖存在 char 陣列裡面
int H, W; // 地圖長寬
int x, y;
// x,y = 找到的入口座標
vector<tuple<int,int>> todo;
todo.emplace_back(x, y);
Tuple : 合併資料儲存 @
網格圖走訪 - 迴圈
一個座標 (x,y) 的鄰居有誰 ?
vector<tuple<int,int>> todo;
bool used[100][100]; // 直接用二維陣列紀錄有沒有用過
todo.emplace_back(x, y);
used[x][y] = true;
while (!todo.empty()) {
tie(x, y) = todo.back();
todo.pop_back();
// 找 (x, y) 的鄰居
}
(x+1,y) (x-1,y) (x,y+1) (x,y-1)
要怎麼快速地列出鄰居 ?
列舉 4 / 8 方向
- 把座標的差用陣列儲存起來
- 就能用迴圈枚舉了 !
int dx[] = {1,-1,0,0};
int dy[] = {0,0,1,-1};
{
for(int i=0;i<4;++i) {
int nx = x + dx[i];
int ny = y + dy[i];
}
}
(x+1,y) (x-1,y) (x,y+1) (x,y-1)
千萬不要幹把程式碼複製 * n 的行為
程式碼越多越容易錯!
int dx[] = {-1, 0, 1,
-1, 1,
-1, 0, 1};
int dy[] ={-1,-1,-1,
0, 0,
1, 1, 1};
while (!todo.empty()) {
tie(x, y) = todo.back();
todo.pop_back();
for (int i=0;i<4;++i) {
int nx = x+dx[i];
int ny = y+dy[i];
}
}
不是每一個格子上下左右都是鄰居!
有些是牆壁
有些超出地圖範圍
=> 判斷跳過
檢查是不是牆壁
檢查在不再地圖內
if (nx < 0 || H <= nx ||
ny < 0 || W <= ny)
if (mp[nx][ny] == '#')
哪一個要先判斷 ?
還是都可以 ?
陣列 :
永遠先檢查
要放到中括號裡的資料
檢查是不是牆壁
檢查在不在地圖內
for (int i=0;i<4;++i) {
int nx = x+dx[i];
int ny = y+dy[i];
if (nx < 0 || H <= nx ||
ny < 0 || W <= ny)
/* 這裡放什麼 */
if (mp[nx][ny] == '#')
/* 這裡放什麼 */
}
if 的下面要放什麼 ?
提示 : 如果座標錯了,"跳過" 他
(A). break
(B). continue
(C). return
while (!todo.empty()) {
tie(x, y) = todo.back();
todo.pop_back();
for (int i=0;i<4;++i) {
int nx = x+dx[i];
int ny = y+dy[i];
if (nx < 0 || H <= nx ||
ny < 0 || W <= ny)
continue;
if (mp[nx][ny] == '#')
continue;
if (!used[nx][ny]) {
todo.emplace_back(nx, ny);
used[nx][ny] = true;
}
}
}
// x,y 永遠記錄著最後從 todo 拿出的資料
// 題目座標從 1 開始的
cout << x+1 << ' ' << y+1 << '\n';
幾乎快完成了 !
題目要求 "最深" 的點
那大概就是最後放到 todo 的點吧
7 9
#########
##.....##
##.###.##
##.#...##
##.#.##..
#..#....#
#########
7 9
######.##
##.....##
##.####.#
##.#....#
##.#.##.#
#..#....#
#########
10 10
##########
#....#####
#.##.....#
#...####.#
#######..#
##....#.##
##.##.#..#
##.##.##.#
##..#....#
###.######
Testcases
幫你打好了
#include <bits/stdc++.h>
using namespace std;
int H, W;
char mp[50][50];
bool used[50][50];
tuple<int, int> findEntry(int h, int w) {
for (int i=0;i<h;++i) {
if (mp[i][0] == '.') return make_tuple(i,0);
if (mp[i][w-1] == '.') return make_tuple(i,w-1);
}
for (int i=0;i<w;++i) {
if (mp[0][i] == '.') return make_tuple(0,i);
if (mp[h-1][i] == '.') return make_tuple(h-1, i);
}
assert(false && "testcase error!");
}
int dx[] = {1,-1,0,0};
int dy[] = {0,0,1,-1};
int main()
{
cin >> H >> W;
for(int i=0;i<H;++i)
cin >> mp[i];
int x, y;
tie(x, y) = findEntry(H, W);
vector<tuple<int,int>> todo;
todo.emplace_back(x, y);
used[x][y] = true;
while (!todo.empty()) {
tie(x, y) = todo.back();
todo.pop_back();
for (int i=0;i<4;++i) {
int nx = x+dx[i];
int ny = y+dy[i];
if (nx < 0 || H <= nx ||
ny < 0 || W <= ny)
continue;
if (mp[nx][ny] == '#')
continue;
if (!used[nx][ny]) {
todo.emplace_back(nx, ny);
used[nx][ny] = true;
}
}
}
cout << x+1 << ' ' << y+1 << '\n';
}
試著改成遞迴版 !
- 跟迴圈幾乎一樣,不過沒有使用到 tuple
- 對於 tuple / vector 不熟的話,遞迴寫法較簡單
int ax, ay;
void dfs(int x, int y) {
used[x][y] = true;
// 暫存答案
ax = x;
ay = y;
for (int i=0;i<4;++i) {
int nx = x+dx[i];
int ny = y+dy[i];
if (nx < 0 || H <= nx ||
ny < 0 || W <= ny)
continue;
if (mp[nx][ny] == '#')
continue;
if (!used[nx][ny])
dfs(nx, ny);
}
}
#include <bits/stdc++.h>
using namespace std;
int H, W;
char mp[50][50];
bool used[50][50];
tuple<int, int> findEntry(int h, int w) {
for (int i=0;i<h;++i) {
if (mp[i][0] == '.') return make_tuple(i,0);
if (mp[i][w-1] == '.') return make_tuple(i,w-1);
}
for (int i=0;i<w;++i) {
if (mp[0][i] == '.') return make_tuple(0,i);
if (mp[h-1][i] == '.') return make_tuple(h-1, i);
}
assert(false && "testcase error!");
}
int dx[] = {1,-1,0,0};
int dy[] = {0,0,1,-1};
int ax, ay;
void dfs(int x, int y) {
used[x][y] = true;
// 暫存答案
ax = x;
ay = y;
for (int i=0;i<4;++i) {
int nx = x+dx[i];
int ny = y+dy[i];
if (nx < 0 || H <= nx ||
ny < 0 || W <= ny)
continue;
if (mp[nx][ny] == '#')
continue;
if (!used[nx][ny])
dfs(nx, ny);
}
}
int main()
{
cin >> H >> W;
for(int i=0;i<H;++i)
cin >> mp[i];
int x, y;
tie(x, y) = findEntry(H, W);
dfs(x, y);
cout << ax+1 << ' ' << ay+1 << '\n';
}
深度優先搜尋
Depth-First-Search
總是拿 "最新發現的鄰居" 來考慮
是一切搜尋最基本的方法
void dfs(int v) {
used[v] = true;
for (int u: V[v]) {
if (!used[u]) {
// 一發現新鄰居,立刻走上去
dfs(u);
}
}
}
while (!todo.empty()) {
v = todo.back(); // 拿最後的東西
for (int u: V[v]) {
if (!used[u]) {
// 一發現新鄰居,放到 todo 最後面
dfs(u);
used[u] = true;
}
}
}
額外練習
c129: 00572 - Oil Deposits (簡單網格圖)
d768: 10004 - Bicoloring (hint: 邊走路邊圖顏色)
b517 : 是否為樹-商競103 (hint : 一個圖為樹 => 連通 且 沒有環)
A
B
C
D
樹
A
B
C
D
不是樹
(ABC形成循環)
A
B
C
D
不是樹
(不連通)
b517 參考輸入範例
這一題雖然簡單,但細節有點多
輸入也對初學者不太友善
可以複製左邊程式碼來繼續寫
程式碼已經寫到把圖建立好的程度了
#include <bits/stdc++.h>
using namespace std;
vector<int> V[81];
int used[81];
bool appeared[81];
int main() {
int T;
cin >> T; cin.get();
while (T--) {
string buf;
getline(cin, buf);
// 初始化放這裡
memset(used, 0, sizeof(used));
memset(appeared, 0, sizeof(appeared));
for(int i=0;i<81;++i) V[i].clear();
int x, y;
stringstream ss(buf);
while (ss >> buf) {
sscanf(buf.c_str(), "%d,%d", &x, &y);
// 讀取一條邊 x<=>y
appeared[x] = appeared[y] = true;
V[x].emplace_back(y);
V[y].emplace_back(x);
}
// 繼續做你想做的事情
}
}
邊最短距離
邊最短距離
- DFS 是依照有路就先走的方法來找到資料的
- 但有的時候,我們會希望按照與起點距離,近到遠找資料
- 能求出每一個點距離起點幾條邊!
A
B
C
D
距離 0 : A
距離 1 : B, C
距離 2 : D
邊最短距離
- 之前的方法,我們總是拿最新發現的資料,這樣會讓搜尋的路優先往遠的方向走
- 其實,只要把原來的方法,改成用 "最舊" 的資料,就能由近到遠搜尋了
// 要把 vector<> todo 改成 deque<> todo
// deque 才能操作 front
while (!todo.empty()) {
v = todo.front(); // 拿前面的東西
todo.pop_front();
for (int u: V[v]) {
if (!used[u]) {
// 一發現新鄰居,放到 todo 最後面
todo.emplace_back(u);
used[u] = true;
}
}
}
A
B
C
D
起點 : A
todo = {A}
0
A
B
C
D
起點 : A
todo = {}
拿出 A
0
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居距離設定為自己+1
0
1
1
A
B
C
D
起點 : A
todo = {B,C}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
0
1
1
A
B
C
D
起點 : A
todo = {C}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
0
1
1
A
B
C
D
起點 : A
todo = {C}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
把 B 的鄰居距離設定為自己+1
0
1
1
A
B
C
D
起點 : A
todo = {C}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
把 B 的鄰居距離設定為自己+1
把 B 的鄰居放入 todo
0
1
1
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
把 B 的鄰居距離設定為自己+1
把 B 的鄰居放入 todo
拿出 C
0
1
1
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
把 B 的鄰居距離設定為自己+1
把 B 的鄰居放入 todo
拿出 C
把 C 的鄰居距離設定為自己+1
0
1
1
2
A
B
C
D
起點 : A
todo = {D}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
把 B 的鄰居距離設定為自己+1
把 B 的鄰居放入 todo
拿出 C
把 C 的鄰居距離設定為自己+1
把 C 的鄰居放入 todo
0
1
1
2
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
把 B 的鄰居距離設定為自己+1
把 B 的鄰居放入 todo
拿出 C
把 C 的鄰居距離設定為自己+1
把 C 的鄰居放入 todo
拿出 D
0
1
1
2
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
把 B 的鄰居距離設定為自己+1
把 B 的鄰居放入 todo
拿出 C
把 C 的鄰居距離設定為自己+1
把 C 的鄰居放入 todo
拿出 D
把 D 的鄰居距離設定為自己+1
0
1
1
2
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
把 B 的鄰居距離設定為自己+1
把 B 的鄰居放入 todo
拿出 C
把 C 的鄰居距離設定為自己+1
把 C 的鄰居放入 todo
拿出 D
把 D 的鄰居距離設定為自己+1
把 D 的鄰居放入 todo
0
1
1
2
A
B
C
D
起點 : A
todo = {}
拿出 A
把 A 的鄰居距離設定為自己+1
把 A 的鄰居放入 todo
拿出 B
把 B 的鄰居距離設定為自己+1
把 B 的鄰居放入 todo
拿出 C
把 C 的鄰居距離設定為自己+1
把 C 的鄰居放入 todo
拿出 D
把 D 的鄰居距離設定為自己+1
把 D 的鄰居放入 todo
0
1
1
2
todo 沒有資料了
搜尋完成
把每一個連通的點都標上了與起點間的距離!
// 要把 vector<> todo 改成 deque<> todo
// deque 才能操作 front
dist[s] = 0; // 起點距離為 0
while (!todo.empty()) {
v = todo.front(); // 拿前面的東西
todo.pop_front();
for (int u: V[v]) {
if (!used[u]) {
// 一發現新鄰居,放到 todo 最後面
todo.emplace_back(u);
dist[u] = v + 1; // 鄰居距離 = 自己 + 1
used[u] = true;
}
}
}
廣度優先搜尋
按照與起點的距離由近到遠搜尋資料!
可以算出每一個點與起點的距離!
練習題
- ZJ d406: 倒水時間
進階練習
- e585: 12797 - Letters (Hint : 暴力枚舉字母排列)
- ZJ e699: 11624 - Fire! (Hint : 多次的 BFS)
- TIOJ 1008 . 量杯問題 (遊戲的最快獲勝法,實作較難)
有向無環圖
Directed Acyclic Graph
有向無環圖
顧名思義,一種有向圖,而且沒有環
這種圖是圖論的特例,在有向無環圖上,問題通常會比較容易解決
拓譜排序
把一個頂點當作一個工作,一份工作要開始之前,他的前置作業必須完成
是否能給出一個順序,使得每個工作開始前,他的前置作業都完成了
如果有一條路 \(a\) 到 \(b\) ,那 \(a\) 就要排在 \(b\) 前面
拓譜排序
拓譜排序的概念與前面的走訪類似
e
我們只在一個點,他的前置工作都完成時,
才把這一個點放到 todo 中
左圖中,如果要把 e 放到 todo 中
那就要先把 a,c,d 放到 todo
為什麼不用考慮 b ?
拓譜排序
e
如何算出每一個點的前置工作有幾個 ?
左圖中,如果要把 e 放到 todo 中
那就要先把 a,c,d 放到 todo
前置工作的數量,就是有幾根箭頭指向自己
就是入度 ( In degree )
a | b | c | d | e | |
---|---|---|---|---|---|
0 | 1 | 1 | 3 | 3 |
拓譜排序
e
如何算出每一個點的前置工作有幾個 ?
前置工作的數量,就是有幾根箭頭指向自己
就是入度 ( In degree )
a | b | c | d | e | |
---|---|---|---|---|---|
tasks | 0 | 1 | 1 | 3 | 3 |
如果一個點一開始就沒有前置作業 (a)
就把他丟到 todo 中!
while (M--) {
int a, b;
cin >> a >> b; // 有向邊 a->b
V[a].emplace_back(b);
tasks[b]++;
}
vector<int> todo;
for(int i=0;i<N;++i)
if (tasks[i]==0)
todo.emplace_back(i);
拓譜排序
e
todo 每次拿出來的一個點
他的鄰居能直接放到 todo 嗎 ?
要檢查這個鄰居,他的前置作業都完成了,
才能放入
如何設計一個簡單快速的檢查方法呢?
每拿出一個點,就把他的鄰居 tasks - 1
表示完成了一個工作
如果鄰居變成 0 ,表示完成所有工作 !
vector<int> solution;
while (!todo.empty()) {
int v = todo.back(); // 任意拿一個出來
todo.pop_back();
solution.emplace_back(v);
for (int u:V[v])
{
tasks[u] = tasks[u] - 1;
if (tasks[u] == 0)
todo.emplace_back(u);
}
}
todo 拿出來的資料,依序就是拓譜排序的結果
可以用另一個陣列存起來,或是做題目要求的事情
e
步驟1. 計算所有點的 tasks
a | b | c | d | e |
---|---|---|---|---|
0 | 1 | 1 | 3 | 3 |
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 1 | 1 | 3 | 3 |
a |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
a | b | c | d | e |
---|---|---|---|---|
0 | 1 | 1 | 3 | 3 |
a |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
a | b | c | d | e |
---|---|---|---|---|
0 | 1 | 1 | 3 | 3 |
a |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 2 | 2 |
a |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 2 | 2 |
b | c |
---|
a |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 2 | 2 |
c |
---|
a | b |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 1 | 2 |
c |
---|
a | b |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 1 | 2 |
c |
---|
a | b |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 1 | 2 |
a | b | c |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 0 | 1 |
a | b | c |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 0 | 1 |
d |
---|
a | b | c |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 0 | 1 |
a | b | c | d |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
a | b | c | d |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
e |
---|
a | b | c | d |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
a | b | c | d | e |
---|
Kahn's algorithm
e
步驟1. 計算所有點的 tasks
步驟2. 把 tasks 為 0 的點放入 todo
while todo 非空
從 todo 拿出一個點 p
檢查 p 的所有鄰居 v
將 v 的 tasks - 1
如果 v 的 tasks 變成 0
就把 v 放到 todo
a | b | c | d | e |
---|
拓譜排序的答案不為一
可能有很多種結果,都滿足拓譜排序
a | c | b | d | e |
---|
Kahn's algorithm
e
如果演算法結束後,如果有點沒走過
代表 圖不是有向無環圖
拓譜排序
A
B
C
D
練習題
ZJ f167: m4a1-社團 Club
#include <bits/stdc++.h>
using namespace std;
vector<int> V[1001];
int main() {
int N, M;
cin >> N >> M;
vector<int> tasks(N+1);
while (M--) {
int a, b;
cin >> a >> b; // 有向邊 a->b
V[a].emplace_back(b);
tasks[b]++;
}
vector<int> todo;
for(int i=1;i<=N;++i)
if (tasks[i]==0)
todo.emplace_back(i);
vector<int> solution;
while (!todo.empty()) {
int v = todo.back(); // 任意拿一個出來
todo.pop_back();
solution.emplace_back(v);
for (int u:V[v])
{
tasks[u] = tasks[u] - 1;
if (tasks[u] == 0)
todo.emplace_back(u);
}
}
if (solution.size() == N) {
cout << "YES\n";
for (int i:solution)
cout << i << '\n';
} else {
cout << "NO\n";
}
}
e
除此方法之外
也能用一次 DFS 找出拓譜序 (的逆序)
方法也十分簡單
拓譜排序
// 如果圖不是DAG,直接這樣用會爛掉
void dfs(int v){
used[v] = true;
for (int u:V[v])
if (!used[u])
dfs(u);
solution.emplace_back(v);
}
Q. 上面的程式碼怎麼判斷是不是 DAG ?
bool OnStack[2000];
bool used[2000];
void dfs(int v){
OnStack[v] = true;
used[v] = true;
for (int u:V[v]) {
if (!used[v]) // 沒看過
dfs(u);
else if (OnStack[u])
return ; // 不是 dag!
}
OnStack[v] = false;
solution.emplace_back(v);
}
記得這個 solution 與前面演算法的剛好前後顛倒阿
進階練習
2021 全國賽 D. 汽車不再繞圈圈 (car)
ZJ a454: TOI2010 第二題:專案時程
TIOJ 1226. H遊戲
TIOJ 1092. 跳格子遊戲
2021 全國賽 D. 汽車不再繞圈圈
- 給一張有向圖,每個邊都有權重
- 請試圖反轉某些邊,使得圖沒有環
- 問要反轉的最大權重至少要是多少
跳格子遊戲 TIOJ 1092
- 有兩個人 Alice, Bob 在一張 DAG 上玩遊戲
- 一開始 Alice 站在起點,接著 Bob 選擇一個與起點相鄰的點,然後再換 Alice 選下一個相鄰點,如此交替著,直到有人到達終點為止。
- 假設一定有路到達終點,如果兩人都用最佳策略玩遊戲,請問誰必勝?
跳格子遊戲 TIOJ 1092
跳格子遊戲 TIOJ 1092
KEY : 如果目前這一步是 "先手必勝"
表示不論如何移動,都會使得 "先手必敗"
因為沒則選擇,只能選擇移動到 "先手必敗" 的狀況 (下一回合先後手交換GG)
Note : 這一個題目是 "後手做移動",如果是 "先手做移動" 結論會有點不一'樣
跳格子遊戲 TIOJ 1092
KEY : 如果目前這一步是 "先手必勝"
表示不論如何移動,都會使得 "先手必敗"
跳格子遊戲 TIOJ 1092
KEY : 如果目前這一步是 "先手必勝"
表示不論如何移動,都會使得 "先手必敗"
如果只有終點 : 先手必勝
跳格子遊戲 TIOJ 1092
KEY : 如果目前這一步是 "先手必勝"
表示不論如何移動,都會使得 "先手必敗"
只有通向必勝的路,因此必敗
跳格子遊戲 TIOJ 1092
KEY : 如果目前這一步是 "先手必勝"
表示不論如何移動,都會使得 "先手必敗"
終點前兩步 : 有路通向必敗,因此必勝
跳格子遊戲 TIOJ 1092
KEY : 如果目前這一步是 "先手必勝"
表示不論如何移動,都會使得 "先手必敗"
以此推出所有點是必勝還是必敗
跳格子遊戲 TIOJ 1092
按照拓譜排序的逆序進行
單源點最短路徑
Single Source Shortest Path
單源點最短路徑
- 在前兩章,示範了 "邊最短路徑" ,解決網格圖、最少方法數的問題
- 但現實上,我們通常不會把 "邊" 都當成是一樣長的,不同的邊會有不同的邊長
- 本章要來講解 當 "邊" 不一樣長時的最短路徑解法。
A
B
C
5
10
邊權的儲存
- 在前幾章的資料結構中,都只記錄鄰居有誰
- 本章開始會有邊權,實作通常以 struct 把鄰居的編號以及邊長包裹紀錄
vector<tuple<int,int>> V; // (neighbor, length)
int s, t, w;
cin >> s >> t >> w;
V[s].emplace_back(t, w);
// V[t].emplace_back(s, w); // 雙向邊
for(auto [u, w] : V[v]) {
// do something
}
從邊最短路徑 到 有權重最短路徑
- 我們已經知道了邊最短路徑是離起點近到遠的存取
- 我們假設邊長是整數,那我們可以把邊 "切割" 程單位長度
- 那麼我們就能用 "BFS" 解出有權重的最短路徑長度 !
A
B
C
3
2
A
B
C
1
1
1
1
1
從邊最短路徑 到 有權重最短路徑
- 這種 "切割" 的做法,遇到很長的邊效率很糟糕
- 而且我們根本不在意中間經過的點 !
- 因此,我們要怎麼樣很快地知道 BFS 下一個會遇到的頂點是哪一個 ?
A
C
1
1
1
1
1
2
B
1
1
1
1
1
1
頂點 A :
現在 B 距離我 9
現在 C 距離我 2
從邊最短路徑 到 有權重最短路徑
- 從已遍歷的點中,找 從 "起點" 到 "自己" 再到 "鄰居"的方法中,答案最小的,就是下一個 BFS遇到的點 !
A
C
1
1
1
1
1
2
B
1
1
1
1
1
1
頂點 A :
現在 B 距離我 9
現在 C 距離我 2
<= 下一個遇到的是 C
從一群東西找最小 : Priority Queue !
實作要點
- 使用一個陣列紀錄答案是否被求出
- 另外使用一個陣列紀錄答案。預設初始化為 無限大
- 由於計算過程中可能會把兩個無限大相加,無限大要比最大值一半還要小
- 按實作方法、演算法而定,但建議都要這樣做
int Dijkstra(int s, int e, int N) {
const int INF = INT_MAX / 2;
vector<int> dist(N, INF);
vector<bool> used(N, false);
dist[s] = 0;
}
實作要點
- 另一種使用 memset 設定無限大的做法
-
0x3f3f 大概比最大正整數 0x7fff 一半 0x3fff 小一點
int dist[2000];
int Dijkstra(int s, int e, int N) {
memset(dist, 0x3f, sizeof(dist));
const int INF = dist[0];//0x3f3f3f3f
dist[s] = 0;
}
也可以用 for loop / fill / fill_n 之類的來設定陣列數值
Priority_Queue 拿最小
- C++ 預設的 priority queue 拿的是最大值,若要最大需要改寫 !
方法 1. 改符號方向
C++ 預設用 小於 (less) 比較,我們換成 大於(greater)
using T = tuple<int,int>;
priority_queue<T, vector<T>, greater<T>> pq;
Priority_Queue 拿最小
- C++ 預設的 priority queue 拿的是最大值,若要最大需要改寫 !
方法 2. 自訂比較函數 (函數物件)
struct cmp {
bool operator()(int a, int b) {
return a > b;
}
};
priority_queue<T, vector<T>, cmp> pq;
Priority_Queue 拿最小
- C++ 預設的 priority queue 拿的是最大值,若要最大需要改寫 !
方法 3. 自訂比較函數 (一般函數)
個人較不建議這樣寫
bool cmp(int a, int b) {
return a > b;
}
priority_queue<T, vector<T>, decltype(&cmp)> pq(cmp);
// priority_queue<T, vector<T>, function<bool(int, int)>> pq(cmp);
int Dijkstra(int s, int e, int N) {
const int INF = INT_MAX / 2;
std::vector<int> dist(N, INF);
using T = tuple<int,int>;
priority_queue<T, vector<T>, greater<T>> pq;
dist[s] = 0;
pq.emplace(0, s); // (w, e) 讓 pq 優先用 w 來比較
}
實作細節
- 其餘部分與 BFS 差不多,我們對於更好的答案,直接放到 priority queue 裡面
- 因此一個點可能會出現很多次,我們只關心每一個點 "第一次" 出現的時候。
while (!pq.empty()) {
tie(std::ignore, s) = pq.top();
pq.pop();
if ( used[s] ) continue;
used[s] = true; // 每一個點都只看一次
for (auto [e, w] : V[s]) {
if (dist[e] > dist[s] + w) {
dist[e] = dist[s] + w;
pq.emplace(dist[e], e);
}
}
}
存在更好的實作,可以更新 heap 裡的資料,而不是塞垃圾到 heap 裡面,然後再篩選資料
int Dijkstra(int s, int e, int N) {
const int INF = INT_MAX / 2;
vector<int> dist(N, INF);
vector<bool> used(N, false);
using T = tuple<int,int>;
priority_queue<T, vector<T>, greater<T>> pq;
dist[s] = 0;
pq.emplace(0, s); // (w, e) 讓 pq 優先用 w 來比較
while (!pq.empty()) {
tie(std::ignore, s) = pq.top();
pq.pop();
if ( used[s] ) continue;
used[s] = true; // 每一個點都只看一次
for (auto [e, w] : V[s]) {
if (dist[e] > dist[s] + w) {
dist[e] = dist[s] + w;
pq.emplace(dist[e], e);
}
}
}
return dist[e];
}
Dijkstra 演算法
- 經典的圖論演算法,該演算法假設所有的邊都大於等於 0 ,以便把模型轉程 BFS 來解決
- 由於前述限制,如果邊長有負數,此方法無法得出正確答案
A
B
C
2
3
-6
A 到 B 的最短路徑是 A->C->B ,答案為 -3 ,但是 dijkstra 會把最先出現的 A->B = 2 當作答案。
時間複雜度
- Dijkstra 的複雜度與找最大值的方法有關
大致等於:
找最大值的複雜度 x 次數 + 把資料放入資料結構複雜度 x 次數
- 範例實作使用內建的 priority queue
- 每一條邊都可能進入 pq ,複雜度為 \(O(E\log E)\)
- 每一條邊都可能進入 pq ,複雜度為 \(O(E\log E)\)
- 實作可更新資料的 priority queue
- 最多只有 \(V\) 個點在 pq ,複雜度為 \(O(E\log V)\)
- 最多只有 \(V\) 個點在 pq ,複雜度為 \(O(E\log V)\)
- 最低可到 \(O(E+V\log V)\) 比賽用不到,常數過大
- fibonacci heap : 更新平攤 \(O(1)\)
找最大值 | 次數 | 更新資料 | 次數 | 總複雜度 | |
---|---|---|---|---|---|
內建的 priority queue | 1 | E | log E | E | E log E |
用陣列暴力做 | V | V | 1 | E | V^2+E |
實作可更新資料的 priority queue | 1 | E | log V | E | E log V |
Fibonacci Heap | log V | V | 1 | E | E+Vlog V |
練習題
- uva 10986 Sending email
負邊處理
- 由於 dijkstra 的模型缺陷,我們無法用它來處理有負邊的邊長
- 因此我們要來介紹另一個能夠處理負邊的演算法
負環偵測
- 如果一個圖存在一個環,其路徑總和為負數,便會讓部分答案變成無限小
- 目前已知 在有負環的圖 找最短路徑,是 NP-Hard 的問題,一般演算法無法正常處理,因此要在發生負環錯誤時,要能偵測出該錯誤
A
B
C
-5
-3
-6
D
8
Relax
- 定義 \(d(x)\) 表示當前已知由起點到 \(x\) 的最短路徑
對於每一條邊 \(s\stackrel{w}\to e\) ,如果有
$$d(e) > d(s) + w$$
那我們就需要 relax
也就是把 \(d(e)\) 更新成更好的答案 \(d(s)+w\)
Bellman Ford Algorithm
- 如果不存在任意一條邊要 relax ,那 \(d(x)\) 收斂於最短路徑
- 對每條邊拼命 relax 到不能為止 !
- 對每條邊拼命 relax 到不能為止 !
- 對於要 "看過每一條邊" 的演算法,使用 "邊陣列" 來儲存比較方便
vector<tuple<int,int,int>> Edges; // (s, t, w)
Bellman Ford
- 非常簡單暴力的演算法
vector<tuple<int,int,int>> Edges;
int BellmanFord(int s, int e, int N) {
const int INF = INT_MAX / 2;
vector<int> dist(N, INF);
dist[s] = 0;
bool update;
while (true) {
update = false;
for(auto [v, u, w] : Edges)
{
if (dist[u] > dist[v] + w)
{
dist[u] = dist[v] + w;
update = true;
}
}
if (!update)
break;
}
return dist[e];
}
時間複雜度
- 執行 \(n\) 次的 Bellman Ford 可以求出經過邊數小於等於 \(n\) 的最短路徑
- 如果圖沒有負環,最短路徑最多只會用 \(V-1\) 條邊,因此複雜度為 \(O(VE)\)
- 如果迴圈執行到第 \(V\) 次仍有 update ,表示有使用超過 \(V-1\) 條邊的最短路,也就是有負環,可以用此來判斷 !
- 如果不判斷會進入無窮迴圈 ! 除非題目保證沒有負環,不然要記得判斷
- 如果迴圈執行到第 \(V\) 次仍有 update ,表示有使用超過 \(V-1\) 條邊的最短路,也就是有負環,可以用此來判斷 !
vector<tuple<int,int,int>> Edges;
int BellmanFord(int s, int e, int N) {
const int INF = INT_MAX / 2;
vector<int> dist(N, INF);
dist[s] = 0;
bool update;
for(int i=1;i<=N;++i) {
update = false;
for(auto [v, u, w] : Edges)
{
if (dist[u] > dist[v] + w)
{
dist[u] = dist[v] + w;
update = true;
}
}
if (!update)
break;
if (i == N) // && update
return -1; // gg !
}
return dist[e];
}
練習題
- uva 558 (負環!)
All pair shortest path
- Bellman Ford 的概念可以加以延伸,用來快速求任兩點間的最短路
- 設 \(d(a,b)\) 表示 \(a\) 到 \(b\) 已知的最短距離
對於任三點 \(a, b, c\) ,如果有
$$d(a, c) > d(a,b) + d(b,c)$$
那我們就需要 relax
也就是把 \(d(a,c)\) 更新程更好的答案 \(d(a,b)+d(b,c)\)
Floyd warshall
- 使用鄰接矩陣儲存任兩點間距離,然後瘋狂 relax
- 一開始 \(d[i][j]\) 表示 \(i\) 到 \(j\) 最短的邊長距離
- 小心重複的邊 !
- \(i\) 到 \(j\) 沒有邊的話設無限大
- 自己到自己 \(d[i][i] = 0\)
- 一開始 \(d[i][j]\) 表示 \(i\) 到 \(j\) 最短的邊長距離
int d[MAXN][MAXN];
Floyd warshall
- 實作極簡單 \(O(V^3)\)
- 裡面兩個迴圈 \(i,j\) 是枚舉所有邊
- 最外面 \(k\) 是枚舉中間點
int d[100][100];
void FloydWarshall(int N){
for(int k=0;k<N;++k)
for(int i=0;i<N;++i)
for(int j=0;j<N;++j)
if(d[i][j] > d[i][k] + d[k][j])
d[i][j] = d[i][k] + d[k][j];
}
練習題
UVa 11463
整理
功能 | 複雜度 | 缺點 | |
---|---|---|---|
Dijkstra | 求單一起點最短路 | O(E log E) | 不能有負邊 |
BellmanFord | 求單一起點最短路 負邊處理 |
O(EV) | 複雜度高 |
SPFA | 求單一起點最短路 負邊處理 |
O(Ek) | 比賽容易被卡,會跟 Bellman Ford 一樣爛 |
FloydWarshall | 任兩點最短路 | O(V^3) | 專解任兩點最短路 |
0
3
1
2
4
5
6
7
8
4
4
2
4
9
4
3
8
1
3
3
10
1
最小生成樹
Mininum Spanning Tree
Tree
Tree
= 由 \(n\) 個點 \(n-1\) 條邊構成的聯通圖
= 在任兩點加上邊就會有環的無環圖
生成樹
- 一個圖的生成樹,是圖的子集,為包含所有頂點的樹
最小生成樹
- 最小生成樹為一張圖的生成樹中,邊權總和最小的樹
最小生成樹
- 最小生成樹為一張圖的生成樹中,邊權總和最小的樹
Kruskal's algorithm
- 競賽中,常使用 kruskal 演算法來求最小生成樹
- 該演算法需要使用 disjoint set @
最小生成樹定理
- \(P,Q\) 分別為最小生成樹的集合,欲使用一些邊將 \(P,Q\) 合併
- 使用最小權重的邊將 \(P\)、\(Q\) 連接,合併後的樹依然是最小生成樹
Kruskal 's algorithm
- 透過前定理,我們可以使用貪心法來作出最小生成樹
- 先將所有邊由小到大排序
- 依序考慮每條邊,如果這條邊的兩端沒有在同一個子樹上就加入這條邊
- 完成!
vector<tuple<int,int,int>> Edges;
int kruskal(int N) {
int cost = 0;
sort(Edges.begin(), Edges.end());
DisjointSet ds(N);
sort(Edges.begin(), Edges.end());
for(auto [w, s, t] : Edges) {
if (!ds.same(s, t)) {
cost += w;
ds.unit(s, t);
}
}
return cost;
}
時間複雜度 : \(O(E\log E)\)
練習題
uva 10034-Freckles
ZJ e509: Conscription
uva 10048 - Audiophobia
uva 534 - Frogger
兩點間最小瓶頸
基本解法 : 二分搜、或構造成 MST,最短路問題
Basic Graph
By sylveon
Basic Graph
- 2,220