前言:上一篇文章我们讲了堆和二叉树,这篇文章我们就来讲数据结构初阶的最后一个内容——排序。
排序:所谓排序,就是使⼀串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。 排序按照不同的条件会排成不一样的序列,比如按照大小排成 升序或降序,按照等级可以排成高的或低的,按照次数可以排成次数多的和次数少的等等。排序在日常生活中的作用非常大,下面我们来看看排序的应用。

比如大学的院校排名:就是按照特定的比较条件来进行排序从而得出这样的排名,这就是排序的典型应用。还有很多的例子这里就不一一列举。
这时有人会问既然排序在日常生活中有这么重要的作用那么排序有哪些种类呢? 下面我们就来看看排序的种类

ps:一共有4类排序,前三类排序有两种排序,最后一种仅有一种所以总共7种,在这7种中冒泡排序和堆排序是在前面讲过的本篇文章不再过多的介绍,只介绍后面的5种排序。
下面我们从插入排序算法开始讲起。
基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。

下面来看看具体的思路:

简单来说默认排升序的情况下:如果end+1处的数据比end处的数据小就交换,如果end被交换到了最后一个元素的位置上则说明最大了,此时减减end再去看看倒数第二个是不是倒数第二大,如果不是就调整;如果是就继续减减去看看倒数第三个数据。以此类推直到end到0位置处所有的元素就都调整完了。
void InsertSort(int* arr, int n);void InserSort(int* arr, int n)
{
for (int i = 0;i < n - 1;i++)
{
int end = i;
int tmp = arr[end + 1];//将end+1的数据保存
while (end >= 0)//每一次插入的结束条件
{
if (arr[end] > tmp)//end位置的数据大于end+1
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
//跳出循环说明end越界 最后还要将tmp的数据放到end+1的位置上!!!
//end+1就是0下标处
arr[end + 1] = tmp;
}
}
直接插入排序的特性总结:
上面我们看了直接插入排序有一些局限性它的效率取决于要排序的元素集合的顺序情况,所以希尔排序使用将数组分成若干小块的思想就优化了直接插入排序的局限。

下面我们来看看思路:
希尔排序法的基本思想是:先选定一个整数(通常是gap = n/3+1),把待排序文件所有记录分成各组,所有的距离相等的记录分在同⼀组内,并对每⼀组内的记录进行排序,然后gap=gap/3+1得到下⼀个整数,再将数组分成各组,进行插入排序,当gap=1时,就相当于直接插入排序**。
下面我们来看看思路:

上面的图文总结起来就是:希尔排序与直接插入排序的比较逻辑没有变,区别就是有长度差而已,是end和end+gap的数据比,按照比较的条件来交换。
那么希尔排序的步骤是怎么比较的呢?

//希尔排序
void ShellSort(int* arr, int n)
{
//给定一个长度差
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
//for循环控制的是多个组的情况
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]往后放 将arr[end+gap]往前放
arr[end+gap] = arr[end];
//每交换一次end就要减gap 去排序end之后的数据
end -= gap;
}
else
{
break;
}
}
//跳出循环此时 arr[end+gap]处的数据为空将tmp给arr[end+gap]
arr[end + gap] = tmp;
}
}
}

先来看看直接选择排序的基本思想:每⼀次从待排序的数据元素中选出最小(或最大)的⼀个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。下面来一张动图让大家更好的理解:

上面的动图很明显展示了,在排序之前每次都去找最小的那个数,找到了就往前排,下一次又从剩下的数据中找最小的那个数,找到了往前排直到找到最后一个最小的数排序就结束了。下面我们来看看具体的思路和过程:

上面的四个下标min,begin,max,end各自的功能总结下来就是:首先begin与end只关注初位置和末位置所以它们是找位置的下标; 而min和max它们只关注最大最小值,所以它们找的最大最小值。 当min找到最小值,max找到最大值之后就将数据交给begin和end,数据小的给begin数据大的给end每次给完之后begin加加,end减减,变成第二个位置与倒数第二位置 这样begin放的数据一次比一次大,end处放的数据一次比一次小直到begin与end相遇排序结束!
//直接选择排序
void selectSort(int* arr, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int mini = begin;
int maxi = begin;
//i=begin+1 就是从第二个数据开始比较
//因为mini和maxi默认为第一个元素
for (int i = begin + 1;i <= end;i++)
{
//比较的是数组中数据的大小 更新的是下标
if (arr[i] > arr[maxi])
{
//更新最大值 注意是更新下标
maxi = i;
}
if (arr[i] < arr[mini])
{
//更新最小值 注意是更新下标
mini = i;
}
}
//跳出循环此时mini与maxi已经更新完了
//交换
if (begin == maxi)//特殊情况
{
maxi = mini;
}
Swap(&arr[mini],&arr[begin]);
Swap(&arr[maxi],&arr[end] );
//交换完了begin往后走 end往前走 缩小范围
begin++;
end--;
}
}再来看看直接选择排序的时间复杂度:首先外层的while循环是n加上内层的for循环也是n,而且是嵌套关系所以时间复杂度为O(N^2),直接选择排序的思路很简单但是效率太低实际中也用的很少。
我们使用测试代码来测试一下:

测试代码后放在最后面,大家可以去尝试一下。
由于堆排序我们在二叉树那个章节讲过了,这里就不再赘述了,小编在这里放一张动图然后贴一个传送门:堆排序

我们虽然在这里不讲堆排序但要看看堆排序与其他排序算法比起来哪个效率高,下面我们就使用测试代码测试一下:

交换排序基本思想: 所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置交换排序的特点是:将键值较⼤的记录向序列的尾部移动,键值较⼩的记录向序列的前部移动
快速排序是Hoare于1962年提出的⼀种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直所有元素都排列在相应位置上为止。

下面我们就来看看分析思路:

下面举个例子让大家更加直观的感受这种思想:

到这里就有一个问题,就是我已经找到了基准值但如何将基准值放到对应的位置上呢?

如果已经找好了基准值就来到了下一步——递归!

//快速排序
void QuickSort(int* arr, int left, int right)
{
//最后一个函数栈帧所执行的内容
//left都大于right了所以就没法找了直接return
if (left >= right)
{
return;
}
//分两步
//第一步找基准值
int keyi = _QuickSort1(arr,left,right);
//int keyi = _QuickSort2(arr, left, right);
//找到基准值后 第二步 递归!
//left keyi right
//左序列[left,keyi-1] 右序列[keyi+1,right]
QuickSort(arr, left, keyi - 1);
QuickSort(arr, keyi + 1, right);
}以上是快速排序的总的框架,在第一步找基准值还有很多版本的优化,下面就是hoare版本的找基准值。
//hoare版本找基准值
int _QuickSort1(int* arr, int left, int right)
{
int keyi = left;
//left从基准值第二个数开始找起
++left;
while (left <= right)//这个是大前提
{
//lerft找大于基准值 所以小于基准值就加加
while (left <= right && arr[left] < arr[keyi])
{
left++;
}
//right找小于基准值 所以大于基准值就减减
while (left<=right && arr[right]>arr[keyi])
{
right--;
}
//到这说明left与right都找到了 交换!
//交换的前提是left<right
if (left <= right)
{
//交换后left往后走 right往前走 所以left++ right--
Swap(&arr[left++], &arr[right--]);
}
}
//跳出循环说明left>right 此时将基准值处的数据与right处的数据交换
Swap(&arr[keyi], &arr[right]);
//最后返回基准值 right处的数据
return right;
}下面来分析一下hore版本找基准值的时间复杂度:递归的时间复杂度为logn,找基准值的时间复杂度为n,由于每一步递归都要去找基准值所以总的时间复杂度为nlogn。
但是有个问题要注意一下:

当hoare版本找基准的时碰到数组中全为重复的数据时,时间复杂度就会变高因此这个版本的找基准值还是会存在一些不足,下面我们再来看看下一个版本的找基准值。
lomoto版本找基准值的思路是:创建前后指针,从左往右找比基准值小的进行交换,使得小的都排在基准值的左边。下面我们画图来更好的理解:

上面就是lomuto版本找基准值的思路了,如果文字看着还是有点迷糊的话,小编不妨再给出一张动图让大家更直观的感受一下:

//lomuto版本找基准值
int _QuickSort2(int* arr, int left, int right)
{
int key = left;
int prev = left, cur = prev + 1;
while (cur <= right)
{
//cur要找小于基准值的数据 如果大于就继续往后走
if (arr[cur] < arr[key] &&cur!=++prev)
{
Swap(&arr[prev], &arr[cur]);;
}
//交换完cur往后走++ 没找到cur也++
cur++;
}
//走到这cur已经越界 那么就要让key位置的数据与prev处的数据交换
Swap(&arr[key], &arr[prev]);
//返回的是prev处基准值的下标
return prev;
}使用lomuto版本的找基准值,时间复杂度也是n*logn,但是在数组中全为重复数据,或者数组已排序或逆序时递归深度变为 n 层,总操作量为:n + (n-1) + … + 1 = O(n²)。
那怎么优这一问题呢?我们就来看看快排的三路划分
当面对有大量跟key相同的值时,三路划分的核心思想有点类似hoare的左右指针和lomuto的前后指针的结合。核心思想是把数组中的数据分为三段【比key小的值】 【跟key相等的值】【比key大的值】,所以叫做三路划分算法。
下面画图来让大家更直观的理解:

void swap(int*x,int*y)
{
int tmp=*x;
*y=*x;
*x=tmp;
}
//三路划分
void QuickSort(int*arr,int left,int right)
{
if(left>right)
{
return;
}
int begin=left;
int end=right;
//随机选key
int randi=left+(rand()%right-left+1);
swap(&arr[randi],&arr[left]);
int cur=begin+1;
int key=arr[left];
while(left<=right)
{
//小于基准值与left交换left++ cur++
if(arr[cur]<key)
{
swap(&arr[cur],&arr[left]);
cur++;
left++;
}
//大于基准值与right交换 right--
else if(arr[cur]>key)
{
swap(&arr[cur],&arr[right]);
right--;
}
//等于基准值cur++
else
{
cur++;
}
}
//跳出循环 cur大于right说明中间已经全为基准值了,再递归左右子区间排序
//左区间[begin,left-1]
QuickSort(arr,begin,left-1);
//右区间[right+1,end]
QuickSort(arr,right+1,end);
}以上就是三路划分,三路划分巧妙将重复数据放在中间然后再去递归排序剩余的两个子区间有效的避免了当遇到重复数据较多的数组时,快速排序的时间复杂度不会退化成O(N^2),而是保持nlogn的时间复杂度。
快速排序能用递归实现,当然也能借用其他数据结构来实现,这个数据结构就是栈。 下面来看看非递归的思路以及详细的过程:

//快速排序
void QuickSort(int* arr, int left, int right)
{
//最后一个函数栈帧所执行的内容
//left都大于right了所以就没法找了直接return
if (left >= right)
{
return;
}
//分两步
//第一步找基准值
//int keyi = _QuickSort1(arr,left,right);
int keyi = _QuickSort2(arr, left, right);
//找到基准值后 第二步 递归!
//left keyi right
//左序列[left,keyi-1] 右序列[keyi+1,right]
QuickSort(arr, left, keyi - 1);
QuickSort(arr, keyi + 1, right);
}
//非递归版本的快速排序——栈
void QuicSortNoR(int* arr, int left, int right)
{
//定义栈
ST st;
StackInit(&st);
//将给定的左右下标入栈
StackPush(&st, left);
StackPush(&st, right);
//只要栈不为空就循环取栈顶 更新区间
while (!StackEmpty(&st))
{
//取栈顶
int end = StackTop(&st);//先取的是右边的范围
StackPop(&st);
int begin = StackTop(&st);//后取的是左边的范围
StackPop(&st);
//取完栈顶有了范围后根据begin与end使用lomuto找基准值
int keyi = begin;
int prev = begin, cur = prev + 1;
while (cur <= end)
{
//cur要找小于基准值的数据 如果大于就继续往后走
if (arr[cur] < arr[keyi] && cur != ++prev)
{
Swap(&arr[prev], &arr[cur]);
}
//交换完cur往后走++ 没找到cur也++
cur++;
}
//走到这cur已经越界 那么就要让key位置的数据与prev处的数据交换
Swap(&arr[keyi], &arr[prev]);
//交换后下标也要变化
keyi = prev;
//begin keyi end
//左序列:[begin,keyi-1] 右序列:[keyi+1,end];
//找到基准值后 就要划分左右区间 直到left大于right的区间不会入栈就会跳出循环、
//所以入栈的条件是当left<right的时候入栈
if (keyi + 1 < end)//右区间
{
StackPush(&st, keyi + 1);
StackPush(&st, end);
}
if (begin < keyi - 1)
{
StackPush(&st, begin);
StackPush(&st, keyi-1);
}
}
StackDestroy(&st);
}由于冒泡排序比较简单,这里小编直接给出代码:

//冒泡排序
void BubbleSort(int* arr, int n)
{
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (arr[i - 1] > arr[i])
{
Swap(&arr[i - 1], &arr[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}冒泡排序时间复杂度为O(N^2)。
先来看看归并排序的思想: 归并排序(MERGE-SORT)是建⽴在归并操作上的⼀种有效的排序算法,该算法是采⽤分治法(Divide and Conquer)的⼀个⾮常典型的应⽤。将已有序的⼦序列合并,得到完全有序的序列;即先使每个⼦序列有序,再使⼦序列段间有序。若将两个有序表合并成⼀个有序表,称为⼆路归并。

下面我们画图来解释一下这段文字:

紧接着我们来看看归并排序的具体步骤:

归并归并就是先递归拆解,将其分成一个一个的小数据,再合并成一个有序的数据。
//归并排序
void MergeSort(int* arr, int n)
{
//开辟一个临时数组
int* tmp = (int*)malloc(sizeof(int) * n);
//递归分解 合并数组
_MergeSort(arr, 0, n - 1, tmp);
//最后释放空间
free(tmp);
tmp = NULL;
}
void _MergeSort(int* arr, int left, int right, int* tmp)
{
//首先是递归
if (left == right)
{
return;
}
//定义mid将数组二分
int mid = (left + right) / 2;
//有了左右区间继续二分递归
_MergeSort(arr, left, mid, tmp);
_MergeSort(arr, mid + 1, right, tmp);
//走到这说明最小的右区间已经返回了‘
//合并 定义begin和end防止left与right改变
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
//比较begin1与begin2 谁小放谁
if (arr[begin1] > arr[begin2])
{
tmp[index++] = arr[begin2++];
}
else
{
tmp[index++] = arr[begin1++];
}
}
//到这要么begin1越界 要么begin2越界
//begin1没越界导入begin1
while (begin1<=end1)
{
tmp[index++] = arr[begin1++];
}
//begin2没越界导入begin2
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
//最后将临时数组的数据导入到原数组中
//[left,right]
for (int i = left;i <= right;i++)
{
arr[i] = tmp[i];
}
}在上面给出的代码中,我们可以看到总时间复杂度为,递归的n层logn,每层都会执行for循环的合并数组操作所以总的时间复杂度为O(n*logn)。 空间复杂度:由于临时数组开辟n次所以空间复杂度为O(N)。

看完了归并排序的递归版本,接着我们就来看看归并排序的非递归版本:

以上就是非递归版本的思路,但是还有一些特殊的情况就是当数组的数据个数不是偶数不能完全二分时就会有问题,下面来看看特殊情况:

下面给出实现后的代码:
//归并排序的非递归
void MergeSortNor(int* arr, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail!");
exit(1);
}
int gap = 1;
while (gap <= n)
{
//分区间
for (int i = 0;i <n ;i*=2*gap)
{
int begin1 = i, end1 = i + gap-1;
int begin2 = i + gap, end2 = i + gap - 1 + gap;
int index = i;//tmp数组的下标
//特殊处理如果左右不能完全二分 则begin2 或end2否可能越界
if (begin2 > n)
{
//进入下一次的分组
break;
}
if (end2 > n)
{
//此时一个数据有一个不同的区间
end2 = n - 1;
}
//分好区间就要合并 两个有序序列进行合并
while (begin1 < end1 && begin2 < end2)
{
//这里不用去比较end1与end2 因为begin1和begin2会++ 走到相应的end上
//再去进行比较
if (arr[begin1] > arr[begin2])
{
tmp[index++] = arr[begin2++];
}
else
{
tmp[index++] = arr[begin1++];
}
}
//跳出循环 两种情况
while (begin1 < end1)
{
//左右不均分有多余的继续插入到后面
tmp[index++] = arr[begin1++];
}
while (begin2 < end2)
{
tmp[index++] = arr[begin2++];
}
//最后再将临时数组中的数据导入到原数组中
//end2为左边子数组最后一个边界 因此可以确定数据个数
memcpy(arr + i, tmp + i,sizeof(end2-i+1));
}
gap *= 2;
}
}非递归的归并排序时间复杂度为:n*logn。
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤: 1)统计相同元素出现次数 2)根据统计的结果将序列回收到原来的序列中

下面画图来分析一下:

上面的分析思路还存在一个问题就是:如何确定count数组的大小?下面我们举个例子来分析这一问题:

下面给出实现代码:
//计数排序
void CountSort(int* arr, int n)
{
//找最大最小值确定count数组大小
int max = arr[0], min = arr[0];
for (int i = 0;i < n;i++)
{
if (arr[i] > max)
{
max = arr[i];
}
if (arr[i] < min)
{
min = arr[i];
}
}
//到这已经找到了最大最小值
int range = max - min + 1;
//根据range开辟count数组
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("malloc fail!");
exit(1);
}
//到这开辟成功 数据换下标
//对count数组初始化 使用memset
memset(count, 0, sizeof(int) * range);
//循环count 使用差值法
for (int i = 0;i < n;i++)
{
count[arr[i] - min]++;
}
//将count数组中的数据导入到原数组中
int index = 0;
for (int i = 0;i < range;i++)
{
while (count[i]--)
{
//能进入循环说明count的改下标处存的数据是不为0的
//使用while循环,递减该下标的值即次数
arr[index++] = i + min;
}
}
}首先计数排序的时间复杂度为O(N+range),空间复杂度为O(range)。 时间复杂度为什么是N+range呢?
想必大家从这里就能看出蹊跷。首先range是原数组最大值与最小值之差,计数排序有个bug就是这个range相差特别大。比如一个数组最小值是1最大值是10000,这时候根据range确定大小的count数组的空间就会开的非常大,那么空间消耗也就会非常大。这也是计数排序的局限性,所以计数排序在排序数据比较集中的数组时效率就会比较高。
以上就是所有排序算法的内容了,但是还没有结束我们再来分析一下这些排序算法的时间复杂度和稳定性。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,⽽在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
如果上面这段文字不够清楚我们画图来理解:

本质上就是看相对位置在排序前后是否发生改变。
下面来看看时间复杂度的分析:


上述排序的测试代码:
/ 测试排序的性能对⽐
void TestOP()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int)*N);
int* a2 = (int*)malloc(sizeof(int)*N);
int* a3 = (int*)malloc(sizeof(int)*N);
int* a4 = (int*)malloc(sizeof(int)*N);
int* a5 = (int*)malloc(sizeof(int)*N);
int* a6 = (int*)malloc(sizeof(int)*N);
int* a7 = (int*)malloc(sizeof(int)*N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N-1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
int begin7 = clock();
BubbleSort(a7, N);
int end7 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
printf("MergeSort:%d\n", end6 - begin6);
printf("BubbleSort:%d\n", end7 - begin7);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
}以上就是本章的全部内容啦!也是数据结构初阶的全部内容了!数据结构完结撒花! 感谢一路陪伴的读者,还望给一个免费的三连,你们的支持就是我最大的动力!