从 O(n²) 到 O(nlogn):一文读懂快速排序的“快”与“妙”

从 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)

快速排序的核心思想可以概括为三个步骤:

  1. 选择基准值(pivot) :从数组中选取一个元素作为基准。
  2. 分区(partition) :将数组重新排列,使得 所有比基准值小的元素放在左边所有比基准值大的元素放在右边。基准值最终位于两个子数组的中间位置。
  3. 递归排序:对基准值左边的子数组和右边的子数组分别递归执行上述过程。
graph TD A[待排序数组] --> B[选择基准值 pivot] B --> C[分区操作 partition] C --> D[左子数组 < pivot] C --> E[右子数组 > pivot] D --> F[递归排序左子数组] E --> G[递归排序右子数组] F --> H[合并结果] G --> H H --> I[排序完成]

四、快速排序的代码实现

下面我们来看快速排序的完整 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 大。
graph LR A[2,4,1,0,3,5] --> B[pivot=2] B --> C[j从右找小于2的数: 找到0] B --> D[i从左找大于2的数: 找到4] C --> E[交换0和4] D --> E E --> F[2,0,1,4,3,5] F --> G[j继续找小于2的数: 找到1] F --> H[i继续找大于2的数: 遇到j停止] G --> I[交换基准值与1] H --> I I --> J[1,0,2,4,3,5]

一轮分区结束后 ,基准值 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)?

分治是一种算法设计思想 ,它的核心是 "分而治之" ,将一个大问题分解成若干个相互独立的小问题,分别解决,最后合并结果。

分治策略包含三个步骤:

  1. 分(Divide) :将原问题分解为若干个规模较小的子问题。
  2. 治(Conquer) :递归地解决每个子问题。当子问题足够小时,直接求解。
  3. 合(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)节省空间

与归并排序等需要额外数组空间的算法不同,快速排序的 分区操作在原数组上通过双指针交换完成,不需要额外的辅助数组。

  • 分区操作中,我们只使用了 ij 两个指针变量,空间复杂度为 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²)。通过 随机选择基准值三数取中法 可以有效避免最坏情况。


十、总结

通过本文的深入剖析,我们对快速排序有了全面的理解:

  1. 从 O(n²) 到 O(nlogn):快速排序通过分治策略将排序效率提升了一个量级,是算法优化思想的典范。

  2. 核心机制:基准值(pivot)+ 双指针分区 + 递归排序,三者缺一不可。

  3. 原地排序:快速排序通过双指针交换实现了原地分区,节省了额外空间,但也因此牺牲了稳定性。

  4. 分治与递归:分治是算法思想,递归是实现工具,快速排序用递归完美诠释了分治策略。

  5. 效率与权衡:快速排序平均 O(nlogn) 的效率使其成为最广泛使用的排序算法之一,但最坏情况 O(n²) 和稳定性缺失也提醒我们,没有一种算法是万能的,需要根据具体场景选择最合适的算法。

graph TD A[快速排序] --> B[分治策略] A --> C[递归实现] B --> D[选择基准值 pivot] B --> E[分区操作 partition] B --> F[递归排序子数组] C --> G[自顶向下] C --> H[缩小问题规模] D --> I[确定元素最终位置] E --> J[双指针原地交换] J --> K[O(n) 时间复杂度] I --> L[O(logn) 递归深度] K --> M[整体 O(nlogn)] L --> M

最后的话:快速排序不仅是一个高效的排序工具,更是一种算法思维的体现。理解它的设计理念,能帮助我们在面对其他复杂问题时,也能想到用"分而治之"的策略来化解。希望本文能帮助你彻底掌握快速排序,在算法之路上更进一步!


如果你觉得这篇文章有帮助,欢迎点赞、收藏、转发,让更多人看到!也欢迎在评论区留言讨论,我们一起进步!

相关推荐
橘子星1 小时前
LLM 无状态架构实践:从原理到代码落地
前端·javascript·人工智能
To_OC2 小时前
手写快排次次翻车?别死背快排模板了,这才是面试官想听的底层逻辑
javascript·算法·排序算法
饼干哥哥3 小时前
Reddit VOC调研太慢?搭一个AI专家团队半小时洞察任何品类|以猫用饮水机为例
人工智能·算法·ai编程
风止何安啊3 小时前
网课倍速痛点解决:一套前端代码实现自由控速播放器
前端·javascript·node.js
地平线开发者4 小时前
Transformer模型部署之性能优化指南
算法
光影少年4 小时前
原生DOM操作在React 中的注意事项
前端·javascript·react.js
地平线开发者4 小时前
人在途中:从“编译失败”到“模型可落地”——CUDA 自定义算子
算法·自动驾驶
糖拌西瓜皮4 小时前
Node.js核心模块实战:文件、路径、HTTP与流处理
javascript·node.js
糖拌西瓜皮4 小时前
NestJS入门指南:Java开发者的Spring Boot体验
javascript·node.js