1961年,英国计算机科学家Tony Hoare在为机器翻译项目开发字典排序功能时,发明了一种在当时看来极其反直觉的算法:随机选择一个元素作为基准,将数组分成两部分递归排序。这个算法就是后来统治计算机排序领域半个多世纪的快速排序。
令人惊讶的是,快速排序并非唯一接近理论最优的排序算法。归并排序早在1945年就由冯·诺依曼提出,堆排序也在1964年问世。这三种算法虽然策略迥异,却都能在$O(n \log n)$时间内完成排序。更令人深思的是,尽管它们的理论复杂度相同,实际性能却差异巨大——快速排序在绝大多数情况下最快,归并排序稳定可靠,堆排序则独占原地排序的优势。
2015年,一个更戏剧性的事件发生了:研究者使用形式化验证工具KeY发现,Python、Java和Android中使用的TimSort算法存在一个隐藏了13年的bug,可能导致数组越界异常。这个发现揭示了工业级排序算法实现的复杂性远超教科书上的伪代码。
排序算法的理论边界
在讨论具体算法之前,需要先理解一个根本性问题:排序到底有多快?
对于基于比较的排序算法,答案是确定的:最坏情况下不可能优于$\Omega(n \log n)$。这个结论可以通过决策树模型证明。
假设有$n$个待排序元素,所有可能的排列共有$n!$种。每次比较只能产生两种结果(大于或小于),因此决策树的每个节点最多有两个子节点。要区分$n!$种排列,决策树至少需要$n!$个叶子节点。一棵高度为$h$的二叉树最多有$2^h$个叶子节点,因此:
$$2^h \geq n!$$取对数得到:
$$h \geq \log_2(n!) = \Omega(n \log n)$$最后一步使用了斯特林公式$\log(n!) \approx n \log n - n$。
这个下界说明了一个深刻的事实:仅通过元素两两比较,不可能突破$n \log n$的瓶颈。要获得更快的排序,必须利用元素本身的特性——这正是计数排序、基数排序等非比较排序算法的出发点。
三大经典排序算法详解
快速排序:分治的极致
快速排序的核心思想是分治:选择一个基准元素(pivot),将数组分成"小于基准"和"大于基准"两部分,然后递归处理。
public void quickSort(int[] nums, int left, int right) {
if (left >= right) return;
int pivotIndex = partition(nums, left, right);
quickSort(nums, left, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, right);
}
private int partition(int[] nums, int left, int right) {
int pivot = nums[right];
int i = left;
for (int j = left; j < right; j++) {
if (nums[j] <= pivot) {
swap(nums, i, j);
i++;
}
}
swap(nums, i, right);
return i;
}
快速排序的精妙之处在于partition操作的就地性——只需$O(1)$额外空间即可完成划分。当pivot选择合理时,每次划分将数组分成两个大致相等的部分,递归深度为$O(\log n)$,总时间复杂度为$O(n \log n)$。
但快速排序有一个致命弱点:最坏情况。当数组已经有序且总是选择最后一个元素作为pivot时,每次划分只能减少一个元素,递归深度退化为$O(n)$,时间复杂度恶化为$O(n^2)$。
工程实践中有多种优化策略:
随机化pivot选择:随机选择pivot可以有效避免最坏情况。
private int randomPartition(int[] nums, int left, int right) {
int randomIndex = left + new Random().nextInt(right - left + 1);
swap(nums, randomIndex, right);
return partition(nums, left, right);
}
三数取中:选择左端、右端、中间三个元素的中位数作为pivot。
private int medianOfThree(int[] nums, int left, int right) {
int mid = left + (right - left) / 2;
if (nums[left] > nums[mid]) swap(nums, left, mid);
if (nums[left] > nums[right]) swap(nums, left, right);
if (nums[mid] > nums[right]) swap(nums, mid, right);
swap(nums, mid, right - 1); // 将中位数放到right-1位置
return partition(nums, left, right);
}
三路划分:针对大量重复元素的场景,将数组分成"小于"、“等于”、“大于"三部分。
private void threeWayQuickSort(int[] nums, int left, int right) {
if (left >= right) return;
int pivot = nums[left];
int lt = left, gt = right, i = left + 1;
while (i <= gt) {
if (nums[i] < pivot) {
swap(nums, lt++, i++);
} else if (nums[i] > pivot) {
swap(nums, i, gt--);
} else {
i++;
}
}
threeWayQuickSort(nums, left, lt - 1);
threeWayQuickSort(nums, gt + 1, right);
}
归并排序:稳定的典范
归并排序同样采用分治策略,但与快速排序不同,它先递归分解,再合并有序子数组。
public void mergeSort(int[] nums, int left, int right, int[] temp) {
if (left >= right) return;
int mid = left + (right - left) / 2;
mergeSort(nums, left, mid, temp);
mergeSort(nums, mid + 1, right, temp);
merge(nums, left, mid, right, temp);
}
private void merge(int[] nums, int left, int mid, int right, int[] temp) {
int i = left, j = mid + 1, k = left;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) { // 注意:<= 保证稳定性
temp[k++] = nums[i++];
} else {
temp[k++] = nums[j++];
}
}
while (i <= mid) temp[k++] = nums[i++];
while (j <= right) temp[k++] = nums[j++];
for (int p = left; p <= right; p++) {
nums[p] = temp[p];
}
}
归并排序的优势在于稳定性和确定性:
- 稳定:相等元素的相对顺序不变
- 时间复杂度稳定:始终是$O(n \log n)$,不存在最坏情况
- 适合链表:归并排序是链表排序的最佳选择,因为链表不需要额外空间
代价是需要$O(n)$的额外空间用于合并。不过,有一种称为原地归并排序的变体可以减少空间使用,但实现复杂且常数因子较大。
堆排序:原地排序的极限
堆排序利用堆这种数据结构的特性:堆顶元素总是最大(或最小)的。通过反复提取堆顶元素,可以实现排序。
public void heapSort(int[] nums) {
int n = nums.length;
// 建堆:从最后一个非叶子节点开始下沉
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(nums, n, i);
}
// 依次取出堆顶元素
for (int i = n - 1; i > 0; i--) {
swap(nums, 0, i); // 将堆顶移到末尾
heapify(nums, i, 0); // 对剩余元素重新堆化
}
}
private void heapify(int[] nums, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && nums[left] > nums[largest]) largest = left;
if (right < n && nums[right] > nums[largest]) largest = right;
if (largest != i) {
swap(nums, i, largest);
heapify(nums, n, largest);
}
}
堆排序的独特价值在于原地排序:除了$O(1)$的临时变量,不需要任何额外空间。这使得它成为内存受限场景下的首选。
然而,堆排序有两个缺点:
- 不稳定:堆化过程中可能改变相等元素的相对顺序
- 缓存不友好:堆的操作涉及跳跃式访问,不利于CPU缓存
实际上,堆排序在现代处理器上通常比快速排序慢2-3倍,主要就是缓存效率的差异。
稳定性:为什么它很重要?
排序算法的稳定性是指:对于相等的元素,排序后它们的相对顺序是否保持不变。
考虑一个实际场景:有一个学生列表,每个学生有姓名和班级两个属性。先按班级排序,再按姓名排序。如果排序算法稳定,那么相同姓名的学生会保持按班级排序的顺序;如果不稳定,结果可能混乱。
// 稳定排序的场景示例
class Student {
String name;
int classNum;
}
// 先按班级排序
students.sort((a, b) -> a.classNum - b.classNum);
// 结果: [(张三,1), (李四,1), (王五,2), (张三,2), (赵六,3)]
// 再按姓名排序(稳定排序)
students.sort((a, b) -> a.name.compareTo(b.name));
// 结果: [(李四,1), (张三,1), (张三,2), (张五,2), (赵六,3)]
// 注意两个"张三"保持了班级顺序
常见排序算法的稳定性:
| 算法 | 稳定性 | 原因 |
|---|---|---|
| 冒泡排序 | 稳定 | 相邻交换,相等时不交换 |
| 插入排序 | 稳定 | 向后移动时不会跨越相等元素 |
| 归并排序 | 稳定 | 合并时优先选择左边元素 |
| 快速排序 | 不稳定 | partition可能改变相对顺序 |
| 堆排序 | 不稳定 | 堆化过程跳跃交换 |
非比较排序:突破理论边界
前面提到,比较排序的理论下界是$\Omega(n \log n)$。但如果能利用元素的额外信息,可以做到更快。
计数排序
当元素范围有限时,可以统计每个值的出现次数,然后按顺序输出。
public void countingSort(int[] nums) {
int max = Arrays.stream(nums).max().getAsInt();
int min = Arrays.stream(nums).min().getAsInt();
int range = max - min + 1;
int[] count = new int[range];
for (int num : nums) {
count[num - min]++;
}
int index = 0;
for (int i = 0; i < range; i++) {
while (count[i]-- > 0) {
nums[index++] = i + min;
}
}
}
计数排序的时间复杂度是$O(n + k)$,其中$k$是元素范围。当$k = O(n)$时,排序时间为线性。
基数排序
基数排序按照位数逐位排序,通常从最低位开始。
public void radixSort(int[] nums) {
int max = Arrays.stream(nums).max().getAsInt();
for (int exp = 1; max / exp > 0; exp *= 10) {
countingSortByDigit(nums, exp);
}
}
private void countingSortByDigit(int[] nums, int exp) {
int[] output = new int[nums.length];
int[] count = new int[10];
for (int num : nums) {
count[(num / exp) % 10]++;
}
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
for (int i = nums.length - 1; i >= 0; i--) {
int digit = (nums[i] / exp) % 10;
output[count[digit] - 1] = nums[i];
count[digit]--;
}
System.arraycopy(output, 0, nums, 0, nums.length);
}
基数排序的时间复杂度是$O(d \cdot n)$,其中$d$是最大位数。对于32位整数,$d$最多为10(十进制表示)。
工业界的选择:TimSort
2002年,Tim Peters为Python设计了一种混合排序算法,结合了归并排序和插入排序的优点。这就是后来被Java、Android、V8、Swift广泛采用的TimSort。
TimSort的核心思想是利用数据的有序性:
- 识别自然有序序列(Run):扫描数组,找到已经有序的子序列
- 扩展短序列:如果Run长度小于minrun,用插入排序扩展
- 合并Run:维护一个栈,按照特定规则合并相邻的Run
// TimSort的简化示意
public void timSort(int[] nums) {
int n = nums.length;
int minRun = calculateMinRun(n); // 通常在32-64之间
// 对每个Run进行插入排序
for (int start = 0; start < n; start += minRun) {
int end = Math.min(start + minRun - 1, n - 1);
insertionSort(nums, start, end);
}
// 合并Run
for (int size = minRun; size < n; size *= 2) {
for (int left = 0; left < n; left += 2 * size) {
int mid = left + size - 1;
int right = Math.min(left + 2 * size - 1, n - 1);
if (mid < right) {
merge(nums, left, mid, right);
}
}
}
}
TimSort的一个关键优化是Galloping Mode:当发现一个Run连续多次"获胜”(其元素总是小于另一个Run的元素)时,切换到指数搜索模式,快速定位合并位置。
TimSort的时间复杂度:
- 最坏情况:$O(n \log n)$
- 最好情况(已排序):$O(n)$
- 空间复杂度:$O(n)$
Java对基本类型和对象使用了不同的排序策略:
- 基本类型:Dual-Pivot Quicksort(更快的比较,但不稳定)
- 对象类型:TimSort(稳定,适合复杂对象)
LeetCode排序题目全解
题目一:直接实现排序算法(912. Sort an Array)
这是最直接的排序题目,要求不使用内置函数对数组排序。
class Solution {
public int[] sortArray(int[] nums) {
// 使用归并排序,保证稳定性
int[] temp = new int[nums.length];
mergeSort(nums, 0, nums.length - 1, temp);
return nums;
}
private void mergeSort(int[] nums, int left, int right, int[] temp) {
if (left >= right) return;
int mid = left + (right - left) / 2;
mergeSort(nums, left, mid, temp);
mergeSort(nums, mid + 1, right, temp);
// 优化:如果已经有序,跳过合并
if (nums[mid] <= nums[mid + 1]) return;
merge(nums, left, mid, right, temp);
}
private void merge(int[] nums, int left, int mid, int right, int[] temp) {
System.arraycopy(nums, left, temp, left, right - left + 1);
int i = left, j = mid + 1, k = left;
while (i <= mid && j <= right) {
if (temp[i] <= temp[j]) {
nums[k++] = temp[i++];
} else {
nums[k++] = temp[j++];
}
}
while (i <= mid) nums[k++] = temp[i++];
while (j <= right) nums[k++] = temp[j++];
}
}
题目二:排序作为预处理(56. Merge Intervals)
这道题展示了排序在解决复杂问题时的威力。通过先按区间起点排序,合并操作变得简单直接。
class Solution {
public int[][] merge(int[][] intervals) {
if (intervals.length <= 1) return intervals;
// 按区间起点排序
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);
List<int[]> result = new ArrayList<>();
int[] current = intervals[0];
result.add(current);
for (int[] interval : intervals) {
if (interval[0] <= current[1]) {
// 重叠,合并
current[1] = Math.max(current[1], interval[1]);
} else {
// 不重叠,添加新区间
current = interval;
result.add(current);
}
}
return result.toArray(new int[result.size()][]);
}
}
题目三:颜色分类(75. Sort Colors)
经典的荷兰国旗问题,要求将0、1、2三种颜色原地排序。
class Solution {
public void sortColors(int[] nums) {
// 三路划分:[0, p0)是0,[p0, i)是1,[i, p2]待处理,(p2, n-1]是2
int p0 = 0, i = 0, p2 = nums.length - 1;
while (i <= p2) {
if (nums[i] == 0) {
swap(nums, i++, p0++);
} else if (nums[i] == 2) {
swap(nums, i, p2--);
} else {
i++;
}
}
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
这道题本质上是一次partition操作,时间复杂度$O(n)$,空间复杂度$O(1)$。
题目四:最大数(179. Largest Number)
这道题需要自定义排序规则:两个字符串拼接后,字典序大的组合更优。
class Solution {
public String largestNumber(int[] nums) {
String[] strs = new String[nums.length];
for (int i = 0; i < nums.length; i++) {
strs[i] = String.valueOf(nums[i]);
}
// 自定义排序:s1 + s2 和 s2 + s1 比较
Arrays.sort(strs, (a, b) -> (b + a).compareTo(a + b));
// 处理前导零
if (strs[0].equals("0")) return "0";
StringBuilder sb = new StringBuilder();
for (String s : strs) {
sb.append(s);
}
return sb.toString();
}
}
排序规则的关键洞察:对于"3"和"30",比较"330"和"303"决定谁应该在前。
题目五:数组中的第K个最大元素(215. Kth Largest Element)
这道题有多种解法,展示了不同算法的权衡。
解法一:快速选择
class Solution {
public int findKthLargest(int[] nums, int k) {
return quickSelect(nums, 0, nums.length - 1, nums.length - k);
}
private int quickSelect(int[] nums, int left, int right, int k) {
if (left == right) return nums[left];
int pivotIndex = partition(nums, left, right);
if (pivotIndex == k) {
return nums[pivotIndex];
} else if (pivotIndex < k) {
return quickSelect(nums, pivotIndex + 1, right, k);
} else {
return quickSelect(nums, left, pivotIndex - 1, k);
}
}
private int partition(int[] nums, int left, int right) {
int pivot = nums[right];
int i = left;
for (int j = left; j < right; j++) {
if (nums[j] <= pivot) {
swap(nums, i++, j);
}
}
swap(nums, i, right);
return i;
}
}
快速选择的平均时间复杂度是$O(n)$,但最坏情况$O(n^2)$。
解法二:最小堆
class Solution {
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int num : nums) {
minHeap.offer(num);
if (minHeap.size() > k) {
minHeap.poll();
}
}
return minHeap.peek();
}
}
堆方法的时间复杂度是$O(n \log k)$,空间复杂度$O(k)$,适合处理海量数据流。
题目六:前K个高频元素(347. Top K Frequent Elements)
这道题结合了哈希表统计和堆排序。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 统计频率
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) {
freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
}
// 使用最小堆,保持k个最高频元素
PriorityQueue<Integer> heap = new PriorityQueue<>(
(a, b) -> freqMap.get(a) - freqMap.get(b)
);
for (int num : freqMap.keySet()) {
heap.offer(num);
if (heap.size() > k) {
heap.poll();
}
}
int[] result = new int[k];
for (int i = 0; i < k; i++) {
result[i] = heap.poll();
}
return result;
}
}
题目七:根据字符出现频率排序(451. Sort Characters By Frequency)
class Solution {
public String frequencySort(String s) {
Map<Character, Integer> freq = new HashMap<>();
for (char c : s.toCharArray()) {
freq.put(c, freq.getOrDefault(c, 0) + 1);
}
// 按频率排序
List<Character> chars = new ArrayList<>(freq.keySet());
chars.sort((a, b) -> freq.get(b) - freq.get(a));
StringBuilder sb = new StringBuilder();
for (char c : chars) {
int count = freq.get(c);
for (int i = 0; i < count; i++) {
sb.append(c);
}
}
return sb.toString();
}
}
题目八:合并K个升序链表(23. Merge k Sorted Lists)
这道题展示了归并排序在链表上的应用,利用最小堆进行多路归并。
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists.length == 0) return null;
PriorityQueue<ListNode> minHeap = new PriorityQueue<>(
(a, b) -> a.val - b.val
);
for (ListNode list : lists) {
if (list != null) {
minHeap.offer(list);
}
}
ListNode dummy = new ListNode(0);
ListNode current = dummy;
while (!minHeap.isEmpty()) {
ListNode node = minHeap.poll();
current.next = node;
current = current.next;
if (node.next != null) {
minHeap.offer(node.next);
}
}
return dummy.next;
}
}
时间复杂度$O(N \log k)$,其中$N$是所有节点总数,$k$是链表数量。
排序算法选择指南
面对实际问题时,如何选择合适的排序策略?
场景一:通用排序
- 数据量小(< 50):插入排序
- 数据量中等:快速排序或归并排序
- 数据量大且内存有限:堆排序
场景二:特殊数据
- 整数且范围有限:计数排序
- 字符串或固定长度数据:基数排序
- 已部分有序:TimSort或插入排序
场景三:面试
- 要求稳定:归并排序
- 要求原地:快速排序或堆排序
- 要求线性时间:检查是否满足计数/基数排序条件
总结
排序算法的发展史本身就是计算机科学进步的缩影。从1887年的基数排序到2002年的TimSort,每一次突破都源于对实际需求的深刻洞察。
理解排序算法,不仅是掌握几行代码,更重要的是培养一种思维模式:在时间和空间之间权衡,在通用性和特殊性之间取舍。没有完美的算法,只有最适合当前场景的选择。
当你在LeetCode上遇到一道新题时,不妨先问自己:**排序能否简化这个问题?**许多看似复杂的问题,排序后往往迎刃而解。
参考文献
- Cormen, T. H., et al. Introduction to Algorithms, 3rd Edition. MIT Press, 2009.
- Peters, T. “Timsort.” Python Developer Mailing List, 2002.
- Hoare, C. A. R. “Quicksort.” The Computer Journal, 1962.
- Knuth, D. E. The Art of Computer Programming, Volume 3: Sorting and Searching. Addison-Wesley, 1998.
- de Gouw, S., et al. “Proof of a Bug: The Verification of TimSort.” Computer Aided Verification, 2015.
- Wikipedia. “Sorting algorithm.” https://en.wikipedia.org/wiki/Sorting_algorithm
- GeeksforGeeks. “Stable and Unstable Sorting Algorithms.” https://www.geeksforgeeks.org/stable-and-unstable-sorting-algorithms/
- Baeldung. “Counting Sort vs. Bucket Sort vs. Radix Sort.” https://www.baeldung.com/cs/radix-vs-counting-vs-bucket-sort