排序
排序的概念
在进行各种排序之前,要先介绍一下排序的一些概念:
排序:指的是将一串记录,按照其中的某个或者某些关键字的大小,进行递增或者递减的排列。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
排序应用的场景非常普遍,比如说淘宝、京东等电商平台都会对你所搜索的宝贝进行某些排序,或者使饿了么、美团会根据好评给你推荐附近的美食,这些都用到了排序。
常见的排序算法
排序算法实现
插入排序
直接插入排序
首先我们来看一张图
从这张图来分析直接插入排序算法的步骤,首先第一个数肯定是不动的,我们就认为第一个数已经是排好序的,然后从第1个元素开始,用arr[i]的值与前面已经排好序的值比较,找到合适的位置插入,每一次比较如果不合适就将该元素向后移动一位。
代码实现:
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void InsertSort(int* a, int n)
{
int i = 0;
for (i = 1; i < n; i++)
{
int num = a[i];
int j = i - 1;
while (j >= 0 && num < a[j])
{
Swap(&a[j], &a[j + 1]);
j--;
}
a[j + 1] = num;
}
}
直接插入排序的特性:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
希尔排序
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成几个组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行排序(可以是插入排序,也可以是其他排序)。然后重复上述分组和排序的工作。当到达gap=1时,所有记录在统一组内排好序。
可以说希尔排序就是加入了预排序的插入排序,在gap不等于1的时候相当于一个预排序,使数列接近有序,这样子再执行插入排序效率更好。
从上图可以看出,对于一个数组,希尔排序第一次的间隔gap为5,对于间隔为5的元素进行一次插入排序;然后间隔gap变为2,再次进行插入排序;最后gap为1,也就是直接插入排序,使整个数组有序。
这里有一个小诀窍,如果按照分组的方式一组一组进行排序效率太低,排序完一组还要从头再来,那么可以直接遍历数组,针对每一个元素来进行插入排序,因为gap分组之后不会有重复的元素,可以放心操作。这样遍历一遍就可以把用gap分的一组预排序一遍。
代码如下:
// 希尔排序
void ShellSort(int* a, int n)
{
//给定间隔
int gap = 5;
while (gap)
{
//由于gap的存在,遍历的数目一定是小于n-gap
for (int i = 0; i < n - gap; i++)
{
//end每一次为i+gap,就避免了漏掉最后一个元素
int end = i + gap;
//如果下一个元素下标小于0就终止
while (end - gap >= 0)
{
if (a[end] < a[end - gap])
Swap(&a[end], &a[end - gap]);
end -= gap;
}
}
//gap每次除以2
gap = gap / 2;
}
}
希尔排序的特性:
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
- 希尔排序的时间复杂度不好计算,需要进行推导,推导出来平均时间复杂度: O(N^1.3 - N^2)
- 稳定性:不稳定
选择排序
选择排序是指每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
直接选择排序
- 在元素集合a[i] - a[n-1]中选择关键码最大(小)的数据元素
- 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
- 在剩余的a[i]–a[n-2](a[i+1]–a[n-1])集合中,重复上述步骤,直到集合剩余1个元素
当然,我们可以在一次选择种选出最大的或者最小的,也可以同时选出最大和最小的,两者的时间复杂度没有区别。
从上图可以看出,每一次选出一个最小的放在序列的第一个位置,然后把已经找到的最小的元素不看作下一次队列中的,然后继续找剩余序列种最小的,以此类推,直到剩余一个元素。
代码如下:
// 选择排序
void SelectSort(int* a, int n)
{
int left = 0, right = n;
while (left < right)
{
//每一次找出当前序列的最大最小值
int min = left, max = left;
for (int i = left; i < right; i++)
{
if (a[i] < a[min])
min = i;
if (a[i] > a[max])
max = i;
}
//将最大最小值分别放在当前序列的开头和结尾
Swap(&a[left], &a[min]);
//如果最大值在要放置的最小值处,需要一下处理
//交换之后原来的最大值在原来的最小值处
if (max == left)
max = min;
Swap(&a[right - 1], &a[max]);
left++;
right--;
}
}
直接选择排序的特性:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
堆排序
堆排序是指利用堆这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。堆排序在我前面的博客中有详细说明,这里放一张图供大家理解。
从上图可以看出,这里如果要排升序就要建大堆,建小堆的话就会破坏树的结构(排降序建大堆同样如此)。建成大堆之后,最大的数一定是在堆顶,那么将其取下来与堆的最后一个元素交换,最大的数在后面的排序中不算做堆的元素。最后一个元素放在堆顶可能并不满足大堆的特性,那么在交换完之后要做一次向下调整,使得堆依旧保持大根堆的特性。重复下去,直到将所有的数排序完成。
代码如下:
// 堆排序
void AdjustDwon(int* a, int n, int root)
{
//假设建的是大根堆
int parent = root;
int child = 2 * parent + 1;//假设交换的是左孩子,后面进行判断
//每次交换的一定是最大的
//比较左孩子和右孩子,如果右孩子更大,则将child改为右孩子
if (child + 1 < n && a[child] < a[child + 1])
child++;
while (child < n && a[parent] < a[child])
{
if (child + 1 < n && a[child] < a[child + 1])
child++;
Swap(&a[parent], &a[child]);
parent = child;
child = 2 * parent + 1;
}
}
void HeapSort(int* a, int n)
{
int i = 0;
//堆排序首先需要建堆
//排升序建大堆,排降序建小堆
for (i = n - 1; i >= 0; --i)
{
AdjustDwon(a, n, i);
}
//排序
for (i = n - 1; i > 0; --i)
{
//交换堆头和堆尾的元素
Swap(&a[i], &a[0]);
//对堆头元素进行向下调整
//交换完之后最大的那个元素不算在堆中,所以堆的长度应该减一
AdjustDwon(a, --n, 0);
}
}
堆排序的特性:
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
交换排序
交换排序指的是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。
交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
冒泡排序
冒泡排序指的是重复地走访过要排序的元素列,依次比较两个相邻的元素,如果顺序错误就把他们交换过来。
每一趟中不断把顺序错误的两个元素进行交换,直到走到序列的尾。然后将最后一个元素不算在队列中,继续下一趟排序,以此类推,直到没有可交换的元素。
代码如下:
//冒泡排序
void BubbleSort(int* a, int n)
{
int i, j;
int flag = 0;//作为一趟中是否有交换过的元素标识
for (i = 0; i < n; i++)
{
for (j = 0; j < n - i - 1; j++)
{
flag = 1;
if (a[j] > a[j + 1])
Swap(&a[j], &a[j + 1]);
}
//如果flag为0说明序列中已经有序
if (flag == 0)
break;
}
}
冒泡排序的特性:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
快速排序
快速排序指的是通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序。
(1) Hoare法
上图展示了Hoare法快速排序的一趟,基准值可以是最左边的值,也可以是最右边的值,这个没有规定。给定两个指针,左指针lo和右指针hi,如果基准值在左边,就让hi先走;如果基准值在右边,就让lo先走。我们加假设基准值在右边,那么让左指针lo先走找比基准值大的数,然后右指针再走找比基准值小的数。然后将两个指针的元素交换,以此类推,直到左右指针相遇,最后把相遇位置的元素和基准值交换。这样基准值的左边就是比基准值小的数,右边是比基准值大的数。这样一趟排序就完成了。此时整个序列被分为左右两部分,接着对这两部分继续排序。直到将所有的元素都排序完成。
代码如下:
//Hoare法 --- 一趟排序
int PartSort_Hoare(int* a, int left, int right)
{
int pivot = left;//基准值设定为左值
while (left < right)
{
//右边先走,找比基准值小的
while (right > left && a[right] > a[pivot])
right--;
//再走左边,找比基准值大的
while (left < right && a[left] <= a[pivot])
left++;
//交换左右指针对应的元素
if (left < right)
Swap(&a[left], &a[right]);
}
//最后交换基准值与相遇点的值
Swap(&a[pivot], &a[left]);
return left;
}
(2) 挖坑法
挖坑法与Hoare法的基本思想差不多,不同的是Hoare法在一趟排序的过程中是交换元素,而挖坑法则是将基准值处的元素拿出,将基准值对应的位置当作一个坑,也就是空位置。假设基准值为最左边的元素,那就让右指针先走,找到比基准值小的元素放在空的坑内,而此时右指针对应的位置就变成了一个坑,然后移动左指针,找到比基准值大的元素,放在坑内,将左指针对应的位置当作一个坑。以此类推,直到左右指针相遇,将基准值放在最后的坑内即可。此时基准值的左右继续按照此方法排序,直到全部排序完成即可。
代码如下:
//挖坑法 -- 一趟排序
int PartSort_Potholing(int* a, int left, int right)
{
int space = left;//表示坑
int pivot = a[left];//基准值取最左边的
while (left < right)
{
//右边先走,找比基准值小的,放在坑内
while (right > left && a[right] >= pivot)
right--;
if (left < right)
{
a[space] = a[right];
space = right;
}
//再走左边,找比基准值大的,放在坑内
while (left < right && a[left] <= pivot)
left++;
//将坑内放入相遇点的元素,然后相遇点为新的坑
if (left < right)
{
a[space] = a[left];
space = left;
}
}
//最后把基准值放在空的坑内
a[space] = pivot;
return space;
}
前后指针法
前后指针法与前两种方法略有不同,通过一前一后两个指针prev和cur,cur先去寻找比基准值小的值,找到之后prev向后移动一位,如果与cur下标不相等就交换。以此类推,直到cur走到序列的末尾,然后交换prev与基准值,一趟排序结束。然后此时基准值的左右部分接着按照这样的方法排序,直到所有的元素排序完成。
代码如下:
//前后指针法 -- 一趟排序
int PartSort_DoublrPtr(int*a, int left, int right)
{
int pivot = left;
int prev = left, cur = left + 1;
while (cur <= right)
{
//cur先去找比基准值小的
while (cur <= right && a[cur] >= a[pivot])
cur++;
//如果此时cur走到序列的尾部,说明基准值的左边没有比其小的数,不需要排序
if (cur > right)
break;
//找到之后,prev++,如果和cur不相等就交换
prev++;
if (prev != cur)
{
Swap(&a[prev], &a[cur]);
}
//这里如果不加加cur的话会导致死循环
cur++;
}
Swap(&a[prev], &a[pivot]);
return prev;
}
快排可以通过递归实现,也可以通过非递归实现,这里先讲递归版本的。
快排在每趟排序之后都会找到一个中间坐标,整个序列被中间坐标分为两部分,这样看就很像二叉树的结构,那么我们就可以进行递归来完成排序。
递归需要满足两个条件:
(1) 终止条件是左指针大于等于右指针,因为只要左指针小于右指针,就说明至少序列中还有两个元素,可以继续进行排序;而一个元素就不需要排序了。
(2) 每一趟排序之后,中间坐标都会把序列分为两半,这样不断分割,就会慢慢接近终结的条件。
代码如下:
void _QuickSort(int* a, int left, int right)
{
//如果left大于等于right,说明不需要排序,直接返回
if (left >= right)
{
return;
}
//否则对左右分别排序
else
{
//先对序列进行一次排序,找出基准点
int base = PartSort_DoublrPtr(a, left, right);
//分别对左右两部分进行排序
_QuickSort(a, left, base - 1);
_QuickSort(a, base + 1, right);
}
}
void QuickSort(int* a, int n)
{
//进去直接调用快排子函数
_QuickSort(a, 0, n - 1);
}
快排的优化
不知道大家发现了没有,如果一个序列是降序排列的,而我们要排升序,那么这样子用快排的时间复杂度是O(N^2),效率很低,这样用快排和用其他排序可能没什么区别。但是快排却是排序中很常用的排序,因此对于这种情况我们需要做出优化的方案。
优化的方案有两个:
1、三数取中法
三数取中法指的是对最左边、最右边以及中间的数,进行大小比较,选择中间值作为基准值。这样就可以避免极端情况下效率低的问题。
//三数取中法
int GetMiddle(int* a, int left, int right)
{
int mid = (left + right) >> 1;
//如果中间值大于左值
if (a[mid] > a[left])
{
//比较中间值与右值,如果右值大则中间值为基准值
if (a[right] > a[mid])
return mid;
//如果中间值大于右值,则中间值最大,要比较左值和右值的大小
//如果右值大于左值,左值为基准值
else if (a[left] < a[right])
return right;
//如果左值大于右值,右值为基准值
else
return left;
}
//如果中间值小于等于左值
else
{
//如果中间值大于右值,中间值为基准值
if (a[mid] > a[right])
return mid;
//如果中间值小于右值,中间值则为最小,那么要比较左值和右值大小
//如果右值小于左值,右值为基准值
else if (a[right] < a[left])
return right;
//如果左值小于右值,左值为基准值
else
return left;
}
}
2、小区间优化
在递归到某一个给定的小区间之后,不使用快排进行排序,而是用插入排序。就是说在小的区间中可以先把这部分排序,然后再用快排,效率会高很多。
void _QuickSort(int* a, int left, int right)
{
//如果left大于等于right,说明不需要排序,直接返回
if (left >= right)
{
return;
}
//否则对左右分别排序
else
{
//在小区间之外使用快排
if (right - left > MAX_LENGTH_INSERT_SORT)
{
//先对序列进行一次排序,找出基准点
int base = PartSort_DoublrPtr(a, left, right);
_QuickSort(a, left, base - 1);
_QuickSort(a, base + 1, right);
}
//小区间内使用插入排序
else
{
_InsertSort(a, left, right);
}
}
}
快排的非递归
快排可以用递归实现,自然也可以使用非递归实现。
非递归实现需要用到栈,通过栈来模拟递归的过程。每一次将需要部分排序的左右下标入栈,然后排序完一段之后将该次的左右下标出栈,更新栈中的左右下标,直到栈为空。
//快排非递归
void QuickSort_NonR(int* a, int left, int right)
{
Stack s;
StackInit(&s);
//把最开始的左右下标入栈
StackPush(&s, left);
StackPush(&s, right);
//循环
//栈不为空说明还有序列需要排序
while (!StackEmpty(&s))
{
//先入栈的是左指针,后入的是左指针
//先出来的是右指针,后出来左指针
int end = StackTop(&s);
StackPop(&s);
int begin = StackTop(&s);
StackPop(&s);
//根据左右指针进行部分排序
int base = PartSort_Hoare(a, begin, end);
//如果左指针小于右指针再把下标入栈
if (begin < base - 1)
{
StackPush(&s, begin);
StackPush(&s, base - 1);
}
if (end > base + 1)
{
StackPush(&s, base + 1);
StackPush(&s, end);
}
}
StackDestory(&s);
}
快速排序的特性:
- 快速排序整体的综合性能和使用场景都是比较好的
- 时间复杂度:O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
归并排序
归并排序是是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。
从上图可以看出,归并排序先将一个序列分成很多个子序列,先使这些子序列有序,然后合并成一个有序的序列。归并的思路是在两个子序列中按照大小选择元素,使两个子序列成为有序,自底向上,直到整个序列有序。
代码如下:
//归并排序
void MergePartSort(int* a, int* arr, int left1, int right1, int left2, int right2)
{
int ptr1 = left1, ptr2 = left2;
int index = left1;//要放在对应的位置
//两个区间有一个到头就停止
while (ptr1 <= right1 && ptr2 <= right2)
{
if (a[ptr1] < a[ptr2])
{
arr[index++] = a[ptr1];
ptr1++;
}
else
{
arr[index++] = a[ptr2];
ptr2++;
}
}
//将剩下的元素全部放在后面即可
while (ptr1 <= right1)
{
arr[index++] = a[ptr1];
ptr1++;
}
while (ptr2 <= right2)
{
arr[index++] = a[ptr2];
ptr2++;
}
}
void _MergeSort(int* a, int* arr, int left, int right, int n)
{
if (left >= right)
{
return;
}
else
{
int mid = (left + right) >> 1;
_MergeSort(a, arr, left, mid, n);
_MergeSort(a, arr, mid + 1, right, n);
//两个子区间找到后,归并放置
MergePartSort(a, arr, left, mid, mid + 1, right);
//归并完一次复制一次
//不要用memcpy,复制的时候只是复制某一个区间,用循环更加灵活
for (int i = left; i <= right; ++i)
a[i] = arr[i];
}
}
void MergeSort(int* a, int n)
{
int* arr = (int*)malloc(sizeof(int)*n);
if (arr == NULL)
{
printf("malloc fail\n");
exit(-1);
}
//将原数组的元素复制到新数组
_MergeSort(a, arr, 0, n - 1, n);
free(arr);
}
上面讲解的是归并排序的递归版本,那么同样可以用非递归来完成。
非递归的方法相当于是把递归中的每一段小区间放在一起进行归并,然后通过维护变量gap来改变小区间的长度,直到左右元素排序完成。
非递归的时候要注意三种特殊情况:
(1) 第一种是归并的时候第二个子区间不存在,第一个子区间为gap个,那么不需要归并,因为这个子区间在上一个gap已经排序好了。
(2) 第二种是第二个子区间不存在,第一个子区间不够gap个,那么也不需要归并。
这两种方案可以总结为都不需要归并,直接跳出循环。
(3) 第三种是第二个子区间存在但是不够gap个,那么需要调整最后的begin2,否则会越界,将begin2调整为数组长度减一即可。
void MergeSort_NonR(int* a, int n)
{
int* arr = (int*)malloc(sizeof(int)*n);
if (arr == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
int i = 0;
while (gap < n)
{
//gap指的是要进行归并的两个子区间的间隔
for (i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1, begin2 = i + gap, end2 = i + 2 * gap - 1;
//对于非递归的归并,有三种情况需要注意
//第一种是归并的时候第二个子区间不存在,第一个子区间为gap个,那么不需要归并,因为这个子区间在上一个gap已经排序好了
//第二种是第二个子区间不存在,第一个子区间不够gap个,那么也不需要归并
//总结起来就是第二个子区间不存在的话,都不需要归并
if (begin2 >= n)
break;
//第三种是第二个子区间存在但是不够gap个,那么需要调整最后的begin2,否则会越界,将begin2调整为数组长度即可
//走到这说明存在第二个子区间
if (end2 >= n)
end2 = n - 1;
MergePartSort(a, arr, begin1, end1, begin2, end2);
}
gap *= 2;
}
//将开辟的空间释放
free(arr);
}
归并排序的特性:
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
非比较排序
非比较排序中应用最多的是计数排序,在OJ题中使用的非常多。
计数排序的步骤:
(1) 统计相同元素出现的次数
(2) 根据统计的结果将数据放回到原数列中。
计数数组如果是排序正整数数组不需要改变下标,但是如果数组中有负数的话,就需要改变一下下标所对应的值,也就是说,要改变下标表示的范围。
比如要排序 -100 ~ 100范围的数,那么就需要开辟一个长度为201的数组,也就是将下标加上100,使得下标表示的范围变成0 ~ 200,然后还原的时候再减去100。
//计数排序
void CountSort(int* a, int n)
{
int i = 0;
//可以对空间复杂度做一个优化,开辟长度为数组中最大元素的计数数组
int max = 0;
for (i = 0; i < n; i++)
{
if (a[i] > max)
max = a[i];
}
int* count = (int*)malloc(sizeof(int)*(max + 1));
if (count == NULL)
{
printf("malloc fail\n");
exit(-1);
}
memset(count, 0, sizeof(int)*(max + 1));
//统计数组中元素出现的次数
for (i = 0; i < n; i++)
count[a[i]]++;
int index = 0;
//将count数组中的元素放回原数组
for (i = 0; i < max + 1; i++)
{
while (count[i]--)
{
a[index++] = i;
}
}
free(count);
}
计数排序的特性:
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
- 时间复杂度:O(MAX(N,范围))
- 空间复杂度:O(范围)
- 稳定性:稳定