八大排序算法
1.冒泡排序
特点:相邻元素两两比较,把值大的元素往下交换。
缺点:冒泡排序的时间复杂度太高
冒泡排序的过程如下图所示,其展示了一趟排序的过程,在下一趟的排序过程中,最后一个排序完成的元素无需进行遍历,重复此过程,直到整个数组排序完成:
![](https://img2023.cnblogs.com/blog/2026333/202212/2026333-20221222195429105-666175711.png)
其实现代码如下:
void bubbleSort(vector<int>& nums) {
for (int i = 0; i < nums.size(); i++) {
bool flag = false;
for (int j = 0; j < nums.size() - 1; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
flag = true;
}
}
if (!flag) return;
}
}
其中对冒泡排序做了些许优化,如果待排数组已经是有序的,则在遍历一次数组元素之后就会停止排序。
2.选择排序
特点:每次在剩下的元素中选择值最小的元素,和当前元素进行交换
缺点:相对于冒泡排序而言,交换的次数变少的,但是比较的次数依然很多
选择排序的过程如下图所示,其为一趟选择排序的处理过程,在每次遍历的过程中,找到未排序数组中的最小值即可,遍历完成后,让当前数与其交换即可,减少了冒泡排序中元素交换的次数,但比较次数未曾减少。重复上述过程,直到整个数组排序完成:
![](https://img2023.cnblogs.com/blog/2026333/202212/2026333-20221222195512614-983546472.png)
其相关代码如下:
void selectionSort(vector<int>& nums) {
for (int i = 0; i < nums.size() - 1; i++) {
int minIdx = i, minVal = nums[i];
for (int j = i + 1; j < nums.size(); j++) {
if (nums[j] < minVal) {
minIdx = j;
minVal = nums[j];
}
}
if (i != minIdx) swap(nums[i], nums[minIdx]);
}
}
3.插入排序
特点:从第二个元素开始,把前面的元素序列当作是已经有序的,然后寻找合适的位置插入。相比与冒泡排序和选择排序而言,比较的次数和数据交换的次数都得到了减少。
优点:插入排序是普通排序里面效率最高的排序算法,而且在数据越趋于有序的情况下,插入排序的效率是最高的。
插入排序的过程如下图所示,从第二个元素开始,每次在已排序完成的序列中找到第一个大于等于当前元素的值的索引,然后将当前元素插入到该索引之前,同时,插入排序也可以保证算法的稳定性:
![](https://img2023.cnblogs.com/blog/2026333/202212/2026333-20221222195547141-590266034.png)
其代码实现如下:
void insertionSort(vector<int>& nums) {
for (int i = 1; i < nums.size(); i++) {
int val = nums[i], j = i - 1;
for (; j >= 0; j--) {
if (nums[j] <= val) {
break;
}
nums[j + 1] = nums[j];
}
nums[j + 1] = val;
}
}
4.希尔排序
特点:可以看作是多路的插入排序,分组的数据越趋于有序,整体上的数据也就趋于有序,插入排序的效率完美体现。
希尔排序的过程如下图所示,最开始时,按照间隔为 size()
的一半来选取元素进行分组,每一组采用快速排序,完成后,将间隔更改为上次间隔的一半,继续进行分组快速排序,直到间隔为 1,对整个数组进行一次快速排序,即可完成排序:
![](https://img2023.cnblogs.com/blog/2026333/202212/2026333-20221222195627901-1092890964.png)
其代码实现如下:
void shellSort(vector<int>& nums) {
for (int gap = nums.size() / 2; gap > 0; gap /= 2) {
for (int i = gap; i < nums.size(); i++) {
int val = nums[i], j = i - gap;
for (; j >= 0; j -= gap) {
if (nums[j] <= val) {
break;
}
nums[j + gap] = nums[j];
}
nums[j + gap] = val;
}
}
}
5.快速排序
特点:快速排序是冒泡排序的升级算法。每次选择基准数,把小于基准数的放在基准数的左边,把大于基准数的放到基准数的右边,采用“分治思想”处理剩余的序列元素,直到整个序列变为有序序列为止。
快速排序的过程如下:
- 选取基准数 val
- 从右指针开始往前找第一个小于 val 的数字,放到左指针的地方
- 左指针前移
- 从左指针开始往后找第一个大于 val 的数组,放到右指针的地方
- 右指针右移
- 重复上述过程
快速排序的优化方法:
- 由于在序列基本有序时,最快的排序方法是插入排序,因此,可以在快速排序二分到一定阶段时采用插入排序;
- 基准数的选取越趋于中间位置,二分所构成的二叉树就越趋于平衡。如果基准数是序列最小值或最大值,则会形成极端情况,二叉树均没有左子节点或右子节点。因此,可以选取三个数,即左指针所指数、右指针所指数、中间位置的数值,取这三个数的中位数作为基准数。
快速排序的过程如下图所示,可见,快速排序二分的过程类似于二叉树,其时间复杂度为 \(O(logn)\),同时,在一趟快速排序中,以基准数为标准分离元素的时间复杂度为 \(O(n)\),因此,快速排序的时间复杂度为 \(O(nlogn)\),而其空间复杂度主要用于消耗递归操作所带来的栈帧,即二叉树的递归深度:
![](https://img2023.cnblogs.com/blog/2026333/202212/2026333-20221222195707133-1952710173.png)
快速排序的代码实现如下:
// 快速排序的分割函数
int partation(vector<int>& nums, int left, int right) {
int val = nums[left];
while (left < right) {
while (left < right && nums[right] > val) right--;
if (left < right) {
nums[left] = nums[right];
left++;
}
while (left < right && nums[left] < val) left++;
if (left < right) {
nums[right] = nums[left];
right--;
}
}
nums[left] = val;
return left;
}
// 快速排序的递归函数
void quickSort(vector<int>& nums, int left, int right) {
if (left >= right) return;
int pos = partation(nums, left, right);
quickSort(nums, left, pos - 1);
quickSort(nums, pos + 1, right);
}
// 快速排序
void quickSort(vector<int>& nums) {
quickSort(nums, 0, nums.size() - 1);
}
6.归并排序
特点:和快速排序一样,也采用“分治思想”,先进行序列划分,再进行元素的有序合并。
归并排序的过程如下图所示,可见,在“分”的过程中,其时间复杂度为 \(O(logn)\),而在“合”的过程中,其时间复杂度为 \(O(n)\),那么归并排序的时间复杂度就是 \(O(nlogn)\),同时归并排序在“合”的过程中也可以保证算法的稳定性。而对于空间复杂度而言,二叉树的递归深度为 \(O(logn)\),“合”的过程中则需要 \(O(n)\) 的额外空间,那么归并排序的空间复杂度就为 \(O(n)\):
![](https://img2023.cnblogs.com/blog/2026333/202212/2026333-20221222195743237-327710499.png)
其代码实现如下:
// 将 [left, mid] 和 [mid+1, right] 这两个有序序列合并为一个有序序列
void merge(vector<int>& nums, int left, int mid, int right) {
int* tmp = new int[right - left + 1];
int i = left, j = mid + 1, idx = 0;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
tmp[idx++] = nums[i++];
} else {
tmp[idx++] = nums[j++];
}
}
while (i <= mid) tmp[idx++] = nums[i++];
while (j <= right) tmp[idx++] = nums[j++];
for (i = left, j = 0; i <= right; i++, j++) {
nums[i] = tmp[j];
}
delete[] tmp;
}
// 归并排序的递归函数
void mergeSort(vector<int>& nums, int left, int right) {
if (left >= right) return;
int mid = (left + right) / 2;
mergeSort(nums, left, mid);
mergeSort(nums, mid + 1, right);
merge(nums, left, mid, right);
}
// 归并排序
void mergeSort(vector<int>& nums) {
mergeSort(nums, 0, nums.size() - 1);
}
7.堆排序
特点:堆排序就是通过利用[[二叉堆]]数据结构的性质,先一个无序序列调整为一个大根堆/小根堆,然后将堆顶元素与序列中最末尾位置的元素进行交换,完成后使用下沉操作重新调整堆结构,重复上述操作,直到整个序列有序即可。
例如,对于如下图所示的无序序列,先将其调整为大根堆/小根堆,从最后一个非叶子节点开始,即图中值为 6 的元素,从左至右,从下至上进行调整:
![](https://img2023.cnblogs.com/blog/2026333/202212/2026333-20221222195818810-652577879.png)
将其调整为二叉堆后,此时堆顶元素即为序列中的最大元素(如果是小根堆则为最小元素)。然后按照下图过程,将堆顶元素与序列中最后一个元素进行交换,那么最大元素则会被交换到序列的最末尾位置,即该元素已经排好序,后面操作时无需理会该元素。此时执行下沉操作,将二叉堆重新变为大根堆/小根堆,重复执行上述过程,直到整个序列排序完成:
![](https://img2023.cnblogs.com/blog/2026333/202212/2026333-20221222195847790-390166492.png)
堆排序的代码实现如下:
// 堆的下沉操作
void siftDown(vector<int>& nums, int idx, int size) {
int val = nums[idx];
// 下沉时不能超过最后一个有孩子的节点
while (idx < size / 2) {
int child = 2 * idx + 1;
if (child + 1 < size && nums[child + 1] > nums[child]) {
// 有右孩子 且右孩子的值大于左孩子 则将其更新为右孩子
child++;
}
if (nums[child] > val) {
nums[idx] = nums[child];
idx = child;
} else {
break;
}
}
nums[idx] = val;
}
// 堆排序
void heapSort(vector<int>& nums) {
int endIdx = nums.size() - 1; // 最后一个元素的索引
// 从第一个非叶子节点开始
for (int i = (endIdx - 1) / 2; i >= 0; i--) {
siftDown(nums, i, nums.size());
}
for (int i = endIdx; i > 0; i--) {
swap(nums[0], nums[i]);
siftDown(nums, 0, i);
}
}
8.基数排序
特点:基数排序也称为桶排序。
基数排序的过程如下:
- 找出位数最长的数字,确定要处理的位数,即基数排序的趟数;
- 依次由个位开始处理,把相应位数上的数字,放入相应序号的桶里面,完成后,再按照桶的序号,依次取出桶里面的数据,放回原始的数组当中
- 当处理完所有的位数,最终得到有序的序列。
基数排序的过程如下图所示:
![](https://img2023.cnblogs.com/blog/2026333/202212/2026333-20221222195922889-779487720.png)
其代码实现如下:
void radixSort(vector<int>& nums) {
int maxVal = nums[0];
for (int num : nums) maxVal = max(num, maxVal);
int len = to_string(maxVal).size();
vector<vector<int>> vecs;
int mod = 10, dev = 1;
for (int i = 0; i < len; i++, mod *= 10, dev *= 10) {
vecs.resize(10);
for (int j = 0; j < nums.size(); j++) {
int idx = (nums[j] % mod) / dev;
vecs[idx].push_back(nums[j]);
}
int idx = 0;
for (auto vec : vecs) {
for (auto v : vec) {
nums[idx++] = v;
}
}
vecs.clear();
}
}
基数排序的缺点是:其它七种排序方法可以直接对负数进行排序,而上述基数排序代码则无法对负数进行排序,因为计算桶下标时会产生越界行为。如果需要对负数进行排序,则需要对代码做出相应的修改。
9.总结
排序算法的时间复杂度和空间复杂度对比如下表所示:
排序算法 | 平均时间复杂度 | 最好时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | \(O(n^2)\) | \(O(n)\) | \(O(n^2)\) | \(O(1)\) | 稳定 |
选择排序 | \(O(n^2)\) | \(O(n^2)\) | \(O(n^2)\) | \(O(1)\) | 不稳定 |
插入排序 | \(O(n^2)\) | \(O(n)\) | \(O(n^2)\) | \(O(1)\) | 稳定 |
希尔排序 | \(O(n^{1.3})\) | \(O(n)\) | \(O(n^2)\) | \(O(1)\) | 不稳定 |
快速排序 | \(O(nlogn)\) | \(O(nlogn)\) | \(O(nlogn)\) | \(O(1)\) | 不稳定 |
归并排序 | \(O(nlogn)\) | \(O(nlogn)\) | \(O(n^2)\) | \(O(nlogn)-O(n)\) | 不稳定 |
堆排序 | \(O(nlogn)\) | \(O(nlogn)\) | \(O(nlogn)\) | \(O(n)\) | 稳定 |
基数排序 | \(O(nd)\) | \(O(nd)\) | \(O(nd)\) | \(O(n)\) | 稳定 |
算法的【稳定性】就是假定在待排序记录序列中,存在多个具有相同关键字的记录,如果经过排序以后,这些记录的相对次序保持不变,即原序列中,则称这种排序算法是稳定的,否则就是不稳定的。