数据结构:快速排序 (Quick Sort)
目录
从“一分为二”的思想开始
核心操作 Partition (分割)
现在我们开始扫描:
代码的逐步完善
第一阶段:实现 partition 函数
第二阶段:实现递归的 quickSort 函数
复杂度与特性分析
时间复杂度 (Time Complexity)
空间复杂度 (Space Complexity)
稳定性 (Stability)
接下来我们将进入一个重量级的、也是工业界应用最广泛的排序算法之一:快速排序 (Quick Sort)。它的思想与我们之前讨论的 O(n^2) 算法有着本质的区别。
从“一分为二”的思想开始
我们先回顾一下之前几个排序算法的共性:
-
冒泡排序:每轮把一个最大的元素“冒泡”到队尾。
-
选择排序:每轮选一个最小的元素放到队首。
-
插入排序:每轮把一个新元素插入到有序的队伍中。
它们的共同点是,每一轮操作,都只让一个元素到达了它“近乎”或“最终”的正确位置。为了让所有 n
个元素都归位,就需要大约 n
轮操作。
而每轮操作又需要大约 n
次比较或移动,这就导致了它们的复杂度基本都是 O(n^2)。
要想实现突破,我们就必须提出一个颠覆性的问题:
有没有一种方法,我们只操作一轮,就能产生比“一个元素归位”更大的进展?
✅ 答案是肯定的。想象一下,如果我们随便在数组里选一个元素,比如 arr[k]
,我们叫它基准 (pivot)。然后我们进行一次操作,使得:
-
pivot
这个元素本身被放到了一个位置p
上,这个位置就是它排序后最终应该在的位置。 -
所有比
pivot
小的元素,都被移动到了它的左边。 -
所有比
pivot
大的元素,都被移动到了它的右边。
[...小于pivot的元素...]
pivot
[...大于pivot的元素...]
这样一轮操作下来,我们不仅让 pivot
一个元素归位了,还顺便把整个数组分成了两个独立的子区域。
接下来,我们只需要对左右两个子区域分别重复这个过程,直到每个子区域都只剩一个元素或为空,整个排序就完成了!
这种“先找一个基准将问题分解成两个子问题,再对子问题递归求解”的思路,就是大名鼎鼎的分而治之 (Divide and Conquer) 思想。
核心操作 Partition (分割)
“分治”思想听起来很棒,但关键在于,我们如何实现上面说的那个核心操作?这个操作通常被称为 Partition (分割)。
📌 目标:给定一个数组(或子数组)和一个 pivot
,将数组重排。
我们来设计一个具体的 Partition 方案。为了简单,我们总是选择当前处理的子数组的最后一个元素作为 pivot
。
假设数组是 arr = [2, 8, 7, 1, 3, 5, 6, 4]
,我们处理 low=0
到 high=7
的范围。 pivot
就是 arr[7] = 4
。
我们的目标是把所有 <4
的放左边,>4
的放右边。 我们可以用两个“指针”来做这件事:
一个指针
i
,它代表“小于等于pivot
区域”的右边界。初始时,这个区域不存在,我们可以让i
指向low - 1
的位置。另一个指针
j
,它作为扫描指针,从low
开始遍历到high - 1
。
arr = [2, 8, 7, 1, 3, 5, 6, 4] ↑ ↑i j
现在我们开始扫描:
1️⃣j=0
, arr[0]=2
。2 < pivot(4)
。它应该属于“小于区”。
我们把 i
向右移动一位(i
现在是0),然后交换 arr[i]
和 arr[j]
的值。(这里因为 i
和 j
刚好相等,交换了自己)。
i=0
, arr
变为 [**2**, 8, 7, 1, 3, 5, 6, 4]
。小于区是 [2]
arr = [2, 8, 7, 1, 3, 5, 6, 4] ↑i,j
2️⃣ j=1
, arr[1]=8
。8 > pivot(4)
。它不属于“小于区”,我们什么都不做,i
不动。
arr = [2, 8, 7, 1, 3, 5, 6, 4] ↑ ↑i j
3️⃣ j=2
, arr[2]=7
。7 > pivot(4)
。i
仍然不动。
arr = [2, 8, 7, 1, 3, 5, 6, 4] ↑ ↑i j
4️⃣ j=3
, arr[3]=1
。1 < pivot(4)
。它应该属于“小于区”。
我们把 i
向右移动一位(i
现在是1),然后交换 arr[i]
和 arr[j]
的值:交换 arr[1]
(8) 和 arr[3]
(1)。
i=1
, arr
变为 [2, **1**, 7, 8, 3, 5, 6, 4]
小于区是 [2, 1]
arr = [2, 1, 7, 8, 3, 5, 6, 4] ↑ ↑i j
5️⃣ j=4
, arr[4]=3
。3 < pivot(4)
。
i
右移到2,交换 arr[2]
(7) 和 arr[4]
(3)。
i=2
, arr
变为 [2, 1, **3**, 8, 7, 5, 6, 4]
小于区是 [2, 1, 3]
arr = [2, 1, 3, 8, 7, 5, 6, 4] ↑ ↑i j
6️⃣ j=5
, arr[5]=5
。5 > pivot(4)
。i
不动。
arr = [2, 1, 3, 8, 7, 5, 6, 4] ↑ ↑i j
7️⃣ j=6
, arr[6]=6
。6 > pivot(4)
。i
不动。
arr = [2, 1, 3, 8, 7, 5, 6, 4] ↑ ↑i j
扫描指针 j
走到了尽头。现在数组是 [2, 1, 3, | 8, 7, 5, 6, | 4]
。 i=2
指向了小于区的最后一个元素 3
。
❓ pivot=4
还在数组末尾。它的最终位置应该在哪里?
就在小于区的后面!也就是
i+1
的位置。 我们最后一步,就是将pivot
和arr[i+1]
(当前是8)交换。
交换 arr[3]
(8) 和 arr[7]
(4)。 最终数组变为:[2, 1, 3, **4**, 7, 5, 6, 8]
arr = [2, 1, 3, 4, 7, 5, 6, 8] ↑ ↑i j
看!👉4
已经在他最终的位置上了。所有比它小的都在左边,所有比它大的都在右边。Partition 操作成功!这个函数需要返回 pivot
的新索引 i+1
,也就是 3
。
代码的逐步完善
第一阶段:实现 partition
函数
这个函数是快速排序的核心,我们先把它写出来。
#include <iostream>// 我们一直需要的 swap 函数
void swap(int* a, int* b) {int temp = *a;*a = *b;*b = temp;
}// Partition 函数
// arr[] --> 数组
// low --> 子数组的起始索引
// high --> 子数组的结束索引
int partition(int arr[], int low, int high) {// 1. 选择最后一个元素作为 pivotint pivot = arr[high];// 2. 'i' 是小于等于 pivot 区域的右边界int i = (low - 1); // 3. 'j' 遍历从 low 到 high-1for (int j = low; j <= high - 1; j++) {// 如果当前元素小于或等于 pivotif (arr[j] <= pivot) {i++; // 扩展小于区的边界swap(&arr[i], &arr[j]);}}// 4. 将 pivot 放到它的最终位置 (i+1)swap(&arr[i + 1], &arr[high]);// 5. 返回 pivot 的索引return (i + 1);
}
第二阶段:实现递归的 quickSort
函数
有了 partition
这个强大的工具,quickSort
函数本身就变得非常简洁和优雅了。它只需要做三件事:
-
调用
partition
来分割数组。 -
对
pivot
左边的子数组,递归调用quickSort
。 -
对
pivot
右边的子数组,递归调用quickSort
。
当然,递归必须有一个终止条件:
当子数组的
low >= high
时,说明这个子数组只剩一个或零个元素,它天然就是有序的,不需要再处理。
// 快速排序的主函数
void quickSort(int arr[], int low, int high) {// 递归的终止条件if (low < high) {// 1. 调用 partition,找到 pivot 的正确位置 piint pi = partition(arr, low, high);// 2. 递归地对 pivot 左边的子数组进行排序quickSort(arr, low, pi - 1);// 3. 递归地对 pivot 右边的子数组进行排序quickSort(arr, pi + 1, high);}
}
现在我们把所有部分组合起来,写一个主函数来启动排序。
void printArray(int arr[], int size) {for (int i = 0; i < size; i++)std::cout << arr[i] << " ";std::cout << std::endl;
}int main() {int arr[] = {2, 8, 7, 1, 3, 5, 6, 4};int n = sizeof(arr) / sizeof(arr[0]);std::cout << "Original array: \n";printArray(arr, n);// 启动快速排序,初始范围是整个数组 0 到 n-1quickSort(arr, 0, n - 1);std::cout << "Sorted array: \n";printArray(arr, n);return 0;
}
复杂度与特性分析
快速排序的分析比之前的算法要复杂一些,因为它依赖于 pivot
的选择。
时间复杂度 (Time Complexity)
最好与平均情况 (Best/Average Case):
-
如果每次我们选择的
pivot
都恰好是中位数,那么partition
操作会把数组均分成两个大小接近n/2
的子数组。 -
第一层递归,
partition
对n
个元素操作,成本是 O(n)。 -
第二层递归,对两个
n/2
的子数组操作,总成本是 2timesO(n/2)=O(n)。 -
第三层递归,对四个
n/4
的子数组操作,总成本是 4timesO(n/4)=O(n)。 -
需要多少层才能把数组分解完?大约需要 log_2n 层。
-
在数学上可以证明,即使
pivot
选择得不那么完美(比如每次都按1:9的比例分割),平均时间复杂度依然是 O(nlogn)。
最坏情况 (Worst Case):
-
如果数组已经有序或完全逆序,而我们每次都选择最后一个元素做
pivot
。 -
partition
会把数组分成一个包含n-1
个元素的子数组和一个空的子数组。 -
递归树会变成一条长链,深度为
n
。 -
总的时间复杂度是 O(n)+O(n−1)+...+O(1)=O(n2)。
-
这是一个致命的缺陷,但在实际应用中可以通过随机选择 pivot等方法来极大程度地避免。
空间复杂度 (Space Complexity)
-
快速排序是原地排序,它没有使用额外的数组。但是,递归调用本身需要消耗栈空间 (stack space) 来存储函数的参数、返回地址等。
-
最好与平均情况: 递归树的深度是 O(logn),所以空间复杂度是:O(logn)
-
最坏情况: 递归树的深度是 O(n),所以空间复杂度是:O(n)
稳定性 (Stability)
-
我们来看
partition
操作中的swap
。swap(&arr[i], &arr[j])
和最后的swap(&arr[i + 1], &arr[high])
都是长距离交换。 -
例如,对于数组
[5a, 2, 8, 5b]
,如果我们选择pivot=5b
。5a
在扫描时会因为<=pivot
而被交换到“小于区”,但它的新位置和5b
的相对关系很难保证不变。很容易就能举出反例。 -
因此,快速排序是❌不稳定排序 (Unstable Sort)。
快速排序名副其实,其平均情况下的性能非常出色,是实践中最高效的排序算法之一。它的主要缺点是存在一个性能很差的最坏情况,但这可以通过优化 pivot 的选择策略来有效缓解。理解它的“分治”思想是学习更高级算法的重要一步。