iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

30 天的資料結構與演算法之旅系列 第 25 篇

[Day 25] Graph Traversal (1):DFS 一路走到底

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20261009/20168201kxPL9MWUIX.png

前言

昨天把地圖存進 Graph 之後,如果要問「台北和新竹之間有沒有路」只要一次查找就能回答。但如果問的是台北和台中呢?這兩個城市之間沒有直接相連的路,hasEdge 會回 false,可是地圖上明明可以從台北經桃園、新竹一路開到台中啊?按照昨天的處理方式來回答用戶問題,很明顯會被客訴(?

問題在於,Adjacency List 記下的是「誰和誰直接相連」,而「有沒有一條路連得過去」得真的走一趟才知道。

那要怎麼走呢?Day 19 已經寫過 Tree 的走訪,而 Tree 也是 Graph 的一種,應該可以沿用那套邏輯吧?今天就從 preorder 的寫法開始,把它改成走訪這張地圖,看看深度優先走訪 (Depth-First Search,簡稱 DFS) 遇到 Graph 時,還需要多處理什麼~

把 Tree 的走訪搬到 Graph 上

先把 child 換成鄰居

Day 19 的 preorder 是先處理自己,再依序走訪 left、right subtree。換成城市地圖後,每個城市相連的地方不一定只有兩個,所以把 left、right 換成 neighbors(city) 提供的鄰居名單,逐一遞迴走訪:

function traverse(graph, city, list = []) {
  list.push(city); // 先處理自己

  for (const next of graph.neighbors(city)) {
    traverse(graph, next, list); // 再交代每個鄰居
  }
  return list;
}

graph 就是昨天的那個 Graph class,neighbors 把某個城市的鄰居名單拿出來,而走訪的對象沿用昨天的地圖,再加上一條桃園到苗栗的路,讓途中也有岔路可走:

const roads = new Graph();
roads.addEdge('台北', '桃園');
roads.addEdge('桃園', '新竹');
roads.addEdge('新竹', '台中');
roads.addEdge('台北', '宜蘭');
roads.addEdge('台北', '新竹');
roads.addEdge('桃園', '苗栗');

https://ithelp.ithome.com.tw/upload/images/20261009/20168201xCaMnn0Bpg.png
圖 1 台北與桃園都有岔路;台北可以直接抵達新竹,也可以經桃園抵達新竹

看起來,只要每個鄰居都走訪完,這一層呼叫就能結束。但每一次往鄰居走,都真的回得來嗎?

換到 Graph 上就繞不出來

現在拿上面那段程式跑這張地圖,從台北出發,結果是這樣:

traverse(roads, '台北');
// RangeError: Maximum call stack size exceeded

走訪連第三個城市都沒走到,台北的鄰居名單第一個是桃園,於是程式往桃園去;而桃園的鄰居名單裡有台北,於是程式又往台北去;台北的名單第一個還是桃園,於是又往桃園去,無限來回。把呼叫的順序列出來,會長這樣:

台北 → 桃園 → 台北 → 桃園 → 台北 → 桃園 → ⋯

這張地圖的路是雙向的,桃園的鄰居也包含台北。程式沒有記住自己來過,每次都繼續呼叫下一層,光是一條路就能讓 call stack 不斷增加,直到超過上限。

為什麼 Tree 的版本沒事呢?因為它只沿著 left、right 從 parent 往 child 走,沒有走回 parent 的操作,加上每個 node 從 root 出發只有一條路能抵達,所以不會重複走訪。同樣的寫法遇到 Undirected Graph 的鄰居名單,就把回頭的路也走進去了。

https://ithelp.ithome.com.tw/upload/images/20261009/20168201YGJRa7m7UK.png
圖 2 沒有記錄走過誰,走訪在一條路上無限來回

改成單行道也一樣要記

那如果把路改成單行道,讓桃園不能直接回台北呢?還是可能遇到問題。例如台北到桃園、桃園到新竹、新竹再回台北,若三條單行道形成一個環,程式就會沿著它一直繞:

台北 → 桃園 → 新竹 → 台北 → 桃園 → 新竹 → ⋯

那沒有環的單行道地圖呢?這時走訪會停下來,但停下來不代表沒事。假設台北到桃園、桃園到新竹、台北到新竹、新竹到台中,四條路都是單行道,也沒有任何一條路繞得回起點,跑出來的順序是:

台北 → 桃園 → 新竹 → 台中 → 新竹 → 台中

四個城市走了 6 步,新竹和台中各走了兩次,原因是新竹有兩條路徑通到它,一條經過桃園、一條從台北直達,而走訪不知道自己來過,於是把新竹底下的東西整個重做一次。城市再多幾層,重做的份量會跟著增加。

https://ithelp.ithome.com.tw/upload/images/20261009/20168201Lp6oDNJAlZ.png
圖 3 單行道有環會繞不停,無環也可能重複處理

由此可知,要讓每個城市只處理一次,不能只靠道路的方向或有沒有環,程式還需要記住自己走過誰。

Visited Set:走訪過程要自己記住的狀態

那要怎麼記呢?需要的東西其實只有一份「已經處理過的城市」清單,每處理一個城市就把它放進去,每次要處理一個城市之前先問清單一次。這份清單叫做 Visited Set,我們可以用 Set 存:

function traverse(graph, city, visited = new Set(), list = []) {
  if (visited.has(city)) return list; // 走過了就直接回去

  visited.add(city); // 標記成走過
  list.push(city);

  for (const next of graph.neighbors(city)) {
    traverse(graph, next, visited, list);
  }
  return list;
}

這裡先用 visited.has(city) 檢查,來過就直接返回;第一次來則立刻標記,再往鄰居走,而標記要放在遞迴之前,這樣鄰居回頭找到自己時,才會知道這裡已經有人處理了。

Set.has 平均是 O(1),適合這種反覆查詢;visited 和 list 則透過參數傳給下一層,讓整趟走訪共用同一份記錄;如果每次呼叫都在函式裡重新建立 visited,就記不住前面走過的城市了。

拿它再跑一次同一張地圖:

traverse(roads, '台北'); // [ '台北', '桃園', '新竹', '台中', '苗栗', '宜蘭' ]

這次六個城市各出現一次,從桃園回頭找到台北時,檢查會讓那次呼叫直接返回,桃園就能繼續走訪下一個鄰居。

DFS:一路走到底再回頭

走訪停下來了,但那個順序是怎麼來的呢?六個城市為什麼是 台北, 桃園, 新竹, 台中, 苗栗, 宜蘭 這個排法,而不是別的?

走一遍主例

跟著程式走一次,可以分成以下五個階段。這裡的編號是說明流程用的,和圖上標示的城市首次走訪順序不同:

  1. 從台北往桃園走。 台北的鄰居依序是桃園、宜蘭、新竹,所以先往桃園去。台北這一層暫停在第一次遞迴呼叫,等桃園的走訪結束。
  2. 經過新竹,深入到台中。 桃園跳過已走過的台北,往新竹走;新竹再跳過桃園,往台中走。這時已依序記下台北、桃園、新竹、台中。
  3. 退回桃園,先走剩下的苗栗。 台中的鄰居只有已走過的新竹,所以這一層結束。回到新竹後,剩下的台北也走過了,於是再返回桃園。桃園的名單是台北、新竹、苗栗,這時還有苗栗沒走過,所以先往苗栗去。苗栗唯一的鄰居桃園已走過,這一層便結束。
  4. 桃園的分支走完,才回台北走宜蘭。 從苗栗返回後,桃園的鄰居才全部處理完,控制權回到台北,繼續處理名單上的第二個鄰居。記下宜蘭後,因為它唯一的鄰居台北已經走過,所以返回。
  5. 檢查最後一個鄰居,結束走訪。 台北名單上剩下的新竹已經走過,跳過後,整趟走訪完成。

https://ithelp.ithome.com.tw/upload/images/20261009/20168201HGTSUNV7ZK.png
圖 4 先深入台中,退回桃園後繼續走苗栗;桃園的分支全部完成,才回台北走宜蘭

DFS 的重點:先深入,再換分支

這種走法就是 DFS。先給一句話的定義:

DFS 每次挑一個還沒走過的鄰居,沿著它一路深入到走不動,才退回上一個岔路換下一個。

鄰居的排列會影響結果,但不影響它是不是 DFS,例如台北先選宜蘭,就會先走完宜蘭再回來,得到 台北, 宜蘭, 桃園, 新竹, 台中, 苗栗。重點是選了一個鄰居後,會先把那條路走完,才換下一個。

遞迴版本:那疊待回頭的位置

剛才那趟走訪裡,「回頭」是怎麼發生的?程式裡沒有任何一行寫著回到哪個城市,程式卻知道要先退回桃園處理苗栗,之後才回台北處理宜蘭。

答案是 call stack,每次往下一個城市遞迴,就有一層新的呼叫疊上去,而那一層記著兩件事:

  1. 它處理到哪個城市
  2. 它的 for 迴圈停在名單的第幾個

剛進入台中的呼叫、還沒呼叫它的鄰居時,疊在 call stack 上的正是台北、桃園、新竹、台中四層,也就是從起點走到台中的那一條路徑本身。台中結束、那一層被移除,控制權落回新竹,而新竹那層記得自己的迴圈停在哪裡,於是接著往下一個鄰居問。

https://ithelp.ithome.com.tw/upload/images/20261009/20168201AhNz1KV2R5.png
圖 5 call stack 逐步深入到台中

https://ithelp.ithome.com.tw/upload/images/20261009/20168201AjDkWQA2at.png
圖 6 桃園剩下的苗栗走完、整條分支返回,才輪到台北名單上的宜蘭

顯式 Stack 版本:把那疊拿出來自己管

如果不想用遞迴一層層增加,可以改用迴圈,自己建立一個 Stack 保存待處理的城市。每次拿出最上面的城市,處理完後再把還沒走過的鄰居推進去:

function traverseWithStack(graph, start) {
  const visited = new Set();
  const list = [];
  const stack = [start]; // 還沒處理的城市

  while (stack.length > 0) {
    const city = stack.pop();
    if (visited.has(city)) continue; // 走過了就跳過這一輪

    visited.add(city);
    list.push(city);

    for (const next of graph.neighbors(city)) {
      if (!visited.has(next)) stack.push(next);
    }
  }
  return list;
}

這次 while 負責取出城市,裡面的 for 只把鄰居放進 Stack,留待後續處理,只要還有沒走過的鄰居,新推入的項目就會壓在舊岔路上方,下一輪優先取出;走不動時,才輪到底下留下來的岔路。這就是迴圈版仍會一路深入的原因。

traverseWithStack(roads, '台北'); // [ '台北', '新竹', '台中', '桃園', '苗栗', '宜蘭' ]

順序和遞迴版的 台北, 桃園, 新竹, 台中, 苗栗, 宜蘭 不同,因為 Stack 是後進先出,最後被推進去的鄰居會最先被拿出來。

那疊 stack 裡放的是什麼

比較兩個版本的那一疊,差別如下:

那疊裡放的是 誰在管
遞迴版 從起點走到目前這個城市的那一條路徑 引擎維護的 call stack
顯式 Stack 版 已經被某個鄰居推進來、但還沒輪到的城市 自己宣告的 stack

例如處理完台北,stack 是 [桃園, 宜蘭, 新竹],接著拿出新竹時,桃園還沒被處理,所以新竹又把桃園推入一次,變成 [桃園, 宜蘭, 桃園, 台中]。

這也解釋了為什麼推入前已經檢查 visited,取出時還要再檢查:放進 Stack 不等於已經處理過。兩份桃園先後被取出時,第一份會正常處理,另一份則由 if (visited.has(city)) continue 跳過。

https://ithelp.ithome.com.tw/upload/images/20261009/20168201lruFsjpyLm.png
圖 7 顯式 Stack 版每一輪的 stack 內容

走訪要花多少成本

先看時間。兩種寫法都讓每個城市只處理一次,但還得逐一檢查鄰居,這些檢查加起來有多少呢?

不能只算城市數

拿兩張都有六個城市的地圖來比較:一張沿用目前的 6 條路,另一張把每兩個城市都連起來,共有 15 條路。先只計算「處理城市」和「檢查鄰居」這兩類操作:

地圖 每個城市的鄰居數 要處理的城市 鄰居檢查 合計
6 條路 台北 3、桃園 3、新竹 3、台中 1、宜蘭 1、苗栗 1 6 3 + 3 + 3 + 1 + 1 + 1 = 12 18 步
15 條路 六個城市都是 5 6 5 × 6 = 30 36 步

走訪要處理的城市兩張都是六個,鄰居檢查卻從 12 次變成 30 次,步數因此差了 18 步。vertex 的數量描述不了 Graph 走訪的成本,得把 edge 也算進去。

走訪的時間複雜度:O(V + E) 是怎麼來的

把剛才數的兩件事寫成一般的形式,就得到 DFS 的複雜度。V 是 vertex 數量,E 是 edge 數量:

  • 每個 vertex 至多進入處理流程一次,這部分是 V
  • 每個 vertex 的鄰居名單各被掃過一遍,加起來就是所有 edge 被看到的次數

第二點要注意的是,一條路會被看到兩次,以台北到桃園那條路為例,處理台北的時候會在台北的名單裡看到桃園一次,處理桃園的時候又會在桃園的名單裡看到台北一次,同一條路數了兩遍。實際的步數因此更接近 V + 2E,而 Big O 不計常數,寫成 O(V + E)。

這裡算的是能走到所有城市的情況;如果地圖有不相連的區塊,從一個起點出發只會花時間處理它走得到的部分。

顯式 Stack 版還多了重複推入的項目,不過它們最多也只有 edge 數量的量級,而且各在取出時被 visited 擋掉一次,兩種寫法因此同樣落在 O(V + E)。

O(V + E) 這個結論綁在 Adjacency List 上

目前這些說法有個前提,就是本篇的程式從頭到尾用的都是昨天的 Adjacency List,neighbors 拿到的是那個城市自己的名單,掃幾筆就是它真的有幾個鄰居。

換成 Adjacency Matrix 就不是這樣了,昨天算過,Adjacency Matrix 列一個 vertex 的鄰居要掃完一整列,因為「沒有連線」也各佔一格,不管那個城市實際上有幾個鄰居,都得看 V 格。走訪要對每個 vertex 各做一次這件事,V 個 vertex 各掃 V 格就是 V² 格,因此同樣的 DFS 走訪,改用 Adjacency Matrix 表示法之後,時間複雜度會變成 O(V²)。

走訪要另外佔多少空間

接著看空間,兩種寫法都得記錄走訪狀態,這裡一樣用 Auxiliary Space 算法,不計入要回傳的結果 list。

遞迴版記的是 call stack 與 visited,而 call stack 跟著遞迴深度增加,如果 V 個城市排成一條線,從一端出發就能一路深入到另一端,深度最壞是 O(V);visited 最多也記下 V 個城市,兩者合計 O(V)。顯式 Stack 版把 call stack 換成自己宣告的 stack,visited 一樣,量級也相同。

這裡要留意的是,不能因為某次走訪只鑽了幾層,就把深度當成一個比 O(V) 更小的保證。Tree 的 height 還有形狀撐著,Graph 卻沒有任何規則限制它不能長成一條線。

補充:本篇這兩份寫法還各多佔一點空間

目前我們的寫法,neighbors() 每次會把 Set 展開成新的陣列。遞迴版往下走時,上層還沒跑完的 for 迴圈都留著自己那一份;顯式 Stack 版則是 stack 收得下重複項目。兩者最壞都會隨 edge 數增加,讓實際的額外空間到 O(V + E)。若改成直接迭代原本的鄰居集合、也不讓重複項目進 Stack,才是通常說的 O(V)。

兩種表示法的成本比較

剛才算的時間都建立在 Adjacency List 上,換一種表示法會變。兩種一起比較如下,以下都以能走到所有城市的情況來看:

Graph 表示法 儲存 Graph 的空間 DFS 時間
Adjacency List O(V + E) O(V + E)
Adjacency Matrix O(V²) O(V²)

Adjacency List 那一列的兩個 O(V + E) 是兩件事:左邊是 Graph 本身佔的空間,昨天算過;右邊是這趟走訪要花的時間,剛才算過。至於上一節那個也寫成 O(V + E) 的額外空間,指的是走訪過程另外建立的東西,不在這張表裡。

DFS 直接能回答的問題

回到前言那個問題:台北和台中之間有沒有路可以抵達呢?

利用 DFS,我們已經有走訪結果了,只要檢查裡面是否包含台中,就能回答。為了同時看看走不到的情況,這裡再加上花蓮到台東這條路,讓它和原本六個城市沒有任何連線:

roads.addEdge('花蓮', '台東');

https://ithelp.ithome.com.tw/upload/images/20261009/20168201Dyq5Aq0XtA.png
圖 8 加上花蓮到台東之後,這張地圖分成互不相連的兩塊

兩點之間有沒有路

有了 DFS,要問「兩點之間有沒有路」,只要從起始點開始走訪,然後把走訪跑完,再問 visited 裡有沒有目的地即可。這裡另外寫一份而不直接用 traverse,是因為只需要 visited,不必累積走訪順序的 list;以下也假設起點與目的地都已經在 Graph 裡:

function hasPath(graph, from, to) {
  const visited = new Set();

  function visit(city) {
    if (visited.has(city)) return;
    visited.add(city);
    for (const next of graph.neighbors(city)) visit(next);
  }
  visit(from);

  return visited.has(to);
}

hasPath(roads, '台北', '台中'); // true
hasPath(roads, '台北', '花蓮'); // false

hasPath 把從台北走得到的城市記進 visited,台中在裡面、花蓮不在,所以分別得到 true 和 false。這樣就能回答前言的問題,即使沒有直接相連的 edge,仍可能經過其他城市抵達目的地。

從一個起點走得到哪些城市

hasPath 其實只用了走訪結果的一小部分。走完之後 visited 裡裝的是「從這個起點出發走得到的所有城市」,這份名單本身還可以回答「這起點能到哪些城市」:

traverse(roads, '台北'); // [ '台北', '桃園', '新竹', '台中', '苗栗', '宜蘭' ]
traverse(roads, '花蓮'); // [ '花蓮', '台東' ]

兩趟走訪分別列出 6 個和 2 個城市,剛好是這張地圖互不相連的兩塊,而對這種 Undirected Graph 來說,也可以從任一城市跑一次 DFS,檢查走到的城市數是否等於總數,判斷整張圖是不是全部連在一起。

延伸:ESM 如何處理 module 之間的依賴

再來小小補充下實際應用~平常用 JavaScript 開發時,我們會把程式拆成不同的 module,再透過 import 使用其他檔案匯出的值。例如兩個功能都需要同一份設定,就可以把設定放在獨立的 module,讓兩邊各自 import。

如果某個 module 在檔案最外層就要用這份設定來計算自己的值,那執行到這行之前,設定就得先準備好。我們平常只寫了 import,並沒有另外寫一段程式,交代「先執行設定檔,再執行使用設定的功能」。這個先後關係是怎麼被安排好的呢?

要算出自己的值,得先準備好依賴

先用一個簡化的例子來看,假設有四個檔案,a.mjs 是入口,會 import b.mjs 和 d.mjs;而 b.mjs 和 d.mjs 都需要 c.mjs 匯出的值,才能算出自己要匯出的結果。這裡先用字串相加代表計算,並在每個檔案裡加上 console.log,方便觀察過程:

// a.mjs
import { b } from './b.mjs';
import { d } from './d.mjs';
console.log('a 執行');

// b.mjs
import { c } from './c.mjs';
console.log('b 執行');
export const b = c + 'b';

// d.mjs
import { c } from './c.mjs';
console.log('d 執行');
export const d = c + 'd';

// c.mjs
console.log('c 執行');
export const c = 'c';

把它們分別存成四個檔案,再用 node a.mjs 執行,會得到:

c 執行
b 執行
d 執行
a 執行

可以看到,c.mjs 先執行,把 c 的值準備好,接著才輪到需要它的 b.mjs 和 d.mjs,最後才是入口 a.mjs。而且,雖然有兩個檔案都需要 c.mjs,它的程式碼也只跑了一次。JavaScript 怎麼沿著依賴找到這個順序,又怎麼知道哪些已經處理過?這就能接回今天的 DFS 和 visited。

這裡先從上面這種沒有循環依賴、也沒有 top-level await(寫在檔案最外層的 await)的例子看起。

沿著 import 一路找到最底下

先把檔案間的關係畫出來,每個檔案是一個 vertex,import 則是一條指向被依賴檔案的 edge,這四個檔案於是也組成了一張 Graph:a 指向 b 和 d,b 和 d 又各自指向 c。

從 a.mjs 出發,它的第一個依賴是 b.mjs,所以先往 b.mjs 走。但 b.mjs 還需要 c.mjs,例如裡面的 const b = c + 'b' 就要用到 c,於是先繼續往 c.mjs 走。到了 c.mjs,它沒有其他依賴了,這時才執行它的程式碼,印出 c 執行,並把 c 初始化成字串 'c'。

c.mjs 執行完,才回到 b.mjs,印出 b 執行,接著算出 b;然後回到 a.mjs,繼續處理下一個依賴 d.mjs,而 d.mjs 也依賴 c.mjs,但這是剛才已經執行過的同一個 module,不會再跑一次,直接接著執行 d.mjs。最後,a.mjs 的兩個依賴都處理完,才輪到它印出 a 執行。

https://ithelp.ithome.com.tw/upload/images/20261009/20168201CLOnM1OLQW.png
圖 9 沿 import 深入到 c 才開始執行,兩條路徑遇到同一個 c 只執行一次

這個「沿著一條路深入,走到底才回頭,再換下一條」的順序,是不是和前面的走訪城市很像?它也是 DFS,只是執行自己被放在依賴都處理完之後。

前面的城市走訪是一進到城市就 list.push(city),所以先記下起點,再往鄰居走;這裡則先處理依賴,回頭時才執行自己的程式碼。Day 19 提過,先處理子節點、最後才處理自己的順序叫 postorder,這例子的執行順序也可以這樣理解。

為什麼 c 不會被執行兩次?

剛才走到 d.mjs 時,有提到「c.mjs 執行過了,所以跳過」,那 JavaScript 怎麼知道它執行過了呢?

ESM 在每個 module 的記錄上放了一個 [[Status]] 欄位,執行流程會先檢查它的狀態。如 ECMA-262 的 InnerModuleEvaluation 所述,第 2 步與第 2.a 步寫的是:

If module.[[Status]] is either evaluating-async or evaluated, then

If module.[[EvaluationError]] is empty, return index.

也就是說,如果 module 已經處於 evaluating-async 或 evaluated 狀態,而且沒有記錄執行錯誤,這次呼叫就直接返回,不再往下執行。

套回前面那例子,第一次沿著 b 走到 c 時,c 會進入執行流程,完成後標成 evaluated;之後沿著 d 再遇到它,就會符合上面的返回條件。因此,兩個檔案 import 的是同一個 c.mjs,不會各自讓它的頂層程式碼重跑一次。

規格裡也看得到這個 DFS

除了剛才的狀態檢查,規格怎麼安排依賴與自己的執行順序呢?這裡需要先區分兩件事:建立 import 名字與 export 綁定之間的對應,是連結(Link)階段的工作;等連結完成,真正去跑 console.log、計算 const b = c + 'b',才是執行(Evaluate)階段的工作。

對應剛才輸出順序的演算法叫 InnerModuleEvaluation。以下整理這個同步、無環範例的五個步驟,右欄摘錄對應的規格原文,方便對照。左欄是本文的流程編號,右欄則標出規格原本的步驟編號:

簡化流程 對應規格原文
1. 看到已經執行完成,而且沒有執行錯誤,就直接返回。 第 2、2.a 步:If module.[[Status]] is either evaluating-async or evaluated, thenIf module.[[EvaluationError]] is empty, return index.
2. 記下這個 module 正在處理。 第 5 步:Set module.[[Status]] to evaluating.
3. 逐一遞迴處理它 import 的 module。 第 11 步:For each ModuleRequest Record request of module.[[RequestedModules]], do其中第 11.b 步:Set index to ? InnerModuleEvaluation(requiredModule, stack, index).
4. 執行自己的程式碼。 第 13.a 步:Perform ? module.ExecuteModule().
5. 記下這個 module 已經執行完成。 第 16.b.v 步:If requiredModule.[[AsyncEvaluationOrder]] is unset, set requiredModule.[[Status]] to evaluated.

在我們這個沒有循環依賴、也沒有非同步等待的例子裡,c 會在返回前被標成 evaluated,因此之後從 d 再遇到它時,就會符合第一列的直接返回條件。

而執行順序的重點在第 3、4 列:先處理依賴,才執行自己。規格把 ExecuteModule() 放在遞迴處理依賴的迴圈之後,這個例子會等依賴都處理完,才執行自己的程式碼。詳細可參考 ECMA-262:InnerModuleEvaluation。

而在更早的連結階段,InnerModuleLinking 也會沿著這張依賴圖做 DFS,只是它回頭時做的事情是建立 module 的環境與綁定。也就是說,同一張依賴圖會在不同階段被走訪,剛才觀察到的 console.log 順序,對應的是執行階段那一次。連結階段的詳細流程可參考 ECMA-262:InnerModuleLinking。

補充一下,ESM 也允許循環依賴,module 的依賴圖因此不一定是 DAG。有環時,不能要求環上的每個 module 都等依賴跑完才開始;而有 top-level await 時,還需要處理非同步等待。規格為這些情況記錄了更多狀態,因此這裡介紹的步驟只是簡化版本~循環依賴與非同步執行的處理方式,可參考 ECMA-262:Cyclic Module Records。

小結

小小總結一下今天對 DFS 的認識~

  • 為什麼需要走訪 Graph? 因為昨天的 Adjacency List 只記得「誰和誰直接相連」,要回答「台北和台中之間有沒有路」,還得沿著關係走訪。過程中可能再次遇到同一個城市,因此需要記錄走過誰,避免無限往返或重複處理。
  • 有了 DFS 之後差在哪? 走訪跑完,visited 裡就是從起點走得到的所有城市,「兩點之間有沒有路」和「從一個起點走得到哪些城市」都可以透過這份清單回答。代價是需要保存走訪狀態,而時間要連表示法一起看,用 Adjacency List 存是 O(V + E)。
  • DFS 到底是什麼? 每次挑一個還沒走過的鄰居,沿著它一路深入到走不動,才退回上一個岔路換下一個。先挑哪一個鄰居不影響它是不是 DFS,深入優先於展開才是它規定的事。

實際使用時,還可以記住幾件事~

  • 遞迴版與顯式 Stack 版是相同邏輯,差在那疊待處理的東西由誰管、裡面放什麼:遞迴版交給 call stack,疊的是從起點走到目前這個城市的那條路徑;顯式版自己宣告一個 stack,裡面是已經被推進來、還沒輪到的城市,同一個城市可能被推進去兩次,所以取出時要再檢查一次
  • O(V + E) 是 DFS 加上 Adjacency List 的結果,不是 DFS 單獨的性質;換成 Adjacency Matrix,列鄰居就得掃完一整列,同一支程式會變成 O(V²)
  • 走訪狀態之外,實作細節也會佔空間:neighbors() 每次把 Set 展開成新陣列,上層還沒跑完的迴圈都留著那一份,讓本篇這份實作的額外空間到 O(V + E);顯式 Stack 版的 stack 收得下重複項目,長度同樣是跟著 edge 走

圖表說明:本文圖表由作者整理,並使用 Claude Code 協助繪製;內容與數據由作者確認。

Reference


上一篇
[Day 24] Graph
系列文
30 天的資料結構與演算法之旅 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言