MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript中的递归函数及其应用场景

2023-01-083.8k 阅读

递归函数的基本概念

在JavaScript中,递归函数是指在函数的定义中使用自身的函数。简单来说,一个函数在其内部调用自己,这就构成了递归。递归函数通常包含两个关键部分:基线条件(base case)和递归条件(recursive case)。

基线条件

基线条件是递归的终止条件。它定义了函数不再调用自身的情况,防止函数无限递归,从而避免栈溢出错误。例如,在计算阶乘的递归函数中,当输入为0或1时,函数直接返回1,这就是基线条件。

递归条件

递归条件是函数调用自身的部分,它通过改变参数,逐步向基线条件靠近。例如,在计算阶乘时,n 的阶乘等于 n 乘以 (n - 1) 的阶乘,这就是递归条件。

递归函数的语法结构

在JavaScript中,递归函数的语法结构和普通函数类似,只是在函数体内部会调用自身。以下是一个简单的递归函数示例:

function factorial(n) {
    // 基线条件
    if (n === 0 || n === 1) {
        return 1;
    }
    // 递归条件
    return n * factorial(n - 1);
}

在上述代码中,factorial 函数接收一个参数 n。当 n 为0或1时,满足基线条件,函数返回1。否则,函数执行递归条件,返回 n 乘以 factorial(n - 1) 的结果。

递归函数的调用过程

为了更好地理解递归函数的工作原理,我们来看一下 factorial(5) 的调用过程。

  1. 第一次调用 factorial(5)

    • 由于 5 既不等于 0 也不等于 1,不满足基线条件。
    • 执行递归条件,返回 5 * factorial(4)。此时,factorial(5) 的执行被暂停,等待 factorial(4) 的结果。
  2. 第二次调用 factorial(4)

    • 同样,4 不满足基线条件。
    • 执行递归条件,返回 4 * factorial(3)factorial(4) 的执行暂停,等待 factorial(3) 的结果。
  3. 第三次调用 factorial(3)

    • 3 不满足基线条件。
    • 执行递归条件,返回 3 * factorial(2)factorial(3) 的执行暂停,等待 factorial(2) 的结果。
  4. 第四次调用 factorial(2)

    • 2 不满足基线条件。
    • 执行递归条件,返回 2 * factorial(1)factorial(2) 的执行暂停,等待 factorial(1) 的结果。
  5. 第五次调用 factorial(1)

    • 1 满足基线条件,返回 1
  6. 随着 factorial(1) 返回 1,之前暂停的函数调用依次恢复执行:

    • factorial(2) 得到 2 * 1 = 2
    • factorial(3) 得到 3 * 2 = 6
    • factorial(4) 得到 4 * 6 = 24
    • factorial(5) 得到 5 * 24 = 120

最终,factorial(5) 返回 120

递归函数的应用场景

数学计算

  1. 阶乘计算 如前文所述,计算阶乘是递归函数的经典应用场景。除了正整数的阶乘,递归还可以用于计算双阶乘等变体。例如,双阶乘 n!! 的定义为:当 n 为偶数时,n!! = n * (n - 2) * (n - 4) *... * 2;当 n 为奇数时,n!! = n * (n - 2) * (n - 4) *... * 1。以下是用递归实现双阶乘的代码:
function doubleFactorial(n) {
    if (n <= 2) {
        return n;
    }
    return n * doubleFactorial(n - 2);
}
  1. 斐波那契数列 斐波那契数列是另一个适合用递归解决的数学问题。该数列的前两项为0和1,从第三项开始,每一项都等于前两项之和。其递归实现如下:
function fibonacci(n) {
    if (n === 0) {
        return 0;
    }
    if (n === 1) {
        return 1;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

然而,上述斐波那契数列的递归实现存在效率问题,因为它会重复计算很多中间结果。例如,计算 fibonacci(5) 时,fibonacci(3) 会被计算两次。为了提高效率,可以使用记忆化(memoization)技术。

const memo = {};
function fibonacciMemoized(n) {
    if (memo[n]) {
        return memo[n];
    }
    if (n === 0) {
        memo[0] = 0;
        return 0;
    }
    if (n === 1) {
        memo[1] = 1;
        return 1;
    }
    const result = fibonacciMemoized(n - 1) + fibonacciMemoized(n - 2);
    memo[n] = result;
    return result;
}

数据结构操作

  1. 树结构遍历 在JavaScript中,处理树状数据结构时,递归是一种非常有效的遍历方式。例如,考虑一个简单的二叉树结构:
function TreeNode(val) {
    this.val = val;
    this.left = null;
    this.right = null;
}

function preOrderTraversal(root) {
    if (!root) {
        return [];
    }
    const result = [];
    result.push(root.val);
    result = result.concat(preOrderTraversal(root.left));
    result = result.concat(preOrderTraversal(root.right));
    return result;
}

// 创建一个简单的二叉树
const root = new TreeNode(1);
root.right = new TreeNode(2);
root.right.left = new TreeNode(3);

console.log(preOrderTraversal(root)); // 输出: [1, 2, 3]

上述代码通过递归实现了二叉树的前序遍历。先访问根节点,然后递归地访问左子树,最后递归地访问右子树。

同样,我们可以用递归实现中序遍历和后序遍历:

function inOrderTraversal(root) {
    if (!root) {
        return [];
    }
    const result = [];
    result = result.concat(inOrderTraversal(root.left));
    result.push(root.val);
    result = result.concat(inOrderTraversal(root.right));
    return result;
}

function postOrderTraversal(root) {
    if (!root) {
        return [];
    }
    const result = [];
    result = result.concat(postOrderTraversal(root.left));
    result = result.concat(postOrderTraversal(root.right));
    result.push(root.val);
    return result;
}
  1. 链表反转 对于链表数据结构,递归也可以用于反转链表。假设有如下链表节点定义:
function ListNode(val) {
    this.val = val;
    this.next = null;
}

以下是用递归实现链表反转的代码:

function reverseList(head) {
    if (!head ||!head.next) {
        return head;
    }
    const newHead = reverseList(head.next);
    head.next.next = head;
    head.next = null;
    return newHead;
}

// 创建一个简单的链表: 1 -> 2 -> 3
const head = new ListNode(1);
head.next = new ListNode(2);
head.next.next = new ListNode(3);

const newHead = reverseList(head);
let current = newHead;
while (current) {
    console.log(current.val);
    current = current.next;
}
// 输出: 3 2 1

算法设计

  1. 分治算法 分治算法是一种重要的算法设计策略,它将一个问题分解为多个子问题,分别解决这些子问题,然后将子问题的解合并起来得到原问题的解。递归是实现分治算法的常用手段。例如,归并排序是一种典型的分治算法。
function mergeSort(arr) {
    if (arr.length <= 1) {
        return arr;
    }
    const mid = Math.floor(arr.length / 2);
    const left = arr.slice(0, mid);
    const right = arr.slice(mid);
    const sortedLeft = mergeSort(left);
    const sortedRight = mergeSort(right);
    return merge(sortedLeft, sortedRight);
}

function merge(left, right) {
    const result = [];
    let i = 0;
    let j = 0;
    while (i < left.length && j < right.length) {
        if (left[i] < right[j]) {
            result.push(left[i]);
            i++;
        } else {
            result.push(right[j]);
            j++;
        }
    }
    return result.concat(left.slice(i)).concat(right.slice(j));
}

const array = [38, 27, 43, 3, 9, 82, 10];
console.log(mergeSort(array)); // 输出: [3, 9, 10, 27, 38, 43, 82]

在上述代码中,mergeSort 函数将数组不断分割成两半,直到子数组长度为1(基线条件)。然后通过 merge 函数将两个已排序的子数组合并成一个更大的已排序数组。

  1. 回溯算法 回溯算法常用于解决组合、排列等问题,它通过尝试所有可能的解,并在发现某个解不满足条件时回溯,重新尝试其他路径。递归在回溯算法中起到了关键作用。例如,八皇后问题是一个经典的回溯算法问题,要求在8×8的棋盘上放置8个皇后,使得它们互不攻击(即同一行、同一列和同一斜线上不能有两个皇后)。
function solveNQueens(n) {
    const board = new Array(n).fill(0).map(() => new Array(n).fill('.'));
    const result = [];
    function backtrack(row) {
        if (row === n) {
            result.push([...board.map(row => row.join(''))]);
            return;
        }
        for (let col = 0; col < n; col++) {
            if (isValid(board, row, col)) {
                board[row][col] = 'Q';
                backtrack(row + 1);
                board[row][col] = '.';
            }
        }
    }
    function isValid(board, row, col) {
        const n = board.length;
        // 检查列
        for (let i = 0; i < row; i++) {
            if (board[i][col] === 'Q') {
                return false;
            }
        }
        // 检查左上方对角线
        for (let i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
            if (board[i][j] === 'Q') {
                return false;
            }
        }
        // 检查右上方对角线
        for (let i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
            if (board[i][j] === 'Q') {
                return false;
            }
        }
        return true;
    }
    backtrack(0);
    return result;
}

console.log(solveNQueens(4));

在上述代码中,backtrack 函数是递归的核心,它尝试在每一行放置皇后。如果当前位置合法,则继续递归放置下一行的皇后;如果不合法,则回溯并尝试下一个位置。

动态规划中的递归应用

动态规划是一种通过把原问题分解为相对简单的子问题,并保存子问题的解以避免重复计算的方法。虽然动态规划通常使用迭代方式实现,但递归在理解和设计动态规划算法时也有重要作用。

以背包问题为例,假设有一个背包,它的容量为 W,有 n 个物品,每个物品有重量 weights 和价值 values。要求选择一些物品放入背包,使得物品的总重量不超过背包容量,且总价值最大。

function knapsack(W, weights, values, n) {
    if (n === 0 || W === 0) {
        return 0;
    }
    if (weights[n - 1] > W) {
        return knapsack(W, weights, values, n - 1);
    } else {
        return Math.max(
            values[n - 1] + knapsack(W - weights[n - 1], weights, values, n - 1),
            knapsack(W, weights, values, n - 1)
        );
    }
}

const W = 50;
const weights = [10, 20, 30];
const values = [60, 100, 120];
const n = weights.length;
console.log(knapsack(W, weights, values, n)); // 输出: 220

上述代码通过递归实现了背包问题的求解。在每个递归步骤中,考虑是否将当前物品放入背包,然后根据情况递归地解决剩余的子问题。然而,这种递归实现同样存在重复计算的问题,可以通过记忆化(如使用二维数组记录已经计算过的子问题的解)来优化。

function knapsackMemoized(W, weights, values, n, memo = {}) {
    if (n === 0 || W === 0) {
        return 0;
    }
    const key = `${W}-${n}`;
    if (memo[key]) {
        return memo[key];
    }
    if (weights[n - 1] > W) {
        memo[key] = knapsackMemoized(W, weights, values, n - 1, memo);
    } else {
        memo[key] = Math.max(
            values[n - 1] + knapsackMemoized(W - weights[n - 1], weights, values, n - 1, memo),
            knapsackMemoized(W, weights, values, n - 1, memo)
        );
    }
    return memo[key];
}

console.log(knapsackMemoized(W, weights, values, n)); // 输出: 220

图形绘制与动画

在JavaScript的图形绘制和动画领域,递归也有其应用。例如,在使用Canvas绘制分形图形时,递归可以帮助我们生成复杂而规则的图形。以科赫曲线(Koch Curve)为例,它是一种典型的分形曲线。

function drawKochCurve(ctx, x1, y1, x2, y2, level) {
    if (level === 0) {
        ctx.moveTo(x1, y1);
        ctx.lineTo(x2, y2);
    } else {
        const dX = x2 - x1;
        const dY = y2 - y1;
        const sX = dX * Math.cos(-Math.PI / 3) - dY * Math.sin(-Math.PI / 3);
        const sY = dX * Math.sin(-Math.PI / 3) + dY * Math.cos(-Math.PI / 3);
        const xA = x1 + dX / 3;
        const yA = y1 + dY / 3;
        const xB = x1 + dX * 2 / 3;
        const yB = y1 + dY * 2 / 3;
        const xC = xB + sX / 3;
        const yC = yB + sY / 3;
        drawKochCurve(ctx, x1, y1, xA, yA, level - 1);
        drawKochCurve(ctx, xA, yA, xC, yC, level - 1);
        drawKochCurve(ctx, xC, yC, xB, yB, level - 1);
        drawKochCurve(ctx, xB, yB, x2, y2, level - 1);
    }
}

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.beginPath();
drawKochCurve(ctx, 50, 300, 750, 300, 4);
ctx.stroke();

上述代码通过递归在Canvas上绘制科赫曲线。每一级递归都会将线段分割并按照一定规则生成新的线段,随着递归深度的增加,曲线变得越来越复杂。

在动画方面,递归可以用于实现一些复杂的动画效果,比如递归式的粒子系统动画。每个粒子可以根据一定规则产生新的粒子,形成一种递归式的动画效果。

class Particle {
    constructor(x, y, vx, vy, color) {
        this.x = x;
        this.y = y;
        this.vx = vx;
        this.vy = vy;
        this.color = color;
        this.radius = 5;
    }
    update() {
        this.x += this.vx;
        this.y += this.vy;
        if (this.x < 0 || this.x > window.innerWidth) {
            this.vx = -this.vx;
        }
        if (this.y < 0 || this.y > window.innerHeight) {
            this.vy = -this.vy;
        }
    }
    draw(ctx) {
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI);
        ctx.fillStyle = this.color;
        ctx.fill();
    }
}

class ParticleSystem {
    constructor() {
        this.particles = [];
        this.maxParticles = 1000;
        this.init();
    }
    init() {
        const centerX = window.innerWidth / 2;
        const centerY = window.innerHeight / 2;
        const mainParticle = new Particle(centerX, centerY, 0, 0, 'blue');
        this.particles.push(mainParticle);
        this.split(mainParticle, 0);
    }
    split(particle, depth) {
        if (depth > 5 || this.particles.length >= this.maxParticles) {
            return;
        }
        const newVx = Math.random() * 2 - 1;
        const newVy = Math.random() * 2 - 1;
        const newParticle = new Particle(particle.x, particle.y, newVx, newVy, 'green');
        this.particles.push(newParticle);
        this.split(newParticle, depth + 1);
    }
    update() {
        this.particles.forEach(particle => particle.update());
    }
    draw(ctx) {
        ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
        this.particles.forEach(particle => particle.draw(ctx));
    }
}

const particleSystem = new ParticleSystem();
function animate() {
    const ctx = document.createElement('canvas').getContext('2d');
    ctx.canvas.width = window.innerWidth;
    ctx.canvas.height = window.innerHeight;
    document.body.appendChild(ctx.canvas);
    particleSystem.update();
    particleSystem.draw(ctx);
    requestAnimationFrame(animate);
}
animate();

上述代码实现了一个简单的递归式粒子系统动画。每个粒子在运动过程中可能会分裂产生新的粒子,通过递归控制粒子的分裂深度和数量,从而实现复杂的动画效果。

函数式编程风格中的递归

在JavaScript的函数式编程风格中,递归是实现循环和迭代的重要手段。与命令式编程中的循环不同,函数式编程倾向于使用递归函数来处理重复操作,以保持函数的纯性和不可变性。

例如,在函数式编程中计算数组元素的总和,可以使用递归代替传统的 for 循环:

function sumArray(arr) {
    if (arr.length === 0) {
        return 0;
    }
    return arr[0] + sumArray(arr.slice(1));
}

const numbers = [1, 2, 3, 4, 5];
console.log(sumArray(numbers)); // 输出: 15

上述代码通过递归不断取出数组的第一个元素并与剩余元素的总和相加,直到数组为空。这种方式避免了使用可变的索引变量和传统的循环结构,符合函数式编程的理念。

在处理复杂的数据结构,如嵌套数组时,递归也能发挥重要作用。例如,将一个嵌套数组展平为一维数组:

function flattenArray(arr) {
    const result = [];
    arr.forEach(item => {
        if (Array.isArray(item)) {
            result.push(...flattenArray(item));
        } else {
            result.push(item);
        }
    });
    return result;
}

const nestedArray = [1, [2, [3, 4], 5], 6];
console.log(flattenArray(nestedArray)); // 输出: [1, 2, 3, 4, 5, 6]

上述代码通过递归处理嵌套数组中的子数组,将其展开并合并到结果数组中。这种函数式的递归实现使得代码逻辑更加清晰,易于理解和维护。

递归函数的性能考虑

虽然递归函数在解决某些问题时非常简洁和直观,但它也存在一些性能问题。

栈溢出风险

递归函数调用会在调用栈中创建新的栈帧。每次递归调用都会增加栈的深度,如果递归深度过大,调用栈可能会溢出,导致程序崩溃。例如,在没有正确设置基线条件的情况下,递归函数会无限递归,很快就会耗尽栈空间。

function infiniteRecursion() {
    infiniteRecursion();
}
// 调用infiniteRecursion()会导致栈溢出错误

重复计算

如斐波那契数列的递归实现中,很多中间结果会被重复计算,这会导致性能急剧下降。随着输入规模的增加,计算时间会呈指数级增长。

为了避免这些性能问题,可以采取以下措施:

  1. 优化基线条件:确保基线条件能够尽早满足,减少不必要的递归调用。
  2. 使用记忆化:通过缓存已经计算过的结果,避免重复计算。例如,在斐波那契数列计算中使用 memo 对象记录已经计算过的数值。
  3. 尾递归优化:尾递归是指递归调用是函数的最后一个操作。在支持尾递归优化的JavaScript引擎中,尾递归不会增加栈的深度,从而避免栈溢出问题。然而,目前JavaScript引擎对尾递归优化的支持并不完善。
// 尾递归实现阶乘
function factorialTailRecursion(n, acc = 1) {
    if (n === 0 || n === 1) {
        return acc;
    }
    return factorialTailRecursion(n - 1, n * acc);
}

上述代码实现了尾递归的阶乘计算。虽然目前并非所有JavaScript引擎都能对其进行优化,但这种方式在理论上可以避免栈溢出问题。

递归函数与迭代的对比

递归和迭代是两种不同的解决重复问题的编程方式。

语法和可读性

递归函数的语法通常更加简洁和直观,尤其是在处理具有递归结构的数据,如树和链表时。递归函数通过自身调用的方式表达问题的分解和解决过程,代码逻辑与问题的递归定义紧密相关。例如,二叉树的前序遍历使用递归实现时,代码结构与遍历的逻辑非常契合。

迭代则通常使用循环结构(如 forwhile 循环),其语法更侧重于控制循环的条件和步长。在处理简单的重复操作,如数组求和时,迭代的代码可能更加直接。然而,在处理复杂的递归结构时,迭代的代码可能会变得冗长和难以理解。

性能

如前文所述,递归函数存在栈溢出和重复计算的性能问题,尤其是在递归深度较大或存在大量重复计算的情况下。迭代则不存在栈溢出的风险,因为它使用循环而不是函数调用栈。同时,迭代可以通过合理的优化避免重复计算,例如在计算斐波那契数列时,可以使用迭代方式逐步计算并保存中间结果,而不需要像递归那样重复计算。

内存使用

递归函数由于需要在调用栈中创建栈帧,随着递归深度的增加,内存消耗也会增加。当递归深度过大时,可能会导致内存不足。迭代则在内存使用上相对稳定,它不需要额外的栈空间来保存递归调用的状态,只需要在循环过程中维护一些局部变量。

在实际编程中,选择递归还是迭代应根据具体问题的特点来决定。如果问题具有明显的递归结构,且递归深度不会过大,递归函数可以提供简洁的解决方案。如果性能和内存使用是关键因素,或者问题可以通过简单的循环结构解决,迭代可能是更好的选择。

综上所述,JavaScript中的递归函数是一种强大而灵活的编程工具,在数学计算、数据结构操作、算法设计等多个领域都有广泛的应用。了解递归函数的原理、应用场景和性能考虑,能够帮助开发者在实际编程中更加有效地使用递归,编写出高质量的代码。