大二上數據結構課,老師在講解「棧與遞歸的實現」時,引入了漢諾塔的問題,使用遞歸來解決n個盤在(x,y,z)軸上移動。html
例以下面的動圖(圖片出自於漢諾塔算法詳解之C++):算法
三個盤的狀況:編程
四個盤的狀況:數據結構
若是是5個、6個、7個、...,該如何移動呢?架構
因而,老師給了一段經典的遞歸代碼:函數
void hanoi(int n,char x,char y,char z){ if(n == 1) move(x,1,z); else{ hanoi(n-1,x,z,y); move(x,n,z); hanoi(n-1,y,x,z); } }
簡簡單單的幾句代碼裏蘊含中深奧的祕密啊!post
用圖像來說解一下這裏面的原理吧,例以下圖:優化
(圖片來源於「碼農翻身」公衆號)ui
假設盤的序號從上往下增大,第一個盤序號爲1,最後一個盤序號爲n。每次只能移動一個盤,而且大盤不能在小盤的上面。那麼運用遞歸的思想可知,若想將n號盤放到z軸上,那麼必須先將(1,...,n-1)號盤移動到y軸上,此時z軸做爲輔助軸。即url
hanoi(n-1,x,z,y);
而後移動n號盤到z軸上,即
move(x,n,z);
最後將y軸上的(1,...,n-1)號盤移動到z軸上,此時x軸做爲輔助軸。即
hanoi(n-1,y,x,z);
再說一個例子:計算n的階乘
f(n) = n!
其遞歸算法以下:
int factorial(int n){ if(n == 1) return 1; else return n * factorial(n-1); }
這段程序加載到內存的分配圖以下:
(圖片來源於「碼農翻身」公衆號)
因爲遞歸是函數自身調用自身,因此程序被編譯後代碼段中只有一份代碼。
遞歸調用是如何進行的呢?
注意看堆棧中的棧幀啊, 每一個棧幀就表明了被調用中的一個函數, 這些函數棧幀以先進後出的方式排列起來,就造成了一個棧, 棧幀的結構以下圖所示:
(圖片來源於「碼農翻身」公衆號)
相信你們還記得《數據結構》(嚴蔚敏版)一書中提到的「工做記錄」就是指函數棧幀。棧頂指針被稱爲「當前環境指針」。
忽略到其餘內容, 只關注輸入參數和返回值的話,階乘函數factorial(4)的工做棧以下圖所示:
(圖片來源於「碼農翻身」公衆號)
其計算過程以下圖所示:
(圖片來源於「碼農翻身」公衆號)
注意, 每一個遞歸函數必須得有個終止條件, 要否則就會發生無限遞歸了, 永遠都出不來了。
固然針對於此遞歸算法,對於n的值是有限制的。由於堆棧容量是有限的,若是n值太大程序會崩掉。
該如何解決呢?
從上面的代碼中能夠知道「factorial(n) = n * factorial(n-1 ) 」 ,這個計算式是整個程序的核心。 圖中每一個棧幀都須要記錄下當前的n的值, 還要記錄下一個函數棧幀的返回值, 而後才能運算出當前棧幀的結果。 也就是說使用多個棧幀是不可避免的。
可使用下面的遞歸算法:
int factorial(int n,int result){ if(n == 1){ return result; } else{ return factorial(n-1,n * result); } }
注意函數的最後一個語句, 就不是 n * factorial(n-1) 了, 而是直接調用factorial(....) 這個函數自己, 這就帶來了巨大的好處。
計算過程以下:
當執行到factorial(1, 24)的時候直接就能夠返回結果了。
這就是妙處所在了,計算機發現這種狀況,只用一個棧幀就能夠搞定這些計算,不管n有多大。
(圖片來源於「碼農翻身」公衆號)
這就是所謂的「尾遞歸」了, 當遞歸調用是函數體中最後執行的語句而且它的返回值不屬於表達式一部分時, 這個遞歸就是尾遞歸。
現代的編譯器就會發現這個特色, 生成優化的代碼, 複用棧幀。 第一個算法中由於有個n * factorial(n-1) , 雖然也是遞歸,可是遞歸的結果處於一個表達式中,還要作計算, 因此就無法複用棧幀了,只能一層一層的調用下去。
另外,向你們推薦一個公衆號「碼農翻身」。上面有不少有關計算機方面的文章,淺顯易懂,十分受用。本文也在必定程度上,吸取了該公衆號上的精華。
「碼農翻身」 公共號 : 由工做15年的前IBM架構師建立,分享編程和職場的經驗教訓。