手撕数据结构八大排序算法
小编个人主页详情<—请点击
小编个人gitee代码仓库<—请点击
数据结构与算法系列专栏<—请点击
倘若命中无此运,孤身亦可登昆仑,送给屏幕面前的读者朋友们和小编自己!
目录
一、排序概念讲解
人的生活离不开排序,身高高矮,成绩高低,游戏排名高低,电脑文件按照修改时间进行的排序,排队买饭的先来后到等等等等都在进行排序,这些排序通常都是有一个指标,根据这个指标来进行排序
排序:将杂乱无章的数据,通过一定方法按照某个关键字(指标),使这些数据成为递增或者递减的操作
- 稳定性:在待排序序列中存在有多个相同的关键字,经过排序后这些相同的关键字的相对序列保持不变,即在待排序序列中,有a[r]=a[j],且a[r]在a[j]的前面,如果排序完成后,a[r]仍然在a[j]的前面,则说明该排序算法具有稳定性,否否则该排序算法就不具有稳定性
- 内部排序:数据元素全部放在内存中的排序,八大排序都可看作内部排序
- 外部排序:数据元素太多不能同时放到内存中,根据排序要求不能在内外存之间移动数据的排序,归并排序可以对文件中的数进行归并,可以看作外部排序
例如:当你想购买一款当下比较畅销的电脑,你将电脑按照销量进行排序,那么最上面的就为你想要找的那一款电脑
接下来小编将围绕下图的排序算法依次进行讲解
排序接口总览
//插入排序
void InsertSort(int* a, int n);
//希尔排序
void ShellSort(int* a, int n);
//选择排序
void SelectSort(int* a, int n);
//向下调整算法
void AdjustDown(int* a, int n, int root);
//堆排序
void HeapSort(int* a, int n);
//冒泡排序
void BubbleSort(int* a, int n);
//快速排序
void QuickSort(int* a, int left, int right);
//快速排序的非递归形式
void QuickSortNonR(int* a, int left,int right);
//归并排序
void MergeSort(int* a, int n);
//归并排序非递归
void MergeSortNonR(int* a, int n);
//计数排序
void CountSort(int* a, int n);
二、八大排序的实现
注意事项:
- 为了便于理解进行排序讲解的时候通常是先讲解单趟再讲解整体,书写代码也是先写单趟再写整体
- 这里的八大排序都是以升序为例进行讲解
2.1直接插入排序
概念讲解
直接插入排序属于插入排序,概念是:把待排序的记录,按照其值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列
其实我们从小就会插入排序的思想,当我们打扑克的时候,摸牌将其逐个按顺序插入我们排序好的牌中的过程其实就是插入排序
- 由于在数组中的值是连续的,那么我们的有序序列最开始看作为1个数,因为1个数既可以看作升序,又可以看作降序,这里我们将1个数看作升序,采用将,我们要依次将数组中剩余的值插入到这个序列中
- 创建一个变量保存要插入的值,使用下标标记有序序列的最后一个位置
- 由于我们是要排升序,所以要插入的值和有序序列最后一个位置的值依次比较,如果比有序序列的值小,那么说明要插入的位置应该还在有序序列的值的前面,所以我们将有序序列的最后一个位置的值往后覆盖一个位置,为插入位置腾出位置,如果比有序序列的值大符合升序,那么直接插入即可
- 如果有序序列的值全部比被插入的值大,此时经过我们的循环判断,下标标记的位置应该在-1,我们应该插入到0这个位置,所以被插入的值应该在有序序列的最前面
代码实现
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
时间复杂度
- 当为降序的时候,每个被插入的值都要插入到n个有序序列的最前面,被插入的值有n个数,所以时间复杂度最差为O(N^2)
- 当为升序的时候,有序序列的最后一个位置的值正好大于被插入的值,被插入的值插入到有序序列的后一个位置,恰好就为被插入的值的位置的原位置,所以不用对数组的值进行修改,遍历一遍即可确定有序的不用动,所以时间复杂度最好为O(N)
- 即数据越接近有序直接插入排序的效率越高
2.2希尔排序
概念讲解
希尔排序:希尔排序又称缩小增量法,先选定一个整数gap,根据整数gap将待排序文件所有的数据分成n组,将距离为gap的数据进行排序,组内排序完成后,缩小gap重复分组,排序,直至gap为1,注:当gap为1的时候就相当于直接插入排序
希尔排序的底层也是插入排序,换句话来说是直接插入排序的plus版本,直接插入排序的数一次只能跳动1位,希尔排序的数一次跳动gap位
以升序为例,希尔排序让更大的数更快的跳到后面,让更小的数更快的跳到前面
- 希尔排序根据gap划分,当gap>1的时候是进行预排序,当gap为1的时候是进行直接插入排序
- 确定整数gap作为间距,分组,对组内进行排序,排序方法思想上与直接插入排序相同,不同的是直接插入排序中的数字跳动间隔为1,这里的数字跳动间隔为gap
- 相同的我们还是使用变量end作为下标来控制有序序列最后一个数,tmp用于保存要插入的数,使用循环变量i控制end的位置,类比直接插入排序排序数组的数的间隔是1,tmp的位置是end+1,这里排序数组的数的间隔是gap,所以tmp的位置是end+gap,观察下图当end位于n-gap的时候,gap已经越界了,所以end应该小于n-gap,即用于控制end的i应该小于n-gap
- 当第一组和第二组已经排序完成后,每一组内部都为有序,整个数组相对未排序已经相对有序了,此时逐步缩小gap,当gap为1的时候即继续直接插入排序
- 这里的gap的初始值为n,使用循环控制gap我们可以使用gap/2并且每次缩小二倍,代数进去,循环让数一直/2,例如5/2,4/2,gap一定可以缩小为1,也可以使用gap/3+1,循环让数字/3,例如2/3为0,所以要加一,这里的+1是为了确保最后一次gap一定可以减小为1
- 当gap缩小为1的时候就是进行了直接插入排序
代码实现
以使用gap划分的组为基准进行排序
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
//gap /= 2;
gap = gap / 3 + 1;
for (int j = 0; j < gap; j++)
{
for (int i = j; i < n - gap; i += gap)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
PrintArray(a, n);
}
}
}
以1为基准进行的排序,进行多个组一起排序,就类似于先把每一组的第一个位置的元素进行排序,再排序每一组的第二个元素,再排序第三个,第四个……直到所有组的最后一个元素都排序完成,即可,这样写比较美观,且嵌套循环少了一层,但是读者朋友们还应该知道这个方法的本质和上面方法的本质是相同的,只不过上方法是按照一组一组来进行排序的,这两种希尔排序方法的时间复杂度都相同
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap /= 2;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
时间复杂度
- 假设n个数为逆序,每一组中的数据为3个,那么gap=n/3,即一共有gap组,那么由于是逆序每组数据进行间距为gap的插入排序都要将数字插入3次,一共有gap组即n/3组,那么一共要插入组数*每组需要插入的次数=n / 3 * 3 = n
- 假设n个数为逆序,每一组中的数据为3个,那么gap=n/3,即一共有gap组,那么由于是逆序每组数据进行间距为gap的插入排序都要将数字插入3次,一共有gap组即n/3组,那么一共要插入组数*每组需要插入的次数=n / 3 * 3 = n,
- 假设n个数为逆序,每一组中的数据为5个,那么gap=n/5,即一共有gap组,那么由于是逆序每组数据进行间距为gap的插入排序都要将数字插入10次,一共有gap组即n/5组,那么一共要插入组数*每组需要插入的次数=n / 5 * 10 = 2n,可以看出这里的时间复杂度相对于每一组中的数据为3个的时候变大了
- 假设gap很小,有n个数据,即每一组中的数据个数为n/gap很大,粗略估计那么每次都要进行插入n次,一共有n个数据,那么时间复杂度粗略估计为n^2,但是这里由于是进行了预排序,每一次的预排序都使数据变得更为由于,当gap很小的时候,这里的数据已经是将近有序,总共需要进行挪动数据的数据量很小很小,那么这时候的时间复杂度为O(N)
- 经过上面的推理,希尔排序的时间复杂度很不好估量,当gap=n/3的时候是n,当gap=n/5的时候是2n,当gap再逐渐减小到一个很小的数的时候时间复杂度又降为了n,希尔排序的时间复杂度大致为下面的一个图像
- 由于希尔排序的时间复杂度较为复杂,这里只能大致估量希尔排序的时间复杂度为O(N^1.3)
2.3直接选择排序
概念讲解
- 遍历有n个元素的数组并且选出最小的和最大的数,分别交换放在数组的头a[0]和尾a[n-1]
- 缩小区间为a[1]到a[n-2],从缩小的新区间中选出最小的数和最大的数,交换放在数组的a[1]和a[n-2]的位置上,迭代,循环直到区间内数组的元素个数为0或者1,退出循环即可
- 这里有一点需要注意,由于是同时选择两个数字,如果最大值恰好为区间数组的头,那么你找出的最大值就和区间数组的头的数据重合,区间数组的头要和数组最小的数交换,而数组的头中存储的又是最大值,那么最大值就被换到遍历找到的最小值位置上去了,这时候交换完了后区间数组的头中存储的就为最小值,由于这里使用的是下标访问元素而不是采用的保存值的方式,这个时候使用你遍历找到的最大值的下标即为数组的头的下标,去访问遍历找到的最大值会错误的访问到最小值,造成错误
- 所以在最小值和区间数组的头进行交换后,应该判断你之前遍历找到的最大值的下标是否等于区间数组的头,如果相等那么说明这时候最大值已经被换到之前遍历找到的最小值的位置上,将最大值的下标更新到你找到的最小值位置的下标上即可
代码实现
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void SelectSort(int* a, int n)
{
int left = 0, right = n;
while (left < right)
{
int mini = left, maxi = left;
for (int i = left+1; i < right; i++)
{
if (a[i] < a[mini])
mini = i;
if (a[i] > a[maxi])
maxi = i;
}
Swap(&a[left], &a[mini]);
if (left == maxi)
maxi = mini;
Swap(&a[right - 1], &a[maxi]);
left++;
right--;
}
}
时间复杂度
- 当数组有序顺序的时候使用选择排序,其会选出最大值和最小值,放在原最大值和最小值的位置上,虽然值没有变,但是选择排序还是会死板的去继续遍历数组寻找出最大值和最小值继续循环,所以有n个数,每次都需要遍历数组,时间复杂度近似为O(N^2)
- 当数组为有序逆序的时候使用选择排序,其会选出最大值和最小值,将最大值和最小值进行换位置,选择排序会去继续遍历数组寻找出最大值和最小值继续循环,所以有n个数,每次都需要遍历数组,同样时间复杂度近似为O(N^2)
- 无论是顺序逆序还是乱序,直接插入排序对于每一次查找最大值和最小值都要遍历一遍数组,所以时间复杂度一直为O(N^2)
2.4堆排序
概念讲解
堆的概念介绍与实现请移步小编之前讲解的堆的讲解实现详情请点击
堆排序的讲解请移步小编之前讲解的堆排序详情请点击<—
代码实现
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
}
else
{
break;
}
parent = child;
child = parent * 2 + 1;
}
}
//时间复杂度为O(N*logN)
void HeapSort(int* a,int n)
{
for (int i = (n - 2) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
时间复杂度
向下调整建堆的时间复杂度计算
交换确定元素位置的循环的时间复杂度计算为
所以向下调整的两个部分的时间复杂度的和为O(N+N*logN),采用大O渐进法这里的N和N*logN不在同一个数量级,所以忽略N,所以堆排序的时间复杂度为O(N*logN)
2.5冒泡排序
概念讲解
- 冒泡排序的思想是依次遍历数组,依次比较数组元素,如果当前元素比后一个元素大,那么当前元素和和后一个元素进行交换,同时如果当前元素比后一个元素小,那么则不交换,继续向后遍历即可,这样遍历一遍数组元素后,当前数组最大的值就被逐个交换到了最后一个位置,接着缩小区间,忽略最后一个位置,继续从头进行遍历数组将缩小的区间内的最大值交换到缩小区间的最后一个位置上,继续缩小区间,直到区间元素为1个停止交换
- 为了更好的确定边界情况,这里我们使用下标i控制两个比较数的后一个数,前一个数下标就为i-1,为了不越界,所以i的起始位置就在1这个位置,i-1就在0这个位置,迭代判断,当后一个数到达n-1的位置,即数组的边界位置这个时候一组的判断交换已将完成,所以i的边界条件为i<n
- 然后由于每次要缩小区间即一个数,那么我们在这个循环外面嵌套一个循环,让n依次减去变量j,这个j从0开始,到n-2结束,即变量j要小于n-1,因为当j等于n-2的时候,n-j等于2,这时候i只能为1进入循环依次,即判断第一个数和第二个数是否需要交换,这时候已经是最后一次判断了,所以这就是临界情况
- 这里关于冒泡排序还有一个小优化,当遍历一边数组后没有交换任何一个元素,这说明,数组内的每一个元素都小于它后面的元素,数组为递增序列有序,我们可以再交换位置标记一下,如果标记未被修改说明遍历过程中没有交换,这时候直接退出循环即可
代码实现
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//时间复杂度 最差为O(N^2)---最好为O(N)
void BubbleSort(int* a, int n)
{
for (int j = 0; j < n; j++)
{
int flag = 0;
for (int i = 1; i < n-j; i++)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
flag = 1;
}
}
if (flag == 0)
break;
}
}
时间复杂度
- 由于我们使用标记对冒泡排序进行了优化,如果冒泡排序为有序,那么遍历一边数组就会退出循环,排序完成,时间复杂度为O(N)
- 当数组为逆序的时候,每一次遍历都要将元素依次交换到数组的最后一个位置再缩小区间再进行交换元素到缩小区间的最后一个位置,循环,每次区间数组的元素个数减一,那么就为遍历次数就为n-1,n-2,n-3,n-4,n-5……3,2,1的和,使用等差数列求和公式为n*(a1+an)/2=(n-1)*(n-1+1)/2=(n-1)*n/2使用大O的渐进法计算为O(N^2)
2.6快速排序
快速排序是horea提出的一种类似于二叉树的前序遍历的排序方法:取待排序元素中的某一个元素作为基准值,然后按照该排序码将待排序序列分割成左右两个字序列,左子序列的元素的值全部小于基准值,右子序列的元素的值全部大于该元素,然后左右子序列重复该过程,直至所有元素都在其相应的位置上
- 下面即为三种快速排序的主逻辑,观察一下很像二叉树的前序遍历,二叉树的讲解详情请点击<—
- 下面由小编为大家介绍快速排序的三种方法,这里使用接口的形式进行介绍
- 同时这里有对二叉树的一个小区间优化,当递归的元素的个数还剩下10个的时候相较于递归,插入排序更有性价比,如果采用递归,当元素个数剩余10个的时候通常该递归调用已经快结束了,那么就是处于二叉树的底层,我们直到二叉树的节点越往下节点越多,那么就是当还有10个元素的时候递归调用的次数很多几乎占到总递归次数的百分之50,那么当还剩下10个元素的时候我们采用插入排序进行优化,一方面可以减少递归次数,递归要压栈会有效率的损失,另一方面在元素个数为10个的时候,插入排序和快速排序相比并不差,所以我们这里当元素个数为10个的时候我们采用插入排序进行优化
- 在调用插入排序接口的时候,要注意调用的数组的起始位置不一定在a数组的起始位置,有可能在中间的后半部分,所以这里要将a+left才能找到我们要优化的数组位置
- 由于这里的left和right的下标是闭区间,那么采用,right-left+1的形式去计算
- 当left==right的时候只有一个元素,这时候它已经在它应该在的位置上了,返回即可,当left>right的时候不存在该区间也要返回,所以left>=right作为递归调用的返回条件
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
//小区间优化
if (right - left > 10)
{
//int keyi = PartSort1(a, left, right);//horea版本
//int keyi = PartSort2(a, left, right);//挖坑法
int keyi = PartSort3(a, left, right);//前后指针法
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
else
{
InsertSort(a+left, right-left+1);
}
}
2.6.1 horea版本
概念讲解
- 采用两个数组下标代表指针指向数组的最左侧和最右侧,并且选用最左侧作为key基准值,让右侧先走
- 同时也可以选用最右侧作为key基准值,让左侧先走(都可以)
- 接下来单次锁定key应该在的位置执行下图操作
- 当操作完成后找到key值应该放在的位置后观察key值的左侧都为比key值小的数字
- key值右侧都为比key值大的数字
- 这样整个数组就被分为了三部分,key的左子序列,key,key的右子序列
- 这里的将key值放在它应该放的位置并且调整数组的数可以看作二叉树中的访问根节点,接下来执行类似于二叉树前序递归左右子树的操作即可
- 这里使用了一个可以使用三数取中或者取随机值的操作,这里小编会在这三种方法都讲完之后进行展开叙述
代码实现
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//hoare
int PartSort1(int* a, int left, int right)
{
//int random = left + rand() % (right - left + 1);
//Swap(&a[left], &a[random]);
int mid = SelectMid(a, left, left + (right - left) / 2, right);
Swap(&a[left], &a[mid]);
int keyi = left;
while (left < right)
{
while (left < right && a[right] >= a[keyi])
right--;
while (left < right && a[left] <= a[keyi])
left++;
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[right]);
keyi = right;
return keyi;
}
2.6.2挖坑法
概念讲解
- 采用两个数组下标代表指针指向数组的最左侧和最右侧,并且选用最左侧作为坑位key基准值,让右侧先走
- 同时也可以选用最右侧作为坑位key基准值,让左侧先走(都可以)
- 接下来单次锁定key应该在的位置执行下图操作
- 当操作完成后找到key值应该放在的位置后观察key值的左侧都为比key值小的数字
- key值右侧都为比key值大的数字
- 这样整个数组就被分为了三部分,key的左子序列,key,key的右子序列
- 这里的将key值放在它应该放的位置并且调整数组的数可以看作二叉树中的访问根节点,接下来执行类似于二叉树前序递归左右子树的操作即可
- 这里使用了一个可以使用三数取中或者取随机值的操作,这里小编会在这三种方法都讲完之后进行展开叙述
代码实现
//挖坑法
int PartSort2(int* a, int left, int right)
{
int keyi = left;
int key = a[left];
while (left < right)
{
while (left < right && a[right] >= key)
right--;
a[left] = a[right];
while (left < right && a[left] <= key)
left++;
a[right] = a[left];
}
a[left] = key;
keyi = left;
return keyi;
}
2.6.3前后指针法
概念讲解
- 采用两个数组下标代表指针指向数组的最左侧和最左侧的下一个位置,并且选用最左侧作为key基准值,让最左侧的下一个位置先走
- 同时也可以选用最右侧作为key基准值,让最右侧的上一个位置先走(都可以)
- 接下来单次锁定key应该在的位置执行下图操作
- 当操作完成后找到key值应该放在的位置后观察key值的左侧都为比key值小的数字
- key值右侧都为比key值大的数字
- 这样整个数组就被分为了三部分,key的左子序列,key,key的右子序列
- 这里的将key值放在它应该放的位置并且调整数组的数可以看作二叉树中的访问根节点,接下来执行类似于二叉树前序递归左右子树的操作即可
- 这里使用了一个可以使用三数取中或者取随机值的操作,这里小编会在这三种方法都讲完之后进行展开叙述
代码实现
//前后指针法
int PartSort3(int* a,int left,int right)
{
int keyi = left;
int prev = left, cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[keyi])
{
prev++;
Swap(&a[prev], &a[cur]);
cur++;
}
else
{
cur++;
}
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
return keyi;
}
三数取中
快速排序是每次选出一个key,通过一系列的方法将key放到它应该放的位置上,让key的左边的序列比key小,右遍的序列比key大,可是,读者朋友们,我们试想,如果给我们的待排序数组本身就是有序的,那么选key放到它应该放的位置又是什么情况了呢?
三数取中的本质是有三个值a b c,固定b不动
- 假设b大于a(a<b),使用c进行比较当c大于b的时候,b就是中间值,返回b,当c不大于b的时候,如果c小于a,那么a就是中间值,返回a即可,如果c大于a,那么c就是中间值,返回c即可
- 否则再假设b小于a(b<a),使用c进行比较,如果c大于a,那么a就是中间值,如果c不大于a,如果c小于b,那么b就是中间值,返回b即可,如果c大于b,那么c就是中间值,返回c即可
- 以升序为例,快速排序开始选用数组最左侧的元素作为key进行一系列操作后,将key放在了原位置,key的左侧没有值,那么递归二叉树数的话递归的次数会变得相当的多
- 这时候我们采用三数取中操作,最左侧是值和最右侧的值和中间的值三个数中取中间值
- 由于我们的快速排序要从数组的左侧选key,那么我们要保持快排的性质,所以让我们选出来的中间值和数组的最左侧的位置进行交换值即可
- 这样在数组的左侧访问选key选的值相比较来说更为科学,更为靠近中间值,同时由于选key的数相对靠近中间,那么我们的快速排序的递归层数也会大大减少,提高效率
- 使用三数取中的方法优化快速排序,那么当遇到接近有序的情况,每次取的都是中间值,递归调用形状更为接近二叉树,递归调用的层数也会大大减少
int SelectMid(int* a,int left,int mid,int right)
{
if (a[left] < a[mid])
{
//left < mid < right
if (a[mid] < a[right])
{
return mid;
}
else
{
if (a[left] < a[right])
{
return right;
}
else
{
return left;
}
}
}
else
{
//mid < left
//mid < left < right
if (a[right] > a[left])
{
return left;
}
else
{
if (a[right] > a[mid])
{
return right;
}
else
{
return mid;
}
}
}
}
随机数
同时我们采用随机数的方法同样也可以解决当快速排序碰到有序时递归调用层数过多的这个问题
随机数的原理是采用rand函数生成一个小于当前数组个数的一个数,再使用这个数加上left,就生成了一个范围再当前数组中的一个随机下标,利用这个随机下标的数与数组的最左侧的元素进行交换,使原随即下标对应的值作为最左侧的key进行快速排序
当使用随机生成的下标的值作为key的时候,选key碰到数组开头的元素的概率很小,这也就大大降低了碰到有序或接近有序序列的递归层数过多的问题
int random = left + rand() % (right - left + 1);
Swap(&a[left], &a[random]);
三数取中和随机数都可以解决快速排序遇到原带排序数组接近有序或有序递归层数过多的问题,相比较而言,三数取中更为科学
时间复杂度分析
- 这里我们仅讨论快速排序的递归形式的时间复杂度
- 当我们的快速排序采用了小区间优化和三数取中之后,我们近似的将快速排序的递归看作类似于二叉树的前序遍历,即近似认为每次都可以将数组均分进行递归
- 那么共有N个元素,进行递归的开辟栈帧的深度就类似于求二叉树的高度为logN,那么每一层递归相较于上一层仅仅减少一个元素,所以这里我们认为每一层进行递归的有N个元素
- 使用大O的渐进法进行计算,那么快速排序的时间复杂度为O(N*logN)
2.7三路划分
2.8快速排序的非递归
概念讲解
递归改非递归要么是使用栈进行模拟,要么就是直接改循环,这里是采用使用栈进行模拟
观察快速排序的递归调用每次传入的都是数组,左右子区间的两端下标进行的递归调用,那么我们可以使用栈的后进先出,存储下标,模拟这个传入左右子区间的两端下标的过程
栈的讲解以及源代码详情请点击<—
- 由于栈是先入后出,所以为了先拿到开始的left下标,那么我们先入right,再入left
- 开始入完之后,栈中肯定不为空,采用一个循环进行判断,当栈为空的时候,说明没有下标了,而下标又是代表着需要进行快速排序的子区间,所以当栈不为空的时候,说明有需要排序的子区间,最初有入的left和right两个下标所以可以进入循环
- 进入循环之后使用栈的操作分别拿到两个下标,因为有两个下标才能调用排序函数PartSort3前后指针法函数进行排序,当排序完成后,已经选出了key,PartSort3前后指针法函数要返回key位置的下标,所以我们拿keyi进行接收
- 这时候仅仅是模拟了一次,key的左右子区间还未进行模拟,所以需要入左右子区间序列下标进栈
- 这里有一点需要注意当left左下标大于等于keyi的时候,当right下标小于等于keyi下标的时候,子区间序列剩余1个值或者子区间序列不存在,这种情况下不需要入栈进行模拟了
- 由于栈是先入后出,所以为了先模拟快速排序递归调用左子序列,那么我们要先入右子序列的两个下标再入左子序列的两个下标,这样才能先拿到左子序列进行优先调用PartSort3前后指针法函数进行排序
- 当目前调用的序列有对应的左右子区间需要排序的时候一定会进入栈,当没有对应的左右子区间的时候一定不会入栈,所以当栈为空的时候一定会模拟排序完成快速排序
- 这样使用栈的判空函数判断栈中是否有数据进行循环起来即可,当栈为空的时候一定会模拟排序完成快速排序
- 同时在创建栈的时候不要忘记初始化,在使用完栈之后不要忘记销毁栈,这是一个好习惯,读者朋友们可以尽量去养成哦
代码实现
void QuickSortNonR(int* a, int left,int right)
{
ST st;
STInit(&st);
STPush(&st, right);
STPush(&st, left);
while (!STEmpty(&st))
{
int begin = STTop(&st);
STPop(&st);
int end = STTop(&st);
STPop(&st);
int keyi = PartSort3(a, begin, right);
if (end > keyi + 1)
{
STPush(&st, end);
STPush(&st, keyi + 1);
}
if (begin < keyi - 1)
{
STPush(&st, keyi - 1);
STPush(&st, begin);
}
}
STDestory(&st);
}
2.9归并排序
概念讲解
如果给你一个乱序数组,你不能够说它有序,但是如果给你一个数我们可以说它有序,我们将这一个数看作一个子序列,那么我们就可以说这一个子序列是有序的,给你一个有N个数的数组,将其划分为N个子区间序列,每一个子区间都有一个数,每一个子区间我们都看作是有序的,当两个子序列有序的时候我们可以依次拿出子序列的值进行比较,将两个子序列归并成为一个新的有序的子序列,类似的所有的子区间都可以这样进行归并,理想状态下,一个数两两归并成两个数,两个数两两归并成四个数,四个数两两归并成八个数,即1->2->4->8->16->……->N,都进行这样的归并,那么这个由N个数的数组就成为了有序数组
- 在进行排序前由于我们不能在元素中中直接进行排序,这样会覆盖元素丢失数据,所以我们先开辟一个和当前数组等大的tmp数组进行拷贝,排完序到tmp对应位置后再拷贝回原数组即可
- 由于我们要等分递归数组到子数组进行排序,所以需要均分数组,这里我们计算出当前数组的中间位置的下标位置,类似于二叉树的后序遍历,递归调用到还有一个元素,进行返回排序
- 采用下标控制两个数组,依次比较两个数组中的值,将较小的数依次放到tmp数组中的对应位置,由于数组中的数的值不一样,不等大,也有可能出现两个子数组的个数不相等,所以可能会出现一个数组提前结束的情况,当两个子数组中都有值的时候我们就进行比较,将较小的数放到tmp数组中的对应位置,直到有一个数组没有数之后退出循环
- 一个数组中有值,另一个数组中没有值,那么我们使用while循环,将有值的数组中的值持续的放到tmp数组中去即可
- 当我们将子数组排序完成后,此时排序完成的数存储在tmp数组中,将其拷贝回原数组即可
代码实现
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
return;
int mid = left + (right - left) / 2;
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int i = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] > a[begin2])
{
tmp[i++] = a[begin2++];
}
else
{
tmp[i++] = a[begin1++];
}
}
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
for (int j = left; j <= right; j++)
{
a[j] = tmp[j];
}
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc error");
return;
}
_MergeSort(a, 0, n - 1, tmp);
}
时间复杂度
这里我们仅探讨归并排序的递归形式的时间复杂度
- 这里是进行了取中操作,在理想状态下,归并排序的递归调用类似于二叉树的后序遍历
- 那么共有N个元素,进行递归的开辟栈帧的深度就类似于求二叉树的高度为logN,这里进行归并每一层进行递归的有共有N个元素要继续归并
- 使用大O的渐进法进行计算,那么快速排序的时间复杂度为O(N*logN)
2.10归并排序的非递归
递归改非递归要么是使用栈进行模拟,要么就是直接改循环,这里是采用直接改循环
如果给你一个乱序数组,你不能够说它有序,但是如果给你一个数我们可以说它有序,我们将这一个数看作一个子序列,那么我们就可以说这一个子序列是有序的,给你一个有N个数的数组,将其划分为N个子区间序列,每一个子区间都有一个数,每一个子区间我们都看作是有序的,当两个子序列有序的时候我们可以依次拿出子序列的值进行比较,将两个子序列归并成为一个新的有序的子序列,类似的所有的子区间都可以这样进行归并,理想状态下,一个数两两归并成两个数,两个数两两归并成四个数,四个数两两归并成八个数,即1->2->4->8->16->……->N,都进行这样的归并,那么这个由N个数的数组就成为了有序数组
概念讲解
- 根据上面的分析我们已经知道归并排序的本质其实是不断递归,直达子数组的元数个数为1个,将这一个子数组看作有序,一个数两两归并成两个数,两个数两两归并成四个数,四个数两两归并成八个数,即1->2->4->8->16->……->N,都进行这样的归并,那么这个由N个数的数组就成为了有序数组
- 我们已经知道了结果,那么我们就可以使用循环模拟这个过程
- 在进行排序前由于我们不能在元素中中直接进行排序,这样会覆盖元素丢失数据,所以我们先开辟一个和当前数组等大的tmp数组进行拷贝,排完序到tmp对应位置后再拷贝回原数组即可
- 开始使用循环中的变量gap为1,控制进行两两归并,gap变量扩大二倍,进行四四归并,以此类推,循环变量gap应该小于n,因为循环变量gap控制的是子数组中的最多个数,子数组最多为n-1个元素,此时另一个子数组为1个数,它们进行归并,所以循环变量gap小于n
- 同时当我们进行找子区间的时候还应该特别注意,理想状态下,begin1最初应该在子数组的开头元素下标位置上,因为begin1和begin2代表的是两个子数组最开始的位置为i,而由于gap是表示的子数组的最多个数,begin2应该在begin1下标位置i+gap上,同时end1应该在begin2的前一个位置i+gap-1,类似的推得end2应该在begin3位置的前一个位置i+2*gap-1
- 同时这里不同于递归,这里的子数组排序不一定均分,由于是以2的倍数开始逐渐扩大进行子数组排序,那么有可能会出现越界
- 第一种情况,当end1越界的时候,end1越界begin2,end2必然越界,那么我们要将end1调整到n-1的位置,并且把由begin2,end2控制的数组修改成不存在的数组即begin2=n,end2=n-1
- 第二种情况,当begin2越界,这个时候end2必然越界,那么我们仍然要把由begin2,end2控制的数组修改成不存在的数组即begin2=n,end2=n-1
- 第三种情况,当只有end2越界的时候,我们要将end2修改为成n-1数组的边界即可
- 此时数组边界越界的情况已经被我们修改到正确的位置上了,这时候进行数组的归并即可操作类似于归并排序的递归那里的操作,这里小编不再进行展开叙述,详情请看归并排序的递归
- 当一趟子数组数量为gap的归并完成后,此时数据还在tmp数组中,原数组a并未进行任何修改,这时候我们要将tmp数组中的内容使用memcpy拷贝到原数组a中即可
- 此时数据已经拷贝会原数组,这时候我们将gap扩大二倍继续进行新的子数组的归并排序即可
- 当gap大于等于n的时候元素已经全部完成归并排序,退出循环即可
代码实现
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc error");
return;
}
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 + 2 * gap - 1;
//整体拷贝tmp数组,修正边界
if (end1 >= n)
{
end1 = n - 1;
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
end2 = n - 1;
}
printf("[%d,%d][%d,%d] ", begin1, end1, begin2, end2);
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] > a[begin2])
{
tmp[j++] = a[begin2++];
}
else
{
tmp[j++] = a[begin1++];
}
}
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
printf("\n");
memcpy(a, tmp, sizeof(int) * n);
gap *= 2;
}
}
2.11计数排序
概念讲解
- 计数排序要使用开辟映射数组,映射数组有两个
- 其一为绝对映射数组,这种数组下标即为原数组的元素用于统计,元素直接对应绝对映射数组下标进行统计出现的次数,但是有局限性,不能表示负数,同时如果元素组的值全部处于10000到10005之间,那么会有10000个的数据浪费
- 其二为相对映射数组,相对映射数组是采用原数组的元素减去原数组的最小值对应相对映射数组的下标进行统计出现的次数,可以统计负数,空间也能节约
- 但是对于计数排序不适用数据分散的,最大值最小值的相对值过大的情况
- 计数排序适用于数据集中,最大值最小值的相对值较小的情况,在这种情况下时间复杂度理想状态下可以到达O(N)
- 这里的原数组的范围range计算,由于最大值和最小值是闭区间,比如max=2,min=1,实际上1和2的范围是2,但是采用max-min=2-1=1,结果反而是1,所以这里当max和min是闭区间的时候,需要加上1,即range=max-min+1
- 关于calloc函数的使用,不了解的读者友友请点击<—使用calloc进行初始化开辟个数为range,每个个数的空间大小sizoof(int)的数组,使用calloc进行开辟数组,那么开辟好的数组会全部被初始化为0,满足这里我们初始化相对映射数组的需求
代码实现
void CountSort(int* a, int n)
{
int min = a[0];
int max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
int range = max - min + 1;
int* tmp = (int*)calloc(sizeof(int) ,range);
if (tmp == NULL)
{
perror("malloc error");
return;
}
for (int j = 0; j < n; j++)
{
tmp[a[j] - min]++;
}
int q = 0;
for (int p = 0; p < range; p++)
{
while (tmp[p]--)
{
a[q++] = p + min;
}
}
}
八大排序的稳定性分析
- 插入排序,稳定,当需要插入的值相同时,会依次放在被插入的值的后面
- 希尔排序,不稳定,由于是将数组划分为不同是组,那么相同的数在不同的组中由于不同组的数据大小各不相同,所以相同的数的相对顺序不能确保
- 选择排序,不稳定,例如,数组序列为5,5,4,8,3,3,7那么遍历找出最小值第一个3与第一个5进行交换位置,那么第一个5和第二个5的相对位置就被改变了,所以不稳定
- 堆排序,不稳定,例如堆排序需要向下调整,出现了相同值5的,第一个5被换到最后,第一个5和第二个5在数组中的相对位置就发生了改变
- 冒泡排序,稳定,当出现相同元素的时候,采用最后面的相同的元素向后进行继续交换,相同的值的相对顺序不会改变
- 快速排序,不稳定,当为5,3,2,5,1,5,4的时候,使用快排虽然当遇到相同的值不会挪动相同的值,但是最后将数组开头的key5放到它应该放的位置的时候,数组中的相同元素5的顺序就被打乱了
- 归并排序,稳定,尽管数组被分成了多个子序列,在进行归并判断的时候,当子序列数组1和子序列数组2进行依次判断值的时候,判断到了两个值相同,那么我们仅需要将子序列数组1的相同的值放入tmp拷贝数组中即可维持稳定
- 计数排序,这里不进行探讨它的稳定性,由于放入原数组中的数已经不是元素组的了,原数组中相同的值都被覆盖了,连原有的相同的值都被新的数覆盖了,那么讨论相同的值的相对位置没有意义,所以这里小编就不探讨计数排序的稳定性了
总结
以上就是今天的博客内容啦,希望对读者朋友们有帮助
水滴石穿,坚持就是胜利,读者朋友们可以点个关注
点赞收藏加关注,找到小编不迷路!