目录
一、插入类排序
1.1 直接插入排序
先借助一张图(下图排的是升序),我们来了解一下啥是直接插入排序
直接插入就是直接替换
如图,7>2,这里并不是将7和2的位置互换,而是将数据7直接覆盖2
当然为了我们不丢失原来的数据2,我们要创建临时变量tmp保存2的值。
那么,现在我带大家把上图的流程走一遍:
先创建一个变量end用于遍历,还要创建一个临时变量tmp来保存我们需要排的值。
一开始end,tmp分别指向4和7,满足升序。
满足升序,end就往后走。
4和7已经排序完成,接下来排2,故tmp走向2。
7>2,所以7直接替换2(也就相当于7往后走了一步),
这个时候end要再回退一步(因为我们还不确定2要排的正确位置在哪里,所以要将之前排好了的数据都比较一遍,直至找到正确位置)
此时end指向4,4>2,所以4直接替换原来旧的7的位置(就是arr[1]的位置)
由于4和7都排好了,end回退到了arr[-1]
那么2现在就是最小的数据,故排到arr[0]处
然后我们再让end回到正确的位置,以此类推,重复上述步骤
代码如下:
void InsertSort(int* arr, int n)//排升序
{
for (int i = 0; i < n - 1; i++)
{
int end = i;//先从第一个元素开始向后遍历
int tmp = arr[end + 1];
while (end >= 0)//每次往前走到第一个元素结束,说明没有数据比tmp小了
{
if (arr[end] > tmp)
{
//往后移
arr[end + 1] = arr[end];
end--;//往前走,继续比较前面的元素,看和tmp的大小关系
}
else
{
break;
}
}
//找到tmp应该插入的位置
arr[end + 1] = tmp;
}
}
其实,直接插入排序就是比较大小,然后找正确插入位置
1.2 希尔排序
先借助一张图,我们来了解一下啥是希尔排序
其实,希尔排序就是升级版的直接插入排序
因为希尔排序的底层逻辑就是直接插入排序,而与之不同的就是希尔排序多了分组这个步骤。
而正是因为多了分组,才让希尔排序的效率更高。
为什么分组让希尔排序的效率比直接插入排序高嘞?
我们拿上图来说,4,7,2,6,5分成了三组【4,6】【7,5】【2】,让每一组分别进行直接插入排序,易得【4,6】【5,7】【2】
这个时候我们再进行一次直接插入排序,显然比一开始就进行直接插入排序效率高的多
再来举个例子,一组数据9,1,2,5,7,4,8,6,3,5,再gap=3的情况下,分为了【9,5,8,5】【1,7,6】【2,4,3】这三组。
当我们对每一组直接插入排序后,得到【5,5,8,9】【1,6,7】【2,3,4】
那么如何分组呢?
我们用步幅(相当于一段差距)来划分组,用gap来表示步幅。
分组不能分太多也不能分太少,通常gap = n / 3 +1;
这里除以3,分出来的组数就刚刚好。+1是为了保证最后一次gap = 1.
因为gap = 1就代表着直接插入排序。
而希尔排序的最后一步一定得是直接插入排序。
接下来,我们来分析一下希尔排序的排序思路:
首先根据gap分组,然后在每个组内进行排序,但是这里是交叉排序。
也就是第一组的第一对数据比较完,接下来比较第二组的第一对数据,以此类推。
而不是说把第一组的全部数据都排好后,再去排第二组。
当每一组都排好之后,再对gap进行变化,然后重复上述操作,直至gap = 1。
gap = 1时再对所有的数据进行一次直接插入排序。
代码实现:
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)//控制gap组
{
//分组
gap = gap / 3 + 1;//保证最后一次gap一定为1,为1也就是说明进行直接插入排序
for (int i = 0; i < n - gap; i++)//控制组中的小组
{
//i++:第一小组的第一对数据比完,直接到第二小组的第一对
//i<n-tmp: 结束条件就是,tmp走到末尾元素arr[n-1]
int end = i;
int tmp = arr[end + gap];
while (end >= 0)//这个while循环的底层逻辑就是直接插入排序
{
if (arr[end] > tmp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
//走到这,说明某小组中的某对数据已经排好;然后i++
}
}
}
不难发现,希尔排序其实就是两步
①先分组预排序
②直接插入排序
二、选择类排序
2.1 直接选择排序
先借助一张图,我们来了解一下啥是直接排序
直接选择排序又叫做简单选择排序。
听着名字,又直接又简单的,那么真的很简单吗?
(在我看来,还不如快速排序当中的lomuto法好理解嘞。)
不过还是给大家说一下,直接选择排序的思路吧。
用一个变量去遍历,在一轮遍历中,我们能确定出本轮最大的数和最小的数。
将最大数放到末尾,最小的数放到开头
我们先把max和min都统一指向首元素,
拿一个变量从第二个元素开始往后遍历,去找比首元素大的值,以及比首元素小的值。
找到后,再将max和min分别指向对应的位置
一轮遍历后,我们肯定可以找到这一轮中最大的数和最小的数
然后将max和min分别放到尾元素和头元素的位置。也就是上图中的end和begin
再将end往前走,begin往后走,再确定下一轮中的最大最小数,以此类推。
代码实现:
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
void SelectSort(int* arr, 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++)//每一轮中都要找出此轮中最大的数和最小的数
{
//下标i开始往后遍历,i每往后走一步就比较两次
if (arr[i] > arr[maxi])
{
maxi = i;//maxi指向i此时所在位置
}
if (arr[i] < arr[mini])
{
mini = i;//mini指向i此时所在位置
}
}
//避免maxi 和 begin指向同一位置(begin和mini交换之后,maxi数据变成了最小的数据)
if (maxi == begin)
{
maxi = mini;
}
//一轮遍历结束,已找到此轮中最大和最小的数
//交换,把最大的数放到后面,把最小的数放在前面
Swap(&arr[mini], &arr[begin]);
Swap(&arr[maxi], &arr[end]);
//交换完,忽视已经排好的位置,故begin往后走,end往前走
++begin;
--end;
//继续进入大循环,开始下一轮的遍历
}
}
2.2 堆排序
在之前的博客中已详细讲解,可移步至:
三、交换类排序
3.1 冒泡排序
先借助一张图,我们来了解一下啥是冒泡排序
说白了,冒泡排序就是大循环套着小循环
那这两个循环分别是啥意思嘞?
先来说小循环,小循环就是每两个数据进行比较。
如图,第一次小循环里面就是,先3,5比较,5,8比较,再8,2比较。
若在小循环里面发生交换,那么就拿交换后的数据,与其紧挨着的后一个数据进行比较。
一趟小循环结束后,每两个紧挨着的数据是有序的,并且这趟循环中最大的数据会出现在末尾位置。
然后,再重头来一遍小循环,直至排序完成。
所谓大循环就是控制一个又一次的发生小循环。
我们知道每次小循环结束后都会找到最大的数据放到最后,
但是每次循环我们又得要忽视掉上一次循环的最后一个位置,如此才能按照一定的顺序排好数据。
以此类推,当大循环结束时,就代表着所有小循环已经完成,数据已经排好
代码实现:
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++)
{
//每一小趟结束后都会排出一个最大的,所以到下一次小趟的时候就不用再比较最后一个元素
//故n-i(最后一个元素的下标)之后还要-1
if (arr[j] < arr[j + 1])
{
exchange = 1;
Swap(&arr[j], &arr[j + 1]);
}
}
if (exchange == 0)
{
break;//如果在一小趟中没有发生交换,说明已经排好了,直接退出
}
}
}
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
简单来说,冒泡排序就是循环两两对比
3.2 快速排序
快速排序,顾名思义它的排序效率一定是最高的。
快速排序的核心就是找基准值的正确位置。
最终使得基准值左侧的序列中的每个值都小于基准值,右侧序列中的每个值都大于基准值。
通常将首元素视为基准值
找基准值有三种方法:
①hoares法
②挖坑法
③lomuto前后指针法
我依次来给大家介绍这三种方法:建议先掌握lomuto前后指针法
hoare法
hoare法就是左边和右边同时开始遍历。
从右往左找比基准值小的数;从左往右找比基准值大的数。
当我们把这两个数都找到之后,再把这两个数进行交换,这样小的就会换到左边,大的就会换到右边。
交换完成后再继续朝着各自的方向遍历,直至交叉越过。
此时right所在的位置就是基准值所在的正确位置,二者一交换,便可让基准值在正确的位置。
每一轮都会找到基准值的正确位置。
然后就是递归的思想了,让基准值的左侧区间继续找其基准值,让其右区间找其基准值。
因为每一轮都会确定下来基准值的位置,当所有轮结束,序列也就排序好了
代码实现:
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
//hoare法
int _QuickSort01(int* arr, int left, int right)
{
int keyi = left;//通常把首元素看作基准值
++left;//从基准值的下一个元素开始比较
//找到基准值的应该所在的正确位置,确保基准值的左边都是小的,右边都是大的
while (left <= right)//=是为了,避免left和right相遇的位置的值比基准值要大,然后把大的值放到了左边
{
//先right从右往左找比基准值小的值
while (left <= right && arr[right] > arr[keyi])
{
right--;//没找到往前走,继续找
}
//再left从左往右找比基准值大的值
while (left <= right && arr[left] > arr[keyi])
{
left++;//没找到往后走,继续找
}
//找到了大值or小值
if (left <= right)//并且第一轮完还没有走完,就先让它们交换,交换完,继续走
{
Swap(&arr[left++], &arr[right--]);//小的值换到左边去,大的值换到右边去
}
}
//找到基准值的正确位置
Swap(&arr[keyi], &arr[right]);
return right;
}
挖坑法
挖坑法就是不断的挖坑然后填坑,最后一个坑就是基准值所在的位置。
对于挖坑法和hoare法,其实我感觉啊大差不差。
最大的区别就是hoare法是左右两边同时遍历,而挖坑法则是有顺序的,先右边找基准值小的,找到之后,再从左边找比基准值大的,然后再从右边找(即右、左、右、左……的次序)。
好了,现在我们具体来讲一下怎么挖坑填坑。
我们要创建一个变量保存基准值(相当于在一开始的时候就在最前面挖了一个坑,不过坑里面的基准值被我们事先保存了下来)。
然后再从右边找到比基准值小的数,找到时,再将这个数填到坑(旧坑)里面,再在找到这个数的原来位置上挖一个坑,以此类推。
由于左边右边不是同时进行的,而是带有次序的,所以在结束的时候必然是左边和右边相遇。
左右相遇的位置就是基准值应该在的正确位置。
代码实现:
//挖坑法
int _QuickSort02(int* arr, int left, int right)
{
int hole = left;//一开始把坑放到最左边
int key = arr[hole];//保存基准值
//找基准值的位置
while (left < right)
{
//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;
}
lomuto前后指针法
前后指针法就是有两个指针,一前一后,后面的指指针再和前面的指针交换。
前后指针法在我看来是最简单的方法,并且相较于hoare法和挖坑法来说,代码的实现也更简洁。
lomuto法的第一步仍旧是保存基准值,
准备两个变量cur(相当于后指针)和prev(相当于前指针),
cur从基准值的下一个数据开始往后遍历,prev指向基准值(也就是头元素),这样刚好就是一前一后。
cur往后遍历找比基准值小的数据,找到之后,先让prev往后走一步,再将prev和cur进行交换,直至cur遍历结束。
此时,prev所在的位置就是基准值所在的位置,最后再交换头元素(视头元素为基准值)和prev的位置。
代码实现:
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
int _QuickSort03(int* arr, int left, int right)
{
int prev = left, cur = left + 1;
int keyi = left;
while (cur <= right)//让cur往后遍历,找比基准值小的数
{
if (arr[cur] < arr[keyi] && ++prev != cur)//找到小的数,让prev往后走一步后,再交换,把小的数换到左边来
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
//跳出循环,说明cur遍历完了
//prev左边的值(包括prev)都是比基准值小的,右边的都是比基准值大的
Swap(&arr[keyi], &arr[prev]);
return prev;//返回基准值所在的位置
}
以上找基准值的方法都已经给大家介绍完毕。
接下来我们进入到快速排序中,
快速排序的实现分为递归类和非递归类。
但是归根结底,快速排序就两步:
①找基准值
②递归去分区间,找每个区间的基准值
由于递归类的实现非常好理解,我就直接把代码放下来了:
//快速排序(递归版)的主函数:
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;//这个子区间已经排完了,直接返回
}
//找基准值的正确位置
int keyi = _QuickSort01(arr, left, right);//选一个找基准值的方法
//把基准值的左右分为两个子区间,利用递归的思想,继续确认每个区间内的基准值的所在位置
QuickSort(arr, left, keyi - 1);
QuickSort(arr, keyi+1, right);
}
非递归类的快速排序,需要借助栈。
即栈中的基本操作代码得包含(如push、top、pop…),才能有条件去实现非递归的快速排序。
我们就只是为了排一个序,而去把栈的代码全都写了一遍,其实是得不偿失的。
故只要求理解有这种方法不要求掌握。
确实出于感性化这个非递归的不好用,写出来太麻烦了。但是,它的好处是不用递归,不用考虑栈溢出的问题。
好了,接下来,我们说一下利用栈如何去实现快速排序。
将一个区间的头元素和尾元素,放到栈里面去,
用一个while循环,取出头元素和尾元素,确定出一个区间,
然后去找这个区间基准值的正确位置,找完之后。
再利用这个基准值给这个区间再一分为二,然后继续入栈,以此类推。
用来回入栈出栈去实现递归的效果
换句话来说,就是用循环实现递归的效果,以此达到排序的目的。
代码实现:
其中关于栈基本操作的代码,有需要的宝宝,可以根据函数名去下面的链接找对应的代码
栈和队列的相互实现-CSDN博客 //也是我之前写的
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
//快速排序(非递归版)的主函数:
//非递归的实现需要借助栈
//然后再利用循环来实现递归的效果
void QuickSortNonR(int* arr, int left, int right)
{
//创建一个栈并初始化
ST st;
STInit(&st);
//将这组数据的首元素和尾元素入栈【先入尾,再入头;因为排序的时候是先左再右】
StackPush(&st, right);
StackPush(&st, left);
//利用循环实现递归效果
while (!StackEmpty(&st)) //直到全部排好,才能跳出循环
{
//取栈顶元素,因为要取到一个区间,所以要取两次
int begin = StackTop(&st);//区间的头元素
StackPop(&st);
int end = StackTop(&st);//区间的尾元素
StackPop(&st);
//用lomuto法找基准值的正确位置
int prev = begin;
int cur = begin + 1;
int keyi = begin; //保存基准值
while (cur <= end)
{
if (arr[cur] < arr[keyi] && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(&arr[keyi], &arr[prev]);
keyi = prev;
//根据基准值的位置划分左右区间
//左区间:[begin,keyi-1] ; 右区间:[keyi+1,end]
if (keyi + 1 < end)//右区间的入栈条件
{
StackPush(&st, end);
StackPush(&st, keyi + 1);
}
if (keyi - 1 > begin)//左区间的入栈条件
{
StackPush(&st, keyi - 1);
StackPush(&st, begin);
}
}
//跳出循环,说明已经排好,那就销毁栈
STDestroy(&st);
}
四、其他排序
4.1 归并排序
先借助一张图,我们来了解一下啥是归并排序
归并排序就是先分开,再合并。(上图就是最常见的二路归并排序)
归并排序的思路特别好理解,
就是先把一组数据不断的二分,直至每个子区间都只整下一个元素
然后再把临近的两个区间的数据有序的放到一个稍大的区间。
不过为了实现的更为方便,我们通常会再创建一个临时数组,保存暂排好的数据
最后全都排好后,统一把临时数组中的数据全都复制到原来数组中。
代码实现:
void _MergeSort(int* arr, int left, int right, int* tmp)
{
//开始分
if (left >= right)
{
return;//说明这段区间已经排好了,直接返回
}
int mid = (left + right) / 2;//mid用于保存这组区间中间值的下标,方便后续从中间一分为二
_MergeSort(arr, left, mid, tmp);//左区间
_MergeSort(arr, mid + 1, right, tmp);//右区间
//开始合并,也就是治的过程
//每次都是两个区间(左区间和右区间)开始合并,记录每个区间的头和尾
int begin1 = left, end1 = mid;
int begin2 = mid + 1, 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)//要么begin2越界但begin1没有越界
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)//要么begin1越界但begin2没有越界
{
tmp[index++] = arr[begin2++];
}
//已经排好了,把tmp中的数据复制到原数组arr中
for (int i = left; i <= right; i++)
{
arr[i] = tmp[i];
}
}
void MergeSort(int* arr, int n)
{
//在治的时候,要用一个临时的数组保存暂时排好的数
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(1);
}
_MergeSort(arr, 0, n - 1, tmp);//用于分治的子函数
free(tmp);
}
4.2 计数排序
先借助一张图,我们来了解一下啥是直接排序
计数排序属于非比较类排序,也就是说,不需要通过具体比较数据的大小就能排序。
基数排序的核心就是统计一组数据中每个数出现的次数。
由此,我们在实现的过程中,要借用到一个新的数组,来放我们统计的次数。
然后再通过新数组将数据反射到原数组中,这个时候原数组就已经排序完成。
那为什么就只是统计一下次数就能排好序呢?
注意啊,这里我们统计次数的方法非常的巧妙,
让原数组中的每个数据都对应着新数组的中的每个下标。
正因为这种巧妙的方法,我们不仅不会让这个新数组浪费空间,也可以在对应下标的时候,就潜在的排好了序
小下标对应的数一定是小于大下标的。
所以最后通过新数组去还原数的时候,在原数组中呈现的结果就是已经排序好的。
代码实现:
void CountSort(int* arr, int n)
{
//计数排序需要借助一个新数组来排序
//找最大值最小值确定数组大小
int max = arr[0], 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* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("malloc fail!");
exit(1);
}
//把新数组中的每个元素初始化为0
memset(count, 0, range * sizeof(int));
//统计原数组中每个数据出现的次数
for (int i = 0; i < n; i++)
{
count[arr[i] - min]++;//i用来遍历原数组,出现一次就往新数组中加一次
}
//取count中的数据,往arr中放
int index = 0;//index用来遍历原数组
for (int i = 0; i < range; i++)//i用来遍历新数组
{
//从新数组中开头开始遍历
while (count[i]--)//直至新数组中每个计数到0
{
arr[index++] = i + min;//i+min能够还原出原来的数据,放到原数组arr中
//放完一个数据,继续往后放
}
//跳出循环,看新数组中下一个的统计结果
}
}
计数排序的时间复杂度:O(range)
可见计数排序的效率是非常高的,但是不推荐使用哈,因为这个计数排序通常只是在特定情况(大量重复数据)下才是非常好用的。
五、时间复杂度and稳定性
时间复杂度这里讲起来有点费时,所以我就直接贴在下面了。
如上图,有个稳定性的分区。
啥是稳定性呢?
简单来说就是相对序列不发生改变,那就是稳定。
比如一组数据,排序前5在5*的前面,排序后5仍在5*的前面,那就说明是稳定的,否则就是不稳定。
判断是否稳定,除了死记硬背,我还有个简单的方法。
就是简单的去想一下是不是稳定的,如果很容易就能判断出稳定,那就是稳定。如果还需要自己思考一下,甚至是举例那就是不稳定的。
就拿冒泡排序来讲,就是不断的两两交换,遇到两个相同的数肯定就直接越过去了,不会交换,何谈相对位置发生改变一说,那它就是稳定的。同理直接插入排序,归并排序也能很快判断出是稳定的。
六、碎碎念
无特殊要求且需要排序的情况下,首选快速排序的(lomuto法)!代码简单且效率高。