拓撲排序 — Kahn's BFS 與 DFS 雙解法完整教學 | 資料結構與演算法
拓撲排序(Topological Sort) 是對 有向無環圖(DAG) 節點進行線性排序的技術,確保每條有向邊
u → v中,u永遠出現在v之前。從大學選課的先修規劃,到 npm 套件的依賴安裝順序,背後都是拓撲排序在默默運作。本文完整介紹 Kahn’s Algorithm(BFS) 與 DFS 後序反轉法 雙解法,並搭配環偵測與 LeetCode 實戰題解。
前言
想像你是一位大學新生,面對一份課程目錄,上面標注了各課程的先修要求:
- 修「演算法」之前必須先修「資料結構」
- 修「資料結構」之前必須先修「程式設計概論」
- 修「機器學習」之前必須先修「線性代數」與「機率統計」
你需要規劃一份修課順序,讓每門課在修習時,其所有先修課程都已完成。這就是拓撲排序要解決的問題——在一個有先後依賴關係的系統中,找到一個合法的處理順序。
拓撲排序應用無所不在:
- Build 系統:Make / Gradle / Bazel 編譯前先做拓撲排序,確保模組依賴順序正確
- 套件管理器:npm / pip / cargo 按依賴拓撲順序安裝套件,並偵測循環依賴
- CI/CD Pipeline:GitHub Actions / Jenkins 的 Job 依賴圖,決定哪些 Job 可並行
- Excel 公式計算:儲存格依賴關係按拓撲順序計算,確保引用的儲存格已先計算
讀完本文,你將能夠:
- 理解 DAG(有向無環圖) 與 入度(In-degree) 的核心概念
- 掌握 Kahn’s Algorithm(BFS 入度移除法) 的完整流程
- 學會 DFS 後序反轉法 與三色標記環偵測
- 用 TypeScript / JavaScript 與 C++ 實作兩種解法
- 解析 5 道 LeetCode 精選題(207、210、269、802、1136)
核心概念
有向無環圖(DAG)與入度
有向無環圖(Directed Acyclic Graph,DAG) 是拓撲排序的必要條件。「有向」指邊有方向性(u → v 不等於 v → u);「無環」指圖中不存在從某節點出發、沿有向邊走最終回到自身的路徑。
入度(In-degree) 是拓撲排序的關鍵數據——某節點的入度,就是「有幾條有向邊指向它」的數量。
先修課程範例:
A ──→ C ──→ F
│ │
↓ ↓
B ──→ D ──→ E
入度計算:
A: 0 (起點,無人指向它)
B: 1 (A→B)
C: 1 (A→C)
D: 2 (B→D, C→D)
E: 1 (D→E)
F: 1 (C→F)
合法的拓撲排序(多種):
A → B → C → D → E → F
A → C → B → D → F → E
A → C → F → B → D → E
規則:若有邊 u → v,則 u 必須出現在 v 之前
入度為 0 的節點沒有前置依賴,可以最先處理。這就是 Kahn’s Algorithm 的靈感來源。
Kahn’s Algorithm(BFS 入度移除法)
Kahn’s Algorithm 是最直觀的拓撲排序實作,流程如下:
- 計算所有節點的入度
- 將所有入度為 0 的節點加入 Queue
- 從 Queue 取出節點,加入結果序列
- 將其所有鄰居的入度減 1
- 若鄰居的入度變為 0,加入 Queue
- 重複直到 Queue 空
環偵測:若最終結果序列長度 < 節點總數,代表圖中有環(有些節點的入度永遠無法降為 0)。
DAG 步驟圖(6 個節點:0~5,邊:0→2, 0→3, 1→3, 1→4, 2→5, 3→5, 4→5)
初始入度:
節點: [0, 1, 2, 3, 4, 5]
入度: [0, 0, 1, 2, 1, 3]
Step 1:入度為 0 → Queue = [0, 1],Result = []
Step 2:取出 0 → 移除邊 0→2(入度[2]: 1→0)、0→3(入度[3]: 2→1)
Queue = [1, 2],Result = [0]
Step 3:取出 1 → 移除邊 1→3(入度[3]: 1→0)、1→4(入度[4]: 1→0)
Queue = [2, 3, 4],Result = [0, 1]
Step 4:取出 2 → 移除邊 2→5(入度[5]: 3→2)
Queue = [3, 4],Result = [0, 1, 2]
Step 5:取出 3 → 移除邊 3→5(入度[5]: 2→1)
Queue = [4],Result = [0, 1, 2, 3]
Step 6:取出 4 → 移除邊 4→5(入度[5]: 1→0)
Queue = [5],Result = [0, 1, 2, 3, 4]
Step 7:取出 5 → Queue = []
Result = [0, 1, 2, 3, 4, 5]
Result 長度(6) == 節點數(6) → 無環!
DFS 後序反轉法
DFS 版本的核心思想:對每個節點做深度優先搜尋,在所有鄰居都處理完之後,才將自己推入 Stack。最終 Stack 反轉即為拓撲順序。
為什麼後序反轉能得到正確順序?因為在 DFS 中,節點 v 的所有後繼節點(及其遞迴後繼)都會先被推入 Stack,v 最後推入。反轉後,v 就排在所有它指向的節點之前,滿足拓撲排序的定義。
環偵測:使用三色標記法:
0(白色):尚未訪問1(灰色):正在 DFS 路徑中2(黑色):已完全處理完
若 DFS 途中遇到「灰色」節點,代表發現了回邊(Back Edge),即存在環。
JS/TS 實作
Kahn’s Algorithm
interface TopologicalResult {
order: number[];
hasCycle: boolean;
}
function kahnTopologicalSort(n: number, edges: [number, number][]): TopologicalResult {
// 建立鄰接表與入度陣列
const graph: number[][] = Array.from({ length: n }, () => []);
const inDegree = new Array(n).fill(0);
for (const [u, v] of edges) {
graph[u].push(v);
inDegree[v]++;
}
// 將所有入度為 0 的節點加入 Queue
const queue: number[] = [];
for (let i = 0; i < n; i++) {
if (inDegree[i] === 0) queue.push(i);
}
const order: number[] = [];
while (queue.length > 0) {
const u = queue.shift()!;
order.push(u);
for (const v of graph[u]) {
inDegree[v]--;
if (inDegree[v] === 0) queue.push(v);
}
}
// 若結果長度 < n,代表有環存在
return {
order,
hasCycle: order.length < n,
};
}
// 使用範例
const result = kahnTopologicalSort(6, [[0,2],[0,3],[1,3],[1,4],[2,5],[3,5],[4,5]]);
console.log(result.order); // 輸出:[0, 1, 2, 3, 4, 5]
console.log(result.hasCycle); // 輸出:false
帶層級的 Kahn(並行任務排程)
當題目問「最少需要幾個學期/輪次」時,可以用帶層級的 Kahn 版本——每一層的節點可以並行執行:
interface LayeredResult {
layers: number[][];
hasCycle: boolean;
}
function kahnWithLayers(n: number, edges: [number, number][]): LayeredResult {
const graph: number[][] = Array.from({ length: n }, () => []);
const inDegree = new Array(n).fill(0);
for (const [u, v] of edges) {
graph[u].push(v);
inDegree[v]++;
}
// 初始層:所有入度為 0 的節點
let currentLayer: number[] = [];
for (let i = 0; i < n; i++) {
if (inDegree[i] === 0) currentLayer.push(i);
}
const layers: number[][] = [];
let processed = 0;
while (currentLayer.length > 0) {
layers.push([...currentLayer]);
processed += currentLayer.length;
const nextLayer: number[] = [];
for (const u of currentLayer) {
for (const v of graph[u]) {
inDegree[v]--;
if (inDegree[v] === 0) nextLayer.push(v);
}
}
currentLayer = nextLayer;
}
return {
layers,
hasCycle: processed < n,
};
}
// 使用範例
const { layers } = kahnWithLayers(6, [[0,2],[0,3],[1,3],[1,4],[2,5],[3,5],[4,5]]);
// layers[0] = [0, 1] → 第一學期可修
// layers[1] = [2, 3, 4] → 第二學期可修
// layers[2] = [5] → 第三學期可修
// 最少需要 3 個學期(layers.length)
DFS 後序反轉版
function dfsTopologicalSort(n: number, edges: [number, number][]): TopologicalResult {
const graph: number[][] = Array.from({ length: n }, () => []);
for (const [u, v] of edges) graph[u].push(v);
// 三色標記:0=未訪問, 1=訪問中(在當前 DFS 路徑上), 2=已完成
const state = new Array(n).fill(0);
const stack: number[] = [];
let hasCycle = false;
function dfs(u: number): void {
if (hasCycle) return;
state[u] = 1; // 標記為「訪問中」
for (const v of graph[u]) {
if (state[v] === 1) {
// 遇到「訪問中」的節點 → Back Edge → 有環!
hasCycle = true;
return;
}
if (state[v] === 0) {
dfs(v);
}
}
state[u] = 2; // 後序:標記為「已完成」,推入 Stack
stack.push(u);
}
for (let i = 0; i < n; i++) {
if (state[i] === 0) dfs(i);
}
return {
order: hasCycle ? [] : stack.reverse(),
hasCycle,
};
}
課程排程應用(整合版)
class CourseScheduler {
private n: number;
private prerequisites: [number, number][];
constructor(numCourses: number, prerequisites: [number, number][]) {
this.n = numCourses;
this.prerequisites = prerequisites;
}
// 是否可完成所有課程(環偵測)
canFinish(): boolean {
const { hasCycle } = kahnTopologicalSort(this.n, this.prerequisites);
return !hasCycle;
}
// 回傳一個合法的修課順序
findOrder(): number[] {
const { order, hasCycle } = kahnTopologicalSort(this.n, this.prerequisites);
return hasCycle ? [] : order;
}
// 回傳每個學期可以修的課(最少學期數)
findParallelSchedule(): number[][] {
const { layers, hasCycle } = kahnWithLayers(this.n, this.prerequisites);
return hasCycle ? [] : layers;
}
}
// 使用範例
const scheduler = new CourseScheduler(4, [[1,0],[2,0],[3,1],[3,2]]);
console.log(scheduler.canFinish()); // 輸出:true
console.log(scheduler.findOrder()); // 輸出:[0, 1, 2, 3](其中一種合法順序)
console.log(scheduler.findParallelSchedule()); // 輸出:[[0], [1, 2], [3]]
C++ 對照
Kahn’s Algorithm(BFS 版)
#include <vector>
#include <queue>
using namespace std;
struct TopologicalResult {
vector<int> order;
bool hasCycle;
};
TopologicalResult kahnTopologicalSort(int n, vector<pair<int,int>>& edges) {
vector<vector<int>> graph(n);
vector<int> inDegree(n, 0);
for (auto [u, v] : edges) {
graph[u].push_back(v);
inDegree[v]++;
}
queue<int> q;
for (int i = 0; i < n; i++)
if (inDegree[i] == 0) q.push(i);
vector<int> order;
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : graph[u]) {
if (--inDegree[v] == 0) q.push(v);
}
}
return { order, (int)order.size() < n };
}
DFS 後序反轉版(含三色標記)
#include <vector>
#include <stack>
using namespace std;
class TopoSortDFS {
int n;
vector<vector<int>>& graph;
vector<int> state; // 0=未訪 1=訪問中 2=完成
stack<int> stk;
bool hasCycle = false;
void dfs(int u) {
if (hasCycle) return;
state[u] = 1;
for (int v : graph[u]) {
if (state[v] == 1) { hasCycle = true; return; }
if (state[v] == 0) dfs(v);
}
state[u] = 2;
stk.push(u);
}
public:
TopoSortDFS(int n, vector<vector<int>>& g) : n(n), graph(g), state(n, 0) {}
vector<int> sort() {
for (int i = 0; i < n; i++)
if (state[i] == 0) dfs(i);
if (hasCycle) return {};
vector<int> result;
while (!stk.empty()) { result.push_back(stk.top()); stk.pop(); }
return result; // 已是正確拓撲順序(Stack 反轉)
}
};
帶層級的 Kahn(並行任務計算)
#include <vector>
#include <queue>
using namespace std;
// 回傳每層可並行執行的節點,若有環則回傳空
vector<vector<int>> kahnLayers(int n, vector<pair<int,int>>& edges) {
vector<vector<int>> graph(n);
vector<int> inDegree(n, 0);
for (auto [u, v] : edges) {
graph[u].push_back(v);
inDegree[v]++;
}
vector<vector<int>> layers;
vector<int> current;
for (int i = 0; i < n; i++)
if (inDegree[i] == 0) current.push_back(i);
int processed = 0;
while (!current.empty()) {
layers.push_back(current);
processed += (int)current.size();
vector<int> next;
for (int u : current)
for (int v : graph[u])
if (--inDegree[v] == 0) next.push_back(v);
current = next;
}
return processed == n ? layers : vector<vector<int>>{};
}
複雜度分析
| 方法 | 時間複雜度 | 空間複雜度 | 環偵測 | 特色 |
|---|---|---|---|---|
| Kahn’s BFS | O(V + E) | O(V + E) | 自然偵測(count < n) | 直觀、支援層級計算 |
| DFS 後序反轉 | O(V + E) | O(V + E)(遞迴棧) | 需三色標記 | 程式碼更精簡 |
| 帶層級 Kahn | O(V + E) | O(V + E) | 自然偵測 | 可直接得到最短並行輪數 |
兩種主要演算法的時間與空間複雜度相同,皆為 O(V + E),但實務上各有優劣:
- Kahn’s BFS:程式流程清晰、易於偵錯;結合最小堆可輸出字典序最小的拓撲排序;天然支援層級(並行任務)資訊
- DFS 後序反轉:程式碼更短;但遞迴深度可能超過 Stack 限制,需考慮改用迭代版本
變體與延伸
字典序最小的拓撲排序
標準 Kahn’s Algorithm 用普通 Queue,取出順序取決於加入順序。若要輸出字典序最小的拓撲排序,只需把 Queue 換成最小堆(Min-Heap / Priority Queue):
// 用排序陣列模擬 Min-Heap(面試可接受)
function lexSmallestTopoSort(n: number, edges: [number, number][]): number[] {
const graph: number[][] = Array.from({ length: n }, () => []);
const inDegree = new Array(n).fill(0);
for (const [u, v] of edges) {
graph[u].push(v);
inDegree[v]++;
}
// 初始化:保持升序排列以模擬 Min-Heap
const minHeap: number[] = [];
for (let i = 0; i < n; i++) if (inDegree[i] === 0) minHeap.push(i);
minHeap.sort((a, b) => a - b);
const result: number[] = [];
while (minHeap.length > 0) {
const u = minHeap.shift()!; // 取出最小值
result.push(u);
for (const v of graph[u]) {
if (--inDegree[v] === 0) {
// 二分搜尋找插入位置,維持有序
let lo = 0, hi = minHeap.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (minHeap[mid] < v) lo = mid + 1;
else hi = mid;
}
minHeap.splice(lo, 0, v);
}
}
}
return result.length === n ? result : []; // 空陣列代表有環
}
最長路徑(DAG 上的動態規劃)
在 DAG 上求最長路徑是拓撲排序最重要的延伸應用。先做拓撲排序,再按拓撲順序做 DP:
// 在 DAG 上求最長路徑(可代表最多串連任務數)
function longestPathInDAG(n: number, edges: [number, number][]): number {
const graph: number[][] = Array.from({ length: n }, () => []);
const inDegree = new Array(n).fill(0);
for (const [u, v] of edges) {
graph[u].push(v);
inDegree[v]++;
}
// Step 1:Kahn's Algorithm 得到拓撲順序
const queue: number[] = [];
for (let i = 0; i < n; i++) if (inDegree[i] === 0) queue.push(i);
// dp[i] = 以節點 i 為終點的最長路徑長度
const dp = new Array(n).fill(1);
while (queue.length > 0) {
const u = queue.shift()!;
for (const v of graph[u]) {
// Step 2:按拓撲順序做 DP 轉移
dp[v] = Math.max(dp[v], dp[u] + 1);
if (--inDegree[v] === 0) queue.push(v);
}
}
return Math.max(...dp);
}
// 使用範例
console.log(longestPathInDAG(6, [[0,2],[0,3],[1,3],[1,4],[2,5],[3,5],[4,5]]));
// 輸出:4(最長路徑:0→2→5 或 1→3→5 等)
面試考點
拓撲排序在面試中有兩種核心考法,必須熟練:
考法一:環偵測(Can I finish?)
題目通常說「判斷是否可以完成所有任務」。本質就是判斷圖是否為 DAG。使用 Kahn’s Algorithm,若最終 count < n 則有環。
考法二:求排序順序(What is the order?)
題目要求輸出一個合法的完成順序。直接回傳 Kahn’s Algorithm 的結果陣列即可。若有環則回傳空陣列。
考法三:最少輪次(How many rounds?)
題目問「最少需要幾個學期/批次可以完成」。使用帶層級的 Kahn,答案就是 layers.length。
建圖方向要特別注意:題目通常說「修 A 前需先修 B」,代表邊是 B → A(先修 B,後修 A)。建圖時方向容易搞錯。
LeetCode 練習
207. Course Schedule(環偵測)
// 給定 numCourses 門課與 prerequisites 陣列
// prerequisites[i] = [a, b] 表示修 a 之前必須先修 b(邊:b → a)
// 判斷能否完成所有課程
function canFinish(numCourses: number, prerequisites: number[][]): boolean {
const graph: number[][] = Array.from({ length: numCourses }, () => []);
const inDegree = new Array(numCourses).fill(0);
for (const [course, pre] of prerequisites) {
graph[pre].push(course); // 邊:pre → course
inDegree[course]++;
}
const queue: number[] = [];
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) queue.push(i);
}
let completed = 0;
while (queue.length > 0) {
const course = queue.shift()!;
completed++;
for (const next of graph[course]) {
inDegree[next]--;
if (inDegree[next] === 0) queue.push(next);
}
}
return completed === numCourses; // 全部處理完 → 無環
}
// 時間:O(V+E),空間:O(V+E)
210. Course Schedule II(求拓撲順序)
// 在 207 基礎上,回傳一個合法修課順序;若有環則回傳空陣列
function findOrder(numCourses: number, prerequisites: number[][]): number[] {
const graph: number[][] = Array.from({ length: numCourses }, () => []);
const inDegree = new Array(numCourses).fill(0);
for (const [course, pre] of prerequisites) {
graph[pre].push(course);
inDegree[course]++;
}
const queue: number[] = [];
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) queue.push(i);
}
const order: number[] = [];
while (queue.length > 0) {
const course = queue.shift()!;
order.push(course);
for (const next of graph[course]) {
inDegree[next]--;
if (inDegree[next] === 0) queue.push(next);
}
}
return order.length === numCourses ? order : [];
}
// 時間:O(V+E),空間:O(V+E)
269. Alien Dictionary(外星字典)
外星字典是拓撲排序的經典難題:給定按外星語字典序排列的單詞列表,推導出字母的相對順序。
// 給定排好序的單字列表,推導出字母之間的相對大小順序
function alienOrder(words: string[]): string {
// 收集所有出現過的字母
const chars = new Set(words.join(''));
const n = chars.size;
const charIndex = new Map<string, number>();
let idx = 0;
for (const c of chars) charIndex.set(c, idx++);
const graph: number[][] = Array.from({ length: n }, () => []);
const inDegree = new Array(n).fill(0);
// 相鄰單字兩兩比較,找出字母順序關係
for (let i = 0; i < words.length - 1; i++) {
const w1 = words[i], w2 = words[i + 1];
const len = Math.min(w1.length, w2.length);
let found = false;
for (let j = 0; j < len; j++) {
if (w1[j] !== w2[j]) {
// w1[j] 排在 w2[j] 之前(邊:w1[j] → w2[j])
const u = charIndex.get(w1[j])!;
const v = charIndex.get(w2[j])!;
graph[u].push(v);
inDegree[v]++;
found = true;
break;
}
}
// 若 w1 是 w2 的前綴且 w1 更長,則字典序無效(如 ["abc", "ab"])
if (!found && w1.length > w2.length) return '';
}
// Kahn's Algorithm
const queue: number[] = [];
for (let i = 0; i < n; i++) if (inDegree[i] === 0) queue.push(i);
const indexChar = new Map<number, string>();
for (const [c, i] of charIndex) indexChar.set(i, c);
let result = '';
while (queue.length > 0) {
const u = queue.shift()!;
result += indexChar.get(u);
for (const v of graph[u]) {
inDegree[v]--;
if (inDegree[v] === 0) queue.push(v);
}
}
return result.length === n ? result : ''; // 有環則字典序矛盾
}
// 時間:O(C),C 為所有單字的字母總數;空間:O(1)(最多 26 個字母)
802. Find Eventual Safe States(反向圖 + 拓撲排序)
// 安全節點:從該節點出發的所有路徑,最終都終止於終端節點(無出邊)
// 等同於:不在任何環上,且不指向環中節點的節點
//
// 關鍵洞見:在反向圖中,終端節點(原圖出度=0)的入度=0
// 對反向圖做拓撲排序,能被排入的節點即為安全節點
function eventualSafeNodes(graph: number[][]): number[] {
const n = graph.length;
const reverseGraph: number[][] = Array.from({ length: n }, () => []);
const outDegree = new Array(n).fill(0); // 原圖出度 = 反向圖「入度」
for (let u = 0; u < n; u++) {
for (const v of graph[u]) {
reverseGraph[v].push(u); // 反向邊 v → u
outDegree[u]++;
}
}
// 原圖終端節點(出度=0)在反向圖中入度=0,最先處理
const queue: number[] = [];
for (let i = 0; i < n; i++) {
if (outDegree[i] === 0) queue.push(i);
}
const safe = new Array(n).fill(false);
while (queue.length > 0) {
const u = queue.shift()!;
safe[u] = true;
for (const prev of reverseGraph[u]) {
outDegree[prev]--;
if (outDegree[prev] === 0) queue.push(prev);
}
}
const result: number[] = [];
for (let i = 0; i < n; i++) {
if (safe[i]) result.push(i); // 結果自然按升序排列
}
return result;
}
// 時間:O(V+E),空間:O(V+E)
1136. Parallel Courses(最少學期數)
// 每個學期只能修入度為 0 的課程,求修完所有課程的最少學期數
// 若有環(某些課程永遠無法修),回傳 -1
function minimumSemesters(n: number, relations: number[][]): number {
const graph: number[][] = Array.from({ length: n + 1 }, () => []);
const inDegree = new Array(n + 1).fill(0);
for (const [prev, next] of relations) {
graph[prev].push(next);
inDegree[next]++;
}
// 初始層:所有入度為 0 的課程(課程編號從 1 開始)
let currentLayer: number[] = [];
for (let i = 1; i <= n; i++) {
if (inDegree[i] === 0) currentLayer.push(i);
}
let semesters = 0;
let completed = 0;
while (currentLayer.length > 0) {
semesters++;
completed += currentLayer.length;
const nextLayer: number[] = [];
for (const course of currentLayer) {
for (const next of graph[course]) {
inDegree[next]--;
if (inDegree[next] === 0) nextLayer.push(next);
}
}
currentLayer = nextLayer;
}
return completed === n ? semesters : -1; // 若有環,部分課程無法修完
}
// 時間:O(V+E),空間:O(V+E)
LeetCode 題目總覽
| 題號 | 題目 | 難度 | 核心技巧 |
|---|---|---|---|
| 207 | Course Schedule | Medium | Kahn’s Algorithm 環偵測 |
| 210 | Course Schedule II | Medium | Kahn’s Algorithm 求拓撲順序 |
| 269 | Alien Dictionary | Hard | 建圖 + Kahn’s Algorithm |
| 802 | Find Eventual Safe States | Medium | 反向圖 + 拓撲排序 |
| 1136 | Parallel Courses | Medium | 帶層級的 Kahn(最少學期) |
總結
拓撲排序是圖論中最實用的演算法之一,核心在於處理有方向性的依賴關係。本文介紹的兩種實作各有適用場景:
- Kahn’s Algorithm(BFS):直觀、易理解,自然偵測環,支援層級計算和字典序最小排序;是面試與實務的首選
- DFS 後序反轉法:程式碼精簡,但需要額外的三色標記才能偵測環;適合在已知無環的 DAG 上快速取得排序
解題時,記住三個關鍵問題:
- 能不能完成?→ 環偵測(count < n 即有環)
- 完成的順序?→ Kahn’s Algorithm 結果序列
- 最少幾輪?→ 帶層級的 Kahn,答案為
layers.length
下一篇將進入 最小生成樹(Minimum Spanning Tree),介紹 Kruskal 與 Prim 演算法——同樣是圖論中不可或缺的核心主題。
如有任何問題或疑惑,歡迎在下方留言,或透過
聯絡頁面與我聯繫,希望這篇文章能幫助你深刻理解拓撲排序的精髓!