如何加速你的 JavaScript【Part3】:优化递归算法

115 阅读4分钟

在 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》