我们在算法题进行练习提升时,经常会看到题目要求数据从大到小输出,从小到大输出,前一半从小到大输出,后一半从大到小输出等,那么这时候就需要用到排序算法,通过排序算法将数据按照一定的顺序进行排序。本文将从易到难介绍以下常见的排序算法:
冒泡排序
冒泡排序相比各位都不陌生,它是最基础的交换排序,从名得知,冒泡排序中每一个元素都可以向气泡一样,根据上方的气泡大小一点一点进行移动
动图演示:
要实现冒泡排序,我们需要确定待排元素的下标,然后从待排元素的下标+1位置依次比较
假设有五个数据:
第一步:第一个元素要和后面的四个数据比较,比较完成后第一个元素的排序完成
第二步:第二个元素要和后面的三个元素比较,比较完成后第二个元素的排序完成
第三步:第三个元素要和后面的两个元素比较,比较完成后第三个元素的排序完成
第四步:第四个元素要和后面的一个元素比较,比较完成后第四个元素的排序完成
第五步:第五个元素是最后一个元素,经过上面四次排序后,第五个元素的排序也完成了
在这里我们可以设置两个循环来实现:外循环控制当前要排序的元素下标,内循环控制当前下标要比较的次数
五个元素,下标为0-4,当下标为1的元素进行排序时,需要和后面的元素比较三次
因此内循环的次数 j 和下标 i 的关系为:j<n-i-1
cpp
void BubbleSort(int* arr, int n) {
for (int i = 0; i < n; i++) {
int exchange = 0;
//如果后面都是有序的,那么就不用再继续循环了,也是对冒泡排序的一个小优化
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] < arr[j + 1]) {
Swap(&arr[j], &arr[j + 1]);
exchange = 1;
}
}
if (exchange == 0) {
break;
}
}
}
直接插入排序
直接插入算法是简单的插入排序,算法思想是:将带排序的数据插入到一个已经排序好的序列中,让该序列成为一个新的有序序列
动图演示:
这就跟我们玩扑克牌一样,在抓了N张牌后,手中的牌基本上都是乱序的,我们每个人都会按照各自的习惯将手中最左侧或者最右侧放置的是抓取N张牌中最小的牌,每次从当前位置每次将相邻的一张牌进行调整,直到手上的牌是顺序的。
假设下列乱序数组进行直接插入排序:
第一次调整时,第一个元素3自身形成一个个数为1的有序序列,记录有序序列往下一个元素,此时该元素为2,如果 有序序列往下一个元素比有序序列中最大的数小,那么该元素需要插入到有序序列中形成新的有序序列
插入后数组元素排序如下:
插入的思想简单明了,那么插入的逻辑呢,它是怎么实现的呢?
待排元素如果需要插入有序序列中,那么该元素需要和有序序列中的元素从大到小一一比较,直到找到比待插元素小的元素,在比待插元素小的下一个位置进行插入,那么有序序列的最大元素和比待插元素大的元素都需要往后挪动一位,腾出位置让待插元素插入
因为我们已经记录了有序序列的下一个元素,所以有序序列向后挪动的过程中,可以通过覆盖的方式实现,即arr[end+1]=arr[end],end记录的是有序序列的最大下标。
覆盖:
插入:
通过不断遍历剩下待排元素,将数组变成一个有序序列
cpp
//直接插入排序
void InsertSort(int* arr, int n) {
for (int i = 0; i < n - 1; i++) {
int end = i;
//tmp指向end的下一个元素,并记录
int tmp = arr[end + 1];
while (end >= 0) {
//后比前大,将end覆盖end的后一位,循环覆盖,直到end出循环,
//出循环end的位置是-1,因为如果待排元素比当前有序序列的最小元素还小
//+1后存放tmp
if (arr[end] > tmp) {
arr[end+1] = arr[end];
end--;
}
//待插入元素比有序序列最大的数大,不需要插入,此次while循环直接退出
else {
break;
}
}
arr[end + 1] = tmp;
}
}
while循环的条件图解:
根据代码可以看出,元素集合越接近有序,直接插入排序算法的时间效率就越高
但是如果一个元素集合是倒序的,那么此时时间复杂度达到最大,为O(N^2);
希尔排序
希尔排序是在直接插入排序算法的基础上改进而来,它的效率是优于直接插入排序的。
希尔排序的基本思想是:
选定一个整数gap(一般是gap=n/3+1),把待排序文件所有记录进行分组,所有距离相等的记录分在同一组内,对每一组内的记录进行排序,然后gap=gap/3+1,再将数组分成各组,进行插入排序,当gap=1时,相当于直接插入排序。
上面的话非常隐晦难懂,我们直接上图:
在gap>1时,对数组进行预排序(其实就是带有间隔的直接插入排序)
在gap=1时,对数组进行直接插入排序
gap的值为N,那么当前数组就要分成N组,每组N/gap个数据
希尔排序的代码和直接插入排序算法的代码十分相似:
cpp
//希尔排序
void ShellSort(int* arr, int n) {
int gap = n;
while (gap > 1) {
gap = gap / 3 + 1;
//与直接插入排序相似,数据只是间隔开
for (int i = 0;i<n-gap; i++) {
int end = i;
int tmp = arr[end + gap];
while (end >= 0) {
if (arr[end] < tmp) {
arr[end + gap] = arr[end];
end = end - gap;//超出范围跳出循环,将要调换的数进行调换
}
else {
break;
}
}
arr[end + gap] = tmp;
}
}
}
希尔排序的时间复杂度为O(N^1.3),相对直接插入排序的时间效率有了提高。
直接选择排序
直接选择排序基本思想:在待排序列中选出最大或最小的元素,存放在序列的起始位置,直到全部带排序的元素排完
1.假设有N个元素待排,将第一个元素与后面的N-1个元素比较,找到N-1个元素中比第一个元素小(大)的最小(最大元素),将第一个元素与选择到的元素相交换
2.在剩下的集合中,重复上述操作,直到集合剩一个元素
cpp
//直接选择排序(找最大值或最小值)
void _SelectSort(int* arr, int n) {
for (int i = 0; i < n; i++) {
//假设第i元素为最大值,记录下标
int max = i;
for (int j = i + 1; j < n; j++) {
//在循环中不断更新坐标
if (arr[j] > arr[max]) {
max = j;
}
}
//值交换
Swap(&arr[i], &arr[max]);
}
}
当然,我们也可以对上述排序进行改进:同时找最大和最小值,采用双指针
cpp
//直接选择排序(同时找最大值和最小值)
void SelectSort(int* arr, int n) {
int begin = 0;
int end = n-1;
while (begin < end) {
int maxi = begin;
int mini = begin;
for (int i = begin + 1; i <= end; i++) {
if (arr[i] < arr[mini]) {
mini = i;
}
if (arr[i] > arr[maxi]) {
maxi = i;
}
}
//9 3 2 经过两次交换后还是9 3 2 我们需要 2 3 9
//特殊情况
if (maxi == begin) {
maxi = mini;
}
Swap(&arr[mini], &arr[begin]);
Swap(&arr[maxi], &arr[end]);
++begin;
--end;
}
}
直接选择排序的思路非常简单,但是它的效率并不高,时间复杂度达到了O(N^2)
堆排序
堆排序是利用数据结构------堆来设计的一种排序算法。
堆的底层结构就是数组,因此数组排序可以使用堆排序
在上文中详细解释了堆 这种数据结构和向下调整算法和向上调整算法,以及根据排序建堆的种类,在这里不再过多赘述。
只上一个例子的图解:
cpp
//向下调整算法(小堆)
void AdjustDowm(int* arr, int parent, int n) {
//找最小子结点
int child = parent * 2 + 1;//求出的是左孩子的索引
while (child < n) {
//右孩子存在且右孩子比左孩子小
if (child + 1<n && arr[child] > arr[child + 1]) {//n为数组长度,那么有效索引为n-1
child++;
}
if (arr[child] < arr[parent]) {
Swap(&arr[child], &arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else {
break;
}
}
}
//堆排序
void HeapSort(int* arr, int n) {
//先建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--) {//int i = (n - 1 - 1) / 2 求到的是一个父结点
AdjustDowm(arr, i, n);
}
int end = n - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
AdjustDowm(arr, 0, end);
end--;
}
}
快速排序
快速排序的基本思想是:
1.取任意待排元素为基准值,将待排元素序列分成两个序列,右边序列的元素全比基准值大,左边序列元素全比基准值小
2.将左右序列在各自区域中再取基准值,再将一个序列分成两个序列,左右子序列重复该过程,直到所有元素排列在相应位置上
看到这幅图时,相信你会联想到二叉树,是的,这里和二叉树一样用到了遍历的思想
对于一个数组,有着左区间和右区间,根据遍历的思想:
cpp
void QuickSort(int* arr, int left,int right) {
//递归结束条件
if (left >= right) {
return;
}
int keyi = _QuickSort(arr, left, right);//找当前区域的基准值
//左序列均小于基准值,右序列均大于基准值
//左右序列再找基准值,重复该过程
//类似二叉树左右子树遍历
QuickSort(arr, left, keyi - 1);
QuickSort(arr, keyi + 1, right);
}
显然,找基准值在快速排序中成为了最重要的问题
1.hoare版本
算法思路:
1.以序列最左侧为假基准值
2.创建左右指针,左指针指向假基准值+1位置,右指针指向当前序列最大下标处
3.左指针向右找比假基准值大的数,右指针向左找比假基准值小的数
4.当左右指针找到对应的数据时,若左指针指向位置小于等于右指针指向位置,则对应数据交换位置,随后左指针和右指针更新位置
5.当左指针指向位置大于右指针时,将右指针所指向位置数据与假基准值数据向交换,此时右指针所指向位置的数据就是该区间的真正基准值
图解:
cpp
int _QuickSort(int* arr, int left, int right) {
//1.以待排序列最左侧为假基准值,left指针指向假基准值+1,right指向序列最右侧
int keyi = left;
++left;
while (left <= right) {
//2.left向右寻找比假基准值大的数,right向左寻找比假基准值小的数
//3.left和right交换,交换后left++,right--,直到left>right停下
while (left <= right && arr[left] < arr[keyi]) {
++left;
}
while (left <= right && arr[right] > arr[keyi]) {
--right;
}
if (left <= right) {
Swap(&arr[left], &arr[right]);
++left;
--right;
}
}
//4.将假基准值和right指向的元素向交换,此时right指向的就是这个序列真正的基准值
Swap(&arr[right], &arr[keyi]);
return right;
}
2.挖坑法
算法思路:
1.创建左右指针,左指针指向当前序列最左侧,右指针指向当前序列最右侧
2.将第一个数据存放在临时变量key中,形成第一个坑位,同时将第一个数据作为基准值
注意:左指针指向的位置不能大于右指针
3.右指针从右向左找比基准值小的数据,找到后将数据放在坑中,随后当前位置更新成新的坑位
4.左指针从左到右找比基准值大的数据,找到后将数据放在坑中,随后当前位置更新成新的坑位
5.在最后一个坑位,将临时变量key中的数据存放在坑中,此时的坑位就是基准值的位置
图示:
cpp
//挖坑法
int _QuickSort2(int* arr, int left, int right) {
//先将第一个元素保存在key中,形成第一个坑位
//left找比key大,right找比key小
int min = arr[left];
int hole = left;
int key = arr[hole];
while (left < right) {
while (left < right && arr[right] >= key) {
--right;
}
arr[hole] = arr[right];
hole = right;
while (left < right && arr[left] <= key) {
++left;
}
arr[hole] = arr[left];
hole = left;
}
arr[hole] = key;
return hole;
}
3.lomuto前后指针
算法思路:
1.以第一个元素为基准值,变量key指向第一个元素,设置变量prve指向基准值,变量cur指向prve下一个位置
2.cur从左往右找比基准值小的数,如果arr[cur]<arr[key],prve向后走一步,与cur交换,如果arr[cur]>arr[key],cur继续往后走
3.最后key的值和prve的值交换,此时prve指向的位置的数据为基准值
cpp
//lomuto前后指针法
//创建前后指针,从左往右找比基准值小的进行交换,使小的都排在基准值左边
int _QuickSort3(int* arr, int left, int right) {
int key = left;
int prve = left;
int cur = prve + 1;
while (cur<=right) {
if (arr[key] > arr[cur]&&++prve!=cur) {
Swap(&arr[cur], &arr[prve]);
}
++cur;
}
Swap(&arr[key], &arr[prve]);
return prve;
}
非递归排序
借助数据结构------栈,我们可以实现非递归排序
非递归排序,可以说是将快速排序中的递归借由栈来实现
通过在栈中确定区间,入栈两次出栈一次再入栈两次求得新的区间
cpp
//非递归版本快排----借助数据结构栈
void QuickSortNonR(int* arr, int left, int right) {
ST st;
STInit(&st);
STPush(&st,right);
STPush(&st,left);
while (!StackEmpty(&st)) {
int begin = STTop(&st);
STPop(&st);
int end = STTop(&st);
STPop(&st);
//在[begin,end]范围内找基准值
int key = begin;
int prve = begin;
int cur = prve + 1;
while (cur <= end) {
if (arr[key] > arr[cur] && ++prve != cur) {
Swap(&arr[cur], &arr[prve]);
}
++cur;
}
Swap(&arr[key], &arr[prve]);
key= prve;
//以基准值为中,分成两个区间
if (key + 1 < end) {
STPush(&st, end);
STPush(&st, key + 1);
}
if (key - 1 > begin) {
STPush(&st, begin);
STPush(&st, key - 1);
}
}
STDestroy(&st);
}
归并排序
归并排序是建立在归并操作上的一种有效的排序算法,通过将序列不断细分,再进行合并成一个有序序列
我们可以创建一个新的数组,数组的大小和原数组一致,该数组用来进行合并时两个区间该插入的数据,最后通过覆盖原数组得到排序序列
cpp
//建立在归并操作上的一种有效的排序算法。将已有的子序列合并,得到完全有序的序列
void _MergeSort(int*arr, int left, int right, int*tmp)
{
//类似二叉树遍历,区间递归到叶子结点,每一次递归都将原本的区间分为两个区间
if (left >= right) {
return;
}
int min = (right + left) / 2;
//[left,min],[min+1,right]
_MergeSort(arr, left, min, tmp);
_MergeSort(arr, min+1, right, tmp);
//遍历到最后,是叶子结点
int begin1 = left;
int end1 = min;
int begin2 = min + 1;
int end2 = right;
int index = begin1;
//对于每一个区间
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++];
}
//通过循环将新数组覆盖旧数组
for (int i = 0; i < right - left + 1; i++) {
arr[i] = tmp[i];
}
}
void MergenSort(int* arr, int n) {
//将一个数组分成多个区间,每个区间进行排序,再整合区间进行排序
//不在原数组上进行排序
int* tmp = (int*)calloc(n,sizeof(int));
int len = sizeof(arr) / sizeof(arr[0]);
_MergeSort(arr, 0, len-1, tmp);
free(tmp);
tmp = NULL;
}
非比较排序(计数排序)
计数排序是哈希定址法的变形应用
算法思想:
1.统计相同元素出现次数
2.根据统计结果将序列回收到原来的序列中
哈希定址:数组对应的下标为序列的数据,数组下标对应的元素是该数据出现的次数
但是该法只使用于整数,在数据范围集中时,效率很高,使用场景非常有限
假设有以下3个数据进行排序:1,3,9999,如果通过哈希定址法,就会造成大量的空间浪费
而对于比较集中的数据:10,14,17,18,我们也不需要开辟十八个空间,只需要开辟最大值-最小值+1个空间即可
cpp
void CountSort(int* arr, int n) {
//哈希定址法的变形应用
//1.计算相同元素出现的次数
//2.根据统计的结果将序列回收到原来的序列中
//找到最大值和最小值
int max = arr[0];
int min = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] > max){
max = arr[i];
}
if (arr[i] < min) {
min = arr[i];
}
}
//定址数组个数
int range = max - min + 1;
int* tmp = (int*)calloc(range, sizeof(int));
if (tmp == NULL) {
perror("calloc:");
}
//哈希定值
for (int i = 0; i < n; i++) {
tmp[arr[i]-min]++;
}
//排序
int j = 0;
for (int i = 0; i < range; i++) {
while (tmp[i]--) {
arr[j++] = i+min;
}
}
}