JavaScript 递归函数

一、递归函数

1. 什么是递归函数?

  • 定义:在函数内部直接或间接调用自身的函数,是一种 “自调用” 的编程思想,模拟现实中 “重复嵌套” 的逻辑(如 “从前有座山” 的故事)。
  • 核心本质:将复杂问题拆解为 “与原问题结构相同但规模更小” 的子问题,直到子问题可直接解决(即 “出口”),再反向合并结果。

2. 递归的两种调用形式

(1)直接调用:函数内部直接调用自身
// 注意:无出口的“死递归”,会导致栈溢出(浏览器报错)
function fun() {
  console.log("递归调用中");
  fun(); // 直接调用自身
}
fun(); 
(2)间接调用:函数 A 调用函数 B,函数 B 再调用函数 A
// 同样是死递归(无出口)
function foo() {
  console.log("来自foo");
  bar(); // foo调用bar
}
function bar() {
  console.log("来自bar");
  foo(); // bar调用foo,形成循环
}
foo(); 

3. 递归的关键:必须有 “出口”

递归的核心是 “有进有出”:“进” 是函数调用自身(规模缩小),“出” 是存在一个 “终止条件”(出口),避免死递归。

示例 1:简单递归实现(打印数字到 1)
// 需求:输入n,依次打印n、n-1、...、1,最后输出18
function age(n) {
  console.log(n); // 每次调用打印当前n(“进”的过程)
  // 出口:当n=1时,执行操作并终止递归
  if (n === 1) {
    console.log(18);
    return; // 终止递归,不再调用自身
  }
  age(n - 1); // 调用自身,规模缩小(n-1)
}
age(5); // 执行结果:5 → 4 → 3 → 2 → 1 → 18
示例 2:递归求 “平方和”(1² + 2² + ... + n²)
// 普通循环写法
function sumCycle(n) {
  let result = 0;
  for (let i = 1; i <= n; i++) {
    result += i * i;
  }
  return result;
}
console.log(sumCycle(5)); // 55(1+4+9+16+25)

// 递归写法(有出口)
function sumRecursive(n) {
  // 出口:n=0时,平方和为0
  if (n === 0) {
    return 0;
  }
  // 拆解:n² + (1²+2²+...+(n-1)²)
  return n * n + sumRecursive(n - 1);
}
console.log(sumRecursive(5)); // 55

4. 递归的优势与注意事项

(1)优势
  • 简化代码:将复杂的嵌套逻辑(如多层循环)转化为简洁的自调用(如多层数据渲染)。
  • 适合处理 “层级嵌套” 或 “规模递减” 的问题:如树形结构遍历、大型数据查找(减少遍历次数)。
(2)注意事项
  • 必须有出口:否则会导致 “栈溢出”(函数调用栈内存耗尽)。
  • 内存消耗:每次递归调用会将函数压入 “调用栈”,递归深度过大会占用大量内存(如递归 10 万次可能报错)。
  • 性能:递归效率可能低于循环(因栈操作开销),但代码可读性更高。

5. 递归经典案例

案例 1:求 1 到 n 的累加和(循环 vs 递归)
// 循环写法
function sumCycle(n) {
  let sum = 0;
  for (let i = 1; i <= n; i++) {
    sum += i;
  }
  return sum;
}
console.log(sumCycle(100)); // 5050

// 递归写法
function sumRecursive(n) {
  // 出口:n=0时和为0
  if (n === 0) {
    return 0;
  }
  // 拆解:n + (1+2+...+(n-1))
  return n + sumRecursive(n - 1);
}
console.log(sumRecursive(100)); // 5050
案例 2:小熊掰玉米(反向递归)
  • 问题描述:小熊每走 1 米,扔掉手中玉米的 “一半 + 1 个”;走到 10 米时,手中剩 1 个玉米。求开始时(0 米)的玉米数。
  • 分析:正向难算,反向推导(10 米→9 米→...→0 米):
    设当前米数为length,玉米数为x;下 1 米的玉米数为y,则y = x - (x/2 + 1) = x/2 - 1 → 反向推导:x = 2*(y + 1)
  • 代码实现
function getTotalCorn(length) {
  // 出口:走到10米时,玉米数为1
  if (length === 10) {
    return 1;
  }
  // 反向推导:当前玉米数 = 2*(下1米玉米数 + 1)
  return 2 * (getTotalCorn(length + 1) + 1);
}
console.log(getTotalCorn(0)); // 1534(开始时的玉米数)
案例 3:递归渲染多层菜单(树形结构)
  • 需求:将多层嵌套的菜单数据(如foodData)渲染为 HTML 的<ul>/<li>结构。
  • 代码实现
// 递归生成多层菜单HTML
function createMenu(data) {
  let html = '<ul>';
  for (let i = 0; i < data.length; i++) {
    html += '<li>';
    html += data[i].title; // 渲染当前菜单标题
    // 若有子菜单(cont属性),递归调用生成子菜单HTML
    if (data[i].cont && data[i].cont.length > 0) {
      html += createMenu(data[i].cont);
    }
    html += '</li>';
  }
  html += '</ul>';
  return html;
}

// 假设foodData是多层菜单数据(示例结构)
const foodData = {
  data: [
    { title: '主食', cont: [{ title: '米饭' }, { title: '面条' }] },
    { title: '蔬菜', cont: [{ title: '青菜', cont: [{ title: '菠菜' }, { title: '油麦菜' }] }] }
  ]
};

// 渲染到页面
const menuHtml = createMenu(foodData.data);
document.write(menuHtml);

6. 递归面试题:变量提升与作用域

var a = 10;
function show() {
  console.log(a); // 结果:undefined(函数内var a提升,未赋值)
  a = 5; // 给函数内的a赋值(非全局a)
  console.log(window.a); // 结果:10(全局a未被修改)
  var a = 20; // 函数内a的最终赋值
  console.log(a); // 结果:20(函数内a的值)
}
show();

  • 关键:函数内的var a会 “变量提升” 到函数顶部,形成 “函数作用域内的变量”,与全局a无关。

二、常见算法

1. 冒泡排序(Bubble Sort)

(1)原理
  • 核心:相邻元素两两比较,若 “前大后小” 则交换位置,每一轮将 “最大元素” 像 “气泡” 一样推到数组末尾。
  • 优化:每轮结束后,末尾已排好序的元素无需再比较,因此内层循环次数可递减(arr.length - 1 - i)。
(2)代码实现
// 原始数组
var exampleArr = [8, 94, 15, 88, 55, 76, 21, 39];

// 冒泡排序函数
function bubbleSort(arr) {
  // 外层循环:控制排序轮数(n个元素需n-1轮)
  for (let i = 0; i < arr.length - 1; i++) {
    // 内层循环:每轮比较相邻元素,未排序部分长度递减(-i)
    for (let j = 0; j < arr.length - 1 - i; j++) {
      // 前元素 > 后元素,交换位置
      if (arr[j] > arr[j + 1]) {
        let temp = arr[j];
        arr[j] = arr[j + 1];
        arr[j + 1] = temp;
      }
    }
  }
  return arr;
}

console.log(bubbleSort(exampleArr)); // [8, 15, 21, 39, 55, 76, 88, 94]
(3)复杂度分析
  • 时间复杂度:O (n²)(最坏 / 平均情况,两层循环);最好情况 O (n)(数组已有序,需加 “是否交换” 的判断优化)。
  • 空间复杂度:O (1)(仅用临时变量temp,无额外内存消耗)。

2. 选择排序(Selection Sort)

(1)原理
  • 核心:每轮找出 “最小元素”,将其与 “当前轮的起始位置元素” 交换,逐步将最小元素排到数组前面。
  • 优势:相比冒泡排序,减少了交换次数(每轮仅交换 1 次,冒泡排序每轮可能交换多次)。
(2)代码实现
// 原始数组
var arr = [5, 3, 7, 1, 2, 8];

// 选择排序函数
function selectionSort(arr) {
  // 外层循环:控制轮数(n个元素需n-1轮)
  for (let i = 0; i < arr.length - 1; i++) {
    // 假设当前轮的第一个元素是最小值,记录其下标
    let minIndex = i;
    let minValue = arr[i];
    // 内层循环:找当前轮的最小元素(从i+1开始,避免重复比较)
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[j] < minValue) {
        minValue = arr[j]; // 更新最小值
        minIndex = j; // 更新最小值下标
      }
    }
    // 交换:将最小元素与当前轮起始位置元素交换
    let temp = arr[i];
    arr[i] = arr[minIndex];
    arr[minIndex] = temp;
  }
  return arr;
}

console.log(selectionSort(arr)); // [1, 2, 3, 5, 7, 8]
(3)复杂度分析
  • 时间复杂度:O (n²)(无论数组是否有序,都需两层循环找最小值,无最好情况优化)。
  • 空间复杂度:O (1)(仅用临时变量tempminIndex等,无额外内存消耗)。

3. 冒泡排序 vs 选择排序

对比维度冒泡排序选择排序
核心逻辑相邻元素比较交换,推最大元素到末尾找最小元素,与起始位置交换
交换次数每轮可能多次交换(最多 n-1 次 / 轮)每轮仅 1 次交换
时间复杂度最坏 O (n²),最好 O (n)(优化后)始终 O (n²)(无优化空间)
空间复杂度O(1)O(1)
实际性能数据量大时,性能略差于选择排序数据量大时,因交换少,性能略优
性能测试(10 万条随机数据)
// 生成10万条随机数据
var randomArr = [];
for (let i = 0; i < 100000; i++) {
  randomArr.push(Math.round(Math.random() * 1000));
}

// 测试时间函数
function testSortTime(sortFn, arr) {
  const startTime = new Date().getTime();
  sortFn([...arr]); // 拷贝数组,避免原数组被修改
  const endTime = new Date().getTime();
  console.log(`${sortFn.name} 耗时:${endTime - startTime}ms`);
}

// 测试(结果仅供参考,受环境影响)
testSortTime(bubbleSort, randomArr); // 冒泡排序:约13000ms
testSortTime(selectionSort, randomArr); // 选择排序:约1000ms

4. 二分查找(Binary Search)

(1)原理
  • 核心:“折半查找”,仅适用于 “有序数组”(升序 / 降序),每次排除一半数据,大幅减少查找次数。
  • 步骤:
    1. 定义 “左边界”low(初始 0)和 “右边界”high(初始arr.length-1)。
    2. 计算中间位置mid,比较arr[mid]与目标值:
      • arr[mid] === 目标值:找到,返回mid(下标)。
      • arr[mid] > 目标值:目标值在左半部分,更新high = mid - 1
      • arr[mid] < 目标值:目标值在右半部分,更新low = mid + 1
    3. 重复步骤 2,直到low > high(未找到,返回 - 1)。
(2)代码实现
// 有序数组(必须先排序)
var sortedArr = [1, 3, 5, 7, 9, 11, 13, 15];

// 二分查找函数(非递归实现,避免栈溢出)
function binarySearch(target, arr) {
  let low = 0; // 左边界
  let high = arr.length - 1; // 右边界

  while (low <= high) { // 边界条件:左<=右才有查找空间
    const mid = Math.floor((low + high) / 2); // 中间下标(避免小数)
    if (arr[mid] === target) {
      return mid; // 找到,返回下标
    } else if (arr[mid] > target) {
      high = mid - 1; // 目标在左半部分
    } else {
      low = mid + 1; // 目标在右半部分
    }
  }
  return -1; // 未找到
}

console.log(binarySearch(7, sortedArr)); // 3(7在数组下标3的位置)
console.log(binarySearch(4, sortedArr)); // -1(4不在数组中)
(3)复杂度分析
  • 时间复杂度:O (log₂n)(每轮排除一半数据,查找次数为 log₂n,如 n=16 时仅需 4 轮)。
  • 空间复杂度:O (1)(仅用lowhighmid变量,无额外内存消耗)。

三、总结

  1. 递归函数:核心是 “自调用 + 出口”,适合处理嵌套 / 规模递减问题,需注意栈溢出风险。
  2. 排序算法
    • 冒泡排序:相邻交换,易理解但交换频繁。
    • 选择排序:找最小交换,交换少,性能略优。
    • 两者时间复杂度均为 O (n²),适合小规模数据。
  3. 二分查找:仅适用于有序数组,时间复杂度 O (log₂n),是高效的查找算法。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值