为了性能,慎用递归
慎用遞歸
起因:
在學(xué)習(xí)Rust的時(shí)候,有一道語(yǔ)法練習(xí)題是計(jì)算斐波那契數(shù)列的第N項(xiàng)的值,這是一道非常簡(jiǎn)單的題,但是引發(fā)了一個(gè)使用遞歸性能問(wèn)題,考慮到用Rust的人不多,后面的代碼都是C#的,因?yàn)镃#的語(yǔ)法更大眾一些,更好看懂
第一次解
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);
}
}
這個(gè)寫法非常的符合大腦思考,第一項(xiàng)返回0,第二項(xiàng)返回1,后面的返回前兩項(xiàng)之和,簡(jiǎn)單測(cè)試沒(méi)有任何問(wèn)題。但是,這個(gè)算法有非常嚴(yán)重的性能問(wèn)題,當(dāng)n到40的時(shí)候,計(jì)算速度已經(jīng)到了肉眼不可接受的地步,再往上就到了分鐘級(jí)的了,造成運(yùn)行緩慢的原因,就是遞歸會(huì)不停的壓棧,存儲(chǔ)當(dāng)前的狀態(tài),這是非常沒(méi)有必要的,于是我寫了第二種解法
第二次解
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;
}
}
這一次使用循環(huán)代替遞歸,它沒(méi)有頻繁的壓棧,性能非常好,計(jì)算第200項(xiàng)的值也在納秒級(jí)別,于是便有了思考,是否所有的遞歸都是這么不堪?經(jīng)過(guò)查閱資料,發(fā)現(xiàn)第一次的遞歸有很多是無(wú)效遞歸,于是進(jìn)行了改寫
第三次解
public static ulong FibonacciNumberRecursion2(int n, ulong a = 0, ulong b = 1)
{
// 斐波那契數(shù)列是第N項(xiàng)等于前兩項(xiàng)的和
if (n == 1)
{
return a;
}
else
{
return FibonacciNumberRecursion2(n - 1, b, a + b);
}
}
這一次的遞歸使用了a和b兩個(gè)變量去緩存前兩項(xiàng)的值,這里看起來(lái)和實(shí)際情況是有差異的,它的計(jì)算過(guò)程更接近循環(huán),因?yàn)閍,b是從0,1開(kāi)始往上加出來(lái)的,雖然遞歸是n-1。這里的n-1更像是為了達(dá)到終止遞歸的條件
經(jīng)過(guò)修改的遞歸方法,性能和循環(huán)已經(jīng)很接近了,只差一點(diǎn)點(diǎn),那這個(gè)是不是遞歸已經(jīng)非常強(qiáng)了?也不是,經(jīng)過(guò)查閱資料,發(fā)現(xiàn)是編譯器針對(duì)尾遞歸進(jìn)行了優(yōu)化,會(huì)用類似循環(huán)的機(jī)制來(lái)運(yùn)行尾遞歸
尾遞歸:如果一個(gè)函數(shù)中所有遞歸形式的調(diào)用都出現(xiàn)在函數(shù)的末尾,我們稱這個(gè)遞歸函數(shù)是尾遞歸的。當(dāng)遞歸調(diào)用是整個(gè)函數(shù)體中最后執(zhí)行的語(yǔ)句且它的返回值不屬于表達(dá)式的一部分時(shí),這個(gè)遞歸調(diào)用就是尾遞歸。尾遞歸函數(shù)的特點(diǎn)是在回歸過(guò)程中不用做任何操作,這個(gè)特性很重要,因?yàn)榇蠖鄶?shù)現(xiàn)代的編譯器會(huì)利用這種特點(diǎn)自動(dòng)生成優(yōu)化的代碼。
第四次解
經(jīng)過(guò)上面的解法,經(jīng)過(guò)編譯器優(yōu)化的尾遞歸已經(jīng)很好了,但是還想看看如果沒(méi)有優(yōu)化的性能是什么樣子呢?因?yàn)榈谝淮谓獾乃俣嚷恢皇沁f歸的原因,還有很多無(wú)意義計(jì)算,那么拋開(kāi)無(wú)意義的計(jì)算,遞歸和循環(huán)有多少差距呢?
public static ulong FibonacciNumberRecursion3(int n, ulong a = 0, ulong b = 1)
{
// 斐波那契數(shù)列是第N項(xiàng)等于前兩項(xiàng)的和
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項(xiàng)的值,Loop使用循環(huán),Recursion2是尾遞歸,Recursion3是破環(huán)了尾遞歸的情況,從這上面來(lái)看,衛(wèi)隊(duì)貴對(duì)性能的影響還是很大的
結(jié)論
上面4中求斐波那契數(shù)列的第N項(xiàng)值的寫法,有不同的性能表現(xiàn),使用循環(huán)和尾遞歸相差無(wú)幾,如果是線性遞歸,那么性能就會(huì)差很多,因此
為了性能,優(yōu)先使用循環(huán)解決問(wèn)題,經(jīng)過(guò)編譯器優(yōu)化的尾遞歸性能也不差,盡量避免使用普通的遞歸
總結(jié)
- 上一篇: Linux下redis的安装下载以及连接
- 下一篇: 搭建Samba服务器笔记全套