最近一次面试中,我被问到了快速排序(QuickSort)的时间复杂度。我回答平均情况下是 O(n log n),面试官紧接着追问:"一定是 O(n log n) 吗?有没有可能退化到 O(n^2)?如何优化?"这让我意识到,面试不仅考察基础知识,还需要深入理解算法的边界和优化思路。此外,我之前还被问到过排序的稳定性问题,于是决定结合这些内容,写一篇博客,详细聊聊快排的时间复杂度、排序的稳定性,以及面试中常见的排序算法和解题思路。
快排的时间复杂度:从 O(n log n) 到 O(n^2)
快速排序的核心是通过选定基准(pivot),将数组分区后递归处理。它的平均时间复杂度是 O(n log n),但在特定情况下会退化。
-
为什么平均分成两半是 O(n log n)?
在理想情况下,基准每次将数组分成两半,问题规模从 n 变成 n/2,再变成 n/4,依此类推。递归的层数取决于需要多少次"除以 2"才能让规模缩小到 1。数学上,这可以用对数表示:log₂ n 就是将 n 不断除以 2 所需的次数(底数是 2,因为每次分一半)。每层需要比较 n 个元素,总复杂度就是 n × log₂ n,即 O(n log n)。
为什么用 log₂?因为快排的划分是基于"二分"的,但在算法复杂度中,底数不影响大 O 表示法(因为 log₂ n = logₖ n / logₖ 2,底数转换只是常数因子),所以我们通常简写为 O(n log n)。
-
为什么会退化到 O(n^2)?
如果基准选择很差,比如总选到最小值或最大值,分区失衡。比如在一个有序数组中,每次选最左侧元素,划分后一边是空集,另一边是 n-1 个元素。递归深度变成 n,每层仍需 O(n) 的比较,总复杂度就退化到 O(n^2)。
-
如何优化?
避免退化的关键是改进基准选择:
- 随机选基准:随机挑选 pivot,避免有序数组的极端情况。
- 三数取中:取首、中、尾三个元素的中位数。
- 小规模切换:子数组很小时(比如小于 10),改用插入排序。
下面是我熟悉的快排分区方法,用 Java 实现,i 和 j 是相向而行的双指针:
java
public void quickSort(int[] arr, int low, int high) {
if (low < high) {
if (high - low < 10) {
insertionSort(arr, low, high); // 小规模用插入排序
return;
}
int pivotIndex = partition(arr, low, high);
quickSort(arr, low, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, high);
}
}
private int partition(int[] arr, int low, int high) {
// 随机选基准并与最左侧交换
int randomIndex = low + (int)(Math.random() * (high - low + 1));
swap(arr, randomIndex, low);
int pivot = arr[low]; // 基准值
int i = low; // i 从左侧开始
int j = high; // j 从右侧开始
while (i < j) {
// j 从右向左找小于 pivot 的元素
while (i < j && arr[j] >= pivot) {
j--;
}
// i 从左向右找大于 pivot 的元素
while (i < j && arr[i] <= pivot) {
i++;
}
if (i < j) {
swap(arr, i, j); // 交换 i 和 j
}
}
// 最后将基准换到正确位置
swap(arr, low, i);
return i; // 返回基准位置
}
private void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
这种分区方法用 i 和 j 相向而行:i 从左往右找大于 pivot 的元素,j 从右往左找小于 pivot 的元素,找到后交换,直到 i 和 j 相遇。随机化基准选择依然能避免 O(n^2) 的退化。
什么是排序的稳定性?通俗易懂的理解
排序的稳定性是面试中常被提及的一个点,尤其在多字段排序场景中很关键。
-
定义 :
如果数组中有两个值相等的元素(比如 A 和 B),排序前 A 在 B 前面,排序后 A 仍保持在 B 前面,这个排序算法就是稳定的。反之,如果相对顺序可能改变,就是不稳定的。
-
通俗理解 :
想象你在排队买奶茶,按订单金额排序。如果两个人的订单都是 20 元,排完队后他们还是按下单顺序站着(先来先站),这就是稳定。如果金额相同但顺序随便颠倒(比如后来的跑到前面),那就是不稳定。
在实际应用中,比如一个表格先按价格排序,再按销量排序,如果价格相同的商品在按销量排序后还能保持原来的相对顺序,用户体验会更好,这就是稳定性的价值。
-
快排的稳定性分析 :
快排是不稳定的。以我的双指针分区为例,假设数组是 [3A, 3B, 4](3A 和 3B 值相等,A 在前),随机选 4 作为基准并换到左侧:
- 初始:[4, 3B, 3A]
- 分区过程:i 从左走,停在 4;j 从右走,停在 3A,交换后可能是 [3A, 3B, 4],但如果 j 先找到 3B 并与 i 交换,可能变成 [3B, 3A, 4]。
相等元素在交换中可能颠倒顺序,稳定性无法保证。
-
稳定与不稳定的排序算法:
- 稳定的 :
- 归并排序:合并时优先取左侧元素,保证相等元素的相对顺序。
- 插入排序:插入时不交换相等元素。
- 冒泡排序:比较时可选择不交换相等元素。
- 不稳定的 :
- 快速排序:分区交换破坏相对顺序。
- 堆排序:堆调整不关心相等元素的原始位置。
- 选择排序:选最小值时可能跳过中间的相等元素。
- 判断方法:如果算法涉及远距离交换(像快排的跨分区移动),通常不稳定;如果只做局部调整(像插入排序的逐步插入),通常稳定。
- 稳定的 :
面试常问的排序算法及通用思路
以下是面试中常见的排序算法和考察点:
-
快速排序
- 时间复杂度:平均 O(n log n),最坏 O(n^2)
- 空间复杂度:O(log n)(递归栈)
- 稳定性:不稳定
- 考察点:分区实现、优化思路。
-
归并排序
- 时间复杂度:O(n log n)(任何情况)
- 空间复杂度:O(n)
- 稳定性:稳定
- 考察点:分治逻辑、合并时的稳定性。
-
插入排序
- 时间复杂度:平均 O(n^2),最好 O(n)(近乎有序)
- 空间复杂度:O(1)
- 稳定性:稳定
- 考察点:小数据场景的应用。
-
堆排序
- 时间复杂度:O(n log n)
- 空间复杂度:O(1)
- 稳定性:不稳定
- 考察点:堆的构建与调整。
- 通用解题思路 :
- 理解需求:是否有时间、空间或稳定性的限制?
- 分析输入:输入是否有序、是否有重复值?
- 推导复杂度:能清晰说明时间和空间复杂度。
- 优化方案:如快排的随机化、归并的空间优化。
- 代码实现:写出简洁代码,必要时解释关键逻辑。