JavaScript專題之函數記憶

JavaScript 專題系列第十七篇,講解函數記憶與菲波那切數列的實現git

定義

函數記憶是指將上次的計算結果緩存起來,當下次調用時,若是遇到相同的參數,就直接返回緩存中的數據。github

舉個例子:算法

function add(a, b) {
    return a + b;
}

// 假設 memorize 能夠實現函數記憶
var memoizedAdd = memorize(add);

memoizedAdd(1, 2) // 3
memoizedAdd(1, 2) // 相同的參數,第二次調用時,從緩存中取出數據,而非從新計算一次

原理

實現這樣一個 memorize 函數很簡單,原理上只用把參數和對應的結果數據存到一個對象中,調用時,判斷參數對應的數據是否存在,存在就返回對應的結果數據。編程

初版

咱們來寫一版:緩存

// 初版 (來自《JavaScript權威指南》)
function memoize(f) {
    var cache = {};
    return function(){
        var key = arguments.length + Array.prototype.join.call(arguments, ",");
        if (key in cache) {
            return cache[key]
        }
        else return cache[key] = f.apply(this, arguments)
    }
}

咱們來測試一下:app

var add = function(a, b, c) {
  return a + b + c
}

var memoizedAdd = memorize(add)

console.time('use memorize')
for(var i = 0; i < 100000; i++) {
    memoizedAdd(1, 2, 3)
}
console.timeEnd('use memorize')

console.time('not use memorize')
for(var i = 0; i < 100000; i++) {
    add(1, 2, 3)
}
console.timeEnd('not use memorize')

在 Chrome 中,使用 memorize 大約耗時 60ms,若是咱們不使用函數記憶,大約耗時 1.3 ms 左右。函數

注意

什麼,咱們使用了看似高大上的函數記憶,結果卻更加耗時,這個例子近乎有 60 倍呢!測試

因此,函數記憶也並非萬能的,你看這個簡單的場景,其實並不適合用函數記憶。this

須要注意的是,函數記憶只是一種編程技巧,本質上是犧牲算法的空間複雜度以換取更優的時間複雜度,在客戶端 JavaScript 中代碼的執行時間複雜度每每成爲瓶頸,所以在大多數場景下,這種犧牲空間換取時間的作法以提高程序執行效率的作法是很是可取的。prototype

第二版

由於初版使用了 join 方法,咱們很容易想到當參數是對象的時候,就會自動調用 toString 方法轉換成 [Object object],再拼接字符串做爲 key 值。咱們寫個 demo 驗證一下這個問題:

var propValue = function(obj){
    return obj.value
}

var memoizedAdd = memorize(propValue)

console.log(memoizedAdd({value: 1})) // 1
console.log(memoizedAdd({value: 2})) // 1

二者都返回了 1,顯然是有問題的,因此咱們看看 underscore 的 memoize 函數是如何實現的:

// 第二版 (來自 underscore 的實現)
var memorize = function(func, hasher) {
    var memoize = function(key) {
        var cache = memoize.cache;
        var address = '' + (hasher ? hasher.apply(this, arguments) : key);
        if (!cache[address]) {
            cache[address] = func.apply(this, arguments);
        }
        return cache[address];
    };
    memoize.cache = {};
    return memoize;
};

從這個實現能夠看出,underscore 默認使用 function 的第一個參數做爲 key,因此若是直接使用

var add = function(a, b, c) {
  return a + b + c
}

var memoizedAdd = memorize(add)

memoizedAdd(1, 2, 3) // 6
memoizedAdd(1, 2, 4) // 6

確定是有問題的,若是要支持多參數,咱們就須要傳入 hasher 函數,自定義存儲的 key 值。因此咱們考慮使用 JSON.stringify:

var memoizedAdd = memorize(add, function(){
    var args = Array.prototype.slice.call(arguments)
    return JSON.stringify(args)
})

console.log(memoizedAdd(1, 2, 3)) // 6
console.log(memoizedAdd(1, 2, 4)) // 7

若是使用 JSON.stringify,參數是對象的問題也能夠獲得解決,由於存儲的是對象序列化後的字符串。

適用場景

咱們以斐波那契數列爲例:

var count = 0;
var fibonacci = function(n){
    count++;
    return n < 2? n : fibonacci(n-1) + fibonacci(n-2);
};
for (var i = 0; i <= 10; i++){
    fibonacci(i)
}

console.log(count) // 453

咱們會發現最後的 count 數爲 453,也就是說 fibonacci 函數被調用了 453 次!也許你會想,我只是循環到了 10,爲何就被調用了這麼屢次,因此咱們來具體分析下:

當執行 fib(0) 時,調用 1 次

當執行 fib(1) 時,調用 1 次

當執行 fib(2) 時,至關於 fib(1) + fib(0) 加上 fib(2) 自己這一次,共 1 + 1 + 1 = 3 次

當執行 fib(3) 時,至關於 fib(2) + fib(1) 加上 fib(3) 自己這一次,共 3 + 1 + 1 = 5 次

當執行 fib(4) 時,至關於 fib(3) + fib(2) 加上 fib(4) 自己這一次,共 5 + 3 + 1 = 9 次

當執行 fib(5) 時,至關於 fib(4) + fib(3) 加上 fib(5) 自己這一次,共 9 + 5 + 1 = 15 次

當執行 fib(6) 時,至關於 fib(5) + fib(4) 加上 fib(6) 自己這一次,共 15 + 9 + 1 = 25 次

當執行 fib(7) 時,至關於 fib(6) + fib(5) 加上 fib(7) 自己這一次,共 25 + 15 + 1 = 41 次

當執行 fib(8) 時,至關於 fib(7) + fib(6) 加上 fib(8) 自己這一次,共 41 + 25 + 1 = 67 次

當執行 fib(9) 時,至關於 fib(8) + fib(7) 加上 fib(9) 自己這一次,共 67 + 41 + 1 = 109 次

當執行 fib(10) 時,至關於 fib(9) + fib(8) 加上 fib(10) 自己這一次,共 109 + 67 + 1 = 177 次

因此執行的總次數爲:177 + 109 + 67 + 41 + 25 + 15 + 9 + 5 + 3 + 1 + 1 = 453 次!

若是咱們使用函數記憶呢?

var count = 0;
var fibonacci = function(n) {
    count++;
    return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
};

fibonacci = memorize(fibonacci)

for (var i = 0; i <= 10; i++) {
    fibonacci(i)
}

console.log(count) // 12

咱們會發現最後的總次數爲 12 次,由於使用了函數記憶,調用次數從 453 次下降爲了 12 次!

興奮的同時不要忘記思考:爲何會是 12 次呢?

從 0 到 10 的結果各儲存一遍,應該是 11 次吶?咦,那多出來的一次是從哪裏來的?

因此咱們還須要認真看下咱們的寫法,在咱們的寫法中,其實咱們用生成的 fibonacci 函數覆蓋了本來了 fibonacci 函數,當咱們執行 fibonacci(0) 時,執行一次函數,cache 爲 {0: 0},可是當咱們執行 fibonacci(2) 的時候,執行 fibonacci(1) + fibonacci(0),由於 fibonacci(0) 的值爲 0,!cache[address] 的結果爲 true,又會執行一次 fibonacci 函數。原來,多出來的那一次是在這裏!

多說一句

也許你會以爲在平常開發中又用不到 fibonacci,這個例子感受實用價值不高吶,其實,這個例子是用來代表一種使用的場景,也就是若是須要大量重複的計算,或者大量計算又依賴於以前的結果,即可以考慮使用函數記憶。而這種場景,當你遇到的時候,你就會知道的。

專題系列

JavaScript專題系列目錄地址:https://github.com/mqyqingfeng/Blog

JavaScript專題系列預計寫二十篇左右,主要研究平常開發中一些功能點的實現,好比防抖、節流、去重、類型判斷、拷貝、最值、扁平、柯里、遞歸、亂序、排序等,特色是研(chao)究(xi) underscore 和 jQuery 的實現方式。

若是有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。若是喜歡或者有所啓發,歡迎 star,對做者也是一種鼓勵。

相關文章
相關標籤/搜索