在數(shù)據(jù)結(jié)構(gòu)與算法中,重復(fù)執(zhí)行某個(gè)任務(wù)是很常見(jiàn)的,其與算法的復(fù)雜度密切相關(guān)。而要重復(fù)執(zhí)行某個(gè)任務(wù),我們通常會(huì)選用兩種基本的程序結(jié)構(gòu):迭代和遞歸。
「迭代 iteration」是一種重復(fù)執(zhí)行某個(gè)任務(wù)的控制結(jié)構(gòu)。在迭代中,程序會(huì)在滿足一定的條件下重復(fù)執(zhí)行某段代碼,直到這個(gè)條件不再滿足。
for
循環(huán)是最常見(jiàn)的迭代形式之一,適合預(yù)先知道迭代次數(shù)時(shí)使用。
以下函數(shù)基于 for
循環(huán)實(shí)現(xiàn)了求和
res
記錄。需要注意的是,Python 中 range(a, b)
對(duì)應(yīng)的區(qū)間是“左閉右開(kāi)”的,對(duì)應(yīng)的遍歷范圍為
iteration.cpp
/* for 循環(huán) */
int forLoop(int n) {
int res = 0;
// 循環(huán)求和 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
res += i;
}
return res;
}
圖 2-1 展示了該求和函數(shù)的流程框圖。
圖 2-1 求和函數(shù)的流程框圖
此求和函數(shù)的操作數(shù)量與輸入數(shù)據(jù)大小
與 for 循環(huán)類似,while 循環(huán)也是一種實(shí)現(xiàn)迭代的方法。在 while 循環(huán)中,程序每輪都會(huì)先檢查條件,如果條件為真則繼續(xù)執(zhí)行,否則就結(jié)束循環(huán)。
下面,我們用 while 循環(huán)來(lái)實(shí)現(xiàn)求和 1+2+?+n 。
iteration.cpp
/* while 循環(huán) */
int whileLoop(int n) {
int res = 0;
int i = 1; // 初始化條件變量
// 循環(huán)求和 1, 2, ..., n-1, n
while (i <= n) {
res += i;
i++; // 更新條件變量
}
return res;
}
在 while
循環(huán)中,由于初始化和更新條件變量的步驟是獨(dú)立在循環(huán)結(jié)構(gòu)之外的,因此它比 for
循環(huán)的自由度更高。
例如在以下代碼中,條件變量 i 每輪進(jìn)行了兩次更新,這種情況就不太方便用 for 循環(huán)實(shí)現(xiàn)。
iteration.cpp
/* while 循環(huán)(兩次更新) */
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化條件變量
// 循環(huán)求和 1, 4, ...
while (i <= n) {
res += i;
// 更新條件變量
i++;
i *= 2;
}
return res;
}
總的來(lái)說(shuō),for
循環(huán)的代碼更加緊湊,while
循環(huán)更加靈活,兩者都可以實(shí)現(xiàn)迭代結(jié)構(gòu)。選擇使用哪一個(gè)應(yīng)該根據(jù)特定問(wèn)題的需求來(lái)決定。
我們可以在一個(gè)循環(huán)結(jié)構(gòu)內(nèi)嵌套另一個(gè)循環(huán)結(jié)構(gòu),以 for
循環(huán)為例:
iteration.cpp
/* 雙層 for 循環(huán) */
string nestedForLoop(int n) {
ostringstream res;
// 循環(huán) i = 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
// 循環(huán) j = 1, 2, ..., n-1, n
for (int j = 1; j <= n; ++j) {
res << "(" << i << ", " << j << "), ";
}
}
return res.str();
}
圖 2-2 給出了該嵌套循環(huán)的流程框圖。
圖 2-2 嵌套循環(huán)的流程框圖
在這種情況下,函數(shù)的操作數(shù)量與 n^2 成正比,或者說(shuō)算法運(yùn)行時(shí)間和輸入數(shù)據(jù)大小 n 成“平方關(guān)系”。
我們可以繼續(xù)添加嵌套循環(huán),每一次嵌套都是一次“升維”,將會(huì)使時(shí)間復(fù)雜度提高至“立方關(guān)系”、“四次方關(guān)系”、以此類推。
「遞歸 recursion」是一種算法策略,通過(guò)函數(shù)調(diào)用自身來(lái)解決問(wèn)題。它主要包含兩個(gè)階段。
而從實(shí)現(xiàn)的角度看,遞歸代碼主要包含三個(gè)要素。
觀察以下代碼,我們只需調(diào)用函數(shù) recur(n)
,就可以完成
recursion.cpp
/* 遞歸 */
int recur(int n) {
// 終止條件
if (n == 1)
return 1;
// 遞:遞歸調(diào)用
int res = recur(n - 1);
// 歸:返回結(jié)果
return n + res;
}
圖 2-3 展示了該函數(shù)的遞歸過(guò)程。
圖 2-3 求和函數(shù)的遞歸過(guò)程
雖然從計(jì)算角度看,迭代與遞歸可以得到相同的結(jié)果,但它們代表了兩種完全不同的思考和解決問(wèn)題的范式。
以上述的求和函數(shù)為例,設(shè)問(wèn)題 f(n)=1+2+?+n 。
遞歸函數(shù)每次調(diào)用自身時(shí),系統(tǒng)都會(huì)為新開(kāi)啟的函數(shù)分配內(nèi)存,以存儲(chǔ)局部變量、調(diào)用地址和其他信息等。這將導(dǎo)致兩方面的結(jié)果。
如圖 2-4 所示,在觸發(fā)終止條件前,同時(shí)存在 n 個(gè)未返回的遞歸函數(shù),遞歸深度為 n 。
圖 2-4 遞歸調(diào)用深度
在實(shí)際中,編程語(yǔ)言允許的遞歸深度通常是有限的,過(guò)深的遞歸可能導(dǎo)致棧溢出報(bào)錯(cuò)。
有趣的是,如果函數(shù)在返回前的最后一步才進(jìn)行遞歸調(diào)用,則該函數(shù)可以被編譯器或解釋器優(yōu)化,使其在空間效率上與迭代相當(dāng)。這種情況被稱為「尾遞歸 tail recursion」。
以計(jì)算 1+2+?+n 為例,我們可以將結(jié)果變量 res 設(shè)為函數(shù)參數(shù),從而實(shí)現(xiàn)尾遞歸。
recursion.cpp
/* 尾遞歸 */
int tailRecur(int n, int res) {
// 終止條件
if (n == 0)
return res;
// 尾遞歸調(diào)用
return tailRecur(n - 1, res + n);
}
尾遞歸的執(zhí)行過(guò)程如圖 2-5 所示。對(duì)比普通遞歸和尾遞歸,求和操作的執(zhí)行點(diǎn)是不同的。
圖 2-5 尾遞歸過(guò)程
Tip
請(qǐng)注意,許多編譯器或解釋器并不支持尾遞歸優(yōu)化。例如,Python 默認(rèn)不支持尾遞歸優(yōu)化,因此即使函數(shù)是尾遞歸形式,但仍然可能會(huì)遇到棧溢出問(wèn)題。
當(dāng)處理與“分治”相關(guān)的算法問(wèn)題時(shí),遞歸往往比迭代的思路更加直觀、代碼更加易讀。以“斐波那契數(shù)列”為例。
Question
給定一個(gè)斐波那契數(shù)列 0,1,1,2,3,5,8,13,… ,求該數(shù)列的第 n 個(gè)數(shù)字。
設(shè)斐波那契數(shù)列的第 n 個(gè)數(shù)字為 f(0) ,易得兩個(gè)結(jié)論。
按照遞推關(guān)系進(jìn)行遞歸調(diào)用,將前兩個(gè)數(shù)字作為終止條件,便可寫(xiě)出遞歸代碼。調(diào)用 fib(n) 即可得到斐波那契數(shù)列的第 n 個(gè)數(shù)字。
recursion.cpp
/* 斐波那契數(shù)列:遞歸 */
int fib(int n) {
// 終止條件 f(1) = 0, f(2) = 1
if (n == 1 || n == 2)
return n - 1;
// 遞歸調(diào)用 f(n) = f(n-1) + f(n-2)
int res = fib(n - 1) + fib(n - 2);
// 返回結(jié)果 f(n)
return res;
}
觀察以上代碼,我們?cè)诤瘮?shù)內(nèi)遞歸調(diào)用了兩個(gè)函數(shù),這意味著從一個(gè)調(diào)用產(chǎn)生了兩個(gè)調(diào)用分支。如圖 2-6 所示,這樣不斷遞歸調(diào)用下去,最終將產(chǎn)生一個(gè)層數(shù)為 n 的「遞歸樹(shù) recursion tree」。
圖 2-6 斐波那契數(shù)列的遞歸樹(shù)
本質(zhì)上看,遞歸體現(xiàn)“將問(wèn)題分解為更小子問(wèn)題”的思維范式,這種分治策略是至關(guān)重要的。
總結(jié)以上內(nèi)容,如表 2-1 所示,迭代和遞歸在實(shí)現(xiàn)、性能和適用性上有所不同。
表 2-1 迭代與遞歸特點(diǎn)對(duì)比
迭代 | 遞歸 | |
---|---|---|
實(shí)現(xiàn)方式 | 循環(huán)結(jié)構(gòu) | 函數(shù)調(diào)用自身 |
時(shí)間效率 | 效率通常較高,無(wú)函數(shù)調(diào)用開(kāi)銷 | 每次函數(shù)調(diào)用都會(huì)產(chǎn)生開(kāi)銷 |
內(nèi)存使用 | 通常使用固定大小的內(nèi)存空間 | 累積函數(shù)調(diào)用可能使用大量的棧幀空間 |
適用問(wèn)題 | 適用于簡(jiǎn)單循環(huán)任務(wù),代碼直觀、可讀性好 | 適用于子問(wèn)題分解,如樹(shù)、圖、分治、回溯等,代碼結(jié)構(gòu)簡(jiǎn)潔、清晰 |
Tip
如果感覺(jué)以下內(nèi)容理解困難,可以在讀完“?!闭鹿?jié)后再來(lái)復(fù)習(xí)。
那么,迭代和遞歸具有什么內(nèi)在聯(lián)系呢?以上述的遞歸函數(shù)為例,求和操作在遞歸的“歸”階段進(jìn)行。這意味著最初被調(diào)用的函數(shù)實(shí)際上是最后完成其求和操作的,這種工作機(jī)制與棧的“先入后出”原則是異曲同工的。
事實(shí)上,“調(diào)用?!焙汀皸臻g”這類遞歸術(shù)語(yǔ)已經(jīng)暗示了遞歸與棧之間的密切關(guān)系。
因此,我們可以使用一個(gè)顯式的棧來(lái)模擬調(diào)用棧的行為,從而將遞歸轉(zhuǎn)化為迭代形式:
recursion.cpp
/* 使用迭代模擬遞歸 */
int forLoopRecur(int n) {
// 使用一個(gè)顯式的棧來(lái)模擬系統(tǒng)調(diào)用棧
stack<int> stack;
int res = 0;
// 遞:遞歸調(diào)用
for (int i = n; i > 0; i--) {
// 通過(guò)“入棧操作”模擬“遞”
stack.push(i);
}
// 歸:返回結(jié)果
while (!stack.empty()) {
// 通過(guò)“出棧操作”模擬“歸”
res += stack.top();
stack.pop();
}
// res = 1+2+3+...+n
return res;
}
觀察以上代碼,當(dāng)遞歸被轉(zhuǎn)換為迭代后,代碼變得更加復(fù)雜了。盡管迭代和遞歸在很多情況下可以互相轉(zhuǎn)換,但也不一定值得這樣做,有以下兩點(diǎn)原因。
總之,選擇迭代還是遞歸取決于特定問(wèn)題的性質(zhì)。在編程實(shí)踐中,權(quán)衡兩者的優(yōu)劣并根據(jù)情境選擇合適的方法是至關(guān)重要的。
更多建議: