快速排序与递归思维:从分治策略到数组扁平化------面试必考算法全解析
本文深入解析快速排序的分治策略与双指针原地交换原理,对比递归与迭代的本质差异,并通过数组扁平化实战巩固递归思维。一文掌握前端面试中最常考的排序算法与递归技巧。
前言
排序算法是计算机科学的基石,而快速排序(Quick Sort)是其中最经典、最高效的算法之一。从面试考场到生产环境,从数组排序到 DOM 树遍历,快速排序的分治思想无处不在。
本文将基于实际代码,系统讲解快速排序的核心原理、递归与迭代的对比,以及递归思维在前端开发中的实战应用。
一、排序算法概览:从 O(n²) 到 O(n log n)
1.1 常见排序算法对比
| 算法 | 时间复杂度 | 空间复杂度 | 稳定性 | 核心思想 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(1) | ✅ 稳定 | 两两相邻比较,交换位置 |
| 选择排序 | O(n²) | O(1) | ❌ 不稳定 | 选择最小元素放到当前位置 |
| 插入排序 | O(n²) | O(1) | ✅ 稳定 | 与前面元素比较,找到合适位置插入 |
| 快速排序 | O(n log n) | O(log n) | ❌ 不稳定 | 分治策略,基准值分区 |
| 归并排序 | O(n log n) | O(n) | ✅ 稳定 | 分治策略,合并有序数组 |
💡 为什么快排是面试高频考点? 因为它综合考察了分治思想、双指针技巧、递归实现,且在实际应用中性能优异。
1.2 排序算法的稳定性
稳定性定义:相等元素的相对位置在排序后是否保持不变。
css
原始数组:[3(a), 1, 3(b), 2]
↑ ↑
两个相等的 3,用下标区分
稳定排序后:[1, 2, 3(a), 3(b)] ← 3(a) 仍在 3(b) 前面 ✅
不稳定排序后:[1, 2, 3(b), 3(a)] ← 3(a) 和 3(b) 位置颠倒了 ❌
快排为什么不稳定?
快排的分区过程中,相等元素可能跨越基准值交换位置,导致相对顺序改变。
二、快速排序:分治策略的经典应用
2.1 核心思想
快速排序基于 分治策略(Divide and Conquer):
scss
分(Divide):选择基准值 pivot,将数组分为左右两部分
┌─────────────────────────────────────┐
│ [小于pivot] pivot [大于pivot] │
└─────────────────────────────────────┘
治(Conquer):递归地对左右两部分分别排序
quickSort(左半部分)
quickSort(右半部分)
合(Combine):原地排序,无需额外合并步骤
2.2 分治 vs 递归
| 概念 | 定义 | 关系 |
|---|---|---|
| 递归 | 函数自身调用自身 | 代码实现方式 |
| 分治 | 把大问题分解为互相独立的小问题 | 算法思想 |
💡 关键洞察:分治是思想,递归是实现方式。绝大多数分治算法用递归实现,但也可以用迭代(栈模拟)实现。
2.3 快排的核心:partition 分区函数
javascript
/**
* 分区函数:将数组分为 [小于pivot] 和 [大于pivot] 两部分
* @param {number[]} nums - 待排序数组
* @param {number} left - 左边界索引
* @param {number} right - 右边界索引
* @returns {number} - 基准值的最终位置
*/
function partition(nums, left, right) {
let i = left, j = right; // 左右双指针
while (i < j) {
// 右指针向左移动:找第一个小于基准值的元素
while (i < j && nums[j] >= nums[left]) {
j--;
}
// 左指针向右移动:找第一个大于基准值的元素
while (i < j && nums[i] <= nums[left]) {
i++;
}
// 交换左右指针指向的元素
[nums[i], nums[j]] = [nums[j], nums[i]];
}
// 将基准值交换到分界位置
[nums[i], nums[left]] = [nums[left], nums[i]];
return i; // 返回基准值的最终位置
}
2.4 partition 执行流程图解
css
初始数组:[2, 4, 1, 0, 3, 5]
↑
left=0 (基准值=2)
第1步:右指针 j 向左找小于 2 的元素
[2, 4, 1, 0, 3, 5]
↑
j=3 (nums[3]=0 < 2) ✓
第2步:左指针 i 向右找大于 2 的元素
[2, 4, 1, 0, 3, 5]
↑
i=1 (nums[1]=4 > 2) ✓
第3步:交换 i 和 j 指向的元素
[2, 0, 1, 4, 3, 5]
↑ ↑
i j
第4步:继续移动指针
i=2 (nums[2]=1 < 2) → i++ → i=3
j=3 (nums[3]=4 > 2) → j-- → j=2
i=3, j=2 → i >= j,循环结束
第5步:将基准值交换到分界位置
[1, 0, 2, 4, 3, 5]
↑
pivot=2 的最终位置
结果:左半部分 [1, 0] 都 < 2,右半部分 [4, 3, 5] 都 > 2
2.5 完整快速排序实现
javascript
function partition(nums, left, right) {
let i = left, j = right;
while (i < j) {
// 右指针:找小于基准值的元素
while (i < j && nums[j] >= nums[left]) {
j--;
}
// 左指针:找大于基准值的元素
while (i < j && nums[i] <= nums[left]) {
i++;
}
// 交换
[nums[i], nums[j]] = [nums[j], nums[i]];
}
// 基准值归位
[nums[i], nums[left]] = [nums[left], nums[i]];
return i;
}
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);
}
// 测试
const arr = [2, 4, 1, 0, 3, 5];
quickSort(arr, 0, arr.length - 1);
console.log(arr); // [0, 1, 2, 3, 4, 5]
2.6 快排为什么快?
| 优化点 | 说明 | 效果 |
|---|---|---|
| 基准值选择 | 每次分区确定一个元素的最终位置 | O(log n) 层递归 |
| 原地交换 | 不创建新数组,直接交换元素 | O(1) 额外空间 |
| 双指针 | 左右同时扫描,减少遍历次数 | 一次遍历完成分区 |
时间复杂度分析:
scss
最好情况:每次基准值都是中位数
层数:log n
每层工作量:O(n)
总复杂度:O(n log n)
最坏情况:数组已有序,每次基准值都是最小/最大值
层数:n
每层工作量:O(n)
总复杂度:O(n²)
平均情况:O(n log n)
三、递归思维:从求和到数组扁平化
3.1 递归的本质
递归是函数自身调用自身的编程技巧,包含两个核心要素:
scss
递归公式:f(n) = f(n-1) + n (大问题分解为小问题)
退出条件:f(1) = 1 (防止无限递归)
递归的执行过程:
ini
sum(5)
= sum(4) + 5
= sum(3) + 4
= sum(2) + 3
= sum(1) + 2
= 1 ← 退出条件
= 1 + 2 = 3
= 3 + 3 = 6
= 6 + 4 = 10
= 10 + 5 = 15
3.2 迭代 vs 递归:求 1+2+...+n
迭代实现
javascript
function sumIterative(n) {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
return total;
}
// 执行:循环累加,直观易懂
// sumIterative(5) → 0+1+2+3+4+5 = 15
递归实现
javascript
function sumRecursive(n) {
// 退出条件
if (n === 1) {
return 1;
}
// 递归公式
return sumRecursive(n - 1) + n;
}
// 执行:自顶向下分解,再自底向上合并
// sumRecursive(5) → sum(4)+5 → sum(3)+4 → sum(2)+3 → sum(1)+2 → 1
对比:
| 维度 | 迭代 | 递归 |
|---|---|---|
| 代码风格 | 命令式,显式循环 | 声明式,接近数学定义 |
| 空间复杂度 | O(1) | O(n)(调用栈) |
| 适用场景 | 简单累加 | 树形结构、分治算法 |
| 可读性 | 直观 | 抽象,需理解递归思想 |
3.3 递归实战:数组扁平化
ES6 内置方法
javascript
const arr = [1, [2, [3, 4, [5, 6]]]];
// flat 方法:默认扁平化一层
console.log(arr.flat()); // [1, 2, [3, 4, [5, 6]]]
// 传入深度参数
console.log(arr.flat(2)); // [1, 2, 3, 4, [5, 6]]
// Infinity:扁平化所有层级
console.log(arr.flat(Infinity)); // [1, 2, 3, 4, 5, 6]
递归实现
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, 不是数组 → result=[1, 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]
。 │ → return [5, 6]
。 └── result=[3, 4].concat([5, 6]) = [3, 4, 5, 6]
。 → return [3, 4, 5, 6]
└── result=[1, 2].concat([3, 4, 5, 6]) = [1, 2, 3, 4, 5, 6]
→ return [1, 2, 3, 4, 5, 6]
递归三要素:
| 要素 | 数组扁平化中的体现 |
|---|---|
| 递归公式 | flatten(arr) = flatten(子数组) + 非数组元素 |
| 退出条件 | 元素不是数组,直接 push |
| 相同子问题 | 每个子数组都是同样的扁平化问题 |
四、面试高频题
Q1:快排的时间复杂度是多少?最坏情况是什么?
答:平均时间复杂度 O(n log n),最坏情况 O(n²)。最坏情况发生在数组已有序时,每次基准值都是最小或最大值,导致分区极度不平衡。
优化方案:随机选择基准值,或三数取中法(取左中右三个数的中位数作为基准值)。
Q2:快排为什么不稳定?
答 :分区过程中,相等元素可能跨越基准值交换位置。例如 [3(a), 2, 3(b)],如果 3(b) 被交换到基准值左边,最终顺序可能变为 [2, 3(b), 3(a)],相等元素的相对位置被颠倒。
Q3:递归和迭代的区别?什么时候用递归?
答:递归是函数自身调用,代码简洁但消耗栈空间;迭代是显式循环,空间效率高但代码可能更复杂。处理树形结构、分治算法时,递归更自然直观。
Q4:如何实现一个稳定的快速排序?
答:标准快排不稳定,但可以通过以下方式改造:
- 使用额外数组存储分区结果(牺牲空间换稳定)
- 三路快排:将等于基准值的元素放在中间,不交换相等元素
五、知识图谱
scss
快速排序与递归思维
├── 排序算法概览
│ ├── 冒泡/选择/插入排序:O(n²)
│ ├── 快速/归并排序:O(n log n)
│ └── 稳定性概念
├── 快速排序
│ ├── 分治策略(Divide + Conquer + Combine)
│ ├── 分治 vs 递归
│ ├── partition 分区函数
│ │ ├── 双指针扫描
│ │ ├── 原地交换
│ │ └── 基准值归位
│ ├── 完整实现
│ └── 时间复杂度分析
├── 递归思维
│ ├── 递归三要素
│ │ ├── 递归公式
│ │ ├── 退出条件
│ │ └── 相同子问题
│ ├── 迭代 vs 递归
│ └── 递归执行过程
└── 数组扁平化实战
├── ES6 flat 方法
└── 递归实现
├── 判断 Array.isArray
├── concat 合并
└── 递归调用
六、总结
本文系统梳理了快速排序与递归思维的核心知识点:
- 排序算法从 O(n²) 到 O(n log n) 的进化,体现了算法设计的重要性。
- 快速排序基于分治策略,通过 partition 分区函数和双指针原地交换实现高效排序。
- 分治是思想,递归是实现方式。理解这一区别,才能灵活运用。
- 递归三要素(递归公式、退出条件、相同子问题)是写出正确递归代码的关键。
- 数组扁平化是递归思维的典型应用,通过判断元素类型决定是否递归。
- 快排的不稳定性源于分区过程中的元素交换,理解这一点有助于选择合适的排序算法。
🚀 学习建议:先理解 partition 的分区原理(画图解),再手写完整快排代码,最后用递归实现数组扁平化巩固思维。面试时,能清晰讲解 partition 的执行过程是加分项。
参考资源
- MDN - Array.prototype.flat
- 《算法导论》- 快速排序章节
- VisuAlgo - 排序算法可视化
📌 标签:#快速排序 #递归 #分治 #算法 #面试题 #数组扁平化 #双指针 #时间复杂度
💬 互动:你面试时被问过哪些排序算法的问题?欢迎在评论区分享!