排序笔记(基于“LeetCode 算法笔记”的笔记)

文章介绍了排序算法中的冒泡排序、快速排序和归并排序,包括它们的工作原理、时间复杂度、空间复杂度以及优化策略。冒泡排序最坏情况是O(N^2),快速排序平均情况为O(NlogN),归并排序则在所有情况下都是O(NlogN)。快速排序在实际应用中通常更高效,而归并排序则是稳定的排序算法。
摘要由CSDN通过智能技术生成

排序笔记

常见算法性能对比

Picture2.png

分类算法的评价标准
稳定性

根据 相等元素 在数组中的 相对顺序 是否被改变,排序算法可分为「稳定排序」和「非稳定排序」两类。

就地性

根据排序过程中 是否使用额外内存(辅助数组),排序算法可分为「原地排序」和「异地排序」两类。一般地,由于不使用外部内存,原地排序相比非原地排序的执行效率更高。

自适应性

根据算法 时间复杂度 是否 受待排序数组的元素分布影响 ,排序算法可分为「自适应排序」和「非自适应排序」两类。

冒泡排序

冒泡排序是最基础的排序算法,由于其直观性,经常作为首个介绍的排序算法。其原理为:

内循环: 使用相邻双指针 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 N1的两个数组,此时递归轮数达到 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 N1两个子数组,此时时间复杂度劣化至 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
  • 稳定:归并排序不改变相等元素的相对顺序。
  • 非自适应:对于任意输入数据,归并排序的时间复杂度皆相同。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值