提升JavaScript遞迴效率:Memoization技術詳解[轉載]

水之原發表於2013-06-27

遞迴是拖慢指令碼執行速度的大敵之一,太多的遞迴會讓瀏覽器變得越來越慢直到死掉或者莫名其妙的突然自動退出。這裡我們可以通過memoization技術來替代函式中太多的遞迴呼叫,提升JavaScript效率。

遞迴是拖慢指令碼執行速度的大敵之一。太多的遞迴會讓瀏覽器變得越來越慢直到死掉或者莫名其妙的突然自動退出,所以我們一定要解決在JavaScript中出現的這一系列效能問題。

我們可以通過memoization技術來替代函式中太多的遞迴呼叫。memoization是一種可以快取之前運算結果的技術,這樣我們就不需要重新計算那些已經計算過的結果。

對於通過遞迴來進行計算的函式,memoization簡直是太有用了。我現在使用的memoizer是由Crockford寫的,主要應用在那些返回整數的遞迴運算中。當然並不是所有的遞迴函式都返回整數,所以我們需要一個更加通用的memoizer()函式來處理更多型別的遞迴函式。

  1. function memoizer(fundamental, cache) {   
  2.   cachecache = cache || {};   
  3.   var shell = function(arg) {   
  4.       if (! (arg in cache)) {   
  5.           cache[arg] = fundamental(shell, arg);   
  6.       }   
  7.       return cache[arg];   
  8.   };   
  9.   return shell;   

這個版本的函式和Crockford寫的版本有一點點不同。首先,引數的順序被顛倒了,原有函式被設定為第一個引數,第二個引數是快取物件,為可選引數,因為並不是所有的遞迴函式都包含初始資訊。在函式內部,我將快取物件的型別從陣列轉換為物件,這樣這個版本就可以適應那些不是返回整數的遞迴函式。在shell函式裡,我使用了in操作符來判斷引數是否已經包含在快取裡。這種寫法比測試型別不是undefined更加安全,因為undefined是一個有效的返回值。我們還是用之前提到的斐波納契數列來做說明:

  1. var fibonacci = memoizer(function(recur, n) {   
  2.   return recur(n - 1) + recur(n - 2);   
  3. }, { "0": 0, "1": 1} ); 

同樣的,執行fibonacci(40)這個函式,只會對原有的函式呼叫40次,而不是誇張的331,160,280次。memoization對於那些有著嚴格定義的結果集的遞迴演算法來說,簡直是棒極了。然而,確實還有很多遞迴演算法不適合使用memoization方法來進行優化。

有的觀點認為,任何使用遞迴的情況,如果有需要,都可以使用迭代來代替。實際上,遞迴和迭代經常會被作為互相彌補的方法,尤其是在另外一種 出問題的情況下。將遞迴演算法轉換為迭代演算法的技術,也是和開發語言無關的。這對JavaScript來說是很重要的,因為很多東西在執行環境中是受到限制的。讓我們回顧一個典型的遞迴演算法,比如說歸併排序,在JavaScript中實現這個演算法需要下面的程式碼:

  1. function merge(left, right) {   
  2.   var result = [];   
  3.   while (left.length > 0 && right.length > 0) {   
  4.       if (left[0] < right[0]) {   
  5.           result.push(left.shift());   
  6.       } else {   
  7.           result.push(right.shift());   
  8.       }   
  9.   }   
  10.   return result.concat(left).concat(right);   
  11. }  
  12.  
  13. //採用遞迴實現的歸併排序演算法   
  14. function mergeSort(items) {   
  15.   if (items.length == 1) {   
  16.       return items;   
  17.   }   
  18.   var middle = Math.floor(items.length / 2),   
  19.   left = items.slice(0, middle),   
  20.   right = items.slice(middle);   
  21.   return merge(mergeSort(left), mergeSort(right));   

呼叫mergeSort()函式處理一個陣列,就可以返回經過排序的陣列。注意每次呼叫mergeSort()函式,都會有兩次遞迴呼叫。這個演算法不可以使用memoization來進行優化,因為每個結果都只計算並使用一次,就算緩衝了結果也沒有什麼用。如果你使用mergeSort()函式來處理一個包含100個元素的陣列,總共會有199次呼叫。1000個元素的陣列將會執行1999次呼叫。在這種情況下,我們的解決方案是將遞迴演算法轉換為迭代演算法,也就是說要引入一些迴圈:

  1. // 採用迭代實現的歸併排序演算法   
  2. function mergeSort(items) {   
  3.   if (items.length == 1) {   
  4.       return items;   
  5.   }   
  6.   var work = [];   
  7.   for (var i = 0,   
  8.   len = items.length; i < len; i++) {   
  9.       work.push([items[i]]);   
  10.   }   
  11.   work.push([]); //in case of odd number of items   
  12.   for (var lim = len; lim > 1; lim = (lim + 1) / 2) {   
  13.       for (var j = 0,   
  14.       k = 0; k < lim; j++, k += 2) {   
  15.           work[j] = merge(work[k], work[k + 1]);   
  16.       }   
  17.       work[j] = []; //in case of odd number of items   
  18.   }   
  19.   return work[0];   

這個歸併排序演算法實現使用了一系列迴圈來代替遞迴進行排序。由於歸併排序首先要將陣列拆分成若干只有一個元素的陣列,這個方法更加明確的執行了這個操作,而不是通過遞迴函式隱晦的完成。work陣列被初始化為包含一堆只有一個元素陣列的陣列。

在迴圈中每次會合並兩個陣列,並將合併後的結果放回work陣列中。當函式執行完成後,排序的結果會通過work陣列中的第一個元素返回。在這個歸併排序的實現中,沒有使用任何遞迴,同樣也實現了這個演算法。

相關文章