从 O(n²) 到 O(nlogn):一文读懂快速排序的"快"与"妙"
排序算法是每个程序员的必修课,而快速排序凭借其高效的分治策略,成为了最广泛使用的排序算法之一。本文将从最基础的排序算法说起,一步步带你理解快速排序的设计思想、实现细节与性能分析。
一、引言:为什么我们需要更好的排序算法?
在计算机科学中,排序是最基础也是最常见的问题之一。无论是数据库中的索引、搜索引擎的结果排序,还是日常开发中对列表数据的整理,排序算法都扮演着至关重要的角色。
想象一下,如果每次排序都需要花费 O(n²) 的时间,当数据量达到百万级别时,排序将变得极其缓慢。因此,设计更高效的排序算法一直是计算机科学家的核心追求之一。
二、基础排序算法:O(n²) 的简单实现
在正式进入快速排序之前,我们先来回顾三种经典的 O(n²) 排序算法。理解它们的局限性能帮助我们更好地欣赏快速排序的"快"。
2.1 冒泡排序(Bubble Sort)
冒泡排序的核心思想是:两两比较相邻元素,如果顺序错误就交换位置,每一轮都会将当前未排序部分的最大值"冒泡"到末尾。
javascript
function bubbleSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
}
时间复杂度:O(n²) --- 两层循环嵌套。
2.2 选择排序(Selection Sort)
选择排序的思想是:每次选择最小的元素放到当前位置。具体来说,第 i 轮从剩余未排序元素中找到最小值,与第 i 个位置交换。
javascript
function selectionSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
let minIndex = i;
for (let j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) minIndex = j;
}
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
}
return arr;
}
时间复杂度:O(n²) --- 同样需要两层循环。
2.3 插入排序(Insertion Sort)
插入排序的思路是:从当前位置开始,与前面的元素逐一比较,直到找到合适的位置并插入,类似于打扑克牌时整理手牌。
javascript
function insertionSort(arr) {
for (let i = 1; i < arr.length; i++) {
let current = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
return arr;
}
时间复杂度:O(n²) --- 最坏情况下每个元素都需要与前面所有元素比较。
2.4 小结
上述三种算法的时间复杂度均为 O(n²) ,当数据规模 n 较大时,运行时间会急剧增长。那么,有没有更快的排序算法呢?
答案是肯定的------快速排序(Quick Sort) ,它的平均时间复杂度仅为 O(nlogn) ,比 O(n²) 快了一个数量级。
三、快速排序:分治思想的典范
3.1 什么是快速排序?
快速排序(Quick Sort)是由英国计算机科学家 Tony Hoare 于 1960 年提出的一种排序算法。它基于 分治策略,运行高效(平均 O(nlogn)),是应用最广泛的排序算法之一。
分治策略 的核心是 "分而治之" :将一个大问题分解成若干个相互独立的小问题,分别解决,最后合并结果。
快排的"快",源于它巧妙地利用了 基准值(pivot) 来划分数组,使得每一轮都能确定一个元素的最终位置。
3.2 核心思想:基准值(pivot)
快速排序的核心思想可以概括为三个步骤:
- 选择基准值(pivot) :从数组中选取一个元素作为基准。
- 分区(partition) :将数组重新排列,使得 所有比基准值小的元素放在左边 ,所有比基准值大的元素放在右边。基准值最终位于两个子数组的中间位置。
- 递归排序:对基准值左边的子数组和右边的子数组分别递归执行上述过程。
四、快速排序的代码实现
下面我们来看快速排序的完整 JavaScript 实现。代码分为两个核心函数:partition(分区)和 quickSort(递归排序)。
4.1 分区函数(partition)
分区函数是快速排序的 灵魂。它的任务是将数组按照基准值划分为左右两部分,并返回基准值的最终位置。
javascript
// 治 ------ 分区函数,核心逻辑
function partition(nums, left, right) {
let i = left, j = right; // 两个指针,分别从左右两端向中间移动
// 检查一遍数组,将比基准值小的移到左边,大的移到右边
while (i < j) {
// 把数组的第一项作为基准值
// 注意:这里始终用 nums[left] 作为基准值
// 不开销新的空间,原地排序(in-place sort)
// 右侧指针向左移动,找到第一个比基准值小的元素
while (i < j && nums[j] >= nums[left]) {
j--; // 退出循环时,j 指向第一个比基准值小的元素
}
// 左侧指针向右移动,找到第一个比基准值大的元素
while (i < j && nums[i] <= nums[left]) {
i++; // 退出循环时,i 指向第一个比基准值大的元素
}
// 元素交换:将左边的大数与右边的小数交换位置
[nums[i], nums[j]] = [nums[j], nums[i]];
}
// 将基准值交换到两个子数组的分界线位置
// 此时 i 指向分界线,nums[i] 是左子数组的最后一个元素
[nums[i], nums[left]] = [nums[left], nums[i]];
return i; // 返回基准值所在的位置,作为分界线的索引
}
代码解读:
- 双指针设计 :
i从左向右移动,j从右向左移动,二者相向而行。 - 基准值选择 :这里选择数组的第一个元素
nums[left]作为基准值。 - 原地排序:所有操作都在原数组上进行,不额外开辟数组空间,节省内存。
- 交换逻辑 :当
i找到比基准值大的元素、j找到比基准值小的元素时,二者交换。这样一轮下来,比基准值小的都在左边,比基准值大的都在右边。 - 分界线 :最后将基准值交换到
i所在的位置,此时基准值左边的元素都比它小,右边的元素都比它大。
4.2 递归主函数(quickSort)
主函数负责递归调用分区函数,对左右子数组分别排序。
javascript
function quickSort(nums, left, right) {
// 递归终止条件:当左边界大于等于右边界时,说明子数组长度 ≤ 1,无需排序
if (left >= right) {
return;
}
// pivot 基准值所在的位置(分界线索引)
let pivot = partition(nums, left, right);
// 递归排序左子数组 [left, pivot - 1]
quickSort(nums, left, pivot - 1);
// 递归排序右子数组 [pivot + 1, right]
quickSort(nums, pivot + 1, right);
}
4.3 测试运行
javascript
const arr = [2, 4, 1, 0, 3, 5];
quickSort(arr, 0, arr.length - 1);
console.log(arr); // 输出: [0, 1, 2, 3, 4, 5]
五、双指针交换过程详解
为了更直观地理解分区过程,我们以数组 [2, 4, 1, 0, 3, 5] 为例,逐步演示第一轮分区的完整过程。
初始状态:
ini
nums = [2, 4, 1, 0, 3, 5]
left = 0, right = 5
基准值 pivot = nums[0] = 2
i = 0, j = 5
步骤 1:移动右指针 j
- j 从右向左找第一个比 2 小的元素。
- nums5 = 5 ≥ 2 → j = 4
- nums4 = 3 ≥ 2 → j = 3
- nums3 = 0 < 2 → 停!j = 3
步骤 2:移动左指针 i
- i 从左向右找第一个比 2 大的元素。
- nums0 = 2 ≤ 2 → i = 1(注意:基准值本身也满足≤条件,所以会跳过)
- nums1 = 4 > 2 → 停!i = 1
步骤 3:交换 numsi 和 numsj
- 交换 nums1 和 nums3:
[2, 0, 1, 4, 3, 5] - 此时数组变为:
[2, 0, 1, 4, 3, 5]
步骤 4:继续移动指针
- i = 1, j = 3,继续循环
- j 向左移动:nums2 = 1 < 2 → 停!j = 2
- i 向右移动:nums1 = 0 ≤ 2 → i = 2,此时 i == j,退出外层循环
步骤 5:将基准值交换到分界线
- 交换 nums0 和 nums2:
[1, 0, 2, 4, 3, 5] - 返回 i = 2,基准值 2 位于索引 2,左侧 1, 0 都比 2 小,右侧 4, 3, 5 都比 2 大。
一轮分区结束后 ,基准值 2 已经处于最终位置,接下来只需要对 [1, 0] 和 [4, 3, 5] 分别递归排序即可。
六、递归与分治:一对好搭档
在阅读快速排序的代码时,我们同时用到了 递归 和 分治 两个概念。它们虽然紧密相关,但含义并不相同。
6.1 什么是递归(Recursion)?
递归是一种代码实现方式 ,指的是 函数自身调用自身。通过递归,我们可以把一个复杂问题转化为规模更小的同类问题。
javascript
// 递归的经典示例:计算阶乘
function factorial(n) {
if (n <= 1) return 1; // 递归终止条件
return n * factorial(n - 1); // 递归调用自身
}
递归的典型特征是 自顶向下,从大问题出发,逐步缩小问题的规模,直到达到基准条件(终止条件)后开始回溯。
在快速排序中,quickSort 函数不断调用自身来处理左右子数组,这就是典型的递归实现。
6.2 什么是分治(Divide and Conquer)?
分治是一种算法设计思想 ,它的核心是 "分而治之" ,将一个大问题分解成若干个相互独立的小问题,分别解决,最后合并结果。
分治策略包含三个步骤:
- 分(Divide) :将原问题分解为若干个规模较小的子问题。
- 治(Conquer) :递归地解决每个子问题。当子问题足够小时,直接求解。
- 合(Combine) :将子问题的解合并成原问题的解。
在快速排序中:
- 分:选择基准值,将数组划分为左右两个子数组。
- 治:递归排序左右子数组。
- 合:因为快速排序是原地排序,子数组合并后自然有序,不需要显式的合并操作。
6.3 递归与分治的区别
| 维度 | 递归(Recursion) | 分治(Divide and Conquer) |
|---|---|---|
| 本质 | 代码实现方式(函数自调用) | 算法设计思想(分而治之) |
| 方向 | 自顶向下,缩小问题规模 | 分 + 治 + 合 三个步骤 |
| 实现 | 通过函数自身调用实现 | 绝大多数用递归实现,但不必须 |
| 关注点 | 如何调用自身 | 如何分解、解决、合并 |
一句话总结:分治是一种思想,递归是一种工具。快速排序用递归实现了分治思想。
七、为什么快速排序"快"?
快速排序之所以快,可以从两个维度来理解:
7.1 基准值(pivot)带来的分层优势
快速排序每经过一轮分区,基准值就会被放置到最终位置。这意味着我们每轮都能"消灭"一个元素,同时将问题规模减半。
从递归树的角度来看:
- 理想情况下,每次分区都将数组分成两个大小相等的子数组。
- 递归树的深度为 O(logn) ,每层需要 O(n) 的时间进行分区。
- 总时间复杂度为 O(nlogn)。
css
[n]
/ \
[n/2] [n/2]
/ \ / \
[n/4] [n/4] [n/4] [n/4]
... ... ... ...
每层处理的总元素数为 n,树的高度为 logn,因此总复杂度为 O(nlogn)。
7.2 原地交换(in-place)节省空间
与归并排序等需要额外数组空间的算法不同,快速排序的 分区操作在原数组上通过双指针交换完成,不需要额外的辅助数组。
- 分区操作中,我们只使用了
i、j两个指针变量,空间复杂度为 O(1)。 - 递归调用产生的栈空间为 O(logn)(递归深度)。
综合来看:快速排序在平均情况下时间复杂度为 O(nlogn),空间复杂度为 O(logn),是时间和空间效率都很优秀的排序算法。
八、快速排序的稳定性分析
8.1 什么是稳定性?
排序算法的稳定性指的是:如果两个相等的元素在排序前后的相对位置保持不变,则称该排序算法是稳定的;否则是不稳定的。
8.2 为什么快速排序不稳定?
快速排序的不稳定性来自于 分区过程中的元素交换。当两个相等的元素分别在基准值的两侧时,交换操作可能会改变它们的相对顺序。
示例演示:
假设数组为 [3a, 1, 3b, 2],其中 3a 和 3b 是两个值相等但身份不同的元素(用 a、b 区分)。
选择基准值为 nums[0] = 3a,分区过程中,3a 和 3b 的相对顺序可能被交换操作改变。
ini
初始: [3a, 1, 3b, 2]
分区后可能变为: [1, 2, 3b, 3a]
可以看到,3a 和 3b 的相对顺序发生了颠倒,因此快速排序是 不稳定 的。
8.3 不稳定的影响
在实际开发中,如果我们需要对多个字段进行排序(例如先按年龄排序,再按姓名排序),不稳定的排序算法可能会打乱第一轮的排序结果。因此,在要求稳定性的场景下(如多关键字排序),我们通常会选择归并排序等稳定的算法。
快速排序的不稳定性并非缺陷,而是其为了实现高效原地排序而做出的权衡。完美体现了"快"的代价。
九、复杂度总结
| 维度 | 最好情况 | 最坏情况 | 平均情况 |
|---|---|---|---|
| 时间复杂度 | O(nlogn) | O(n²) | O(nlogn) |
| 空间复杂度 | O(logn) | O(n) | O(logn) |
| 稳定性 | ❌ 不稳定 | ❌ 不稳定 | ❌ 不稳定 |
最坏情况分析 :当数组已经有序(升序或降序)且每次选择第一个元素作为基准值时,分区极度不平衡,递归树退化为链表,时间复杂度退化为 O(n²)。通过 随机选择基准值 或 三数取中法 可以有效避免最坏情况。
十、总结
通过本文的深入剖析,我们对快速排序有了全面的理解:
-
从 O(n²) 到 O(nlogn):快速排序通过分治策略将排序效率提升了一个量级,是算法优化思想的典范。
-
核心机制:基准值(pivot)+ 双指针分区 + 递归排序,三者缺一不可。
-
原地排序:快速排序通过双指针交换实现了原地分区,节省了额外空间,但也因此牺牲了稳定性。
-
分治与递归:分治是算法思想,递归是实现工具,快速排序用递归完美诠释了分治策略。
-
效率与权衡:快速排序平均 O(nlogn) 的效率使其成为最广泛使用的排序算法之一,但最坏情况 O(n²) 和稳定性缺失也提醒我们,没有一种算法是万能的,需要根据具体场景选择最合适的算法。
最后的话:快速排序不仅是一个高效的排序工具,更是一种算法思维的体现。理解它的设计理念,能帮助我们在面对其他复杂问题时,也能想到用"分而治之"的策略来化解。希望本文能帮助你彻底掌握快速排序,在算法之路上更进一步!
如果你觉得这篇文章有帮助,欢迎点赞、收藏、转发,让更多人看到!也欢迎在评论区留言讨论,我们一起进步!