【本文網址】 http://cuimingda.com/2009/02/speed-up-your-javascript-part-3.html
遞迴是拖慢腳本運行速度的大敵之一。太多的遞歸會讓瀏覽器變得越來越慢直到死掉或莫名其妙的突然自動退出,所以我們一定要解決在JavaScript中出現的這一系列效能問題。在這個系列文章的第二篇中,我曾經簡短的介紹瞭如何透過memoization技術來取代函數中太多的遞歸呼叫。 memoization是一種可以快取之前運算結果的技術,這樣我們就不需要重新計算那些已經計算過的結果。對於透過遞歸來進行計算的函數,memoization簡直是太有用了。我現在使用的memoizer是由Crockford寫的,主要應用在那些傳回整數的遞歸運算中。當然並不是所有的遞歸函數都會傳回整數,所以我們需要一個更通用的memoizer()函數來處理更多類型的遞歸函數。
function memoizer(fundamental, cache){ cache = cache || {} var shell = function(arg){ if (!(arg in cache)){ cache[arg] = fundamental(shell, arg) } return cache[arg] ; }; return shell;}這個版本的函數和Crockford寫的版本有點不同。首先,參數的順序被顛倒了,原有函數被設定為第一個參數,第二個參數是快取對象,為可選參數,因為並不是所有的遞歸函數都包含初始資訊。在函數內部,我將快取物件的類型從陣列轉換為對象,這樣這個版本就可以適應那些不是傳回整數的遞歸函數。在shell函數裡,我使用了in操作符來判斷參數是否已經包含在快取裡。這種寫法比測試類型不是undefined更安全,因為undefined是一個有效的回傳值。我們還是用之前提到的斐波納契數列來做說明:
var fibonacci = memoizer(function (recur, n) { return recur(n - 1) + recur(n - 2); }, {"0": 0, "1":1});同樣的,執行fibonacci(40)這個函數,只會對原有的函數呼叫40次,而不是誇張的331,160,280次。 memoization對於那些有著嚴格定義的結果集的遞歸演算法來說,簡直是棒極了。然而,確實還有很多遞歸演算法不適合使用memoization方法來進行最佳化。
我在學校時的一位教授一直堅持認為,任何使用遞歸的情況,如果有需要,都可以使用迭代來代替。實際上,遞歸和迭代經常被當作互相彌補的方法,尤其是在另一個出問題的情況下。將遞歸演算法轉換為迭代演算法的技術,也是和開發語言無關的。這對JavaScript來說是很重要的,因為很多東西在執行環境中是受限的(the importance in JavaScript is greater, though, because the resources of the execution environment are so restrictive.)。讓我們回顧一個典型的遞歸演算法,比如說歸併排序,在JavaScript中實作這個演算法需要下面的程式碼:
function merge(left, right){ var result = []; while (left.length > 0 && right.length > 0){ if (left[0] < right[0]){ result.push(left.shift()); } else { result.push(right.shift()); } } return result.concat(left ).concat(right);}//採用遞歸實現的歸併排序演算法function mergeSort(items){ if (items.length == 1) { return items; } var middle = Math.floor(items.length / 2) , left = items.slice(0, middle), right = items.slice(middle); return merge(mergeSort(left), mergeSort(right));}呼叫mergeSort()函數處理一個數組,就可以返回經過排序的數組。注意每次呼叫mergeSort()函數,都會有兩次遞歸呼叫。這個演算法不可以使用memoization來進行最佳化,因為每個結果都只計算並使用一次,就算緩衝了結果也沒有什麼用。如果你使用mergeSort()函數來處理一個包含100個元素的陣列,總共會有199次呼叫。 1000個元素的陣列將會執行1999次呼叫。在這種情況下,我們的解決方案是將遞歸演算法轉換為迭代演算法,也就是說要引入一些循環(關於演算法,可以參考這篇《List Processing: Sort Again, Naturally》):
// 採用迭代實現的歸併排序演算法function mergeSort(items){ if (items.length == 1) { return items; } var work = []; for (var i=0, len=items.length; i < len; i++){ work.push([items[i]]); } work.push([]); //in case of odd number of items for (var lim=len; lim > 1; lim = (lim+1)/2 ){ for (var j=0,k=0; k < lim; j++, k+=2){ work[j] = merge(work[k], work[k+1]); } work[j] = []; //in case of odd number of items } return work[0];}這個歸併排序演算法實作使用了一系列循環來代替遞歸進行排序。由於歸併排序首先要將數組拆分成若干只有一個元素的數組,這個方法更加明確的執行了這個操作,而不是通過遞歸函數隱晦的完成。 work陣列被初始化為包含一堆只有一個元素陣列的陣列。在循環中每次會合併兩個數組,並將合併後的結果放回work數組中。當函數執行完成後,排序的結果會透過work數組中的第一個元素傳回。在這個歸併排序的實作中,沒有使用任何遞歸,同樣也實作了這個演算法。然而,這樣做卻引入了大量的循環,循環的次數是基於要排序的數組中元素的個數,所以我們可能需要使用在上篇討論過的技術來進行修訂,處理這些額外開銷。
總結一下基本原則,不管是什麼時候使用遞歸的時候都應該小心謹慎。 memoization和迭代是代替遞歸的兩個解決方案,最直接的結果當然就是避免那個提示腳本失控的對話框。