邂逅Hello算法 第二篇(尾递归)

191 阅读8分钟

1. 迭代

1. for 循环

for 循环是最常用的迭代方式,它允许你通过设定初始值、循环条件和每次迭代后要执行的操作来重复执行一段代码。

// 例子:打印 04
for (let i = 0; i < 5; i++) {
  console.log(i);
}

初始值let i = 0,定义一个初始值。

循环条件i < 5,只要条件为 true 就继续循环。

每次迭代后操作i++,每次循环后 i 加 1。

2. while 循环

while 循环在每次迭代前检查条件是否为 true。如果条件为 true,则执行循环体中的代码。

// 例子:打印 0 到 4
let i = 0;
while (i < 5) {
  console.log(i);
  i++;
}

初始值let i = 0,定义一个初始值。

循环条件i < 5,检查条件。

每次迭代后操作i++,每次循环后 i 加 1。

while循环比for循环更加灵活,能处理更多复杂的迭代。

3. 嵌套循环

嵌套循环是指在一个循环内部再放置一个循环。常用于处理二维数据结构,比如矩阵。

// 例子:打印 2D 矩阵
for (let i = 0; i < 3; i++) {
  for (let j = 0; j < 3; j++) {
    console.log(`i: ${i}, j: ${j}`);
  }
}
  • 外层循环for (let i = 0; i < 3; i++)
  • 内层循环for (let j = 0; j < 3; j++)

当你不停的嵌套循环的时候,几维的循环其实能模拟几维的空间,一层for循环可以模拟出一条线上的轨迹,两层for循环能模拟出一个平面上的动态,三层for循环能模拟出三维空间中的人的运动,四层for循环就是加上时间这个轴以此类推... 但是低维的生物是无法想象更高维的空间是如何运行的。

2. 递归(分为递和归两个部分)

1. 调用栈

递归函数会在调用栈中不断地推入新的帧,每个帧保存了函数的状态。当递归条件结束时,调用栈会被逐一弹出,函数逐层返回。

function factorial(n) {
  if (n === 0) return 1;//终止条件
  return n * factorial(n - 1);//递归调用和返回结果写一起了
}

递归分为三个部分

终止条件

递归调用

返回结果

  1. 终止条件:用于决定什么时候由“递”转“归”。
  2. 递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
  3. 返回结果:对应“归”,将当前递归层级的结果返回至上一层。
  • 调用栈示意

    • factorial(3) 调用 factorial(2)
    • factorial(2) 调用 factorial(1)
    • factorial(1) 调用 factorial(0)
    • factorial(0) 返回 1,逐层返回。

2. 尾递归

尾递归是递归的一种特殊形式,在尾递归中,递归调用是函数执行的最后一步。尾递归的特点使得它可以被编译器或解释器优化,减少递归调用的开销,提高性能。

尾递归的定义

尾递归是指递归函数在返回结果之前,最后一步操作是直接调用自身,而不是在返回之前执行其他操作。简单来说,尾递归的形式是:

function tailRecursiveFunction(params, ...args) {
  // 一些处理
  return tailRecursiveFunction(...newArgs); // 递归调用
}

在尾递归中,递归调用的结果会直接返回,而不会在返回之前进行任何额外计算。

为什么尾递归更高效

正常的递归调用每次都会创建新的调用栈帧,导致空间复杂度较高,可能会导致栈溢出。而尾递归由于递归调用是函数的最后一步,可以通过尾递归优化(Tail Call Optimization,TCO)来避免创建新的栈帧,从而减少空间复杂度。

尾递归优化

尾递归优化(TCO)是编译器或解释器对尾递归的优化技术。它将尾递归调用转换为循环,从而避免了递归调用带来的额外栈帧开销。

尾递归示例

下面是一个计算阶乘的尾递归示例:

function factorial(n, acc = 1) {
  if (n === 0) return acc;
  return factorial(n - 1, n * acc); // 尾递归
}
  • 参数 n:当前计算的值。
  • 参数 acc:累积结果,用于存储当前的阶乘值。

在这个例子中,递归调用 factorial(n - 1, n * acc) 是函数的最后一步操作,所以它是尾递归的。

尾递归与普通递归的对比

  1. 普通递归

    function factorial(n) {
      if (n === 0) return 1;
      return n * factorial(n - 1); // 非尾递归
    }
    

    在这个例子中,factorial(n - 1) 的结果需要乘以 n,因此它不是尾递归。函数需要保存状态以便在递归调用返回后继续计算。

  2. 尾递归

    function factorial(n, acc = 1) {
      if (n === 0) return acc;
      return factorial(n - 1, n * acc); // 尾递归
    }
    

    在这个例子中,递归调用 factorial(n - 1, n * acc) 是函数的最后一步,返回值不会被进一步操作,因此它是尾递归。 假设你正在倒计时从10到1,每秒钟减去1,直到到达0。这个过程可以用递归来描述:

  • 普通递归:每秒钟的倒计时需要等前一秒的倒计时结束后才能继续进行。
  • 尾递归:每秒钟的倒计时在进行下一秒的倒计时前不需要做额外的计算,直接进入下一秒的倒计时。

例子

普通递归的倒计时

在普通递归中,每个倒计时的结果需要等到前一个倒计时完成后才能继续进行:

function countdown(n) {
  if (n <= 0) {
    console.log("Time's up!");
  } else {
    console.log(n);
    countdown(n - 1); // 递归调用
  }
}

解释

  • 每秒钟的倒计时 countdown(n - 1) 需要等待前一个倒计时完成后才能开始。
  • 每个递归调用都需要保存当前的倒计时状态,直到递归到达0。

尾递归的倒计时

在尾递归中,每个倒计时调用的结果直接传递到下一个倒计时调用,不需要在返回之前进行额外的处理:

function countdown(n) {
  function helper(n) {
    if (n <= 0) {
      console.log("Time's up!");
      return;
    }
    console.log(n);
    helper(n - 1); // 尾递归调用
  }
  helper(n);
}

解释

  • helper(n - 1) 是函数的最后一步操作,因此这是一个尾递归调用。
  • 在尾递归中,helper(n - 1) 不需要保留当前调用的状态,直接进行下一次调用。这个过程可以被优化为循环,从而避免额外的栈开销。

实际的尾递归优化

在尾递归中,编译器或解释器可以优化这个过程,将递归调用转换为循环。这意味着每秒钟的倒计时不需要创建新的调用栈帧,只需更新当前的状态并进行下一次倒计时。这减少了空间开销,使得倒计时过程更高效。

注意事项

  1. 语言支持:虽然 JavaScript 的规范(ECMAScript 6)支持尾递归优化,但实际的 JavaScript 引擎(如 V8)可能不一定实现尾递归优化。因此,在某些环境中,尾递归优化可能不会生效。
  2. 性能考虑:即使语言支持尾递归优化,在实际应用中还是要注意递归的深度和性能。对于深度很大的递归,尾递归优化可能会有帮助,但还是要谨慎使用。

3. 递归树

递归树是一个树形结构,用来表示递归函数的调用关系。每个节点表示一个函数调用,每个子节点表示递归调用。

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}
  • 递归树示意

    • fibonacci(4) 调用 fibonacci(3)fibonacci(2)
    • fibonacci(3) 调用 fibonacci(2)fibonacci(1)
    • 依此类推,形成树状结构。

3. 两者对比

  • 性能:迭代通常比递归更高效,因为递归需要处理调用栈,而迭代则没有这些额外的开销。递归在深度较大时可能导致栈溢出。

  • 代码简洁性:递归可以使代码更简洁,尤其是处理树状结构和分治算法时,递归代码通常比迭代代码更易读。

  • 使用场景

    • 迭代:适用于大多数情况,尤其是需要处理大数据集时。
    • 递归:适用于处理分治问题、树形结构和动态规划等问题。

代码

//for循环迭代
function forLoop(n){
    let res = 0
    for(let i=0;i<n;i++){
        res += i
    }
    return res
}

//while循环迭代
function whileLoop(n){
    let res = 0;
    let i = 0;
    while(i < n){
        res += i;
        i++
    }
    return res
}

//作为while循环迭代 它比for循环的更灵活 
function whileLoopII(n){
    let res = 0;
    let i = 0;
    while(i <= n){
        res += i;
        i++;
        i *= 2;//每两次更新一次
    }
    return res;
}

//递归调用
function recur(n){
    if(n === 1)return 1;//终止条件
    const res = recur(n - 1);//递归调用
    return n + res//返回结果
}

//尾递归
function tailRecur(n,res){
    if(n === 0) return res;
    return tailRecur(n - 1,res)//把计算的过程丢进递中,归只需要层层返回
}

/**
*   普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
*   尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
 */


// 斐波那契求和 经典递归题目
function fib(n){
    if(n === 1 || n === 2) return n - 1;
    const res = fib(n - 1) + fib(n - 2);
    return res
}

//用栈去模拟递归这个过程
function forLoopRecur(n){
    const stack= [];
    let res = 0;
    for(let i = n;i > 0;i--){
        stack.push(i)//递的过程
    }
    while(stack.length){
        res += stack.pop();//归的过程
    }

    return res;
}