JavaScript 專題之函式記憶

冴羽發表於2017-09-06

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

定義

函式記憶是指將上次的計算結果快取起來,當下次呼叫時,如果遇到相同的引數,就直接返回快取中的資料。

舉個例子:

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

}// 假設 memorize 可以實現函式記憶var memoizedAdd = memorize(add);
memoizedAdd(1, 2) // 3memoizedAdd(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)
}
}複製程式碼

我們來測試一下:

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 倍呢!

所以,函式記憶也並不是萬能的,你看這個簡單的場景,其實並不適合用函式記憶。

需要注意的是,函式記憶只是一種程式設計技巧,本質上是犧牲演算法的空間複雜度以換取更優的時間複雜度,在客戶端 JavaScript 中程式碼的執行時間複雜度往往成為瓶頸,因此在大多數場景下,這種犧牲空間換取時間的做法以提升程式執行效率的做法是非常可取的。

第二版

因為第一版使用了 join 方法,我們很容易想到當引數是物件的時候,就會自動呼叫 toString 方法轉換成 [Object object],再拼接字串作為 key 值。我們寫個 demo 驗證一下這個問題:

var propValue = function(obj){ 
return obj.value
}var memoizedAdd = memorize(propValue)console.log(memoizedAdd({value: 1
})) // 1console.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) // 6memoizedAdd(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)) // 6console.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專題系列目錄地址:github.com/mqyqingfeng…

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

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

來源:https://juejin.im/post/59af56a96fb9a0248f4aadb8

相關文章