HomeGithub

使用递归时的注意事项

开发者对递归应该都很熟悉了, 可是在使用递归的时候, 还是需要额外小心一些常见问题, 比如无限循环导致了栈溢出, 算法速度过慢或者太耗内存等

防止栈溢出

递归是函数调用自己,这样就会形成一个调用栈,如果递归的层级太深就会导致栈溢出。著名的技术问答网站 Stack Overflow 就是因为这个原因而得名。常见的栈溢出原因有两个:

一是递归没有 base case 导致无法终止,比如下面阶乘的递归实现:

function factorial(n) {
  return n * factorial(n - 1)
}
console.log(factorial(10))

这段代码会报错:

RangeError: Maximum call stack size exceeded

解决方法也很简单, 把 base case 加上就可以了:

function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1)
}
console.log(factorial(10))

二是递归的参数没有改变导致死循环,比如下面的快排算法的实现:

function quickSort(arr) {
  if (arr.length <= 1) return arr;
  const pivot = arr[0];
  const lows = [];
  const highs = [];
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] < pivot) {
      lows.push(arr[i]);
    } else {
      highs.push(arr[i]);
    }
  }
	return quickSort(lows).concat(quickSort(highs))
}
const nums = [10, 7, 5]
console.log(quickSort(nums))

看起来好像没什么问题,但运行时仍然会抛出栈溢出的错误,让我们看下具体发生了什么:

第一次调用 quickSort, 参数为 [10, 7, 5]
  pivot = 10
  lows = []
  highs = [10, 7, 5]
第二次调用 quickSort(lows) 参数为 [] => 直接返回 []
第三次调用 quickSort(highs) 参数为 [10, 7, 5] => 无限循环

问题的本质在于递归的过程中参数没有改变,导致无限循环。针对这个例子,我们可以改变 for 循环的起始值,让它从 1 开始,这样就可以确保在 arr 被分为 lows 和 highs 后数组的规模一定是在下降的:

function quickSort(arr) {
  if (arr.length <= 1) return arr;
  const pivot = arr[0];
  const [lows, highs] = [[], []]
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] < pivot) {
      lows.push(arr[i]);
    } else {
      highs.push(arr[i]);
    }
  }
  return quickSort(lows).concat(pivot).concat(quickSort(highs))
}
const nums = [10, 7, 5]
console.log(quickSort(nums))

避免重复计算 (缓存)

下面是斐波那契数列的递归实现:

function fib(n) {
  if (n <= 2) return 1;
  else return fib(n - 1) + fib(n - 2);
}
console.log(fib(50));

上面的算法并没有错,但运行后会发现什么也没有被打印而且 CPU 占用一直很高,这说明计算仍然在运行但是迟迟不能给出结果。其核心原因是做了很多重复计算。下面以 fib(6) 的计算作为说明:

                                        fib(6)
                        fib(5)          +               fib(4)
        fib(4)      +       fib(3)      +       fib(3)      +       fib(2)
    fib(3)  +  fib(2) + fib(2) + fib(1) +   fib(2) + fib(1) +       fib(2)
fib(2)+fib(1)+ fib(2) + fib(2) + fib(1) +   fib(2) + fib(1) +       fib(2)
1     +  1   +   1    +    1   +   1    +     1    +   1    +       1
结果: 8

可以看到 fib(4) 计算了 2 次,fib(3) 计算了 3 次。由此可以推算:计算 fib(7) 时,fib(5) 计算了 2 次,fib(4) 计算了 3 次,fib(3) 计算了 4 次...

输入 n 越大,重复的计算越多,给出结果所需的时间会因为计算冗余极速下降。时间复杂度为 O(2^n)。

解决重复计算的方案很简单,只要记录下已经计算过的值,下次碰上同样的输入直接取就可以了。这种缓存小技巧又被称为记忆化(Memorization), 比如可以用一个 Map 来作为记忆化的载体:

const cache = new Map();
function fib(n) {
  if (n <= 1) return 1;
  if (!cache.has(n)) {
    cache.set(n, fib(n - 1) + fib(n - 2));
  }
  return cache.get(n);
}
console.log(fib(50)); // 立马打印出结果

尾递归优化内存占用

未完待续...