這個系列是我多年前找工做時對數據結構和算法總結,其中有基礎部分,也有各大公司的經典的面試題,最先發布在CSDN。現整理爲一個系列給須要的朋友參考,若有錯誤,歡迎指正。本系列完整代碼地址在 這裏。linux
注:此刻,我正在從廣州回家的綠皮火車上整理的這篇文章,火車搖搖晃晃,顛簸的滿是鄉愁,忍不住又想翻翻周雲蓬的《綠皮火車》了。———記於2018年9月30日夜22:00分。git
前面總結了隨機算法,此次再把之前寫的遞歸算法的文章梳理一下,這篇文章主要是受到宋勁鬆老師寫的《Linux C編程》的遞歸章節啓發寫的。最能體現算法精髓的非遞歸莫屬了,但願這篇文章對初學遞歸或者對遞歸有困惑的朋友們能有所幫助,若有錯誤,也懇請各路大牛指正。二叉樹的遞歸示例代碼請參見倉庫的 binary_tree 目錄,本文其餘代碼在 這裏。github
本段內容主要摘自《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(n-1) 這一步當作是在調用另外一個函數--另外一個有着相同函數名和相同代碼的函數,調用它就是跳到它的代碼裏執行,而後再返回 factorial(n-1) 這個調用的下一步繼續執行。編程
爲了證實遞歸算法的正確性,咱們能夠一步步跟進去看執行結果。記得剛學遞歸算法的時候,總是有丈二和尚摸不着頭腦的感受,那時候老是想着把遞歸一步步跟進去看執行結果。遞歸層次少還算好辦,可是層次一多,頭就大了,徹底不知道本身跟到了遞歸的哪一層。好比求階乘,若是隻是factorial(3)跟進去問題還不大,可是如果factorial(100)要跟進去那真的會煩死人。數組
事實上,咱們並非每一個函數都須要跟進去看執行結果的,好比咱們在本身的函數中調用printf函數時,並無鑽進去看它是怎麼打印的,由於咱們相信它能完成打印工做。 咱們在寫factorial函數時有以下代碼:bash
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)是正確的?若是你相信你正在寫的遞歸函數是正確的,並調用它,而後在此基礎上寫完這個遞歸函數,那麼它就會是正確的,從而值得你相信它正確。數據結構和算法
這麼說仍是有點玄乎,咱們從數學上嚴格證實一下 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)
也是正確的。
其實這就是在中學時學的數學概括法,用數學概括法來證實只須要證實兩點:Base Case正確,遞推關係正確。寫遞歸函數時必定要記得寫Base Case,不然即便遞推關係正確,整個函數也不正確。若是 factorial 函數漏掉了Base Case,那麼會致使無限循環。
從上一節的一個關於求階乘的簡單例子的論述,咱們能夠了解到遞歸算法的精髓:要從功能上理解函數,同時你要相信你正在寫的函數是正確的,在此基礎上調用它,那麼它就是正確的。 下面就從幾個常見的算法題來看看如何理解遞歸,這是個人一些理解,歡迎你們提出更好的方法。
題: 漢諾塔問題是個常見問題,就是說有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) return;
hano(a, c, b, n-1);
move(a, c);
hano(b, a, c, n-1);
}
void move(char a, char b)
{
printf("%c->%c\n", a, b);
}
複製代碼
這裏的深度指的是二叉樹從根結點到葉結點最大的高度,好比只有一個結點,則深度爲1,若是有N層,則高度爲N。
int depth(BTNode* 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。
一顆平衡的二叉樹是指其任意結點的左右子樹深度之差不大於1。判斷一棵二叉樹是不是平衡的,可使用遞歸算法來實現。
int isBalanceBTTop2Down(BTNode *root)
{
if (!root) return 1;
int leftHeight = btHeight(root->left);
int rightHeight = btHeight(root->right);
int hDiff = abs(leftHeight - rightHeight);
if (hDiff > 1) return 0;
return isBalanceBTTop2Down(root->left) && isBalanceBTTop2Down(root->right);
}
複製代碼
該函數的功能定義是二叉樹 root 是平衡二叉樹,即它全部結點的左右子樹深度之差不大於1。首先判斷根結點是否知足條件,若是不知足,則直接返回 0。若是知足,則須要判斷左子樹和右子樹是否都是平衡二叉樹,若都是則返回1,不然0。
排列算法也是遞歸的典範,記得當初第一次看時一層層跟代碼,頭都大了,如今從函數功能上來看確實好理解多了。先看代碼:
/**
* 輸出全排列,k爲起始位置,n爲數組大小
*/
void permute(int a[], int k, int n)
{
if (k == n-1) {
printIntArray(a, n); // 輸出數組
} else {
int i;
for (i = k; i < n; i++) {
swapInt(a, i, k); // 交換
permute(a, k+1, n); // 下一次排列
swapInt(a, i, k); // 恢復原來的序列
}
}
}
複製代碼
首先明確的是 perm(a, k, n)
函數的功能:輸出數組 a 從位置 k 開始的全部排列,數組長度爲 n。這樣咱們在調用程序的時候,調用格式爲 perm(a, 0, n)
,即輸出數組從位置 0 開始的全部排列,也就是該數組的全部排列。基礎條件是 k==n-1
,此時已經到達最後一個元素,一次排列已經完成,直接輸出。不然,從位置k開始的每一個元素都與位置k的值交換(包括本身與本身交換),而後進行下一次排列,排列完成後記得恢復原來的序列。
假定數組a aan na a =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爲第一個值的排列。
組合算法也能夠用遞歸實現,只是它的原理跟0-1揹包問題相似。即要麼選要麼不選,注意不能選重複的數。完整代碼以下:
/*
* 組合主函數,包括選取1到n個數字
*/
void combination(int a[], int n)
{
int *select = (int *)calloc(sizeof(int), n); // select爲輔助數組,用於存儲選取的數
int k;
for (k = 1; k <= n; k++) {
combinationUtil(a, n, 0, k, select);
}
}
/*
* 組合工具函數:從數組a從位置i開始選取k個數
*/
void combinationUtil(int a[], int n, int i, int k, int *select)
{
if (i > n) return; //位置超出數組範圍直接返回,不然非法訪問會出段錯誤
if (k == 0) { //選取完了,輸出選取的數字
int j;
for (j = 0; j < n; j++) {
if (select[j])
printf("%d ", a[j]);
}
printf("\n");
} else {
select[i] = 1;
combinationUtil(a, n, i+1, k-1, select); //第i個數字被選取,從後續i+1開始選取k-1個數
select[i] = 0;
combinationUtil(a, n, i+1, k, select); //第i個數字不選,則從後續i+1位置開始還要選取k個數
}
}
複製代碼
這個比較簡單,代碼以下:
void reversePrint(const char *str)
{
if (!*str)
return;
reversePrint(str + 1);
putchar(*str);
}
複製代碼
鏈表逆序一般咱們會用迭代的方式實現,可是若是要顯得特立獨行一點,可使用遞歸,以下,代碼請見倉庫的 aslist
目錄。
/**
* 鏈表逆序,遞歸實現。
*/
ListNode *listReverseRecursive(ListNode *head)
{
if (!head || !head->next) {
return head;
}
ListNode *reversedHead = listReverseRecursive(head->next);
head->next->next = head;
head->next = NULL;
return reversedHead;
}
複製代碼