第五章面試題解答
5-31.
DFS和BFS使用了哪些數據結構?
解析:
其實剛讀完這一章,我一開始想到的是用鄰接表來表示圖,但其實用鄰接矩陣也能實現啊?后來才發現應該回答,BFS用隊列實現;DFS可以用棧實現也可以改寫成遞歸形式。用棧來消除遞歸改寫DFS也出現在《算法導論》的練習題22.3-6。
5-32.
寫一個函數,在遍歷二叉查找數的時候,輸出第i個結點。
解析:
模仿DFS遍歷時維護一個進入時間數組和完成時間數組的特點,維護一個全局變量n,在中序遍歷的時候,每遍歷一個結點就n++,直到n=i時打印這個結點,或者遍歷完成時仍然n!=i時報錯即可。
題外話:
第5章“圖遍歷”的Interview Problems部分確實只有這兩題,而第6章“帶權圖算法”干脆就沒Interview Problems這一部分。其實圖本身的表示就比較復雜,幾個基本的圖算法雖然思路不難,但是代碼量不小,同時要寫繁瑣的初始化方法,寫具體算法實現時還要用到各種輔助數據結構,編碼起來想少都不行。況且就算真寫出來了,正確性的證明又很費功夫(比如拓撲排序、強聯通分支),因此面試時除了專門做這個方向的,很少會考到具體的代碼書寫,更不用說其他變形、改進了。這也就是為什么圖相關的面試題並不多的原因。
另外提一下,《算法設計手冊》上的拓撲排序和強聯通分支算法是基於邊分類的,而且它把DFS寫成了一個可擴充的框架;而《算法導論》則是利用最后完成時間來實現這兩個算法,在此之前把DFS寫成了一個子程序供這兩個算法調用。究竟孰優孰劣我不評價,從先入為主和對我而言易於理解的角度和來說,我更傾向於使用后者。
DFS應用之找掛接點(Articulation Vertices,《算法導論》中文版的翻譯)
既然提到了《算法設計手冊》上DFS的框架寫法了,這個算法正好來進行演示。(《算法導論》思考題22-2曾提到了這個概念)。
先來看看《算法設計手冊》版DFS框架:

//圖用鄰接表實現 //entry_time[] 某結點開始處理的時間 //exit_time[] 某結點處理完畢的時間 //discoverd[] 某個結點是否已被發現 //process_vertex_early() 某個結點剛發現時采取的處理 //process_edge() 對邊的處理 //process_vertex_late() 某個結點所有鄰接邊處理完后的動作 //以上三個函數決定了DFS的行為,如果只需要基本的功能,可以實現為空操作,或者輸出該結點/邊用於追蹤遍歷過程 dfs(graph *g, int v) { edgenode *p; /* temporary pointer */ int y; /* successor vertex */ if (finished) return; /* allow for search termination */ discovered[v] = TRUE; time = time + 1; entry_time[v] = time; process_vertex_early(v); p = g->edges[v]; while (p != NULL) { y= p->y; if (discovered[y] == FALSE) { parent[y] = v; process_edge(v,y); dfs(g,y); } else if ((!processed[y]) || (g->directed)) process_edge(v,y); if (finished) return; p = p->next; } process_vertex_late(v); time = time + 1; exit_time[v] = time; processed[v] = TRUE; }
掛接點是指,如果我們從連通圖中刪除這個結點,會導致圖不再連通。下圖中的白點就是掛接點,可以把它看作為圖上最脆弱的點。
使用DFS或BFS寫一個暴力算法很簡單:刪除一個結點,用DFS或BFS判斷是否連通;恢復原圖,刪除下一個結點繼續判斷,直至所有接點都判斷過。如果結點數n個,邊數m個,暴力算法時間復雜度為O(n(m+n))。
現在用DFS遍歷時生成樹的角度來看。對於這棵樹上所有在原圖的邊,歸為TREE邊;其余所有邊是BACK邊,即它們指向一個先於這個結點遍歷的另一個結點。
可以發現一些規律:
DFS樹的葉結點不可能是掛接點,刪去它樹的連通性未被破壞。只有樹的內結點可能是掛接點。
對於DFS樹的根,如果它只有一個孩子,那么刪去它和刪去一個葉結點是一樣的。而孩子多於1個時,刪去根會導致孩子們不再連通,也即它是掛接點。
對於一個BACK邊,它連接的兩個結點的TREE路徑(即DFS時形成的路徑)上的所有結點都不可能是掛接點。
尋找掛接點需要維護BACK邊連接DFS樹上結點與其祖先的信息。用reachable_ancesor[v]表示結點v用BACK邊能連接的最老祖先(初始化為v),tree_out_degree[v]表示結點在DFS樹的出度。edge_classification(int x,int y)用於判斷(x,y)是TREE還是BACK。
int reachable_ancestor[MAXV+1]; /* earliest reachable ancestor of v */ int tree_out_degree[MAXV+1]; /* DFS tree outdegree of v */ process_vertex_early(int v) { reachable_ancestor[v] = v; } process_edge(int x, int y) { int class; /* edge class */ class = edge_classification(x,y); if (class == TREE) tree_out_degree[x] = tree_out_degree[x] + 1; if ((class == BACK) && (parent[x] != y)) { if (entry_time[y] < entry_time[ reachable_ancestor[x] ] ) reachable_ancestor[x] = y; } }
int edge_classification(int x, int y) { if (parent[y] == x) return TREE; else return BACK; }
下面是v與祖先的連通性和v是否是掛接點的關系,一共是三種情況:
用代碼實現在process_vertex_late()里,即:
process_vertex_late(int v) { bool root; /* is the vertex the root of the DFS tree? */ int time_v; /* earliest reachable time for v */ int time_parent; /* earliest reachable time for parent[v] */ if (parent[v] < 1) { /* test if v is the root */ if (tree_out_degree[v] > 1) printf("root articulation vertex: %d \n",v); return; } root = (parent[parent[v]] < 1); /* is parent[v] the root? */ if ((reachable_ancestor[v] == parent[v]) && (!root)) printf("parent articulation vertex: %d \n",parent[v]); if (reachable_ancestor[v] == v) { printf("bridge articulation vertex: %d \n",parent[v]); if (tree_out_degree[v] > 0) /* test if v is not a leaf */ printf("bridge articulation vertex: %d \n",v); } time_v = entry_time[reachable_ancestor[v]]; time_parent = entry_time[ reachable_ancestor[parent[v]] ]; if (time_v < time_parent) reachable_ancestor[parent[v]] = reachable_ancestor[v]; }
最后幾行用entry_time[v]表示v的年齡,time_v是v通過BACK邊達到的最老結點。如果v的parent能通過v的BACK到達v的最老祖先,那么parent(v)肯定不是掛接點,下次處理parent(v)時做出這樣的標記讓它能通過v的BACK到達v的最老祖先。