递归与快速排序:函数调用自身的边界与优雅

递归与快速排序:函数调用自身的边界与优雅

递归的本质,是用"相同结构"描述"不同规模"的问题。它不是技巧,而是一种将复杂问题归约为简单形式的思维方式。快速排序,则是这种思维在算法领域最经典的实践。


一、递归:从"自我调用"到"问题归约"

1.1 同一问题的两种实现范式

以求 1 到 n 的和为例,存在两种本质不同的实现方式:

javascript 复制代码
// 迭代实现:显式控制循环状态
function sum(n) {
  let result = 0;
  for (let i = 1; i <= n; i++) {
    result += i;
  }
  return result;
}

// 递归实现:将问题归约为更小的同类问题
function sum(n) {
  if (n === 1) return 1;
  return n + sum(n - 1);
}

迭代范式关注"如何一步步计算",递归范式关注"大问题与小问题之间的关系"。前者是命令式的状态推进,后者是数学归纳法在代码层面的直接映射------f(n) = n + f(n-1),这正是递推公式的程序化表达。

1.2 递归成立的三个必要条件

递归并非可以随意构造,它必须同时满足以下三个条件:

条件 含义 违反的后果
子问题同构 每一层递归处理的问题结构相同 递归无法继续,问题发生变异
递推关系 大问题能用小问题的解表示 无法构造递归体
终止条件 存在最小规模可直接求解 无限递归,调用栈溢出

其中,终止条件是最容易被忽视的 。递归调用在底层依赖调用栈(Call Stack)实现,每层递归都会压入一个栈帧。若无终止条件,栈空间终将耗尽,抛出 Stack Overflow 错误。这不是逻辑问题,而是资源约束问题。

1.3 递归的执行模型:调用栈与回溯

sum(4) 为例,其执行过程如下:

复制代码
sum(4)                          ← 栈顶压入
  └─ 4 + sum(3)                 ← 等待 sum(3) 返回
            └─ 3 + sum(2)       ← 等待 sum(2) 返回
                      └─ 2 + sum(1)  ← 等待 sum(1) 返回
                                └─ return 1   ← 触底,开始回溯
                      └─ return 2 + 1 = 3
            └─ return 3 + 3 = 6
  └─ return 4 + 6 = 10          ← 栈顶弹出

递归的执行分为两个阶段:递推阶段 (自顶向下压栈)与回溯阶段(自底向上弹栈)。理解这一点,是理解所有递归算法的基础。

递归的设计哲学在于:开发者只需定义"当前层"的逻辑,无需关心整个调用链。 这种局部性原则,正是递归优雅的根源。


二、数组扁平化:递归思维的典型应用

2.1 问题定义

将任意深度嵌套的数组转化为一维数组:

复制代码
[1, [2, [3, 4, 5]]]  →  [1, 2, 3, 4, 5]

2.2 语言原生方案

ES2019 引入的 Array.prototype.flat 可直接解决:

javascript 复制代码
[1, [2, [3, 4, 5]]].flat(Infinity);  // [1, 2, 3, 4, 5]

Infinity 表示展开任意深度。然而,理解其底层实现原理,比使用 API 更为重要。

2.3 手写实现:递归的标准范式

javascript 复制代码
const flatten = (arr) => {
  let result = [];
  arr.forEach((item) => {
    if (Array.isArray(item)) {
      result = result.concat(flatten(item));
    } else {
      result.push(item);
    }
  });
  return result;
};

逻辑结构清晰:遍历数组,若元素为数组则递归处理,否则直接收集。关键在于 concat 的使用------它将子问题的解"展开"后合并,而非整体嵌入。

2.4 concatpush 的语义差异

这是一个常被初学者忽略的细节:

javascript 复制代码
[1].push([2, 3]);   // [1, [2, 3]]   --- 将数组作为单一元素插入
[1].concat([2, 3]); // [1, 2, 3]     --- 将数组元素逐个合并

push 保持嵌套结构,concat 实现扁平合并。在递归实现中,若误用 push,将导致"剥开一层又套一层"的逻辑错误。

2.5 执行流程

复制代码
flatten([1, [2, [3, 4, 5]]])
  ├─ 1:非数组 → push → [1]
  └─ [2, [3, 4, 5]]:数组 → 递归
      ├─ 2:非数组 → push → [2]
      └─ [3, 4, 5]:数组 → 递归
          └─ 3, 4, 5:非数组 → [3, 4, 5]
      └─ concat → [2, 3, 4, 5]
  └─ concat → [1, 2, 3, 4, 5]

这段代码没有显式处理"嵌套层数"------无论嵌套多深,逻辑保持不变。 这正是递归的威力:用固定结构应对任意规模的问题。


三、快速排序:分治思想的工程实践

3.1 为何需要 O(n log n) 的排序算法

基础排序算法的时间复杂度均为 O(n²):

算法 核心策略 时间复杂度
冒泡排序 相邻元素比较交换 O(n²)
选择排序 每轮选取最小值归位 O(n²)
插入排序 在有序区间内插入元素 O(n²)

O(n²) 的代价在于:数据规模增长 1000 倍,耗时增长 100 万倍。对于百万级数据,这类算法已不具备工程可行性。快速排序通过分治策略,将平均时间复杂度降至 O(n log n),成为工业界最常用的排序算法之一。

3.2 分治与递归:思想与实现

需要明确区分两个概念:

  • 分治(Divide and Conquer):算法设计思想,遵循"分解 → 解决 → 合并"三步
  • 递归(Recursion):代码实现手段,函数调用自身

分治是战略层面的设计,递归是战术层面的实现。分治可用递归实现,也可用迭代实现;递归可用于分治,也可用于遍历等其他场景。

3.3 快速排序的核心思路

复制代码
[10, 5, 2, 3, 7, 6, 4, 8, 9]
         ↓ 选取基准
[5, 2, 3, 7, 6, 4, 8, 9]  10  []
←------ 小于基准 ------→            基准归位
         ↓ 对左右子区间递归
      最终有序

快速排序的每一轮分区,都使一个元素到达其最终位置。这是其效率的核心来源------每轮操作都有确定的产出

3.4 分区函数:双指针交换法

javascript 复制代码
function partition(nums, left, right) {
  let i = left;
  let j = right;

  while (i < j) {
    // j 从右向左扫描,寻找第一个小于基准的元素
    while (i < j && nums[j] > nums[left]) j--;
    // i 从左向右扫描,寻找第一个大于基准的元素
    while (i < j && nums[i] <= nums[left]) i++;
    // 交换两个越界元素
    [nums[i], nums[j]] = [nums[j], nums[i]];
  }

  // 基准归位
  [nums[left], nums[i]] = [nums[i], nums[left]];
  return i;
}

分区函数采用双指针策略:i 从左端扫描大于基准的元素,j 从右端扫描小于基准的元素,二者相遇即确定基准的最终位置。整个过程是原地操作,空间复杂度为 O(1)。

3.5 递归主函数

javascript 复制代码
function quickSort(nums, left, right) {
  if (left >= right) return;                    // 递归终止
  const pivot = partition(nums, left, right);   // 分区
  quickSort(nums, left, pivot - 1);             // 递归处理左子区间
  quickSort(nums, pivot + 1, right);            // 递归处理右子区间
}

四行代码完成了完整的排序逻辑。递归的简洁性在此体现得淋漓尽致------开发者只需定义"分区 + 递归"的结构,剩余的调用链由语言运行时自动完成。

3.6 分区过程示例

[6, 2, 8, 1, 9, 3] 为例,基准选取首元素 6:

复制代码
初始: [6, 2, 8, 1, 9, 3]
       i=0              j=5
       基准=6

第1轮:
  j 找 < 6: 3 → 停于 j=5
  i 找 > 6: 8 → 停于 i=2
  交换 → [6, 2, 3, 1, 9, 8]

第2轮:
  j 找 < 6: 1 → 停于 j=3
  i 找 > 6: 越过 j → 退出循环

基准归位: 交换 nums[0] 与 nums[3]
结果: [1, 2, 3, 6, 9, 8]
              ↑ 基准归位

分区完成后,基准 6 左侧均小于 6,右侧均大于 6。随后对 [1,2,3][9,8] 分别递归,最终完成排序。


四、三个关键问题

4.1 快速排序为何不稳定?

稳定性的定义:值相同的元素在排序后保持原有相对顺序。

复制代码
排序前: [3a, 1, 3b, 2]
快排后: [1, 2, 3b, 3a]   ← 3a 与 3b 的相对顺序改变

原因 :分区过程中的交换是"非相邻"的------ij 可能相距很远,相同值的元素在交换过程中相对位置被打乱。

对比归并排序:归并在合并阶段采用相邻比较,遇到相等元素时左侧优先,因此稳定。快速排序的"远程交换"特性,决定了其不稳定性。

4.2 为何 j 必须先于 i 移动?

这是快速排序实现中一个微妙但关键的细节。

结论:j 先移动,保证最终相遇点的元素值 ≤ 基准。

证明j 的扫描条件是 nums[j] > nums[left],即 j 停下时必然满足 nums[j] ≤ nums[left]。若 i 先移动,i 停下时 nums[i] > nums[left],此时基准与 nums[i] 交换,会导致左侧出现大于基准的元素------分区失效。

工程口诀:j 先走,保正确性。

4.3 最坏情况的时间复杂度

当输入数组已有序,且每次选取首元素为基准时:

复制代码
[1, 2, 3, 4, 5]
→ [] 1 [2, 3, 4, 5]     ← 左子区间为空
→          [] 2 [3, 4, 5]
→                   [] 3 [4, 5]

每轮分区仅减少一个元素,递归深度退化为 O(n),时间复杂度从 O(n log n) 恶化至 O(n²)。这是快速排序的主要缺陷,也是后续优化的动因。


五、三种工程优化策略

优化一:随机化基准选取

javascript 复制代码
const rand = left + Math.floor(Math.random() * (right - left + 1));
[nums[left], nums[rand]] = [nums[rand], nums[left]];

通过随机选取基准,避免在特定输入(如有序数组)下退化。数学上可以证明,随机化快排的期望时间复杂度为 O(n log n),与输入分布无关。

优化二:三数取中法

leftmidright 三个位置元素的中位数作为基准。相比纯随机,这种方法在工程实践中表现更稳定,能有效避免极端基准的出现。

优化三:小规模数据切换插入排序

javascript 复制代码
if (right - left < 10) {
  insertionSort(nums, left, right);
  return;
}

当数据规模较小时,递归调用的开销(栈帧分配、函数调用)超过了排序本身的开销。此时切换到插入排序,虽然理论复杂度仍为 O(n²),但常数因子更小,实际运行更快。这种"混合算法"策略在工业级排序实现中极为常见,如 V8 的 Array.prototype.sort 即采用类似思路。


六、知识结构总览

复制代码
递归(实现手段)
  │
  ├── 数值递归:f(n) = n + f(n-1)
  │     └── 三要素:子问题同构 + 递推关系 + 终止条件
  │
  ├── 结构递归:数组扁平化
  │     └── 关键:concat 合并,而非 push 嵌套
  │
  └── 分治(算法思想)
        │
        └── 快速排序
              ├── 分:选取基准,双指针分区
              │     └── j 先移动,保证基准归位正确
              ├── 治:递归处理左右子区间
              └── 合:原地操作,无需额外合并
                    │
                    ├── 平均时间复杂度:O(n log n)
                    ├── 最坏时间复杂度:O(n²)
                    ├── 空间复杂度:O(log n)(递归栈)
                    ├── 稳定性:不稳定
                    └── 优化:随机基准 / 三数取中 / 小规模切换

结语

递归的价值,不在于它能让代码更短,而在于它提供了一种思考复杂问题的方式:将大问题归约为同构的小问题,直至达到可直接求解的最小规模。

这种思维方式的影响远超算法本身。当面对一个庞大的系统、一个复杂的需求、一个难以定位的 Bug------递归思维提示我们:寻找问题的同构结构,定义归约关系,确定终止条件。

快速排序之所以经典,不仅因为它的效率,更因为它完美诠释了"分治"这一思想:不试图一次性解决整个问题,而是找到一种结构,让问题的每个部分都能用相同的方式处理。

这或许就是计算机科学最深刻的启示:复杂性并非不可应对,关键在于找到正确的归约路径。