递归与快速排序:函数调用自身的边界与优雅
递归的本质,是用"相同结构"描述"不同规模"的问题。它不是技巧,而是一种将复杂问题归约为简单形式的思维方式。快速排序,则是这种思维在算法领域最经典的实践。
一、递归:从"自我调用"到"问题归约"
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 concat 与 push 的语义差异
这是一个常被初学者忽略的细节:
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 的相对顺序改变
原因 :分区过程中的交换是"非相邻"的------i 与 j 可能相距很远,相同值的元素在交换过程中相对位置被打乱。
对比归并排序:归并在合并阶段采用相邻比较,遇到相等元素时左侧优先,因此稳定。快速排序的"远程交换"特性,决定了其不稳定性。
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),与输入分布无关。
优化二:三数取中法
取 left、mid、right 三个位置元素的中位数作为基准。相比纯随机,这种方法在工程实践中表现更稳定,能有效避免极端基准的出现。
优化三:小规模数据切换插入排序
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------递归思维提示我们:寻找问题的同构结构,定义归约关系,确定终止条件。
快速排序之所以经典,不仅因为它的效率,更因为它完美诠释了"分治"这一思想:不试图一次性解决整个问题,而是找到一种结构,让问题的每个部分都能用相同的方式处理。
这或许就是计算机科学最深刻的启示:复杂性并非不可应对,关键在于找到正确的归约路径。