【力扣】2623. 记忆函数——函数转换
【力扣】2623. 记忆函数——函数转换
文章目录
- 【力扣】2623. 记忆函数——函数转换
- 一、题目
- 二、解决方案
- 1、概述
- 1.1纯函数
- 2、在Web开发中的记忆化用途
- 2.1缓存网站文件
- (1)React 组件
- (2)缓存 API 调用
- 3、算法中的记忆化
- 4、专业实现的考虑
- 4.1处理任意输入
- 4.2内存管理
- 方法 1:使用 Rest/Spread 语法 + JSON.stringify()
- 方法 2:使用参数语法
- 方法 3:基于数字约束进行优化 + Function.apply
- 方法4:一行代码
- 5、复杂度分析
一、题目
请你编写一个函数 fn
,它接收另一个函数作为输入,并返回该函数的 记忆化 后的结果。
记忆函数 是一个对于相同的输入永远不会被调用两次的函数。相反,它将返回一个缓存值。
你可以假设有 3 个可能的输入函数:sum
、fib
和 factorial
。
sum
接收两个整型参数a
和b
,并返回a + b
。假设如果参数(b, a)
已经缓存了值,其中a != b
,它不能用于参数(a, b)
。例如,如果参数是(3, 2)
和(2, 3)
,则应进行两个单独的调用。fib
接收一个整型参数n
,如果n <= 1
则返回1
,否则返回fib (n - 1) + fib (n - 2)
。factorial
接收一个整型参数n
,如果n <= 1
则返回1
,否则返回factorial(n - 1) * n
。
示例 1:
输入:
fnName = "sum"
actions = ["call","call","getCallCount","call","getCallCount"]
values = [[2,2],[2,2],[],[1,2],[]]
输出:[4,4,1,3,2]
解释:
const sum = (a, b) => a + b;
const memoizedSum = memoize(sum);
memoizedSum (2, 2);// "call" - 返回 4。sum() 被调用,因为之前没有使用参数 (2, 2) 调用过。
memoizedSum (2, 2);// "call" - 返回 4。没有调用 sum(),因为前面有相同的输入。
// "getCallCount" - 总调用数: 1
memoizedSum(1, 2);// "call" - 返回 3。sum() 被调用,因为之前没有使用参数 (1, 2) 调用过。
// "getCallCount" - 总调用数: 2
示例 2:
输入:
fnName = "factorial"
actions = ["call","call","call","getCallCount","call","getCallCount"]
values = [[2],[3],[2],[],[3],[]]
输出:[2,6,2,2,6,2]
解释:
const factorial = (n) => (n <= 1) ? 1 : (n * factorial(n - 1));
const memoFactorial = memoize(factorial);
memoFactorial(2); // "call" - 返回 2。
memoFactorial(3); // "call" - 返回 6。
memoFactorial(2); // "call" - 返回 2。 没有调用 factorial(),因为前面有相同的输入。
// "getCallCount" - 总调用数:2
memoFactorial(3); // "call" - 返回 6。 没有调用 factorial(),因为前面有相同的输入。
// "getCallCount" - 总调用数:2
示例 3:
输入:
fnName = "fib"
actions = ["call","getCallCount"]
values = [[5],[]]
输出:[8,1]
解释:
fib(5) = 8 // "call"
// "getCallCount" - 总调用数:1
提示:
0 <= a, b <= 105
1 <= n <= 10
1 <= actions.length <= 105
actions.length === values.length
actions[i]
为 “call” 和 “getCallCount” 中的一个fnName
为 “sum”, “factorial” 和 “fib” 中的一个
二、解决方案
1、概述
此问题要求你编写一个修改所提供函数的函数,使所提供的函数只有在没有传递参数的情况下才会被调用。如果之前已传递过这些参数,它应返回之前的输出而且无需调用所提供的函数。这种类型的优化称为 记忆化,是 高阶函数 的一个极其重要的示例。
为了具体说明记忆化,以下是没有记忆化的一些代码示例。
let callCount = 0;
const add = (a, b) => {callCount += 1;return a + b;
}add(2, 2); // 4
console.log(callCount); // 1
add(2, 2); // 4
console.log(callCount); // 2
add(2, 2); // 4
console.log(callCount); // 3
不出所料,每次调用add
时都会增加 callCount
。
然而,如果我们应用 记忆化:
let callCount = 0;
const add = (a, b) => {callCount += 1;return a + b;
};
const memoizedAdd = memoize(add);memoizedAdd(2, 2); // 4
console.log(callCount); // 1
memoizedAdd(2, 2); // 4
console.log(callCount); // 1
memoizedAdd(2, 2); // 4
console.log(callCount); // 1
就可以看到callCount
仅在第一次调用memoizedAdd
时增加。每次后续传递 (2, 2)
时,记忆化逻辑会检测到这些参数以前已传递,并立即返回缓存的值 4
,而不调用 add
。
避免添加两个数字显然算不上什么巨大的优化,但可以想象如果是对一个复杂的多的函数进行记忆化,会给性能带来多大的提升。
1.1纯函数
值得注意的是,记忆化 仅对 纯函数 有效。纯函数的定义为:给定相同的输入,始终返回相同的输出,并且没有任何副作用的函数。
例如,假设你尝试记忆化不纯的函数 Date.now
,它返回自Unix
时间戳以来的当前时间(以毫秒为单位)。
const getCurrentTimeMemoized = memoize(Date.now);getCurrentTimeMemoized(); // 1683784131157
getCurrentTimeMemoized(); // 1683784131157
getCurrentTimeMemoized(); // 1683784131157
getCurrentTimeMemoized
第一次调用时会正确返回当前时间。但每次后续调用时,它都会错误地返回和第一次相同的值。
类似的,假设你有一个具有副作用的函数,如将数据上传到数据库。
function uploadRow(row) {// 上传逻辑
}const memoizedUpload = memoize(uploadRows);
memoizedUpload('Some Data'); // 成功上传
memoizedUpload('Some Data'); // 什么都不会发生
第一次调用memoizedUpload
时,数据将正确上传到数据库,但每次后续调用都不会再得到新的结果。
事实上,你只能在纯函数上应用此优化,这也是尽可能使函数纯粹的一个很好的理由。
2、在Web开发中的记忆化用途
记忆化有无数的用途,在这里我们讨论其中一些典型案例。
2.1缓存网站文件
大型网站通常由许多 JavaScript 文件组成,在用户访问不同页面时会动态下载这些文件。有时会采用一种模式,其中文件名基于文件内容的哈希值。这样,当 Web 浏览器请求已经在之前请求过的文件名时,它可以从磁盘上本地加载文件,而不必重新下载它。
(1)React 组件
React 是一个非常流行的用于构建用户界面的库,尤其适用于单页面应用程序。其核心原则之一是将应用程序分解为单独的 组件。每个组件负责渲染应用程序HTML的不同部分。
例如,你可能有一个组件如下:
const TitleComponent = (props) => {return <h1>{props.title}</h1>;
};
上面的函数将在每次父组件渲染时调用,即使title
没有更改。通过在其上调用 React.memo
,可以提高性能,避免不必要的渲染。
const TitleComponent = React.memo((props) => {return <h1>{props.title}</h1>;
});
现在,TitleComponent
只有在title
发生变化时才会重新渲染,从而提高了应用程序的性能。
(2)缓存 API 调用
假设你有一个函数,用于向API发送网络请求以访问数据库中的键值对。
async function getValue(key) {// 数据库请求逻辑
}
const getValueMemoized = memoize(getValue);
现在,getValueMemoized
将仅为每个键进行一次网络请求,可能大大提高性能。需要注意的是,由于getValue
是异步的,它将返回一个 Promise 而不是实际值。对于这种用例,这实际上是最理想的,因为即使在第一次请求返回值之前调用两次,它仍然只会进行一次网络请求。
记忆化网络请求的一个潜在缺点是数据陈旧的风险。如果数据库中与特定键关联的值发生更改,记忆化函数可能仍然返回旧的缓存结果,使用户无法看到更新。
处理这种情况的几种方法:
- 始终向
API
发送请求,询问值是否已更改。 - 使用
WebSocket
订阅数据库中值的更改。 - 为值提供 过期时间,以使用户至少不会看到太过时的数据。
你可以在 此处 了解有关 HTTP 缓存的更多信息。
3、算法中的记忆化
记忆化的一个经典应用是在 动态规划 中,将问题分解为若干子问题。这些子问题可以表示为函数调用,其中许多函数调用多次且使用相同的输入,因此可以进行优化。
动态规划极大提高效率的一个经典示例是计算斐波那契数。
function fib(n) {if (n <= 1) return n;return fib(n - 1) + fib(n - 2);
}
fib(100); // 耗时多年
上面的代码非常低效,时间复杂度为 O(1.6 n )(1.6是黄金比率)。
但是,通过不再使用相同的输入两次调用 fib
,我们可以在 O(n) 的时间内计算斐波那契数。
const cache = new Map();
function fib(n) {if (n <= 1) return n;if (cache.has(n)) {return cache.get(n);}const result = fib(n - 1) + fib(n - 2);cache.set(n, result);return result;
}
fib(100); // 几乎立即解决
我们是否可以只是调用了fib
的第一个实现,然后在其上写了memoizedFib = memoize(fib);
以获得相同的性能优化?不幸的是,不能。fib
的原始实现引用了自身(未记忆化版本)。因此,如果调用 memoizedFib(100)
,缓存只会添加一个键(100),仍然需要数年时间才能计算。这是 JavaScript 的一个基本限制(Python 没有此问题)。
4、专业实现的考虑
4.1处理任意输入
之所以仅假设将 3 个具体的函数作为参数传递,都具有数值输入,是有原因的。这是因为数字具有唯一的字符串表示,使缓存逻辑更简单。如果函数可以传递任意输入,你将需要比仅将输入直接转换为字符串更复杂的方法。考虑解决 2630. 记忆函数 II,它需要更通用的方法。
4.2内存管理
由于你可以无限次地调用函数并传递不同的输入,因此可能会耗尽内存。实施一些机制来限制缓存大小非常重要。一种方法是使用Least Recently Used(LRU)
缓存。你可以在 2630. 记忆函数 II 的底部相关信息。
方法 1:使用 Rest/Spread 语法 + JSON.stringify()
在 JavaScript 中,你可以使用rest
语法访问所有参数作为数组。然后,可以将参数数组展开以将其传递回函数。
由于参数是数字数组(即有效的 JSON),将它们转换为字符串键的便捷方式是使用 JSON.stringify()
。
- 初始化一个用于新的记忆化函数的缓存对象。
- 每次调用记忆化函数时,将传递的参数转换为字符串。
- 检查键是否已存在于缓存中。如果是,则立即返回关联的值。
- 否则,调用提供的函数,将输出放入缓存,并返回输出。
function memoize(fn) {const cache = {};return function(...args) {const key = JSON.stringify(args);if (key in cache) {return cache[key];}const functionOutput = fn(...args);cache[key] = functionOutput;return functionOutput;}
}
方法 2:使用参数语法
JavaScript 还允许你使用特殊的arguments
变量访问传递的参数。
使用arguments
变量时需要注意以下几点:
- 它不能与箭头函数一起使用,而是引用任何包含非箭头函数的封闭函数。
- 虽然
arguments
类似于数组,但实际上是一个类似 数组的可迭代 对象。像循环遍历它和访问索引一样的操作会按预期工作。但是,调用push
和join
等方法不会起作用。 - 在处理可变参数时,通常最佳实践是使用
rest
语法,而arguments
主要用于较旧的代码库。
function memoize(fn) {const cache = {};return function() {// 将参数转换为字符串let key = '';for (const arg of arguments) {key += ',' + arg;}if (key in cache) {return cache[key];}const functionOutput = fn(...arguments);cache[key] = functionOutput;return functionOutput;}
}
方法 3:基于数字约束进行优化 + Function.apply
将参数转换为字符串是一个相对昂贵的操作。因为根据问题的约束,永远不会有超过两个参数,参数永远不会大于 100,000,所以我们可以避免将它们转换为字符串。
假设你有两个数字a
和 b
,并且希望将它们转换为一个唯一的数字,使得没有其他值的a
和b
映射到相同的数字。你可以使用公式 key = a + (b * 100001)
。
我们还可以使用Function.apply
方法调用提供的函数。它的第一个参数是this
上下文,我们可以将其设置为 null
,因为提供的函数不引用 this。第二个参数是要传递给函数的参数。
function memoize(fn) {const cache = new Map();return function() {let key = arguments[0];if (arguments[1]) {key += arguments[1] * 100001;}const result = cache.get(key);if (result !== undefined) {return result;}const functionOutput = fn.apply(null, arguments);cache.set(key, functionOutput);return functionOutput;}
}
方法4:一行代码
为了展示 JavaScript 提供的一些语法,以下是一种一行代码的解决方案。让我们看看代码的不同部分,以了解它是如何工作的。
var memoize = (fn, cache = {}) => (...args) =>
定义了memoize
函数,它接受两个参数:一个函数fn
和一个可选的缓存对象cache
。由于永远不会传递第二个参数,cache
将始终设置为一个空对象{}
。memoize
函数继续返回另一个函数,它接受任意数量的参数。??
这是Nullish
合并运算符。仅当左侧的第一个操作数不为null
或undefined
时,它才会返回左侧的第一个操作数。否则,它将返回右侧的第二个操作数。cache[args.join()]
将参数转换为逗号分隔的字符串,并返回与该键关联的值。如果值不存在,则返回undefined
(导致函数返回右侧的值)。(cache[args.join()] = fn(...args))
将缓存中的键设置为提供的函数的输出。然后返回该值。如果存在缓存未命中,将执行此代码。
var memoize = (fn, cache = {}) => (...args) => cache[args.join()] ?? (cache[args.join()] = fn(...args))
5、复杂度分析
以下分析适用于所有方法。
设 N 为以前调用函数的次数。
- 时间复杂度:O(1)。每次调用记忆化函数时,只执行一次字典查找。
- 空间复杂度:O(N)。在最坏的情况下,需要存储之前的所有参数。