排序笔记
常见算法性能对比
分类算法的评价标准
稳定性
根据 相等元素 在数组中的 相对顺序 是否被改变,排序算法可分为「稳定排序」和「非稳定排序」两类。
就地性
根据排序过程中 是否使用额外内存(辅助数组),排序算法可分为「原地排序」和「异地排序」两类。一般地,由于不使用外部内存,原地排序相比非原地排序的执行效率更高。
自适应性
根据算法 时间复杂度 是否 受待排序数组的元素分布影响 ,排序算法可分为「自适应排序」和「非自适应排序」两类。
冒泡排序
冒泡排序是最基础的排序算法,由于其直观性,经常作为首个介绍的排序算法。其原理为:
内循环: 使用相邻双指针 j , j + 1 从左至右遍历,依次比较相邻元素大小,若左元素大于右元素则将它们交换;遍历完成时,最大元素会被交换至数组最右边 。
外循环: 不断重复「内循环」,每轮将当前最大元素交换至 剩余未排序数组最右边 ,直至所有元素都被交换至正确位置时结束。
复杂度分析
时间复杂度: O ( N 2 ) O(N^2) O(N2).
空间复杂度: O ( 1 ) O(1) O(1).
效率优化
通过增加一个标志位 flag
,若在某轮「内循环」中未执行任何交换操作,则说明数组已经完成排序,直接返回结果即可。
优化后的冒泡排序的最差和平均时间复杂度仍为 O ( N 2 ) O(N^2) O(N2) ;在输入数组 已排序 时,达到 最佳时间复杂度 O ( N ) O(N) O(N).
void bubbleSort(vector<int> &nums) {
int N = nums.size();
for (int i = 0; i < N - 1; i++) {
bool flag = false; // 初始化标志位
for (int j = 0; j < N - i - 1; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
flag = true; // 记录交换元素
}
}
if (!flag) break; // 内循环未交换任何元素,则跳出
}
}
快速排序
快速排序算法有两个核心点,分别为 哨兵划分 和 递归 。
哨兵划分
以数组某个元素(一般选取首元素)为 基准数 ,将所有小于基准数的元素移动至其左边,大于基准数的元素移动至其右边。
递归
对 左子数组 和 右子数组 分别递归执行 哨兵划分,直至子数组长度为 1 时终止递归,即可完成对整个数组的排序。
int partition(vector<int>& nums, int l, int r) {
// 以 nums[l] 作为基准数
int i = l, j = r;
while (i < j) {
while (i < j && nums[j] >= nums[l]) j--;
while (i < j && nums[i] <= nums[l]) i++;
swap(nums[i], nums[j]);
}
swap(nums[i], nums[l]);
return i;
}
void quickSort(vector<int>& nums, int l, int r) {
// 子数组长度为 1 时终止递归
if (l >= r) return;
// 哨兵划分操作
int i = partition(nums, l, r);
// 递归左(右)子数组执行哨兵划分
quickSort(nums, l, i - 1);
quickSort(nums, i + 1, r);
}
// 调用
vector<int> nums = { 4, 1, 3, 2, 5, 1 };
quickSort(nums, 0, nums.size() - 1);
时间复杂度
-
最佳: O ( N log N ) O(N\log N) O(NlogN)。在最佳情况下, 每轮哨兵划分操作将数组划分为等长度的两个子数组;哨兵划分操作为线性时间复杂度 O ( N ) O(N) O(N),递归总轮数为 O ( log ) O(\log) O(log)。
-
平均: O ( N log N ) O(N\log N) O(NlogN)。对于随机输入数组,哨兵划分操作的递归轮数也为 O ( N log N ) O(N\log N) O(NlogN)。
-
最差: O ( N 2 ) O(N^2) O(N2)。对于某些特殊输入数组,每轮哨兵划分操作都将长度为 N N N的数组划分为长度为 1 1 1和 N − 1 N-1 N−1的两个数组,此时递归轮数达到 N N N。
空间复杂度: O ( N ) O(N) O(N)。
虽然平均时间复杂度与「归并排序」和「堆排序」一致,但在实际使用中快速排序 效率更高 ,这是因为:
- 最差情况稀疏性: 虽然快速排序的最差时间复杂度为 O ( N 2 ) O(N^2) O(N2),差于归并排序和堆排序,但统计意义上看,这种情况出现的机率很低。大部分情况下,快速排序以 O ( N log N ) O(N\log N) O(NlogN)复杂度运行。
- 缓存使用效率高: 哨兵划分操作时,将整个子数组加载入缓存中,访问元素效率很高;堆排序需要跳跃式访问元素,因此不具有此特性。
- 常数系数低: 在提及的三种算法中,快速排序的 比较、赋值、交换 三种操作的综合耗时最低(类似于插入排序快于冒泡排序的原理)。
原地: 不用借助辅助数组的额外空间,递归仅使用 O ( log N ) O(\log N) O(logN)大小的栈帧空间。
非稳定: 哨兵划分操作可能改变相等元素的相对顺序。
自适应: 对于极少输入数据,每轮哨兵划分操作都将长度为 N N N的数组划分为长度 1 1 1和 N − 1 N-1 N−1两个子数组,此时时间复杂度劣化至 O ( N 2 ) O(N^2) O(N2)算法优化。
算法优化
快速排序的常见优化手段有「Tail Call」和「随机基准数」两种。
-
Tail Call
由于普通快速排序每轮选取「子数组最左元素」作为「基准数」,因此在输入数组 完全倒序 时, partition() 的递归深度会达到 N N N,即 最差空间复杂度 为 O ( N ) O(N) O(N) 。
每轮递归时,仅对 较短的子数组 执行哨兵划分 partition() ,就可将最差的递归深度控制在 O ( log N ) O(\log N) O(logN) (每轮递归的子数组长度都 ≤ ≤ ≤ 当前数组长度 / 2 /2 /2),即实现最差空间复杂度 O ( log N ) O(\log N) O(logN) 。
void quickSort(vector<int>& nums, int l, int r) {
// 子数组长度为 1 时终止递归
while (l < r) {
// 哨兵划分操作
int i = partition(nums, l, r);
// 仅递归至较短子数组,控制递归深度
if (i - l < r - i) {
quickSort(nums, l, i - 1);
l = i + 1;
} else {
quickSort(nums, i + 1, r);
r = i - 1;
}
}
}
-
随机基准数
同样地,由于快速排序每轮选取「子数组最左元素」作为「基准数」,因此在输入数组 完全有序 或 完全倒序 时, partition() 每轮只划分一个元素,达到最差时间复杂度 O ( N 2 ) O(N^2) O(N2)。因此,可使用 随机函数 ,每轮在子数组中随机选择一个元素作为基准数,这样就可以极大概率避免以上劣化情况。
值得注意的是,由于仍然可能出现最差情况,因此快速排序的最差时间复杂度仍为 O ( N 2 ) O(N^2) O(N2)。
int partition(vector<int>& nums, int l, int r) {
// 在闭区间 [l, r] 随机选取任意索引,并与 nums[l] 交换
int ra = l + rand() % (r - l + 1);
swap(nums[l], nums[ra]);
// 以 nums[l] 作为基准数
int i = l, j = r;
while (i < j) {
while (i < j && nums[j] >= nums[l]) j--;
while (i < j && nums[i] <= nums[l]) i++;
swap(nums[i], nums[j]);
}
swap(nums[i], nums[l]);
return i;
}
归并排序
归并排序体现了 “分而治之” 的算法思想,具体为:
- 「分」: 不断将数组从 中点位置 划分开,将原数组的排序问题转化为子数组的排序问题;
- 「治」: 划分到子数组长度为 1 时,开始向上合并,不断将 左右两个较短排序数组 合并为 一个较长排序数组,直至合并至原数组时完成排序;
代码
void mergeSort(vector<int>& nums, int l, int r) {
// 终止条件
if (l >= r) return;
// 递归划分
int m = (l + r) / 2;
mergeSort(nums, l, m);
mergeSort(nums, m + 1, r);
// 合并阶段
int tmp[r - l + 1]; // 暂存需合并区间元素
for (int k = l; k <= r; k++) // 拷贝nums数组
tmp[k - l] = nums[k];
int i = 0, j = m - l + 1; // 两指针分别指向左/右子数组的首个元素
for (int k = l; k <= r; k++) { // 遍历合并左/右子数组
if (i == m - l + 1)
nums[k] = tmp[j++];
else if (j == r - l + 1 || tmp[i] <= tmp[j])
nums[k] = tmp[i++];
else {
nums[k] = tmp[j++];
}
}
}
// 调用
vector<int> nums = { 4, 1, 3, 2, 5, 1 };
mergeSort(nums, 0, nums.size() - 1);
算法分析
- 时间复杂度:最佳、平均、最差均为 O ( N log N ) O(N\log N) O(NlogN)
- 空间复杂度: O ( N ) O(N) O(N). 在合并两个有序数组时,需要一个辅助数组 t m p tmp tmp
- 非原地:在合并过程中,需要一个辅助数组 t m p tmp tmp
- 稳定:归并排序不改变相等元素的相对顺序。
- 非自适应:对于任意输入数据,归并排序的时间复杂度皆相同。