被面试官问到快排,我用5行代码让他当场点头

4 阅读5分钟

被面试官问到快排,我用5行代码让他当场点头

前言

面试官:「手写一个快速排序吧。」

我:「好的。」

然后我写出了这个:

function quickSort(nums, left, right) {
  if (left >= right) return;
  let pivot = partition(nums, left, right);
  quickSort(nums, left, pivot - 1);
  quickSort(nums, pivot + 1, right);
}

面试官看完点了点头:「可以,讲讲原理。」

如果你也曾在面试中被问到快排,或者一直对这个"最快的排序"一知半解,这篇文章就是为你写的。


一、为什么快排这么"快"?

先看一组数据:

排序算法平均时间复杂度最坏时间复杂度空间复杂度稳定性
冒泡排序O(n²)O(n²)O(1)✅ 稳定
选择排序O(n²)O(n²)O(1)❌ 不稳定
插入排序O(n²)O(n²)O(1)✅ 稳定
快速排序O(n log n)O(n²)O(log n)❌ 不稳定
归并排序O(n log n)O(n log n)O(n)✅ 稳定

快排的平均时间复杂度是 O(n log n),比 O(n²) 的算法快了一个量级。

当 n = 10000 时:

  • O(n²) = 1亿次操作
  • O(n log n) ≈ 13万次操作

差了将近1000倍!

但快排之所以"快",不只是因为时间复杂度低,更因为它的原地排序特性——不需要额外的数组空间,直接在原数组上交换元素。


二、分治思想:大事化小,小事化了

快排的核心思想是分治策略(Divide and Conquer)

分治 = 分 + 治 + 合

  1. :把大问题切成互相独立的小问题
  2. :递归解决每个小问题
  3. :合并小问题的结果(快排这步是隐式的,不需要额外操作)

举个例子:

原始数组:[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

第一轮 partition:
  pivot = 10(选第一个元素作为基准值)
  结果:[1, 9, 8, 7, 6, 5, 4, 3, 2, 10]
                     ↑ pivot就位

  左边:[1, 9, 8, 7, 6, 5, 4, 3, 2] → 继续递归
  右边:[10] → 已排好

第二轮递归左边:
  pivot = 1
  结果:[1, 9, 8, 7, 6, 5, 4, 3, 2]
  
  ...以此类推,直到所有元素就位

三、代码逐行拆解

3.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;
}

这段代码在干什么?

想象你是一个图书馆管理员,要把书架上的书按大小排列:

  1. 你选了一本书作为"基准线"(pivot)
  2. 从右边开始,找到一本比基准线小的书
  3. 从左边开始,找到一本比基准线大的书
  4. 把这两本书交换位置
  5. 重复步骤2-4,直到左右指针相遇
  6. 把基准线那本书放到最终位置

关键点:

  • nums[left] 作为基准值(pivot),所以 j 从右往左找比它小的,i 从左往右找比它大的
  • 找到后交换,这样比基准值小的都在左边,大的都在右边
  • 最后把基准值放到分界线位置

3.2 quickSort函数——递归分治

function quickSort(nums, left, right) {
  if (left >= right) return;  // 递归终止条件
  
  let pivot = partition(nums, left, right);  // 分区
  quickSort(nums, left, pivot - 1);   // 递归排左边
  quickSort(nums, pivot + 1, right);  // 递归排右边
}

就3行核心代码,是不是比想象中简单?


四、递归 vs 分治,别再搞混了

很多人分不清递归和分治,其实它们是两个层面的概念:

概念是什么类比
递归代码实现方式(函数调用自身)像俄罗斯套娃,一层套一层
分治算法设计思想(分+治+合)像分而治之的管理策略
  • 递归是手段,分治是目的
  • 分治几乎都用递归实现,但递归不一定都是分治

快排的递归调用栈:

quickSort([10,9,8,7,6,5,4,3,2,1], 0, 9)
  → partition → pivot=1 (位置0)
  → quickSort([1,9,8,7,6,5,4,3,2], 0, 8)
    → partition → pivot=1 (位置0)
    → quickSort([], 0, -1) → return
    → quickSort([9,8,7,6,5,4,3,2], 1, 8)
      → ...继续递归
  → quickSort([10], 1, 9) → return

五、快排的两个"致命伤"

5.1 不稳定排序

什么是不稳定?举个例子:

原数组:[3a, 3b, 1, 2]
排序后:[1, 2, 3a, 3b][1, 2, 3b, 3a]

3a和3b的相对位置可能颠倒,这就是"不稳定"。

原因: partition过程中,相等元素的交换会改变相对顺序。

5.2 最坏情况O(n²)

当数组已经有序时(比如[1,2,3,4,5]),每次partition只排好一个元素,递归深度变成n,时间复杂度退化为O(n²)。

解决方案:

  • 随机选择pivot
  • 三数取中法(取左、中、右三个元素的中位数)

六、完整代码+测试

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;
  let pivot = partition(nums, left, right);
  quickSort(nums, left, pivot - 1);
  quickSort(nums, pivot + 1, right);
}

// 测试
const arr = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
quickSort(arr, 0, arr.length - 1);
console.log(arr); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

总结

问题答案
快排为什么快?基准值分治(O(log n)) + 原地交换(O(n))
为什么不稳定?相等元素在partition中相对位置会颠倒
核心思想是什么?分治策略:分→治→合
面试怎么讲?先说思想,再写代码,最后分析复杂度

记住这3点,面试官问到快排你就能侃侃而谈了。


写在最后

快排是算法面试的"必考题",理解它的分治思想比死记代码更重要。

如果你觉得有帮助,点个赞👍支持一下,后续还会分享更多算法面试干货~

有什么问题欢迎在评论区讨论!