為了效能,慎用遞迴

2023-11-16 12:00:47

慎用遞迴

起因:

在學習Rust的時候,有一道語法練習題是計算斐波那契數列的第N項的值,這是一道非常簡單的題,但是引發了一個使用遞迴效能問題,考慮到用Rust的人不多,後面的程式碼都是C#的,因為C#的語法更大眾一些,更好看懂

第一次解

public static ulong FibonacciNumberRecursion(int n)
{
    if (n == 1)
        return 0;
    else if (n == 2)
        return 1;
    else
    {
        return FibonacciNumberRecursion(n - 1) + FibonacciNumberRecursion(n - 2);
    }
}

這個寫法非常的符合大腦思考,第一項返回0,第二項返回1,後面的返回前兩項之和,簡單測試沒有任何問題。但是,這個演演算法有非常嚴重的效能問題,當n到40的時候,計算速度已經到了肉眼不可接受的地步,再往上就到了分鐘級的了,造成執行緩慢的原因,就是遞迴會不停的壓棧,儲存當前的狀態,這是非常沒有必要的,於是我寫了第二種解法

第二次解

public static ulong FibonacciNumber(int n)
{
    if (n == 1)
        return 0;
    else if (n == 2)
        return 1;
    else
    {
        var x = 3;
        ulong xSub1 = 1;
        ulong xSub2 = 0;
        ulong value = 0;
        while (x <= n)
        {
            value = xSub1 + xSub2;
            xSub2 = xSub1;
            xSub1 = value;
            x += 1;
        }
        return value;
    }
}

這一次使用迴圈代替遞迴,它沒有頻繁的壓棧,效能非常好,計算第200項的值也在納秒級別,於是便有了思考,是否所有的遞迴都是這麼不堪?經過查閱資料,發現第一次的遞迴有很多是無效遞迴,於是進行了改寫

第三次解

public static ulong FibonacciNumberRecursion2(int n, ulong a = 0, ulong b = 1)
{
    // 斐波那契數列是第N項等於前兩項的和
    if (n == 1)
    {
        return a;
    }
    else
    {
        return FibonacciNumberRecursion2(n - 1, b, a + b);
    }
}

這一次的遞迴使用了a和b兩個變數去快取前兩項的值,這裡看起來和實際情況是有差異的,它的計算過程更接近迴圈,因為a,b是從0,1開始往上加出來的,雖然遞迴是n-1。這裡的n-1更像是為了達到終止遞迴的條件

經過修改的遞迴方法,效能和迴圈已經很接近了,只差一點點,那這個是不是遞迴已經非常強了?也不是,經過查閱資料,發現是編譯器針對尾遞迴進行了優化,會用類似迴圈的機制來執行尾遞迴

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

第四次解

經過上面的解法,經過編譯器優化的尾遞迴已經很好了,但是還想看看如果沒有優化的效能是什麼樣子呢?因為第一次解的速度慢不只是遞迴的原因,還有很多無意義計算,那麼拋開無意義的計算,遞迴和迴圈有多少差距呢?

public static ulong FibonacciNumberRecursion3(int n, ulong a = 0, ulong b = 1)
{
    // 斐波那契數列是第N項等於前兩項的和
    if (n == 1)
    {
        return a;
    }
    else
    {
        var r = FibonacciNumberRecursion3(n - 1, b, a + b);
        var z = r + 1;
        
        return z-1;
    }
}

在這裡使用了+1和-1,主要是為了破壞尾遞迴,那麼最後的效能是怎樣的呢

BenchmarkDotNet v0.13.10, Windows 10 (10.0.19045.3570/22H2/2022Update)
AMD Ryzen 7 4800HS with Radeon Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.100
  [Host]     : .NET 6.0.25 (6.0.2523.51912), X64 RyuJIT AVX2
  DefaultJob : .NET 6.0.25 (6.0.2523.51912), X64 RyuJIT AVX2
Method Mean Error StdDev
Loop 53.02 ns 0.111 ns 0.098 ns
Recursion2 52.98 ns 0.261 ns 0.232 ns
Recursion3 348.34 ns 4.367 ns 4.084 ns

求第200項的值,Loop使用迴圈,Recursion2是尾遞迴,Recursion3是破環了尾遞迴的情況,從這上面來看,衛隊貴對效能的影響還是很大的

結論

上面4中求斐波那契數列的第N項值的寫法,有不同的效能表現,使用迴圈和尾遞迴相差無幾,如果是線性遞迴,那麼效能就會差很多,因此

為了效能,優先使用迴圈解決問題,經過編譯器優化的尾遞迴效能也不差,儘量避免使用普通的遞迴