文章目錄
- 1)漢諾塔問題
- 2)求二叉樹的深度
- 3)判斷二叉樹是否平衡
- 4)排列演算法
- 5)組合演算法
一、前言
找工作面試時最喜歡問的是演算法題,雖然我覺得有些取巧的演算法題只是跟刷題量有關。但是為了找工作不得不加強演算法,特別是基礎演算法,這是一個人基本功的體現。《演算法導論》是不錯的教材,不過大部頭看起來確實需要發時間,到現在還只是大略看了一遍,很多推導過程沒細心看下來,深以為憾,以後有時間還是要猛補。演算法題中最能體現演算法精髓的則非遞迴莫屬了,我對遞迴一直總覺得是一知半解,為了加深自己的理解,決定把自己的一些想法記下來,方便更好的理清自己的思路,也懇請各路大牛指正。
二、遞迴演算法初探
本段內容素材來自《linux C一站式編程》,作者是宋勁松老師,說實話這是目前看到的國內關於linux C編程的最好的一本技術書籍,強烈推薦!
關於遞迴的一個簡單例子是求整數階乘,n!=n*(n-1)!,0!=1 。則可以寫出如下的遞迴程式:
int factorial(int n){if (n == 0)return 1;else {int recurse = factorial(n-1);int result = n * recurse;return result;}}
factorial這個函數就是一個遞迴函式,它調用了它自己。自己直接或間接調用自己的函數稱為遞迴函式。這裡的factorial是直接調用自己,有些時候函數A調用函數B,函數B又調用函數A,也就是函數A間接調用自己,這也是遞迴函式。如果覺得迷惑,可以把factorial(n-1)這一步看成是在調用另一個函數--另一個有著相同函數名和相同代碼的函數,調用它就是跳到它的代碼裡執行,然後再返回factorial(n-1)這個調用的下一步繼續執行。
為了證明遞迴演算法的正確性,我們可以一步步跟進去看執行結果。記得剛學遞迴演算法的時候,老是有丈二和尚摸不著頭腦的感覺,那時候總是想著把遞迴一步步跟進去看執行結果。遞迴層次少還算好辦,但是層次一多,頭就大了,完全不知道自己跟到了遞迴的哪一層。比如求階乘,如果只是factorial(3)跟進去問題不大,但是若是factorial(100)那就麻煩了。
事實上,我們並不是每個函數都需要跟進去看執行結果的,比如我們在自己的函數中調用printf函數時,並沒有鑽進去看它是怎麼列印的,因為我們相信它能完成列印工作。我們在寫factorial函數時有如下代碼:
...int recurse = factorial(n-1);int result = n * recurse;...
這時,如果我們相信factorial是正確的,那麼傳遞參數為n-1它就會返回(n-1)!,那麼result=n*(n-1)!=n!,從而這就是factorial(n)的結果。
當然這有點奇怪:我們還沒寫完factorial這個函數,憑什麼要相信factorial(n-1)是正確的?可Leap
of Faith本身就是Leap(跳躍)的,不是嗎?如果你相信你正在寫的遞迴函式是正確的,並調用它,然後在此基礎上寫完這個遞迴函式,那麼它就會是正確的,從而值得你相信它正確。
這麼說好像有點兒玄,我們從數學上嚴格證明一下factorial函數的正確性。剛才說了,factorial(n)的正確性依賴於factorial(n-1)的正確性,只要後者正確,在後者的結果上乘個n返回這一步顯然也沒有疑問,那麼我們的函數實現就是正確的。因此要證明factorial(n)的正確性就是要證明factorial(n-1)的正確性,同理,要證明factorial(n-1)的正確性就是要證明factorial(n-2)的正確性,依此類推下去,最後是:要證明factorial(1)的正確性就是要證明factorial(0)的正確性。而factorial(0)的正確性不依賴於別的函數調用,它就是程式中的一個小的分支return
1;這個1是我們根據階乘的定義寫的,肯定是正確的,因此factorial(1)的實現是正確的,因此factorial(2)也正確,依此類推,最後factorial(n)也是正確的。其實這就是在中學時學的數學歸納法(Mathematical
Induction),用數學歸納法來證明只需要證明兩點:Base Case正確,遞推關係正確。寫遞迴函式時一定要記得寫Base
Case,否則即使遞推關係正確,整個函數也不正確。如果factorial函數漏掉了Base
Case,那麼會導致無限迴圈。
三、遞迴演算法題
從上一節的一個關於求階乘的簡單例子的論述,我們可以瞭解到遞迴演算法的精髓:要從功能上理解函數,同時你要相信你正在寫的函數是正確的,在此基礎上調用它,那麼它就是正確的。下面就從幾個常見的演算法題來看看如何理解遞迴,這是我的一些理解,歡迎大家提出更好的方法。
1)漢諾塔問題
漢諾塔問題是個常見問題,就是說有n個大小不等的盤子放在一個塔A上面,自底向上按照從小到大的順序排列。要求將所有n個盤子搬到另一個塔C上面,可以藉助一個塔B中轉,但是要滿足任何時刻大盤子不能放在小盤子上面。
基本思想分三步,先把上面的N-1個盤子經C移到B,然後將最底下的盤子移到C,再講B上面的N-1個盤子經A移動到C。總的時間複雜度f(n)=2f(n-1)+1,所以f(n)=2^n-1。
void hano(char a, char b, char c, int n) { if (n > 0) { hano(a, c, b, n-1); move(a, c); hano(b, a, c, n-1); }}void move(char a, char b){ cout << a << "->" << b << endl;}
2)求二叉樹的深度這裡的深度指的是二叉樹從根結點到葉結點最大的高度,比如只有一個結點,則深度為1,如果有N層,則高度為N。
int depth(struct node* root) { if (root == NULL) return 0; else { int lDepth = depth(root->left); //擷取左子樹深度 int rDepth = depth(root->right); //擷取右子樹深度 return lDepth>rDepth? lDepth+1: rDepth+1; //取較大值+1即為二叉樹深度 } } 那麼如何從功能上理解depth函數呢?我們可以知道定義該函數的目的就是求二叉樹深度,也就是說我們要是完成了函數depth,那麼depth(root)就能正確返回以root為根結點的二叉樹的深度。
因此我們的代碼中depth(root->left)返回左子樹的深度,而depth(root->right)返回右子樹的深度。儘管這個時候我們還沒有寫完depth函數,但是我們相信depth函數能夠正確完成功能。因此我們得到了lDepth和rDepth,而後通過比較返回較大值加1為二叉樹的深度。如果不好理解,可以想象在depth中調用的函數depth(root->left)為另外一個同樣名字完成相同功能的函數,這樣就好理解了。
注意Base Case,這裡就是當root==NULL時,則深度為0,函數返回0。
3)判斷二叉樹是否平衡一顆平衡的二叉樹是指其任意結點的左右子樹深度之差不大於1。判斷一棵二叉樹是否是平衡的,可以使用遞迴演算法來實現。
bool is_balanced(BinaryTreeNode* pRoot){ if(pRoot == NULL) //基本情況,為空白的話,返回true return true; int left = depth(pRoot->m_pLeft); int right = depth(pRoot->m_pRight); int diff = left - right; //計算左右子樹深度之差 if(diff > 1 || diff < -1) //如果深度之差大於1返回false return false; return is_balanced(pRoot->m_pLeft) && is_balanced(pRoot->m_pRight); //遞迴判斷左右子樹,注意是&&,即左右子樹都必須是平衡的這棵二叉樹才是平衡的}
該函數的功能定義是二叉樹pRoot是平衡二叉樹,即它所有結點的左右子樹深度之差不大於1。首先判斷根結點是否滿足條件,如果不滿足,則直接返回false。如果滿足,則需要判斷左子樹和右子樹是否都是平衡二叉樹,若都是則返回true,否則false。上面代碼效能不高,會重複遍曆結點,一個改進的演算法是採用後序遍曆的方式遍曆樹的結點,在遍曆到本結點前我們已經遍曆完了它的左右子樹,我們只需要在遍曆的時候記錄結點的深度,就可以一邊遍曆一邊判斷該結點是否是平衡的。代碼如下:
bool is_balanced_2(BinaryTreeNode* pRoot, int* pDepth){ if(pRoot == NULL) { *pDepth = 0; return true; } int left, right; if(is_balanced_2(pRoot->m_pLeft, &left) //左子樹平衡 && is_balanced_2(pRoot->m_pRight, &right)) //右子樹平衡 { int diff = left - right; if(diff <= 1 && diff >= -1) { *pDepth = 1 + (left > right ? left : right); return true; } } return false;}
該函數功能定義是返回以pRoot為根的二叉樹是否是平衡二叉樹,同時把樹的深度儲存在pDepth指向的值中。基本情況是樹為NULL,則深度為0,返回true。否則只有左右子樹都是平衡的情況下,深度分別存在變數left和right中,判斷左右子樹的深度之差是否不大於1,如果是則返回true,注意還要設定樹的深度值。
調用的函數定義如下:
bool IsBalanced(BinaryTreeNode* pRoot){ int depth = 0; return is_balanced_2(pRoot, &depth);}4)排列演算法排列演算法也是遞迴的典範,記得當初第一次看時一層層跟代碼,頭都大了,現在從函數功能上來看確實好理解多了。先看代碼:
void perm(int a[], int k, int N) { //k為起始位置,N為數組大小if (k == N-1) { output(a, N); //輸出排列} else {for (int i=k; i<N; i++) {swap(a, i, k); //交換perm(a, k+1, N); //下一次排列swap(a, i, k); //恢複原來的序列}}}
首先明確的是perm(a, k, N)函數的功能:輸出數組a從位置k開始的所有排列,數組長度為N。這樣我們在調用程式的時候,調用格式為perm(a, 0, N),即輸出數組從位置0開始的所有排列,也就是該數組的所有排列。
基礎條件是k==N-1,此時已經到達最後一個元素,一次排列已經完成,直接輸出。否則,從位置k開始的每個元素都與位置k的值交換(包括自己與自己交換),然後進行下一次排列,排列完成後記得恢複原來的序列。假定數組a大小N=3,則程式調用perm(a, 0, 3)可以如下理解:第一次交換0,0,並執行perm(a, 1, 3),執行完再次交換0,0,數組此時又恢複成初始值。第二次交換1,0(注意數組此時是初始值),並執行perm(a, 1, 3), 執行完再次交換1,0,數組此時又恢複成初始值。第三次交換2,0,並執行perm(a, 1, 3),執行完成後交換2,0,數組恢複成初始值。也就是說,從功能上看,首先確定第0個位置,然後調用perm(a, 1, 3)輸出從1開始的排列,這樣就可以輸出所有排列。而第0個位置可能的值為a[0], a[1],a[2],這通過交換來保證第0個位置可能出現的值,記得每次交換後要恢複初始值。如數組a={1,2,3},則程式運行輸出結果為:1 2 3 ,1 3 2 ,2 1 3 ,2 3 1 ,3 2 1 ,3 1 2 。即先輸出以1為排列第一個值的排列,而後是2和3為第一個值的排列。5)組合演算法組合演算法也可以用遞迴實現,只是它的原理跟0-1背包問題類似。即要麼選要麼不選,注意不能選重複的數。完整代碼如下:
#include<iostream>using namespace std;#define N 3 //數組大小為3int select[N] = {0}; //選擇數組,用於儲存數組哪些數字被選中。/*輸出數組中選中的數*/void output(int a[], int n){ for (int i=0; i<n; i++) { if (select[i]) cout << a[i] << " "; } cout << endl;}/*數組a從位置i開始選取k個數*/void combination(int a[], int i, int k){ if (i > N) return; //位置超出數組範圍直接返回,否則非法訪問會出段錯誤 if (k == 0) { //選取完了,輸出選取的數字 output(a, N); } else { select[i] = 1; combination(a, i+1, k-1); //第i個數字被選取,從後續i+1開始選取k-1個數 select[i] = 0; combination(a, i+1, k); //第i個數字不選,則從後續i+1位置開始還要選取k個數 }}/*組合主函數,包括選取1到n個數字*/void combination_helper(int a[], int n) { for (int k=1; k<=n; k++) { combination(a, 0, k); }}int main(){ int a[N] = {1, 2, 3}; combination_helper(a, N); return 0;}