數據結構和算法面試題系列—遞歸算法總結

這個系列是我多年前找工做時對數據結構和算法總結,其中有基礎部分,也有各大公司的經典的面試題,最先發布在CSDN。現整理爲一個系列給須要的朋友參考,若有錯誤,歡迎指正。本系列完整代碼地址在 這裏linux

注:此刻,我正在從廣州回家的綠皮火車上整理的這篇文章,火車搖搖晃晃,顛簸的滿是鄉愁,忍不住又想翻翻周雲蓬的《綠皮火車》了。———記於2018年9月30日夜22:00分。git

0 概述

前面總結了隨機算法,此次再把之前寫的遞歸算法的文章梳理一下,這篇文章主要是受到宋勁鬆老師寫的《Linux C編程》的遞歸章節啓發寫的。最能體現算法精髓的非遞歸莫屬了,但願這篇文章對初學遞歸或者對遞歸有困惑的朋友們能有所幫助,若有錯誤,也懇請各路大牛指正。二叉樹的遞歸示例代碼請參見倉庫的 binary_tree 目錄,本文其餘代碼在 這裏github

1 遞歸算法初探

本段內容主要摘自《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,那麼會致使無限循環。

2 遞歸經典問題

從上一節的一個關於求階乘的簡單例子的論述,咱們能夠了解到遞歸算法的精髓:要從功能上理解函數,同時你要相信你正在寫的函數是正確的,在此基礎上調用它,那麼它就是正確的。 下面就從幾個常見的算法題來看看如何理解遞歸,這是個人一些理解,歡迎你們提出更好的方法。

2.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) 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);
}
複製代碼

2.2)求二叉樹的深度

這裏的深度指的是二叉樹從根結點到葉結點最大的高度,好比只有一個結點,則深度爲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 函數可以正確完成功能。所以咱們獲得了 lDepthrDepth,然後經過比較返回較大值加1爲二叉樹的深度。

若是很差理解,能夠想象在 depth 中調用的函數 depth(root->left) 爲另一個一樣名字完成相同功能的函數,這樣就好理解了。注意 Base Case,這裏就是當 root==NULL 時,則深度爲0,函數返回0

2.3)判斷二叉樹是否平衡

一顆平衡的二叉樹是指其任意結點的左右子樹深度之差不大於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。

2.4)排列算法

排列算法也是遞歸的典範,記得當初第一次看時一層層跟代碼,頭都大了,如今從函數功能上來看確實好理解多了。先看代碼:

/**
 * 輸出全排列,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爲第一個值的排列。

2.5)組合算法

組合算法也能夠用遞歸實現,只是它的原理跟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個數
    }
}
複製代碼

2.6) 逆序打印字符串

這個比較簡單,代碼以下:

void reversePrint(const char *str) 
{
    if (!*str)
        return;

    reversePrint(str + 1);
    putchar(*str);
}
複製代碼

2.7) 鏈表逆序

鏈表逆序一般咱們會用迭代的方式實現,可是若是要顯得特立獨行一點,可使用遞歸,以下,代碼請見倉庫的 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;
}
複製代碼

參考資料

  • 宋勁鬆老師《Linux C編程》遞歸章節
  • 數據結構與算法-C語言實現
相關文章
相關標籤/搜索