深入理解递归与快速排序 ------ 从基础入门到手写实现 🚀
学习日志 | 算法与数据结构 | 前端基本功
📖 写在前面
算法是程序员的灵魂。在日常开发中,我们或许不会天天手写排序、倒腾递归,但当你遇到深拷贝 、树形结构遍历 、组件递归渲染等场景时,递归与分治的思想无处不在。
本文是一篇学习日志 ,记录了笔者系统学习「递归」与「快速排序」的过程。文章从最基础的递归概念出发,逐步深入到快速排序的分治思想,配合完整可运行的代码,力求让你读完后能够:
- ✅ 彻底搞懂递归的三要素与执行流程
- ✅ 手写数组扁平化(flatten)的递归实现
- ✅ 理解冒泡 / 选择 / 插入排序的优缺点
- ✅ 手写快速排序并讲清楚分区函数的每一行
- ✅ 厘清「递归」与「分治」这两个易混淆概念的区别
一、递归:从入门到精通
1.1 什么是递归?
递归(Recursion) 是一种编程技巧,指函数在执行过程中调用自身。它能够将复杂问题自顶向下地拆解为规模更小的同类子问题,直到问题简单到可以直接求解。
举个最经典的例子:求 1 + 2 + 3 + ... + n 的和。
1.2 迭代 vs 递归 --- 两种思路解决同一问题
迭代解法(循环累加)
javascript
// 迭代:自底向上,逐个累加
function sum(n) {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
return total;
}
console.log(sum(100)); // 5050
迭代的思路很直观:从 1 开始,一个一个往上加,直到 n。执行流程是一条直线,时间复杂度 O(n),空间复杂度 O(1)。
递归解法(函数自调用)
javascript
// 递归:自顶向下,缩小问题规模
function sum(n) {
if (n === 1) return 1; // 退出条件(递归出口)
return n + sum(n - 1); // 递归公式
}
console.log(sum(100)); // 5050
递归的思路则完全不同:它假设 sum(n-1) 已经有人帮你算好了,你只需要把 n 加上去就行。
执行过程像一棵树:
scss
sum(5)
→ 5 + sum(4)
→ 4 + sum(3)
→ 3 + sum(2)
→ 2 + sum(1)
→ 1 ← 到达出口,开始"归"
→ 2 + 1 = 3
→ 3 + 3 = 6
→ 4 + 6 = 10
→ 5 + 10 = 15
1.3 递归的核心三要素
任何一个递归函数,都必须具备以下三个要素,缺一不可:
| 要素 | 说明 | 本例中的体现 |
|---|---|---|
| 递归公式 | 如何将大问题分解为子问题 | f(n) = n + f(n-1) |
| 退出条件 | 子问题缩小到何时直接返回(防止死循环) | f(1) = 1 |
| 相同子问题 | 每一层子问题的求解方式完全一致 | 每一层都是 "当前值 + 剩余和" |
⚠️ 没有退出条件 → 栈溢出(Stack Overflow),这是新手最容易犯的错误。
1.4 递归的执行模型:递→归 两阶段
理解递归的关键在于理解调用栈(Call Stack):
scss
阶段一【递 --- 压栈】
sum(5) 入栈 → sum(4) 入栈 → sum(3) 入栈 → sum(2) 入栈 → sum(1) 入栈
↓ 到达出口
阶段二【归 --- 弹栈】
sum(1)=1 出栈 → sum(2)=3 出栈 → sum(3)=6 出栈 → sum(4)=10 出栈 → sum(5)=15 出栈
「递」 是不断将问题规模缩小的过程(函数不断入栈),「归」 是拿到最小问题的答案后逐层返回合并的过程(函数不断弹栈)。
1.5 实战:数组扁平化(Array Flatten)
数组扁平化是递归的经典应用场景,也是前端面试高频手写题。
需求描述
javascript
const arr = [1, [2, [3, 4, [5, 6]]]];
// 期望输出: [1, 2, 3, 4, 5, 6]
将一个嵌套多层的数组"拍平"为一维数组。
方案一:ES6 原生方法
javascript
const arr = [1, [2, [3, 4, [5, 6]]]];
// flat() 默认只扁平化一层
console.log(arr.flat()); // [1, 2, [3, 4, [5, 6]]]
// 传入 Infinity 表示扁平化所有层
console.log(arr.flat(Infinity)); // [1, 2, 3, 4, 5, 6]
Array.prototype.flat(depth) 是 ES2019 引入的方法:
depth参数指定展开的深度,默认值为1- 传入
Infinity可将任意深度的嵌套数组完全扁平化 - 它和
map/reduce一样,属于函数式编程风格的数组方法
方案二:手写递归实现
javascript
const flatten = (arr) => {
let result = [];
arr.forEach((item) => {
if (Array.isArray(item)) {
// 当前元素是数组 → 递归扁平化并合并结果
result = result.concat(flatten(item));
} else {
// 当前元素不是数组 → 直接加入结果
result.push(item);
}
});
return result;
};
const arr = [1, [2, [3, 4, [5, 6]]]];
console.log(flatten(arr)); // [1, 2, 3, 4, 5, 6]
代码逐行解析
ini
flatten([1, [2, [3, 4, [5, 6]]]])
│
├─ item=1, 不是数组 → result=[1]
├─ item=[2, [3, 4, [5,6]]], 是数组 → 递归调用 flatten([2, [3, 4, [5,6]]])
│ │
│ ├─ item=2, 不是数组 → result=[2]
│ └─ item=[3, 4, [5,6]], 是数组 → 递归调用 flatten([3, 4, [5,6]])
│ │
│ ├─ item=3, 不是数组 → result=[3]
│ ├─ item=4, 不是数组 → result=[3, 4]
│ └─ item=[5, 6], 是数组 → 递归调用 flatten([5, 6])
│ │
│ ├─ item=5, 不是数组 → result=[5]
│ └─ item=6, 不是数组 → result=[5, 6]
│ → 返回 [5, 6]
│ → concat → [3, 4, 5, 6]
│ → concat → [2, 3, 4, 5, 6]
└─ concat → [1, 2, 3, 4, 5, 6]
💡 性能优化提示 :上面的实现使用了
concat(每次创建一个新数组),如果数组非常深或元素非常多,可以用尾递归优化或改用迭代+栈的方式来避免栈溢出。
二、排序算法全景概览
在深入快速排序之前,我们先简单回顾三种基础排序算法,理解它们的共性 O(n²) 复杂度,才能体会快速排序为什么叫"快"排。
2.1 冒泡排序(Bubble Sort)
核心思想:相邻元素两两比较,较大的向后"冒泡",每轮确定一个最大值。
css
[5, 3, 8, 1] → [3, 5, 1, 8] → [3, 1, 5, 8] → [1, 3, 5, 8]
- 时间复杂度:O(n²)
- 空间复杂度:O(1)
- 稳定排序 ✅(相等元素不交换)
2.2 选择排序(Selection Sort)
核心思想:每轮从未排序部分选出最小的元素,放到已排序部分的末尾。
css
[5, 3, 8, 1]
→ 找到最小 1,与第一位交换:[1, 3, 8, 5]
→ 找到最小 3(从第二位开始),已在正确位置
→ 找到最小 5,与第三位交换:[1, 3, 5, 8]
- 时间复杂度:O(n²)
- 空间复杂度:O(1)
- 不稳定排序 ❌(跨越式交换可能打乱相等元素的相对顺序)
2.3 插入排序(Insertion Sort)
核心思想:从第二个元素开始,逐个向前比较并插入到正确位置,类似打扑克牌时整理手牌。
css
[5, 3, 8, 1]
→ 3 与前面的 5 比较,插入前面:[3, 5, 8, 1]
→ 8 与前面比较,已在正确位置:[3, 5, 8, 1]
→ 1 与前面比较,插入最前面:[1, 3, 5, 8]
- 时间复杂度:O(n²)(最好情况 O(n),即数组已近有序)
- 空间复杂度:O(1)
- 稳定排序 ✅
2.4 三种算法对比
| 排序算法 | 最好 | 平均 | 最坏 | 稳定性 | 核心操作 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(n²) | 稳定 | 相邻两两交换 |
| 选择排序 | O(n²) | O(n²) | O(n²) | 不稳定 | 选最小放最前 |
| 插入排序 | O(n) | O(n²) | O(n²) | 稳定 | 向前面找位置 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | 不稳定 | 分区+递归 |
从 O(n²) 到 O(n log n) 的提升是巨大的------当 n = 1,000,000 时,n² 是 1 万亿,而 n log n 仅约 2000 万,差了 5 个数量级。
三、快速排序:分治策略的典范
3.1 核心思想 ------ 分而治之(Divide and Conquer)
快速排序由 Tony Hoare 于 1960 年提出,其核心是分治策略:
scss
分治三步走:
┌───────────────┐ ┌─────────────────┐ ┌────────────────┐
│ 分(Divide) │ → │ 治(Conquer) │ → │ 合(Combine) │
│ 将大问题拆成 │ │ 递归解决子问题 │ │ 合并子问题结果 │
│ 独立小问题 │ │ │ │ │
└───────────────┘ └─────────────────┘ └────────────────┘
在快速排序中的具体体现:
markdown
1. 分:选取一个基准值(pivot),将数组分成"左小右大"两个子数组
2. 治:对左右两个子数组分别递归进行快速排序
3. 合:由于是原地交换,左子数组 + pivot + 右子数组 天然有序,无需额外合并
快速排序之所以快,靠的是两个关键操作:
- pivot 基准值 :每次将问题规模对半缩小(log n 层递归)
- 原地交换(双指针) :每层分区只需 O(n) 的扫描时间
两者相乘 → O(n log n)。
3.2 分区函数(Partition)------ 快速排序的灵魂
分区是整个算法最核心也最容易写错的部分。让我们逐行深入分析。
less
pivot=3
↓
初始: [3, 2, 1, 5, 6, 4]
↑ ↑
i(left) j(right)
Step 1: j 从右向左扫描,找比 pivot 小的 → j 停在 1
[3, 2, 1, 5, 6, 4]
↑ ↑
i j
Step 2: i 从左向右扫描,找比 pivot 大的 → i 就是 3 自己
注意:nums[i] <= pivot 所以 i 会一直向右走
[3, 2, 1, 5, 6, 4]
↑
i,j 相遇!
Step 3: i 和 j 相遇,循环结束
将 pivot 放到相遇位置:
[1, 2, 3, 5, 6, 4]
↑
pivot 归位!
左边 [1, 2] < 3,右边 [5, 6, 4] > 3 ✅
完整代码逐行解析
javascript
// 快速排序 - 分区函数
function partition(nums, left, right) {
let i = left; // 左指针,从区间最左开始
let j = right; // 右指针,从区间最右开始
let pivot = nums[left]; // 选取最左元素作为基准值
while (i < j) { // 当左右指针未相遇时继续
// 右侧找比基准值小的元素
while (i < j && nums[j] >= pivot) {
j--; // 比基准大或相等 → 继续向左走
}
// 左侧找比基准值大的元素
while (i < j && nums[i] <= pivot) {
i++; // 比基准小或相等 → 继续向右走
}
// 交换找到的两个元素(左边大 + 右边小 → 互换)
if (i < j) {
[nums[i], nums[j]] = [nums[j], nums[i]];
}
}
// 将基准值放到最终的正确位置
[nums[left], nums[i]] = [nums[i], nums[left]];
return i; // 返回基准值的最终索引
}
function quickSort(nums, left = 0, right = nums.length - 1) {
if (left >= right) return; // 区间长度为 0 或 1 → 已有序,直接返回
let pivot = partition(nums, left, right); // 分区,得到基准值位置
quickSort(nums, left, pivot - 1); // 递归排序左半部分
quickSort(nums, pivot + 1, right); // 递归排序右半部分
}
// 测试
const arr = [3, 2, 1, 5, 6, 4];
quickSort(arr);
console.log(arr); // [1, 2, 3, 4, 5, 6]
分区函数中极易踩的坑 🔥
⚠️ 坑 1 --- 为什么先移动右指针 j,再移动左指针 i?
因为选择的是 nums[left] 作为基准值。如果先移动 i,当数组已经有序时(如 [1, 2, 3]),i 会一路走到 right,导致 pivot 被放错位置。先移动 j 保证 i 和 j 相遇时的位置一定是 ≤ pivot 的(因为 j 停在小的元素上),交换才正确。
⚠️ 坑 2 --- 内层 while 必须加
i < j条件
没有 i < j 的话,指针会越界。例如数组中所有元素都比基准值大,j 会一直减到 left 左边。
⚠️ 坑 3 --- 条件是
>=和<=,不是>和<
用严格不等号会导致遇到与 pivot 相等的元素时死循环(指针不移动)。
3.3 完整排序流程可视化
以 [3, 2, 1, 5, 6, 4] 为例,完整追踪一次快排的递归树:
scss
quickSort([3, 2, 1, 5, 6, 4], 0, 5)
│
├─ partition → pivot 归位到索引 2,数组变为 [1, 2, 3, 5, 6, 4]
│
├─ 左递归 quickSort([1, 2, 3, 5, 6, 4], 0, 1)
│ │
│ └─ partition → pivot 归位到索引 0,数组变为 [1, 2, 3, 5, 6, 4]
│ ├─ 左递归 quickSort(..., 0, -1) → left >= right → return
│ └─ 右递归 quickSort(..., 1, 1) → left >= right → return
│
└─ 右递归 quickSort([1, 2, 3, 5, 6, 4], 3, 5)
│
└─ partition → pivot 归位到索引 3,数组变为 [1, 2, 3, 4, 5, 6]
├─ 左递归 quickSort(..., 3, 2) → left >= right → return
└─ 右递归 quickSort(..., 4, 5)
│
└─ partition → pivot 归位到索引 4,数组变为 [1, 2, 3, 4, 5, 6]
├─ 左递归 quickSort(..., 4, 3) → return
└─ 右递归 quickSort(..., 5, 5) → return
最终结果: [1, 2, 3, 4, 5, 6] ✅
3.4 递归 vs 分治 ------ 这对"孪生兄弟"到底什么关系?
这是一个非常经典的问题,学习算法时几乎人人都困惑过:
| 维度 | 递归(Recursion) | 分治(Divide & Conquer) |
|---|---|---|
| 本质 | 一种编程实现方式(函数调用自身) | 一种算法设计思想(分而治之的策略) |
| 核心步骤 | 递(缩小问题)+ 归(合并结果) | 分(拆成子问题)+ 治(递归解决)+ 合(合并结果) |
| 关系 | 是分治的实现手段 | 绝大多数情况下用递归来实现 |
| 类比 | "锤子" --- 工具 | "造房子" --- 方法论 |
💡 一句话总结 :递归是代码实现方式,分治是算法思想;分治绝大多数用递归实现,但不是所有递归都是分治。
例如:
- 递归求
sum(n)→ 有递归,但不算严格的分治(没有显式的"合"操作,结果直接累加) - 快速排序 → 既有递归 (函数调用自身),又体现了完整的分治思想(分+治+合三步齐全)
3.5 快速排序的性能分析
为什么快?------ O(n log n) 的奥秘
scss
快排的性能 = 分区层数 × 每层工作量
理想情况(每次 pivot 都恰好是中位数):
┌─────────────────────────────────────┐
│ 分区层数:log₂n 层(每层问题减半) │
│ 每层工作量:n(扫描整个区间一次) │
│ 总复杂度:O(n log n) │
└─────────────────────────────────────┘
最坏情况(每次 pivot 都是最小/最大值,如已有序数组):
┌─────────────────────────────────────┐
│ 分区层数:n 层(每次只减少一个元素)│
│ 每层工作量:n │
│ 总复杂度:O(n²) ← 退化! │
└─────────────────────────────────────┘
💡 规避最坏情况:实际工程中常用「随机选取 pivot」或「三数取中法」(选 left / mid / right 的中位数)来避免退化为 O(n²)。
为什么不稳定?
快速排序是不稳定排序 ,原因是分区过程中的跨越式交换:
ini
原始: [5₁, 3, 5₂, 1] (5₁ 和 5₂ 的相对位置是 5₁ 在 5₂ 前面)
↑
pivot=5₁
分区后: [1, 3, 5₂, 5₁] (5₁ 被换到了后面,相对位置颠倒了!)
相等元素(两个 5)的相对位置在排序后发生了变化,这完美体现了快速排序的不稳定性。
四、知识体系总结
4.1 思维导图
scss
┌─ 冒泡排序(相邻比较,O(n²))
│
┌─ 基础排序 ─┼─ 选择排序(选最小放最前,O(n²))
│ │
│ └─ 插入排序(向前找位置,O(n²))
│
排序算法 ───┤ ┌─ 核心:分治策略(分+治+合)
│ │
│ ├─ 关键:pivot基准 + 双指针分区
└─ 快速排序 ─┤
├─ 复杂度:平均 O(n log n),最坏 O(n²)
│
└─ 特点:原地排序、不稳定排序
┌─ 核心三要素:递归公式 + 退出条件 + 相同子问题
│
递归 ──────┼─ 执行模型:"递"(压栈)→ "归"(弹栈)
│
└─ 经典应用:数组扁平化(flatten)、树遍历、深拷贝
4.2 核心要点回顾
| 主题 | 核心要点 |
|---|---|
| 递归三要素 | 递归公式 + 退出条件 + 相同子问题 |
| 递归执行 | 递(压栈缩小问题)→ 归(弹栈合并结果) |
| 数组扁平化 | 遍历 → 判断是否为数组 → 是则递归 / 否则 push |
| 快排核心 | 选 pivot → 双指针分区(左小右大)→ 递归左右 |
| 快排复杂度 | 平均 O(n log n),最坏 O(n²)(可通过随机 pivot 规避) |
| 快排稳定性 | 不稳定(相等元素相对位置可能颠倒) |
| 递归 vs 分治 | 递归是代码实现方式(工具),分治是算法思想(方法论) |
4.3 延伸思考
-
快速排序 vs 归并排序:都是分治思想的代表,归并排序是稳定排序但需要 O(n) 额外空间,快速排序不稳定但是原地排序。什么场景选哪个?
-
尾递归优化 :本文的
flatten和quickSort都不是尾递归。如何改写为尾递归形式?尾递归在 JavaScript 引擎中的支持情况如何? -
数组扁平化的其他实现 :除了递归,还可以用
reduce+ 递归、Generator、栈迭代等方式实现 flatten,各自的优缺点是什么? -
实际应用场景:
- 递归:Vue/React 的虚拟 DOM diff、路由嵌套渲染、文件目录遍历
- 快速排序:数据库索引构建、V8 引擎中
Array.prototype.sort的实现(小数组用插入排序,大数组用快排)
🎯 写在最后
从最基础的递归求和,到数组扁平化的手写实现,再到快速排序中精巧的分区函数设计------这条学习路径让我们看到:
好的算法设计,往往只需要改动一个小细节,就能将时间复杂度从 O(n²) 优化到 O(n log n)。
理解算法不是为了默写,而是为了在遇到实际问题时,能够从这些经典的「思维模板」中汲取灵感。当下次你面对一个带层级的数据结构、一个需要分而治之的业务场景时,希望递归与分治的思想能自然地浮现在你的脑海中。
📝 本文为个人学习日志,如有疏漏欢迎讨论交流。代码均可直接复制运行验证。
🕐 2026年6月 整理记录