遞歸
在此之前分享一句話:遞歸是神,迭代是人。這里的神是針對數據結構這門課程,在實際應用中因為諸多的物理限制,導致遞歸可能因為棧溢出等,使用受限,其實如果是單純數據結構這門課程,遞歸能為你節省相當多的麻煩,故遞歸是“神”!
有太多太多的同學匆匆就開始學習二叉樹、鏈表等數據結構,對指針跟遞歸等基本概念都沒有徹底明白,導致學習數據結構的時候只能知曉個大概,動手寫的時候只能套用別人的,自己寫憋半天,其實二叉樹並不難,難是同學們的基礎沒有打牢實,就匆匆學習,從而產生畏懼心理學不全面,草草了事。
你已經見過許多基於循環的算法,它們一遍又一遍地執行某些任務。現在來講另一類不使用循環卻可以重復執行代碼的方法,這種方法使用的是重復的函數調用,我們把它稱為遞歸。遞歸是一種在表達操作時會用到自身的技術,也就是說,遞歸意味着編寫的函數會調用自身。它跟循環類似,但功能更強大。它可以使某些幾乎不可能用循環來完成的程序變成小事一樁!遞歸尤其適合於應用在諸如鏈表、二叉樹(馬上就講到了)這樣的數據結構中。接下來的兩章內容,我們一起通過一些具體的例子,來探討遞歸的基本思想。
如何看待遞歸
一個思考遞歸的有效方法是:把遞歸看做一個執行過程,這個執行過程的其中一條指令是“重復這個執行過程”。這聽起來跟循環非常類似,因為都是在重復相同的代碼。遞歸和循環確實在某些方面是類似的,但是,遞歸可以更容易地表達這樣一種想法:執行過程的結果是完成執行過程所必需的。當然,這個“執行過程”必須存在某個時刻可以不用再遞歸調用就能夠完成。舉個簡單的例子,砌築一面10尺高牆。如果我想建造一面十英尺高的牆,我會先建造一個九英尺高的牆,然后添加一層額外的牆磚。從概念上講,這就好比說:“建牆”函數接受了一個高度值,如果這個高度值大於1,“建牆”函數首先要調用自身來建造一個稍低的牆,然后添加一層額外的牆磚。
這個“建牆”函數的基本結構看起來應該如下面的代碼所示。(這段代碼有幾個明顯的缺陷,我們很快會討論到。)這里面最重要的思想是:建造一個特定高度的牆可以用建造一個更低的牆來表達。
void buildWall (int height) { buildWall( height - 1 ); addBrickLayer(); }
但這段代碼有一個小問題,不是嗎?什么時候會停止調用buildWall呢?很遺憾,答案是,永遠不。解決辦法很簡單:我們需要在牆高為0時停止遞歸調用。牆的高度為0時,我們應該僅僅添加一層牆磚即可,不用建造任何更低的牆體。
void buildWall (int height) { if ( height > 0 ) { buildWall( height - 1 ); } addBrickLayer(); }
函數不調用自身的情況稱為函數的基線條件。在剛才的例子中,“建牆”函數知道如果已經到達地面,就只要添加一層牆磚就可以了(建牆的基線條件)。否則,我們仍然需要建立一堵更低的牆,然后在上面添加一層磚。如果你對這段代碼還是疑惑不解(第一次見到遞歸時,人們往往一頭霧水),想想建造一堵牆的物理過程。剛開始,你希望建造一堵特定高度的牆,接着就會說:“我需要一堵矮一層的牆,好讓我把磚塊放上去。”最終,你就會說:“我不需要一堵更矮的牆了,我可以直接在地面上建造。”這就是基線條件。
注意,這個算法先將一個大問題簡化成更小的問題(建造一堵更矮的牆),然后去解決這個更小的問題。在某些情況下,更小的問題(如在地面上建造一層高的牆體)小到不再需要進一步簡化,而是可以馬上就解決。在現實生活中,這意味着可以建立一堵牆了;而在C++里,這確保了該函數將最終停止遞歸調用。這很像之前看到過的自頂向下的設計過程,我們把問題分解成更小的子問題,創建出這些子問題的函數,然后用它們來構建完整的程序。這種情況下,我們將問題分解成了不同的子問題,而不是一個正在解決的問題;而在遞歸中,我們將一個問題分解成了相同問題的更小版本。
一旦函數調用了自己,當調用返回時,它會去執行調用點之后的下一行語句。類似的,遞歸調用返回后,函數仍可以執行操作或調用其他函數。在“建牆”的例子中,建造小牆后,函數將繼續執行,添加一層新的磚塊。
下面是一個實際可運行的例子,用來展示實際的輸出。怎樣寫出一個遞歸函數,來輸出數字123 456 789 987 654 321呢?我們可以先編寫一個函數,它接受一個數字,然后兩次輸出這個數字,一次在函數遞歸之前,一次在遞歸之后。
#include <iostream> using namespace std; void printNum (int num) { // 函數的兩次cout調用,將像“三明治”一樣輸出 // 形如 (num+1)...99...(num+1) 的數字序列 cout << num; // 只要num小於9, 就遞歸輸出 // 序列 (num+1) ... 99 ... (num+1) if ( num < 9 ) { printNum( num + 1 ); } cout << num; } int main () { printNum( 1 ); }
有些數據結構會借用到遞歸算法,因為這些數據結構的組成可以描述成含有相同數據結構的更小版本。既然遞歸算法通過將問題分解成原問題的更小版本來解決,數據結構也一樣可以將原數據結構分解成相同數據結構的更小版本——鏈表就是一種這樣的數據結構。
之前已經說過,鏈表是這樣一種列表:你可以在鏈表前面增添更多的新節點。但從另一個角度去思考,也可以認為,鏈表由一個首節點構成,這個首節點指向了另一個更小版本的鏈表。
這一點很重要,因為它提供了一個非常有用的特性:可以編寫這樣一種處理鏈表的程序,它要么處理當前節點,要么去處理“列表的其余部分”。例如,要找到列表中的一個特定節點,可以使用此基本算法:
如果我們在列表的末尾,返回NULL。 否則,如果當前節點就是查找的目標,將其返回。否則,在列表的其余部分繼續查找。
在代碼中,應該是這樣的:
struct node { int value; node *next; }; node* search (node* list, int value_to_find) { if ( list == NULL ) { return NULL; } if ( list->value == value_to_find ) { return list; } else { return search( list->next, value_to_find ); } }
當考慮一個遞歸調用時,我們提到過,被調用函數中會做一些事情。函數在給定的輸入下所承諾要做的事,稱為函數的契約。函數契約總結了函數所要做的事情。search函數的契約是查找到列表中的一個給定的節點。search函數的實現就相當於在說,“如果當前節點是我們想要找的,那么返回它;否則,函數的契約還是在列表中查找某個節點,讓我們用這個契約,來看看剩余的列表吧!”
在列表的剩余部分調用search函數,而不是整個列表,這一點很重要。
遞歸只有在滿足以下兩個條件時,才能夠正確運行:
1.能夠構造出一個通過解決同類型的較小問題來解決原問題的方案;
2.能夠解決基線條件。
search函數的解決有兩個可能的基線條件:要么到達列表的末尾,要么找到想要的節點。如果這兩種情況都沒有滿足,那么使用search函數來解決相同問題的較小版本。關鍵在於:我們能夠遞歸地利用相同問題的較小版本的解決結果,來解決更大的原問題,只有這樣,遞歸才能起到效果。
請注意,使用遞歸的過程中,我們不斷地求解子問題,然后用子問題的結果來做一些事。在搜索一個鏈表時,我們只是返回子問題的求解結果。遞歸用於兩種方式:要么是僅靠遞歸調用就能夠解決全部的問題,要么是獲得子問題的求解結果,然后使用該結果做更多的計算。
在某些情況下,遞歸算法可以很容易地轉化成用結構相同的循環來表示。例如,搜索列表的代碼可以寫成這樣:
node *search (node *list, int value_to_find) { while ( 1 ) { if ( list == NULL ) { return NULL; } if ( list->value == value_to_find ) { return list; } else { list = list->next; } } }
這段代碼進行的檢查實際上跟使用遞歸的版本是一樣的,你很容易看出兩者的差異。兩種算法的唯一區別是,這段代碼使用了一個循環,而不是遞歸。它沒有使用遞歸調用來縮短列表的大小,而是通過每次將它指向“列表的剩余部分”來實現的。這是一個遞歸的解決方案和迭代(基於循環)的解決方案有相似之處的例子。
當不需要對遞歸調用函數的返回值做任何處理時,通常很容易寫出遞歸算法的循環版本,反之亦然,我們也能很容易寫出循環算法的遞歸版本。這種情況就是尾遞歸(tail reursion):遞歸調用是遞歸函數在函數尾部所做的最后一件事情。由於遞歸調用是最后一個操作,這無異於循環中的下一步。一旦下一個調用完成,之前的調用就不再需要了。列表搜索就是一個尾遞歸的例子。
二叉樹
來看看結構化的數據到底是什么。剛開始時,你只會使用數組,數組僅僅是一個順序列表,沒有能力來提供其他任何數據結構。鏈表使用指針來逐步構建一個順序列表,但它沒有利用指針所具有的靈活性來構建更精巧的數據結構。
所謂的“更精巧的數據結構”指什么呢?首先,可以構建一個數據結構,它能夠同時擁有不止一個“下一個節點”。為什么要這么做呢?如果你有兩個“下一個節點”,其中一個代表比當前元素小的元素,另一個代表比當前元素大的元素,這種數據結構就稱為二叉樹。之所以如此命名,是因為在二叉樹中,每個節點最多有兩個分支。這里的“下一個節點”稱為子節點,指向一個子節點的節點稱為該子節點的父節點。
二叉樹的一個重要特性是,一個節點的每個子節點本身就是一棵完整的二叉樹。
這一特征,結合上“左子節點比當前節點小,右子節點比當前節點大”這一規則,使得尋找一棵樹中的某個節點的算法設計起來很容易。首先,查看當前節點的值,如果它等於搜索目標,則搜索結束,大功告成;如果搜索目標小於當前節點的值,你往左邊的樹中找;否則,到右邊的樹去找。這個算法能夠有效,主要因為左子樹中的每個節點都小於當前節點,而右子樹中的每個節點都大於當前節點。
最理想的二叉樹是平衡樹,即左子樹與右子樹的節點數量相同。對於一棵平衡樹來說,每個子樹是整棵樹的一半大小,如果你正在查找樹中的某個值,每到一個子節點,你的搜索就可以排除掉一半的元素。所以,如果有一棵1000個元素的平衡樹,你可以立即砍掉500個元素。搜索就減少到在一棵500個元素的子樹中進行。對一棵500個元素的樹進行搜索,我們再次可以砍掉大約一半的元素,約250個。繼續這樣每到一個節點就排除掉一半的元素,不用多久就能找到想要找的元素。總共需要多少次拆分樹的操作才能到達只有一個節點的樹呢?
答案是log2n,其中n為整棵樹的節點數量。這個值很小,即使對於非常大的樹(對於一棵約有40億個元素的樹,log2n為32,這意味着,其搜索速度比對同等大小的鏈表進行同樣的搜索要快近1億倍,因為在鏈表中你必須要逐個地查看每個元素 ) 。然而,如果這棵樹不平衡,可能就不能每次砍去樹的一半元素。在最壞情況下,每個節點只有一個子節點,也就是說這棵樹本質上是一個鏈表,只是比普通的鏈表多了一些額外的指針,那么其搜索過程就會退化到要遍歷全部的n個元素。
如你所見,當一棵樹大致平衡時(沒有必要一定要完全平衡 ) ,搜索節點的速度要遠遠快於在鏈表中的搜索。這一切歸根結底是因為我們可以根據自己的喜好來結構化內存,而不是止步於簡單的列表1。
實現二叉樹
讓我們來看看簡單實現一個二叉樹所需的代碼。首先,我們聲明一個節點結構體:
struct node { int key_value; node *p_left; node *p_right; };
我們的節點可以將key_value的值作為一個簡單的整數值存儲下來,並且包含兩個子樹,分別是p_left和p_right。
這幾個是你會在二叉樹上執行的常用函數:插入節點到樹中,搜索樹中的某個值,從樹中刪除某個節點,刪除整棵樹以釋放內存。
node* insert (node* p_tree, int key); node *search (node* p_tree, int key); void destroyTree (node* p_tree); node *remove (node* p_tree, int key);
在樹中插入新節點
首先學習使用遞歸算法來實現樹節點的插入。遞歸算法能用在樹上,是因為每棵樹都包含兩棵更小的樹,所以整個數據結構本身就是遞歸的。(假設每棵樹都包含一個數組或是一個指向鏈表的指針,那么這種數據結構就不是遞歸的了。 )
函數接受一個key值和一棵已存在的樹(可能為空 ) ,返回包含此插入值的新樹。
node* insert (node *p_tree, int key) { // 基線條件:我們到達了一棵空樹,需要將新節點插入到這里 if ( p_tree == NULL ) { node* p_new_tree = new node; p_new_tree->p_left = NULL; p_new_tree->p_right = NULL; p_new_tree->key_value = key; return p_new_tree; } // 決定將新節點插入到左子樹或右子樹中 // 取決於新節點的值 if( key < p_tree->key_value ) { // 根據p_tree -> left和新增的key值,構建一棵新樹, // 然后用一個指向新樹的指針來替換現有的p_tree -> left // 之所以需要替換現有的p_tree -> left,是為了防止 // 原有的p_tree -> left為NULL的情況(如果不為NULL,p_tree->p_left // 實際上不會改變,但替換下也無妨) p_tree->p_left = insert( p_tree->p_left, key ); } else { // 插入到右子樹的情況與插入到左子樹是對稱的 p_tree->p_right = insert( p_tree->p_right, key ); } return p_tree;
}
此算法的基本邏輯是:如果當前擁有的是一棵空樹,那就創建一棵新的樹。若非空樹,那么如果要插入的值大於當前節點,就將其插入左子樹中,並用新創建的子樹替換原來的左子樹;否則就將新節點插入右子樹中,並做同樣的替換。
讓我們在實例中看看這段代碼——將一棵空樹構建成有兩個節點的樹。如果將值10插入一棵空樹(NULL ) 中,立即達到了基線條件,其結果是一棵非常簡單的樹:
這棵樹的兩個子樹都指向了NULL。
如果再將值5插入到樹中,將調用函數:
insert( <頭為10的樹>, 5 )
由於5比10小,我們將對左子樹進行遞歸調用:
insert( NULL, 5 ) insert( <頭為10的樹>, 5 )
函數insert( NULL, 5 )將創建一棵新的樹,並將它返回:
當函數insert( <頭為10的樹>, 5 )收到返回的樹時,會將兩棵樹鏈接到一起。在這種情況下,頭為10的樹的左子樹原本為NULL,被替換后就變成了一棵全新的樹。
在樹中搜索
現在,來看看如何實現在樹中進行搜索,其基本邏輯與在樹中插入新節點的算法幾乎完全一樣:首先,檢查兩個基線條件(是否發現目標節點,或是否到達了一個空樹 ) ;如果基線條件不滿足,就確定應該去哪個子樹中搜索。
node *search (node *p_tree, int key) { // 如果到達了空樹,很明顯,值key不在這棵樹中! if ( p_tree == NULL ) { return NULL; } // 如果找到了值key,搜索完成! else if ( key == p_tree->key_value ) { return p_tree; } // 否則,嘗試在左子樹或右子樹中尋找 else if ( key < p_tree->key_value ) { return search( p_tree->p_left, key ); } else { return search( p_tree->p_right, key ); } }
上面的search函數首先檢查兩個基線條件:是否到達樹的分支末端或是否找到了值key。無論哪種情況,我們都知道應該返回什么:如果到達樹的分支末端,就返回NULL;如果找到了key值,就返回這棵樹本身。
如果基線條件不滿足,我們就在子樹中找key值,從而減小了問題。在左子樹還是在右子樹中查找,取決於key的值。請注意,每次遞歸調用,樹的大小正如本章開頭所講——約減少了一半。在本章開頭,我們還看到,在一棵平衡二叉樹中搜索所花費的時間正比於log2n,當數據量很大時,這遠比通過鏈表或數組進行搜索要快得多。
刪除樹
destroy_tree函數也應該是遞歸的。該算法將先刪除當前節點的兩個子樹,然后再刪除當前節點。
void destroy_tree (node *p_tree) { if ( p_tree != NULL ) { destroy_tree( p_tree->p_left ); destroy_tree( p_tree->p_right ); delete p_tree; } }
為了幫助理解整個遞歸調用過程,你可以在刪除節點前輸出節點的值:
void destroy_tree (node *p_tree) { if ( p_tree != NULL ) { destroy_tree( p_tree->p_left ); destroy_tree( p_tree->p_right ); cout << "Deleting node: " << p_tree->key_value; delete p_tree; } }
你會看到,那棵樹是“自下而上”被刪除的。節點5和節點8首先被刪除,接着是節點6;然后刪除樹的另一邊,刪除節點11和節點18,接着是節點14;最后,當所有的子節點都被刪除時,刪除節點10。樹中的值並不重要,重要的是節點的位置。我在下面的二叉樹中放置的是節點刪除的順序,而不是每個節點的值:
等等!!