---
# System prepended metadata

title: Tarjan 的連通塊演算法
tags: [內湖高中程式競賽]

---

# Tarjan 的連通塊演算法
Tarjan 提出過很多圖論演算法，其中作知名的莫過於最各種連通塊的東西。這篇會提及如何使用 Tarjan 的想法找出圖中的橋連通塊、雙連通塊與強連通塊。這些演算法都會用到 DFS tree 的性質，有興趣可以去[這篇](https://hackmd.io/@ShanC/dfs-tree)複習一下

此外要另外題的就是 Connected Component 有很多種翻譯方式 (e.g. 連通分量、連通成分、連通單元......)，我這邊採用的是「連通塊」這個翻譯，因為這樣可以少打一個字

## 複習 : DFS Tree
### 各種 Edge
有關於什麼是 DFS tree 的結構還有一些名詞，可以複習[這邊](https://hackmd.io/@ShanC/dfs-tree)。這邊就簡單帶過
- {樹邊|tree edge}: 在 DFS tree 的邊
- {後向邊|back edge}: 使樹上後代連回祖先的非樹邊
- {前向邊|forward edge}: 使樹上祖先連到後代的非樹邊
- {交叉邊|cross edge}: 連結兩棵不同子樹節點的非樹邊，兩點沒有血緣關係

### 時間戳記
有關時間戳記還有他們的性質我在[這篇](https://hackmd.io/@ShanC/bfs_dfs#%E6%99%82%E9%96%93%E6%88%B3%E8%A8%98)講過了，有興趣可以去複習。這邊就稍微代個名詞

- $\text{dfn}[~]$ : 紀錄每個點的發現時間

```cpp=
int timmer = 0;
int dfn[MXN];           // 初始化為 0
void dfs(int u) {
    dfn[u] = ++timmer;  // 紀錄發現時間
    for (int &v : g[u]) {
        if (!dfn[v])
            dfs(v);
    }
}
```

以上就是找 $\text{dfn}[~]$ 的程式碼

## 名詞定義
以下介紹橋、關節點與各種連通塊的定義。可以想像如果你開一個轟炸機，要轟炸一些地方使得彼此不連通，那需要轟炸哪裡比較好? 其實這些名詞都是在探討這些東西

### 強連通塊 Strongly Connected Components
「強連通」是一個對於有向圖的形容詞，意思是對於圖上任意兩點都可以互相走到彼此。強連通塊就是極大的強連通子圖，也簡稱 SCC

<center>

![image](https://hackmd.io/_uploads/ryT5On2w-x.png =250x)

圖源 : [台師大演算法筆記](https://web.ntnu.edu.tw/~algo/ConnectedComponent.html)

</center>

### 橋 Bridge
給一張連通無向圖 $G$，若一條邊 $e$ 被刪去後會使圖被分成兩個連通塊，則 $e$ 稱為 bridge。其實就是 cut vertex

<center>

![shapes at 26-02-13 09.10.59](https://hackmd.io/_uploads/HkAV5l3PWe.png =300x)

</center>

如上圖，紅色邊就是 bridge。可以是著拔邊看看是否會符合定義

### 橋連通塊 Bridge-connected Component
拔掉所有 bridge 後所形成的連通塊，就稱為橋連通塊，簡稱 BCC

<center>

![shapes at 26-02-13 10.40.10](https://hackmd.io/_uploads/S17E1f2vZl.png =300x)


</center>

如上圖，每個顏色圈起來的就是一個橋連通塊

### 橋的一些性質
#### 環上沒有橋
因為環上割掉任一邊都還是會連通，因此不存在 bridge

<center>

![shapes at 26-02-13 10.52.01](https://hackmd.io/_uploads/r1YkGzhDWe.png =180x)

</center>

#### 橋必在每棵生成樹上
[生成樹](https://hackmd.io/@ShanC/minimum-spanning-tree#%E7%94%9F%E6%88%90%E6%A8%B9-Spanning-Tree)就是包含原圖所有節點的最小連通子圖，當然也是一棵樹。其中，由於 bridge 是連接兩個橋連通塊、不可分割的一條邊，因此也定會存在每棵生成樹上，否則不連通

<center>
    
![shapes at 26-02-13 11.10.54](https://hackmd.io/_uploads/HylIIfhDbx.png =300x)

</center>

上圖為其中一種生成樹，可以發現確實包含所有 bridge (綠色)，而且就算是換成其他生成樹，也只是選出另一條虛線邊、拔掉另一個黑色邊而已，這凸顯 bridge 在生成樹中無可取代的地位

### 關節點 Articulation Vertex
在一個連通圖 $G$ 中，若刪除某節點 $v$ 及其相連的邊，使得連通塊數量增加 (不一定 $+1$)，則 $v$ 為一個關節點。其實就是 cut-vertex

注意到下圖 $x$ 就是關節點: 
<center>

![shapes at 26-02-14 20.51.01](https://hackmd.io/_uploads/rkbR1e0Dbx.png =200x) 

</center>


再舉個例子

<center>

![shapes at 26-02-13 11.31.56](https://hackmd.io/_uploads/HJ5rjfhD-e.png =300x)

</center>


綠色的邊是 bridge，紅節點是關節點，注意到 : 
- 只要 bridge 連接的是兩個大小 $>1$ 的連通塊，那麼該 bridge 兩終點都會是關節點
- 因為切掉 $\deg=1$ 的節點不會分割整張圖，因此他不是關節點
- 刪除關節點後會行成的連通塊數量可能很多，像圖中某個關節點刪除後就會形成 $3$ 個連通塊


### 雙連通塊 Biconnected Component
這邊定義稍微繞一點
雙連通塊就是一個不會產生關節點的極大子圖，也就是說刪去任一個點，該子圖仍連通，有時又稱 block 或是 2-connected component，也簡稱 BCC

<center>

![shapes at 26-02-13 11.57.43](https://hackmd.io/_uploads/HyCHb72wWl.png =300x)

</center>

上圖被我畫的很亂，抱歉抱歉


### 注意
- 有橋就**一定**有關節點 (終點 $\deg$ 不為 $1$ 的情況下)
- 有關節點**不一定**有橋
- 再次強調，強連通塊是 for 有向圖，雙連通、橋連通塊是 for 無向圖
- 沒事別用 BCC 這個簡稱，會搞混

如果沒注意到就多注意一點

## 尋找橋
要討論橋，那圖必須是**無向圖**，以下的圖都是無向圖
### 觀察 : 什麼圖不存在橋
根據定義，若拿掉橋，則整張圖會變得不連通。反之，若圖不存在橋，則拿掉任一條邊圖還是會連通，那圖其實就會有個環

<center>

![shapes at 26-02-12 13.44.40](https://hackmd.io/_uploads/HyT0d1iwZx.png =300x)
不存在橋的圖

</center>

根據我們在 [DFS tree](https://hackmd.io/@ShanC/dfs-tree) 中的觀察，無向圖測環只需要找 back edge

### 觀察 : bridge 在 DFS tree 中扮演的角色
這邊列舉幾個 bridge 在的性質
#### bridge 在 DFS tree 中一定是 tree edge
若不是 tree edge，那代表一定存在另一條 tree edge 使得原本兩終點連通，但這與 bridge 的性質矛盾，所以不可能

而且 DFS tree 也是一棵生成樹，根據[前面](#橋必在每棵生成樹上)所述，bridge 必備包含在任一生成樹裡面

#### 什麼樣的 tree edge 不是 bridge?
透過觀察，可以知道若有一條 tree edge $u\to v$ 是 bridge，那 $u\to v$ 肯定不存在環上。也就是說 $u,v$ 的子代不會連回去 $u$ 跟的祖先 (包含自己)

<center>

![shapes at 26-02-13 12.10.34](https://hackmd.io/_uploads/HyMUNmhPZe.png =150x)

不是 bridge 的情況

</center>

所以我們也可以發現，如果有環產生，但是在 $v$ 的子代且沒有連回去 $u$ 的祖先 (含 $u$)，那 $u\to v$ 仍是一條 bridge

<center>

![shapes at 26-02-13 12.12.55](https://hackmd.io/_uploads/SJURNm2D-l.png =150x) ![shapes at 26-02-13 12.14.08](https://hackmd.io/_uploads/Hyx7HQ3vZe.png =150x)

是 bridge 的情況 (左邊有環，右邊沒有)

</center>

### 想法

#### 要處理的問題
要判斷「無向圖中哪條邊是 bridge ?」，根據以上的觀察，對於邊 $(u,v)$ 我們需要處理三個 case : 
- 非 tree edge
    - 那就一定是 back edge，肯定不是 bridge，因為 bridge 一定是 tree edge
- 是 tree edge
    - 如果 $(u,v)$ 是在某個環上，i.e. $v$ 的後代存在一條 back edge 連回去 $u$ 或 $u$ 的祖先，則 $(u,v)$ 不是 bridge
    - 若 $(u, v)$ 不在某個環上，那 $(u,v)$ 就是 bridge

#### 要維護的東西
首先要先知道每個節點 $v$ 的發現時間 $\text{dfn}[v]$。為了知道每條邊是不是在某個環上，Tarjan 的演算法中維護了一個很妙的陣列 : 

$\text{low}[u]$ : 透過最多一條 back edge，所能到達的節點的最小時間

#### 舉些例子 :chestnut: 
如果圖是無環圖 (就是 tree)，那麼每條邊都是 bridge

<center>

![shapes at 26-02-13 22.06.11](https://hackmd.io/_uploads/BJ7Lgn3DZe.png =350x)

</center>

如上圖，因為樹沒有 back edge，所以每個節點的 $\text{low}[~]$ 就是他自己

如果有環的話，可能會向下圖醬
PS : 這裡為了方便，節點編號跟 $\text{dfn}$ 相同

<center>

![shapes at 26-02-13 22.12.55](https://hackmd.io/_uploads/SyVsb23PWg.png =350x)

</center>

在上圖中，當我們拜訪到 $(1,5)$ 那條邊的時候會發現那條邊是 back edge，$5$ 能利用 back edge 到達的最小 $\text{dfn}$ 是 $1$。接下來當 $5$ 成為黑點的時候回傳到 $4$，$4$ 的 $\text{low}$ 也會被更新成 $1$ ...\... 以此類推

#### bridge 的 $\text{dfn}$ 與 $\text{low}$
如果一條邊 $u\to v$ 是 bridge，根據上面觀察，會有以下不等式 : 

$$\text{dfn}[u] < \text{low}[v]$$

因為 $v$ 的子代不存在一條 back edge 回到 $u$ 的頭上，因此 $\text{low}[v]$ 一定比較大

### 實作程式碼
這裡來講一下變數好了
- $\text{g}[~]$ : 鄰接陣列
- $\text{timmer}$ : 計時器!!
- $\text{dfn}[~]$ : 發現順序 (時間)
- $\text{low}[u]$ : 從 $u$ 或其子樹節點出發，透過最多一條回邊，所能到達的最小 $\text{dfn}$ 值
- $\text{bridges}[~]$ : 存 bridge 的陣列，裡面包一個 `Edge` 的 struct

因為圖是無向圖，因此 DFS 時需要維護父節點，並且在探索子代時忽視到父節點的邊

```cpp=
vector<int> g[MXN];
int timmer = 0;
int dfn[MXN], low[MXN];
 
struct Edge {                               // 就是紀錄邊的 struct
    int u, v;
};
vector<Edge> bridges;
 
void dfs(int u, int p = -1) {
    dfn[u] = low[u] = ++timmer;
    for (int &v : g[u]) {
        if (v == p) continue;
        if (dfn[v]) {                       // 這個點走過
            low[u] = min(low[u], dfn[v]);   // 那這就是 back edge :D
        }                                   // 所以就要更新
        else {                              // 沒走過的點 -> 繼續拜訪
            dfs(v, u);
            low[u] = min(low[u], low[v]);   // 更新 low[u]
            if (dfn[u] < low[v])            // 若子節點 low 大於自己 
                bridges.push_back({u, v});  // 則這就是 bridge
        }
    }
}
```

提醒 : 在第 $20$ 行，我們必須等子節點是黑點的時候才能判斷

## 找橋連通塊
### 想法
其實就跑兩次 DFS，第一次找 bridge，然後把兩終點分別標上編號。第二次找橋連通塊，看到未編號就標上相同編號、看到不同編號就停下來

### 實作程式碼
整個程式碼分兩階段進行 : 
- 第一階段 : 找 bridge
- 第二階段 : 分群找橋連通塊

為了效率，通常會把橋存在一個 set

```cpp=
vector<int> g[MXN];
int timmer = 0;
int dfn[MXN], low[MXN];
set<Edge> bridges;

// 第一階段
void dfs1(int u, int p = -1) {
    dfn[u] = low[u] = ++timmer;
    for (int &v : g[u]) {
        if (v == p) continue;
        if (dfn[v]) {
            low[u] = min(low[u], dfn[v]);
        } 
        else {
            dfs1(v, u);
            low[u] = min(low[u], low[v]);
            if (dfn[u] < low[v])
                bridges.insert({u, v}); // 配合 set 改 insert
        }
    }
}

// 第二階段
int idx[MXN];
void dfs2(int u, int id) {
    idx[u] = id;
    for (int &v : g[u]) { // 已分群或是遇到 bridge 就別走
        if (idx[v] || bridges.count({u, v}) || bridges.count({v, u})) 
            continue;
        dfs2(v, id);
    }
}

// 該寫在主程式的東西
for (int i = 1; i <= n; i++) {
    if (!dfn[i]) 
        dfs1(i);
}
int bcc_cnt = 0; // 橋連通塊編號 (數量)
for (int i = 1; i <= n; i++) {
    if (!ebcc_id[i]) 
        dfs2(i, ++bcc_cnt);
}
```

如此一來我們就可以知道每個點在哪個橋連通塊

## 尋找關節點
要討論關節點，那圖必須是**無向圖**，以下的圖都是無向圖
bridge 需要觀察的東西是兩終點，而關節點需要觀察的就只有點本身，相對容易觀察一點

### 觀察 : 環與關節點
根據上面在環的觀察，不免令人好奇，關節點會在環上嗎?

<center>

![shapes at 26-02-14 22.23.58](https://hackmd.io/_uploads/rylqr-0wWl.png =300x)

</center>

會的，注意到 $x$ 是在環上的關節點

### 觀察 : 關節點與 DFS Tree
假設你[已經知道 $\text{low}[~]$](#要維護的東西) 是什麼了，這邊要分幾個 case 來談談 : 
- 若 $x$ 在環上 : 
    - 肯定有至少一條 back edge 連到自己才能形成環，且 $x$ 肯定是這個子 DFS tree 的祖先 
$\Rightarrow$ $x$ 的子後代們透過一條 back edge 不會跑去 $\text{dfn}$ 值  $<\text{dfn}[x]$ 的地方
$\Rightarrow$ 對於所有子節點 $v$，$\text{dfn}[x] \le\text{low}[v]$
- 若 $x$ 不在環上 : 
    - 那麼就不會有 back edge 的問題，只要 $x$ 還有子節點，那肯定是關節點，因為 $x$ 至少連了父節點與子節點。我們也可以直接用剛剛的結論套用 : 對於所有子節點 $v$，$\text{dfn}[x] \le\text{low}[v]$

### 觀察 : 根節點
我們[剛剛](#觀察-:-關節點與-DFS-Tree)觀察到的事情有個瑕疵，就是我們預設了每個節點在 DFS tree 上都有父節點，然而根節點是個例外，因此我們要特別觀察根節點 : 

若 $x$ 是根節點，不論是否在環上，只要 DFS 樹上的子節點 $>1$ 就肯定是關節點

<center>

![shapes at 26-02-14 23.13.51](https://hackmd.io/_uploads/SJBSWfRvWe.png =300x) ![shapes at 26-02-14 23.16.26](https://hackmd.io/_uploads/B1lkfGCDZx.png =269x)


左圖 : 根 $x$ 不是關節點、右圖 : 根 $x$ 是關節點

</center>

### 實作程式碼
這裡實作其實跟找 bridge 很像，只是改改判斷而已
- $\text{g}[~]$ : 鄰接陣列
- $\text{timmer}$ : 計時器!!
- $\text{dfn}[~]$ : 發現順序 (時間)
- $\text{low}[u]$ : 從 $u$ 或其子樹節點出發，透過最多一條回邊，所能到達的最小 $\text{dfn}$ 值
- $\text{ans}$ : 存關節點的東西，這裡使用 `set` 是因為可以避免重複

```cpp=
vector<int> g[MXN];
int timmer = 0;
int dfn[MXN], low[MXN];
set<int> ans;

void dfs(int u, int p = -1) {
    dfn[u] = low[u] = ++timmer;
    int child = 0;
    for (int &v : g[u]) {
        if (v == p) continue;
        if (dfn[v]) {
            low[u] = min(low[u], dfn[v]);
        }
        else {
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            if (low[v] >= dfn[u] && p != -1) // 判斷是不是關節點
                ans.insert(u);               // (for 非根)
            child++;                         // 這裡紀錄有幾個孩子
        }
    }
    if (p == -1 && child > 1)                // 判斷根是否為關節點 
        ans.insert(u);
}
```

要注意這邊因為用 `set` 來存，所以複雜度最差會多一個 $\log$
當然，你可以改這邊的東西

注意 : 這裡 DFS tree 上的子節點數量 $\neq$ $\deg$，因為 DFS tree 會排除 back edge 還有連回父節點的邊

## 找雙連通塊

### 回溯
會發現在找到關節點的時候，其實我們已經拜訪完整個雙連通塊了。因此可以利用 DFS 的性質來回溯整個雙連通塊的節點

### 實作程式碼
這裡都跟剛才一樣，只是多了兩個東西 : 
- $\text{st}$ : 存點的 stack，以便回溯
- $\text{bccs}$ : 存雙連通塊的點

要注意的是根節點在這不用特別判
還有就是注意下面對於關節點的操作細節

```cpp=
vector<int> g[MXN];
int dfn[MXN], low[MXN], timmer;
stack<int> st;            // 存點
vector<vector<int>> bccs; // 存結果

void dfs(int u, int p = -1) {
    dfn[u] = low[u] = ++timmer;
    st.push(u); // 進入點 u 時 push 進 stack
    int child = 0;
    for (int &v : g[u]) {
        if (v == p) continue;
        
        if (dfn[v]) {
            low[u] = min(low[u], dfn[v]);
        } 
        else {
            child++;
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            if (low[v] >= dfn[u]) {      // 當滿足關節點條件時
                vector<int> bcc;
                int z;
                do {                     // pop 出所有在子樹 v 裡面的點
                    z = st.top();
                    st.pop();
                    bcc.push_back(z);
                } while (z != v);
                
                // 關節點 u 本身不彈出，因為它可能屬於其他 bcc
                // 我們直接把它加入這個 bcc 集合
                bcc.push_back(u);
                bccs.push_back(bcc);
            }
        }
    }
}
```

### 找 Bridge?
其實 size 為 $2$ 的雙連通塊就是 bridge，所以也可以直接用這個模板判
但是這本質上還是和橋連通塊不一樣，千萬不要把兩者搞混了，不然就要吃 WA

## 尋找強連通塊
在這邊，SCC 是有向圖才有的東西，所以以下討論都是有向圖
### 觀察 : SCC 在 DFS 樹裡面會形成子樹
根據 SCC 的定義，任意兩點都可以找到一條路徑。所以對於任一個節點也一定可以對所有其他節點找出一條路徑，這樣就形成了一棵樹 (recall 樹的定義)

所以當我們 DFS 完這棵子樹後，$\text{dfn}$ 最小的一定就是根，而且此根的 $\text{low}=\text{dfn}$。因為若 $\text{low}<\text{dfn}$，代表應該有條 back edge 或 cross edge 可以回到更早節點，若 $\text{low}>\text{dfn}$，那就不合理。所以根節點 $\text{low}=\text{dfn}$

### 回溯
跟上找雙連通塊的方法類似，我們可以用回溯的方式找強連通塊

### 判環

要注意的是，在有向圖中，cross edge 或 forward edge 都不會形成環 (當然也不會形成 SCC)，因此只有 back edge 需要判。我們可以用一個陣列來處理這個問題，以下程式碼是利用 $\text{inStack}[~]$ 來處理

### 實作程式碼
原本的變數我懶得寫了，基本都一樣
- $\text{idx}[v]$ : $v$ 是屬於哪個連通塊
- $\text{sccn}$ : SCC 編號

要注意的是，這裡是有向圖，與無向圖寫法有點差異
所以這邊用 $\text{inStack}[~]$ 來維護

```cpp=
vector<int> g[MXN];
int dfn[MXN], low[MXN], timmer = 0;
int idx[MXN], sccn = 0;
bool inStack[MXN];

void dfs(int u) {
    dfn[u] = low[u] = ++timer;
    s.push(u);
    inStack[u] = true;

    for (int &v : g[u]) {
        if (!dfn[v]) {         // 未訪問
            dfs(v);
            low[u] = min(low[u], low[v]);
        } 
        else if (inStack[v]) { // 環 (是 back edge)
            low[u] = min(low[u], dfn[v]);
        }
    }

    if (low[u] == dfn[u]) {    // 找到 SCC 的根
        sccn++;
        while (true) {
            int v = s.top();
            s.pop();
            inStack[v] = false;
            idx[v] = sccn;
            if (u == v) break;
        }
    }
}

// 要使用時應該要這樣
for (int i = 1; i <= n; i++) {
    if (!dfn[i])
        dfs(i);
}
```

這樣就能知道每個節點屬於哪個 SCC 囉

## 後記
### 為什麼要寫這篇呢?
在海大競程學完之後的一年後才自己研究這些東西，為了避免忘記，我寫了這篇筆記，讓自己可以隨時回來複習。因為我們學校競程講義寫得有點糟糕，只把這個演算法當作黑盒子，套用模板而已。Tarjan 的演算法使用非常多 DFS 的性質，可以說沒讀過這些演算法就無法掌握 DFS 的精隨。我是覺得這麼活的東西只套套模板實在是太可惜，這樣解題非常不靈活

### 關於 Tarjan
Tarjan 真的好厲害，把 DFS 玩得淋漓盡致

### 注意到、觀察到
有些證明會幫助理解，有些則比較像是對「數學」這個嚴謹的體系交差而已，不一定會有助於理解。上面提到的「注意到」或「觀察到」應該都是要證明的，只是那樣並不會幫助理解，反而會讓話題失焦，而且這樣的文章我複習起來會看不懂

### 有關參考資料
CP-algorithm 那邊，明明都是用 Tarjan 的概念，但是找 bridge、找關節點與找 SCC，顯然是不同人寫的。符號跟用詞顯然都不一樣，我看的有點燥。台師大的演算法筆記都是用鄰接矩陣，看的時候要稍微翻譯一下變成鄰接串列的版本

### 其他
寫到後面我累了，實作細節講也講不完，反正都看到這裡了，多少對圖論的 code 有點直覺了吧

## 題目練習
[LeetCode 1192. Critical Connections in a Network](https://leetcode.com/problems/critical-connections-in-a-network/description/?envType=problem-list-v2&envId=biconnected-component) (裸題)
[CSES Necessary Roads](https://cses.fi/problemset/task/2076/) (裸題)
[CSES Necessary Cities](https://cses.fi/problemset/task/2077) (裸題)
[CSES Planets and Kingdoms](https://cses.fi/problemset/task/1683/) (裸題)
[CSES Coin Collector](https://cses.fi/problemset/task/1686) (縮點 + DP on DAG)
[2019 ICPC World Finals E. Dead-End Detector](https://codeforces.com/gym/102511/problem/E)

----

## 參考資料
- Introduction to Algorithms, Fourth Edition
- [[Tutorial] The DFS tree and its applications: how I found out I really didn't understand bridges](https://codeforces.com/blog/entry/68138)
- [Wikipedia - Bridge (graph theory)](https://en.wikipedia.org/wiki/Bridge_(graph_theory))
- [Wikipedia - Biconnected component](https://en.wikipedia.org/wiki/Biconnected_component)
- [cp-algorithms - Finding bridges in a graph in $O(N+M)$](https://cp-algorithms.com/graph/bridge-searching.html)
- [cp-algorithms - Finding articulation points in a graph in $O(N+M)$](https://cp-algorithms.com/graph/cutpoints.html)
- [海大競程 - 2023 Advanced Graph Algorithm](https://hackmd.io/@leeshowhaodian/advance_graph#/)
- [台師大演算法筆記 - connected component](https://web.ntnu.edu.tw/~algo/ConnectedComponent.html)
- [Articulation points and bridges (Tarjan's Algorithm)](https://codeforces.com/blog/entry/71146)
- [XDEv11 - 2021 Week12 - Graph (BCC, SCC, LCA)](https://hackmd.io/@XDEv11/NCKU-AdvCP-2021-Graph3)

----

> [ShanC 程式競賽筆記](https://hackmd.io/@ShanC/B1ouGxqcC)
> 作者: ShanC
> 更新: 2026/2/15