前幾天面試的時候,面試官讓寫出快排的代碼,於是我就很easy的寫了一遍。面試官於是又問,你這代碼有什么可以優化的地方嗎?我當時想,這還不easy嗎?必須是隨機選取樞軸啊。於是我就開始解釋,在現實中,待排序的系列極有可能是基本有序的,此時,總是固定選取第一個關鍵字(其實無論是固定選取哪一個位置的關鍵字)作為首個樞軸就變成了極為不合理的作法。這時候應該隨機獲得一個low與high之間的數rnd,讓它的關鍵字r[rnd]與r[low]交換,此時就不容易出現這樣的情況。於是乎就又洋洋灑灑的把隨機選取樞軸的代碼給加上了。這時候,面試官又問。如果我的待排序序列的划分極度不平衡,遞歸的深度趨近與n,而不是logn。怎么辦?這時候,我才明白。。原來面試官想問的是尾遞歸相關的問題啊。可惜。。我當時就只知道這個概念,沒有看過相關的資料和文檔。於是就跪了,這回來研究一下什么是尾遞歸吧,還有就是尾遞歸對於遞歸層次過深的優化。
first section:什么是尾遞歸?
直接上代碼:
public static void main(String[] args) { System.out.println(fact(4)); System.out.println(facttail(4, 1)); } public static int fact(int n){ if(n < 0){ return 0; }else if(n == 0){ return 1; }else if(n == 1){ return 1; }else{ return n * fact(n - 1); } } public static int facttail(int n, int a){ if(n < 0){ return 0; }else if(n == 0){ return 1; }else if(n == 1){ return a; }else{ return facttail(n - 1, n * a); } }
如代碼所示,這兩個函數都是計算n階乘的函數。上一種方法是普通的遞歸,下面的函數才是尾遞歸。尾遞歸到底是什么呢?其實很簡單:尾遞歸就是函數返回之前的最后一個操作是遞歸調用。說白了,尾遞歸的就是:將單次計算的結果緩存起來,傳遞給下次調用,相當於自動累積。
Second Section:尾遞歸為什么會節省棧的空間呢?
我們知道遞歸調用是通過棧來實現的,每調用一次函數,系統都將函數當前的變量、返回地址等信息保存為一個棧幀壓入到棧中,那么一旦要處理的運算很大或者數據很多,有可能會導致很多函數調用或者很大的棧幀,這樣不斷的壓棧,很容易導致棧的溢出。
我們回過頭看一下尾遞歸,函數在遞歸調用之前已經把所有的計算任務已經完畢了,他只要把得到的結果全交給子函數就可以了,無需保存什么,子函數其實可以不需要再去創建一個棧幀,直接把就着當前棧幀,把原先的數據覆蓋即可。
需要注意的是:在Java、C#等語言中,尾遞歸使用非常少見,一方面我們可以直接用循環解決,另一方面這幾種語言的編譯器也不會自動優化尾遞歸。而在函數式語言中,尾遞歸卻是一種神器,要實現循環就靠它了。
Third Section:快排中尾遞歸的使用!
QuickSort函數在其尾部有兩次遞歸操作。如果待排序的序列划分極端不平衡,遞歸的深度將趨近於n,而不是平衡時的logn。我們怎么對QuickSort進行尾遞歸的改動呢?
public void QuickSort(int[] num, int low, int high) { int pivot; while (low < high) { pivot = partition(num, low, high); ; QuickSort(num, low, pivot - 1); // 對低子表遞歸排序 low = pivot + 1; // 尾遞歸 } }
注意高亮部分的代碼。將if語句,改成了while,並且把low的值賦為pivot+1。再循環后,來一次Partition(L,low,high),其效果等同於“QuickSort(num,pivot+1,high);”。因采用迭代而不是遞歸的方法可以縮減堆棧深度,從而提高了整體性能。
