C++-遞迴
遞迴
在此之前分享一句話:遞迴是神,迭代是人。這裡的神是針對資料結構這門課程,在實際應用中因為諸多的物理限制,導致遞迴可能因為棧溢位等,使用受限,其實如果是單純資料結構這門課程,遞迴能為你節省相當多的麻煩,故遞迴是“神”!
有太多太多的同學匆匆就開始學習二叉樹、連結串列等資料結構,對指標跟遞迴等基本概念都沒有徹底明白,導致學習資料結構的時候只能知曉個大概,動手寫的時候只能套用別人的,自己寫憋半天,其實二叉樹並不難,難是同學們的基礎沒有打牢實,就匆匆學習,從而產生畏懼心理學不全面,草草了事。
你已經見過許多基於迴圈的演算法,它們一遍又一遍地執行某些任務。現在來講另一類不使用迴圈卻可以重複執行程式碼的方法,這種方法使用的是重複的函式呼叫,我們把它稱為遞迴。遞迴是一種在表達操作時會用到自身的技術,也就是說,遞迴意味著編寫的函式會呼叫自身。它跟迴圈類似,但功能更強大。它可以使某些幾乎不可能用迴圈來完成的程式變成小事一樁!遞迴尤其適合於應用在諸如連結串列、二叉樹(馬上就講到了)這樣的資料結構中。接下來的兩章內容,我們一起通過一些具體的例子,來探討遞迴的基本思想。
如何看待遞迴
一個思考遞迴的有效方法是:把遞迴看做一個執行過程,這個執行過程的其中一條指令是“重複這個執行過程”。這聽起來跟迴圈非常類似,因為都是在重複相同的程式碼。遞迴和迴圈確實在某些方面是類似的,但是,遞迴可以更容易地表達這樣一種想法:執行過程的結果是完成執行過程所必需的。當然,這個“執行過程”必須存在某個時刻可以不用再遞迴呼叫就能夠完成。舉個簡單的例子,砌築一面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。樹中的值並不重要,重要的是節點的位置。我在下面的二叉樹中放置的是節點刪除的順序,而不是每個節點的值:
等等!!