一、排序的概念及应用
1、排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的,否则称为不稳定的。
内部排序(内排序):数据元素全部放在内存中的排序。
外部排序(外排序):数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
2、排序的运用
排序在现实生活中非常常见,比如:去购物软件购物时价格排行榜,销量排行榜等等,考试成绩排行榜,学校知名度排行榜等等。
这是某购物平台的商品选项。
3、常见的排序算法
以上7个是常见的排序算法。
二、排序(以升序为例)
1、冒泡排序
说到冒泡排序,想必大家不会感到陌生,它的核心思想是:从头开始,相邻两个进行比较,若排升序,则如果前一个比后一个大,就交换两个数,然后迭代往下走直到比到最后一个,这一趟下来,最后一个数就是最大的。然后依次排第二个数,第三个数等等,直到排完,就完成了排序。
动图展示:
代码展示:
//冒泡排序
//时间复杂度:O(N^2)
//最好情况:O(N) 有序
void BubbleSort(int* a, int n)
{
for (int i = 0;i < n - 1;i++)
{
int flag = 1;
for (int j = 0;j < n - 1 - i;j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
flag = 0;
}
}
if (flag == 1)
break;
}
}
冒泡排序的时间复杂度是典型的O(N^2),因为在最坏的情况下(元素个数为N),第一趟,比较N-1次,第二趟,比较N-2次,最后一趟,比较1次,是等差数列,故它的时间复杂度为:O(N^2)
2、选择排序
基本思想:在所有的待排序元素中选一个最小的元素,放在首部,接着,从剩下的元素中再选出一个最小的,放在第二个位置,依次进行,直到选完为止。
- 在元素集合array[i]--array[n-1]中选择关键码最小的数据元素
- 若它不是这组元素中的第一个元素,则将它与这组元素中的第一个元素交换
- 在剩余的array[i+1]--array[n-1]集合中,重复上述步骤,直到集合剩余1个元素
动图展示:
代码展示:
//选择排序
//时间复杂度:O(N^2) 等差数列
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void SelectSort(int* a,int n)
{
int begin = 0;
while (begin < n)
{
int mini = begin;
for (int i = begin + 1;i < n;i++)
{
if (a[i] < a[mini])
mini = i;
}
Swap(&a[begin],&a[mini]);
begin++;
}
}
上述代码可以排序,但还可以稍微改进一下,我们在一趟中同时找到最小的和最大的,将最小的放到首部,将最大的放到最后,这样会快一点。但这种方法需要注意一个点,在第一个交换时,最大元素的下标可能在begin位置,在进行第二个交换前需要判断一下。
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
//选择排序
//时间复杂度:O(N^2) 等差数列
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin;
for (int i = begin + 1;i <= end;i++)
{
if (a[i] > a[maxi])
maxi = i;
if (a[i] < a[mini])
mini = i;
}
Swap(&a[begin], &a[mini]);
if (begin == maxi) //最大元素的下标可能在begin位置
maxi = mini;
Swap(&a[end], &a[maxi]);
++begin;
--end;
}
}
3、直接插入排序
基本思想:直接插入排序就像我们玩扑克牌时,拿到第一张,不动,拿到第二张时,与第一张判断,大的放后面,小的放前面,当摸到最后一张牌时,前面的牌都有序,最后一张牌依次与前面的进行比较,放入合适的位置,保证所有的牌有序。把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。
当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
动图展示:
代码展示:
//直接插入排序
//时间复杂度:O(N^2) 最坏情况:逆序 等差数列 1+2+...+n-1
//最好情况:顺序 O(N)
void InsertSort(int* a, int n)
{
for (int i = 0;i < n - 1;i++)
{
//[0,end]是有序的,end + 1位置的值插入到[0,end],保持有序
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;
}
}
虽然它的时间复杂度为O(N^2),但完全逆序的情况很少,这种排序在接近有序的时候速度较快,相较于前两种排序,简直是“碾压”。
4、希尔排序
希尔排序是在直接插入排序的基础上进行改进的,希尔排序又称缩小增量法。
基本思想:选定一个整数gap,把待排序的文件中所有记录分成几个组,所有距离为gap的记录分在同一组内,并对每一组内的记录用直接插入法进行排序。然后减少gap,重复上述分组和排序的工作。直到gap==1时(相当于直接插入排序),所有记录在同一组内排好序。
这时就有人会想,这不是“脱裤子放屁,多此一举嘛”,实则不然,这种方法的好处是,大数会以较快的速度往后移,小数会以较快的速度往前移。在gap==1之前的排序称为“预排序”,进行几轮预排序后,几乎就接近有序了,当gap==1时,就是直接插入排序(看下面代码),这时速度很快。
动图展示:
这个图大家初看可能有点懵,没关系,我们可以对比下面图来看一下:
第一趟排序时,gap==5(每两个数之间有5个空)
9 4 是一组
1 8 是一组
...
7 5 是一组
每个组进行直接插入排序。
第二趟,gap缩减,这时gap==2(每两个数之间有2个空)
4 2 5 8 5 是一组
1 3 9 6 7 是一组
每个组进行直接插入排序。
...
最后一趟,gap==1(直接插入排序)
排一遍,就有序了。
代码展示:
//希尔排序
//时间复杂度:O(N^1.3)
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
//gap /= 2;
gap = gap / 3 + 1; //经过大量试验认为这种gap取值合适,加1是控制gap最后一个取值必须为1
for (int j = 0;j < gap;j++) //控制每趟,gap是几就被分成几组,每一组都会走一趟
{
for (int i = j;i < n - gap;i += gap) //一组排序,当gap==1时就是直接插入排序
{
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;
}
}
//初学时可以把每一趟的gap值打印出来看看
//printf("gap:%d -> ", gap);
//PrintArray(a, n);
}
}
这种方式用了4层循环,也可以缩减为3层循环。代码如下:
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
//gap /= 2;
gap = gap / 3 + 1;
for (int i = 0;i < n - gap;i++) //由原来的i+=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;
}
}
}
这种代码虽然是3层循环,但效率和4层循环一样,只不过是代码走的形式不一样了,第一种代码是一组一组分开来排序,第二种代码是每组代码一起排,并驾齐驱。
对于预排序:
gap越大,大的数可以越快跳到后面,小的数可以越快跳到前面,越不接近有序
gap越小,跳的越慢,但越接近有序。
当gap==1时,相当于直接插入排序了,就有序了。
gap由大变小直至为1,这就是希尔排序的妙处,不要想着复杂,其实它的速度很快。
接下来,分析一下这个排序算法的复杂度:
假设gap==3,就是分成了3组,这点想必大家非常明白。算复杂度时,gap=gap/3+1,可以先忽略+1,不影响整体结果
第一趟gap=n/3:一共有n个数据,gap组,每组有n/gap=3个数据,最坏情况下第一趟预排序的消耗:每组比较次数 * 组数 = (1+2)*gap = n
第二趟gap=n/9:每组有n/gap=9个数据,最坏情况下第二趟预排序的消耗:(1+2+...+8)*gap = 4n
...
最后一趟gap=1:经过前几趟后,数据已接近有序,故预排序的消耗:n
但我们知道,第一趟走完,走第二趟时,不可能是最坏情况,所以时间复杂度是不好求的,我们可以先了解它的大致过程。
这里大家先记住它的时间复杂度为O(N^1.3)。
5、堆排序
堆排序,我在之前的文章中已详细讲述,大家不明白的可以去这里:数据结构---堆
在这里只给出代码:
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = 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]) //此时a[child]就是两个子节点的较小值
{
break;
}
else
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
}
}
//堆排序
//时间复杂度:O(N*logN)
void HeapSort(int* a, int n)
{
//建堆
//向下调整建堆,时间复杂度为:O(N)
for (int i = (n - 1 - 1) / 2;i >= 0;i--)
AdjustDown(a, n, i);
//以下while循环代码时间复杂度为:O(N*logN)
int end = n - 1;
while (end > 0)
{
Swap(&a[0],&a[end]);
AdjustDown(a, end, 0);
--end;
}
for (int i = 0;i < n;i++)
printf("%d ",a[i]);
}
6、快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
(1)hoare快排
动图展示:
上述动图是一趟下来的结果,要控制多趟需要用到递归。
因为,快排是在一个个区间中进行的,所以我们在传参时,参数要传一个区间的第一个元素和最后一个元素的下标,这和之前的排序有所不同。
代码展示:
//hoare版本快排
//时间复杂度:O(N*logN)
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = left;
int begin = left, end = right;
while (begin < end)
{
//1.右边找小
while (begin < end && a[end] >= a[keyi])
--end;
//2.左边找大
while (begin < end && a[begin] <= a[keyi])
++begin;
if (begin != end)
Swap(&a[begin], &a[end]);
}
Swap(&a[keyi],&a[begin]);
keyi = begin;
//[left,keyi-1] keyi [keyi+1,right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
它的大致步骤是:
- 利用两个变量begin,end分别指向数组的起始位置与末尾位置。并且以数组第一个元素的下标作为keyi值。
- end先从右往左依次遍历找到比keyi对应的值小的数,begin从左往右依次遍历找到比keyi对应的值大的数。然后交换begin与end下标对应的值。重复步骤2直至end==begin。
- 交换keyi对应的值与begin或者end对应的值(begin,end对应的是同一个值),并且把keyi更新该位置的下标。
- 最后划分区间[left,keyi-1]与[keyi+1,right]继续重复1,2步骤。直至不能划分。
这时大家会有疑问:
假设keyi下标对应的值为key。
为什么end先走,为什么不是begin先走,begin不能先走吗?答案是不能的,因为我们一定要让它们相遇时的值比key小才行,否则交换后,把大的值交换到左边了,就乱套了,而end先走的话,相遇时的值一定小于key,为什么呢?
相遇场景分析:
- begin遇end:end先走,停下来,end停下条件是遇到比key小的值,begin没有找大的,遇到end就停下了。
- end遇begin:end先走,找小,没有找到比key小的,直接与begin相遇了。begin停留的位置是上一轮交换的位置,上一轮交换,把比keyi小的值,换到begin的位置了,此时begin位置就是比key小的值
所以,end先走,相遇点必比key小。这也是我们想要的结果。
但如果我们把keyi定到最后一个元素的下标,就需要让begin先走。
这里还有一个问题,如果排升序的话,原序列为降序的话,那排起来效率极低,它的时间复杂度会退化到O(N^2),就体现不了它的“快”,但我们有办法解决它。三数取中,取一个区间的第一个元素和最后一个元素和中间元素的下标,判断中间大的值的下标赋给keyi,这样就保证排完序后keyi两边的元素数量不会出现极端情况。
具体代码是:
//三数取中
int GetMidi(int* a, int left, int right)
{
int midi = (right - left) / 2 + left;
if (a[left] < a[midi])
{
if (a[right] > a[midi])
return midi;
else //a[right] < a[midi]
{
if (a[right] > a[left])
return right;
else
return left;
}
}
else //a[left] > a[midi]
{
if (a[midi] > a[right])
return midi;
else //a[midi] < a[right]
{
if (a[right] > a[left])
return left;
else
return right;
}
}
}
经过以上步骤后,大致就差不多了,但还有最后一个问题,我么在这里用的是递归,内存容量不大,递归层数太多会导致栈溢出,这是不允许的。
如何解决这个问题?我们可以尽量的将递归层数减少,在递归过程中,大家可以想象二叉树,先将原序列一分为二,接着二分四,四分八,... ,倒数第二层再分时,分的是最多的,大约占总共层数的1/2,所以我们可以从这点入手,如果一个区间的数小于10个就不用快排了,因为数量较少,改为用直接插入排序更优。
优化后完整代码如下:
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
//小区间优化
if (right - left + 1 < 10)
{
InsertSort(a + left, right - left + 1);
}
else
{
//三数取中
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
int begin = left, end = right;
while (begin < end)
{
//1.右边找小
while (begin < end && a[end] >= a[keyi])
--end;
//2.左边找大
while (begin < end && a[begin] <= a[keyi])
++begin;
if (begin != end)
Swap(&a[begin], &a[end]);
}
Swap(&a[keyi], &a[begin]);
keyi = begin;
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
时间复杂度: 在递归过程中,大家可以想象二叉树,先将原序列一分为二,接着二分四,四分八,... ,一共有logN层,每一层最多比较N次,所以它的时间复杂度为:O(N*logN)。
(2)挖坑法
后人在hoare的基础上,进行改进,发明了一种“挖坑法”。
基本步骤:
- 先将第一个数据存放在临时变量key中,形成一个坑位
- 右指针R开始向前移动,找到比key值小的位置
- 找到后,将该位置的值放入坑位,该位置形成新的坑位
- 左指针L开始向后移动,找到比key值大的位置
- 找到后,将该位置的值放入坑位,该位置形成新的坑位
- 重复2、3、4、5步骤,直到L与R相遇,最后将key的值放入相遇位置的坑位中
动图展示:
这种方法的优点是:
- 不用考虑左边做key,右边先走的问题
- 不用考虑相遇位置为什么比key小的问题,因为它的相遇位置是坑
代码展示:
//挖坑法
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
//小区间优化
if ((right - left + 1) < 10)
{
InsertSort(a + left, right - left + 1);
}
else
{
//避免效率退化
//三数取中后得到的数,仍交换到最左边,为不影响整体逻辑
int midi = GetMidi(a, left, right);
Swap(&a[midi], &a[left]);
//key是临时变量,记录最左边的元素
int key = a[left];
int begin = left, end = right;
//pit是坑位
int pit = left;
while (begin < end)
{
//右边找小于key的数
while (begin < end && a[end] >= key)
{
--end;
}
//将找到的比key小的数填到坑位,刷新新坑位
a[pit] = a[end];
pit = end;
//左边找大于arr[keyi]的数
while (begin < end && a[begin] <= key)
{
++begin;
}
//将找到的比key大的数填到坑位,刷新新坑位
a[pit] = a[begin];
pit = begin;
}
//最后将key值填充到坑位
a[pit] = key;
key = a[begin];
//[left,pit - 1] pit [pit + 1,right]
//划分区间,递归
QuickSort(a, left, pit - 1);
QuickSort(a, pit + 1, right);
}
}
(3)前后指针法
这种方法容易理解,它是由lomuto提出的。
基本步骤:
- 初始时,prev指针指向序列开头,cur指向prev指向的下一位
- 判断cur指针指向的数据是否小于key,若小于,则prev指针前移一位,并将cur与prev所指向的内容交换,然后cur++
- 若cur指向的数据大于key,cur++,prev不动
- 重复2、3步骤,直到cur越界
- 最后将prev所指向的值与key交换
动图展示:
代码展示:
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
//小区间优化
if (right - left + 1 < 10)
{
InsertSort(a + left, right - left + 1);
}
else
{
//三数取中
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
++cur;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
以上就是快排的3种递归方法,它们的效率都是一样的,只是思路不一样,时间复杂度都是O(N*logN)。
(4)非递归法
前面3种都是用递归来解决问题,其实非递归也可以解决问题,不过需要借助栈来实现。
因为我们这里是用C语言实现的,C语言中没有专门的STL库,所以栈必须自己实现,但这里的重点不是栈,而是快排的非递归方法。请看下面代码:
//hoare快排(单趟排,没有递归)
int PartSort1(int* a, int left, int right)
{
int keyi = left;
int begin = left, end = right;
while (begin < end)
{
//1.右边找小
while (begin < end && a[end] >= a[keyi])
--end;
//2.左边找大
while (begin < end && a[begin] <= a[keyi])
++begin;
if (begin != end)
Swap(&a[begin], &a[end]);
}
Swap(&a[keyi], &a[begin]);
keyi = begin;
return keyi;
}
void QuickSortNonR(int* a, int left, int right)
{
//大家不用在意具体与栈相关的函数接口的细节,只需知道st是一个栈,栈遵循先进后出即可
ST st;
StackInit(&st); //初始化栈
StackPush(&st,right); //向栈中添加元素
StackPush(&st,left);
while (!StackEmpty(&st)) //判断栈是否为空,为空返回true,否则返回false
{
int begin = StackTop(&st); //取栈顶元素
StackPop(&st); //删除栈顶元素
int end = StackTop(&st);
StackPop(&st);
int keyi = PartSort1(a, begin, end);
//[begin,keyi-1] keyi [keyi+1,end]
if (keyi + 1 < end)
{
StackPush(&st,end);
StackPush(&st, keyi + 1);
}
if (begin < keyi - 1)
{
StackPush(&st, keyi - 1);
StackPush(&st,begin);
}
}
StackDestroy(&st);
}
这段非递归代码执行的过程,其实就像是模仿递归执行的过程,这里不过多赘述。
(5)快排的优化
三路划分:当待排序列中元素重复过多时,会导致keyi左右两边的元素个数分布不均匀,快排的效率会下降。比如:
//数组中有多个跟key相等的值
int a[] = { 6,1,7,6,6,6,4,9 };
int a[] = { 3,2,3,3,3,3,2,3 };
//数组中全是相同的值
int a[] = { 2,2,2,2,2,2,2,2 };
当面对有大量跟key相同的值时,三路划分的核心思想有点类似hoare的左右指针和lomuto的前后指针的结合。核心思想是把数组中的数据分为三段[比key小的值][跟key相等的值][比key大的值],所以叫做三路划分算法。
算法步骤:
- key默认取left位置的值。
- left指向区间最左边,right指向区间最后边,cur指向left+1位置。
- cur遇到比key小的值后跟left位置交换,换到左边,left++,cur++。
- cur遇到比key大的值后跟right位置交换,换到右边,right--。
- cur遇到跟key相等的值后,cur++。
- 直到cur > right结束
代码实现:
void Swap(int* x, int* y) {
int tmp = *x;
*x = *y;
*y = tmp;
}
void QuickSort(int* a, int left, int right) {
if (left >= right)
return;
int begin = left;
int end = right;
// 随机选key
int randi = left + (rand() % (right - left + 1));
// printf("%d\n", randi);
Swap(&a[left], &a[randi]);
int key = a[left];
int cur = left + 1;
//开始划分
while (cur <= right) {
if (a[cur] < key) {
Swap(&a[left], &a[cur]);
left++;
cur++;
}
else if (a[cur] > key) {
Swap(&a[cur], &a[right]);
right--;
}
else {
cur++;
}
}
//[begin,left - 1][left,right][right + 1,end]
QuickSort(a, begin, left - 1);
QuickSort(a, right + 1, end);
}
无论是hoare,还是lomuto的前后指针法,面对key有大量重复时,划分都不是很理想。三数取中和随机选key,都不能很好的解决这里的问题。但是三路划分算法,把跟key相等的值都划分到了中间,可以很好的解决这里的问题。
但待排序列重复值较少时,三路划分“开销”会显得的大些。
C++ STL sort中用的introspective sort(内省排序)。(introsort是由David Musser在1997年设计的排序算法)。
introsort是introspective sort采用了缩写,它的名字其实表达了它的实现思路,它的思路就是进行自我侦测和反省,快排递归深度太深(sgi stl中使用的是深度为2倍排序元素数量的对数值)那就说明在这种数据序列下,选key出现了问题,性能在快速退化,那么就不要再进行快排分割递归了,改换为堆排序进行排序。
代码实现:
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = 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])
{
break;
}
else
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
}
}
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 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;
}
for (int i = 0;i < n;i++)
printf("%d ", a[i]);
}
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 (a[end] > tmp)
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
void IntroSort(int* a, int left, int right, int depth, int defaultDepth)
{
if (left >= right)
return;
// 数组长度⼩于16的⼩数组,换为插⼊排序,简单递归次数
if (right - left + 1 < 16)
{
InsertSort(a + left, right - left + 1);
return;
}
// 当深度超过2*logN时改用堆排序
if (depth > defaultDepth)
{
HeapSort(a + left, right - left + 1);
return;
}
depth++;
int begin = left;
int end = right;
// 随机选key
int randi = left + (rand() % (right - left));
Swap(&a[left], &a[randi]);
int prev = left;
int cur = prev + 1;
int keyi = left;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
++cur;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
// [begin, keyi-1] keyi [keyi+1, end]
IntroSort(a, begin, keyi - 1, depth, defaultDepth);
IntroSort(a, keyi + 1, end, depth, defaultDepth);
}
void QuickSort(int* a, int left, int right)
{
int depth = 0;
int logn = 0;
int N = right - left + 1;
for (int i = 1; i < N; i *= 2)
{
logn++;
}
// introspective sort -- 自省排序
IntroSort(a, left, right, depth, logn * 2);
}
7、归并排序
(1)递归法
基本思路:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
基本步骤:
- 把长度为n的输入序列分成两个长度近似为n/2([begin,mid] [mid + 1,end] )的子序列
- 对这两个子序列再分别进行归并排序,将区间分解到不可在分为止
- 依次将两个排序好的子序列合并成一个最终的排序序列
动图展示:
代码展示:
void _MergeSort(int* a, int* tmp, int begin, int end)
{
if (begin == end)
return;
int mid = (end - begin) / 2 + begin;
//如果[begin,mid][mid+1,end]有序,就可以进行归并
_MergeSort(a, tmp, begin, mid);
_MergeSort(a, tmp, mid + 1, end);
//归并
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
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 + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(a, tmp, 0, n - 1);
free(tmp);
tmp = NULL;
}
因为归并排序需要开一个等大的数组,所以需要在一个子函数进行递归操作, 这里还有一个点是“如果[begin,mid][mid+1,end]有序,就可以进行归并”,那为什么不把区间划分为[begin,mid-1][mid,end]呢?
因为如果区间是[偶数,偶数+1],在此基础上划分时,会分出[偶数,偶数-1][偶数,偶数+1],左区间不存在,右区间会导致死循环,例如区间为[2,3],划分后的区间为[2,1][2,3]。
若以[begin,mid][mid+1,end]这种方式划分,则会避免出现以上问题,例如区间为[2,3],划分后的区间为[2,2][3,3]。
时间复杂度:我们想象归并排序的过程也是类似于二叉树,一分二,二分四,四分八,... ,一共有logN层,每层比较N次,故时间复杂度为:O(N*logN)。
(2)非递归法
归并排序也有非递归的方法。
我们先看代码:
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
//gap每组归并数据的个数
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;
if (end1 >= n)
break;
if (end2 >= n)
end2 = n - 1;
//开始归并
int j = i;
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; //第二次每组归并2个,第三次每组归并4个,直到gap>=n归并结束
}
free(tmp);
tmp = NULL;
}
非递归的基本思路是:我按顺序先两个两个归并,begin1和end1控制前一个区间的下标范围,begin2和end2控制后一个区间的下标范围,因为要保证归并前的两个区间必须有序,所以gap从1开始,先从每个区间有1个数开始,然后扩大gap的值,每个区间2个数,每个区间3个数...直到gap>=n为止,归并完毕。
但有一个问题,在for循环中end1,begin2,end2很有可能发生越界情况,因为begin1=i,i<n,所以begin1不可能会发生越界。
那么越界行为有哪些呢?
- end2越界
- begin2,end2越界
- end1,begin2,end2越界。
只有这3种情况。
如何处理呢?
如果end1越界,那么begin2一定越界,所以3和2可以一起处理。
所以:
如果end1比n大,就跳出循环,不参与归并。
如果end2越界,就需要把end2的调整为n-1,保证不越界。
还有一个注意点:
每一趟归并完须立刻拷贝回去,归并需要两个区间,如果end1越界了,就只剩一个区间,就不需要归并了(由break控制),如果全部归并完再进行拷贝,会导致一个问题,原本没有归并的区间,会被覆盖掉,变成随机数,这不是我们想要的结果。每一趟归并完须立刻拷贝回去就会避免这种情况发生。
(3)文件归并排序
外排序(External sorting)是指能够处理极大量数据的排序算法。通常来说,外排序处理的数据不能一次装入内存,只能放在读写较慢的外存储器(通常是硬盘)上。外排序通常采用的是一种“排序-归并”的策略。在排序阶段,先读入能放在内存中的数据量,将其排序输出到⼀个临时文件,依次进行,将待排序数据组织为多个有序的临时文件。然后在归并阶段将这些临时文件组合为⼀个大的有序文件,也即排序结果。
跟外排序对应的就是内排序,我们之前讲的常见的排序,都是内排序,他们排序思想适应的是数据在内存中,支持随机访问。归并排序的思想不需要随机访问数据,只需要依次按序列读取数据,所以归并排序既是⼀个内排序,也是一个外排序。
如果要排的数据量非常大时,由于内存容量较小,假如只给你1G内存,而你的数据有4G,这时,依次读取大文件,每读取1G到内存,进行快速排序,写到一个小文件中,产生4个小文件(1G),归并时有两种方法,第一种,任意两个1G的文件归并,然后整体归并,第二种,按顺序归并,前两个1G的文件先归并,然后归并后的结果形成一个新文件与第三个1G的文件归并...直到归并完最后一个文件。因为第一种控制起来比较麻烦,我们这里讲一下第二种:
基本步骤:
- 读取n个值排序后写到file1,再读取n个值排序后写到file2
- file1和file2利用归并排序的思想,依次读取比较,取小的尾插到mfile,mfile归并为⼀个有序文件
- 将file1和file2删掉,mfile重命名为file1
- 再次读取n个数据排序后写到file2
- 继续走file1和file2归并,重复步骤2,直到文件中无法读出数据。最后归并出的有序数据放到了 file1中
代码展示:
// 创建N个随机数,写到文件中
void CreateNDate()
{
// 造数据
int n = 1000;
srand((unsigned int)time(NULL));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (int i = 0; i < n; ++i)
{
int x = rand() + i;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
int compare(const void* a, const void* b)
{
return (*(int*)a - *(int*)b);
}
//返回实际读到的数据个数
int ReadNDateSortToFile(FILE* fout,int n,const char* file1)
{
int* a = (int*)malloc(sizeof(int) * n);
if (a == NULL)
{
perror("malloc fail");
return 0;
}
//想读取n个数据,若提前读到文件末尾,则读取了i个数据
int i = 0;
for (i = 0;i < n;i++)
{
if (fscanf(fout, "%d", &a[i]) == EOF)
break;
}
//如果一个数据都没读到,直接返回
if (i == 0)
{
free(a);
return 0;
}
//排序
qsort(a, i, sizeof(int), compare);
FILE* fin = fopen(file1, "w");
if (fin == NULL)
{
free(a);
perror("fopen fail");
return 0;
}
//写回file1文件
for (int j = 0;j < i;j++)
{
fprintf(fin,"%d\n",a[j]);
}
free(a);
fclose(fin);
return i;
}
void MergeFile(const char* file1, const char* file2, const char* mfile)
{
FILE* fout1 = fopen(file1, "r");
if (fout1 == NULL)
{
perror("fopen error");
return;
}
FILE* fout2 = fopen(file2, "r");
if (fout2 == NULL)
{
perror("fopen error");
return;
}
FILE* mfin = fopen(mfile, "w");
if (mfin == NULL)
{
perror("fopen error");
return;
}
//归并
int x1 = 0,x2 = 0;
int ret1 = fscanf(fout1,"%d",&x1);
int ret2 = fscanf(fout2,"%d",&x2);
while (ret1 != EOF && ret2 != EOF)
{
if (x1 < x2)
{
fprintf(mfin,"%d\n",x1);
ret1 = fscanf(fout1, "%d", &x1);
}
else
{
fprintf(mfin, "%d\n", x2);
ret2 = fscanf(fout2, "%d", &x2);
}
}
while (ret1 != EOF)
{
fprintf(mfin, "%d\n", x1);
ret1 = fscanf(fout1, "%d", &x1);
}
while (ret2 != EOF)
{
fprintf(mfin, "%d\n", x2);
ret2 = fscanf(fout2, "%d", &x2);
}
fclose(fout1);
fclose(fout2);
fclose(mfin);
}
int main()
{
//CreateNDate();
const char* file1 = "file1.txt";
const char* file2 = "file2.txt";
const char* mfile = "mfile.txt";
FILE* fout = fopen("data.txt", "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
int m = 100;
ReadNDateSortToFile(fout, m, file1);
ReadNDateSortToFile(fout, m, file2);
while (1)
{
MergeFile(file1, file2, mfile);
//删除file1和file2
remove(file1);
remove(file2);
//重命名mfile为file1
rename(mfile, file1);
//当再去读取数据时,一个都读不到,说明已经没有数据了
//已经归并完成,归并结果在file1
if (ReadNDateSortToFile(fout, m, file2) == 0)
break;
}
return 0;
}
8、计数排序
计数排序又称鸽舍原理是一种线性时间的排序算法,适用于整数或有限范围内的非负整数排序。
核心思想:通过计数每个元素的出现次数来进行排序,不需要比较元素的大小。
动图展示:
看完动图,想必大家已经知道它的基本思路。
代码展示:
//时间复杂度:O(N+range)
//只适合整数/适合范围集中的
//空间复杂度:O(range)
void CountSort(int* a, int n)
{
int min = a[0], 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* count = (int*)malloc(sizeof(int) * range); //开range大的空间
if (count == NULL)
{
perror("malloc fail");
return;
}
memset(count, 0, sizeof(int) * range); //空间初始化为0
//统计次数
for (int i = 0;i < n;i++)
{
count[a[i] - min]++;
}
//排序
int j = 0;
for (int i = 0;i < range;i++)
{
while (count[i]--)
{
a[j++] = i + min;
}
}
}
它的时间复杂度为:O(N+range),空间复杂度为:O(range)
9、桶排序
桶排序是分治策略的一个典型应用。它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。
它的基本思路是:定义10个桶,每个桶是一个指针,原数据看最高位,最高位是几就放在几号桶后面,对每个桶进行排序,在合并所有桶。
适用条件:每个桶中数据个数分布均匀。
桶排序不过多介绍。
10、基数排序
基数排序是按照低位先排序;再按照次高位排序;依次类推,直到最高位。最后,按顺序排放完毕。
动图展示:
这里,基数排序也不过多介绍。
三、性能测试
我们衡量一个排序不单单只看时间复杂度,还需要对它进行测试。
代码如下:
//测试排序的性能对比
void TestOP()
{
srand((unsigned int)time(NULL));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
int* a8 = (int*)malloc(sizeof(int) * N);
for (int i = 0;i < N;i++)
{
a1[i] = rand() + i;
a2[i] = a1[i];
a3[i] = a2[i];
a4[i] = a3[i];
a5[i] = a4[i];
a6[i] = a5[i];
a7[i] = a6[i];
a8[i] = a7[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
HeapSort(a3, N);
int end3 = clock();
int begin4 = clock();
BubbleSort(a4, N);
int end4 = clock();
int begin5 = clock();
SelectSort(a5, N);
int end5 = clock();
int begin6 = clock();
QuickSort(a6, 0, N - 1);
int end6 = clock();
int begin7 = clock();
MergeSort(a7, N);
int end7 = clock();
int begin8 = clock();
CountSort(a8, N);
int end8 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("HeapSort:%d\n", end3 - begin3);
printf("BubbleSort:%d\n", end4 - begin4);
printf("SelectSort:%d\n", end5 - begin5);
printf("QuickSort:%d\n", end6 - begin6);
printf("MergeSort:%d\n", end7 - begin7);
printf("CountSort:%d\n", end8 - begin8);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
free(a8);
}
这段代码大家一看便知,不需要过多赘述。
四、排序总结
我们按时间复杂度、空间复杂度、稳定性依次对每个常见的排序分析一下:
1、冒泡排序,它的整个过程显然是一个等差数列,所以它的时间复杂度是O(N^2),排序过程中没有开额外的空间,所以它的空间复杂度为O(1),在排序过程中,可以控制相同元素的相对位置保持不变,所以稳定。
2、选择排序,它的整个过程显然也是一个等差数列,所以它的时间复杂度是O(N^2),排序过程中没有开额外的空间,所以它的空间复杂度为O(1),在排序过程中,不可以控制相同元素的相对位置保持不变,所以不稳定。例如,6 6 1 3 5 最小元素1与第1个6交换时,两个6的相对位置改变了。
3、直接插入排序,它的整个过程显然也是一个等差数列,所以它的时间复杂度是O(N^2),排序过程中没有开额外的空间,所以它的空间复杂度为O(1),在排序过程中,可以控制相同元素的相对位置保持不变,所以稳定。
4、希尔排序,它的时间复杂度为O(N^1.3),排序过程中没有开额外的空间,所以它的空间复杂度为O(1),在排序过程中,不可以控制相同元素的相对位置保持不变,所以不稳定。例如,相同的元素被分到不同的组中,相同元素的相对位置是不可控的。
5、堆排序,每个元素进行一次向下调整,向下调整算法的时间复杂度为O(logN),故堆排序的时间复杂度为O(N*logN),排序过程中没有开额外的空间,所以它的空间复杂度为O(1),在排序过程中,不可以控制相同元素的相对位置保持不变,所以不稳定。例如,所有元素一样,在排序过程中,相对位置就会改变。
6、快速排序,在递归过程中,大家可以想象二叉树,先将原序列一分为二,接着二分四,四分八,... ,它一共有logN层,每一层最多比较N次,所以它的时间复杂度为:O(N*logN)。由于是递归,所有必然会有内存消耗,一共有logN层,故空间复杂度为O(logN)。在排序过程中,不可以控制相同元素的相对位置保持不变,所以不稳定。例如,6 1 2 5 6 7 3 6 ,在这个序列中,第一趟排完后,前两个6的相对位置就发生了改变。
7、归并排序,我们想象归并排序的过程也是类似于二叉树,一分二,二分四,四分八,... ,一共有logN层,每层比较N次,故时间复杂度为:O(N*logN),在归并排序中,我们开了一个和原数组大小相同的空间以及递归所带来的消耗,故它的空间复杂度为O(N+logN),但logN的量级没N大,故它的空间复杂度为O(N),在排序过程中,可以控制相同元素的相对位置保持不变,所以稳定。
下面是图示:
本篇中的十大排序,其中前7个是比较排序,后三个是非比较排序。
五、总结
以上就是本篇的全部内容啦!若有不足的地方,请大家指出,我会改正的,最后,希望大家有所收获!我们下篇再见!