函式進階內容 遞迴和堆疊
遞迴和堆疊
讓我們回到函式,進行更深入的研究。
我們的第一個主題是遞迴(recursion)。
如果你不是剛接觸程式設計,那麼你可能已經很熟悉它了,那麼你可以跳過這一章。
遞迴是一種程式設計模式,在一個任務可以自然地拆分成多個相同型別但更簡單的任務的情況下非常有用。或者,在一個任務可以簡化為一個簡單的行為加上該任務的一個更簡單的變體的時候可以使用。或者,就像我們很快會看到的那樣,處理某些資料結構。
當一個函式解決一個任務時,在解決的過程中它可以呼叫很多其它函式。在部分情況下,函式會呼叫自身。這就是所謂的遞迴。
兩種思考方式
簡單起見,讓我們寫一個函式pow(x, n)
,它可以計算x
的n
次方。換句話說就是,x
n
次。
pow(2, 2) = 4
pow(2, 3) = 8
pow(2, 4) = 16
有兩種實現方式。
-
迭代思路:使用
for
迴圈:function pow(x, n) { let result = 1; // 再迴圈中,用 x 乘以 result n 次 for (let i = 0; i < n; i++) { result *= x; } return result; } alert( pow(2, 3) ); // 8
-
遞迴思路:簡化任務,呼叫自身:
function pow(x, n) { if (n == 1) { return x; } else { return x * pow(x, n - 1); } } alert( pow(2, 3) ); // 8
請注意,遞迴變體在本質上是不同的。
當pow(x, n)
被呼叫時,執行分為兩個分支:
if n==1 = x
/
pow(x, n) =
\
else = x * pow(x, n - 1)
- 如果
n == 1
,所有事情都會很簡單,這叫做基礎的遞迴,因為它會立即產生明顯的結果:pow(x, 1)
等於x
。 - 否則,我們可以用
x * pow(x, n - 1)
表示pow(x, n)
。在數學裡,可能會寫為xn= x * xn-1
。這叫做一個遞迴步驟:我們將任務轉化為更簡單的行為(x
n
的pow
運算)。接下來的步驟將其進一步簡化,直到n
達到1
。
我們也可以說pow
遞迴地呼叫自身直到n == 1
。
比如,為了計算pow(2, 4)
,遞迴變體經過了下面幾個步驟:
pow(2, 4) = 2 * pow(2, 3)
pow(2, 3) = 2 * pow(2, 2)
pow(2, 2) = 2 * pow(2, 1)
pow(2, 1) = 2
因此,遞迴將函式呼叫簡化為一個更簡單的函式呼叫,然後再將其簡化為一個更簡單的函式,以此類推,直到結果變得顯而易見。
遞迴通常更短遞迴解通常比迭代解更短。
在這兒,我們可以使用條件運算子?
而不是if
語句,從而使pow(x, n)
更簡潔並且可讀性依然很高:
function pow(x, n) {
return (n == 1) ? x : (x * pow(x, n - 1));
}
最大的巢狀呼叫次數(包括首次)被稱為遞迴深度。在我們的例子中,它正好等於n
。
最大遞迴深度受限於 JavaScript 引擎。對我們來說,引擎在最大迭代深度為 10000 及以下時是可靠的,有些引擎可能允許更大的最大深度,但是對於大多數引擎來說,100000 可能就超出限制了。有一些自動優化能夠幫助減輕這種情況(尾部呼叫優化),但目前它們還沒有被完全支援,只能用於簡單場景。
這就限制了遞迴的應用,但是遞迴仍然被廣泛使用。有很多工中,遞迴思維方式會使程式碼更簡單,更容易維護。
執行上下文和堆疊
現在我們來研究一下遞迴呼叫是如何工作的。為此,我們會先看看函式底層的工作原理。
有關正在執行的函式的執行過程的相關資訊被儲存在其執行上下文中。
執行上下文是一個內部資料結構,它包含有關函式執行時的詳細細節:當前控制流所在的位置,當前的變數,this
的值(此處我們不使用它),以及其它的一些內部細節。
一個函式呼叫僅具有一個與其相關聯的執行上下文。
當一個函式進行巢狀呼叫時,將發生以下的事兒:
- 當前函式被暫停;
- 與它關聯的執行上下文被一個叫做執行上下文堆疊的特殊資料結構儲存;
- 執行巢狀呼叫;
- 巢狀呼叫結束後,從堆疊中恢復之前的執行上下文,並從停止的位置恢復外部函式。
讓我們看看pow(2, 3)
呼叫期間都發生了什麼。
pow(2, 3)
在呼叫pow(2, 3)
的開始,執行上下文(context)會儲存變數:x = 2, n = 3
,執行流程在函式的第1
行。
我們將其描繪如下:
- Context: { x: 2, n: 3, at line 1 }pow(2, 3)
這是函式開始執行的時候。條件n == 1
結果為假,所以執行流程進入if
的第二分支。
function pow(x, n) {
if (n == 1) {
return x;
} else {
return x * pow(x, n - 1);
}
}
alert( pow(2, 3) );
變數相同,但是行改變了,因此現在的上下文是:
- Context: { x: 2, n: 3, at line 5 }pow(2, 3)
為了計算x * pow(x, n - 1)
,我們需要使用帶有新引數的新的pow
子呼叫pow(2, 2)
。
pow(2, 2)
為了執行巢狀呼叫,JavaScript 會在執行上下文堆疊中記住當前的執行上下文。
這裡我們呼叫相同的函式pow
,但這絕對沒問題。所有函式的處理都是一樣的:
- 當前上下文被“記錄”在堆疊的頂部。
- 為子呼叫建立新的上下文。
- 當子呼叫結束後 —— 前一個上下文被從堆疊中彈出,並繼續執行。
下面是進入子呼叫pow(2, 2)
時的上下文堆疊:
- Context: { x: 2, n: 2, at line 1 }pow(2, 2)
- Context: { x: 2, n: 3, at line 5 }pow(2, 3)
新的當前執行上下文位於頂部(粗體顯示),之前記住的上下文位於下方。
當我們完成子呼叫後 —— 很容易恢復上一個上下文,因為它既保留了變數,也保留了當時所在程式碼的確切位置。
請注意:在上面的圖中,我們使用“行(line)”一詞,因為在我們的示例中,每一行只有一個子呼叫,但通常一行程式碼可能會包含多個子呼叫,例如pow(…) + pow(…) + somethingElse(…)
。
因此,更準確地說,執行是“在子呼叫之後立即恢復”的。
pow(2, 1)
重複該過程:在第5
行生成新的子呼叫,現在的引數是x=2
,n=1
。
新的執行上下文被建立,前一個被壓入堆疊頂部:
- Context: { x: 2, n: 1, at line 1 }pow(2, 1)
- Context: { x: 2, n: 2, at line 5 }pow(2, 2)
- Context: { x: 2, n: 3, at line 5 }pow(2, 3)
此時,有 2 箇舊的上下文和 1 個當前正在執行的pow(2, 1)
的上下文。
出口
在執行pow(2, 1)
時,與之前的不同,條件n == 1
為真,因此if
的第一個分支生效:
function pow(x, n) {
if (n == 1) {
return x;
} else {
return x * pow(x, n - 1);
}
}
此時不再有更多的巢狀呼叫,所以函式結束,返回2
。
函式完成後,就不再需要其執行上下文了,因此它被從記憶體中移除。前一個上下文恢復到堆疊的頂部:
- Context: { x: 2, n: 2, at line 5 }pow(2, 2)
- Context: { x: 2, n: 3, at line 5 }pow(2, 3)
恢復執行pow(2, 2)
。它擁有子呼叫pow(2, 1)
的結果,因此也可以完成x * pow(x, n - 1)
的執行,並返回4
。
然後,前一個上下文被恢復:
- Context: { x: 2, n: 3, at line 5 }pow(2, 3)
當它結束後,我們得到了結果pow(2, 3) = 8
。
本示例中的遞迴深度為:3。
從上面的插圖我們可以看出,遞迴深度等於堆疊中上下文的最大數量。
請注意記憶體要求。上下文佔用記憶體,在我們的示例中,求n
次方需要儲存n
個上下文,以供更小的n
值進行計算使用。
而迴圈演算法更節省記憶體:
function pow(x, n) {
let result = 1;
for (let i = 0; i < n; i++) {
result *= x;
}
return result;
}
迭代pow
的過程中僅使用了一個上下文用於修改i
和result
。它的記憶體要求小,並且是固定了,不依賴於n
。
任何遞迴都可以用迴圈來重寫。通常迴圈變體更有效。
……但有時重寫很難,尤其是函式根據條件使用不同的子呼叫,然後合併它們的結果,或者分支比較複雜時。而且有些優化可能沒有必要,完全不值得。
遞迴可以使程式碼更短,更易於理解和維護。並不是每個地方都需要優化,大多數時候我們需要一個好程式碼,這就是為什麼要使用它。
遞迴遍歷
遞迴的另一個重要應用就是遞迴遍歷。
假設我們有一家公司。人員結構可以表示為一個物件:
let company = {
sales: [{
name: 'John',
salary: 1000
}, {
name: 'Alice',
salary: 1600
}],
development: {
sites: [{
name: 'Peter',
salary: 2000
}, {
name: 'Alex',
salary: 1800
}],
internals: [{
name: 'Jack',
salary: 1300
}]
}
};
換句話說,一家公司有很多部門。
-
一個部門可能有一陣列的員工,比如,
sales
部門有 2 名員工:John 和 Alice。 -
或者,一個部門可能會劃分為幾個子部門,比如
development
有兩個分支:sites
和internals
,它們都有自己的員工。 -
當一個子部門增長時,它也有可能被拆分成幾個子部門(或團隊)。
例如,
sites
部門在未來可能會分為siteA
和siteB
。並且,它們可能會被再繼續拆分。沒有圖示,腦補一下吧。
現在,如果我們需要一個函式來獲取所有薪資的總數。我們該怎麼做?
迭代方式並不容易,因為結構比較複雜。首先想到的可能是在company
上使用for
迴圈,並在第一層部分上巢狀子迴圈。但是,之後我們需要更多的子迴圈來遍歷像sites
這樣的二級部門的員工…… 然後,將來可能會出現在三級部門上的另一個子迴圈?如果我們在程式碼中寫 3-4 級巢狀的子迴圈來遍歷單個物件, 那程式碼得多醜啊。
我們試試遞迴吧。
我們可以看到,當我們的函式對一個部門求和時,有兩種可能的情況:
- 要麼是由一陣列的人組成的“簡單”的部門 —— 這樣我們就可以通過一個簡單的迴圈來計算薪資的總和。
- 或者它是一個有
N
個子部門的物件—— 那麼我們可以通過N
層遞迴呼叫來求每一個子部門的薪資,然後將它們合併起來。
第一種情況是由一陣列的人組成的部門,這種情況很簡單,是最基礎的遞迴。
第二種情況是我們得到的是物件。那麼可將這個複雜的任務拆分成適用於更小部門的子任務。它們可能會被繼續拆分,但很快或者不久就會拆分到第一種情況那樣。
這個演算法從程式碼來看可能會更簡單:
let company = { // 是同一個物件,簡潔起見被壓縮了
sales: [{name: 'John', salary: 1000}, {name: 'Alice', salary: 1600 }],
development: {
sites: [{name: 'Peter', salary: 2000}, {name: 'Alex', salary: 1800 }],
internals: [{name: 'Jack', salary: 1300}]
}
};
// 用來完成任務的函式
function sumSalaries(department) {
if (Array.isArray(department)) { // 情況(1)
return department.reduce((prev, current) => prev + current.salary, 0); // 求陣列的和
} else { // 情況(2)
let sum = 0;
for (let subdep of Object.values(department)) {
sum += sumSalaries(subdep); // 遞迴呼叫所有子部門,對結果求和
}
return sum;
}
}
alert(sumSalaries(company)); // 7700
程式碼很短也容易理解(希望是這樣?)。這就是遞迴的能力。它適用於任何層次的子部門巢狀。
下面是呼叫圖:
我們可以很容易地看到其原理:對於物件{...}
會生成子呼叫,而陣列[...]
是遞迴樹的“葉子”,它們會立即給出結果。
請注意,該程式碼使用了我們之前講過的智慧特性(smart features):
- 在陣列方法中我們介紹過的陣列求和方法
arr.reduce
。 - 使用迴圈
for(val of Object.values(obj))
遍歷物件的(屬性)值:Object.values
返回它們組成的陣列。
遞迴結構
遞迴(遞迴定義的)資料結構是一種部分複製自身的結構。
我們剛剛在上面的公司結構的示例中看過了它。
一個公司的部門是:
- 一陣列的人。
- 或一個部門物件。
對於 Web 開發者而言,有更熟知的例子:HTML 和 XML 文件。
在 HTML 文件中,一個HTML 標籤可能包括以下內容:
- 文字片段。
- HTML 註釋。
- 其它HTML 標籤(它有可能又包括文字片段、註釋或其它標籤等)。
這又是一個遞迴定義。
為了更好地理解遞迴,我們再講一個遞迴結構的例子“連結串列”,在某些情況下,它可能是優於陣列的選擇。
連結串列
想象一下,我們要儲存一個有序的物件列表。
正常的選擇會是一個數組:
let arr = [obj1, obj2, obj3];
……但是用陣列有個問題。“刪除元素”和“插入元素”的操作代價非常大。例如,arr.unshift(obj)
操作必須對所有元素重新編號以便為新的元素obj
騰出空間,而且如果陣列很大,會很耗時。arr.shift()
同理。
唯一對陣列結構做修改而不需要大量重排的操作就是對陣列末端的操作:arr.push/pop
。因此,對於大佇列來說,當我們必須對陣列首端的元素進行操作時,陣列會很慢。(譯註:此處的首端操作其實指的是在尾端以外的陣列內的元素進行插入/刪除操作。)
如果我們確實需要快速插入/刪除,則可以選擇另一種叫做連結串列的資料結構。
連結串列元素是一個使用以下元素通過遞迴定義的物件:
value
。next
屬性引用下一個連結串列元素或者代表末尾的null
。
例如:
let list = {
value: 1,
next: {
value: 2,
next: {
value: 3,
next: {
value: 4,
next: null
}
}
}
};
連結串列的圖形表示:
一段用來建立連結串列的程式碼:
let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
list.next.next.next.next = null;
在這兒我們可以清楚地看到,這裡有很多個物件,每一個都有value
和指向鄰居的next
。變數list
是鏈條中的第一個物件,因此順著next
指標,我們可以抵達任何元素。
該連結串列可以很容易被拆分為多個部分,然後再重新組裝回去:
let secondList = list.next.next;
list.next.next = null;
合併:
list.next.next = secondList;
當然,我們可以在任何位置插入或移除元素。
比如,要新增一個新值,我們需要更新連結串列的頭:
let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
// 將新值新增到連結串列頭部
list = { value: "new item", next: list };
要從中間刪除一個值,可以修改前一個元素的next
:
list.next = list.next.next;
我們讓list.next
從1
跳到值2
。現在值1
就被從連結串列中移除了。如果它沒有被儲存在其它任何地方,那麼它會被自動從記憶體中刪除。
與陣列不同,連結串列沒有大規模重排,我們可以很容易地重新排列元素。
當然,連結串列也不總是優於陣列的。不然大家就都去使用連結串列了。
連結串列主要的缺點就是我們無法很容易地通過元素的編號獲取元素。但在陣列中卻很容易:arr[n]
是一個直接引用。而在連結串列中,我們需要從起點元素開始,順著next
找N
次才能獲取到第 N 個元素。
……但是我們也並不是總需要這樣的操作。比如,當我們需要一個佇列甚至一個雙向佇列—— 有序結構必須可以快速地從兩端新增/移除元素,但是不需要訪問的元素。
連結串列可以得到增強:
- 我們可以在
next
之外,再新增prev
屬性來引用前一個元素,以便輕鬆地往回移動。 - 我們還可以新增一個名為
tail
的變數,該變數引用連結串列的最後一個元素(並在從末尾新增/刪除元素時對該引用進行更新)。 - ……資料結構可能會根據我們的需求而變化。
總結
術語:
-
遞迴是程式設計的一個術語,表示從自身呼叫函式(譯註:也就是自呼叫)。遞迴函式可用於以更優雅的方式解決問題。
當一個函式呼叫自身時,我們稱其為遞迴步驟。遞迴的基礎是函式引數使任務簡單到該函式不再需要進行進一步呼叫。
-
遞迴定義的資料結構是指可以使用自身來定義的資料結構。
例如,連結串列可以被定義為由物件引用一個列表(或
null
)而組成的資料結構。list = { value, next -> list }
像 HTML 元素樹或者本章中的
department
樹等,本質上也是遞迴:它們有分支,而且分支又可以有其他分支。就像我們在示例
sumSalary
中看到的那樣,可以使用遞迴函式來遍歷它們。
任何遞迴函式都可以被重寫為迭代(譯註:也就是迴圈)形式。有時這是在優化程式碼時需要做的。但對於大多數任務來說,遞迴方法足夠快,並且容易編寫和維護。