如何提升JavaScript的運行速度(3遞歸篇)

影響JavaScript性能的另外一個殺手就是遞歸,在上一節中提到採用memoization技術可以優化計算數值 的遞歸函數,但memoization不是萬能的,不是所有的遞歸函數都可以用memoization技術優化,本文介紹了這些情況,並介紹瞭解決辦法, 就是將遞歸轉換爲迭代,同時需要注意,本文末尾介紹的方案不是最終的方案,還需要和上一節優化循環的方案綜合起來才能達到最佳效果。

【原文】Speed up your JavaScript, Part 3
【作者】Nicholas C. Zakas
【譯文】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和迭代是代替遞歸的兩種解決方案,最直接的結果當然就是避免那個提示腳本失控的對話框


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章