插入排序
插入排序就跟我们玩扑克一下,当我们码牌的时候,不断选大的往后插,选小的往前插
代码如何实现呢?
我们可以把第一个数看做有序的, 直接枚举 (end作为枚举变量)第二个数(下标为1) 同时让一个tmp变量存储它下一个位置的值,判断此时a[end]与tmp的大小 。
如果 a[end] > tmp 那么此时就说明 tmp该放前面 所 以 a[end]后 移 , 同 时 end-- 往 前 移 动 , 然 后 再 判 断 此 时a[end]与tmp的 大小
这时要注意, 如果此时我们 的tmp是最小值,那么end比下标为0的都要小 ,那么end有可能会小于0
所以我们每次放tmp都是a[end+1]= tmp,因为此时我们把a[end]往后移, end–,如果停下来了,就说明此
时a[end] < tmp,那么a[end+1]不就是我们应该放的位置吗。
代码实现:
//1.插入排序
//时间复杂度: O(N^2)
void InsertSort(int* a, int n)
{
int end = 0;
int tmp = 0;
for (end = 1; end < n-1; end++)
{
tmp = a[end + 1];
while (end >= 0 && a[end] > tmp)
{
a[end + 1] = a[end];
end--;
}
a[end + 1] = tmp;
}
}
希尔排序
希尔排序可以看做是插入排序的优化版,希尔排序可以分为两个步骤:
- 预排序
- 插入排序
预排序:
预排序就是对数组进行分组排序,先让数组稍微有序,然后再插入排序。
如上图: ,插入排序我们上文说过时间复杂度最坏情况下就是O(N^2),我们交换的次数此时就是一个等差
数列
如果我们能让数组中的元素部分有序的话,是不是就可以减少交换的次数?,这也是为什么我们需
要对数组进行预排序的原因。
如何进行预排序呢:
如上图:我们把数组分为gap(假设为3)组,我们依次排完每一组,图中不同颜色代表不同的组,这样排完之后数组就比原来有序了。
当gap > 1的时候就是预排序
当gap == 1的时候此时,数组已经接近有序,所以插入排序会排的很快,这样就对插入排序进行了一个优化。
代码实现:
这里我们先看一下我们上图的思路:
这里分为两组写法::
第一种: 一次排一组
由于每组我们要找到下一个位置,所以我们i += gap,又因为是gap组,所以我们的 j < gap。
第二种: 一次排多组
当i = n-gap-1的时候,n+gap恰好等于n-1指向最后一个元素
这两种效率并没有差异,只不过一个需要三层 循环,一个需要两层循环。
总代码:
//一次排多组
void ShellSort(int* a, 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 = a[end + gap];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else
break;
}
a[end + gap] = tmp;
}
}
}
至于希尔排序的时间复杂度: 这里我们先记住希尔排序的时间复杂度大概O(N^1.3),希尔排序的时间复杂度非常难算。
测试排序消耗时间
那究竟有没有优化呢?
这里有个函数clock,他可以返回程序消耗的时间,单位是毫秒
如图定义一个大小为10万的数组,每个元素都是rand随机出来的,再分别对他们进行插入和希尔排序,记录所需要的时间。
这里我们测试可以调到release版本,可以看到希尔排序对于插入排序来说是一个很大的优化
快速排序
核心思路 :在数组中选一个key出来,它可以是数组中的任意元素,一般我们都选首元素和尾元素。
第一趟的结果就是让数组以key为界限,key左边的元素一定小于它,key右边的元素一定大于它,然后再不断的缩小左区间和右区间,直到排到一个元素为止。
时间复杂度: O(N*lgN)
快速排序作为最快的排序,我们这里分为递归和不递归版本
根据核心思路,我们这里有三种做法
- 挖坑法
- 左右指针法
- 前后指针法
不管哪种方法,目的都是核心思路
挖坑法
首先我们先定义一个key,表示我们要依据它划分,下图中我们选择第一个数作为key,用**pivot(坑)**记录此时的下标。
从end往前找只要找到比他小的我们就交换两个数的位置,同时pivot指向end。
找到比key还小的数后,start从头开始找比key大的,找到了就交换,同时pivot就变为start此时的位置了,一直找直到start >= end为止
当找完一遍过后,此时数组元素如下图:
我们可以看以看到 ,此时 红色49左边都 是比他小的数字,红色49右的都比它大,这个步骤就是我们快排的核心步骤。
此时红色49的下标为pivot,我们只需要再按照上面的步骤遍历 [0.pivot-1] [pivot+1,rihgt] 就可以得到一个有序的数组了。
代码实现:
//挖坑法
这里的right注意我们调用的时候要传入闭区间
void PartSort1(int* a, int left, int right)
{
if (left >= right)//如果left == right就说明只有一个元素了,那就不用比了,直接return返回
return;
int begin = left;
int end = right;
int pivot = left;
int key = a[pivot];
while (begin < end)
{
//从后找小去前面
while (end > begin && a[end] >= key)
{
end--;
}
//此时a[end] < a[pivot]
//1.交换
swap(&a[end], &a[pivot]);
//2.把end给Pivot
pivot = end;
while (end > begin && a[begin] <= key)
{
begin++;
}
swap(&a[begin], &a[pivot]);
pivot = begin;
}
PartSort1(a, left, pivot - 1);
PartSort1(a, pivot + 1, right);
}
但是快排还有个缺陷,就是当此时如果我们用现在写的快排去排一个有序的数组时,效率会很低,为什么呢?
如果数组有序的话,那么我们的end就会找到start为止,此时Pivot指向0,pivot-1肯定执行不了了,所以我们只能执行[pivot+1,right],那么end还是会找到头,指向不断找,最后的执行次数就是 1+2+3+ …+ n-1,那最后的时间复杂度就为O(N^2)。
怎么解决呢?
三数取中
这里有个方法叫三数取中,既然数组有序的情况下,我们如果取第一个那就会取到最小值,这样就会造成O(N^2)的时间复杂度,那么我们就取中间的值就好了。
如何取呢,当我们拿到一个数组的时候,我们可以判断在[l,r]这个区间中 nums[L]、nums[mid]、nums[R]的大小,我们把nums[mid]这个和nums[L]的值一交换。那么无论数组是有序还是无序,我们都不会取到极端情况,这样就能保证快排的效率。
代码实现:
int TreeNumMid(int* a, int left, int right)
{
int mid = left + ((right - left) >> 1);
if (a[left] < a[right])
{
if (a[right] < a[mid])//left < right < mid
return right;
else if (a[left] > a[mid]) // mid < left < right
return left;
else //right >= mid left <= mid
return mid;
}
else // a[left] > right
{
if (a[left] < a[mid]) // right < left <mid
return left;
else if (a[right] > mid)
return right;
else
return mid;
}
return -1;
}
小区间优化
由于我们是用递归实现的,如果数据很大,比如100万、1000万,这么多数要进行排序,那么我们会有大量的小区间排序,这里我们以10个数排序为例:
如果数据量在1000万那么在最后在开辟大约10个元素这样的小区间,就会开辟大量的栈帧,就会消耗大量时间。
所以我们当数据元素在10个或者15个的时候,我们就不使用用快排,直接使用插入排序,为什么使用插入排序呢?因为我们此时快排排完之后已经较为有序了,所以使用插入排序效率会高一点。
代码实现:
void PartSort1(int* a, int left, int right)
{
if (left >= right)
return;
//1.优化2,小区间优化
if (right - left + 1 < 14)
{
InsertSort(a + left, right - left + 1);
}
else
{
//优化1.三数取中
int Minindex = TreeNumMid(a, left, right);
swap(&a[left], &a[Minindex]);
int begin = left;
int end = right;
int pivot = left;
int key = a[pivot];
while (begin < end)
{
//从后找小去前面
while (end > begin && a[end] >= key)
{
end--;
}
//此时a[end] < a[pivot]
//1.交换
swap(&a[end], &a[pivot]);
//2.把end给Pivot
pivot = end;
while (end > begin && a[begin] <= key)
{
begin++;
}
swap(&a[begin], &a[pivot]);
pivot = begin;
}
PartSort1(a, left, pivot - 1);
PartSort1(a, pivot + 1, right);
}
}
左右指针
这里借用一下佬的动图
动图来源<-
以上只是第一趟的结果,我们还需要不断的调整,[left,keyi-1]、[keyi+1,right]的区间的顺序,最终才能有序。
快排这里我们都选第一个值当做key
不过这里我们除了记录key值以外,还要记录此时的keyi(key值下标),然后end往前遍历找小,start往后遍历找大,只有都找到了才能交换,当start >= end的时候还要与keyi交换,因为我们是以key作为分界点的。
注意:这里我们要先让end先找
我们是找严格大于或小于key值的数,就是 > 或 < 而不是 >=、<=,所以如果我们先让start开始找的话,那么最后start和keyi交换的值就是比key要大的值,如下图:
那么此时这个76比key还要大的值就会跑到key值的左边。
代码实现:
//左右指针法
void PartSort2(int* a, int left, int right)
{
if (left >= right)
return;
if (right - left + 1 < 14)
{
InsertSort(a + left, right - left + 1);
}
else
{
//优化1.三数取中
int Minindex = TreeNumMid(a, left, right);
swap(&a[left], &a[Minindex]);
int begin = left;
int end = right;
int keyi = begin;
//如果先从后面找大,就让keyi等于left
//错误原因:
/* keyi = begin, 如果我们先从Begin的位置找小, 那么最后begin停下来的位置一定是大于keyi位置的元素的, 此时只要交换就把
比keyi位置元素大的元素交换过去了,而我们是要begin左边比keyi小,右边比他大,所以出错了*/
//否则就让keyi等于right
while (begin < end)
{
while (begin < end && a[end] >= a[keyi])
{
end--;
}
while (begin < end && a[begin] <= a[keyi])
{
begin++;
}
swap(&a[begin], &a[end]);
}
swap(&a[begin], &a[keyi]);
PartSort2(a, left, begin - 1);
PartSort2(a, begin + 1, right);
}
}
前后指针法
网上找的动图 [doeg]
以上只是第一趟的结果,我们还需要不断的调整,[left,keyi-1]、[keyi+1,right]的区间的顺序最终才能有序。
这里定义一个prev指向left,而cur指向prev+1,同时选第一个为key,并用keyi记 录此时key值的下标,cur不断移动取找比key要小的放在key的左边,然后prev首先要++,然后才交换。
因为最终我们的keyi位置是要当做分界点的,直到cur > right然后交换keyi和prev,再继续遍历[left,prev-1]、[prev+1,right]的子区间
代码实现:
//前后指针法
void PartSort3(int* a, int left, int right)
{
if (left >= right)
return;
//1.优化2,小区间优化
if (right - left + 1 < 14)
{
InsertSort(a + left, right-left + 1);
}
else
{
//优化1.三数取中
int Minindex = TreeNumMid(a, left, right);
swap(&a[left], &a[Minindex]);
int begin = left;
int end = right;
int keyi = begin;
int prev = left;
int cur = prev + 1;
while (cur <= end)
{
if (a[cur] < a[keyi])
{
++prev;
swap(&a[prev], &a[cur]);
}
cur++;
}
swap(&a[prev], &a[keyi]);
PartSort1(a, left, prev - 1);
PartSort1(a, prev + 1, right);
}
}
//快速排序
//核心思想: 取一个值当做中间的值,不断地比较,最终使得mid 左边的值一定比mid这个位置的值小,右边一定大于它
快排-非递归版
上述中我们使用的都是递归,也可以不适用递归,其实我们递归主要是用来划分我们要调整的子区间。
那么我们也可以使用栈来存储我们每次需要调整的区间,因为递归所需要的数据结构其实就是栈,特点是: 后入先出
如图我们只需要往栈里面存储我们需要调整区间的下标即可,当我们用完之后直接Pop掉就可以了
如上图:这里我选则先调整左区间,那么我们就要先把有区间给弄进去,然后再把左区间给压进去,红色序号代表压栈的顺序。
以左区间为例子:
只要我们此时的left < pivot-1,那我们就先把pivot-1压栈然后再把left压栈,这样取出来的就是left,pivot-1
右区间就是只要 pivot+1 < right,就依次压入,right,pivot+1
代码实现:
这里我们以挖坑法作为调整的方法,但是此时我们就要让挖坑法返回pivot的下标
//快排-非递归
int PartSort1_NonR(int* a, int left, int right)
{
//优化1.三数取中
int Minindex = TreeNumMid(a, left, right);
swap(&a[left], &a[Minindex]);
int begin = left;
int end = right;
int pivot = left;
int key = a[pivot];
while (begin < end)
{
//从后找小去前面
while (end > begin && a[end] >= key)
{
end--;
}
//此时a[end] < a[pivot]
//1.交换
swap(&a[end], &a[pivot]);
//2.把end给Pivot
pivot = end;
while (end > begin && a[begin] <= key)
{
begin++;
}
swap(&a[begin], &a[pivot]);
pivot = begin;
}
return pivot;
}
void QuickSortNonR(int* a, int n)
{
Stack st;
StackInit(&st);
StackPush(&st,n-1);
StackPush(&st,0);
int left = 0;
int right = 0;
while (!StackEmpty(&st))
{
left = StackTop(&st);
StackPop(&st);
right = StackTop(&st);
StackPop(&st);
int mid = PartSort1_NonR(a,left,right);
if (mid + 1 < right)
{
StackPush(&st, right);
StackPush(&st,mid+1);
}
if (mid - 1 > left)
{
StackPush(&st,mid-1);
StackPush(&st, left);
}
}
StackDestroy(&st);
}
归并排序
归并排序就是排序两个有序数组,只要两个区间有序,那我把他俩按照顺序排列起来不就是有序数组了吗?
那怎么让两个区间有序呢? ,把每个区间再划分为两个子区间,再让两个子区间分别有序,然后再排列,一直分分分直到子区间只有一个元素时,那他肯定有序
如图,我们把数组一半一半分为两个子区间,再分到只有一个元素,然后排序保证每个子区间都有序。
最后一合并就为一个有序数组,所以这里我们会创建一个数组来存储合并后的有序数组,然后再把数据拷回去。
归并递归版
我们直到了应该划分子区间直到不可划分之后再合并,那如何划分呢?
我们可以求出mid下标,然后再不断递归[0,mid]和[mid+1,right],只要此时区间只有一个元素的时候我们就直接return。 此时都不用归并。这样我们只需要写一个合并两个有序数组就可以了。
代码实现:
//归并排序
void MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
return;
int mid = left + ((right - left) >> 1);
MergeSort(a,left,mid,tmp);
MergeSort(a,mid+1,right,tmp);
int begin1 = left;
int begin2 = mid + 1;
int begin3 = left;
int j = 0;
while (begin1 <= mid && begin2 <= right)
{
if (a[begin1] < a[begin2])
{
tmp[begin3++] = a[begin1++];
}
else
{
tmp[begin3++] = a[begin2++];
}
}
while (begin1 <= mid)
{
tmp[begin3++] = a[begin1++];
}
while (begin2 <= right)
{
tmp[begin3++] = a[begin2++];
}
for (j = left; j <= right; j++)
{
a[j] = tmp[j];
}
}
归并非递归版
归并非递归版我们要一个一个开始合并,然后再依次增大合并的范围,定义一个gap表示每次归并的范围,这个gap应该每次都要*2
如下图:
至于i怎么取,可以看到我们是有一个end2,他作为第二个数组的边界,他是不能大于等于数组长度的,而且每次我们的i应该要跨越2*gap,2*gap就我们合并的两个有序数组的长度
注意:我们的gap每次*2那就会是偶数,那如果我们在这个的基础上多一个元素呢?
如下图:
当我们此时begin1来到新的结尾后,此时begin2都超出数组长度了,那么此时我们就不应该再继续了,直接break跳出循环,同时gap*2就可以了
当我们的gap此时等于原数组长度时(8),那么此时begin2此时指向了3,但是此时 的end2就已经超了。那这个时候我们就要把end2修正了,只要begin2还在,end2超出数组长度,我们就要给他修正一下,直接赋值为n-1。
总结一下:
- 只要begin2此时大于等于数组长度直接break,不要再调整了
- begin2还没有超出,但是end2已经超出了,及时修复end2,给end2赋值为n-1
代码实现:
void MergeSortNonR(int* a,int n, int* tmp)
{
int gap = 1;
int i = 0;
int j = 0;
while (gap < n)
{
for (i = 0; i < n; i += (2 * gap))
{
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
int begin3 = begin1;
if (begin2 >= n)
break;
if (end2 >= n)
end2 = n - 1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[begin3++] = a[begin1++];
}
else
{
tmp[begin3++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[begin3++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[begin3++] = a[begin2++];
}
for (j = i; j <= end2; j++)
{
a[j] = tmp[j];
}
}
gap *= 2;
}
}
结言:
到这里我要说的排序差不多说完了,后期学完二叉树的时候会把堆排序写在二叉树章节,如果有问题的欢迎指出来我们下期再见