遞迴運用
一個函式直接或間接的呼叫自身,這個函式即可叫做遞迴函式。
遞迴主要功能是把問題轉換成較小規模的子問題,以子問題的解去逐漸逼近最終結果。
遞迴最重要的是邊界條件,這個邊界是整個遞迴的終止條件。
1 2 3 4 5 6 7 |
static int RecFact(int x) { if (x == 0) return 1; return x * RecFact(x - 1); } RecFact(10); |
上面是個經典階乘函式的實現。這裡分2步:
- 轉換,把10的階乘轉化成10*9!,10(9*8!)….每次轉換規模就變的更小。
- 逼近,轉換到最小規模時0!,求解1。開始逆向合併逐漸逼近到10,得出解。
這裡的x==0就是我們的邊界條件(即終止條件),也有的依賴外部變數為邊界。
如果一個遞迴函式沒有邊界,也就無法停止(無限迴圈至記憶體溢位),當然這樣也沒什麼意義。
RecFact呼叫堆疊:
常見使用場景:
- 階乘/斐波那契數列/漢諾塔
- 遍歷硬碟檔案
- InnerExceptions異常撲捉(exception.InnerException==null)
尾遞迴優化
當邊界不明確的時候,遞迴就很容易出現溢位問題。
在階乘過程中,堆疊需要儲存每次(RecFact)呼叫的返回地址及當時所有的區域性變數狀態,期間堆疊空間是無法釋放的(即容易出現溢位)。
為了優化堆疊佔用問題,從而提出尾遞迴優化的辦法。
1 2 3 4 5 6 7 8 |
static void TailRecursion(int x) { Console.WriteLine(x); if (x == 10) return; TailRecursion(x + 1); } TailRecursion(0); |
使用尾遞迴堆疊可以不用儲存上次的函式返回地址/各種狀態值,而方法遺留在堆疊上的資料完全可以釋放掉,這是尾遞迴優化的核心思想。
由於尾遞迴期間,堆疊是可以釋放/再利用的,也就解決遞迴過深而引起的溢位問題,這也是尾遞迴的優勢所在。
編譯器優化
尾遞迴優化,看起來是蠻美好的,但在net中卻有點亂糟糟的感覺。
- Net在C#語言中是JIT編譯成彙編時進行優化的。
- Net在IL上,有個特殊指令tail去實現尾遞迴優化的(F#中)。
我們執行 TailRecursion(0)(x==1000000) 得出如下結論:
C#/64位/Release是有JIT編譯器進行尾遞迴優化的(非C#編譯器優化)。
C#/32位或C#/Debug模式中JIT是不進行優化的。
F#在優化尾遞迴也分2種情況:
1、 簡單的尾遞迴優化成while迴圈,如下:
1 2 3 |
let rec TailRecursion(x) = if (x = 1000) then true else TailRecursion(x + 1) |
(方便演示C#呈現)編譯器優化成:
1 2 3 4 5 6 7 8 |
public static bool TailRecursion(int x) { while (x != 0x3e8) { x++; } return true; } |
2、 複雜的尾遞迴,F#編譯器會生成IL指令Tail進行優化,如下:
1 |
let TailRecursion2 a cont = cont (a + 1) |
優化成:
如何定義複雜的尾遞迴呢?通常是後繼傳遞模式(CPS)。
F#中在debug模式下,需要在編譯時配置:
總結
在C#語言(過程式/物件導向程式設計思想)中,優先考慮的是迴圈,而不是遞迴/尾遞迴。 但在函數語言程式設計思想當中,遞迴/尾遞迴使用則是主流用法,就像在C#使用迴圈一樣。