資料結構和演算法面試題系列—遞迴演算法總結

ssjhust發表於2018-09-30

這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在CSDN。現整理為一個系列給需要的朋友參考,如有錯誤,歡迎指正。本系列完整程式碼地址在 這裡

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

0 概述

前面總結了隨機演算法,這次再把以前寫的遞迴演算法的文章梳理一下,這篇文章主要是受到宋勁鬆老師寫的《Linux C程式設計》的遞迴章節啟發寫的。最能體現演算法精髓的非遞迴莫屬了,希望這篇文章對初學遞迴或者對遞迴有困惑的朋友們能有所幫助,如有錯誤,也懇請各路大牛指正。二叉樹的遞迴示例程式碼請參見倉庫的 binary_tree 目錄,本文其他程式碼在 這裡

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函式時有如下程式碼:

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語言實現

相關文章