一、排序的概念及其应用
概念:
所谓排序,就是使⼀串记录,按照其中的某个或某些关键字的⼤⼩,递增或递减的排列起来的 操作。
下面是我们常见的几种排序算法:

下面我们废话不多说,直接开始干货。
二、插入排序
1、直接插入排序
插入排序是一种简单的插入排序算法,其基本思想如下。
基本思想:
把待排序的记录按照其关键码值的大小逐个插入到我们已经排序好的有序序列中,直到所有的记录都插入完成为止,得到一个新的序列。
其思路就和我们日常中,打扑克牌很像,我们前面先摸的牌,我们会先进行排序,然后后面摸上的牌,我们就会看其大小,然后进行插入,使得最后的牌是一个有序的状态。
下面我们拿一个数组来演示一遍:
我们现在数组是: 5,3,9,6,2,4
那么首先我们数组的第一个元素组成是就是一个有序的数组。
那么我们此时就从第二个位置开始进行插入排序,是将其插入到5这个有序序列中去,所以我们是从有序序列的最后一个元素进行排序的,我们将其记录为end。
那么我们要比较的元素的位置就是end+1,那么我们上面就是比较5和3,那么3比5小,那么要插入到5的前面,那么我们将5先挪动到3这个位置,然后将3再挪动到5原来的位置,那么为了防止5挪动覆盖了3,那么我们再创建一个变量将end+1这个位置的元素保存一下。
所以我们的数组就变成了下面这种样子:
3、5、9、6、2、4
那么此时就继续比较,此时end就应该是1,也就是从数组的第二个位置进行排序了。
此时我们发现end+1的位置的元素要大于end的元素,那么此时我们这个部分的排序就完成了。
那么此时的数组就变成了下面这种情况:
3、5、9、6、2、4
那么此时有序数组的长度就变成了3,那么就end就要到数组的第三个位置了也就是end=2了。
那么我们继续排序,此时end+1这个位置为6,和9比较小了,那么end这个位置的元素先挪动到end+1这个位置,那么此时要继续往前排序,那么end--,然后和5比较,比5大,那么就放在end+1这个位置。
那么要是最坏的情况就是,我们此时这个元素是数组中的最小值,那么我们就要比较好多次,那么我们最终要比较到啥情况就完成了对当前位置的元素的排序呢?
首先就是其已经比前面的元素大了,那么其排序完成
然后就是其要到第一个位置中去,那么就是end为0的时候,那么我们进行比较的条件就是end>=0。
如图所示,直接插入排序就差不多是这样:

下面是我们直接插入排序的代码:

下面我们测试一下:

那么我们的插入排序就完成了,那么我们来看看其时间复杂度。
首先就是第一个循环,那么其要运行n次,然后就是第二个while循环,那么其最坏的情况就是每个元素都要进行比较,那么其运行次数为1+2+3+......+n。所以我们这个最差的情况就是O(n^2)。
那么最好的情况就是数组本来就是有序的,那么我们的时间复杂度就为O(n)。
那么我们有没有啥办法可以将其改进呢?
那么就是要是我们的数组本身就有一点有序,将数组小的元素就存放在前面,大的元素存放在数组的尾部。那么就可以减少我们的比较次数,那么我们可以将数组分成好几个部分,然后这几个部分的数组先进行比较,然后再进行整个数组的比较。
那么就是我们的希尔排序了。
2、希尔排序
希尔排序法又称为缩小增量法。其基本思想如下:
先选定一个整数,通常是gap=n/3+1,把待排序的文化的所有数据分成各组,所有距离相等的分在同一组内,并对每一组的数据进行排序,然后gap=gap/3+1,得到下一次排序的分组,直到gap为1的时候就是直接插入排序了。
其基本排序逻辑如下:
假设我们有下面这个数组:
我们上面的是将gap=n/2的情况。
我们看第一步,gap=5,那么我们就是将数组分成了五个部分,不过其分法是有特点的,就是从首位置开始分,走gap就是同一组的。然后我们的每一个小组的数组内的进行排序,然后就得到了第二步的数组,然后gap=gap/2=2,然后重复上面的方式进行排序。排序完成后gap=gap/2=1,那么此时就直接对数组进行直接插入排序。
那么有的同学就有会有疑惑了,我们上面的方式很明显就多了好几次分组的操作,那么我们的外层循环不就多进行了好几次嘛。
但是我们前面的小数组中的,已经将大部分的大的数据排序到后面了,使得我们后面要排序的数据多的时候,需要进行挪动的数据很少。使得我们后面要比较的次数减少了很多。
下面是希尔排序的代码实现:

下面我们来计算一下希尔排序的时间复杂度:
首先是外层循环,外层循环的话,和我们对于gap的分组有关,我们上面采用的是gap=n/3+1的方式,那么外层循环的时间复杂度就为log3(n)。
若采用gap=n/2的情况的话,那么就是log2(n)。
然后我们再看内层循环:
一共是gap组,然后一组是n/gap个数据,那么每组中,最坏的情况就是:
1+2+3+......+(n/gap-1),然后一共是gap组。
总合计最坏的情况是:gap*[1+2+3......+(n/gap-1)]。
然后gap的取值可以为n/3,n/9.....2,1。
当gap=n/3的时候总的移动次数为:n/3*(1+2)=n。
当gap=n/9的时候总的移动次数为n/9*[1+2+3+......+8]=(n/9)*((1+8)*9)/2=4.5n
所以对于希尔排序的时间复杂度是不定的,其随着gap的变化而变化:

这是其大概变化,那么从权威的书籍中得到的数据大概如下:
希尔排序的时间复杂度通常表示为O(n^1.3),但这并不是一个精确的数值。这是因为希尔排序的性能受到增量序列选择的影响。增量序列是希尔排序中用于分组的关键参数,其选择会直接影响算法的效率。
在希尔排序的过程中,初始时选择一个较大的增量,将数组分为多个子序列进行排序。随着增量的逐渐减小,数组逐渐趋向于有序,当增量减小到1时,整个数组将完全有序。在这个过程中,如果增量较大,每组的插入排序操作次数较少,时间复杂度接近O(n) 。而当增量较小时,由于数组已经接近有序,插入排序的效率很高,时间复杂度同样接近O(n)。
三、选择排序
1、直接选择排序
选择排序的基本思想如下:
每一次从待排序的元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部的待排序的数据元素排完。
不过我们下面的方法会比其理论上的要快一点,我们使用两个整型,一个表示大的maxi,一个表示最小的mini。然后一个end从数组的末尾,一个begin表示从数组的头开始。
首先我们默认maxi和mini都是数组的初始位置,然后我们遍历数组,找到最大的值maxi,将其和end位置的值进行交换,找到最小值将其和begin的值进行交换,然后end往前挪一个位置,begin往后挪动一个位置,直到end<=begin,那么就说明我们的数组已经排序完成。
下面我们通过一个数组进行演示:
int arr[]={5,3,9,6,2,4};
那么我们数组初始情况如下:

然后我们遍历数组,找到最小为2,最大为9,然后,最小的和end位置的元素进行交换,最大的和end位置的元素进行交换。
如下:

然后又继续在begin和end这个区间进行排序,直达end<=begin这样,那么就可以结束循环了。
下面是我们的代码实现:

下面我们测试一下:

我们发现我们上面的排序有一部分是有问题的,我们中间的数据排序是不对的,那么我们按照代码走一遍,发现在begin在第四个位置、end在第六个位置的时候,进行了两次交换。
那么其两次交换是如何导致的呢?
这是因为此时最大值的位置就是在begin,最小值的位置就在end这个位置。
所以我们对于这种情况特殊处理一下:

运行结果:

四、交换排序
交换排序的基本思想如下
基本思想:
所谓交换,就是根据序列中两个记录键值的比较结果来换这两个记录在序列中的位置
特点:
将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前面移动
1、冒泡排序
这个排序方法相信很多同学都不陌生,这是我们学习编程中接触的第一个排序了。所以在这里我就不过多讲解了,直接上代码。

代码如下:

2、快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想如下
基本思想:
任取待排序元素序列中的某元素作为基准值,按照该排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
那么大概的框架我们已经可以想到了,就是对于左右子序列进行递归调用即可。
那么我们具体是操作如下:

首先我们默认基准值为数组的首元素,将其命名为keyi,然后我们在创建一个left还有一共right。
left是从数组的左边开始,right是从数组的右边开始,然后left是从数组的左边往右边找,找比基准值要大的值,然后right是从数组的右边开始往左边移动,找比数组要小的值,找到后,将right和left位置的值进行交换,然后继续找,直到left比right小,那么当前的序列就排完了,然后将基准值keyi和right当前位置的数据进行交换,然后就进入到我们的左右子序列的排序了。
那么我们的函数的参数设置为三个一个是数组地址,一个是left,一个是right。
然后我们的左子序列的话传入的地址也是当前数组,left还是初始情况下的left,不过right就变成了keyi-1。
右子序列的话,也是这个数组,然后right还是原来的,不过left要传入的是keyi+1。
基本框架:

那么大致过程我们知道了,那么我们的基准值要如何寻找呢?
下面我们提供三种方式
1、hoare版本
right:从右往左走,找比基准值要小的数据
left:从左往右走,找比基准值要大的数据
在left<=right的时候找到了,就让两者对应位置的数据进行交换
left>right,keyi和right数据进行交换,然后keyi=right
代码如下:

那么我们对着代码再重新走一遍,首先我们默认基准值是left这个位置的元素,那么我们的left就可以从第二个位置开始走了,然后我们的条件是left<=right才进入循环进行寻找,这是因为当left>right,那么当前序列就是二叉树的叶子结点了,就只有一个数据了呀。然后进入循环后,我们的left和right都走,left往右边走,right往左走。要注意的是走的时候也要防止left走到>right的情况。
然后两个循环都走完,说明其找到了对应的值,那么就将这两个位置的数据进行交换,然后继续找,直达循环结束,然后我们就将keyi的值和right的值进行交换,然后返回right,这个right就是下一次递归的基准值了。
有同学会问,比较那个是否可以相等呢?
我们可以去试试,如果运行相等,那么当我们的数据很多相等数据的时候,那么时间复杂度就变高。
我们来求一下当前我们的快速排序的时间复杂度:
首先就是递归,其递归的时间复杂度就是O(logn)
然后就是求基准值的函数了,首先是第一个循环,那么我们的第一个循环的时间复杂度很明显是O(n),那么内层循环的话,其实我们发现内层循环中left<=right是一样的,所以总的来说其时间复杂度就是O(n)。
那么总的来说快速排序的时间复杂度就是O(nlogn)。
但是也有一种情况会导致快速排序的时间复杂度变得很高:
当数组本身就是有序的情况下,会导致我们的递归时间复杂度达到O(n),所以这种情况下快速排序的时间复杂度就是O(n)。
2、挖坑法
其基本思想如下
基本思想:
创建左右指针,首先从右边向左找出比基准值小的数据,找到后立即放在左坑中,当前位置就变成新的坑,然后从左向右找出比基准值大的数据,找到后立马放在右坑中,当前位置就变成新的坑,结束循环后将开始存储的分界值放在当前的坑中,返回当前坑的下标。
如下图所示:

先将第一个位置的数据拿出来,然后形成一个坑位,然后我们创建两个指针一个left,一个right,然后我们默认第一个位置是基准值,然后先走right,找到比基准值小的,然后就将这个位置的数据移到基准值那个坑位。然后这个位置就有了一个新的坑位然后left就走,找比基准值大的值,然后将大的值放在形成的新的坑位。直达left>=right跳出循环。
代码如下:

3、lomuto前后指针法
创建前后指针,从左往右找比基准值小的进行交换,使得小的都排在基准值的左边。
如下所示:

具体思路如下:
创建两个指针变量prev,cur。
然后cur从左往右找比基准值要小的数据,prev,cur进行交换。
cur探路,找比基准值要小的数据,那么其会有两种可能:
1、cur找到了比基准值要小的数据:++prev,prev和cur进行交换,cur++。
2、cur没有找到比基准值要小的数据:cur++。
当cur越界后,我们就将prev当前位置的数据和 keyi位置的数据进行交换,然后返回我们的prev。
代码如下所示:

我们上面实现的三种方式都是依托我们的递归的快速排序服务的,那么下面我们来看看非递归方式的。
3、非递归的快速排序
非递归的快速排序的实现,我们要借助我们的数据结构--栈或队列来实现
我们初始化一个栈,然后我们将数组的边界下标入栈,我们先入left,然后入right,然后我们进入循环,循环的条件是栈不为空,然后我们取栈顶元素,记住我们先入的是left,后入栈的是right,那么我们取栈顶元素的第一个是要排序序列的尾部的下标,那么我们创建一个变量end表示尾部,用来收取第一个取栈顶的数据,然后我们再进行取栈顶元素,这个就是要排序序列的头部,我们将其命名为begin。
然后就在[begin,end]这个区间中去找基准值,找基准值的方式和我们上面实现递归的快速排序的方式是一样的,我们下面使用lomuto的方式,然后找到基准值后,我们就要进行判断,判断当前的序列是否已经完成排序,是否还有子序列。
判断的依据是找到的基准值的下标是否越界。
代码如下:

五、归并排序
归并排序的基本思想如下:
其是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一种非常典型的应用。其将已经有序的子序列进行合并,然后得到完全有序的序列。即先将我们完整的序列先分成有序的子序列。
其基本逻辑如下图所示:

我们将数组进行分割,使用二分法进行分割,而且我们发现上面的结构很像二叉树,所以我们也是使用的递归的方式进行分割。然后递归结束的方式就是left>=right的时候。
代码如下:


前面的递归我们都很清楚了,那么我们来看看排序函数。
首先是参数,第二个参数是左序列是头,然后第二个是分割点,然后第三个就是右边序列的界限。
然后我们就开始对两个子序列进行合并,我们的合并方式有很多种,就简单的就是使用一个第三方数组进行辅助。
也可以选择在原来的数组进行排。
各有好处,我们上面的这种方式时间复杂度不行,效率很低。

这种方式就使用空间换时间
六、非比较排序
前面的几个排序都是属于比较排序的方式。
下面我们学习一个非比较排序的方式
计数排序
计数排序又称为鸽巢原来,是对哈希直接定址法的变形应用。
其操作步骤如下:
使用一个数组,然后这个数组存储的数据表示的是这个元素在原来数组出现的次数。
然后通过统计的结果将序列回收到原来的序列。

如上所示,我们的下标就表示存储的是这个数据的次数。
但是有个问题,如果我们的数据比较大,那么我们就需要开辟很大的数组,但是很多空间实际上是用不上的。
如下:

所以我们选择通过范围来开空间:
就用我们的最大值和最小值成一个范围,开一个max-min+1个空间的数组。
那么我们返回到原来序列的时候,计数数组的下标+min就是原来的数据了。
代码如下:
七、排序算法复杂度及稳定性分析
稳定性:
假设在待排序的序列中,存在多个相同的关键字的记录,这些记录的相对次序保持不变,那么在原序列中,arr[i]=arr[j],而且在arr[i]在arr[j]的前面,然后排序后,arr[i]还是在arr[j]之前,则称这个排序是稳定的。

#define _CRT_SECURE_NO_WARNINGS 1
#include"sort.h"
//交换函数
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
//直接插入排序
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)
{
if (arr[end]>tmp)
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
arr[end+1] = tmp;
}
}
//希尔排序
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 -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
//直接选择排序
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; i <=end; i++)
{
if (arr[mini]>arr[i])
{
mini = i;
}
if (arr[maxi]<arr[i])
{
maxi = i;
}
}
if (maxi==begin)
{
maxi = mini;
}
Swap(&arr[begin], &arr[mini]);
Swap(&arr[end], &arr[maxi]);
begin++;
end--;
}
}
//冒泡排序
void BubbleSort(int* a, int n)
{
int exchange = 0;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n - i - 1; j++)
{
if (a[j] > a[j + 1])
{
exchange = 1;
Swap(&a[j], &a[j + 1]);
}
}
if (exchange == 0)
{
break;
}
}
}
//快速排序
int _QuickSort(int *arr, int left, int right) //找基准值函数Hoare版本
{
int keyi = left;
left++;
while (left <= right)
{
while (left<=right&&arr[right]>arr[keyi])
{
right--;
}
while (left <= right && arr[left] < arr[keyi])
{
left++;
}
if (left<=right)
{
Swap(&arr[left], &arr[right]);
}
}
Swap(&arr[keyi], &arr[right]);
return right;
}
int _QuickSort2(int* arr, int left, int right) //挖坑法
{
int keyi = arr[left];
int hole = left;//坑位
while (left < right)
{
while (left<right && arr[right]>keyi)
{
right--;
}
arr[hole] = arr[right];
hole = right;
while (left < right && keyi)
{
left++;
}
arr[hole] = arr[left];
hole = left;
}
arr[hole] = keyi;
return hole;
}
int _QuickSort3(int* arr, int left, int right)//lomuto指针
{
int keyi = left;
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
if (arr[cur]<keyi&&++prev!=cur)
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(&arr[keyi], &arr[prev]);
return prev;
}
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);
}
//非递归快速排序
void QuicSortNoR(int* arr, int left, int right)
{
stack<int> st;
st.push(left);
st.push(right);
while (st.empty())
{
int end = st.top();
st.pop();
int begin = st.top();
st.pop();
//找基准值
int keyi = begin;
int prev = begin;
int cur = prev + 1;
while (cur <= end)
{
if (arr[cur]<arr[keyi]&&++prev!=cur)
{
Swap(&arr[prev], &arr[cur]);
}
cur++;
}
Swap(&arr[keyi], &arr[prev]);
keyi = prev;
//判断是否越界,然后左序列入栈
if (keyi+1<end)
{
st.push(keyi + 1);
st.push(end);
}
if (begin<keyi-1)//右序列入栈
{
st.push(begin);
st.push(keyi - 1);
}
}
//离开函数,栈会自动调用析构
}
//归并排序
//时间复杂度很高,用时间换空间
void _MergeSort1(int *arr,int left,int mid,int right)
{
if (arr[mid]<arr[mid+1])//说明右序列的最小值已经大于左序列的最大值
{
return;
}
int begin1 = left;
int begin2 = mid + 1;
while (begin1 <= mid && begin2 <= right)
{
if (arr[begin1]<=arr[begin2])
{
begin1++;
}
else//上面不满足,说明左边序列中此时的begin1这个位置的数据要比左边序列的最小值要大,那么将begin1到begin2-1的数据往右挪动一个位置
{
int key = arr[begin2];
int tmp = begin2;
while (tmp>begin1)
{
arr[tmp] = arr[tmp - 1];
tmp--;
}
arr[begin1] = key;
begin1++;
begin2++;
mid++;
}
}
}
void MergeSort1(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
int mid = (left + right) / 2;
//将数组分割[left,mid] [mid+1,right]
MergeSort1(arr, left, mid);
MergeSort1(arr, mid + 1, right);
_MergeSort1(arr, left, mid, right);
}
//用空间换时间
void _MergeSort2(int* a, int left, int right, int* tmp)
{
if (left >= right)
{
return;
}
int mid = (right + left) / 2;
//[left,mid] [mid+1,right]
_MergeSort2(a, left, mid, tmp);
_MergeSort2(a, mid + 1, right, tmp);
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = begin1;
//合并两个有序数组为⼀个数组
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
for (int i = left; i <= right; i++)
{
a[i] = tmp[i];
}
}
void MergeSort2(int* a, int n)
{
int* tmp = new int[n];
_MergeSort2(a, 0, n - 1, tmp);
delete[] tmp;
}
//非比较排序
//计数排序
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] > max)
max = a[i];
if (a[i] < min)
min = a[i];
}
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("malloc fail");
return;
}
memset(count, 0, sizeof(int) * range);
// 统计次数
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;
}
}
}