目录
一、排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
在生活中,我们离不开排序,比如你要在网上买东西,以热销榜为参考;点外卖,以好评榜为参考,等等。所以排序在生活中是非常重要的,我们需要学好排序。
二、常见排序算法的实现
2.1 直接插入排序
直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想。
直接插入排序要求在除要进行排序的数据外已经有序的序列中排序,我们先假设这个条件成立,了解一下单趟排序的过程。
这次单趟排序,除最后的数字8以外数据全部有序,我们就以8为基准并记录下它的值,一个个的向前比较,如果前面的数比8大,就向后移动一位;如果前面的数比8小,8就插入在该数据的后面。
单趟排序解决完了,就该解决它的先决条件--已经有序的序列了,因为我们不知道被排序的数据前面是否有序,且因为只有一个数的序列是有序的,所以我们可以从第二个数开始排序,然后再排第三个数......这样的话就能保证每个数被排序时,它前面的序列就是有序的了。
//时间复杂度为O(N^2)
//空间复杂度为O(1)
//稳定
void InsertSort(int* arr, int size)
{
assert(arr);
int i = 0;
//i最多为倒数第二个数 tmp要取倒数第一个数
//从前往后开始排序 即排完前前两个 再排前三个......
//从后往前比较插入
for (i = 0; i < size - 1; ++i)
{
//我们把要被排序的前一个数当作end
int end = i;
int tmp = arr[end + 1];
//实现单趟
while (end >= 0)
{
if (tmp < arr[end])
{
arr[end + 1] = arr[end];
--end;
}
else
{
break;
}
}
//当tmp >= arr[end] 和end < 0时的处理是相同的 故都放在一起处理
arr[end + 1] = tmp;
}
}
直接插入排序的特性总结:
1. 元素集合越接近有(顺)序,直接插入排序算法的时间效率越高
反之如果元素集合越接近逆序,直接插入排序算法的时间效率越低
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:稳定
2.2 希尔排序( 缩小增量排序 )
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数gap,把待排序文件中所有数据分成gap个组,所有距离为gap的数据分在同一组内,并对每一组内的记录进行排序。然后将gap逐渐减小重复上述分组和排序的工作。当到达gap == 1时,所有记录在统一组内排好序。
希尔排序是对直接插入排序的优化。
当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,希尔排序就相当于直接插入排序,此时数组已经接近有序的了,而直接插入排序在排已经或接近有序的数据非常快。这样整体而言,可以达到优化的效果。
每一个组内都会进行插入排序,希尔排序其实就是先通过多次小的插入排序将整个序列先排成接近有序的状况,最后再进行一次面向整个序列的直接插入排序,这样子就整体排序的效率就会非常高。
//时间复杂度为O(N^1.3 -- N^2)
//空间复杂度为O(1)
//不稳定
void ShellSort(int* arr, int size)
{
assert(arr);
int gap = size;
while (gap > 1)
{
//保证最后一次循环 gap一定==1
//当gap == 1时,ShellSort == InsertSort
gap = gap / 3 + 1;
//并不是一组排完了再排另一组 而是混合在一起排序
//排一次第1组再排一次第2组再排一次第3组...再回到第1组、第2组、第3组,直到所有组都被排完
int i = 0;
//当 i >= size - gap 时 i后面已经没有完整的组别了 不需要再循环了
for (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的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定。
《数据结构(C语言版)》--- 严蔚敏:
《数据结构-用面相对象方法与C++描述》--- 殷人昆 :
因为我们的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:到
来算。
稳定性:不稳定
2.3 选择排序
选择排序的基本思想为:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
直接选择排序:
①在元素集合array[i]--array[n-1]中选择关键码最大(小)的数据元素。
②若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换。
③在剩余的array[i]--array[n-2](array[i+1]--array[n-1])集合中,重复上述步骤,直到集合剩余1个元素。
在实现时,我们可以做一个小优化,原来的直接选择排序在一次遍历,只选择了一个最大(小)值,即一次循环只获得了一个有效数据。我们可以让一次循环同时选择最大和最小值,让一次循环获得两个有效数据。
//时间复杂度为O(N^2)
//空间复杂度为O(1)
//不稳定
void SelectSort(int* arr, int size)
{
assert(arr);
int begin = 0;
int end = size - 1;
int mini = 0;
int maxi = 0;
while (begin < end)
{
mini = maxi = begin;
int i = 0;
for (i = begin + 1; i <= end; ++i)
{
if (arr[i] > arr[maxi])
{
maxi = i;
}
if (arr[i] < arr[mini])
{
mini = i;
}
}
Swap(&arr[begin], &arr[mini]);
//如果begin和maxi的位置重合了 在begin和mani位置上的数据进行交换后 最大的数据就不在begin上了 而在mini上
//如果先交换end和maxi 如果end与mini重合 也要修正位置
if (begin == maxi)
{
maxi = mini;
}
Swap(&arr[end], &arr[maxi]);
++begin;
--end;
}
}
我们发现,当begin先与mini交换时,如果begin与maxi指向同一个位置,那么经过两次变换位置后,最后最大最小值都没有在它们应在的位置,所以我们需要在begin与mini进行交换后,对maxi的位置进行更正。同理,如果先交换end与maxi,如果end与mini指向同一个位置,也要对mini的位置进行修正。
直接选择排序的特性总结:
1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
2.4 堆排序
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
我们这里以排升序为例:
①要实现堆排序,我们首先要创建一个堆出来,要排升序,我们就要建大堆,建堆我们可以采用从第一个非叶节点开始,自下往上的进行向下调整,这样当调整完毕时,一个堆就建好了。
②建完堆后,我们就要进行排升序,因为我们建的是大堆,所以堆顶的值是最大的,我们可以将堆顶的值与堆中最后一个数进行交换,这样最大的数就到了末尾,也就是它在全部数据都排完后该在的位置。
③在交换完数据后,我们假设将堆中最后一个数删除(其实只是让它不再参与建堆与排序),这样的话,堆顶的左右子树依旧是大堆,我们只需要对堆顶进行一次向下调整,大堆就又建好了,我们再次将堆顶的数,与最后一个数(不计假设被删除的数)进行交换,依此类推,堆排序就完成了。
注:实际中并没有删除堆中元素,图中为了方便表示,将交换后的位置画成了空。
//向下调整
static void AdjustDown(int* arr, int size, int parent)
{
assert(arr);
int maxchild = parent * 2 + 1;
while (maxchild < size)
{
if (maxchild + 1 < size && arr[maxchild + 1] > arr[maxchild])
{
++maxchild;
}
if (arr[parent] < arr[maxchild])
{
Swap(&arr[parent], &arr[maxchild]);
parent = maxchild;
maxchild = parent * 2 + 1;
}
else
{
break;
}
}
}
//建堆
static void HeapCreat(int* arr, int size)
{
assert(arr);
int i = 0;
for (i = (size - 2) / 2; i >= 0; --i)
{
AdjustDown(arr, size, i);
}
}
//时间复杂度为O(N*log_2 N)
//空间复杂度为O(1)
//不稳定
void HeapSort(int* arr, int size)
{
assert(arr);
HeapCreat(arr, size);
int end = size - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
AdjustDown(arr, end, 0);
--end;
}
}
想更加详细的了解堆排序与堆的性质的可以参考我的这篇博客:
堆排序的特性总结:
1. 堆排序使用堆来选数,效率就高了很多
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
2.5 冒泡排序
冒泡排序是我们最为熟悉的一种排序,它是属于交换排序的一种,根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
两两元素相比,前一个比后一个大就交换,直到将最大的元素交换到末尾位置。
一共进行n-1趟这样的交换将可以把所有的元素排好。
//时间复杂度为O(N^2)
//空间复杂度O(1)
//稳定
void BubbleSort(int* arr, int size)
{
assert(arr);
int i = 0;
int swap = 0;
//实现n-1趟冒泡排序
for (i = 0; i < size - 1; ++i)
{
int k = 0;
//实现一趟冒泡排序
for (k = 0; k < size - 1 - i; ++k)
{
if (arr[k] > arr[k + 1])
{
Swap(&arr[k], &arr[k + 1]);
swap = 1;
}
}
//如果在第一次扫描时 一次也没交换过 就是有序 不需要继续循环交换了
if (swap == 0)
{
break;
}
}
}
冒泡排序的特性总结:
1. 冒泡排序是一种非常容易理解的排序
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:稳定
2.6 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
接下来,我们学习三种实现单趟排序的方法。
2.6.1 hoare版本(左右指针法)
实现hoare版本的思路为:
① 选定一个基准值,最好选定最左边或者最右边的值,因为选中间不好处理,会给自己找麻烦。
② 设立两个指针begin和end,令其分别从左边和右边向中间遍历数组。
③ 如果选最右边为基准值,那么让begin指针先走,反之如果选最左边为基准值,就让end指针先走。begin如果遇到大于基准值的数就停下来,然后end再走,end遇到小于基准值的数就停下来。
④ 交换begin和end指针对应位置的值。
⑤ 重复以上步骤,直到begin == end,即两指针相遇 ,最后将基准值与begin与end相遇处的值交换。
//左右指针法
static int FastSortPart1(int* arr, int begin, int end)
{
assert(arr);
//如果取右边为Key 就要让左边先走 反之让右边先走
//这是为了保证最后小数一定在Key左边 大数一定在Key右边
//左找大 右找小 再交换
int KeyI = end;
while (begin < end)
{
//arr[begin] <= arr[KeyI] arr[end] >= arr[KeyI]中至少一个要带等号
//不然当数组中出现与Key相等的数时会出现死循环
while (begin < end && arr[begin] <= arr[KeyI])
{
++begin;
}
while (begin < end && arr[end] >= arr[KeyI])
{
--end;
}
Swap(&arr[begin], &arr[end]);
}
//最后将Key交换到begin与end相遇的位置
//在这个位置左边都是比Key小的数 在这个位置右边都是比Key大的数
//所以这个位置就是数据排好序后 Key所在的最终位置
//和Key相等的数 既能放在Key左边 又能放在Key右边
int MeetI = begin;
Swap(&arr[MeetI], &arr[KeyI]);
return MeetI;
}
可能有人会有疑问,为什么选一边为基准值,就要让另一边先走。我们不妨尝试一下从同一边先走会有什么结果。
我们发现,如果让同侧先走,就无法保证在基准值与meet交换数据后,基准值的左侧数据都小于基准值,基准值的右侧数据都大于基准值。所以对侧先走,就是为了保证基准值的左右两侧数据都是正确的。
2.6.2 挖坑法
挖坑法的基本思路为:
① 先将选定的基准值直接取出(以左侧为例),因为基准值被取走了,所以在基准值的位置就留了下一个坑。
② 当右侧指针遇到小于基准值的数时,将该数值取出并将该值放入坑中,而右侧指针指向的位置形成新的坑位。
③ 然后左侧指针进行运动,当遇到大于基准值的数时,将该值放入坑中,左侧指针指向的位置形成坑位。
④ 重复该步骤,直到左右指针相遇,最后将记录基准值放入坑位之中。
挖坑法相比于左右指针法更加容易理解,因为它很好的解释了为什么要让基准值对侧的指针先走,因为基准值本侧有坑,指针不能够行动,所以要让对侧先走。
//挖坑法
//移动数据时就会在被移动数据处产生坑 要不断地找数据来填补这个坑
//当循环结束时 最后的坑的位置就是Key的位置
static int FastSortPart2(int* arr, int begin, int end)
{
assert(arr);
//end处的数据被Key取走 end处产生坑
int Key = arr[end];
//因为end处有坑 所以我们要先从begin端开始找数据填补到end处
while (begin < end)
{
while (begin < end && arr[begin] <= Key)
{
++begin;
}
arr[end] = arr[begin];
while (begin < end && arr[end] >= Key)
{
--end;
}
arr[begin] = arr[end];
}
int MeetI = begin;
arr[MeetI] = Key;
return MeetI;
}
2.6.3 前后指针法
前后指针法就与前两种方法不同,前两种方法的指针是从左右两侧开始行动,而前后指针法是从同一侧开始行动。
前后指针发的实现思路是:
① 选定基准值,设置prev和cur指针(cur = prev + 1)。
② cur先走,遇到小于基准值的数就停下,然后将prev向后移动一个位置后,将prev对应值与cur对应值交换。
③ 重复上面的步骤,直到cur遍历完除基准值外全部数据。
④ 最后将基准值与prev(如果基准值为最左侧为prev,如果基准值为最右侧为prev + 1)对应位置交换。
//前后指针法
static int FastSortPart3(int* arr, int begin, int end)
{
assert(arr);
//基准值在右侧
int KeyI = end;
int cur = begin;
int prev = cur - 1;
//cur找比Key小的数 找到了就让prev++ 再把cur处的数与prev处的数交换 这样prev就是小数的开头 cur就是大数的开头
//没找到cur就一直向前走
while (cur < end)
{
//如果++prev == cur则没必要交换 自己和自己交换还是自己
if (arr[cur] < arr[KeyI] && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
++cur;
}
//把Key放到它该在的位置
Swap(&arr[KeyI], &arr[++prev]);
return prev;
//基准值在左侧
//int KeyI = begin;
//int prev = begin;
//int cur = prev + 1;
//while (cur <= end)
//{
// if (arr[cur] < arr[KeyI] && ++prev != cur)
// {
// Swap(&arr[prev], &arr[cur]);
// }
// ++cur;
//}
//Swap(&arr[prev], &arr[KeyI]);
//return prev;
}
2.6.4 快速排序的递归实现
//时间复杂度为O(Nlog_2 N)
//空间复杂度为O(log_2 N) 为递归的深度 因为内存是可以重复利用的
//不稳定
void FastSort(int* arr, int left, int right)
{
assert(arr);
//left和right代表下标
//【3,4】可以继续递归 【3,3】区间就一个数了 一个数本身就是有序 不需要再递归排序 【4,3】不存在这种区间 不需要递归排序
if (left < right)
{
int div = FastSortPart3(arr, left, right);
FastSort(arr, left, div - 1);
FastSort(arr, div + 1, right);
}
}
快速排序一般是通过递归实现的,它的思想与二叉树相同。我们先进行一次单趟排序(三种排序方法任选),这样就把一个数(基准值)放在了其的正确位置,然后根据这个位置,把数组分为【left,基准值位置 - 1】和【基准值位置 + 1 ,right】两个区间,再对这两个区间进行递归,不断地排序,二分,最终当所有的区间都不能再分出有效区间时,排序就完成了。
2.6.5 快速排序的两个优化--三数取中与小区间优化
因为快速排序由递归实现,所以它有着所有递归程序都有的特点,当需要排序的数据过多时容易栈溢出。除此之外,快速排序并不擅长排已经有序或接近有序的序列,因为当快速排序对有序序列进行调整时,它的基准值并不会发生位置上的变化,一直都在最左侧或最右侧,所以每次调整完成后,基准值只会将数组分成一个有效的区间,而不是两个,这即造成了时间效率上的浪费,也造成了空间上的浪费,因为递归归还的空间是可以重复利用的,当分成两个区间时,左区间用完的空间可以先归还然后再分配给右区间用,而如果只分成一个区间,就没有空间可以重复利用,需要向内存不断申请新的空间,这样也同样会造成栈溢出。
快速排序当每次调整基准值最后位于数据的正中间时,效率是最高的,二分是快速排序最理想的状态,为了接近这样状态,我们采用三数取中的方式来确定基准值。
//三数取中
//在头、尾、中间的三个数据中 取出数值位于中间的那一个
//三数取中能够帮快速排序克服不擅长排已经有序的数据的缺点
//让这个最坏情况变成最好情况 故有三数取中的快速排序不再有最坏时间复杂度
static int GetMidI(int* arr, int left, int right)
{
assert(arr);
//这样计算mid可以避免因right + left的大小超出int的存储范围而出错
int mid = left + (right - left) / 2;
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right])
{
return mid;
}
else if (arr[right] < arr[left])
{
return left;
}
else
{
return right;
}
}
else //arr[left] >= arr[mid]
{
if (arr[right] < arr[mid])
{
return mid;
}
else if (arr[right] > arr[left])
{
return left;
}
else
{
return right;
}
}
}
三数取中目的是找到首、尾、中三个位置数据大小居中的那个,然后将其交换到首或尾充当基准值,这样就能让基准值在经过一次调整后尽可能的趋于中间,还能够避免已经或接近有序这种快速排序难以处理的状况,让已经有序从快速排序最坏的状况成为快速排序最好的状况,在经过三数取中处理后,在已经有序的数据中充当基准值的就是中间那个数,就能让每次调整后基准值位于正中央,达到区间二分的效果。
除此之外,我们还可以采用小区间优化的优化方法。
不管每次处理的数据量的多少,只要是递归就会有消耗。所以当在快速排序中,每次递归的处理的数据量不大时,递归的性价比就不是太高了。我们可以采用当区间小到一定程度时,不再用快速排序继续递归,而是调用另一种排序算法,这样就能减少递归的消耗。
//挖坑法
//移动数据时就会在被移动数据处产生坑 要不断地找数据来填补这个坑
//当循环结束时 最后的坑的位置就是Key的位置
static int FastSortPart2(int* arr, int begin, int end)
{
assert(arr);
int mid = GetMidI(arr, begin, end);
Swap(&arr[mid], &arr[end]);
//end处的数据被Key取走 end处产生坑
int Key = arr[end];
//因为end处有坑 所以我们要先从begin端开始找数据填补到end处
while (begin < end)
{
while (begin < end && arr[begin] <= Key)
{
++begin;
}
arr[end] = arr[begin];
while (begin < end && arr[end] >= Key)
{
--end;
}
arr[begin] = arr[end];
}
int MeetI = begin;
arr[MeetI] = Key;
return MeetI;
}
//时间复杂度为O(Nlog_2 N)
//空间复杂度为O(log_2 N) 为递归的深度 因为内存是可以重复利用的
//不稳定
void FastSort(int* arr, int left, int right)
{
assert(arr);
//left和right代表下标
//【3,4】可以继续递归 【3,3】区间就一个数了 一个数本身就是有序 不需要再递归排序 【4,3】不存在这种区间 不需要递归排序
if (left < right)
{
//当下标的区间较大时 用快速排序
//较小时 就相对来说 可以认为数据接近有序了 用直接插入排序
if (right - left + 1 >= 10)
{
int div = FastSortPart2(arr, left, right);
FastSort(arr, left, div - 1);
FastSort(arr, div + 1, right);
}
else
{
InsertSort(arr + left, right - left + 1);
}
}
}
2.6.6 快速排序的非递归实现
对于用递归实现的快速排序,两个优化并不能从根本上解决问题,所以我们需要尝试使用非递归的方法来实现快速排序。
对于将递归实现转化为非递归实现,常见的方法无非就是两种,一种就是将递归直接改成循环,如求斐波那契数列;另一种就是借助数据结构上的栈来完成,就如同快速排序的非递归实现。
能够借助数据结构上的栈完成是因为它和内存上的栈有着相同的特性,即先进后出。递归是在内存的栈上建立栈帧,我们可以模仿它在数据结构的栈上建立“栈帧”,只不过在数据结构的栈上,我们存的是待排序的数据的区间。
快速排序非递归方法的实现思路:
① 将待排序区间的左右边界入栈。
② 若栈不为空,取出两次栈顶元素,分别为闭区间的左右边界。
③ 将区间中的数据进行单趟排序(三种排序方法),并得到基准值的位置。
④ 再以基准值为分界线,若基准值的左右区间中有有效区间,则将区间入栈。
//非递归的好处主要在于可以避免递归过深导致的栈溢出
//效率上的提升在目前的计算机上已经不太明显了
void FastSortNonR(int* arr, int left, int right)
{
assert(arr);
//用数据结构的栈来模拟内存上的栈
//因为两种的性质相同 即先进后出
Stack St;
StackInit(&St);
//栈先进后出 先进的要后拿
StackPush(&St, right);
StackPush(&St, left);
while (!StackEmpty(&St))
{
int begin = StackTop(&St);
StackPop(&St);
int end = StackTop(&St);
StackPop(&St);
int div = FastSortPart1(arr, begin, end);
//我们的模仿类似与二叉树的后序遍历
//要保证右树在左树后处理 就要让右树的区间先入栈
//保证下标的区间有效
if (div + 1 < end)
{
StackPush(&St,end);
StackPush(&St, div + 1);
}
if (begin < div - 1)
{
StackPush(&St, div - 1);
StackPush(&St, begin);
}
}
StackDestroy(&St);
}
将递归改为非递归,除了可以避免栈溢出外,还可以提高效率,但随着CPU的发展,这个提升也就不太明显了。
快速排序的特性总结:
1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN) -- N为同一深度递归的递归处理的数据量 log_2 N 为递归的深度
3. 空间复杂度:O(logN) -- 为递归的深度 因为递归的空间是可以重复利用的
4. 稳定性:不稳定
注:图中的lg应改为log_2(以2为底的对数)
2.7 归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列间有序。若将两个有序表合并成一个有序表,称为二路归并。
2.7.1 归并排序的递归实现
归并排序核心步骤:
归并排序的思想其实就是“合并两个有序数组”,而归并排序的难点就是来构造这两个有序数组,来让其合并成一个大的有序数组。
我们从图中看见,我们会将数组二分,不断地分到数组中只有1个或0个数据为止,我们知道,排序这个概念是对多个数而言的,所以我们可以认为当数组中的数据只有1个或0个时,这个序列就是有序的,因为数组中并没有其他的数据能够进行比较,那么我们最关键的有序数组不就找到了吗?我们就能不断地将两个小的有序数组合成一个大的有序数组,最开始的数组排完序后的结果,就是归并排序最后合成出来的结果。
因为合成有序数组需要它的两个子序列有序,所以这个的实现思路和二叉树的后序遍历相似,先处理左右子序列让其有序,最后再来让自己有序。
因为两个子序列和合成出来的大序列存贮在一个数组中,为了让子序列合并的过程中不改变自身序列的数据,所以我们应开辟额外空间来存贮合并出的大序列,而不是直接覆盖在原数组中,最后再将大序列拷贝回原数组的对应位置。且为了处理方便和不多次开辟空间,我们只开辟一个和原数组一样大的空间。
//归并排序的思想与合并两个有序数组相似 两个已经有序的数组可以合并成一个有序数组
//怎么构造两个有序数组的思想与建堆相似 先让大数组分成两个小数组 让两个小数组有序再合并成大数组
//将数组不断二分 这个数组就类似于二叉树
//当数组中只有一个数据时 我们可以认为这个数组是有序的 如同二叉树一直找子树找到叶节点一般
static void _MergeSort(int* arr,int left, int right, int* tmp)
{
assert(arr);
//保证区间有效
if (left >= right)
{
return;
}
//将数组二分
int mid = left + (right - left) / 2;
//分别排左数组 右数组 类似于二叉树的后序遍历
_MergeSort(arr, left, mid, tmp);
_MergeSort(arr, mid + 1, right, tmp);
//将两个有序数组合并成一个有序数组
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else
{
tmp[index++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
//将tmp中的数据拷贝到原数组对应位置
//因为我们从始至终就只有一个额外数组
memcpy(arr + left, tmp + left, (right - left + 1) * sizeof(int));
}
//时间复杂度为O(Nlog_2 N)
//空间复杂度为O(N)
//稳定
void MergeSort(int* arr, int size)
{
assert(arr);
int* tmp = (int*)malloc(sizeof(int) * size);
if (tmp == NULL)
{
printf("malloc err!\n");
exit(-1);
}
_MergeSort(arr, 0, size - 1, tmp);
free(tmp);
tmp = NULL;
}
2.7.2 归并排序的非递归实现
只要是递归实现的算法,我们就能够通过非递归实现,不过是实现难度的区别罢了,归并排序同样能够通过非递归实现。
归并排序的非递归实现,不需要借助数据结构的栈。并且它的实现思路还与递归的实现思路略有不同。归并排序的递归实现,需要我们将数组不断二分,分到数组中只有1个或0个数为止。但是我们非递归实现并不需要二分,因为我们能够直接将1个数或0个数当成一组,不需要通过二分来分组,所以非递归实现相比于递归实现少了一个分解的过程,它是直接从合并开始的。
void MergeSortNonR(int* arr, int size)
{
assert(arr);
int* tmp = (int*)malloc(sizeof(int) * size);
if (tmp == NULL)
{
printf("malloc err!\n");
exit(-1);
}
//range为每一小组组的大小
//刚开始时 每个小组的大小为1
int range = 1;
//当range == size时 说明整个数组都作为一个小组存在
//此时 整个数组已经有序 没必要再进入循环了
while (range < size)
{
//以每两相邻小组为一个大组
//fi为每个大组第一个数的下标
int fi = 0;
for (fi = 0; fi < size; fi += range * 2)
{
int begin1 = fi, end1 = fi + range - 1;
int begin2 = fi + range, end2 = fi + 2 * range - 1;
//如果size != 2^n时 分组时会出现有组别的数据并不是满的的情况 所以要对组别的大小(边界)进行调整
//当第一组有部分越界时 第二组没有数据且因为第一组本来就有有序 不需要归并 直接退出
if (end1 >= size)
{
break;
}
//当第二组全部越界时
if (begin2 >= size)
{
break;
}
//当第二组部分越界时,把end2修正为真正的最后一个数
if (end2 >= size)
{
end2 = size - 1;
}
//两个有序数组的合并
int index = fi;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else
{
tmp[index++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
//将tmp中每个大组拷贝到arr原数组的对应位置
memcpy(arr + fi, tmp + fi, (end2 - fi + 1) * sizeof(int));
}
//每归并完一次所有大组后 需要将每个小组的大小乘2来构建更大的大组
range *= 2;
}
free(tmp);
tmp = NULL;
}
归并排序的特性总结:
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN) -- 递归的深度
3. 空间复杂度:O(N)
4. 稳定性:稳定
2.7.3 归并排序在外排序上的应用
我们学习的前6种排序都是内排序,即只能排存储在内存中的数据,当要排序的数据量大到无法完全存入内存时,前6种排序就没有办法进行排序了,但是归并排序就能完成这个任务。
为了便于测试,我们就将100个数存在文件中(用100来代替特别大的数据量,实际运用时,100可以变为特别庞大的数据量),然后借助归并排序来对文件中的数据进行处理。
归并排序在外排序上应用的实现思路:
① 我们先将全部数据分成n等份,保证每一小份可以存进内存中。
② 从存贮数据的文件中,读取应存进每一小份文件的数据量到内存中,运用内排序(可以为以上的任何一种排序算法)来对读取的数据进行排序,排完序后将这些数据放进一份单独的小文件中。
③ 当所有的数据都借助内排序排完序并存到对应的小文件后,通过归并排序来对每两份小文件内的数据进行排序,因为每份小文件内的数据都是有序的,所以两份小文件归并处理后的大文件也是有序的,依此对每个小文件进行处理。最后得到的大文件内的数据就是全部数据排完序后的结果。
static void _MergeSortFile(char* File1, char* File2, char* AimFile)
{
assert(File1 && File2 && AimFile);
FILE* pfout1 = fopen(File1, "r");
FILE* pfout2 = fopen(File2, "r");
FILE* pfin = fopen(AimFile, "w");
if (!pfout1 || !pfout2 || !pfin)
{
printf("fopen err!\n");
printf("%d\n", __LINE__);
exit(-1);
}
int num1 = 0, num2 = 0;
//用ret来记录fscanf的返回值是否为EOF 可以避免丢失数据
//因为每fscanf每执行一次 它都会自动读取下一个数据 不管当前数据是否被使用
int ret1 = fscanf(pfout1, "%d\n", &num1);
int ret2 = fscanf(pfout2, "%d\n", &num2);
while (ret1 != EOF && ret2 != EOF)
{
if (num1 < num2)
{
fprintf(pfin, "%d\n", num1);
ret1 = fscanf(pfout1, "%d\n", &num1);
}
else
{
fprintf(pfin, "%d\n", num2);
ret2 = fscanf(pfout2, "%d\n", &num2);
}
}
while (ret1 != EOF)
{
fprintf(pfin, "%d\n", num1);
ret1 = fscanf(pfout1, "%d\n", &num1);
}
while (ret2 != EOF)
{
fprintf(pfin, "%d\n", num2);
ret2 = fscanf(pfout2, "%d\n", &num2);
}
fclose(pfin);
fclose(pfout1);
fclose(pfout2);
pfin = pfout1 = pfout2 = NULL;
}
void MergeSortFile(const char* FileName)
{
//FileName为存放大量数据的文件名
assert(FileName);
FILE* pfout = fopen(FileName, "r");
if (pfout == NULL)
{
printf("fopen err!\n");
exit(-1);
}
//将一份大文件分成多个小文件 小文件全部在内存中排完序后
//通过归并排序 在文件中将多个有序小文件归并成一个有序大文件
//每份小文件存贮的数据量
int SubSize = 10;
//每份小文件存储的数据
int PerDocuData[10] = { 0 };
//每份小文件的名称
char SubFileName[20] = { 0 };
//每份小文件的编号
int SubFileI = 1;
int i = 0;
int num = 0;
while (fscanf(pfout, "%d\n", &num) != EOF)
{
if (i < SubSize - 1)
{
//if里读取SubSize - 1个数据
//剩下一个在else里读 避免丢失数据
//因为每fscanf每执行一次 它都会自动读取下一个数据 不管当前数据是否被使用
//所以如果不这样做 在读取小文件的最后一个数据时
//判断fsacnf的返回值是否是EOF时读取的数据就会被丢失
PerDocuData[i++] = num;
}
else
{
PerDocuData[i] = num;
FastSort(PerDocuData, 0, SubSize - 1);
//给小文件命名
sprintf(SubFileName, "%d", SubFileI++);
FILE* pfin = fopen(SubFileName, "w");
if (pfin == NULL)
{
printf("fopen err!\n");
printf("%d\n", __LINE__);
exit(-1);
}
//将数据存入小文件
int k = 0;
for (k = 0; k < SubSize; ++k)
{
fprintf(pfin, "%d\n", PerDocuData[k]);
}
fclose(pfin);
pfin = NULL;
i = 0;
memset(PerDocuData, 0, sizeof(int) * SubSize);
}
}
char AimFile[20] = "12";
char File1[20] = "1";
char File2[20] = "2";
//要循环小文件的个数减1次
//因为第一次是两个小文件归并
//后面是前两个小文件归并成的文件与一个小文件归并
//SubFileI此时为最大的小文件编号+1
for (i = 3; i <= SubFileI; ++i)
{
_MergeSortFile(File1, File2, AimFile);
strcpy(File1, AimFile);
sprintf(File2, "%d", i);
sprintf(AimFile, "%s%d", AimFile, i);
}
fclose(pfout);
pfout = NULL;
}
2.8 计数排序
计数排序是一种非比较排序,计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。
计数排序的操作步骤:
1. 统计相同元素出现次数
2. 根据统计的结果将序列回收到原来的序列中
//时间复杂度为O(N + range)
//空间复杂度为O(range)
//适合数据范围集中,也就是range小的情况
//只适合非负整数,不适合浮点数、字符串等
void CountSort(int* arr, int size)
{
assert(arr);
int min = arr[0];
int max = arr[0];
//确定数据的大小范围
int i = 0;
for (i = 1; i <= size - 1; ++i)
{
if (arr[i] > max)
{
max = arr[i];
}
if (arr[i] < min)
{
min = arr[i];
}
}
int range = max - min + 1;
//calloc开辟的空间会默认初始化为0
int* CountArr = (int*)calloc(range, sizeof(int));
if (CountArr == NULL)
{
printf("calloc err!\n");
printf("%d\n", __LINE__);
exit(-1);
}
//记录在range范围内的数据每个出现了几次
for (i = 0; i < size; ++i)
{
//用数据的大小减去最小值 形成映射
//假如数据范围为【1000 -- 2000】
//则1000放在下标为0的位置 避免开辟前一千个数的位置但又不存放数据 造成浪费
++CountArr[arr[i] - min];
}
//每个数据出现几次 就往原数组中存放几次
int k = 0;
for (i = 0; i < range; ++i)
{
while (CountArr[i]--)
{
//别忘了+min映射回来
arr[k++] = i + min;
}
}
free(CountArr);
CountArr = NULL;
}
计数排序的特性总结:
1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
适合数据范围集中,也就是range小的情况;只适合非负整数,不适合浮点数、字符串等。
2. 时间复杂度:O(MAX(N,range))
3. 空间复杂度:O(range)
4. 稳定性:稳定
三、小结
排序算法复杂度及稳定性分析:
注:
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
稳定性并不是指时间复杂度是否会波动,而是指相同大小的数据在排序前后的相对位置是否会改变。
最后我们来对这些算法做一个小测试,测试一下它们对10 0000个随机数据的排序效率。
static void CountSortTest(void) { int arr[] = { 1,7,0,5,3,8,12,9,6,38,25,11,15,22 }; int size = sizeof(arr) / sizeof(arr[0]); CountSort(arr, size); ArrPrint(arr, size); } static void SortTest(void) { srand((unsigned int)time(NULL)); const int N = 100000; int* arr1 = (int*)malloc(sizeof(int) * N); int* arr2 = (int*)malloc(sizeof(int) * N); int* arr3 = (int*)malloc(sizeof(int) * N); int* arr4 = (int*)malloc(sizeof(int) * N); int* arr5 = (int*)malloc(sizeof(int) * N); int* arr6 = (int*)malloc(sizeof(int) * N); int* arr7 = (int*)malloc(sizeof(int) * N); int* arr8 = (int*)malloc(sizeof(int) * N); assert(arr1 && arr2 && arr3 && arr4 && arr5 && arr6 && arr7 && arr8 ); int i = 0; for (i = 0; i < N; ++i) { arr1[i] = rand(); arr2[i] = arr1[i]; arr3[i] = arr1[i]; arr4[i] = arr1[i]; arr5[i] = arr1[i]; arr6[i] = arr1[i]; arr7[i] = arr1[i]; arr8[i] = arr1[i]; } //int i = 0; //for (i = 0; i < N; ++i) //{ // arr1[i] = i; // arr2[i] = arr1[i]; // arr3[i] = arr1[i]; // arr4[i] = arr1[i]; // arr5[i] = arr1[i]; // arr6[i] = arr1[i]; //} int begin1 = clock(); InsertSort(arr1, N); int end1 = clock(); printf("InsertSort:%d\n", end1 - begin1); int begin2 = clock(); ShellSort(arr2, N); int end2 = clock(); printf("ShellSort:%d\n", end2 - begin2); int begin3 = clock(); SelectSort(arr3, N); int end3 = clock(); printf("SelectSort:%d\n", end3 - begin3); int begin4 = clock(); HeapSort(arr4, N); int end4 = clock(); printf("HeapSort:%d\n", end4 - begin4); int begin5 = clock(); BubbleSort(arr5, N); int end5 = clock(); printf("BubbleSort:%d\n", end5 - begin5); int begin6 = clock(); FastSort(arr6, 0, N-1); int end6 = clock(); printf("FastSort:%d\n", end6 - begin6); int begin7 = clock(); MergeSort(arr7,N); int end7 = clock(); printf("MergeSort:%d\n", end7 - begin7); int begin8 = clock(); CountSort(arr8, N); int end8 = clock(); printf("CountSort:%d\n", end8 - begin8); free(arr1); free(arr2); free(arr3); free(arr4); free(arr5); free(arr6); free(arr7); free(arr8); }
在完成你的排序算法后,你可以在这里进行测试:
以上就是我对八大基础排序算法的认识,希望能够对你的学习有所帮助。
如有错误,还请指正!