目录
[1. 快速排序](#1. 快速排序)
[1.1 动态演示](#1.1 动态演示)
[1.2 代码实现](#1.2 代码实现)
[1.2.1 经典快排](#1.2.1 经典快排)
[1.2.2 优化快排(三数选中,小区间优化)](#1.2.2 优化快排(三数选中,小区间优化))
[1.2.3 双指针快排](#1.2.3 双指针快排)
[1.2.4 非递归快排(栈实现)](#1.2.4 非递归快排(栈实现))
[2. 归并排序](#2. 归并排序)
[2.1 动态演示](#2.1 动态演示)
[2.2 代码实现](#2.2 代码实现)
[2.2.1 经典归并(递归)](#2.2.1 经典归并(递归))
[2.2.2 非递归归并(循环实现)](#2.2.2 非递归归并(循环实现))
[3. 计数排序](#3. 计数排序)
[3.1 代码实现](#3.1 代码实现)
[4. 总结](#4. 总结)
1. 快速排序
- 时间复杂度: O( NlogN )
- 空间复杂度: O( logN )
- 思想: 找一个基准值,把比它小的放左边,比它大的放右边,然后左右两边递归重复这个过程。
空间复杂度之所以不是O( 1 ),是因为一般的快排都是通过递归实现的,而递归就要不断建立和销毁函数栈帧,因此也会消耗空间。
常见问题和解答:
Q:为什么快排需要先移动右指针?
A:先移动right可以保证两个指针相遇时所指向的值是小于key的。
最后一次移动分为两种情况:
一种是right找到了小于等于key的值,left没有找到大于等于key的值,不断移动直至与right相遇;
另一种是right没有找到小于等于key的值,一直移动直至与left相遇,但此时left位置的值已经在上次移动时交换了,变为了比key小的值,因此相遇点的值还是小于key的。
Q:为什么判断左右指针和key的大小时都要带等号?
A:是为了让等于key的值均匀分布在数组中,使递归更平衡。如果去掉等号,所有等于key的值会堆积在数组一侧,当数组元素相等时,时间复杂度会退化至O(N^2)。
举个例子:
如果一个数组全是1,在左右指针都没有等号的情况下,left和right都不会移动,会导致死循环;如果只有一侧有等号,带等号的指针会一直移动直至与另一侧指针相遇,这样会导致递归树极不平衡。因此加上等号可以显著改善这样的情况。
1.1 动态演示
快速排序
1.2 代码实现
1.2.1 经典快排
**思路:**选取指定区间最左侧元素为key,设置两个指针从两端向中间会合,保证相遇点左侧全部元素小于key,右侧全部元素大于key;然后交换key和相遇点元素,以key为分割点,对左右区间再次快排(递归)。
cpp
//快速排序(递归)
void QuickSort1(int* a, int begin, int end)
{
//终止条件
if(begin >= end)
return;
//begin和end用于标记区间范围
//begin和end是在区间内移动的指针
int keyi = begin;
int left = begin;
int right = end;
while(left < right)
{
//先移动右指针,小于keyi就停下
while(left < right && a[right] >= a[keyi])
{
right--;
}
//再移动左指针,大于keyi就停下
while(left < right && a[left] <= a[keyi])
{
left++;
}
//交换两个指针的值
Swap(&a[left], &a[right]);
}
//当两指针重合时交换keyi和重合点,更新keyi
Swap(&a[keyi], &a[left]);
keyi = left;
//继续递归
QuickSort1(a, begin, keyi - 1);
QuickSort1(a, keyi + 1, end);
}
//基础快排(三数选中,小区间优化,递归)
int GetMidi(int* a, int begin, int end)
{
int midi = (begin + end) / 2;
if(a[begin] < a[midi])
{
if(a[end] < a[midi])
{
if(a[begin] < a[end])
{
return end;
}
else
{
return begin;
}
}
else
{
return midi;
}
}
else // a[begin] > a[midi]
{
if(a[end] < a[begin])
{
if(a[end] < a[midi])
{
return midi;
}
else
{
return end;
}
}
else
{
return begin;
}
}
}
1.2.2 优化快排(三数选中,小区间优化)
三数选中:
原因: 由于经典快排中每次选取区间最左侧元素作为key会导致递归不平衡 ,可能会出现递归区间极小和极大的情况。因此通过三数选中可以尽量令key的大小落在区间的中心,从而平衡递归区间。
具体方案: 选取区间首尾和中间位置的元素,并返回大小居中元素的对应下标。
小区间优化:**原因:**当区间中的元素个数屈指可数时,反复递归会增加函数调用次数,减少效率。
具体方案: 在区间元素个数在10个以内时,直接用插入排序解决。
插入排序的原理实现详细介绍可以看上一篇:
cpp
//快速排序(三数选中,小区间优化,递归)
int GetMidi(int* a, int begin, int end)
{
int midi = (begin + end) / 2;
if(a[begin] < a[midi])
{
if(a[end] < a[midi])
{
if(a[begin] < a[end])
{
return end;
}
else
{
return begin;
}
}
else
{
return midi;
}
}
else // a[begin] > a[midi]
{
if(a[end] < a[begin])
{
if(a[end] < a[midi])
{
return midi;
}
else
{
return end;
}
}
else
{
return begin;
}
}
}
void QuickSort2(int* a, int begin, int end)
{
//小区间优化(插入排序)
if((end - begin) + 1 <= 10)
{
InsertSort(a+begin, end-begin+1);
return;
}
//三数取中
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
//begin和end用于标记区间范围
//begin和end是在区间内移动的指针
int keyi = begin;
int left = begin;
int right = end;
while(left < right)
{
//先移动右指针,小于keyi就停下
while(left < right && a[right] >= a[keyi])
{
right--;
}
//再移动左指针,大于keyi就停下
while(left < right && a[left] <= a[keyi])
{
left++;
}
//再次比较减少一次自交换
if(left < right)
{
Swap(&a[left], &a[right]);
}
}
//当两指针重合时交换keyi和重合点,更新keyi
Swap(&a[keyi], &a[left]);
keyi = left;
//继续递归
QuickSort2(a, begin, keyi - 1);
QuickSort2(a, keyi + 1, end);
}
1.2.3 双指针快排
类似移动零等常见力扣双指针题。
**思路:**设置key和快慢两个指针,同时从区间左端出发,fast指针遇到小于key的则和slow进行交换,大于key则继续走,直至到区间末尾。此时slow指针所在位置即为分界点,交换slow和key的值和位置,即可达到和经典快排一样的效果。随后再次对key左右两区间进行快排(递归)。
仅仅优化了代码量,在性能上还是和普通快排相似。
cpp
//快速排序(双指针)
int PartSort(int* a, int left, int right)
{
int keyi = left;
int prev = left;
int cur = prev + 1;
while(cur <= right)
{
if(a[cur] <= a[keyi])
{
prev++;
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
void QuickSort3(int* a, int begin, int end)
{
int left = begin;
int right = end;
if(right - left + 1 < 10)
{
InsertSort(a + begin, end - begin + 1); //特别注意不是从0位置开始插入排序!不要传错了
}
else
{
//双指针排序
int keyi = PartSort(a, left, right);
QuickSort3(a, begin, keyi - 1);
QuickSort3(a, keyi + 1, end);
}
}
1.2.4 非递归快排(栈实现)
利用快速排序属于前序 排序的特点,可以利用栈的后进先出特性实现快速排序:
cpp
//快速排序(非递归:栈)
void QuickSortNonR(int* a, int begin, int end)
{
//数组首尾下标入栈
ST st;
STInit(&st);
STPush(&st, begin);
STPush(&st, end);
//循环出入
while(!STEmpty(&st))
{
//先入后出
int right = STTop(&st);
STPop(&st);
int left = STTop(&st);
STPop(&st);
//排序
int keyi = PartSort(a, left, right);
//入栈(要判断边界是否有效!)
// [left, keyi - 1] keyi [keyi + 1, right]
if(left < keyi - 1)
{
STPush(&st, left);
STPush(&st, keyi - 1);
}
if(keyi + 1 < right)
{
STPush(&st, keyi + 1);
STPush(&st, right);
}
}
STDestroy(&st);
}
关于栈的具体实现可以看这篇文章:
2. 归并排序
- 时间复杂度: O( NlogN )
- 空间复杂度: O( N )
- **思想:**把数组不断分成两半,分别排好序,再把两个有序数组合并成一个,继续归并直至整个数组有序。
由于需要另开一个等大的数组存储数据,因此空间复杂度也不是O( 1 )。
2.1 动态演示
归并排序
2.2 代码实现
2.2.1 经典归并(递归)
cpp
//归并排序(递归)
void _MergeSort(int* a, int* tmp, int begin, int end)
{
if(begin >= end) return;
int mid = (begin + end) / 2;
_MergeSort(a, tmp, begin, mid);
_MergeSort(a, tmp, mid + 1, end);
int begin1 = begin, begin2 = mid + 1;
int end1 = mid, end2 = end;
int i = begin;
while(begin1 <= end1 && begin2 <= end2)
{
if(a[begin1] <= a[begin2]) //取等时则排序稳定
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
//处理剩余数据
while(begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while(begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
//拷贝(不能用begin1因为已经被更改)
memcpy(a+begin, tmp+begin, (end - begin + 1) * sizeof(int));
}
2.2.2 非递归归并(循环实现)
利用归并排序是后序排序的特点(即不断拆分至最小单位再进行排序),我们可以使用循环,设置gap为每组的元素个数,通过增加gap来不断增加排序数量,从而对数组实现归并排序。
cpp
//归并排序(非递归:循环)
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(n*sizeof(int));
if(tmp == NULL)
{
perror("malloc fail");
exit(1);
}
//gap为每组归并的数据个数
int gap = 1;
while(gap < n)
{
for(int i=0; i<n; i+= 2*gap)
{
//选出两组归并
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + gap * 2 - 1;
//判断是否符合归并条件
if(begin2 >= n) //第二组全部越界,则第一组已经有序,不必继续归并
{
break;
}
if(end2 >= n) //第二组end2越界
{
end2 = n - 1;
}
int j = i; //两组归并起点
while(begin1 <= end1 && begin2 <= end2)
{
if(a[begin1] <= a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
//放入剩余数据
while(begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
while(begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
//拷贝
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
//gap每次乘2,扩大归并范围
gap *= 2;
}
free(tmp);
tmp = NULL;
}
3. 计数排序
- 时间复杂度:O( N + range )
- 空间复杂度:O( range )
- **思路:**统计每个值出现的次数,根据次数把数放回数组中,覆盖原有元素。
- **本质:**利用count数组中的自然序号排序
- 问题: 数据相差特别大时开辟的空间浪费也会很大
优化-> 按照范围开
这里的range为数据范围(max - min)
Q:负数的情况还能排序吗?
A:可以,因为存在a[i] - min,min为负数,减去相当于增加-min,偏移量还是在范围内的,不用担心下标会越界。
3.1 代码实现
**Step1:**遍历一遍找出最大最小值,确定范围range
**Step2:**开辟一块range范围的空间,初始化为0(calloc)
**Step3:**统计数据次数
**Step4:**在原数组中写入数据
cpp
//计数排序
void CountSort(int* a, int n)
{
//step1:找出最大最小值,确定范围
int max = a[0];
int min = a[0];
for(int i=0; i<n; i++)
{
if(a[i] < min)
{
min = a[i];
}
if(a[i] > max)
{
max = a[i];
}
}
int range = max - min + 1;
//step2:开辟空间
int* tmp = (int*)calloc(range, sizeof(int));
//Allocates a block of memory for an array of num elements,
//each of them size bytes long,
//and initializes all its bits to zero.
if(!tmp)
{
perror("malloc fail");
exit(1);
}
//step3:统计次数
for(int i=0; i<n; i++)
{
tmp[a[i] - min]++;
}
//step4:排序
int j = 0;
for(int i=0; i<range; i++)
{
while(tmp[i]--)
{
a[j++] = i + min;
}
}
}
4. 总结
在了解众多经典排序后,我们可以根据他们各自的特点总结出一份对比表格:
(稳定性指的是在遇到相等元素时,元素在数组中的相对位置是否会发生改变。若不会改变,则说明这个排序是稳定的,否则不稳定)
|--------|-----------------------------|--------------------|-------------------------------------------------------------------------------------------|
| 排序 | 时间复杂度 | 空间复杂度 | 稳定性 |
| 直接插入排序 | O(N^2) | O(1) | 稳定 (可以选择在等于时不移动直接插入) |
| 希尔排序 | O(N^1.3) | O(1) | 不稳定 (相同的数据预排序时会分到不同的组,无法控制) |
| 选择排序 | O(N^2) (升序每次遍历选出最小的,共遍历N次) | O(1) | 不稳定 Eg:6的位置被交换了
|
| 堆排序 | O(NlogN) | O(1) | 不稳定 Eg:全是2,交换时顺序全乱 |
| 冒泡排序 | O(N^2) | O(1) | 稳定(相等时可以不交换) |
| 快速排序 | O(NlogN) | O(logN) (递归建立函数栈帧) | 不稳定(相等时,相交会改变顺序) |
| 归并排序 | O(NlogN) | O(N) (需要新建数组) | 稳定(在比较begin1和begin2时加上等号,确保begin1先放入tmp中) |
结论:只要涉及交换大概率不稳定
//感谢阅读,看完顺手点个赞吧( ̄︶ ̄)↗