快速排序完全指南:从冒泡到 O(nlogn) 的分治进化

2 阅读5分钟

面试官:"手写一个快排。" 你:"好的。" 然后沉默了。如果你也曾在面试中被问到快排,或者是一直以来对快排一知半解,看这篇文章就对啦。

快速排序(Quick Sort)是面试出场率最高的排序算法,没有之一。它基于分治策略,平均时间复杂度 O(nlogn),是大多数语言内置排序的底层实现。

本文带你从三种 O(n^2) 排序出发,理解快排为什么快、怎么实现、以及它"不稳定"的本质原因。


一、排序算法的进化:从 O(n^2) 到 O(nlogn)

在讲快排之前,先回顾三种基础排序——它们都是 O(n^2) 级别:

算法核心思想时间复杂度
冒泡排序两两相邻位置比较,大的往后冒O(n^2)
选择排序每轮选择最小元素,放到当前位置O(n^2)
插入排序从当前位置开始,与前面元素比较,找到合适位置插入O(n^2)

这三种算法的共同问题:每轮只确定一个元素的最终位置,效率低下。

快排的突破在于:每轮确定一个基准值的最终位置,同时把数组分成两部分,然后递归处理。这就是分治策略


二、快排核心思想:分而治之

2.1 三步走

分(Divide)→ 选一个基准值(pivot),把数组分成两部分
治(Conquer)→ 分别对左右两部分排序
合(Combine)→ 不需要额外合并,原地排序已完成

2.2 一轮操作的具体过程

假设数组 [2, 4, 1, 0, 3, 5],选第一个元素 2 作为基准值:

初始:  [2, 4, 1, 0, 3, 5]pivot = 2

第一步:从右往左找第一个比 2 小的 → 0
第二步:从左往右找第一个比 2 大的 → 4
第三步:交换 4 和 0

        [2, 0, 1, 4, 3, 5]
              ↑     ↑
             i=1   j=3

第四步:继续,从右找比 2 小的 → 1(但 i 已经 >= j,停止)
第五步:将基准值交换到分界线位置

        [1, 0, 2, 4, 3, 5]
             ↑
           pivot 最终位置

结果:pivot=2 到了正确位置,左边 [1, 0] 都比它小,右边 [4, 3, 5] 都比它大

一轮之后,基准值的最终位置就确定了。然后对左右两部分分别递归执行同样的操作。


三、递归 vs 分治:别再搞混了

很多人把递归和分治当成一回事,其实它们是不同层面的概念:

维度递归分治
本质代码实现方式算法设计思想
定义函数自身调用自身把大问题拆成独立的小问题
方向自顶向下,缩小问题规模分 → 治 → 合 三步
关系分治绝大多数用递归实现递归不一定是分治

快排是分治思想 + 递归实现的经典案例。


四、完整代码实现

4.1 partition 函数:治(分区)

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; // 返回基准值的最终位置
}

逐行解析

代码作用
let i = left, j = right初始化左右双指针
nums[j] >= nums[left]右侧比基准值大的,留在右边,j 继续左移
nums[i] <= nums[left]左侧比基准值小的,留在左边,i 继续右移
[nums[i], nums[j]] = [nums[j], nums[i]]找到一对"该换的",交换
[nums[i], nums[left]] = [nums[left], nums[i]]最后把基准值放到分界线
return i返回基准值位置,作为递归分界

4.2 quickSort 函数:递归调度

function quickSort(nums, left, right) {
    if (left >= right) {
        return; // 递归终止条件:子数组长度为 0 或 1
    }
    // 分区:获取基准值位置
    let pivot = partition(nums, left, right);
    // 递归:分别处理左右子数组
    quickSort(nums, left, pivot - 1);
    quickSort(nums, pivot + 1, right);
}

4.3 调用

const arr = [2, 4, 1, 0, 3, 5];
quickSort(arr, 0, arr.length - 1);
console.log(arr); // [0, 1, 2, 3, 4, 5]

五、快排为什么快?

5.1 两个关键因素

因素说明复杂度贡献
pivot 基准值分区每轮将问题规模减半(类似二分)O(logn) 层递归深度
原地交换(双指针)不需要额外数组,直接在原数组上交换O(n) 每层操作

两者结合:O(logn) 层 × O(n) 每层 = O(nlogn)

5.2 对比其他排序

算法时间复杂度(平均)空间复杂度是否稳定
冒泡排序O(n^2)O(1)稳定
选择排序O(n^2)O(1)不稳定
插入排序O(n^2)O(1)稳定
归并排序O(nlogn)O(n)稳定
快速排序O(nlogn)O(logn)不稳定

快排在时间复杂度和空间复杂度上都做到了优秀平衡。


六、快排为什么不稳定?

稳定排序:相等元素的相对位置在排序前后保持不变。

快排是不稳定排序。来看一个例子:

原始数组:[3a, 2, 3b, 1]  (3a 和 3b 是两个相等的元素)

第一轮:pivot = 3a
- 右指针找到 1(比 3a 小)
- 左指针找到 3b(比 3a 大?不,3b == 3a,不满足 > 条件,i 不移动)
- 但 j 继续左移,最终 3a 和 1 交换

结果:[1, 2, 3b, 3a]

3a 和 3b 的相对位置颠倒了 → 不稳定

本质原因:分区过程中,相等元素可能被交换到基准值的另一侧,导致相对位置改变。


七、总结

快速排序的核心知识点:

  • 快的原因:pivot 基准值分区(O(logn)) + 原地交换双指针(O(n))= O(nlogn)
  • 不稳定的原因:分区过程中相等元素的相对位置会被打乱
  • 核心策略:分治——分、治、合三步
  • 实现方式:递归——函数自身调用,自顶向下缩小问题规模

面试写快排,记住口诀:选基准、双指针、左右扫、交换、递归


本文代码基于原生 JavaScript,兼容所有现代浏览器和 Node.js。