前言
此篇的文章主要是博主为了方便自己复习所以字数很多.大家想看那个看看就好,哪里不会可以私信博主.
总体表格
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
直接插入排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
希尔排序 | O(nlogn)~O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n2) | O(n) | 不稳定 |
912. 排序数组 - 力扣—可以用这个来测试自己的排序函数是否正确,虽然大部分会超时🤪.
排序定义
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次 序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排 序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
排序时使用的Swap函数如下:
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
冒泡排序
冒泡排序是一种非常容易理解的排序
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:稳定
最早学会的排序,以易懂和稳定著称.
冒泡排序的思想如下:
通过将最大的数值向后移动到排序区间的最后来进行排序.
代码如下:
void BubbleSort(int* a, int n)//a是要排序的数组,n是a数组的大小
{
for (int i = n; i >= 0; i--)
{
for (int j = 1; j < i; j++)
{
if (a[j - 1] > a[j])
{
Swap(&a[j - 1], &a[j]);
}
}
}
}
第一个for循环用来控制我们要排序的空间(及i的数值),如刚开始时我们要排序的空间是整个n空间
然后经过第二个for循环后我们将最大的数值一直向后排,排到要排序空间的最后方
然后通过缩减要排序空间知道排序空间为0即排序完毕.
插入排序
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
//直接插入排序
void InsertSort(int* a, int n)
{
//直接插入排序:通过将一个数组分成有序和无序两种状态,然后通过将无序部分一个个的进入
//有序的部分并且让进入的那个元素融入到有序的中即一个个的进行比较然后放到相应的位置
//即可
for (int i = 0; i < n - 1; i++)//因为i是赋值给end的所以只需到n-2即可
{
int end = i;//有序区间的结束位置
int tmp = a[end + 1];//tmp为要插入的无序区间部分
while (end >= 0)
{
if (a[end] >= tmp)
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;//end+1的原因是我们上面的end--使我们应该插入tmp的位置后移了一个单位
}
}
上述代码的注释基本讲解完毕.
直接插入的排序思想如下:
我们将一个数组分为有序部分和无序部分两种状况.
刚开始时有序部分内容仅有下标为0的数组值(及0<=有序区间<=end—end为有序区间结束位置)
然后我们将end+1的位置插入到我们的有序空间内.
插入过程为下面的while区间所示通过将有序空间内的元素与tmp的值进行比较然后不断移动给我们的tmp部分留下位置最后在循环结束时将tmp插入的正确的位置即可.
然后有序空间就增加了1无序空间就减小了一个单位.
一直到end位置为n-2及将n-1下标位置插入到有序空间就结束循环.
我们走一遍我们的程序
开始时我们的i=0;此时end=0;(及我们的有序区间是0~0及下标为0的元素就是我们的有序区间—只有一个元素的时候我们就不用追究他的有序还是无序了.)然后我们把tmp插入到我们的有序区间内及(将end+1位置的元素插入到我们的有序区间内).然后我们进入我们的while循环内.每当我们发现a[end]>tmp的时候我们就将这个end向后移动直到我们找到a[end]<tmp就跳出循环将tmp插入到哪里即可.
比如我们要将已经排序部分的有序空间1 3 5 插入2. 然后进入while循环
一次循环时end指向5进行if判断后有序空间变成了 1 3 5 5;
然后end--
让end 指向3再进行if判断有序空间就变成1 3 3 5;
然后end--
让end指向1时进行if判断后发现a[end]<tmp;进入else分支.
break跳出while后end还是指向1我们要插入tmp的位置应该是无意义的及end+1的位置.
希尔排序
时间复杂度: O(n1.25)~O(1.6*n1.25)—这个复杂度是大佬们求出的,会在希尔排序的末端把大佬们的原话复制出来🤓.
空间复杂度:O(1);
稳定性: 不稳定
希尔排序是用插入的思想但是先将数组分为有间隔的不同的小数组,先对这些有间隔的小数组进行排序(及预排序)然后再对整个数组进行间隔为1(及正常插入排序)的排序.
这样使用的原因是因为我们的插入排序可以在数组为有序的时候极快的排序.所以希尔排序其实就是多了预排序的插入排序
void ShellSort(int* a, int n)
{
int gap = n;//gap为排序的间隔,初始化为n是为了下面/3后保证gap既不大于n又足够大.(也可能跟效率有关)
while (gap > 1)//当gap为1的时候走完下面的操作后才跳出循环
{
gap = gap / 3 + 1;// 这个/3也可以除其他的而且会影响效率但是还没有最好的固定值出现,+1是为了防止gap为0我们需要保证gap最小为1
for (int i = 0; i < n - gap; i++)//因为i是赋值给end的所以只需到n-gap-1即可,不然就越界了.
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] >= tmp)
{
a[end + gap] = a[end];//以gap为间隔分成组来进行的所以+gap
end -= gap;//在gap不为1的时候是预排序,相当于间隔为gap的插入排序
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
如果可以直接看代码+注释解决就不用看下面的解释了.
我们用gap作为间隔.第一次进入while循环时gap=gap/3+1了这个+1是为了保证我们的gap最小为1,因为我们需要在gap为1的时候进行一次排序(及插入排序)
还是搞个例子听听吧:
如:1,4,5,2,3,7,6,9,8
n=9;
进入程序:
gap = 9;
进入while
gap=9/3+1=3+1=4;
我们就以4为间隔进行以预排序
以4为间隔我们可以将数组分为1,3,8
;4,7
;5,6
;2,9
;3,8
;这几组我们对每一组都进行一次插入排序
如对第一组就可以理解为将3插入1这个有序部分然后将8插入到1,3这个有序部分内.(例子不太好,预排没展现出来=-=).
然后再次调整gap;
这次有gap = 4/3+1 =2;再以2为一组分几组.进行预排
排完后再次调整gap = 2/3+1 = 1;
当gap为1的时候就和普通插入排序相同了,但是经历了预排的数组更加接近有序时间复杂度也就会更低.
选择排序
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
其实这玩意没啥好讲的,就从数组中选出最大和最小的数然后分别放在数组的最后和最先方,或者只选出一个最大或最小的放在最前或最后.
很好理解没啥可讲的就直接上代码了.
//每次只弄一个最大的放在数组最后
void SelectSort1(int* a, int n)
{
for (int i = n - 1; i >= 0; --i)
{
int big = 0;
int j = 0;
while (j <= i)
{
if (a[j] > a[big])
{
big = j;
}
j++;
}
Swap(&a[big], &a[i]);
}
}
void SelectSort2(int* a, int n)
{
int left = 0;
int right = n - 1;
while (left < right)
{
int min = left;
int big = left;
for (int i = left + 1; i <= right; i++)
{
if (a[i] > a[big])
big = i;
if (a[i] < a[min])
min = i;
}
Swap(&a[left], &a[min]);
if (big == left)//当big与left相同时因为前面已经将left的值交换走所以我们真正想得到的值其实在min的位置
big = min;
Swap(&a[big], &a[right]);
right--;
left++;
}
}
快速排序
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
- 时间复杂度:O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
一个被大佬选择并加入到函数库的排序,属于是老棒老棒的排序了.
想讲述一下快速排序的思想吧.
快速排序是通过在数组中选出一个基准值
(一般选第一个或最后一个)将基准值放在一个左边的数都比基准值小(大)右边的数都比基准数大(小)然后通过基准值又将数组分开成两个然后再进行上述操作.
例: 4 1 3 2 5 8 7 9 以4为基准值经过了一次快速排序后就可以将4放到1 3 2 4 5 8 7 9(注:不同实现方法的顺序是不同的博主的注重点是将4左边数比4小右边比数大的情况.)
这种排序主干有三种实现方法.分hoare版本
挖坑法
前后指针版本
hoare版本实现
思路就是上面的思路,我们先来通过hoare版本
来实现一下.
int PartSort1(int* a, int left, int right)
{
//整体思路是先让right先走找到一个比a[keyi]小的数然后等待left找到比a[keyi]大的数
//然后交换即可.
int keyi = left;//不能给零,因为每次递归的时候我们都要通过不同的left来操作.
while (left < right)
{
if (a[right] < a[keyi])//先让right先走为了保证left和right听的位
{
while (left < right)
{
if (a[left] > a[keyi])
{
Swap(&a[left], &a[right]);
break;//记得break因为我们要先right走.
}
left++;
}
}
right--;
}
Swap(&a[left], &a[keyi]);//最后记得把keyi的换位完成
return left;
}
void QuickSort(int* a, int left, int right)
{
if (left > right)
{
return;
}
int keyi = PartSort1(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
其中PartSort1是我们排序的主干部分,也就是一次排序的经过,然后下面的QuickSort这个函数就是为了给我们的PartSort1提供要排序的空间.
先简单的讲一下QuickSort函数吧,这个函数的作用是为了给PartSort1函数提供要排序的空间然后PartSort1返回基准值的下标我们的QuickSort函数再通过返回的基准值的下标再次将要排序的空间给PartSort1函数,然后PartSort1再进行排序.
可以看懂代码注释的不用看下面部分了
让我们讲一下PartSort1的实现思路吧:
1这个思路属于是有点难理解而且还有部分细节需要注意的,因为他是快排发明者的思路(大佬的思路懂得懂得=.=).
先让right先走(一定是right先走)直到right找到a[right] < a[keyi]
的情况这样我们就让right停下让left来走寻找直到left找到a[left] > a[keyi]
的情况找到后我们就可以交换left和right的值了,再让left停下让right走.一直这样循环直到left>=right就跳出循环.这时left和right相遇的位置就是我们需要将keyi安放的位置所以最后将keyi和left(或right)的值交换就好.
注意:上面必须right先走不然没法保证left和right相遇的位置可以安放keyi
如果害没理解下面我将用例子来讲解过程.
就用:4,1,5,2,3,7,6,9,8以keyi取左边值做例子.
这样我们的基准值(keyi对应的数组值)左边都是比它小的值了,右边都是比基准值大的值了.
然后通过递归不断的将基准值的左区间和右区间传给PartSort1这样不断更新基准值就可以得到一个有序的序列了.
挖坑法
上面的hoare法有一点难理解,所以就诞生了更易理解的挖坑法.
需要实现的功能一样但是实现思路不同罢了(其实也差不多=.=).
看看代码
int PartSort2(int* a, int left, int right)
{
//坑法可以不用让right先走了,更加便于理解=.=
//下面的思路以升序为例
//整体思路:用一个变量保存keyi的位置的变量并把keyi的位置当做坑位,当right(以此为例)
//的找到比a[keyi]大的值的时候直接塞到坑的位置,然后让right当做坑,用left来找....
int keyi = left;
int tmp = a[keyi];
int pit = leyi;//上面tmp保存那个值就先用那个地方当坑使.
while (left < right)
{
if (a[right] < a[keyi])
{
a[pit] = a[right];
pit = right;//右边搞完了,走左边
while (left < right)
{
if (a[left] > a[keyi])
{
a[pit] = a[left];
pit = left;
break;//break记得弄因为我们这时又把坑位给到了left的位置应该走right了
}
left++;
}
}
right--;
}
a[left] = tmp;
return left;
}
能看懂代码注释就不用看下面的内容了.
例:4,1,5,2,3,7,6,9,8还是以左边为基准值同时也用左边当坑.
用图来看看吧:
不同的方法得到的排序结果不同但最后的排序结果相同.
前后指针法
int PartSort3(int* a, int left, int right)
{
int prev = left;//前指针
int keyi = left;
int cur = prev + 1;//后指针
while (cur <= right)
{
if (a[cur] < a[keyi] && a[++prev] != a[cur])//在这里prev++是因为后续的交换是需要prev的后一位
Swap(&a[prev], &a[cur]);
cur++;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
上述代码 思路如下
- 当a[cur] < a[keyi]而且a[++prev] != a[cur]都成立时两者交换
- 当a[cur] >= a[keyi]的时候prev就不动.只cur++
- 最后交换a[keyi],a[prev]即可
这样我们就保证了
- prev前的值都是比keyi小的(因为如果prev的值不比keyi大prev就++了)
- 最后结尾时a[prev]的值是小于或等于a[keyi]的.
上述动图应该大家能看懂了=.=,上述动图来自[十大排序]有的人图画着画着就疯了(1.5w字详细分析+动图+源码)_君違的博客-CSDN博客(声明:图已经博主同意,而且我们两人是朋友.)
快排优化
快排最差的情况就是遇到一个有序的数组,及你想要一个1234但他给你了个4321让你排序.
这是我们的快排的时间复杂度能到O(N2);
有两种优化方法.
int GetMidIndex(int* a, int left, int right)
{//来得到一个值及不是最大值也不是最小值并返回
int mid = left + (right - left) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
else
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
}
int PartSort3(int* a, int left, int right)
{
int midi = GetMidIndex(a, left, right);//得到中间值
Swap(&a[midi], &a[left]);//让中间值与基准值交换,这样基准值就不是最大的也不是最小值了.
int prev = left;//前指针
int keyi = 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 QuickSort(int* a, int left, int right)
{
if (left > right)
{
return;
}
// 小区间直接插入排序控制有序
if (right - left + 1 <= 30)
{
InsertSort(a + left, right - left + 1);
}
else
{
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
经过优化后的快排就是库里的快排模式了.
非递归快排
我们递归的主要目的是为了给PartSort
的函数传递一个还未经历过PartSort
的区间,那么我们可以通过什么来使用非递归的条件来给我们的PartSort函数传递一个没有被排序的空间呢?
void QuickSortNonR(int* a, int left, int right)
{
//通过栈来模拟实现我们的递归得到的区间,从而实现非递归.
ST st;
StackInit(&st);
StackPush(&st, left);
StackPush(&st, right);
while (!StackEmpty(&st))
{
int end = StackTop(&st);//根据上面的入栈顺序来决定这里和下面的变量以及出栈顺序.
StackPop(&st);
int begin = StackTop(&st);
StackPop(&st);
int keyi = PartSort1(a, begin, end);
if (begin < keyi - 1)//左半区间.
{
StackPush(&st, begin);//这个和下面那个if条件句里的顺序都不能乱.
StackPush(&st, keyi - 1);
}
if (keyi + 1 < end)//右半区间.
{
StackPush(&st, keyi + 1);
StackPush(&st, end);
}
}
StackDestroy(&st);
}
我们只需要改变一下QuickSort
这个函数即可.
如果看注释可以看懂就没必要看下面的内容了.
使用一个栈来保存我们的左右区间范围.
注意:在未进循环的时候的那次
StackPush
是会影响到下面的顺序的.因为栈先进先出的性质
然后得到end begin来传给PartSort来进行排序并得到基准值的位置,然后通过基准值来分为左边和右边无排序的区间.
再经过判断区间的范围后入栈
直到不符合入栈条件后无法入栈我们的栈就空了,就跳出循环结束排序.
归并排序
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
归并排序需要我们先理解合并的思路:88. 合并两个有序数组这道题就使用了合并的思路=.=
合并有序数组(跑路人笔记)我在这里对这道题进行了讲解不会的同学可以看一看.
先看图吧:(注从上向下看.)
//这个是子函数,整体思路请向下翻阅找到父函数.
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
_MergeSort(a, begin, mid, tmp);
//经历了上面的函数递归后前一个区间就只有两个数值了
_MergeSort(a, mid + 1, end, tmp);
//经历了上面两个函数的归并能保证下面的区间第一次只有两个值,再分成两部分就各自有序了(单个数字被看做有序)
int begin1 = begin, end1 = mid;//将一个区间分为两个
int begin2 = mid + 1, end2 = end;//同上
int index = begin;//因为递归带来的头是不一样的所以用begin作为tmp内容的头部
//下面开始归并
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])//用a内数值进行比较是因为
{
tmp[index] = a[begin1];
begin1++;
index++;
}
else
{
tmp[index] = a[begin2];
begin2++;
index++;
}
}
while (begin2 <= end2)//将未归并完的数值传递过去
tmp[index++] = a[begin2++];
while (begin1 <= end1)//同上
tmp[index++] = a[begin1++];
//将tmp里的排好序的区间传递到a内,所以a内也会因为这个而部分有序
memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
void MergeSort(int* a, int n)
{
//大致思路:通过合并时会将两个有序数组合并成一个有序的数组来进行排序.
//整体思路:通过递归将数组分为一个个的小区间(一个数字为有序),再通过一个个小区间合并为一个一个个较大的区 //间最后使整个区间有序即可.
int* tmp = (int*)malloc(sizeof(int) * n);//为了防止原数组的值被覆盖而建立的临时变量
assert(tmp);
_MergeSort(a, 0, n - 1, tmp);//我们需要递归解决,建立一个子函数
free(tmp);
}
通过递归将数组分为足够小的区间后层层合并最终得到我们想要的有序序列.
归并排序的非递归实现
我们可以使用循环的方式来避免递归的分解和合并.
我们只需要通过循环来实现上述思路即可.
除了要控制区间越界问题其他倒是没啥了.
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
int gap = 1;//用来控制区间空间
while (gap < n)
{
for (int k = 0; k < n; k += 2 * gap)
{
int begin1 = k;
int end1 = begin1 + gap - 1;
int begin2 = k + gap;
int end2 = begin2 + gap - 1;
int index = begin1;
if (end1 >= n)
{
end1 = n - 1;
}
if (begin2 >= n)//如果begin2越界了就使区间不存在
{
begin2 = end2 + 1;
}
if (end2 >= n)
{
end2 = n - 1;
}
//归并
while (begin1 <= end1 && begin2 <= end2)//有一个序列为空就停止
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
//将剩余数据放入tmp数组
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
}
memcpy(a, tmp, sizeof(int) * n);
gap *= 2;
}
free(tmp);
}
计数排序
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
- 时间复杂度:O(MAX(N,范围))
- 空间复杂度:O(范围)
- 稳定性:稳定(这个稳定其实有点存疑因为通过计数排序得到的有序数组已经不是原来的数字了,而且计数排序是真的只能排序数字一旦数字里再多带点结构体啥的就不可以用计数排序了=.=)
找个数组将我们的数字出现的次数按照下标的对应位置(优化后为对应值-最小值对应下标的位置)保存起来,再通过数组的下标规则遍历--
最终得到一个有序数组.
void CountSort(int* a, int n)
{
//下面得到我们的最大值和最小值
int max = a[0], min = a[0];
for (int i = 0; i < n; ++i)
{
if (a[i] > max)
max = a[i];
if (a[i] < min)
min = a[i];
}
//找到数据的范围
int range = max - min + 1;
int* countArray = (int*)malloc(range * sizeof(int));
assert(countArray);
memset(countArray, 0, sizeof(int) * range);//初始化为0也可使用calloc函数
//存放在相对位置,可以节省空间
for (int i = 0; i < n; ++i)
{
countArray[a[i] - min]++;
}
//可能存在重复的数据,有几个存几个
int index = 0;
for (int i = 0; i < range; ++i)
{
while (countArray[i]--)
{
a[index++] = i + min;
}
}
}
结尾
我还是一个利益驱使的人.希望等我学到足够多的知识后可以让我得到兴趣.