面试官:说一说尾递归在前端开发中的理解和应用

130 阅读6分钟

面试官:说一说尾递归在前端开发中的理解和应用

image.png

递归

递归(Recursion)在计算机科学中,指的是在函数的定义中使用函数自身的方法,在函数内部,可以调用其他函数,如果一个函数在内部调用本身,这个函数九十递归函数,其核心思想是把一个大型复杂的问题层层转化为一个与原问题相似的规模比较小的问题来求解,一般来说,递归需要有边界条件,递归前阶段和递归返回阶段。当边界条件不满足的时候,递归继续,当边界条件满足的时候,递归返回。

简单的pow(x,n)递归

正常迭代方式如下

function pow(x, n) {
  // 处理负数指数的情况
  if (n < 0) {
    x = 1 / x;
    n = -n;
  }
  let result = 1;
  while (n > 0) {
    // 如果 n 是奇数,那么就乘上 x
    if (n % 2 === 1) {
      result *= x;
    }
    // 平方 x
    x *= x;
    // 将 n 除以 2
    n = Math.floor(n / 2);
  }

  return result;
}
// 测试代码
console.log(pow(2, 3)); // 应该输出 8
console.log(pow(2, -3)); // 应该输出 0.125
console.log(pow(5, 0)); // 应该输出 1

使用递归方式如下

function pow(x, n) {
  function tailRecursivePow(base, exponent, accumulator = 1) {
    if (exponent === 0) {
      return accumulator;
    } else {
      return tailRecursivePow(base, exponent - 1, base * accumulator);
    }
  }
  // 处理负数指数的情况
  if (n < 0) {
    x = 1 / x;
    n = -n;
  }
  return tailRecursivePow(x, n);
}
// 测试代码
console.log(pow(2, 3)); // 应该输出 8
console.log(pow(2, -3)); // 应该输出 0.125
console.log(pow(5, 0)); // 应该输出 1

pow(x,n)被调用时,执行分为两个分支:

image.png

为了计算pow(2,4),递归经过如下几个步骤:

image.png

因此递归将函数调用简化为一个简单的函数调用,然后再将其简化为一个简单的函数,以此类推直到结果。

尾递归

什么是尾递归

尾递归是一种特殊的递归形式,这种形式中,函数的最后一步是调用自身。换句话说,递归调用不是在返回值中进行计算的一部分,而是作为最后一个操作被调用。尾递归的一个关键优势在于它可以被优化以避免栈溢出问题,因为编译器或解释器可以重用一个栈帧来执行递归调用,而不是每次调用创建一个新的栈帧,即:在尾部直接调用自身递归函数。

尾递归的语法特点

  • 递归调用必须是函数的最后一个操作。
  • 递归调用不能出现在其他表达式中,如条件语句,循环或算术运算等。

尾递归factorial示例:

function factorial(n) {
  if (n === 0) {
      return 1;
  }
  return n * factorial(n - 1);
}

这个版本的 factorial 函数并不是尾递归的,因为在递归调用 factorial(n - 1) 之后还有一个乘法操作需要执行。

尾递归版本:

function tailFactorial(n, accumulator = 1) {
  if (n === 0) {
      return accumulator;
  }
  return tailFactorial(n - 1, n * accumulator);
}
console.log(tailFactorial(5)); // 输出 120

在这个例子中,tailFactorial 是尾递归的,因为它在递归调用 tailFactorial(n - 1, n * accumulator) 后不再有其他操作。

尾递归的应用场景

  1. 性能优化:对于需要多次递归调用的算法,使用尾递归可以减少内存占用和提高执行效率。
  2. 无限列表处理:在处理无限列表或者非常大的数据集时,尾递归可以有效地防止栈溢出。
  3. 函数式编程语言:在支持尾递归优化的语言中(如 Scheme、Haskell),尾递归是非常常见的编程模式之一。
应用场景1: 多维数组扁平化

即将嵌套的数组展开成一个单维度的数组。例如,给定输入 a = [1, 2, 3, [1, 2, 3], [1, 2, 3]],经过 flat 函数处理后,输出将是 [1, 2, 3, 1, 2, 3, 1, 2, 3]


function flat(arr) {
  return arr.reduce((acc, val) => {
      if (Array.isArray(val)) {
          return acc.concat(flat(val));
      }
      return acc.concat(val);
  }, []);
}
const input = [1, 2, 3, [1, 2, 3], [1, 2, 3]];
console.log(flat(input)); // 输出: [1, 2, 3, 1, 2, 3, 1, 2, 3]

回调函数逻辑

if (Array.isArray(val)) {
    return acc.concat(flat(val)); 
} 
return acc.concat(val);

判断是否为数组,如果 val 是一个数组,那么递归调用 flat 函数,将嵌套的数组扁平化,然后使用 concat 方法将结果合并到累积器 acc 中。如果 val 不是数组将 val 添加到累积 acc 中,使用 concat 方法。

使用 reduce 方法

return arr.reduce((acc, val) => {
    // ...
}, []);

reduce 方法遍历数组 arr 中的每一个元素,并累积一个结果。reduce 的第一个参数是一个回调函数,该函数接收两个参数:acc(累积器)和val(当前值)。reduce 的第二个参数是一个初始值,这里我们使用空数组 [] 作为初始累积器。

如何工作

  1. 开始时: 初始累积器 acc 是一个空数组 []val 是数组 arr 中的第一个元素 1

  2. 第一次迭代val 不是数组,因此 acc 变为 [1]

  3. 第二次迭代val 不是数组,因此 acc 变为 [1, 2]

  4. 第三次迭代val 不是数组,因此 acc 变为 [1, 2, 3]

  5. 第四次迭代val 是数组 [1, 2, 3],因此 acc 变为 [1, 2, 3, 1, 2, 3]

  6. 第五次迭代val 是数组 [1, 2, 3],因此 acc 变为 [1, 2, 3, 1, 2, 3, 1, 2, 3]

  7. 最终结果acc 为 [1, 2, 3, 1, 2, 3, 1, 2, 3],这就是输出结果。

应用场景2: 数组对象格式化

将一个对象的所有键名转换为小写字母

function keysLower(obj) {
  let reg = new RegExp("([A-Z]+", "g");
  for (let key in obj) {
      if (obj.hasOwnProperty(key)) {
          let temp = obj[key];
          // 将修改后的属性名重新赋值给temp,并在对象obj内添加一个转换后的属性
          temp = obj[key].replace(reg, function(result) {
              return result.toLowerCase();
          });
          delete obj[key]; // 删除之前的大写键属性
          obj[temp] = temp;

          // 如果属性是对象或者数组,重新执行函数
          if (typeof temp === 'object' || Object.prototype.toString.call(temp) === '[object Array]') {
              keysLower(temp);
          }
      }
  }
  return obj;
}

递归处理:如果属性值是对象或数组,递归调用 keysLower 函数处理其属性。

if (typeof temp === 'object' || Object.prototype.toString.call(temp) === '[object Array]') {
  keysLower(temp);
}

替换属性名:使用正则表达式 reg 替换属性名中的大写字母为小写字母。将新名称保存回 temp 变量。

temp = obj[key].replace(reg, function(result) {
   return result.toLowerCase();
});

更新对象: 删除原属性名对应的属性。将 temp 作为新属性名,将属性值赋给新属性。

delete obj[key];
2obj[temp] = temp;

JavaScript 中尾递归的局限性

尽管尾递归在理论上有很多优点,但 JavaScript 引擎直到 ES6 标准都没有提供内置的尾递归优化。这意味着即使你编写了尾递归函数,JavaScript 运行时也不会自动进行优化。不过,一些现代的 JavaScript 引擎(如 V8)在某些情况下会尝试进行尾调用优化,但这并不是普遍适用的。

结论

虽然 JavaScript 目前没有全面支持尾递归优化,但在了解尾递归的概念及其工作原理后,我们可以在设计算法时考虑使用尾递归风格来编写代码。这不仅可以帮助我们在未来 JavaScript 引擎进一步优化时获得更好的性能,而且也可以作为一种更优雅的编程习惯来培养。