开场白
排序相信大家在生活中经常会遇到,当我们排队列的时候,我们总喜欢从低到高排序,或者一串杂乱的数字,我们总喜欢想办法将他们排成有序。似乎有序的数总会让人们看得更舒服一点。
我们来看看上面这副图,这里我选择的是从价格上从大到小排序,我们可以看到右上角,我们还可以从综合排序,销量,评论数等等根据我们的选择,来排序出我们想知道的产品的信息。
现在网购信息庞大,比如这里的iPhone手机,当我们搜索发现就有接近80万的相关物品,如此之多,我们要怎样选择呢?我们就可以从销量来看,看一看每一款手机的销量如何,从而避坑。
不管从什么情况选手机,我们都需要用到排序算法。
排序算法的性能的差异
我们在判断一个算法的好坏时,主要根据它的时间性能,辅助空间,算法的复杂性来判断。
1.时间性能
时间对于我们来说是很宝贵的,我们判断一个方法好不好,一般就看它的时间用了多少,这是最直观的体现,其次再是空间和算法的复杂性的判断。因此排序算法的时间开销是衡量其好坏的标志最重要的标志。在内排序中,主要进行两种操作,比较和移动。比较指关键字之间的比较,这是要做排序最起码的操作。移动指记录从一个位置移动到另一个位置,事实上,移动可以通过改变记录的存储方式来避免。总之,高效率的内排序算法应该是具有尽可能少的关键字比较次数和尽可能的记录移动次数。
2.辅助空间
空间对我们来说也是及其重要的,如果说放在以前的话,在那个内存如金的时代,我们对内存的使用就比较吝啬,而到了现在随便一台电脑都有着巨大的内存,所以人们对内存的消耗就没有那么大的在意了,但是我们再写代码的时候还是要避免因为一些比较简单的错误导致内存的泄露。
评价排序算法的另一个主要标准是执行算法所需要的辅助存储空间。辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需要的其它存储空间。
3.算法的复杂性
这里指的是算法本身的复杂度,而不是指算法的时间复杂度。显然算法过度复杂也会影响排序的性能,也会影响代码的可读性。
我们把内排序分为插入排序,交换排序,选择排序,和归并排序。可以说这些都是比较成熟的排序技术,已经被广泛地应用于许许多多的程序语言和数据库中,甚至它们都封装了关于排序算法的实现代码。因此我们再学习排序算法的目的是为了学习他们的思维和提高我们的代码能力,而不是仅仅学会怎样使用。
因为后面的排序都用到了交换函数,所以我们在这里首先写一个交换函数,后面就不用每一个排序算法都写了。
//交换函数 void Swap(int* p1,int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; }
1.冒泡排序(Bubble Sort)
无论学习哪种计算机语言,在学习到了循环和数组的时候,通常都会介绍一种排序算法来锻炼我们掌握知识的水平,而这个排序算法就是冒泡排序。并不是它的名称好听,而是说这个算法的思路最简单,最容易理解。
冒泡排序(Bubble Sort)是一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序就交换,直到没有反序的记录为止。
我们以升序为例,冒泡排序的第一趟,从数组的第一个元素开始,向后两两比较,最后将最大的数冒到最后;第二趟,还是从第一个元素开始,向后两两比较,最后将次大的数冒到倒数第二个;依次到最小的一个数。
根据上面的动图,我们就可以很好的理解它们的移动关系。9,7,5······依次被移到了最后。
下面我们来看一看代码的实现吧。
冒泡排序
void BubbleSort(int* arr, int size)
{
//冒泡排序的原理是第一次现将最大的冒到最后,第二次将次大的冒到倒数第二个
//外循环控制次数
for(int j=0;j<size;j++)
{
//end表示寻找倒数第几个大的数
int end = size-j-1;
for (int i = 0; i < end; i++)
{
//只要前一个数大于后一个数就交换位置
if (arr[i] > arr[i + 1])
{
Swap(&arr[i], &arr[i + 1]);
}
}
}
}
我们通过外循环来控制整个过程的次数,因为我们要将每一个数都变得有序,所以至少都要进行size次,然后通过内循环,找到前一个和后一个的关系,将他们的位置交换。
这是冒泡排序的最初版本,有没有什么可以改进的地方呢?
因为冒泡排序是要将前后每一个都要比较一遍,如果说我们走过一趟之后,数组就变得有序了呢?那我们再走接下来的比较就没有意义了,就完全是浪费时间了。
所以我们可以将我们的代码改进一下。
改进版本
冒泡排序
void BubbleSort(int* arr, int size)
{
//冒泡排序的原理是第一次现将最大的冒到最后,第二次将次大的冒到倒数第二个
//外循环控制次数
for(int j=0;j<size;j++)
{
int flag = 1;
//end表示寻找倒数第几个大的数
int end = size-j-1;
for (int i = 0; i < end; i++)
{
//只要前一个数大于后一个数就交换位置
if (arr[i] > arr[i + 1])
{
Swap(&arr[i], &arr[i + 1]);
flag == 0;
}
}
if (flag == 1)
break;
}
}
看一看上面的代码,我们再每一趟的前面定义一个变量flag,并赋值为1,当他进入内循环的时并且进入了交换函数,它的值就变为了0,则说明数组还不是有序;如果说它没有进行交换,则说明已经有序了,就不用再进行后面的多余的操作,直接break跳出循环就行了。
经过这样的改进,冒泡排序在性能上就有了一些提升,可以避免已经有序的情况下的无意义循环判断。
时间复杂度分析
分析一下它的时间复杂度。当最好的情况,也就是要排序的数本来就是有序的,那么我们比较次数,根据最后改进的代码,可以推断出就是n-1次比较,没有数据交换,时间复杂度为o(n)。
我们拿最坏的情况来看,如果是一组逆序的数据,比如9,8,7,6,5,4,3,2,1,0。我们要把9拿到最后就要进行9次,把8拿到它应该到的位置就需要8次,依次类推,1则需要1次。我们可以总结出来它其实是一个等差数列,1+2+3+4+···+n-1+n。我们根据所学的数学知识数列求和公式,就等于n*(n+1)/2次,就是O(n^2)。
2.直接插入排序(Straight Isertion Sort)
扑克牌是我们几乎每一个人都玩过的游戏。最基本的扑克玩法都是一遍摸牌,一遍理牌。
可以看到我们再2,4,5,10这4个数已经是有序的情况下,摸上来了一张7,我们现在需要通过比较把它放在合适的位置,我们发现它刚好是大于5小于10的,所以它会被放在5和10的中间。我们人眼可以直接看出来,可是对于计算机来说则需要一一比较才能算出结果。
直接插入排序(Straight Isertion Sort)的基本操作是将一个记录插入到已经排好序的有序列表中,从而得到一个新的,记录增一的有序表。
还是拿上面的那副图来说,假如当我们第一张摸上来的是4,这时就只有一张牌,它就是一个有序数列,然后接着我们摸上5,它比4大所以我们将它排在4的后面,现在就成为了两个数的有序数列,接着摸到10,放在5的后面称为三个数的有序数列·······。
我们通过上面的动图,就能很好的理解插入排序的过程了。下面我们再来实现一下代码。
//插入排序
void InSertSort(int* arr, int size)
{
//外循环控制次数
for (int i = 0; i < size - 1; i++)
{
int end = i;
//tmp就是待插入的数
int tmp = arr[end + 1];
//每次都把第end+1个数,插入到前end个有序数列里面
while (end >= 0)
{ //如果待插入的数比end小,则让end向后移一位
if (tmp < arr[end])
{
arr[end + 1] = arr[end];
end--;
}
//因为前end项都是有序的,所以只要tmp>arr[end]就跳出循环
else
{
break;
}
}
//不管走哪种情况,都需要把tmp插入
arr[end + 1] = tmp;
}
}
因为我们每次插入的时候都是在一个有序数列上进行插入,所以我们只需要找到它在哪两个数之间就行了。
时间复杂度分析
我们首先来分析最好的情况,就是有序的时候,就不需要进行插入操作,只需要进行判断,时间复杂度就位O(n)。
当是最坏的情况下的时候,就是逆序的时候,比如{5,4,3,2,1},此时就需要比较2+3+4+···+n=(n+2)(n-1)/2次,而记录的移动次数达到了,(n+4)(n-1)/2次。平均下来时间复杂度也是O(n^2)。
但其实插入排序要比冒泡排序的性能方面好很多。
3.选择排序(Selection Sort)
选择排序,顾名思义,就是每次选择出最小的放在数据的最前端,一次类推,直到选到最后一个数字。
简单选择排序法就是通过n-i次数字之间的比较,从n-i+1个记录中选出关键字最小的几率,并和第i个交换。
因为选择排序相对来说比较好理解,我们先看一看图,然后就上代码。
红色表示暂时选出来的最小数字,它第一趟就找出最小的数的下标,然后将它换到第一个,接着再找到次小的,换到第二个,依次类推。
但是这样的话操作就会很繁琐,其实每一次寻找都是遍历一次数据,我们这个时候就可以找到最小值的同时找到最大值,我们只需要把最小的值放在最前面,最大的值放在最后就行了。
// 选择排序
void SelectSort(int* arr, int size)
{
int begin = 0;
int end = size-1;
//我们一趟就直接找出一个最大的值,和最小的值
//注意找出的是下标
//定义两个变量向中间走,当他们相遇的时候就结束循环
while (begin < end)
{
//每次都默认下标为begin
int mini = begin;
int maxi = begin;
for (int i = begin + 1; i < end+1; i++)
{
//找到的是下标
if (arr[i] >arr[maxi])
{
maxi = i;
}
if (arr[i] < arr[mini])
{
mini = i;
}
}
//最后把最大的交换到最后
Swap(&arr[end], &arr[maxi]);
//可能存在一种情况,最小的值再最后面
//我们把最大的值换到最后的时候,就把最小的值和maxi换了位置
//这个时候下标maxi的数字才是最小值
if (arr[mini] == arr[end])
mini = maxi;
//把最小的值换到最前面。
Swap(&arr[begin],&arr[mini]);
--end;
++begin;
}
}
这就是最终的代码了,上面的解释也很清晰。我们每次都可以找到一个最大值的下标,和一个最小的值的下标,相当于我们一次就找到了两个数,这就大大的优化了我们的效率。
时间复杂度分析
我们再来看看,第一次寻找,我们比较了n次,第二次寻找,比较了n-2次·······4次,2次。
我们使用数列公式简单的求和,算出来就是O(n^2)。
4.希尔排序
从现在开始,我们就开始要学习效率较高的排序算法了。相较于前面三个算法,接下来将的归并排序,堆排序,快速排序和这里的希尔排序都是更重要,更复杂的算法。接下来就让我们来好好学习吧。
现在我们要讲解的算法叫希尔排序(Shell Sort)。希尔排序是D.LShell再1959年提出来的一种排序算法,再这之前的排序算法的时间复杂度都是O(n^2),希尔排序算法是最早突破这个时间的一种算法。
我们前面讲的直接插入排序,它的效率在某些时候是非常高的,比如,我们记录的书籍本省就是比较有序的话,我们就只需要进行简单的插入操作就可以完成整个记录的排序工作,此时直接插入很高效。还有就是记录的数据比较少的时候,直接插入的优势也比较明显。可以,现实中记录少或者基本有序的情况很少。
如何让待排序的记录个数比较少呢?条件不存在,我们可以创造条件。于是希尔研究出了一种算法,对直接插入排序改进后可以增加效率。
我们怎样让待排序的记录个数少呢?很容易想到的就是将原来的数据进行分组。分成若干个子数列,每一个分别进行插入排序,最后再整体进行一次插入排序。我们每一次的插入排序都可以让大的数尽量向后靠,较小的数尽量向前靠,这样我们在进行每一次排序的过程中,都在让数列尽量变得有序,最后再进行一次插入排序,就会变得比较高效。
分析一下我们前面讲的三种算法,不管是什么算法,我们要找出数字都要进行遍历一遍才能找到最大或者最小的数字,所以我们在找到最大或者最小的数字时,我们可以一边寻找,一边想办法让它们变得有序,这样效率就会提上来了。
我们定义一个gap,距离为gap的数据就分为一组数据,我们通过不断改变gap的值,让gap最后为1,最后进行的就是插入排序算法。我们前面的操作,都是让数据尽可能的有序,最后一次gap等于1 的时候进行的插入排序算法的效率就会大大的提高。
// 希尔排序
void ShellSort(int* arr, int size)
{
int gap = size;
//当gap出循环的时候已经进行了一遍直接插入排序了,数据已经有序了
while (gap > 1)
{
//最后一次gap=1,就是插入排序
gap = gap / 3 + 1;
//让距离为gap的数据分为一组
for (int i = 0; i < size - gap; i++)
{
//同一组的进行插入排序
int end = i;
int tmp =arr[ end + gap];
while (end>=0)
{
if (tmp < arr[end])
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
可以对比一下,其实整体逻辑和插入排序没有什么差别,当gap为1 的时候,就是插入排序,当gap不为1的时候我们再进行的操作都是想让整个数据变得尽量有序。
讲起来比较抽象,所以说需要同学们自己去画图分析,希尔排序本来就是比较抽象的算法,画图更助于我们理解。
时间复杂度分析
因为希尔排序的时间复杂度比较难算,所以说我们记住它的时间复杂度大概是O(n^1.3)就行了 。
5.堆排序(Heap Sort)
我们再进行排序操作的时候,很多时候就是因为在找出一个最大或者最小数的时候,原来的数还是没有变化,这就导致查找下一个数的时候,又进行一些重复的操作,这就会导致效率下降。
如果可以做到每次在选择到最小记录的同时,并根据比较结果堆其他记录做出相应的调整,那么总体效率就会非常高了。而堆排序(Heap Sort)就是对简单选择排序进行的一种改进。
所谓堆,我们先来看一看长什么样子吧。
我们可以看到,左边的是大顶堆,右边的是小顶堆。观察一下它们有什么特点呢。
我们再前面讲过二叉树的性质,其实堆实际上就是一颗完全二叉树,并且可以发现,大顶堆的双亲节点都比它的左右孩子要大,小顶堆的双亲节点都要比它的左右孩子要小。
其实它们的物理结构还是一个数组,只是我们把他们的关心想象成为一颗二叉树,因为完全二叉树特有的性质,它的双亲节点的下标和它们的各自的左右孩子下标都有关系。
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子节点的值,叫大顶堆(左上图),或者每个节点的值都小于或等于其左右孩子节点的值,叫小顶堆(右上图)。
注意,这个树的根节点一定是最大的,较大的数相对来说要靠上面一点。
堆排序算法
堆排序就是利用堆(大顶堆)进行排序的方法,我们要排列升序就要用到大顶堆,排降序就要用到小顶堆。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根节点。将它和末尾的数交换,然后将剩下的n-1个数再次构造成大堆,再选出次大的数。反复执行这样的操作,整个数列就能变为一个有序数列。
从上面的这幅图可以看到,我们把最大的90和最后的30交换了位置,注意这里的30不是最小的值,只是它刚好被调整到了最后的位置,这组数据里面20才是最小的数,但是它没有再最后的位置,我们只需要保证它的每个双亲结点都大于它的孩子节点就行了。然后最大的90被换到了末尾,我们再将它移除堆,再将剩下的数变成一个堆,然后56就到了堆顶,我们再将它和30交换位置,然后将56移除堆,再将剩下的数变为堆,反复执行这样的操作,最后数据就将变得有序。
要将数据变为堆,我们就要用到向下调整算法。向下调整算法就是用双亲节点和左右孩子比较,我们这里以建大堆为例,如果双亲节点小于孩子节点,就将双亲节点的值和孩子节点的值交换,然后让父亲做孩子,再去找父亲的父亲,依次比较,直到最后到根节点。
//向下调整函数
void AdjustDown(int* arr, int size, int root)
{
//父亲就根节点
int parent = root;
//根据父亲找到左孩子
int child = parent * 2 + 1;
//当孩子小于数组长度时
while (child < size)
{
//如果左孩子小于右孩子,则将孩子默认为右孩子
if (child + 1 < size && arr[child] < arr[child + 1])
{
child += 1;
}
//如果孩子比父亲大,则交换值,再将父亲的值给孩子
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
}
parent = child;
child = parent * 2 + 1;
}
}
因为堆的底层其实就是数组,只是我们把它抽象成一颗完全二叉树,他们的父亲节点(parent)和孩子节点(child)的关系为,左孩子:leftchild=parent*2+1,右孩子:rightchild=parent*2+2。parent=(child-1)/2。知道了这个关系之后,我们就可以从最后一个叶子节点的父亲开始计算,然后不断的改变父亲节点,知道最后是根节点,就算调整完成。
接下来我们看一看整体代码。
//堆排序
void HeapSort(int* arr, int size)
{
//从最后一颗子树开始,直到根节点,向下调整成大堆
//升序建大堆,降序建小堆
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, size, i);
}
int end = size - 1;
//走size次,第一次将最大的数换到最后,第二次将次大的换到倒数第二个,一次类推
while (end >= 0)
{
Swap(&arr[0], &arr[end]);
//每次交换后,要向下调整,找到次大的
AdjustDown(arr, end, 0);
//end--,讲最后一个数移除堆
end--;
}
}
上面的代码和注释已经非常详细了,请大家自己观看吧。
时间复杂度分析
时间复杂度比较好分析,我们假设有n个节点,第一层1个节点,第二层2个节点······第h-1层有2^(h-2)个节点,第h层有2^(h-1)个节点。经过简单的数列求和可以算出h=log(n+1)。
然后我们每选出一个数都要调整h次,我们要选出n-1个数,那总体来说,堆排序的时间复杂度就是O(n*logn)次。由于堆排序堆原始记录的排序状态不敏感,因此无论是最好,最坏和平均时间复杂度均为O(n*logn)。这在性能上要远远好于冒泡,选择,直接插入排序的O(n^2)。
6.快速排序(Quick Sort)
希尔排序相当于直接插入排序的升级,它们属于插入排序类,堆排序相当于简单排序的升级,它们同属于选择排序类。而快速排序其实就是我们前面认为最慢的冒泡排序的升级,它们都属于交换排序类。即它也是通过不断比较和移动交换来实现排序的,只不过它的实现,增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面,从而减少了总的比较次数和移动交换次数。
快速排序(Quick Sort)的基本思想是:通过一趟排序将待记录分割成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录的进行排序,以达到整个有序的目的。
我们可以看到首先我们选到了6作为我们的关键字,然后第一趟走完之后,6左边全是比6小的,6的右边全是比6大的,那自然而然6的位置就已经排好了。然后我们再将它的左区间进行同样的操作,右区间进行同样的操作,直到分到一个数时。
// 快速排序hoare版本
int PartSort1(int* arr, int left, int right)
{
//默认中间值是分界线
int keyi = left;
int begin = left;
int end = right;
//只要begin<end 说明循环未结束
while (begin < end)
{
//左边找大,遇到小于arr[keyi]的就跳过 begin++
while (arr[begin] < arr[keyi] && begin < end)
{
begin++;
}
//右边找小,遇到大于arr[keyi]的就跳过 end--;
while (arr[end] > arr[keyi] && begin < end)
{
end--;
}
//此时交换位置,将小于arr[keyi]换到arr[keyi]左边,大于arr[keyi]的换到arr[keyi]右边
Swap(&arr[begin], &arr[end]);
}
return begin;
}
// 快速排序前后指针法
int PartSort2(int* arr, int left, int right)
{
//默认中间值是分界线
int keyi = FindMidi(arr, left, right);
Swap(&arr[left], &arr[keyi]);
keyi = left;
//快慢指针
int slow = left;
int fast = left + 1;
//fast小于right时,即所有大于keyi的数都被换到前面去了
while (fast<=right)
{
//当慢指针走到大于keyi的数的时候并且慢指针走到小于keyi的数的时候就交换值
if (arr[fast] < arr[keyi]&&++slow!=fast)
{
Swap(&arr[fast], &arr[slow]);
}
fast++;
}
Swap(&arr[slow],&arr[keyi]);
//最终slow走到中间值的位置
return slow;
}
//快速排序
void QuickSort(int* arr, int left, int right)
{
//如果左边大于右边,说明越界不存在,等于说明只有一个元素已经是有序
if (left >= right)
return;
if (right - left + 1 <=10)
{
InSertSort(arr + left, right - left +1);
}
else
{
int keyi = PartSort2(arr, left, right);
//本质上是分治,将大问题变成很小的区间
//递归走左边剩下的
QuickSort(arr, 0, keyi - 1);
//递归走右边剩下的
QuickSort(arr, keyi + 1, right);
}
}
hoare版本
// 快速排序hoare版本
int PartSort1(int* arr, int left, int right)
{
//默认中间值是分界线
int keyi = left;
int begin = left;
int end = right;
//只要begin<end 说明循环未结束
while (begin < end)
{
//左边找大,遇到小于arr[keyi]的就跳过 begin++
while (arr[begin] < arr[keyi] && begin < end)
{
begin++;
}
//右边找小,遇到大于arr[keyi]的就跳过 end--;
while (arr[end] > arr[keyi] && begin < end)
{
end--;
}
//此时交换位置,将小于arr[keyi]换到arr[keyi]左边,大于arr[keyi]的换到arr[keyi]右边
Swap(&arr[begin], &arr[end]);
}
return begin;
}
我们使用begin和end的目的是让begin在左边找到比关键字大的数,找到然后停下,使用end在右边找到比关键字小的数,然后停下,最后把它们两个交换,最后小的就到了左边,大的就到了右边。最后再将关键字和它们的相遇处交换,从而第一趟让关键字走到了它的位置,左边全是比关键字小的,右边得到的都是比关键字大的。
然后我们再继续将左区间进行同样的操作,使用递归,当left>=right的时候就不进行任何操作。像上面我们进行了小区间优化,因为如果数据较少的话,对于快速排序来说就有点杀鸡用牛刀的那种效果,而且如果数据很少的话也体现不出快速排序的优势;还有一个原因就是递归层数很多,防止栈溢出。
在上面,我们用了两种方法来求出keyi的位置也就是关键字的位置,一种是hoare版本的找到中间位置并交换值,另一种是前后指针法来找到keyi的值。
快慢指针
// 快速排序前后指针法
int PartSort2(int* arr, int left, int right)
{
//默认中间值是分界线
int keyi = FindMidi(arr, left, right);
Swap(&arr[left], &arr[keyi]);
keyi = left;
//快慢指针
int slow = left;
int fast = left + 1;
//fast小于right时,即所有大于keyi的数都被换到前面去了
while (fast<=right)
{
//当慢指针走到大于keyi的数的时候并且慢指针走到小于keyi的数的时候就交换值
if (arr[fast] < arr[keyi]&&++slow!=fast)
{
Swap(&arr[fast], &arr[slow]);
}
fast++;
}
Swap(&arr[slow],&arr[keyi]);
//最终slow走到中间值的位置
return slow;
}
快慢指针我们在前面判断链表是否带环已经学到过了,这里我们定义一个slow变量,一个fast变量去控制下标。当slow对应的值小于keyi对应的值的时候fast++,slow++,当slow对应的值小于fast对应的值的时候,slow就停下来,判断fast对应的值是否大于keyi的值,知道找到小于keyi的值然后停下来,交换slow和fast的值,然后进行同样的操作。当fast>right的时候就跳出循环,然后将slow和keyide值交换,然后返回keyi的值,然后递归左右区间。
非递归版本
非递归主要运用栈去模拟递归的特性。我们首先将左边界和右边界入栈,因为栈是后入先出的特性,所以我们先把右边界入进去,再入左边界。当我们拿出一组边界数时,我们就调用PartSort1或者PartSort2去进行排序,然后得到keyi的下标,再将[0,begin-1] [begin+1,end]的左右边界分别入栈,因为要保证区间合理,所以需要增加if语句去判断,合理就入栈。当栈为空的时候,就说明已经排序完成了。
//快速排序非递归
//利用栈的特性,模拟递归
void QuickSortNonR(int* arr, int left, int right)
{
//首先定义一个栈
stack st;
Initstack(&st);
Pushstack(&st, right);
Pushstack(&st, left);
//当栈不为空就取栈顶元素
while (!StackEmpty(&st))
{
int begin = Topstack(&st);
Popstack(&st);
int end = Topstack(&st);
Popstack(&st);
int keyi = PartSort2(arr, begin, end);
//分为[0,begin-1] begin [begin+1,end]
//再将右区间和左区间一起入栈
if (begin + 1 < end)
{
Pushstack(&st, end);
Pushstack(&st, begin+1);
}
if (0 < begin - 1)
{
Pushstack(&st, begin - 1);
Pushstack(&st, 0);
}
}
}
7.归并排序(Merge Sort)
归并排序就是将两个或者两个以上的有序数列合并在一起的意思。“归并”一词的中文含义就是合并,并入的意思,而在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。
我们看一看第一排,我们把每一个数分成一组,每一个数都可以看做有序,然后两两归并,然后两个数一组,每一组都是有序;然后两两归并,归并为每一组4个数,然后第三排的数的每一组都变为了有序,直到归并成一组数据。我们把每一次归并到的数据都插入到一个新数组里面,然后再将新数组中的数据拷贝到原数组中就行了。
归并排序也要用到递归的知识。
通过动图就能好好的理解归并排序。
//归并排序子函数
void _MergeSort(int *arr,int* tmp, int left, int right)
{
if (left >= right)
return;
int mid = (left + right) / 2;
//分区间[0,mid] [mid+1,right];
//走递归,让每一个小区间都有序
_MergeSort(arr, tmp,left, mid);
_MergeSort(arr, tmp, mid+1, right);
int begin1 = left; int end1 = mid;
int begin2 = mid + 1; int end2 = right;
int begin = left;
//开始归并
while (begin1<=end1&&begin2<=end2)
{
//同样的将数据尾插进新数组
if (arr[begin1] < arr[begin2])
{
tmp[begin++] = arr[begin1++];
}
else
{
tmp[begin++] = arr[begin2++];
}
}
//其中有一个已经全部拿出来了
//剩下的一组数据已经是有序,直接拿下来就行了
while (begin1 <=end1)
{
tmp[begin++] = arr[begin1++];
}
while (begin2 <=end2)
{
tmp[begin++] = arr[begin2++];
}
//拷贝到原数组
memcpy(arr+left, tmp+left, sizeof(int) * (right - left) + 1);
}
//归并排序
void MergeSort(int* arr, int size)
{
int* tmp = (int*)malloc(sizeof(int) * size);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
//建一个子函数
_MergeSort(arr,tmp,0,size-1);
free(tmp);
tmp = NULL;
}
我们在这里用到了子函数的方法,因为我们需要一个数组来进行拷贝操作,由于递归的原因,如果我们用主函数的话就会反复malloc数组出来,造成不必要的内存浪费。所以我们再主函数中malloc之后再创建一个子函数去代替我们进行操作就可以了。主要操作还是在子函数中。
这个递归函数就和二叉树的前序遍历相似,先走到一个数有序,再回来走两个数有序,一起归并。最后将所有数归并到一起。我们这里归并用的是尾插,将两组数据中的较小的数一直尾插,直到一组数据被尾插完成后,再将剩下的数尾插进新数组,最后拷贝到原数组中。
memcpy函数是包含在头文件<string.h>中的函数。
它是作用是将原指针开始的后num个字节拷贝到目的指针中。
非递归版本
//归并排序非递归
void MergeSortNonR(int* arr, int size)
{
int* tmp = (int*)malloc(sizeof(int) * size);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
//利用循环,一组一组归
int gap = 1;
while (gap < size)
{
//gap=1,2,4,8······
for (int i = 0; i < size;i+=gap*2)
{
int begin1 = i; int end1 = i + gap - 1;
int begin2 = i + gap; int end2 = i + 2 * gap - 1;
int begin = i;
printf("[%d,%d][%d,%d] ", begin1, end1, begin2, end2);
//开始归并
//不存在第二组数据,不用归并,直接跳出循环
if (begin2 >=size)
{
break;
}
//存在第二组数据,begin2<size
if (end2 >=size)
{
end2 = size - 1;
}
//同样的将数据尾插进新数组
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[begin++] = arr[begin1++];
}
else
{
tmp[begin++] = arr[begin2++];
}
}
//其中有一个已经全部拿出来了
//剩下的一组数据已经是有序,直接拿下来就行了
while (begin1 <= end1)
{
tmp[begin++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[begin++] = arr[begin2++];
}
//拷贝到原数组
memcpy(arr + i, tmp + i, sizeof(int) * (end2-i + 1));
}
printf("\n");
gap *= 2;
}
free(tmp);
tmp = NULL;
}
归并的非递归主要用到了循环的思想。
感觉归并的非递归比较复杂,这里就不过多赘述了,大家自己看代码吧。