排序算法
九种排序算法,大体分为三类:遍历排序、分治排序、线性排序。详细展开如下:
选择排序
两层循环,
外循环遍历所有的元素,
内循环记录未排序区间中最小元素,
将其放到已排序元素的末尾。
核心:选择未排序区间的最小元素
/* 选择排序 */
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都来到同一个元素下,交换该元素与基准数,完成哨兵划分。
随后递归左右子数组即可
哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 ≤ 基准数 ≤ 右子数组任意元素”
/* 快速排序 */
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)
稳定排序
堆排序
建立大顶堆,然后取出顶部元素,维护大顶堆。重复这个过程即可。
实际是在数组内操作,所以取出顶部元素这一过程只需要交换数组的首尾元素即可。
/* 堆排序 */
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
/* 计数排序 */
// 简单实现,无法用于排序对象
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
完整代码
/* 计数排序 */
// 完整实现,可排序对象,并且是稳定排序
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(logn) 。
- 归并排序包括划分和合并两个阶段,典型地体现了分治策略。在归并排序中,排序数组需要创建辅助数组,空间复杂度为 O(n) ;然而排序链表的空间复杂度可以优化至 O(1) 。
- 桶排序包含三个步骤:数据分桶、桶内排序和合并结果。它同样体现了分治策略,适用于数据体量很大的情况。桶排序的关键在于对数据进行平均分配。
- 计数排序是桶排序的一个特例,它通过统计数据出现的次数来实现排序。计数排序适用于数据量大但数据范围有限的情况,并且要求数据能够转换为正整数。
- 基数排序通过逐位排序来实现数据排序,要求数据能够表示为固定位数的数字。
- 总的来说,我们希望找到一种排序算法,具有高效率、稳定、原地以及正向自适应性等优点。然而,正如其他数据结构和算法一样,没有一种排序算法能够同时满足所有这些条件。在实际应用中,我们需要根据数据的特性来选择合适的排序算法。
上述资料为个人学习总结笔记,学习资源来自https://www.hello-algo.com/