尾遞歸及快排尾遞歸優化

尾遞歸

概念

如果一個函數中所有遞歸形式的調用都出現在函數的末尾,我們稱這個遞歸函數是尾遞歸的。當遞歸調用是整個函數體中最後執行的語句且它的返回值不屬於表達式的一部分時,這個遞歸調用就是尾遞歸。尾遞歸函數的特點是在迴歸過程中不用做任何操作,這個特性很重要,因爲大多數現代的編譯器會利用這種特點自動生成優化的代碼。

原理

編譯器檢測到一個函數調用是尾遞歸的時候,它就覆蓋當前的活動記錄而不是在棧中去創建一個新的。編譯器可以做到這點,因爲遞歸調用是當前活躍期內最後一條待執行的語句,於是當這個調用返回時棧幀中並沒有其他事情可做,因此也就沒有保存棧幀的必要了。通過覆蓋當前的棧幀而不是在其之上重新添加一個,這樣所使用的棧空間就大大縮減了,這使得實際的運行效率會變得更高。

 

內存中的棧

計算機系統中,棧是一個具有以上屬性的動態內存區域(雖然與數據結構中的棧有區別,但是它們的思想都是先進後出)。程序可以將數據壓入棧中,也可以將數據從棧頂彈出。在i386機器中,棧頂由稱爲esp的寄存器進行定位。壓棧的操作使得棧頂的地址減小,彈出的操作使得棧頂的地址增大。棧在程序的運行中有着舉足輕重的作用。最重要的是棧保存了一個函數調用時所需要的維護信息,這常常稱之爲堆棧幀或者活動記錄堆棧幀一般包含如下幾方面的信息:

(1)函數的返回地址和參數

(2)臨時變量:包括函數的非靜態局部變量以及編譯器自動生成的其他臨時變量。

 

棧在函數調用過程中的工作原理

int main() {
    foo1();
    foo2();
    return 0;
}

上面是一個簡單的示例代碼,現在簡單模擬一下這個 main 函數調用的整個過程,$ 字符用於表示佔地:

(1)建立一個函數棧。 $

(2)main 函數調用,將 main 函數壓進函數棧裏面。$ [main]

(3)做完了一些操作以後,調用 foo1 函數,foo1 函數入棧。$ [main] [foo1]

(4)foo1 函數返回並出棧。$ [main]

(5)做完一些操作以後,調用 foo2 函數,foo2 函數入棧。$ [main] [foo2]

(6)foo2 函數返回並出棧。$ [main]

(7)做完餘下的操作以後,main函數返回並出棧。$

上面這個過程說明了棧的作用。就是第 4 和第 6 步,讓 foo1 和 foo2 函數執行完了以後能夠在回到 main 函數調用 foo1 和 foo2 原來的地方。這就是棧,這種"先進後出"的數據結構的意義所在。

 

尾遞歸實例

尾遞歸,要先從遞歸講起。最簡單的例子——階乘。

以下是一個用線性遞歸寫的計算 n 的階乘的函數:

int fact(int n)             //線性遞歸
{
    if (n < 0)
        return 0;
    else if(n == 0 || n == 1)
        return 1;
    else
        return n * fact(n - 1);
}

普通遞歸的問題在於展開的時候會產生非常大的中間緩存,而每一層的中間緩存都會佔用寶貴的棧的空間,所導致了當這個 n 很大的時候,棧上空間不足則會產生"爆棧"的情況。

當n=5時,線性遞歸的遞歸過程如下:

fact(5)
{5*fact(4)}
{5*{4*fact(3)}}
{5*{4*{3*fact(2)}}}
{5*{4*{3*{2*fact(1)}}}}
{5*{4*{3*{2*1}}}}
{5*{4*{3*2}}}
{5*{4*6}}
{5*24}
120

 

n 的階乘的尾遞歸函數:

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=5時,尾遞歸的遞歸過程如下:

facttail(5,1)
facttail(4,5)
facttail(3,20)
facttail(2,60)
facttail(1,120)
120

 

誤區

跟上面的普通遞歸函數比起來,貌似尾遞歸函數因爲在展開的過程中計算並且緩存了結果,使得並不會像普通遞歸函數那樣展開出非常龐大的中間結果,所以不會爆棧?答案:當然不是!尾遞歸函數依然還是遞歸函數,如果不優化依然跟普通遞歸函數一樣會爆棧,該展開多少層依舊是展開多少層。不會爆棧是因爲語言的編譯器或者解釋器所做了"尾遞歸優化",才讓它不會爆棧的。

 

階乘函數及gdb調試

將上述2個階乘代碼進行編譯,並對兩種方法進行調試,觀察在程序運行過程中棧幀的使用情況以及程序的運行情況。以下會使用的gdb調試命令:

編譯:gcc/g++ test.c -g -o test
運行:gdb test
list+行號      查看程序指定行附近的代碼
b +行號        在該行添加斷點
r              運行程序
n              逐步運行程序
bt             打印調用棧的使用情況
info frame     查看當前棧幀的情況

代碼:

#include <bits/stdc++.h>
using namespace std;
#define M 5

int fact(int n)             //線性遞歸
{
    if (n < 0)
        return 0;
    else if(n == 0 || n == 1)
        return 1;
    else
        return n * fact(n - 1);
}
 
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);
}

int facttail1(int n, int a)  //尾遞歸轉化爲循環
{
    while(n > 0)
    {
        a = n * a;
        n--;
	}
	return a;
}
 
int main()
{
    //printf("%p", facttail);
    int a = fact(M);
    int b = facttail(M, 1);
    cout << "A:" << a <<endl;
    cout << "B:" << b <<endl;
}

 

非尾遞歸階乘的調試情況:

(1)使用 b 設置斷點並運行

(2)使用 bt 命令查看棧的使用情況

(3)遞歸層層返回

 

尾遞歸階乘的調試情況:

上述的尾遞歸階乘函數並未優化,所以兩個階乘函數展開的層數還是一樣的。但是兩者還是有不一樣的地方,從上圖中可以看出,尾遞歸階乘函數在運行到最後時,它是直接返回相應的值。而非尾遞歸階乘函數是層層深入然後再一層層地返回,最後得到結果。在這一過程中可以使用info frame命令查看更爲詳細的棧幀信息。

所有遞歸都能等效於循環+棧(例如:數據結構中的非遞歸前、中、後序遍歷),尾遞歸只是只是恰好是那種沒有找的最簡單的情況遞歸之所以能寫出比循環可讀性高的代碼是因爲遞歸隱含了一個棧,而用循環實現的時候需要手動維護一個棧導致代碼長,但是尾遞歸恰好就是那個不需要這個棧的特殊情況,也就是說這個時候遞歸相對於循環完全沒有任何優勢了。對於無棧循環不能等效的遞歸函數,轉化成尾遞歸比轉化成有棧循環更難看並且還更慢。

 

快排尾遞歸優化及gdb調試

以下將使用兩種快排的方法,即尾遞歸優化的快排和普通快排。通過對兩種方法的調試,觀察程序運行過程中棧的使用情況。將尾遞歸優化成迭代的關鍵

1.代碼主體是根據基準值完成排序後再遞歸調用函數。

2.將參數 low 提取出來,使其成爲迭代變量。

3.將原來函數的裏面所代碼在一個 while (true) 裏面。

4.遞歸終止的 return 不變,這裏當low >= high時遞歸終止

代碼:

#include <stdio.h>

int Partition(int a[], int low, int high)
{
	int i,j,k,temp;
	i = low;
	j = high+1;
	k = a[low];
	while(1)
	{
		while(a[++i] < k && i < j);
		while(a[--j] > k);
		if(i >= j) break;
		else
		{
			temp = a[i];
			a[i] = a[j];
			a[j] = temp;
		}

	}
	a[low] = a[j];
	a[j] = k;
	return j;
}

void QuickSort(int a[], int low, int high)
{
	if(low < high)
	{
		int q = Partition(a, low, high);
		QuickSort(a, low, q-1);
		QuickSort(a, q+1, high);
	}
}

void QuickSort1(int a[], int low, int high)
{
	int pivotPos;
	while(low < high)
    {
        pivotPos = Partition(a,low,high);
        QuickSort(a,low,pivotPos-1);
        low = pivotPos + 1;
    }
}

int main()
{
	int i;
	int a[10] = {3,4,5,6,1,2,0,7,8,9};
	int b[10] = {3,4,5,6,1,2,0,7,8,9};
	QuickSort(a, 0, 9);
	QuickSort1(b, 0, 9);
	for(i = 0; i < 10; ++i){
		printf("[%d]", a[i]);
	}
	printf("\n");
	
	for(i = 0; i < 10; ++i){
		printf("[%d]", b[i]);
	}
	printf("\n");
	return 0;
}

 

普通快排調試情況:

(1)使用 b 設置斷點並運行,在這一過程中注意參數 low 、high 和棧的變化

(2)運行過程中參數的變化以及棧最深的情況

 

尾遞歸優化的快排調試情況:

(1)使用 b 設置第42行代碼爲斷點並運行,在這一過程中注意參數 low 、high 和棧的變化

(2)接下來都是逐步運行並觀察參數和棧的使用情況

(3)最後一步運行完,返回 main 函數

從上圖中可以明顯看出,尾遞歸優化的快排使用的棧空間很少,因爲該方法使用迭代代替了遞歸操作。當數據量足夠大時,使用尾遞歸優化後,可以縮減堆棧的深度,由原來的O(n)縮減爲O(logn)。

 

總結

關於尾遞歸的問題,網上有許多資料,但大多都是將問題敘述了一遍,也沒有提及優化的過程。百度百科中以階乘函數的尾遞歸爲例向大家介紹了這個問題,但是結論中有一個表述是:可以減少棧的深度。(1)這個表述是有問題的,經過對代碼的調試(沒有進行優化),發現兩種階乘方法遞歸的深度是一樣的。(2)需要對代碼進行尾遞歸優化才能達到減少棧的深度的目的。如果發現類似的問題,建議大家調試相應的程序,查看棧的使用情況。

參考:https://zhuanlan.zhihu.com/p/36587160

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章