如何寫好遞歸算法
什麽叫遞歸?(先定義一個比較簡單的說法,為了理解,不一定對)
遞歸:無限調用自身這個函數,每次調用總會改動一個關鍵變量,直到這個關鍵變量達到邊界的時候,不再調用。
比如說我要你先求一個N!的結果
你說我會用循環啊(沒錯,但是現在是學遞歸)
1 int factorial(int x,int ans) 2 { 3 if(x==1) 4 return ans; 5 factorial(x-1,ans*x); 6 }View Code
怎麽樣,對於C基礎如果掌握的還行的話,這段代碼應該很好理解。遞歸,顧名思義就是“遞”和“歸”。也就是說,寫每一個遞歸函數的時候,都應該在寫之前考慮清楚,哪裏體現了“遞”,哪裏體現了“歸”。
但是常常遞歸函數會比較復雜, “遞”和“歸”看起來並不是那麽明顯,這就需要我們進一步來理解遞歸算法的思想。
比如說我現在要你用輾轉相除法求出兩個數的最大公約數,遞歸函數如下:
1 int gcd(int a,int b) 2 { 3 return a%b==0?b:gcd(b,a%b); 4 }View Code
這是一段很常用的代碼,我們知道,在學習過程中不求甚解是最不應該的。因此現在來仔細看一看。這裏的“遞”和“歸”放在同一行。首先進行判斷a==b?(我們可以想象成“歸”的內容,如果這個條件符合的話)。當然,如果不符合這個判斷,那就繼續“遞”,也就是繼續進行 gcd(b,a%b);
看到這裏,你就會發現,遞歸不就是循環的另一種方式麽?
說對了一半,不過遞歸是一種思想,現在還暫時不能說透,需要大家先比較一下循環和遞歸的相同點和不同點(飯一口一口吃,別著急)
總結循環和遞歸:
相同點:
(1)都是通過控制一個變量的邊界(或者多個),來改變多個變量為了得到所需要的值,而反復而執行的;
(2)都是按照預先設計好的推斷實現某一個值求取;(請註意,在這裏循環要更註重過程,而遞歸偏結果一點)
不同點:
(1)遞歸通常是逆向思維居多,“遞”和“歸”不一定容易發現(比較難以理解);而循環從開始條件到結束條件,包括中間循環變量,都需要表達出來(比較簡潔明了)。
簡單的來說就是:用循環能實現的,遞歸一般可以實現,但是能用遞歸實現的,循環不一定能。因為有些題目①只註重循環的結束條件和循環過程,而往往這個結束條件不易表達(也就是說用循環並不好寫);②只註重循環的次數而不註重循環的開始條件和結束條件(這個循環更加無從下手了)。
來看看“漢諾塔問題”
如圖,漢諾塔問題是指有三根桿子A,B,C。C桿上有若幹碟子,把所有碟子從A桿上移到C桿上,每次只能移動一個碟子,大的碟子不能疊在小的碟子上面。求最少要移動多少次?
這是一個循環只註重循環次數的常見例子,我們知道,用循環有點無從下手(就目前作者水平來看),但是遞歸就很好寫了。
漢諾塔,什麽鬼,我不會啊?
別急,慢慢來。
我們首先需要一點思維:解決n塊盤子從A移動到C,那麽我只需要先把n-1塊盤子從A移到B,然後把最下面的第n塊盤子從A移到C,最後把n-1塊盤子從B移到C(這就完成了)。
等等,那麽如何把n-1塊盤子從A移到B?
很好,這說明你已經開始遞歸入門了。
同樣這樣去想:解決n-1塊盤子從A移動到B,那麽我只需要先把n-2塊盤子從A移動到C,然後把倒數第二塊盤子從A移到B,最後把n-2塊盤子從C移到B(這就完成了)。
這就是遞歸的“遞”!
那麽“歸”呢?n==1的時候?
Bingo
1 int i; //記錄步數 2 //i表示進行到的步數,將編號為n的盤子由from柱移動到to柱(目標柱) 3 void move(int n,char from,char to){ 4 printf("第%d步:將%d號盤子%c---->%c\n",i++,n,from,to); 5 } 6 7 //漢諾塔遞歸函數 8 //n表示要將多少個"圓盤"從起始柱子移動至目標柱子 9 //start_pos表示起始柱子,tran_pos表示過渡柱子,end_pos表示目標柱子 10 11 void Hanio(int n,char start_pos,char tran_pos,char end_pos) 12 { 13 if(n==1) //很明顯,當n==1的時候,我們只需要直接將圓盤從起始柱子移至目標柱子即可. 14 move(n,start_pos,end_pos); 15 else 16 { 17 Hanio(n-1,start_pos,end_pos,tran_pos); //遞歸處理,一開始的時候,先將n-1個盤子移至過渡柱上 18 move(n,start_pos,end_pos); //然後再將底下的大盤子直接移至目標柱子即可 19 Hanio(n-1,tran_pos,start_pos,end_pos); //然後重復以上步驟,遞歸處理放在過渡柱上的n-1個盤子 20 //此時借助原來的起始柱作為過渡柱(因為起始柱已經空了) 21 } 22 }View Code
實際上這裏面已經使用到了一點點棧的思想(即最上面的最先考慮變化),但其實遞歸有的時候就是真的可以理解為棧!
到了這一步,相信大家應該已經有所明白。循環其實就是一個控制變量從開始條件走到結束條件的過程(在循環的過程順帶把其他變量也改變一下),因此需要控制變量,開始條件,結束條件(缺一不可)。但是遞歸只要告訴你“歸”是什麽,如何去“遞”,不管過程如何,只要計算結果即可。
(2)遞歸可以是多個“遞”,也可以是多個“歸”;而循環由始至終都只由一個變量控制(就算有幾個變量同時控制)也只有一個出口,每次循環也只是一個“遞”。
再看一個例子
用二分思想建立二叉樹(通常的是遞歸實現),比如說線段樹
1 //root 節點序號 2 //left 節點維護的左邊界 3 //right 節點維護的右邊界 4 void build(int root,int left,int right) 5 { 6 if(left==right) 7 return ; 8 int mid=(left+right)/2; 9 build(root*2,left,mid); 10 build(root*2+1,mid+1,right); 11 }View Code
如果你是新手看不太懂也沒關系,現在最主要的是明白:在這個程序裏面只有一個“歸”,但是有兩個“遞”。
那麽如果學過一點但是對這一塊還不明白的怎麽辦呢?別急,聽我來解釋:
實際上,這兩個“遞”是按照先後分別進行的,等到第一個“遞”執行完(也就是到了“歸”的條件之後),才開始執行第二個“遞”。也就是說,通常在建樹的時候,都不是一層一層同時建的,而是先建一棵子樹,等到這棵子樹全部建完之後,才開始建立另外一棵子樹。
那就會問了,一棵子樹建完了之後root值不會變麽,root值變了之後還怎麽建另外一棵子樹呢?
root值不會變!大家請註意,這裏root*2是寫在遞歸函數裏面的,實際上並沒有賦值?為什麽要這樣寫?因為如果不這樣寫,你直接寫在外邊的話,一棵子節點到達葉子節點之後,需要一層一層往上回溯(在這裏提到了回溯的思想),而回溯就會無故產生很多不必要的時間復雜度,降低了遞歸效率(實際上遞歸的時間效率本來就有一點偏低)。
所以到目前為止,我只是介紹一些很常見的簡單的遞歸,但是在接下來,我就需要說一些比較深層一點的知識了。
首先要理解一下什麽是回溯(寫的不好,大佬勿噴)
回溯:在遞歸的過程中由於改變的量需要倒退到某一個位置而執行的步驟。
先來看一個簡單的素數環問題:
給出1到n的n個連續的正整數(這裏n暫時等於6),並且把這n個數填寫在如下圖的n個圓圈裏面(當然是不重復不遺漏了)。要求是每一個位置上面的數跟他相鄰的數之和都為一個素數,打印並輸出最後滿足條件的情況。
首先明白,開始條件是1,把1填寫在第一個位置,然後在剩下的n-1個數字裏找到一個滿足與1的和是一個素數的數(當然如果有多個,先靠前的先考慮)。接下來再繼續從剩下n-2個數字裏找到一個與這個數的和又是一個素數的數(當然如果有多個,同上。)。。。最後的一個數只要滿足與最開始的數1之和是一個素數的話,這個情況就滿足了(就可以打印輸出這樣一個例子了)
但事情並沒有想象的那麽簡單。。。(告訴我如果在中途尋找的過程中從剩下的數裏找不到與當前數的的和是一個素數的情況出現怎麽辦?在線等)
這就表明這樣一條路終歸是一條思路,你要往回走了!這就很符合我們給回溯的定義了,此時這個改變的量需要倒退的前面一步從另外一個方向尋找了。(還是舉栗子吧)
比如說:
1->2->3->4 突然發現5和6都不滿足要求了
那麽就倒退,準備找另外滿足要求的數
1->2->3 又發現除了4以外3跟5或者3跟6也不滿足要求
那就繼續倒退,繼續準備找另外滿足要求的數
1->2->5->6 接下來發現6跟3或者6跟4不滿足要求
…(還想繼續下去?你是要玩死我?別這樣,我也累啊,看一兩個就行了,啊!)
最後發現滿足條件的一個是
1->4->3->2->5->6
大家應該已經懂了,上面的倒退,實際上就是回溯。(暫時這樣簡單的理解吧,錯了也不能怪你們)
實際上,遞歸+回溯就已經是dfs(深度優先搜索)的內容範疇了。
1 void dfs(int x) 2 { 3 if(x==n+1&&prime(a[x-1]+a[1])) //如果滿足條件就可以打印輸出數據了,這裏就是“歸” 4 { 5 for(int i=1;i<n;i++) 6 cout<<a[i]<<" "; 7 cout<<a[n]<<endl; 8 } 9 else //否則就繼續“遞” 10 { 11 for(int i=2;i<=n;i++) 12 { 13 if(!vis[i]&&prime(i+a[x-1])) 14 { 15 vis[i]=1; //vis[]是一個標記數組,表示當前的數字已經被使用過了 16 a[x]=i; 17 dfs(x+1); //“遞”的入口 18 vis[i]=0; //請註意,回溯點在這裏 19 } 20 } 21 } 22 }View Code
大家可能前面都看懂了,比如說“遞”和“歸”,vis[]標記數組什麽的。但是最後一個vis[i]=0是啥意思?難道不多余麽?
不多余!前面我已經拿建樹給大家講過遞歸的“工作原理”,它是先無限遞歸,然後到達某個條件之後,回溯到上面一個位置,繼續向其他方向遞歸。而這個vis[i]=0就是清楚當前數字的標記,表示從當前節點開始,之後遞歸過的內容統統清空(也就是回溯)。然後根據循環,進行下面一個方向的繼續遞歸。
這也是dfs的經典思想所在!
因此,講到這裏,不說說dfs似乎也是吊大家胃口了。所以接下來,就聊一聊dfs中的遞歸。
比如說hdu上面的1312 http://acm.hdu.edu.cn/showproblem.php?pid=1312
我簡單說一下意思,如下圖,判斷一個圖內包括@符號在內的所有‘.’和‘@’的個數。有個限制條件,如果‘.’被‘#’封死,則‘.’不可訪問。
6 9 (分別表示行和列)
....#.
.....#
......
......
......
......
......
#@...#
.#..#.
45
比如說這一個數據就有三個‘.’被邊界和‘#’困死而不可訪問,因此只有45個點滿足要求。
本題的思路就是從’@’點開始,bfs搜索每一個點,分成上下左右四個方向分別遞歸搜索。
1 int cnt,a[4]={-1,0,1,0},b[4]={0,1,0,-1},n,m,vis[22][22]; 2 char s[22][22]; 3 void dfs(int x,int y) 4 { 5 for(int i=0;i<4;i++) //四個方向循環搜索 6 { 7 int p=x+a[i]; 8 int q=y+b[i]; 9 if(p>=0&&p<m&&q>=0&&q<n&&!vis[p][q]&&s[p][q]==‘.‘) //判斷遞歸條件,包括在數組邊界之內,該點未被標記 10 { 11 vis[p][q]=1; //標記該點 12 cnt++; //計數變量加一 13 dfs(p,q); //遞歸搜索 14 } 15 } 16 }View Code
1 int cnt,a[4]={-1,0,1,0},b[4]={0,1,0,-1},n,m,vis[22][22]; 2 char s[22][22]; 3 void dfs(int x,int y) 4 { 5 for(int i=0;i<4;i++) //四個方向循環搜索 6 { 7 int p=x+a[i]; 8 int q=y+b[i]; 9 if(p>=0&&p<m&&q>=0&&q<n&&!vis[p][q]&&s[p][q]==‘.‘) //判斷遞歸條件,包括在數組邊界之內,該點未被標記 10 { 11 vis[p][q]=1; //標記該點 12 cnt++; //計數變量加一 13 dfs(p,q); //遞歸搜索 14 } 15 }
請註意:本題中因為可以提前判斷下一個要搜索的點是否為‘#’而免於回溯,降低時間復雜度。
並且大家可以看出,上面的代碼實際上是稍微復雜一點的遞歸算法(把從‘@’出發的每一個方向看成一條線段,而這條線段的另外一個終點就是邊界或者’#’),因此這就是可以看成循環了四次的遞歸算法,而每一次遞歸調用的過程,每一方向又看成從當前點出發的一條線段。如此反復。(中間的cnt用來計數)
請註意,cnt就是就是遞歸的次數(因為沒有回溯,如果有回溯,計數的話不一定等於遞歸的次數)
到此,基本知識點已經全部講完,下面給出一點個人關於寫遞歸算法的建議吧:
(1)把遞歸當成復雜的循環來寫,如果不明白過程,多模擬幾遍數據;
(2)把遞歸逆向寫的時候當做一個棧來實現(即符合先進先出的思想);
(3)當遞歸和回溯結合在一起的時候需要明白遞歸次數和統計次數之間的練習和區別;
(4)但遞歸有多個“遞”和“歸”的時候,選擇一個重點的“遞”和“歸”作為匹配,即時題目即時分析,註意隨機應變即可。
如何寫好遞歸算法