在 JavaScript 中,递归往往是造成脚本运行缓慢的罪魁祸首。过度的递归会导致浏览器陷入停滞,甚至出现意外退出。因此,递归是一个需要严肃对待的性能问题。在这个系列的《Part2》中,我们简要介绍了如何通过 memoization(记忆化)技术来处理递归过多的情况。Memoization 是一种缓存已经计算过的结果,避免重复计算的技术。在递归函数中,memoization 可以极大地提升性能。
我们在之前讨论的 memoizer 函数主要适用于返回整数的递归函数。但并不是所有的递归函数都返回整数,因此我们可以创建一个更通用的 memoizer
函数来处理任意类型的递归函数:
function memoizer(fundamental, cache){
cache = cache || {}
var shell = function(arg){
if (!cache.hasOwnProperty(arg)){
cache[arg] = fundamental(shell, arg)
}
return cache[arg];
};
return shell;
}
与 Crockford 的 memoizer 函数相比,这个版本做了一些调整。首先,我们将原始函数作为第一个参数,缓存对象作为可选的第二个参数。并不是所有递归函数都需要初始数据,所以让缓存参数变为可选是合理的。其次,我们将缓存的数据结构从数组改为对象,这样该 memoizer 可以适用于返回非整数结果的递归函数。在 shell
函数内部,我们使用 hasOwnProperty()
方法检查缓存中是否已有对应参数的条目,这比简单检查 undefined
更安全,因为 undefined
也是一种合法的返回值。
示例:优化斐波那契数列的递归计算
通过 memoization,递归计算斐波那契数列的算法可以大幅提升性能。让我们看看使用 memoizer
函数的斐波那契算法:
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 中尤为重要,因为 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()
会根据数组的大小递归调用多次。由于归并排序每次只计算一次结果,因此 memoization 并不能帮助优化这个算法。如果对一个包含 100 个元素的数组调用 mergeSort()
,将会产生 199 次递归调用;而对一个包含 1000 个元素的数组,则会产生 1999 次调用。解决方案是将这个递归算法转换为迭代算法。
迭代版的归并排序
我们可以通过引入循环来消除递归,转换为迭代版本的归并排序算法:
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([]); // 处理奇数个元素
for (var lim=len; lim > 1; lim = Math.floor((lim+1)/2)){
for (var j=0,k=0; k < lim; j++, k+=2){
work[j] = merge(work[k], work[k+1]);
}
work[j] = []; // 处理奇数个元素
}
return work[0];
}
这个版本的归并排序通过循环显式地拆分数组,而不再依赖隐式的递归调用。它首先将数组拆分为多个只有一个元素的数组,然后使用循环逐步合并这些数组,最终得到排序好的数组。
结论
在 JavaScript 中,应时刻关注递归算法的性能问题。Memoization 和迭代是避免递归过度导致性能瓶颈的两种有效方法。Memoization 更适用于有严格定义结果集的递归算法,如斐波那契数列;而迭代更适合通过循环实现的算法,如归并排序。通过选择合适的优化方法,可以有效避免浏览器脚本长时间运行或崩溃的问题。
参考文章: Zakas, Nicholas C.《Speed up your JavaScript, Part 3》