深度優先搜索(DFS)

【引入】

  • 設想我們現在以第一視角身處一個巨大的迷宮當中,沒有上帝視角,沒有通信設施,更沒有熱血動漫裏的奇蹟,有的只是四周長得一樣的牆壁。 於是,我們只能自己想辦法走出去。如果迷失了內心,隨便亂走,那麼很可能被四周完全相同的景色繞暈在其中,這時只能放棄所謂的僥倖,而去採取下面這種看上去很盲目但實際上會很有效的方法。
  1. 以當前所在位置爲起點,沿着一條路向前走,當碰到岔道口時,選擇其中一個岔路前進。如果選擇的這個岔路前方是一條死路,就退回到這個岔道口,選擇另一個岔路前進。如果岔路中存在新的岔道口,那麼仍然按上面的方法枚舉新岔道口的每一條岔路。 這樣,只要迷宮存在出口,那麼這個方法一定能夠找到它。
  2. 可能有讀者會問,如果在第一個岔道口處選擇了一條沒有出路的分支,而這個分支比較深,並且路上多次出現新的岔道口,那麼當發現這個分支是個死分支之後,如何退回到最初的這個岔道口?其實方法很簡單,只要讓右手始終貼着右邊的牆壁一路往前走, 那麼自動會執行上面這個走法,並且最終一定能找到出口。

圖8-1即爲使用這個方法走一個簡單迷宮的示例。

在這裏插入圖片描述

【概念】

  • 從圖8-1可知,從起點開始前進,當碰到岔道口時,總是選擇其中一條岔路前進 (例如圖中總是先選擇最右手邊的岔路),在岔路上如果又遇到新的岔道口,仍然選擇新岔道口的其中一條岔路前進,直到碰到死衚衕纔回退到最近的岔道口選擇另一條岔路。也就是說,當碰到岔道口時,總是以“深度”作爲前進的關鍵詞,不碰到死衚衕就不回頭,因此把這種搜索的方式稱爲深度優先搜索(Depth First Search, DFS)
  • 從迷宮的例子還應該注意到,深度優先搜索會走遍所有路徑,並且每次走到死衚衕就代表一條完整路徑的形成。這就是說,深度優先搜索是一種枚舉所有完整路徑以遍歷所有情況的搜索方法

【過程實現】

  • 那麼如何來實現深度優先搜索 (DFS) 呢?在圖8-1 中,把迷宮中的關鍵結點 (岔道口或死衚衕) 用字母代替,然後來看看在 DFS 的過程中是如何體現在這些關鍵結點上的 (已經在圖中標記了字母):
  1. 從第一條路可以得到先後訪問的結點爲ABDH,此時 H 到達了死衚衕,於是退回到 D;再到 I,但是 I 也是死衚衕,再次退回到D;接着來到 J,很不幸 J 還是死衚衕,於是退回到D,但是此時D的岔路已經走完了,因此退回到上一個岔道口B。
  2. 從 B 到達 E ,接下來又是三條岔路 (K、 L、M),依次進行枚舉:前往K,發現K是死衚衕,退回到E;前往L,發現L是死衚衕,退回到E,前往M,發現M是死衚衕,退回到E。最後因爲E的岔路都訪問完畢了,於是退回到B。但是B的所有岔路(D和E)也都訪問完了,因此退回到A。
  3. 訪問 A 的另一個岔路可以到達C,而 C 仍然有兩條岔路(F 和G),於是先訪問F,發現F是死衚衕,退回到C;再訪問G,發現是出口,DFS過程結束,整個DFS過程中先後訪問結點的順序爲ABDHIJEKLMCFG。
  • 這個過程是不是和出棧入棧的過程很相似?第一步,ABD入棧,然後把 D 的三條岔路 HIJ 先後入棧和出棧,再把 D 出棧;第二步和第三步執行與第一步類似的操作, 讀者不妨自已模擬出棧與入棧的過程。由此可以知道如何去實現深度優先搜索:先對問題進行分析,得到岔道口和死衚衕;再定義一個棧,以深度爲關鍵詞訪問這些岔道口和死衚衕,並將它們入棧;而當離開這些岔道口和死衚衕時,將它們出棧。
  • 因此,深度優先搜索 (DFS) 可以使用棧來實現。這聽起來很容易,但是實現起來卻並不輕鬆,有沒有既容易理解又容易實現的方法呢?有的——遞歸。

現在從 DFS 的角度來看當初求解 Fibonacci 數列的過程。

  • 回顧一下 Fibonacci 數列的定義: F(0) = 1, F(1) = 1, F(n) = F(n- 1)+F(n - 2)(n≥2)。可以從這個定義中挖掘到,每當將 F(n) 分爲兩部分 F(n- 1) 與 F(n- 2) 時,就可以把 F(n) 看作迷宮的岔道口,由它可以到達兩個新的關鍵結點 F(n-1) 與 F(n-2) 。而之後計算 F(n-1) 時,又可以把 F(n-1) 當作在岔道口 F(n) 之下的岔道口。
  • 既然有岔道口,那麼一定有死衚衕。很容易想象,當訪問到 F(0) 和 F(1) 時,就無法再向下遞歸下去,因此 F(0) 和 F(1) 就是死衚衕。這樣說來,遞歸中的遞歸式就是岔道口,而遞歸邊界就是死衚衕,這樣就可以把如何用遞歸實現深度優先搜索的過程理解得很清楚。
  • 爲了使上面的過程更清晰,可以直接來分析遞歸圖 (見圖4-3):可以在遞歸圖中看到,只要 n > 1, F(n) 就有兩個分支,即把 F(n) 當作岔道口;而當n爲1或0時,F(1)與 F(0) 就是迷宮的死衚衕,在此處程序就需要返回結果。這樣當遍歷完所有路徑(從頂端的F(4)到底層的所有F(1)與F(0)後,就可以得到F(4)的值。
    在這裏插入圖片描述
  • 因此,使用遞歸可以很好地實現深度優先搜索。這個說法並不是說深度優先搜索就是遞歸,只能說遞歸是深度優先搜索的一種實現方式,因爲使用非遞歸也是可以實現DFS的思想的,但是一般情況下會比遞歸麻煩。不過,使用遞歸時,系統會調用一個叫系統棧的東西來存放遞歸中每一層的狀態,因此使用遞歸來實現 DFS 的本質其實還是棧。

【舉例一】

  • 接下來講解一個例子,讀者需要從中理解其中包含的 DFS 思想,並嘗試學習寫出本例的代碼。

有 n 件物品,每件物品的重量爲 w[i],價值爲 c[i] 。現在需要選出若干件物品放入一個容量爲 V 的揹包中,使得在選入揹包的物品重量和不超過容量 V 的前提下,讓揹包中物品的價值之和最大,求最大價值。(1≤n≤20)

  • 在這個問題中,需要從 n 件物品中選擇若干件物品放入揹包,使它們的價值之和最大。這樣的話,對每件物品都有選或者不選兩種選擇,而這就是所謂的“岔道口”。那麼什麼是“死衚衕”呢?——題目要求選擇的物品重量總和不能超過v,因此一旦選擇 的物品重量總和超過V,就會到達“死衚衕”,需要返回最近的“岔道口”。
  • 顯然,每次都要對物品進行選擇,因此 DFS 函數的參數中必須記錄當前處理的物品編號 index。而題目中涉及了物品的重量與價值,因此也需要參數來記錄在處理當前物品之前,已選物品的總重量 sumW 與總價值 sumC。於是 DFS 函數看起來是這個樣子:
void DFS(int index; int sumW, int sumC) { ... }
  1. 於是,如果選擇不放入 index 號物品,那麼 sumW 與 sumC 就將不變,接下來處理 index + 1 號物品,即前往 DFS(index + 1, sumW, sumC) 這條分支;
  2. 而如果選擇放入 index 號物品,那麼 sumW 將增加當前物品的重量 w[index], sumC 將增加當前物品的價值 c[index],接着處理 index + 1 號物品,即前往 DFS(index + 1, sumW + w[index], sumC + c[index]) 這條分支。
  3. 一旦 index 增長到了 n,則說明已經把n件物品處理完畢(因爲物品下標爲從 0 到 n- 1), 此時記錄的 sumW 和 sumC 就是所選物品的總重量和總價值。如果 sumW 不超過 V 且 sumC 大於一個全局的記錄最大總價值的變量 maxValue,就說明當前的這種選擇方案可以得到更大的價值,於是用 sumC 更新 maxValue。

下面的代碼體現了上面的思路,請注意“岔道口”和“死衚衕”在代碼中是如何體現的:

#include <cstdio>
const int maxn = 30;
int n, V, maxValue = 0; //物品件數n, 揹包容量V, 最大價值maxValue
int w[maxn], c[maxn]; //w[i]爲每件物品的重量,c[i] 爲每件物品的價值

//DFS,index 爲當前處理的物品編號
// sumW 和 sumC 分別爲當前總重量和當前總價值

void DFS(int index, int sumW, int sumC) 
{
      if(index == n) 
      { //已經完成對 n 件物品的選擇 (死衚衕)
      	if(sumW <= V && sumC > maxValue) 
      	{
      		maxValue = sumC; / /不超過揹包容量時更新最大價值maxValue
      	}
      	return;
      }
      //岔道口
	  DFS(index + 1, sumW, sumC);  //不選第 index件物品
	  DFS(index + 1, sumW + w[index], sumC + c[index]);  //選第index件物品
}

int main()
{
	scanf("%d%d"&n, &V);
	for(int i =0;i< n; i++) 
		scanf("%d", &w[i]); //每件物品的重量

	for(int i=0; i< n; i++) 
		scanf("%d", &c[i]; //每件物品的價值

	DFS(0, 0, 0); //初始時爲第0件物品、當前總重量和總價值均爲0
	
	printf ("%d\n", maxValue);
	return 0;
}
5 8 //5件物品,揹包容量爲8
3 5 1 2 2  //重量分別爲3 5 1 2  2
4 5 2 1 3  //價值分別爲4 5 2 1 3

輸出結果:

10
  • 可以注意到,由於每件物品有兩種選擇,因此上面代碼的複雜度爲 O(2n),這看起來不是很優秀。但是可以通過對算法的優化,來使其在隨機數據的表現上有更好的效率。在上述代碼中,總是把 n 件物品的選擇全部確定之後纔去更新最大價值,但是事實上忽視了揹包容量不超過 V 這個特點。也就是說,完全可以把對 sumW 的判斷加入“岔道口”中,只有當 sumW≤V 時才進入岔道,這樣效率會高很多,代碼如下:
void DFS(int index, int sumW, int sumC) 
{
	if(index == n) 
		return; //已經完成對 n件物品的選擇

	DFS(index + 1, sumW, sumC); //不選第index件物品
	
	//只有加入第index件物品後未超過容量V,才能繼續
	if(sumW + w[index] <= V) 
	{
		if(sumC + c[index] > ans) 
		{
			ans = sumC + c[index]; //更新最大價值 maxValue
		}
		DFS(index + 1, sumW + w[index], sumC + c[index]); 
		//選 第index件物品
	}
}

  • 可以看到,原先第二條岔路是直接進入的,但是這裏先判斷加入第 index 件物品後能否滿足容量不超過 V 的要求,只有當條件滿足時才更新最大價值以及進入這條岔路,這樣可以降低計算量,使算法在數據不極端時有很好的表現。這種通過題目條件的限制來節省 DFS 計算量的方法稱作剪枝。(前提是剪枝後算法仍然正確)。

剪枝是一門藝術,學會靈活運用題目中給出的條件,可以使得代碼的計算量大大降低,很多題目甚至可以使時間複雜度下降好幾個等級。至於爲什麼把這種操作叫作“剪枝”,後面會給出解釋。

事實上,上面的這個問題給出了一類常見 DFS 問題的解決方法,即給定一個序列,枚舉這個序列的所有子序列 (可以不連續)。例如對序列 {1, 2, 3} 來說,它的所有子序列爲{1}、{2}、{3}、{1, 2}、 {1, 3}、 {2, 3}、 ({1, 2, 3}。 枚舉所有子序列的目的很明顯——可以從中選擇一個“最優”子序列,使它的某個特徵是所有子序列中最優的:如果有需要,還可以把這個最優子序列保存下來。顯然,這個問題也等價於 枚舉從 N 個整數中選擇 K 個數的所有方案

【舉例二】

例如這樣一個問題:給定 N 個整數(可能有負數),從中選擇 K 個數,使得這 K 個數之和恰好等於一個給定的整數X;如果有多種方案,選擇它們中元素平方和最大的一個。數據保證這樣的方案唯一。
例如,從 4 個整數 {2, 3, 3, 4} 中選擇2個數,使它們的和爲 6,顯然有兩種方案 {2, 4} 與 {3, 3},其中平方和最大的方案爲 {2, 4}。

  • 與之前的問題類似,此處仍然需要記錄當前處理的整數編號 index;由於要求恰好選擇 K 個數,因此需要一個參數 nowK 來記錄當前已經選擇的數的個數;另外,還需要參數 sum 和 sumSqu 分別記錄當前已選整數之和與平方和。於是 DFS 就是下面這個樣子:
void DFS(int index, int nowk, int sum, int sumSqu) { .... }
  • 此處主要講解如何保存最優方案,即平方和最大的方案。首先,需要一個數組 temp,用以存放當前已經選擇的整數。這樣,當試圖進入 “選index號數” 這條分支時,就把 A[index] 加入 temp 中;而當這條分支結束時,就把它從 temp 中去除,使它不會影響 “不選 index 號數” 這條分支。接着,如果在某個時候發現當前已經選擇了 K 個數,且這K個數之和恰好爲 x 時,就去判斷平方和是否比已有的最大平方和 maxSumSqu 還要大:如果確實更大,那麼說明找到了更優的方案,把 temp 賦給用以存放最優方案的數組 ans。這樣,當所有方案都枚舉完畢後,ans存放的就是最優方案,maxSumSqu 存放的就是對應的最優值。
  • 下面給出了主要部分的代碼,建議讀者能完整理解並自行寫出:
//序列 A 中n個數選k個數使得和爲x,最大平方和爲maxSumSqu
int n, k, x, maxSumSqu = -1, A[maxn];
//temp存放臨時方案,ans 存放平方和最大的方案
vector<int> temp, ans; 
//當前處理index號整數,當前已選整數個數爲nowk
//當前已選整數之和爲sum,當前已選整數平方和爲sumSqu
void DFS(int index, int nowK, int sum, int sumSqu)
{
      if(nowK == k && sum == x)
      { //找到k個數的和爲x
      		if(sumSqu > maxSumSqu) //如果比當前找到的更優
      		{
      			maxSumSqu = sumSqu; //更新最大平方和
      			ans = temp;   //更新最優方案
      		}
      		return;
      }

	//已經處理完n個數,或者超過k個數,或者和超過x,返回
	if(index == n || nowK > k || sum > x) return;
	
	//選index號數
	temp.push_back(A[index]);
	DFS(index+1, nowK+1, sum+A[index], sumSqu+A[index]*A[index]);
	temp.pop_back();
	
	//不選index號數
	DES(index + 1, nowK, sum, sumSqu) ;
}

上面這個問題中的每個數都只能選擇一次, 現在稍微修改題目:假設 N 個整數中的每一個都可以被選擇多次,那麼選擇 K 個數,使得 K 個數之和恰好爲X。例如有三個整數1、4、7,需要從中選擇5個數,使得這5個數之和爲17。 顯然,只需要選擇3個1和2個7,即可得到17。

  • 這個問題只需要對上面的代碼進行少量的修改即可。由於每個整數都可以被選擇多次,因此當選擇了 index 號數時,不應當直接進入 index + 1 號數的處理。顯然,應當能夠繼續選擇 index 號數,直到某個時刻決定不再選擇 index 號數,就會通過 “不選index號數” 這條分支進入 index + 1 號數的處理。因此只需要把 “選index號數” 這條分支的代碼修改爲
    DFS(index, nowK + 1, sum + A[index], sumSqu + A[index] * A[index]) 即可。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章