为了性能,慎用递归
慎用遞歸
起因:
在學習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項值的寫法,有不同的性能表現,使用循環和尾遞歸相差無幾,如果是線性遞歸,那么性能就會差很多,因此
為了性能,優先使用循環解決問題,經過編譯器優化的尾遞歸性能也不差,盡量避免使用普通的遞歸
總結
- 上一篇: Linux下redis的安装下载以及连接
- 下一篇: 搭建Samba服务器笔记全套