查找与排序算法:复杂度、稳定性
查找解决关键字定位,排序解决记录重排。算法选择不能只看平均时间复杂度,还要考虑数据是否有序、是否要求稳定、是否允许额外空间以及是否必须保证最坏性能。
顺序查找与折半查找
顺序查找逐项扫描,最好为 O(1),平均和最坏为 O(n),额外空间为 O(1)。哨兵可以减少边界判断,但不改变渐进复杂度。
折半查找要求序列有序,每轮将区间缩小一半:
cpp
int left = 0, right = n - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (a[mid] == key) return mid;
if (a[mid] < key) left = mid + 1;
else right = mid - 1;
}
return -1;
时间复杂度为 O(log n),空间复杂度为 O(1)。查找第一个或最后一个重复值时,命中后仍需继续收缩边界。
插入、折半插入与希尔排序
插入排序维护有序前缀,将当前元素插入正确位置。它稳定、原地,对近乎有序数据有效,平均和最坏为 O(n²)。
折半插入排序用二分查找减少比较次数,但数组移动仍为 O(n²),因此总体复杂度不变。
希尔排序按间隔 gap 进行分组插入排序,再逐步缩小间隔到 1。复杂度取决于增量序列,通常不稳定,但比直接插入排序更适合中等规模数据。
冒泡排序
冒泡排序交换相邻逆序元素。加入交换标志后,已经有序时可以提前结束:
cpp
for (int end = n - 1; end > 0 && swapped; --end) {
swapped = false;
for (int i = 0; i < end; ++i) {
if (a[i] > a[i + 1]) {
std::swap(a[i], a[i + 1]);
swapped = true;
}
}
}
最好为 O(n),平均和最坏为 O(n²),稳定且原地,但通常只适合小规模或教学场景。
快速排序
快速排序通过枢轴划分区间:
text
partition(l, r)
quick_sort(l, pivot - 1)
quick_sort(pivot + 1, r)
平均复杂度为 O(n log n),最坏为 O(n²),平均递归空间为 O(log n)。随机枢轴、三路划分和小区间插入排序可以减少退化和常数开销。快速排序通常不稳定。
选择排序
选择排序每轮寻找未排序区间的最小元素并交换到前端。比较次数始终为 O(n²),交换次数较少,原地但不稳定。
堆排序
堆排序先建立大根堆,再将堆顶最大元素交换到末尾并恢复堆:
cpp
for (int i = n / 2; i >= 1; --i) sift_down(i, n);
for (int end = n; end > 1; --end) {
swap(a[1], a[end]);
sift_down(1, end - 1);
}
建堆为 O(n),排序为 O(n log n),额外空间为 O(1),最坏复杂度有保证,但不稳定。
复杂度比较
| 算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 |
|---|---|---|---|---|
| 顺序查找 | O(n) |
O(n) |
O(1) |
- |
| 折半查找 | O(log n) |
O(log n) |
O(1) |
- |
| 插入排序 | O(n²) |
O(n²) |
O(1) |
稳定 |
| 希尔排序 | 依增量序列 | 依增量序列 | O(1) |
通常不稳定 |
| 冒泡排序 | O(n²) |
O(n²) |
O(1) |
稳定 |
| 快速排序 | O(n log n) |
O(n²) |
O(log n) 平均 |
不稳定 |
| 选择排序 | O(n²) |
O(n²) |
O(1) |
不稳定 |
| 堆排序 | O(n log n) |
O(n log n) |
O(1) |
不稳定 |
近乎有序数据可优先考虑插入排序;要求最坏 O(n log n) 且内存严格受限时可考虑堆排序;平均性能和缓存行为重要时常用快速排序;需要保持相等元素相对顺序时必须选择稳定算法或增加稳定化处理。