排序算法总结

排序算法

九种排序算法,大体分为三类:遍历排序、分治排序、线性排序。详细展开如下:

选择排序

两层循环,

外循环遍历所有的元素,

内循环记录未排序区间中最小元素,

将其放到已排序元素的末尾。

核心:选择未排序区间的最小元素

选择排序

/* 选择排序 */
void selectionSort(vector<int> &nums) {
    int n = nums.size();
    // 外循环:未排序区间为 [i, n-1]
    for (int i = 0; i < n - 1; i++) {
        // 内循环:找到未排序区间内的最小元素
        int k = i;
        for (int j = i + 1; j < n; j++) {
            if (nums[j] < nums[k])
                k = j; // 记录最小元素的索引
        }
        // 将该最小元素与未排序区间的首个元素交换
        swap(nums[i], nums[k]);
    }
}

时间复杂度O(n2)

空间复杂度O(1)

非稳定排序


冒泡排序

两层for循环

外循环从后向前遍历,确定未排序区间

内循环从前往后遍历,找出当前层最大值放到最右边

核心:相邻元素两两比较,大的放右边

冒泡排序

/* 冒泡排序 */
void bubbleSort(vector<int> &nums) {
    // 外循环:未排序区间为 [0, i]
    for (int i = nums.size() - 1; i > 0; i--) {
        // 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
        for (int j = 0; j < i; j++) {
            if (nums[j] > nums[j + 1]) {
                // 交换 nums[j] 与 nums[j + 1]
                // 这里使用了 std::swap() 函数
                swap(nums[j], nums[j + 1]);
            }
        }
    }
}

可以对冒泡排序效率进行优化,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果

/* 冒泡排序(标志优化)*/
void bubbleSortWithFlag(vector<int> &nums) {
    // 外循环:未排序区间为 [0, i]
    for (int i = nums.size() - 1; i > 0; i--) {
        bool flag = false; // 初始化标志位
        // 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
        for (int j = 0; j < i; j++) {
            if (nums[j] > nums[j + 1]) {
                // 交换 nums[j] 与 nums[j + 1]
                // 这里使用了 std::swap() 函数
                swap(nums[j], nums[j + 1]);
                flag = true; // 记录交换元素
            }
        }
        if (!flag)
            break; // 此轮冒泡未交换任何元素,直接跳出
    }
}

时间复杂度:经过优化,冒泡排序的最差和平均时间复杂度仍为 O(n2) ;但当输入数组完全有序时,可达到最佳时间复杂度 O(n) 。

空间复杂度O(1)

稳定排序


插入排序

两层for循环

默认第一个元素已排序,从第二个元素开始比较

设置未排序的首元素为base,将其与已排序的元素逐个比较

将符合条件的元素,插到已排序部分的正确位置

核心:将未排序的首个元素,插入已排序的元素

插入排序

/* 插入排序 */
void insertionSort(vector<int> &nums) {
    // 外循环:已排序元素数量为 1, 2, ..., n
    for (int i = 1; i < nums.size(); i++) {
        int base = nums[i], j = i - 1;
        // 内循环:将 base 插入到已排序部分的正确位置
        while (j >= 0 && nums[j] > base) {
            nums[j + 1] = nums[j]; // 将 nums[j] 向右移动一位
            j--;
        }
        nums[j + 1] = base; // 将 base 赋值到正确位置
    }
}

时间复杂度 O(n2)

空间复杂度O(1)

稳定排序

插排 冒泡 选择 三者比较:{

插排开销比冒泡开销小,冒泡排序是基于元素交换实现,需要额外的临时变量,涉及3个单元的操作,插排只涉及1个单元操作

选择排序在任何情况下,时间复杂度都是O(n2),如果给定部分有序数组,插排效率更高

选择排序不稳定

}


快速排序

核心:前序遍历,哨兵划分

以某个元素为基准数,小于基准数放左边,大于基准数放右边。

一层while循环套两个while循环

先从右向左找首个小于基准数的元素

再从左往右找首个大于基准数的元素

找到则交换二者位置

当左右ij都来到同一个元素下,交换该元素与基准数,完成哨兵划分。

随后递归左右子数组即可

哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 ≤ 基准数 ≤ 右子数组任意元素”

快排
快排2

/* 快速排序 */
void quickSort(vector<int> &nums, int left, int right) {
    // 子数组长度为 1 时终止递归
    if (left >= right)
        return;
    // 哨兵划分
    int pivot = partition(nums, left, right);
    // 递归左子数组、右子数组
    quickSort(nums, left, pivot - 1);
    quickSort(nums, pivot + 1, right);
}

/* 元素交换 */
void swap(vector<int> &nums, int i, int j) {
    int tmp = nums[i];
    nums[i] = nums[j];
    nums[j] = tmp;
}

/* 哨兵划分 */
int partition(vector<int> &nums, int left, int right) {
    // 以 nums[left] 作为基准数
    int i = left, j = right;
    while (i < j) {
        while (i < j && nums[j] >= nums[left])
            j--; // 从右向左找首个小于基准数的元素
        while (i < j && nums[i] <= nums[left])
            i++;          // 从左向右找首个大于基准数的元素
        swap(nums, i, j); // 交换这两个元素
    }
    swap(nums, i, left); // 将基准数交换至两子数组的分界线
    return i;            // 返回基准数的索引
}

时间复杂度O(nlogn)最差情况下为O(n2)

空间复杂度O(n) 原地排序

非稳定排序

快排为什么快?

出现最差情况概率很低,最差情况下,每轮哨兵划分的情况都是0 和n-1两个数组

缓存使用效率高,哨兵划分时,系统可以将整个子数组加载到缓存

复杂度的常数系数低

优化方式

基准数优化,从首、中、尾三个元素中选取中位数作为基准数。减少哨兵划分过程中出现0和n-1数组的机率(这是从时间复杂度的角度进行)

尾递归优化,哨兵排序后,仅对较短的数组进行递归。由于较短子数组长度不会超过n/2,这种方式,能确保递归深度不超过logn,将最差空间复杂度优化到O(logn)(这是针对空间复杂度)


归并排序

通过递归不断将数组从中点处分开,直到数组长度为1

然后持续将左右两数组合并

核心:后序遍历,合并时候复制一个辅助数组,比较左右子数组的首元素(擂台赛形式),将较小的覆盖到原数组

归并

/* 归并排序 */
void mergeSort(vector<int> &nums, int left, int right) {
    // 终止条件
    if (left >= right)
        return; // 当子数组长度为 1 时终止递归
    // 划分阶段
    int mid = (left + right) / 2;    // 计算中点
    mergeSort(nums, left, mid);      // 递归左子数组
    mergeSort(nums, mid + 1, right); // 递归右子数组
    // 合并阶段
    merge(nums, left, mid, right);
}

/* 合并左子数组和右子数组 */
// 左子数组区间 [left, mid]
// 右子数组区间 [mid + 1, right]
void merge(vector<int> &nums, int left, int mid, int right) {
    //初始化辅助数组
    vector<int> tmp(nums.begin() + left, nums.begin() + right + 1);
    // 左子数组的起始索引和结束索引
    int leftStart = left - left, leftEnd = mid - left;
    // 右子数组的起始索引和结束索引
    int rightStart = mid + 1 - left, rightEnd = right - left;
    // i, j 分别指向左子数组、右子数组的首元素
    int i = leftStart, j = rightStart;
    // 通过覆盖原数组 nums 来合并左子数组和右子数组
    for (int k = left; k <= right; k++) {
        // 若“左子数组已全部合并完”,则选取右子数组元素,并且 j++
        if (i > leftEnd)
            nums[k] = tmp[j++];
        // 否则,若“右子数组已全部合并完”或“左子数组元素 <= 右子数组元素”,则选取左子数组元素,并且 i++
        else if (j > rightEnd || tmp[i] <= tmp[j])
            nums[k] = tmp[i++];
        // 否则,若“左右子数组都未全部合并完”且“左子数组元素 > 右子数组元素”,则选取右子数组元素,并且 j++
        else
            nums[k] = tmp[j++];
    }
}

时间复杂度O(nlogn)

空间复杂度O(n)

稳定排序


堆排序

建立大顶堆,然后取出顶部元素,维护大顶堆。重复这个过程即可。

实际是在数组内操作,所以取出顶部元素这一过程只需要交换数组的首尾元素即可。

堆排1
堆排2
堆排3

/* 堆排序 */
void heapSort(vector<int> &nums) {
    // 建堆操作:堆化除叶节点以外的其他所有节点
    for (int i = nums.size() / 2 - 1; i >= 0; --i) {
        siftDown(nums, nums.size(), i);
    }
    // 从堆中提取最大元素,循环 n-1 轮
    for (int i = nums.size() - 1; i > 0; --i) {
        // 交换根节点与最右叶节点(即交换首元素与尾元素)
        swap(nums[0], nums[i]);
        // 以根节点为起点,从顶至底进行堆化
        siftDown(nums, i, 0);
    }
}

/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
void siftDown(vector<int> &nums, int n, int i) {
    while (true) {
        // 判断节点 i, l, r 中值最大的节点,记为 ma
        int l = 2 * i + 1;
        int r = 2 * i + 2;
        int ma = i;
        if (l < n && nums[l] > nums[ma])
            ma = l;
        if (r < n && nums[r] > nums[ma])
            ma = r;
        // 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
        if (ma == i) {
            break;
        }
        // 交换两节点
        swap(nums[i], nums[ma]);
        // 循环向下堆化
        i = ma;
    }
}

时间复杂度O(nlogn)

空间复杂度O(1)

非稳定排序


桶排序

适合处理体量很大的数据

这里针对元素范围是[0,1)的浮点数,实际上只要定义一个合适的映射函数将元素分配到各个桶中即可。

初始化k个桶,将n个元素分配到k个桶中

对桶内分别执行排序

按桶从小到大合并

注意:适用于元素分布较为均匀的序列,如果序列中元素分配不均匀,可能导致数据被分配到一个桶里

分治思想

桶排

/* 桶排序 */
void bucketSort(vector<float> &nums) {
    // 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
    int k = nums.size() / 2;
    vector<vector<float>> buckets(k);
    // 1. 将数组元素分配到各个桶中
    for (float num : nums) {
        // 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
        int i = num * k;//此处映射函数需要根据不同数据范围来设置不同的映射函数
        // 将 num 添加进桶 bucket_idx
        buckets[i].push_back(num);
    }
    // 2. 对各个桶执行排序
    for (vector<float> &bucket : buckets) {
        // 使用内置排序函数,也可以替换成其他排序算法
        sort(bucket.begin(), bucket.end());
    }
    // 3. 遍历桶合并结果
    int i = 0;
    for (vector<float> &bucket : buckets) {
        for (float num : bucket) {
            nums[i++] = num;
        }
    }
}

时间复杂度O(n+k),排序所有桶的时间为O(nlog(n/k)),当桶数k很大时,排序总时间趋近O(n),合并结果需要遍历所有桶最终总耗时O(n+k)

空间复杂度O(n+k),k个桶和n个元素的额外空间

稳定性取决于桶内排序算法的稳定性

如何实现平均分配?

桶排的时间复杂度理论上可以到O(n),关键可在于将元素平均分配到各个桶

为实现平均分,可以先设定一个大致分界线,将数据粗略分为三个桶,再将元素较多的桶分三个桶,直到所有桶中元素大致相等


计数排序

通过统计元素数量来实现排序,通常用于整数数组

首先遍历数组nums,找出最大元素m,然后创建一个长度为m+1的辅助数组counter

借助counter统计nums中各数字出现的次数

遍历counter,根据各个数字出现的次数,将其按从小到大的顺序填入nums

计数1

/* 计数排序 */
// 简单实现,无法用于排序对象
void countingSortNaive(vector<int> &nums) {
    // 1. 统计数组最大元素 m
    int m = 0;
    for (int num : nums) {
        m = max(m, num);
    }
    // 2. 统计各数字的出现次数
    // counter[num] 代表 num 的出现次数
    vector<int> counter(m + 1, 0);
    for (int num : nums) {
        counter[num]++;
    }
    // 3. 遍历 counter ,将各元素填入原数组 nums
    int i = 0;
    for (int num = 0; num < m + 1; num++) {
        for (int j = 0; j < counter[num]; j++, i++) {
            nums[i] = num;
        }
    }
}

从桶排序的角度看,我们可以将计数排序中的计数数组 counter 的每个索引视为一个桶,将统计数量的过程看作是将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。

如果输入数据是对象,上述步骤 3. 就失效了

解决方法是,统计counter的前缀和

倒序遍历原数组(可以保证稳定性,也是因为counter统计前缀和,所以只知道元素最后的位置)

每次迭代中执行,

1 将元素num填入数组res的索引counter[num]-1处

2 counter[num] - 1

计数2

完整代码

/* 计数排序 */
// 完整实现,可排序对象,并且是稳定排序
void countingSort(vector<int> &nums) {
    // 1. 统计数组最大元素 m
    int m = 0;
    for (int num : nums) {
        m = max(m, num);
    }
    // 2. 统计各数字的出现次数
    // counter[num] 代表 num 的出现次数
    vector<int> counter(m + 1, 0);
    for (int num : nums) {
        counter[num]++;
    }
    // 3. 求 counter 的前缀和,将“出现次数”转换为“尾索引”
    // 即 counter[num]-1 是 num 在 res 中最后一次出现的索引
    for (int i = 0; i < m; i++) {
        counter[i + 1] += counter[i];
    }
    // 4. 倒序遍历 nums ,将各元素填入结果数组 res
    // 初始化数组 res 用于记录结果
    int n = nums.size();
    vector<int> res(n);
    for (int i = n - 1; i >= 0; i--) {
        int num = nums[i];
        res[counter[num] - 1] = num; // 将 num 放置到对应索引处
        counter[num]--;              // 令前缀和自减 1 ,得到下次放置 num 的索引
    }
    // 使用结果数组 res 覆盖原数组 nums
    nums = res;
}

时间复杂度O(n+m), n是遍历nums | m是遍历counter。一般情况下,n>>m时间复杂度趋近于O(n)

空间复杂度O(n+m), 借助了长度为n的数组res 和长度为m的数组counter

稳定排序

注意:只适用于非负整数, 适用于数据量大但数据范围小的情况。


基数排序

基数排序的核心思想与计数排序一致

基数排序可以解决计数排序需要分配大量内存空间的问题

以学号数据为例,假设数字的最低位是第 1 位,最高位是第 8 位,

大致步骤:

1 初始化位数k=1

2 对学号的第 k 位执行“计数排序”。完成后,数据会根据第 k 位从小到大排序

3 将k增加 1 ,然后返回步骤 2 继续迭代,直到所有位都排序完成后结束。

基数排序

/* 基数排序 */
void radixSort(vector<int> &nums) {
    // 获取数组的最大元素,用于判断最大位数
    int m = *max_element(nums.begin(), nums.end());
    // 按照从低位到高位的顺序遍历
    for (int exp = 1; exp <= m; exp *= 10)
        // 对数组元素的第 k 位执行计数排序
        // k = 1 -> exp = 1
        // k = 2 -> exp = 10
        // 即 exp = 10^(k-1)
        countingSortDigit(nums, exp);
}

/* 获取元素 num 的第 k 位,其中 exp = 10^(k-1) */
int digit(int num, int exp) {
    // 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
    return (num / exp) % 10;
}

/* 计数排序(根据 nums 第 k 位排序) */
void countingSortDigit(vector<int> &nums, int exp) {
    // 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
    vector<int> counter(10, 0);
    int n = nums.size();
    // 统计 0~9 各数字的出现次数
    for (int i = 0; i < n; i++) {
        int d = digit(nums[i], exp); // 获取 nums[i] 第 k 位,记为 d
        counter[d]++;                // 统计数字 d 的出现次数
    }
    // 求前缀和,将“出现个数”转换为“数组索引”
    for (int i = 1; i < 10; i++) {
        counter[i] += counter[i - 1];
    }
    // 倒序遍历,根据桶内统计结果,将各元素填入 res
    vector<int> res(n, 0);
    for (int i = n - 1; i >= 0; i--) {
        int d = digit(nums[i], exp);
        int j = counter[d] - 1; // 获取 d 在数组中的索引 j
        res[j] = nums[i];       // 将当前元素填入索引 j
        counter[d]--;           // 将 d 的数量减 1
    }
    // 使用结果覆盖原数组 nums
    for (int i = 0; i < n; i++)
        nums[i] = res[i];
}

时间复杂度O(nk),数据量为n 数据为d进制,最大位数为k,排序所有k位使用O((n+d)k)时间,通常d和k相对较小。

空间复杂度O(n+d),与计数排序相同,基数排序需要借助长度为n和d的数组res和counter

稳定排序


总结

  • 冒泡排序通过交换相邻元素来实现排序。通过添加一个标志位来实现提前返回,我们可以将冒泡排序的最佳时间复杂度优化到 O(n) 。
  • 插入排序每轮将未排序区间内的元素插入到已排序区间的正确位置,从而完成排序。虽然插入排序的时间复杂度为 O(n2) ,但由于单元操作相对较少,它在小数据量的排序任务中非常受欢迎。
  • 快速排序基于哨兵划分操作实现排序。在哨兵划分中,有可能每次都选取到最差的基准数,导致时间复杂度劣化至 O(n2) 。引入中位数基准数或随机基准数可以降低这种劣化的概率。尾递归方法可以有效地减少递归深度,将空间复杂度优化到 O(log⁡n) 。
  • 归并排序包括划分和合并两个阶段,典型地体现了分治策略。在归并排序中,排序数组需要创建辅助数组,空间复杂度为 O(n) ;然而排序链表的空间复杂度可以优化至 O(1) 。
  • 桶排序包含三个步骤:数据分桶、桶内排序和合并结果。它同样体现了分治策略,适用于数据体量很大的情况。桶排序的关键在于对数据进行平均分配。
  • 计数排序是桶排序的一个特例,它通过统计数据出现的次数来实现排序。计数排序适用于数据量大但数据范围有限的情况,并且要求数据能够转换为正整数。
  • 基数排序通过逐位排序来实现数据排序,要求数据能够表示为固定位数的数字。
  • 总的来说,我们希望找到一种排序算法,具有高效率、稳定、原地以及正向自适应性等优点。然而,正如其他数据结构和算法一样,没有一种排序算法能够同时满足所有这些条件。在实际应用中,我们需要根据数据的特性来选择合适的排序算法。

性能对比

上述资料为个人学习总结笔记,学习资源来自https://www.hello-algo.com/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值