排序在日常生活中十分重要,购物平台上商品的排序,各国高校等级的排序......可以说,现代生活中已经离不开排序了;因此学好排序算法至关重要,本篇文章就来讲讲常见的排序算法
排序的种类非常多,按照种类划分,有插入排序,选择排序,交换排序......,而每种排序中又分多种排序,下图是常见的排序算法
1.插入排序
1.1直接插入排序
算法思想:
假设数组中一个区间[0,end]中的数据有序了,插入end+1位置的数据,如何保持数据依然有序?
- 将end+1位置的数据从后往前,依次与前面的数据比较,如果小于比较的数据,则将比较过的数据往后挪,直到找到小于它的数据或者找到头了;再在停下来的下一个位置插入数据
//单趟排序
int i = 0;
int end;
int tmp = a[end + 1];
for (i = end + 1; i > 0; i--)
{
if (tmp < a[i - 1])
a[i] = a[i - 1];
else
break;
}
a[i] = tmp;
注意:以后我们写有多趟逻辑的代码时,建议先写出单趟的逻辑,再加上整体的逻辑
上面是单趟排序,整体的排序,相当于依次对[0,0],[0,1]......,[0,n-1]每个区间都进行一次单趟排序
void InsertSort(int* a, int n)
{
for (int j = 0; j < n - 1; j++)
{
//单趟排序
int i = 0;
int end = j;
int tmp = a[end + 1];
for (i = end + 1; i > 0; i--)
{
if (tmp < a[i - 1])
a[i] = a[i - 1];
else
break;
}
a[i] = tmp;
}
}
复杂度分析:
- 最好的情况:原数据有序或接近于有序,时间复杂度为
最坏的情况:元数据无序或接近于无序,时间复杂度为- 空间复杂度:
1.2希尔排序
直接在数据有序或接近于有序的情况下效率是非常高的;但我们是不知道数据到底是怎么排序的,那能不能让数据先变成有序或接近于有序,再使用直接插入排序?这就是希尔排序的核心思想
算法思想:
希尔排序中,定义了一个间距gap,假设一开始gap为3,从第一个数据开始,将间距为gap的分为一组,一共有gap组
对每组分别使用直接插入排序,将每组排成有序,这样整体接近有序,再降低gap的值,重复操作,让数据更接近于有序,直到最后一次gap为1,此时就相当于直接插入排序了
首先是排一组中单趟的数据:
int i = 0;
int end;
int tmp = a[end + gap];
for (i = end + gap; i > gap - 1; i -= gap)
{
if (tmp < a[i - gap])
a[i] = a[i - gap];
else
break;
}
a[i] = tmp;
再将一组排好
for (int j = 0; j < n - gap; j += gap)
{
int i = 0;
int end = j;
int tmp = a[end + gap];
for (i = end + gap; i > gap - 1; i -= gap)
{
if (tmp < a[i - gap])
a[i] = a[i - gap];
else
break;
}
a[i] = tmp;
}
j<n-gap的原因同样是防止越界
排完第一组还要排后面的组,因此以gap为3的整体代码如下
int gap = 3;
for (int z = 0; z < gap; z++)
{
for (int j = z; j < n - gap; j += gap)
{
int i = 0;
int end = j;
int tmp = a[end + gap];
for (i = end + gap; i > gap - 1; i -= gap)
{
if (tmp < a[i - gap])
a[i] = a[i - gap];
else
break;
}
a[i] = tmp;
}
}
这个代码套了三层循环,其实可以优化一下
int gap = 3;
for (int j = 0; j < n - gap; j++)
{
int i = 0;
int end = j;
int tmp = a[end + gap];
for (i = end + gap; i > gap - 1; i -= gap)
{
if (tmp < a[i - gap])
a[i] = a[i - gap];
else
break;
}
a[i] = tmp;
}
如何理解呢?该代码是先将每组的前两个数据排好,再排每组的前三个数据......直到排好每组的最后一个数据
现在,我们gap组都排好了,需要减小gap的值,重复操作,并且保证最后一次排序gap为1
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 0)
{
gap = gap / 2;
for (int j = 0; j < n - gap; j++)
{
int i = 0;
int end = j;
int tmp = a[end + gap];
for (i = end + gap; i > gap - 1; i -= gap)
{
if (tmp < a[i - gap])
a[i] = a[i - gap];
else
break;
}
a[i] = tmp;
}
}
}
怎么来确定gap的值呢?
我们发现,gap的值越大,大的数据跳到后面越快,小的数据跳到前面越快;gap越小,大的数据跳到后面越慢,小的数据跳到前面越慢
怎么取gap的值才最合适呢?其实也没有一个标准的说法,最关键的是你得保证最后一次排序gap的值为1
复杂度分析:
时间复杂度:
空间复杂度:
2.选择排序
2.1选择排序
算法思想:
遍历数据,选出最大的和最小的,换到尾和头,再选出次大的和次小的......
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
//单趟排序
int mini = begin;
int maxi = begin;
for (int i = begin; i <= end; i++)
{
if (a[i] < a[mini])
mini = i;
if(a[i] > a[maxi])
maxi = i;
}
Swap(&a[begin], &a[mini]);
if (maxi == begin)
maxi = mini;
Swap(&a[end], &a[maxi]);
begin++;
end--;
}
}
复杂度分析:
- 时间复杂度:
最好的情况:
最坏的情况:- 空间复杂度:
2.2堆排序
堆排序的讲解在这篇文章中堆排序(详解)-CSDN博客
这里就不再花时间细说了
复杂度分析:
- 时间复杂度:
- 空间复杂度:
3.交换排序
3.1冒泡排序
算法思想:
每一趟将一个数放到它应该在的位置,N个数需要进行N-1趟;由于该排序较简单,这里也不细讲了
void BubbleSort(int* a, int n)
{
for (int j = 0; j < n - 1; j++)
{
int exchange = 0;
for (int i = 0; i < n - 1 - j; i++)
{
if (a[i] > a[i + 1])
Swap(&a[i], &a[i + 1]);
exchange = 1;
}
if (exchange == 0)
break;
}
}
复杂度分析:
- 时间复杂度:
最好的情况:
最坏的情况:- 空间复杂度:
3.2快速排序
算法思想:
如果一个数的左边的数都比它小,右边的数都比它大,那么这个数是不是就在它应该在的位置;快速排序的单趟排序就是将一个数变成具有上述性质;再去递归该数的左区间和右区间
快速排序的单趟排序有三种版本
第一种:hoare版
- 随便选择一个数作为要调的整数,记为key;right从右往左找比key小的数,left从左往右找比key大的数,一旦找到就停下来,交换left和right位置的数,直到left和right相遇
//单趟排序
int left = begin;
int right = end;
int keyi = left;//keyi是key的下标
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
- 为什么判断大小时要加等号?
我们一开始的key在left的位置,如果不加等于,第一次交换会改变key的值 - 为什么判断的时候要left<right?
在找的过程中可能left超过了right,此时应当终止循环 - 为什么是右边先走?
右边先走停下来的情况有两种:1)遇到比key小的数;2)和begin相等了
由于right先走了,所以下次right走时,left位置的数一定是比key小的
也就是说right停下来的位置的数一定是比key要小的,交换key和left与right相遇的位置,key左边就都比它小,右边都比它大了
如果是left先走,left和right相遇时,相遇的位置的数可能比key大,此时和key交换的话,就不符合我们的要求了,因此右边需要先走
此时数据被分成了三个区间[begin,keyi-1]keyi[keyi+1,end],我们再对[begin,keyi-1]和[keyi,end]区间递归,如果begin>=end就直接返回
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
int left = begin;
int right = end;
int keyi = left;//keyi是key的下标
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
第二种:挖坑法
将随便一个位置的数记录为key,这里就选开始位置的数,作为一个坑位;同样的右边先走,找比key小的数,不同的是,这时找到了就把那个数放到刚才的坑位,留下了另一个坑位;再左边找比key大的数,找到了交换到上一个坑位,留下一个坑位......直到left和right相遇,将key给到上一个坑位
int PartSort2(int* a, int begin, int end)
{
int left = begin;
int right = end;
int keyi = left;
int key = a[left];
while (left < right)
{
while (left < right && a[right] >= key)
{
right--;
}
a[left] = a[right];
while (left < right && a[left] <= key)
{
left++;
}
a[right] = a[left];
}
a[left] = key;
keyi = left;
return keyi;
}
第三种:前后指针法
定义两个指针prev和cur和key;cur去遍历数据,如果cur位置的数大于key,cur++;如果cur位置的数小于key,prev++之后交换perv和cur位置的数,直到cur走到尾;再交换perv和key的值
该方法的本质是让prev和cur错开,让它们之间的数都是比key大的数,再将比key小的数与prev和cur中间的数交换,相当于把中间的数往后挪
int PartSort3(int* a, int begin, int end)
{
int prev = begin;
int cur = begin + 1;
int keyi = begin;
int key = a[begin];
while (cur <= end)
{
if (a[cur] < key)
{
prev++;
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
return prev;
}
3.2.1快排的优化
优化一:三数取中
由于快排是递归进行排序的,如果每次key是中间数,那么需要递归的层数是层;如果数据有序或接近于有序,递归的层数就会接近N层,效率大大降低,还可能会导致栈溢出,因此我们添加一个三数取中算法,确保每次key取到的不是最大或最小数
int GetMidi(int* a, int begin, int end)
{
int midi = (begin + end) / 2;
if (a[begin] < a[end])
{
if (a[midi] < a[begin])
return begin;
else if (a[end] < a[midi])
return end;
else
return midi;
}
else
{
if (a[midi] < a[end])
return end;
else if (a[begin] < a[midi])
return begin;
else
return midi;
}
}
优化二:部分递归换直接插入排序
如果只有10个数,用递归去排序是不是显得很繁琐,因为递归还要建立栈帧,这时可以考虑用其他排序,我们选择了直接插入排序
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
if (end - begin + 1 <= 10)
{
InsertSort(a + begin, end - begin + 1);
}
else
{
//int keyi = PartSort1(a, begin, end);
//int keyi = PartSort2(a, begin, end);
int keyi = PartSort3(a, begin, end);
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
}
复杂度分析:
- 时间复杂度:
- 空间复杂度:
3.3非递归的快速排序
非递归的快排本质是将要排序的区间存到一个栈中,选一种单趟排序,排完后数据分成了三部分,将右区间和左区间入栈,进行排序,直到栈为空
void QuickSortNonR(int* a, int begin, int end)
{
Stack st;
StackInit(&st);
StackPush(&st, end);
StackPush(&st, begin);
while (!StackEmpty(&st))
{
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
int keyi = PartSort3(a, left, right);
//[left,keyi-1]keyi[keyi+1,right]
if (keyi + 1 < right)
{
StackPush(&st, right);
StackPush(&st, keyi + 1);
}
if (left < keyi - 1)
{
StackPush(&st, keyi - 1);
StackPush(&st, left);
}
}
StackDestroy(&st);
}
4.归并排序
4.1 归并排序
算法思想:
如果一串数据中左边一部分有序了,右边一部分也有序了,那么把整体弄成有序?这就是合并两个有序数组的问题了
那怎么让左边和右边有序呢?将左边数据也弄成两部分,只要这两部分有序,再对整体使用合并算法,整体就有序了,右边也是同理
像这样一直分,直到两部分都只有一个数据,此时每部分相当于有序,合并后返回;也就是说,归并排序也是用递归来实现的
void _MergeSort(int* a, int* tmp, int begin, int end)
{
if (begin >= end)
return;
int midi = (begin + end) / 2;
_MergeSort(a, tmp, begin, midi);
_MergeSort(a, tmp, midi + 1, end);
//归并
int begin1 = begin, begin2 = midi + 1;
int end1 = midi, 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, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("MergeSort:malloc fail");
exit(-1);
}
_MergeSort(a, tmp, 0, n - 1);
free(tmp);
}
复杂度分析:
- 时间复杂度:
- 空间复杂度:
4.2 非递归的归并排序
前面的快速排序的非递归我们使用了栈来模拟实现,能不能用栈来模拟实现归并排序的非递归呢?
对于快速排序的非递归,先选出key,排完一趟后,key所在的位置就是它应当在的位置,不需要再动了,再将左区间和右区间入栈
根据快速排序的思想,可以知道,快排的非递归能用栈实现关键在于每次都能排好一个数,不需要回到上一个区间;而如果归并排序的非递归用栈模拟实现,每次想让取出的区间有序,首先得该区间的两个子区间有序,因此让子区间入栈......直到子区间的长度为1,虽然这时子区间是有序的,但上一个区间已经找不到了,不能返回到上一次区间进行归并;因此,归并排序的非递归不能用栈模拟实现
拿下面的数据举例
考虑到上面的问题,我们归并排序排序的非递归不能用栈模拟实现
正确的思路应该是逆向归并,想让整体有序,得子区间有序,那么我们就先归并子区间;一个数据是有序的,那么我们就从一个数据开始归并,再增加归并数据的个数
//归并排序(非递归)
void MergeSortNonR(int* a, int n)
{
int* temp = (int*)malloc(sizeof(int) * n);
if (temp == NULL)
{
perror("MergeSortNonR:malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
int j = 0;
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
//归并
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
temp[j++] = a[begin1++];
else
temp[j++] = a[begin2++];
}
while (begin1 <= end1)
{
temp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
temp[j++] = a[begin2++];
}
//拷贝回原数组
memcpy(a + i, temp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
free(temp);
}
对于上面的数据,该代码能够完成排序,因此数据个数是2的次方;对于数据个数不是2的次方,该代码存在越界的问题
需要对这些越界的地方进行处理
- 对于begin1,由于begin1<n,因此不可能发生越界
- 对于end1,有可能越界,此时数据不需要处理,跳出循环即可
- 对于begin2,同end1同理,直接跳出循环即可
- 对于end2,越界后需要改变end2的位置,应当指向最后一个元素
//归并排序(非递归)
void MergeSortNonR(int* a, int n)
{
int* temp = (int*)malloc(sizeof(int) * n);
if (temp == NULL)
{
perror("MergeSortNonR:malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
//printf("gap=%d--->", gap);
int j = 0;
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
//处理越界的情况
if (end1 >= n || begin2 >= n)
break;
if (end2 >= n)
end2 = n - 1;
//printf("[%d,%d]", begin1, end1);
//printf("[%d,%d]", begin2, end2);
//归并
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
temp[j++] = a[begin1++];
else
temp[j++] = a[begin2++];
}
while (begin1 <= end1)
{
temp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
temp[j++] = a[begin2++];
}
//拷贝回原数组
memcpy(a + i, temp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
//printf("\n");
}
free(temp);
}
复杂度分析:
- 时间复杂度:
- 空间复杂度:
5.计数排序
算法思想:
首先遍历一遍数组,找出最大值和最小值,开一个0~最大值个数据的空间,记为count;count数组中的下标表示数据元素,count每个下标对应的值表示元素出现的个数;这样每个元素和它出现的次数就都知道了
但是,该方法有些缺陷,如果我们的数据是1000~某个数,那么开辟的count数组前1000个空间就浪费了,因此我们开辟的count数组的范围是(最大值-最小值+1),而计算count数组时,将a数组每个元素减去最小值后再计算count数组;排序的时候再加上最小值即可
在计算count数组时,为什么要减去最小值?
- 为了防止空间的浪费,我们count数组开辟的范围是(最大值-最小值+1),将a数组每个元素-最小值后,a数组中每个元素都能对应count数组中的一个下标,这种不直接将数值对应count数组的下标,而是减去某个值再去对应count数组的下标,我们叫做相对映射
- 相对映射的好处是不仅能节约空间,还能处理a数组出现负数的情况
void CountSort(int* a, int n)
{
int max = a[0];
int min = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] > max)
max = a[i];
if (a[i] < min)
min = a[i];
}
int* count = (int*)calloc((max - min + 1), sizeof(int));
if (count == NULL)
{
perror("CountSort:calloc fail");
return;
}
//count数组
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int j = 0;
for (int i = 0; i < max - min + 1; i++)
{
while (count[i]--)
{
a[j++] = i + min;
}
}
free(count);
}
计数排序的局限性:
- 计数排序适合处理值比较集中的数据,对于值比较分散的数据,即使使用了相对映射的思路,还是会有一定浪费的空间
- 不适合浮点数,结构体数据的排序,只适合整型数据的排序
复杂度分析:
- 时间复杂度:(效率极高)
- 空间复杂度:
总结:
常见的排序算法就是这些,需要本篇文章的源码可以去我的Gitee主页查看!Sort/Sort · baiyahua/LeetCode - 码云 - 开源中国 (gitee.com)