这篇文章我们来学习现在最实用的七种排序:
一、直接插入排序
基本思想 :
直接插入排序是一种简单的插入排序算法 , 其基本思想是 : 把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有记录插入完为止,得到一个新的有序序列 。
这里用动图来体现的话就是这样。
代码演示
//插入排序
void InsertSort(int* a, int n)
{
for (int i = 1; i < n; i++)
{
if (a[i] < a[i - 1])//先判断,如果i下标的值大于前面的数,就不进入
{
int tmp = a[i];
int j;
for (j = i - 1; j >= 0 && a[j] >tmp; j--)
{
a[j+1] = a[j];
}
a[j+1] = tmp;
}
}
}
//两次循环就可以实现
//内部循环完成一趟的插入
//外层循环完成插入排序
这里我们总结一下:
直接插入排序的特性
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1),它是一种稳定的排序算法
4. 稳定性:稳定
再来对比之前学过的冒泡排序 ,
1 . 冒泡排序时间复杂度为 O(n^2)
3 . 直接插入排序时间复杂度为 O(n^2)
在冒泡排序中 , 如果序列本身为有序 , 时间复杂度最优 O(n) ; 在直接插入排序中 , 如果序列为有序 , 且为降序 , 时间复杂度最差 O (n^2) , 但这种情况的出现概率很小 ,在一定程度上可以说 , 直接插入排序的时间复杂度达不到 O(n^2) , 但也没比冒泡排序好了多少 。
二、希尔排序
在数组为降序时 , 直接插入排序的时间复杂度为 O(n^2) , 那么可以优化吗 ?
答案是可以的 , 将数组划分成多组来进行直接插入排序,降低时间复杂度
希尔排序法又称缩小增量法 。
希尔排序法的基本思想是 : 选定一个整数 ( 通常是gap = n/3 + 1 ) , 把待排序文件所有记录分成各组 , 所有的距离相等的记录分在同一组内 , 并对每一组内的记录进行排序 ,
然后gap = gap / 3 +1 得到下一个整数 , 再将数组分成各组 , 进行插入排序 , 当 gap = 1 时 , 就相当于直接插入排序 。
它是在直接插入排序算法的基础上进行改进而来的 , 综合来说它的效率肯定是要高于直接插入排序算法的 。
那么这里我们就遇到了一些问题:
1 ) 为什么要分组 ?
通过分组来降低元素之间的比较次数,优化时间复杂度
2 ) 怎么分组 ?
通过间隔 gap =(gap / 3 + 1 ) 的元素 组成一组
3 )为什么要 +1 , 不直接 gap/3 ?
假设gap = 3 , gap/3 = 0 , 此时数据还没有排序好 就终止了排序 , 希望是当gap == 1 时 , 预排序结束 , 然后进行直接插入排序 。
4 ) 可以gap/2 , gap/8 吗?
可以 , 视情况而论 , 一般是 gap/3 , 因为除小了 , 分组过多就过多 ; 除大了 , 比较次数就多了。
希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的都不一样。小编翻了很多的书得出一个具体的数值时间复杂度平均:O(N^1.3)
空间复杂度:O(1)
//希尔排序
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap ; i++)
{
int end = i;
int tmp = arr[end + gap];
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
三、选择排序
思路:
1.内层循环一趟找出最小值的下标,与第一个数交换。重复找小,交换的两个操作。
2.实际上,我们可以一趟选出两个值,一个最大值一个最小值,然后将其放在序列开头和末尾,这样可以使选择排序的效率快一倍。但时间复杂度还是O(N^2),效率不高。
//直接选择排序
void SelectSort(int* arr, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin;
//找最大最小
for (int i = begin + 1; i <= end; i++)
{
if (arr[i] < arr[mini])
{
mini = i;
}
if (arr[i] > arr[maxi])
{
maxi = i;
}
}
//如果maxi == began
if (maxi == begin)
{
maxi = mini;
}
Swap(&arr[mini], &arr[begin]);
Swap(&arr[maxi], &arr[end]);
begin++;
end--;
}
}
四、堆排序
先来认识堆:
1.什么是堆?
大堆:父亲大于儿子 小堆:父亲小于儿子
2.堆的物理结构和逻辑结构?
物理结构:数组 逻辑结构:完全二叉树
这里举个例子:
//向下调整算法(要满足它下面的都满足堆,才能用)
void AdjustDown(int* a, int n, int root)
{
int parent = root;
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child] < a[child + 1]) child+=1;//把他移到右孩子那里
if (a[child] > a[parent])
{
swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else break;
}
}
堆排序
void HeapSort(int* arr, int n)
{
//建大堆
//从最后一个根开始,就相当于它下面的都满足堆,就可以用向下调整算法
for (int i = (n-1-1)/2; i >= 0; i--)//n-1-1是因为数组的最后一个元素下标是n-1
{
AdjustDown(arr, n, i);
}
//排序
for (int i = n; i > 1; i--)
{
swap(&arr[0],&arr[i - 1]);
AdjustDown(arr, i-1, 0);
}
}
我们把堆排序和我们的选择排序一比较:1.堆排序使用堆来选数,效率就高了很多。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
五、快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
但是这种方法看起来就有投机取巧的意味在里面,所以我们针对不同的问题来基于这个算法思想做了不同版本的优化。才衍生出了一系列的办法。
1.hoare原始版本(左右指针法)
1、选出一个key,一般是最左边或是最右边的。
2、定义一个begin和一个end,begin从左向右走,end从右向左走。(需要注意的是:若选择最左边的数据作为key,则需要end先走;若选择最右边的数据作为key,则需要bengin先走)。
3、在走的过程中,若end遇到小于key的数,则停下,begin开始走,直到begin遇到一个大于key的数时,将begin和right的内容交换,end再次开始走,如此进行下去,直到begin和end最终相遇,此时将相遇点的内容与key交换即可。(选取最左边的值作为key)
4.此时key的左边都是小于key的数,key的右边都是大于key的数。
5.将key的左序列和右序列再次进行这种单趟排序,如此反复操作下去,直到左右序列只有一个数据,或是左右序列不存在时,便停止操作,此时此部分已有序。
动图演示如下
//快速排序 hoare版本(左右指针法)
void QuickSort(int* arr, int begin, int end)
{
//只有一个数或区间不存在
if (begin >= end)
return;
int left = begin;
int right = end;
//选左边为key
int keyi = begin;
while (begin < end)
{
//右边选小 等号防止和key值相等 防止顺序begin和end越界
while (arr[end] >= arr[keyi] && begin < end)
{
--end;
}
//左边选大
while (arr[begin] <= arr[keyi] && begin < end)
{
++begin;
}
//小的换到右边,大的换到左边
swap(&arr[begin], &arr[end]);
}
swap(&arr[keyi], &arr[end]);
keyi = end;
//[left,keyi-1]keyi[keyi+1,right]
QuickSort(arr, left, keyi - 1);
QuickSort(arr,keyi + 1,right);
}
2.挖坑法
这里的话跟原始方法比较相似,不过这里巧妙就巧妙在用空间换时间,单独把key拿出来,如果我比key小就去他左边那个坑。减少了一定意义上的调用,小规模看不出来,大规模的软件应用上可就非常有用了。
如果有读者不知道我为什么这样说的话可以去看一下这篇文章。详细讲解了调用到实战的一些程序性能测试结果。相信你看完一定有所感悟。
接下来我们讲一下思路:
选出一个数据(一般是最左边或是最右边的)存放在key变量中,在该数据位置形成一个坑。还是定义一个left和一个right,left从左向右走(当遇到大于key的值时停下来)。right从右向左走(当遇到小于key的值时停下来)。(若在最左边挖坑,则需要right先走;若在最右边挖坑,则需要left先走)
把right的那个小的数放在坑中,在把left那个位置的值放在right那个位置中
重复操作,直到left>right时结束,完成一趟,把key放在了正确的位置
最后用分治思想,分成左边和右边,递归。
//快速排序法 挖坑法
void QuickSort1(int* arr, int begin, int end)
{
if (begin >= end)
return;
int left = begin,right = end;
int key = arr[begin];
while (begin < end)
{
//找小
while (arr[end] >= key && begin < end)
{
--end;
}
//小的放到左边的坑里
arr[begin] = arr[end];
//找大
while (arr[begin] <= key && begin < end)
{
++begin;
}
//大的放到右边的坑里
arr[end] = arr[begin];
}
arr[begin] = key;
int keyi = begin;
//[left,keyi-1]keyi[keyi+1,right]
QuickSort1(arr, left, keyi - 1);
QuickSort1(arr, keyi + 1, right);
}
3.前后指针法
这里有点类似与我们之前学的快慢指针法。
思路如下:
1、选出一个key,一般是最左边或是最右边的。
2、起始时,prev指针指向序列开头,cur指针指向prev+1。
3、若cur指向的内容小于key,则prev先向后移动一位,然后交换prev和cur指针指向的内容,然后cur指针++;若cur指向的内容大于key,则cur指针直接++。如此进行下去,直到cur到达end位置,此时将key和++prev指针指向的内容交换即可。
经过一次单趟排序,最终也能使得key左边的数据全部都小于key,key右边的数据全部都大于key。
然后也还是将key的左序列和右序列再次进行这种单趟排序,如此反复操作下去,直到左右序列只有一个数据,或是左右序列不存在时,便停止操作。
//快速排序法 前后指针版本
void QuickSort2(int* arr, int begin, int end)
{
if (begin >= end)
return;
int cur = begin, prev = begin - 1;
int keyi = end;
while (cur != keyi)
{
if (arr[cur] < arr[keyi] && ++prev != cur)
{
swap(&arr[cur], &arr[prev]);
}
++cur;
}
swap(&arr[++prev],&arr[keyi]);
keyi = prev;
//[begin,keyi -1]keyi[keyi+1,end]
QuickSort2(arr, begin, keyi - 1);
QuickSort2(arr, keyi + 1, end);
}
快速排序优化的思考
1.快排用的都是递归形式,但是对于一些数据集或者对于整体来说,我要加入额外的条件的时候,是不是就会牵一发动全身?而且对于我们新手程序员来说,堆栈的调用我们难以直观跟踪啊。还有我要是数据集大一点,栈溢出了怎么办??那我能不能用一个效率差点,但是实用性高的非递归形式,来实现快速排序??
可以的。这里我们看不到递归,那我们就可以借用我们已经学过的数据结构来解决这个问题。这里我们就要用到显式栈。
void quickSortIterative(int arr[], int l, int h) {
stack<pair<int, int>> st;
st.push({l, h});
while (!st.empty()) {
auto [low, high] = st.top();
st.pop();
if (low >= high) continue;
// 可自由插入各种优化条件
if (high - low + 1 < 16) {
insertionSort(arr, low, high);
continue;
}
int pivot = partition(arr, low, high);
st.push({low, pivot - 1});
st.push({pivot + 1, high});
}
}
2.我们也说了,快排是不稳定的,那么如何减少快排的性能退化呢?
快排性能的关键点分析 : 决定快排性能的关键点是每次单趟排序后 , key 对数组的分割 , 如果每次选key 基本二分居中,那么快排的递归树就是颗均匀的满二叉树,性能最佳。
但是实际中虽然不可能每次都是二分居中,但是性能也还是可控的。但是如果出现每次选到的最小值/最大值,划分为0个和N-1个子问题时,时间复杂度会退化,时间复杂度为O(N^2) , 数组有序的时候会出现这样的问题 , 可以使用 三数取中 或者 随机选key 解决这个问题 , 但是还是有一些情景没有解决(有大量重复的数据)。
那我们就来看一下三路划分:
当面对有大量跟key相同的值时 , 三路划分的核心思想有点类似hoare的左右指针 和 前后指针的结合 。 核心思想是把数组中的数据分成三段 :
【比key小的值】 【与key相等的值】 【比key大的值】
所以,称之为三路划分法 !
代码思路:
1 . key 默认取 left 位置的值
2 . left 指向区间最左边 , right 指向区间最右边 , cur指向left + 1 的位置
3 . cur 遇到比key小的值后 , left 与 cur交换 , left++ , cur--
4 . cur 遇到比key大的值后 , right 与 cur交换 ,right--
5 . cur遇到跟key相等的值后 , cur++
6 . 直到 cur > right 结束
KeyWayIndex PartSort3Way(int* a, int left, int right)
{
int key = a[left];
// left和right指向就是跟key相等的区间
// [开始, left-1][left, right][right+1, 结束]
int cur = left + 1;
while (cur <= right)
{
// 1、cur遇到⽐key⼩,⼩的换到左边,同时把key换到中间位置
// 2、cur遇到⽐key⼤,⼤的换到右边
if (a[cur] < key)
{
Swap(&a[cur], &a[left]);
++cur;
++left;
}
else if (a[cur] > key)
{
Swap(&a[cur], &a[right]);
--right;
}
else
{
++cur;
}
}
KeyWayIndex kwi;
kwi.leftKeyi = left;
kwi.rightKeyi = right;
return kwi;
}
但是这里又涉及一个内省排序。
内省排序
Introsort 是一种混合排序算法,它结合了快速排序(Quicksort)、堆排序(Heapsort)和插入排序(Insertion Sort)的优点。其核心思想在于动态调整排序策略:当快速排序的递归深度过大时(通常意味着数据分布不利于快速排序,如已排序或接近排序的数据),算法会切换到堆排序,以保证整体性能不会严重退化。
也就是说我这里会派一个变量去监控我这段代码。
如果递归深度超过某个阈值(通常是 2 * log(n),其中 n 是数组的大小),算法会认为快速排序的性能正在退化。转而放弃递归,再使用堆排序。
#include <iostream>
#include <vector>
#include <cmath> // 用于计算 log
using namespace std;
// 假设的堆排序实现(简化版,仅作示意)
void HeapSort(vector<int>& arr, int left, int right) {
// 这里应该实现堆排序逻辑
// 由于堆排序实现较复杂,这里仅作占位
sort(arr.begin() + left, arr.begin() + right + 1); // 使用 std::sort 作为替代
}
// 分区函数,返回枢轴元素的最终位置
int Partition(vector<int>& arr, int left, int right) {
int pivot = arr[right]; // 选择最后一个元素作为枢轴
int i = left - 1;
for (int j = left; j < right; ++j) {
if (arr[j] < pivot) {
++i;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[right]);
return i + 1;
}
// 计算以2为底的对数,并向上取整
int Log2Ceil(int n) {
return static_cast<int>(ceil(log2(n)));
}
// Introsort 主函数
void Introsort(vector<int>& arr, int left, int right, int depthLimit) {
if (left >= right) return;
if (depthLimit == 0) {
// 递归深度超过限制,使用堆排序
HeapSort(arr, left, right);
return;
}
// 使用快速排序进行分区
int pivotIndex = Partition(arr, left, right);
// 递归排序左半部分和右半部分
Introsort(arr, left, pivotIndex - 1, depthLimit - 1);
Introsort(arr, pivotIndex + 1, right, depthLimit - 1);
}
int main() {
vector<int> arr = {3, 6, 8, 10, 1, 2, 1};
int depthLimit = 2 * Log2Ceil(arr.size()); // 计算深度限制
Introsort(arr, 0, arr.size() - 1, depthLimit);
// 输出排序后的数组
for (int num : arr) {
cout << num << " ";
}
cout << endl;
return 0;
}
六、归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
#include <iostream>
#include <vector>
using namespace std;
// 合并两个有序子数组
void merge(vector<int>& arr, int left, int mid, int right) {
// 创建临时数组存储合并结果
vector<int> temp(right - left + 1);
int i = left, j = mid + 1, k = 0;
// 合并两个子数组
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
// 复制剩余元素(若有)
while (i <= mid) {
temp[k++] = arr[i++];
}
while (j <= right) {
temp[k++] = arr[j++];
}
// 将合并结果复制回原数组
for (int p = 0; p < temp.size(); ++p) {
arr[left + p] = temp[p];
}
}
// 归并排序主函数
void mergeSort(vector<int>& arr, int left, int right) {
if (left >= right) return; // 递归终止条件
int mid = left + (right - left) / 2; // 防止溢出
mergeSort(arr, left, mid); // 排序左半部分
mergeSort(arr, mid + 1, right); // 排序右半部分
merge(arr, left, mid, right); // 合并两个有序子数组
}
int main() {
vector<int> arr = {38, 27, 43, 3, 9, 82, 10};
mergeSort(arr, 0, arr.size() - 1);
// 输出排序后的数组
for (int num : arr) {
cout << num << " ";
}
cout << endl;
return 0;
}
归并排序的特性总结:
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定
好了最后我们再来看一下六大排序的性能比较。
感谢你看完了这篇一万字的文章,希望对你有所帮助。
如果你觉得对你有帮助,可以点赞关注加收藏,感谢您的阅读,我们下一篇文章再见。
一步步来,总会学会的,首先要懂思路,才能有东西写。