遞迴加回溯

夢在未名湖畔發表於2020-12-03

1、框架

1.1、遞迴框架

function A

 結束條件

 違法判斷

 正常邏輯程式碼

 for every situation
    遞迴
 

        切記上面你的違法判斷,我們不用實現分情況討論啥情況下回違法,只需要最後判斷是否違法就行了,結束條件一定要弄清楚,其次你寫遞迴的時候最好不要帶返回值吧,如果需要返回值的情況下你可以使用全域性變數或者用引數傳遞這個變數!!!(帶返回值的遞迴會比較麻煩一點)

1.2、回溯框架

function B

    結束條件

    違法判斷
 
    正常邏輯程式碼

    for every situation
        
        變數修改或者作出選擇
        
        遞迴
        
        把修改的變數恢復原樣,作出的選擇恢復原樣

 

回溯和遞迴很相似,為啥叫回溯呢實際上就是在後面需要將之前作出的修改恢復。遞迴和回溯都有個很麻煩的邏輯思維,我給你畫一個圖:

        對你想得沒錯,只要你這個遞迴沒有走到結束條件那一塊就會無限這樣套娃,誰都執行不完,只有最後一個可以執行完成,在遞迴呼叫之前的出口執行結束,然後再從最後一個一個的返回執行後續的程式碼。那麼選擇或者變數改變的意思就是在函式A的呼叫前對某些變數作出了修改,目的是為了後面的遞迴執行時需要用到這個變數(讓其保持下一個時刻情況下的正確值),然後返回的時候我們需要將改變進行修改,因為當前分支可能並不滿足,如下圖:

       然後再每個情況的分支上又每個都分N個,一直這樣遞迴的下去,這就是帶情況分支的遞迴。我們就以這個為例:假設我程式先執行情況1對某些變數進行了修改,但是這些修改只是在情況1發生的前提下修改的。(比如我中了彩票,我就要去買個法拉利,但是這個假設是我要中彩票才行!!)然後這個情況走完了(注意這個走完了的意思是:)

1、這個分支可能最後成功了(滿足我們的要求了)

2、這個分支可能失敗了(走不下去了並且不滿足我們的要求)

無論是上面的哪種情況,我們都要返回到上一層的分支,因為我們的目的是求出所有的可能性,然後選一個最好的可能性!!!

        那麼在回到上一層,需要進行情況2的選擇時,我們應該排除之前情況1的干擾,因為不管成不成功,那都將影響我們拔劍的速度(影響後面分支情況),這就是回溯的原因。

2、紙上得來終覺淺

例題2.1

給定一個整數n,要求從1~~~n的整數間選出K個數,但是要求不能選相鄰的兩個數,請問有多少種選擇方法?

 

解析:做程式設計題目的時候就不要想啥排列組合啦,而且也非常的難想,這道題目的難點在於不能取鄰接的兩個資料,這個就是一個小技巧的使用,你看看我的程式碼:

int count = 0;

// @Param last:最後一次選取的數
// @Param n:輸入的n,代表從1-n取資料
void function(int last,int n){
    for(int i = last+2;i < n;i++)
    {
        count++;
        function(i,n);
    }
}

       上面關鍵點就在i = last+2,然後遞迴的引數是記錄上一次最後選取的資料,我們只需要跳過相鄰的那個數就好了,後面的數都可以取。其實從這個程式碼我們還能感受到遞迴的優勢就是之前講解的,值考慮區域性就好了。就比如這個題目你不用考慮我要是選擇的兩個資料是相鄰的,然後怎麼樣;我選擇的兩個數隔兩個,然後怎麼樣。。。。你只需要知道當前局面怎麼處理,然後問題規模減小,直接把新的變數傳入就行了,這麼處理肯定是對的,因為下一次面臨的局面還是和本次一模一樣,只是問題的規模減小了(簡單認為就是數變少了)

       不知道細心的你發現上面程式碼的問題了嗎?對比模板我們發現其實少了一些東西是吧,對照模板補充如下:

int count = 0;

// @Param last:最後一次選取的數
// @Param n:輸入的n,代表從1-n取資料
// @Param k:記錄要取多少個數
void function(int last,int n,int k){
    
    // 出口自然就是數pick完成了
    // return的意思就是本次函式執行完了,
    // 直接跳到本函式的最後面,中間的程式碼全部
    // 不執行了,管你中間有沒有遞迴還是什麼牛
    // 鬼蛇神的
    if(k == 0) return;

    // 違法
    if(k > n) return;

    // 特殊情況,當然你的遞迴寫得優秀這個不一定有
    // 不過有時候一些很特殊的情況,邊界條件拿出來
    // 是會有大用處的
    if(n == k){
        count = 1;
        return;
    }

    for(int i = last+2;i < n;i++)
    {
        count++;
        // 注意這裡不要寫成k--了,因為k--就代表把k改變了
        // 回來的時候你是需要回溯的
        // k-1的好處就是k沒有改變,但是又把k-1的值傳遞個下
        // 一次呼叫了,因為本次選了一個數,所以下一次還需要
        // 選取的數的數量就少1
        function(i,n,k-1);
    }
}

例題2.2

在第一行我們寫上一個 0。接下來的每一行,將前一行中的0替換為011替換為10

給定行數 N 和序數 K,返回第 N 行中第 K個字元。(K從1開始)

同樣也是個遞迴的小題目,我們的很多小夥伴看見題目確實知道該遞迴,但是怎麼寫就不知道了,來我們看模板:

1、出口條件是啥? 

答:第N行

2、違法判斷是啥?

答:無

3、正常邏輯是啥?

答:把每行的數字填充上

4、是否涉及到分支選擇:

答:無

ok,那麼來寫程式:

#include<math.h>
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
char *number;
int index = 0;
char remember;

void kthGrammar(int N, int K){
	// 出口
	if(N == 1){
		remember = number[K-1];
		printf("result:%c\n",remember);
		return;
	}
	
	//違法判斷,K必^n大,這個在主函式判斷一下就行了

	//正常邏輯
    for(int i = index-1;i >= 0;i--){
		// 注意需要從後面開始挪動,不然從前面會把後面還沒有挪動
		// 的資料覆蓋
		if(number[i] == '0'){
			number[2*i] = '0';
			number[2*i+1] = '1';
		}else{
			number[2*i] = '1';
			number[2*i+1] = '0';
		}
	}
	index = index*2;
	number[index] = '\0';
	//printf("當前的層數:%d:%s\n",N,number);
	//printf("\n");
	// 這裡--也行,但是為了統一我們以後都用-1吧!
	kthGrammar(N-1, K);
}

實際上上面的正常邏輯很簡單,主要是你看看遞迴的引數和結束條件。

例題2.3

上面的兩個題目你可能會覺得比較簡單,那麼我們來一個稍微難一點的,但是思路其實都類似!

 

這道題目就很難了,首先我們要明白這種全排列,選子集,一共有多少種方法的題目很大概率可以用遞迴來解決問題!!!

1、出口條件是什麼呢?

答:輸入的字串被我們轉換成字母完成了(轉換最後一個數完成。)

2、違法是什麼呢?

答:無

3、正常邏輯就是看見一個數字轉換成對應的字母就行

4、有無分支選擇?

答:肯定有的嘛,不就是一個按鍵有幾種可能的字母嘛

那麼程式其實並不難:

// 儲存結果和一共存了多少個結果
char ** result;
int index = 0;

// @Param digits:輸入的數字字串
// @Param index:當前已經轉換到第幾個字元了
// @Param transferString:到當前的index為止的結果字串
void letterCombinations(char * digits, int index,char *transferString){
	// 出口條件
	if(index >= strlen(digits)){
		// 複製字串到結果陣列中
		strcpy(result[index++],transferString);
		return;
	}

	//違法沒有

	//分支,麻煩在這裡
	if(digits[index] == '2'){
		// 遞迴,回溯我寫在引數中,這樣回來的時候就自己回溯了
		// 這裡我就寫成虛擬碼的形式了哈,你懂上意思就行了哈
		letterCombinations(digits,index+1,transferString+'a')
		letterCombinations(digits,index+1,transferString+'b')
		letterCombinations(digits,index+1,transferString+'c')
		/*
			上面這種回溯當然還可以寫成:
			index++;
			transferString+='a';
			letterCombinations(digits,index,transferString)
			//回溯
			index--
			transferString-='a';

			//情況2
			index++;
			transferString+='b';
			letterCombinations(digits,index,transferString)
			//回溯
			index--
			transferString-='b';

  			//情況3
			index++;
			transferString+='c';
			letterCombinations(digits,index,transferString)
			//回溯
			index--
			transferString-='c';

		*/
	}else if(digits[index] == '3'){
		// 遞迴,回溯我寫在引數中,這樣回來的時候就自己回溯了
		// 這裡我就寫成虛擬碼的形式了哈,你懂上意思就行了哈
		letterCombinations(digits,index+1,transferString+'d')
		letterCombinations(digits,index+1,transferString+'e')
		letterCombinations(digits,index+1,transferString+'f')
	}else
		...

}

你來嘗試補充完整程式碼!!!!

 

例題2.4

給定一個字串陣列 arr,字串 s 是將 arr 某一子序列字串連線所得的字串,如果 s 中的每一個字元都只出現過一次,那麼它就是一個可行解。

請返回所有可行解 s 中最長長度。

       不知道你看見這道題目的大概思路是怎麼樣的,反正我一看見就覺得應該是找出所有的組合的可能,然後記錄最長的長度,ok我猜到了你的難點就是什麼時候記錄長隊對吧!

解題分析:

1、出口

答:我們將單詞都拼接在一起,一旦出現重複的單詞那麼就應該結束當前的遍歷了!!!!!

2、違法判斷

答:無

3、是否存在分支?

答:顯然存在,其實就是第一題選數字那個,選了不同的詞下一次必須選後面的吧!!!

這個程式碼就那種有很多分支,然後每個都選一次的情況,程式碼大體都如下:

int Max = 0;


// @Param str:字串陣列
// @Param length:字串陣列長度
// @Param index:當前選取到第幾個字串了
// @Param current:當前選取的字元
void find_max_length(char **str,int length,int index,char *current){
	// 出口
	// 自己實現一個函式,看一個字串是否包含重複的字元(HashMap是不是可以用上了)
	if(findchar(current)){
		// 把上一個加入的去除
		int current_length = strlen(current)-strlen(str[index-1]);
		Max = current_length > Max ? current_length : Max;
	}

	// 沒有違法判斷

	//分支
	for(int i= index;i < length-1;i++){
		// 同樣是引數回溯,這樣就可以自動回溯
		find_max_length(str,length,i+1,current+str[i]);
	}
}

習題2.5

習題2.6

 

 

3、在二維矩陣中的遞迴與回溯

        其實我們在之前的題目中發現很多的遞迴和回溯的題目都是和二維陣列有關,對的,對於二維陣列其天然存在遞迴的結構,我們每走到一個位置的時候,面臨的選擇情況和之前是一樣的。然後每次在每個位置都有前後左右四個位置可以給我們選擇,這是就對應了前面的分支(出口)結束條件就是滿足題意的情況違法判斷就是是否超出邊界,簡直就是完美的匹配模板對吧,所以愛考這個。

例題3.1

現在假定有一個遊戲,遊戲地圖是一個二維的陣列(實際上游戲就是這麼處理的),然後每個格子有一個數字,如果是負數就代表有一個怪物,負數就是消滅怪物需要消耗的血量,現在規定從地圖的左上角出發(0,0)要到達右下角去營救公主(n-1,n-1),並且每次都只能朝著左邊或者下邊前進(因為他是勇士),那麼請問你最少需要多少初始血才能營救到公主?(保證全部是負數)

樣例1:

-1      -2     -3
-4      -5     -6
-22   -19    -9

輸出:21 因為   -1------》-2-------》-3-------》-6--------》-9是一條消耗最少的路徑

樣例2、

-1

輸出:1

樣例3、

輸出:74 因為 -1-------》-2---------》-3---------》-6--------》-9---------》-13---------》-11----------》-27--------》-2(如果我沒找錯這個應該是最小的)

 

這道題你來完成,我相信你肯定可以!!(注意可以利用前面講解的剪枝提高搜尋速度

 

例題3.1-1 變式練習

如果我們規定移動的方向可以是前後左右,不再是隻能向著左邊和下面運動,這道題又該怎麼寫?

解題提示:

如果是這樣只需要避免出現圈的情況就好了,就比如說你不能:

遍歷了:

(0,0) --- >(0,1) ----> (1,1) ----> (1,0) ---- > (0,0)

年輕人這樣不好,不講武德,計算機會一直迴圈,那麼怎麼解決呢?

答:用一個bool陣列,將走過的位置全部記為true,每次到一個新位置的時候判斷一下是否被走過了,走過了就直接返回!!

 

例題3.1-2 變式練習

如果我們在地圖的某些地方放上了血包又會出現什麼情況呢?比如我用一個整數代表走到這個格子可以增加的血量。(給你一個提示啊,這個坑的地方在於如下的情況:

有人會做題選出下面這個路徑:

-1 -----》 -4 -----》 -99 -----》 300 ----》 -6 ------》 -23  -----》 -11 -----》-27 -----》 -2

因為這個算下來結果是127,還是個正數,最開始直接不需要血就可以了,但是真的是這樣的嗎??

答案肯定不是的呀!!你在撿300點血的血包之前必須是需要血量才能到達那個位置的,實際上在到達那個位置的時候你是付出了慘痛的代價的,那麼如果按照上面這條路走實際上你應該有初始血量=1+4+99 = 104點血才行,還不如不撿!!

請嘗試完成!!!

 

 

例題3.2、A*遍歷演算法

我們在玩遊戲的時候,主角都有自動尋路的功能,那麼這個是怎麼實現的呢?還有在我們看見的無人駕駛汽車,自動尋路避障都是怎麼實現的呢?沒錯這就是這道題目的豬腳,叫做A*演算法(非常的出名),本題目我們一起來模擬實現A*演算法。我們還是將我們生活的平面看成二維陣列,如下:

圖說明:兩個紅色點代表其實位置和結束位置(行,列)代表位置,白色代表空白地區,橘色代表的是障礙物,不能穿過。現在我問從源頭節點到目標節點的最短距離是多少?

說明輸入:

N:代表地圖大小

接下來一行輸入

x1 y1 x2 y2

分別代表源頭和結束節點的位置(用陣列的下標,不是真正意義上的座標軸那種計數)

接下來一個k代表障礙物的個數

接下來k行,每行輸入x1 y1 x2 y2代表障礙物矩形的左上角點座標和右下角點座標。

輸出:一個整數代表最短距離

樣例1、如圖(自己弄啊,可以弄小一點)

 

解題思路:

1、出口

答:找到結束節點

2、違法

答:邊界和障礙物

3、分支選擇?

答:四個方向!

上面的三個步驟你看似沒用,但是你在做題目的時候按照這個流程思考是很能幫助你寫出程式的。

// 記錄最小距離的
int MinDis = 99999999;
// 用來記錄當前的格子是否被走過了!!!這個很重要
bool **flag;

// @Param map:代表地圖的二維陣列
// @Param n:代表地圖的大小
// @Param obstacle:障礙物
// @Param k:障礙物的個數
// @Param current_x,current_y:當前節點在陣列中的下標
// @Param target_x,target_y:目標節點在陣列中的下標
// @param currentDis:當前路徑的距離
void shortest(int **map,int n,int **obstacle,int k,
			int current_x,int current_y,
			int target_x,int target_y,int currentDis){
	
	// 出口
	if(current_x == target_x && current_y == target_y)
		MinDis = MinDis > currentDis?currentDis:MinDis;
	
	// 違法,這個函式自己去實現,隨便怎麼實現都行,不一定要按照我的寫法
	if(!valid(current_x,current_y,n,int **obstacle,int k)) return;

	// 邏輯
	// 記錄當前這一個代價
	// currentDis += map[current_x][current_y];
	// 因為需要回溯,所以直接加在引數裡面最好了,函式執行完畢回來的時候就自己
	// 回溯了
	// up
	shortest(map,n,obstacle,k,current_x-1,current_y,target_x,target_y,
		currentDis+map[current_x][current_y]);
	// down
	shortest(map,n,obstacle,k,current_x+1,current_y,target_x,target_y,
		currentDis+map[current_x][current_y]);
	// left
	...
	//right
	...
}

 

例題3.2-1 :A*演算法的實現,上面的演算法並不叫A*,上面的就是最簡單的暴力遞迴,那麼A*是這樣的:

因為我們發現在查詢的時候有的地方是不可能的,其方向和我們要查詢的節點直接是相反的,那麼我們可以利用上這一點:

cost = pre + future

其中的pre代表我們從源節點到目前的節點的花費,future代表從當前節點到目標結點的花費,你肯定會問我怎麼知道到目標節點的代價,確實我們無法對未來做出精確的預測,但是我們可以粗略的估計一下,我們可以計算目標節點和當前節點的曼哈頓距離,然後選擇曼哈頓距離小(我們認為這個方向更接近目標方向)的分支優先擴充套件。

請通過上述提供的思路實現A*演算法。

 

習題3.3

 

習題3.4

提示:本題有兩種解法,但是我們就研究其中一種,以此體會模板的好處:

首先我們解題思路很明顯弄一個備選的字母庫,然後從裡面每次選取一個字母就行了

1、出口:

答:組合出的新字串長度等於原來的字串長度了

2、違法:

答:無

3、分支:

答:肯定存在,備選的字母庫就是

那麼我們來寫程式:

//字母庫可以用HashMap來做
int HashMap[26]

// @Param current:當前組裝的字串
// @Param length:需要重新排列的原始字串的長度
void qpl(char *current,int length){

    // 這個預備工作可以放在主函式完成!!
    // builHashMap();
    出口條件
    if(strlen(current) == length){
        printf("%s\n",current);
        return;
    }
    
    // 分支
    for(int i = 0;i < 26;i++){
        // 說明可以選擇這個備選的字母,選擇了就要減少,等回來
        // 的時候再回溯
        if(HashMap[i] != 0)
            HashMap[i]--;
        else continue;
        qpl(current+(i+'a'),length);
        // 別忘記回溯了
        HashMap[i]++;
    }
    
}

 

 

 

相關文章