一.排序的概念和其应用
1.1排序的概念
- 排序:排列或排序是将一组数据按照一定的规则或顺序重新组织的过程,数据既可以被组织成递增顺序(升序),或者递减顺序(降序)。
- 稳定性:假定在待排序的数据序列中,存在多个相同的数据,若经过排序,这些数据的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排 序算法是稳定的;否则称为不稳定的。
- 内部排序:待排序的数据全都存放在内存中。
- 外部排序:外排的数据太多,内存无法同时存储这么多的数据,需要不停从外存中提取数据到内存中进行排序。
1.2排序的应用
排序与我们的生活息息相关,当我们想买一部性价比较高的手机时,我们打开购物网站可以根据综合排序来找到最近热门的手机型号。
当然我们还可以根据销量、评论数等等来进行选择,这里面就是排序的过程。该网站获取所有手机的销量,然后排序,再展示给使用者。这就是排序在生活中的作用。
还比如我们到了一个地方旅游不知道吃什么,我们就可以根据大众点评上面的餐饮排序来选择。
所以,排序是我们日常生活中不可或缺的。而在数据结构与算法中,排序大致分为:插入排序、选择排序、交换排序和归并排序。而这几种下面又具体分化出不同的排序方法。
下面我们来了解一下这几种常见的排序方法。
二.冒泡排序
冒泡排序是我们刚开始借助C语言所学的一种排序方式,其排序的基本思路是遍历待排序的数据,两两相邻的数据之间进行比较,与要求的排列顺序进行对比,如果顺序不正确就要进行交换。直到将所有的数据都交换至有序为止。动图如下:
假设我们要排升序,那我们就从头开始进行比较,如果前一个大于后一个就交换,这样就可以将该数据中最大的一个数据放到最后一个位置,这是一次排序,然后我们要从头开始继续比较,将次大的数放到倒数第二个的位置上。重复该操作我们就可以将该数据变为有序。
//交换
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
//冒泡排序
void BubbleSort(int* a, int n)
{
//打印为排序的数据
Print(a, n);
int j = 0;
for (j = 0; j < n; j++)
{
//一趟交换
int i = 0;
int flag = 0;//标记,判断一趟中是否发生交换
for (i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
flag = 1;
Swap(&a[i - 1], &a[i]);//交换
}
}
//序列已经有序,不必在进行排序
if (flag == 0)
{
break;
}
}
//打印排序好的数据
Print(a, n);
}
我们根据冒泡排序的执行逻辑可以推出其时间复杂度为:O(N^2).冒泡排序的一些细节:
三.插入排序
插入排序是一种非常清晰明了的一种排序方式,通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。我们所熟知的扑克牌中就暗藏我们的插入排序。我们先将某一张牌看作有序,然后将后面无序的牌与前面的牌进行比较,然后插入到合适的位置。
动图演示:
插入排序中的直接插入排序就很好的利用了这一算法逻辑,实现了插入排序。
3.1直接插入排序
直接插入排序的逻辑就是插入排序的逻辑,我们根据动图分析,我们先将第一个数3看作为有序,然后将后面的数与其进行比较,如果小于则3往后挪,将后面的数插到前面,否则就不动,此时这两个数就有序了。然后插入第三个数据,插入的地方一定是大于等于前一个数并且小于等于后一个数。
直接插入排序的特性总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
然而我们现在只是完成了单次的交换,那么怎样实现插入排序的全过程呢? 我们知道了end是有序队列的最后一个的下标,所以我们就可以控制end来实现插入排序的实现。我们使end从0开始,到n-1(n表示数据个数),就可以实现对n个数据的排序。
//插入排序
void InsertSort(int* a, int n)
{
int i = 0;
for (i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end + 1];//待插入的数
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];//将大数往后放
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
我们i从0~n-2,就可以对n个数进行排序。为什么i<n-1呢?因为i = end,而待排序的数的下标是end+1,如果i<n的话,i最大就是n-1,end+1 = n,而n不是数组的下标,就越界了。我们通过i的位置,就可以一直往后找到待排序的数。当i = n-2的时候,此时就只剩一个数没有被排了。
3.2希尔排序
希尔排序利用的基本方法也是插入排序,但是希尔排序在排序前会先进行预排序,使序列更接近有序。然后再进行一次插入排序使序列有序。
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数gap,把待排序文件中所有记录分成gap组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工 作。当到达=1时,所有记录在统一组内排好序。
什么意思呢?我们借助图来进行分析。
知道了思路,那如何实现希尔排序呢?
我们进行一次预排序是不能使队列更接近有序的,所以我们需要进行多组预排序,使我们的数据更可能的接近有序。
那么再次进行预排序的时候gap还等于3有用么?
当然没有用了,因为我们根据gap等于3已经对其进行了预排序,其分为3组的情况已经有序了。所以我们要改变gap的值后再进行预排序。
那么现在gap的值是应该变大还是变小呢?
答案是变小,因为我们在预排序之后还要进行一次插入排序,而我们可以观察到当gap等于1时,希尔排序和插入排序是一样的。
这样不仅可以解决要进行多次预排序的问题,还可以解决最后一次插入排序的问题。 那么gap要怎么取呢?gap的取法有很多种,没有一个固定的要求,但是目前大多数都已gap/3+1的方式来取gap。这样可以保证最后一次gap一定等于1.
// 希尔排序
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
int i = 0;
for (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;
}
}
}
3.2.1希尔排序的时间复杂度
希尔排序的时间复杂度非常难计算,因为gap取的不同,算出的时间复杂度也不同。但是目前都比较认可的时间复杂度为:O(N^1.3)。
我们从图中可分析出,gap的变化会导致交换次数的变化,而gap的取法又是多种多样的,所以希尔排序的时间复杂度非常难计算。
四.选择排序
选择排序也是我们刚开始学习C语言就接触到的一种排序方法。其排序思想是:遍历待排数据,从中选出最小的与第一个数据进行交换,然后重新遍历数据找到次小的数据放到第二个数据的位置。直到所有数据有序。我们从分析中可以得知其时间复杂度为O(N^2).
直接选择排序的特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
//选择排序
void SelectSort(int* a, int n)
{
int i = 0;
for (i = 0; i < n; i++)
{
int mini = i;
int j = 0;
for (j = i; j < n; j++)
{
if (a[mini] > a[j])
{
mini = j;
}
}
Swap(&a[i], &a[mini]);
}
}
我们在遍历的时候记录下当前数据中最小数据的下标,如果遇到更小的就更新下标,遍历完之后,与第一个数据交换。此时我们第一个数据就是最小的数据,我们直接从第二个开始遍历,如果从第一个开始的话结果会错误。以此类推,第三次从第三个开始遍历。
这种选择排序的效率太低了,实践中没有什么意义。所以我们可以对其进行优化。
我们可以从左右两边同时开始遍历,左边找最小的放,右边找最大的放。
//优化选择排序
void OptimizeSelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int mini = begin;//记录最小数据的下标
int maxi = end;//记录最大数据的下标
int j = 0;
for (j = begin; j <= end; j++)
{
if (a[mini] > a[j])
{
mini = j;
}
if (a[maxi] < a[j])
{
maxi = j;
}
}
if (mini + maxi == n - 1)
{
Swap(&a[mini], &a[maxi]);
}
else if (maxi == begin)
{
Swap(&a[end], &a[maxi]);
Swap(&a[begin], &a[mini]);
}
else if (mini == end)
{
Swap(&a[begin], &a[mini]);
Swap(&a[end], &a[maxi]);
}
begin++;
end--;
}
}
五.堆排序
堆排序是数据结构堆的一种应用,我之前已经介绍过了,大家可以看这篇文章来了解堆排序。小伙饿坏了,赶紧点了一份爱学的二叉树——堆,狼吞虎咽学完很过瘾-CSDN博客
六.快速排序
6.1hoare快排
hoare快排的基本思想是:选左边的数为关键字key,然后R先从右边开始遍历序列找到第一个比key小的数就停下,然后L开始从左边开始遍历,找到第一个比key大的数停下来,然后交换R和L的值,然后R继续找,找到后L找,然后交换,当R与L相遇的时候,与key交换。这就完成了一次排序,使key换到了自己有序的位置上,左边的数都小于key右边的数都大于key。然后key会将区间分成左右两个区间,然后分别对这两个区间采用相同的操作就可以使数据有序。
下面我们来实现一下单趟排序的逻辑:
首先选取区间左端点为key,L从左端点开始,R从右端点开始
然后R先向左走,找到第一个比key小的数,然后停下来
然后L开始向右走,找到第一个比key大的数停下来
然后交换L和R对应位置上的数据
此时L和R还没有相遇,R继续向左走找比key小的数据
找到后,L向右走找比key大的数
L和R还没有相遇,交换L和R对应的值
然后重复刚才找小,和找大的过程 判断有没有相遇,没有相遇L再向右走找大
当L和R相遇之后,交换key位置的数据和L、R位置上的数据
然后,原来key对应的数6,此刻就已经有序了,因为它的左边都是小于它的数,右边都是大于它的数 。接着想要让该序列有序的话,只需要让6的左右区间分别有序就可以了。而且让其有序的方法和上述方法一样,所以我们这里需要用到递归的思想。
然后下面的左右区间也会使一个数有序,然后又将区间分成左右两个,然后再利用递归使其有序。
理解了思想,我们先来实现快排单趟如何实现,也就是如果先使一个数据有序?
我们单趟完成之后,只需要采用递归的思想,对左右两个区间分别进行该操作即可。 但是我们递归就必须得有递归地结束条件。每完成一次就会有一个数有序,分出来左右区间,那么当分出来的区间只有一个数时或者区间不存在时,还需要排序么?
所以递归地结束条件就是当区间只有一个数或者区间不存在。
//hoare快排
void hoareSort(int* a, int left, int right)
{
//区间只有一个数或者没有数时不需要排序
if (left >= right)
{
return;
}
int begin = left;
int end = right;
int keyi = left;
while (begin < end)
{
//end先往左走,找小
while (a[end] > a[keyi] && begin < end)
{
end--;
}
//begin往右走,找大
while (a[begin] < a[keyi] && begin < end)
{
begin++;
}
//begin和end在一个位置时交换没必要
if (begin != end)
{
//交换end和begin处的数据
Swap(&a[begin], &a[end]);
}
}
//此时begin和end相遇,交换其和keyi的值
Swap(&a[begin], &a[keyi]);
keyi = begin;
//[left,keyi-1] keyi [keyi+1,right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
这就是hoare快排的基本形式,但是该代码有一定的缺陷。可能会造成栈溢出的风险。我们分析排序过程之后可以发现其递归图就好像二叉树的样子,每一个区间就类似于二叉树的节点,其高度也就是logN,但是当待排序的序列是有序的时候,即最小的数据一直在左边的话,那么就会出现下面这种情况:
每次都将最左边的排好,将区间分为左边和右边,每次相当于左区间为空,右区间比原区间少一个数,所以现在的高度就成了N^2这个量级。 这样递归的深度就增加了,可能会造成栈溢出的封信。
那么有什么办法可以避免该问题么?
采用三数取中法
我们可以在进行排序之前先取出区间左右端点的值和区间中点的值,然后取三个数中中间的数作为key,这样就可以避免key是序列中最小值的情况了。
//三数取中法
int GetMidData(int* a,int left,int right)
{
int begin = left;
int end = right;
int mid = (left + right) / 2;
if (a[begin] > a[mid])
{
if (a[mid] > a[end])
{
//begin > mid > end
return mid;
}
else
{
//mid是最小的,返回end和begin中较小的那个
return a[begin] > a[end] ? end : begin;
}
}
else//begin < mid
{
if (a[mid] < a[end])
{
//begin < mid < end
return mid;
}
else
{
//mid是最大的,返回begin和end中较大的那个
return a[begin] > a[end] ? begin : end;
}
}
}
我们将第二大的数的下标返回,然后在快排函数中接收该下标,与区间左端点值进行交换,使左端点left对应的值为第二大的值。
那么快排的时间复杂度是多少呢?
最坏的情况就是数据已经有序的情况下,递归深度增加,其时间复杂度为O(N^2)。但是我们采用了三数取中的方式对快排进行了优化,就不可能出现递归太深的情况,往往都是哪种类似二叉树的情况,它的高度为logN,每一层都要遍历区间,所以是N,所以它的时间复杂度为O(N*logN)。
到了这里,我们还可以对快排在进行一次优化——小区间优化。
我们刚才介绍,快排展开就像是一棵二叉树一样,而对于二叉树来说,其最后一层上的节点数就占了总结点数的一半。那对于快排来说,最后一层上的区间数就是总区间数的一半,而每一个区间都要进行递归,所以快排最后一层的递归次数占了整个排序过程中递归次数的一半。
而对于后面几层的区间来说,这些已经是被分的很小的区间了。所以我们这里提出一个小区间优化,对区间元素个数小于10个的,直接对其使用插入排序,这样就可以减少快排的大部分递归次数。
hoare快排最后一个问题就是,当L和R相遇之后,key与其交换,相遇处的值是否一定小于key值?答案是:是
我们在这里先给出结论:区间左端点为key,R起始为右端点,L为左端点,R先走找小,L后走找大,当其相遇的时候,相遇位置的值一定小于key位置的值。
在走的过程中,结束的情况有两种:
- L遇见R:L遇见R说明R已经停下来了,此时该L走了,那么它们相遇的位置就是R停下的位置,R是找小,说明其遇到了比key小的数才停了下来。此时相遇值小于key。
- R遇见L:R遇见L说明已经完成了一次交换,该R先走,走到了L之前停的位置,又因为上一次L已经和R发生了交换,此时L位置上的数一定小于key,所以该相遇处的值还是小于key。
当然,key也可以取右端点,那就是L先走,R再走。对于上面的情况,如果想要排降序,那就R找大,L找下。
6.2挖坑法
hoare快排是最先提出来的快排方式,但后来还出现来好几种实现快排的方式,但其本质上都是一样。这里我们了解一下挖坑法实现快排。
这里依旧选区间左端点为key,我们记录其值,然后该位置的数就可以被覆盖,把这记作一个坑。然后R从右端点开始找小,找到之后将数据填入坑中,然后在该位置形成一个新的坑。然后L开始找大,找到之后填入坑中,再形成新的坑。当L与R相遇时,将key的值,填入坑中,就完成了一次单趟快排。然后跟hoare一样,再进行递归实现其他区间排序。
下面给出单趟排序的过程图:
先记录区间左端点的值,然后在该位置形成坑
R开始找小,找到之后将值填入坑中,然后形成新的坑
L开始找大,找到之后填入坑中,形成新坑
然后重复上面的过程,直到L和R相遇
R走
L走
R走
L走 ,但是L走的过程中与R相遇,此时将key填入坑中。这样,6就已经有序了,然后我们在对6左右两个区间分别采用递归的方式使序列有序。
//挖坑法
int DigHoleSort(int* a, int left, int right)
{
//三数取中法
int mid = GetMidData(a, left, right);
//将中间数的值与left的值进行交换,left的值就一定不是最小的了
Swap(&a[mid], &a[left]);
int begin = left;
int end = right;
int key = a[left];//关键字,R和L比较的标准
int hole = left;//在挖坑法中hole就是坑位的下标
while (begin < end)
{
while (a[end] > key && begin < end)
{
end--;
}
//找到小于key的数之后填入坑中
a[hole] = a[end];
//更新坑位
hole = end;
while (a[begin] < key && begin < end)
{
begin++;
}
//找到大于key的数之后填入坑中
a[hole] = a[begin];
//更新坑位
hole = begin;
}
//相遇后,将key填入坑中
a[hole] = key;
//hole下标对应的数据就已经有序了
return hole;//返回的是分左右区间的依据
}
我们对快排进行了拆分,将单趟的排序分割出来,使快排函数内部显得更为简单一点。我们只需要调用单趟排序即可。下面是快排的逻辑。
//快排
void QuickSort(int* a, int left, int right)//left为起始下标,right为终止下标
{
//区间只有一个数或者没有数时不需要排序
if (left >= right)
{
return;
}
//小区间优化
if ((right - left + 1) < 10)
{
InsertSort(a+left, right - left + 1);
}
else
{
//int keyi = hoareSort(a, left, right);//hoare快排
int keyi = DigHoleSort(a, left, right);//挖坑法
//一个单趟走完,此时keyi处的数据已经有序,将还需排序的数据分成了两个区间,再对下面区间数据采用相同逻辑进行排序
//[left,keyi-1] keyi [keyi+1,right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
我们根据上面两端代码的逻辑可以清楚的理解挖坑法为什么要将最后一个坑位返回给快排函数,因为单趟之后最后一个坑的数已经有序了,所以要借助来分割区间。
6.3前后指针法
前后指针法也是一种递归方式的快排,与hoare和挖坑有些许不同。
该方法就是key指向左端点值,prev也指向左端点,cur指向prev的下一个位置,然后cur开始向后遍历,遇到比key小的数后,prev往前走一步,然后交换cur和prev,然后cur继续走,当cur指向序列之外时,此时prev指向的数据就已经有序了,返回该值作为区分区间的依据。然后在进行递归排序。
下面给出一个单趟交换的过程:
//前后指针法
int PrevCurSort(int* a, int left, int right)
{
//三数取中法
int mid = GetMidData(a, left, right);
//将中间数的值与left的值进行交换,left的值就一定不是最小的了
Swap(&a[mid], &a[left]);
int prev = left;
int cur = prev + 1;
int keyi = left;
while (cur <= right)
{
//cur小于key时,prev向后走一步,然后交换
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[cur], &a[prev]);
}
cur++;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
return keyi;
}
6.4非递归实现快排
6.4.1栈实现快排
我们在上面介绍了三种借助递归实现快排的方法。我们在这里了解一下如何用非递归实现快排。
我们仔细观察上面递归实现快排的过程中,每一次不同的地方在于函数的区间不同,所以如果想用非递归实现快排,我们只需要将每一个需要排序的区间记录下来。然后根据区间,调用单趟快排即可。
在这里我们可以借助我们先前了解过的数据结构栈来实现。不了解栈的可以看这篇文章重生之我在学数据结构——栈-CSDN博客。
栈遵循的是后进先出的逻辑,所以如果我们想要实现跟上面递归一样的逻辑时,我们可以先储存右区间,在储存左区间。在储存区间时,先插入区间右端点,再插入区间左端点。当我们要排序的时候先取出区间,然后进行单趟排序,然后将其产生的左右区间再次插入到栈中。当栈为空时,此时序列已经有序。
在这里我们采用上面的哪一个单趟都是可以的。
这就是借助栈来实现快排的思路。接下来我们实现代码:
//非递归快排
void QuickSortNonR(int* a, int left, int right)
{
//创建栈
Stack s = { 0 };
StackInit(&s);
//先将区间端点压入栈中
//先压右端点后压左端点
StackPush(&s, right);
StackPush(&s, left);
while (!StackEmpty(&s))
{
int begin = GetStackTop(&s);
StackPop(&s);
int end = GetStackTop(&s);
StackPop(&s);
int keyi = PrevCurSort(a, begin, end);
//右区间
if (end > keyi + 1)
{
StackPush(&s, end);
StackPush(&s, keyi + 1);
}
//左区间
if (begin < keyi - 1)
{
StackPush(&s, keyi - 1);
StackPush(&s, begin);
}
}
StackDestory(&s);
}
我们再将区间压入栈时要进行判断,如果区间只有一个数那就没有必要入栈去走单趟了。区间不存在时也不能入栈。
6.4.2队列实现快排
我们也可以借助队列来实现快排。基本思路是相同的储存区间即可。但是因为队列遵循的是先进先出,所以在存储区间的时候有所不同。我们要先入左区间再入右区间;入区间端点时,先入左区间,再入右区间。
//借助队列实现非递归快排
void QuickSortNonRByQueue(int* a, int left, int right)
{
//创建队列
Queue q = { 0 };
QueueInit(&q);
QueuePush(&q, left);
QueuePush(&q, right);
while (!QueueEmpty(&q))
{
//获取区间左右端点
int begin = GetQueueFront(&q);
QueuePop(&q);
int end = GetQueueFront(&q);
QueuePop(&q);
int keyi = PrevCurSort(a, begin, end);
//先入左区间
if (begin < keyi - 1)
{
//先入左端点
QueuePush(&q, begin);
QueuePush(&q, keyi - 1);
}
if (end > keyi + 1)
{
QueuePush(&q, keyi + 1);
QueuePush(&q, end);
}
}
QueueDestroy(&q);
}
七.归并排序
7.1递归实现归并
归并排序也是一种常用的排序方法,其基本逻辑是:将一段区间分为左右区间,如果左右区间有序,那就进行归并。归并的操作就是同时遍历左右区间进行比较,将小的数拷贝到一个新数组中,然后拷贝完后在将数据拷贝到原数组,这样就实现了排序的功能。
所以我们的问题就转变成了使区间分成的左右区间有序,那么如何使分成的左右区间有序呢?
我们可以多次进行分割,当区间只有一个数据时或者区间不存在时,那就说明此时这个区间就已经有序了,然后进行归并操作,这时候两个数据就有序了,然后继续归并,直到将所有数据归并完。我们可以看到这里面用到了递归地思想。
我们给出下面一组数据进行归并排序的过程分析:
假设下面就是待排序的数据
为了使这组数据有序,那就得将他分成左右两个区间,让左右区间分别有序然后进行归并操作
但是这两个区间并不有序,那我们就得继续分割,直到其区间中的数据有序
我们将其分为一个区间只有一个数据时,此时每一个区间就都是有序的了,然后我们在进行归并操作。使左右区间的上一级区间有序。
然后再继续归并,使其上一层区间有序
在执行同样的归并操作
当其没有上一层区间时,就说明数据已经有序了。
void _MergeSort(int* a, int* tmp, int left, int right)
{
if (left >= right)
{
return;
}
//分割区间
//[left,mid][mid+1,right]
int mid = (left + right) / 2;
_MergeSort(a, tmp, left, mid);
_MergeSort(a, tmp, mid + 1, right);
//归并
int i = left;
//左区间
int begin1 = left;
int end1 = mid;
//右区间
int begin2 = mid + 1;
int end2 = right;
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++];
}
memcpy(a + left, tmp + left, sizeof(int) * (end2 - left + 1));
}
//归并排序——递归
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("MergeSort()::malloc()");
return;
}
_MergeSort(a,tmp,0,n-1);
free(tmp);
tmp = NULL;
}
那么归并排序的时间复杂度是多少呢?
归并排序最后会将数据分割成一个一个的区间,其分割的过程是一种折半分割,类似于二叉树,所以其高度为logN,然后其每一层都要进行归并,就要对每一个数据进行比较,访问到每一个数据,其消耗是N,所以其时间复杂度为:O(N*logN)
7.2非递归实现归并
我们也可以使用非递归来实现归并排序。但是我们不再借助栈来实现,我们直接通过循环来控制每一次归并的区间,使其每进行一次归并就使区间有序。
我们同样对相同的数据进行操作,下面进行过程的分析:
我们不再像上面那种分割区间,将区间分成只有一个数据时,然后在进行归并。我们这里直接进行归并,但是归并的要求是左右区间是有序的,而在当前情况下,只有一个数据是一个区间时才是有序的,所以我们可以进行11归并,即每组只有一个值,相邻两组进行归并。
我们看到此时就产生了有序的区间了,而区间的值也有1变为2,所以我们这里来在进行22归并,使相邻的区间合并成一个有序区间
此时上面相邻的左右区间就合并成了一个新的有序,然后又作为左区间和右区间进行44归并
当只剩一个区间时,就说明已经排序完成了。 我们看到我们再归并的过程中,控制了每一次归并数据的数据个数,由一个变成2个、4个,这就像是我们递归的逆过程。我们用一个gap变量来表示当前归并的每组数据的数据个数。
//归并排序——非递归
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("MergeSort()::malloc()");
return;
}
int gap = 1;
while (gap < n)
{
int i = 0;
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;
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++];
}
//将数据复制到原数组中
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i));
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
代码写完了,但是我们写的是有问题的,我们的i是每次归并的开始位置,只有每一次归并结束后才改变,但是我们再归并操作中用它做了,导致它进行了自增,对后面其他组的递归造成影响。
所以我们利用j来作为下标使用,防止意外改变了i。
还有一个问题就是拷贝数据的时候,我们拷贝的数据个数是右区间的右端点-左区间的左端再加上1,因为下标是个数-1,所以求个数时要加上1.
修改完成后,我们看结果:
但是当我们的数据不是2的倍数时就会产生错误,会发生越界访问。因为我们的下标都是按照2的倍数计算的,当数据不是时,就会导致下标大于最后一个数据的下标,导致越界访问。
我们来打印一下每一次归并的区间,来判断那个端点越界了:
我们只有10个数据,当下标超过9时,就说明越界了。而根据我们分出的区间我们可以判断出是谁越界了。
这样分类是因为,当end1和begin2都越界时,它们就不再需要进行归并了,说明此时只有一组数据,两组才能归并,所以我们将这两种归为一类。
第二类就是end2越界,说明此时begin2没有越界,是有两组数据的,所以还需要进行归并,此时我们就要对end2进行修改。
再进行测试:
我们已经解决了非递归中出现的问题,代码已经完全:
//归并排序——非递归
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("MergeSort()::malloc()");
return;
}
int gap = 1;
while (gap < n)
{
int i = 0;
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;
//printf("[%d,%d] [%d,%d]", begin1, end1, begin2, end2);
//begin2或者end1越界,都不需要归并,直接跳出循环
if (begin2 >= n)
{
break;
}
//只有end2越界,修改end2的下标,使其指向数据的最后一个元素的下标
if (end2 >= n)
{
end2 = n - 1;
}
int j = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
//一个区间结束放完了,还有一个区间没完
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
//将数据复制到原数组中
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
八.计数排序
计数排序是一种不需要进行比较就可以实现排序的一种的算法,有点像哈希表。其具体操作步骤为:1.统计相同数据出现的个数;2.根据个数将对应数据放到原数组中。
下面借助一组数据来分析计数排序的执行过程:
假设有一组数据都属于0~10这个范围,我们来对其进行排序
第一步就是统计数据中每一种元素出现的数据个数,记录到一个count数组中 ,count数组中每一个数据都初始化为0
我们进行统计之后count数组就发生了变化:
然后我们根据count数组中每一个下标对应元素的个数,开始打印下标。
0下标对应的元素是0,那就不打印0,1下标对应的元素是3,那就打印3次1,一次类推,在遍历count数组时,将数据输出到原数组,此时数据就变得有序了。
这到底是怎么实现的呢?
其实我们是将待排序的数作为了count数组的下标了,而count数组的下标本来就是有序的。所以我们只需要知道每一个下标出现了几次,然后拷贝到原数组就行了。
那该方法遇到数据不是0~10的怎么办呢?比如数据是100~109呢?
其实我们还是可以做到一一对应的关系。我们计算出数据所在的区间大小range,来判断我们需要开多大的空间,然后我们将原数组中每一个数据减去最小值再作为count数组的下标来进行统计。
假如我们的数据是上面的数据,难道我们要开一个空间为110的count数组来存储嘛?
如果这样的话,我们前面的空间都被浪费掉了,因为那里并没有值,所以肯定不可以这样来申请空间。
我们用每一个值减去最小值来看一下:
当前去最小值之后,是不是就可以和下面的下标对应起来, 0就相当于是100,1相当于是101。这样就避免了多开空间,造成空间浪费的问题。而我们这个逻辑转化为代码就是:
我们接下来实现代码:
//计数排序
void CountSort(int* a, int n)
{
//找出最小值和最大值,求出数据的范围
int i = 0;
int min = a[0];
int max = a[0];
for (i = 0; i < n; i++)
{
if (min > a[i])
{
min = a[i];
}
if (max < a[i])
{
max = a[i];
}
}
int range = max - min + 1;
//创建count数组
int* count = (int*)calloc(range, sizeof(int));
if (count == NULL)
{
perror("CountSort()::calloc()");
return;
}
//统计
for (i = 0; i < n; i++)
{
count[a[i] - min]++;
}
//将数据拷贝回原数组
i = 0;
int j = 0;
for (j=0; j<range; j++)
{
while (count[j]--)
{
a[i++] = j + min;
}
}
九.排序的稳定性
排序的稳定性就是说对两个相同的数据,进行排序之后,它们两个的相对位置不发生变化,就说明该排序是稳定的,否则就是不稳定的。我们对上面介绍过的排序进行稳定性分析。
冒泡排序是稳定的,冒泡排序是相邻之间进行交换,如果两个数据相等时就不再交换了,所以其不会改变两个相等元素的相对位置。
插入排序是稳定的,如果该元素小于前面的元素才会向前插入,如果与前面元素相等就会插入到该元素的后面,所以相对位置也不会改变。
希尔排序是不稳定的,因为希尔排序要进行多组的预排序,有可能相等的元素不在同一组,可能会造成数据位置的交换。
选择排序是不稳定的,选择排序会遍历数组选出最小的放到第一个位置,但是如果是下面这种情况就会交换数据位置:
虽然两个1的相对位置没变,但是6的相对位置变了
堆排序是不稳定的,如果数组建堆之后的跟和左右儿子位置的值相等,此时将根换到最后一个位置时,相对位置发生了改变。
快速排序是不稳定的,快速排序中会跳跃交换,这种就很有可能造成相对位置的变化:
归并排序是稳定的,如果归并时两组的数据相同,就会先归并第一个,在归并第二个,但是他们的相对位置却没有变化。