文章目录
[1. 排序的概念与应用](#1. 排序的概念与应用)
[1.1 排序的概念](#1.1 排序的概念)
[1.2 排序的应用](#1.2 排序的应用)
[2.1 直接插入排序](#2.1 直接插入排序)
[2.2 希尔排序](#2.2 希尔排序)
[3. 选择排序](#3. 选择排序)
[3.1 直接选择排序](#3.1 直接选择排序)
[4.2 堆排序](#4.2 堆排序)
[4.1 冒泡排序](#4.1 冒泡排序)
[4.2 快速排序](#4.2 快速排序)
[4.2.1 Haore](#4.2.1 Haore)
[4.2.2 挖坑法](#4.2.2 挖坑法)
[4.2.3 前后指针法](#4.2.3 前后指针法)
[4.2.4 非递归实现](#4.2.4 非递归实现)
前言
到这里,我们已经完成顺序表、链表、栈、队列、堆以及链式二叉树的学习,现在我们开始利用C语言实现八大排序算法,同时会附上详细讲解说明。
1. 排序的概念与应用
1.1 排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不断地在内外存之间移动数据的排序。
1.2 排序的应用
我们只需要稍加注意,就会发现排序在我们日常生活中随处可见。比如在购物软件种,会让我们选择一个参数如好评率、价格、销量或者综合评价......,在我们选择对应的参数后,就会对商品进行排序,同时也可以选择正序还是倒序展现。
2.插入排序
2.1 直接插入排序
动图演示:

直接插入排序是一种简单的插入排序法,其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为 止,得到一个新的有序序列。 实际中我们玩扑克牌时,就用了插入排序的思想。
具体思路:当插入第i(i>=1)个元素时,前面的array[0],array[1],...,array[i-1]已经排好序,此时用array[i]的排序码与 array[i-1],array[i-2],...的排序码顺序进行比较,以升序排序为例,如果arrary[i]小于arr[i-1],那么就让arrary[i-1]向后移动,再让arrary[i]与前面的数进行比较,直到找到合适的位置,就将arrary[i]插入数组。
我的思路:设置一个end,我们把从0到end的序列看作有序序列,那么对下标为end+1的数来说,要让它插入比前一个位置大,比后一个位置小的数,才能保持序列的有序性。我们将end+1个数保存起来,依次与有序序列比较,直到找到对应的位置,就插入,依次循环,把数组中所有的数进行插入。
代码:
cpp
void InsertSort(int* arr, int n)//时间复杂度O(n^2)
{
for (int i = 0;i < n - 1;i++)//要点1:这里i最多为n-2,因为要保证后面的tmp不越界
{
int end = i;
int tmp = arr[end + 1];
while (end >= 0)//从0到end都看成有序的
{
if (arr[end] > tmp)
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
arr[end+1] = tmp;//要点2:这里需要注意,
}
}
代码中标明了要点,以下是说明:
要点1:由于tmp=arr[end+1],所以end最大只能是n-2,否则越界访问
要点2:防止因为tmp需要一直调到最前面,导致end=-1,使得循环终止,那tmp就没有进行插入,所以我们将arr[end+1] = tmp;写在内层循环的外部。
直接插入排序的特性总结:
-
元素集合越接近有序,直接插入排序算法的时间效率越高,当数组是倒序时,需要的时间最长
-
时间复杂度:O(N^2)
-
空间复杂度:O(1),它是一种稳定的排序算法
-
稳定性:稳定
2.2 希尔排序
希尔排序是按其设计者希尔的名字命名的,该算法由希尔1959年公布。希尔可以说是一个脑洞非常大的人,他对普通插入排序的时间复杂度进行分析,得出了以下结论:
1.普通插入排序的时间复杂度最坏情况下为O(N2),此时待排序列为逆序,或者说接近逆序。
2.普通插入排序的时间复杂度最好情况下为O(N),此时待排序列为升序,或者说接近升序。
希尔就想若是能先将待排序列进行一次预排序,使待排序列接近有序(接近我们想要的顺序),然后再对该序列进行一次直接插入排序。因为此时直接插入排序的时间复杂度为O(N),那么只要控制预排序阶段的时间复杂度不超过O(N2),那么整体的时间复杂度就比直接插入排序的时间复杂度低了。
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成对应个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达**=1****时,所有记录在统一组内排好序**。
动画演示:


希尔排序特性总结:
1.希尔排序是对直接插入的优化
2.当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比
- 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定,我们常用的是令gap=gap/3+1;
问题:为什么要让gap由大到小呢?
answer:gap越大,数据挪动得越快;gap越小,数据挪动得越慢。前期让gap较大,可以让数据更快得移动到自己对应的位置附近,减少挪动次数。
我们先尝试进行一次gap分组排序:
cpp
int gap = 5;
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;
}
这是令gap=5,进行的一次排序,那我们只需要再加上一次循环,保证gap最后为1,就可以实现希尔排序了,以下是完整代码:
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];
for (end;end >= 0;end -= gap)
{
if (arr[end] > tmp)
{
arr[end + gap] = arr[end];
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
希尔排序的时间复杂度很难准确计算出来,大概是O(N^1.3),不稳定
3. 选择排序
3.1 直接选择排序

选择排序,即每次从待排序列中选出一个最小值,然后放在序列的起始位置,直到全部待排数据排完即可。
代码展示:
cpp
void SelectSort(int* arr, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int pmin = begin;
for (int i = begin;i <n;i++)
{
if (arr[i] < arr[pmin])
{
pmin = i;
}
}
Swap(&arr[begin], &arr[pmin]);
begin++;
}
}
实际上,我们可以一趟选出两个值,一个最大值 一个最小值,然后将其放在序列开头和末尾,这样可以使选择排序的效率快一倍。
代码实现:
cpp
void SelectSort(int* arr, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int pmax = begin, pmin = end;
for (int i = begin;i <= end;i++)
{
if (arr[i] > arr[pmax])
pmax = i;
if (arr[i] < arr[pmin])
pmin = i;
}
Swap(&arr[begin], &arr[pmin]);
if (pmax == begin)////防止最大值位于序列开头,被最小值交换
pmax = pmin;
Swap(&arr[end], &arr[pmax]);
begin++;
pmin--;
}
}
时间复杂度:O(N^2),稳定性:不稳定
4.2 堆排序
在堆用法深入解析中,我们已经讲到过了堆排序,要实现堆排序,首先要学会建堆,建堆就需要用到向下调整算法,那我们从向下调整算法开始说起。
向下调整算法起初被我们用于堆的删除函数,堆的根节点不符合堆的结构,但是其左右子树完全符合,所以需要将根节点向下调整。那么可以直到向下调整算法的前提是:堆的左右子树必须都是大堆或小堆。
我们将要实现的是升序,所以我们要实现对大堆的向下调整算法(原因在后面进行对排序实现时会详细说明)。
代码展示:
cpp
void AdjustDown(int* arr, int size, int parent)
{
//因为我们要找出左右孩子中较大的那一个进行交换
//我们先假设左孩子是较大的那一个
int child = 2 * parent + 1;
while (child < size)
{
if (child + 1 < size && arr[child + 1] > arr[child])
//如果右孩子存在,且比左孩子大,那么对孩子节点进行更新
{
child++;
}
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
向下建堆:
我们要对一个数组进行堆排序,首先要将数组转换成堆,这里我们使用更为高效的向下调整建堆,事件复杂度为O(N),我们先从最后一个叶子节点的父亲节点开始向下调整,然后一直到根节点再结束。
代码展示:
cpp
void BuildHeap(int* arr,int n)
{
for (int i = (n - 1 - 1) / 2;i >= 0;i--)
{
AdjustDown(arr, n, i);
}
}
堆排序核心思路:我们知道大堆的根节点的值一定是最大的,那如果我们将根节点与最后一个节点交换,再进行size--,然后重新进行调整,直到size=1。
cpp
void HeapSort(int* arr,int n)//堆排序
{
BuildHeap(arr, n);
int end = n - 1;
while (end > 0)
{
Swap(arr, &arr[end]);
AdjustDown(arr, end, 0);
end--;
}
}
时间复杂度:O(nlogn),稳定性:不稳定。
4.交换排序
4.1 冒泡排序
动图展示:

将相邻的两个数依次进行比较,大的数向后进行交换。
代码展示:
cpp
void BubbleSort(int* arr, int n)
{
for (int i = 0;i < n;i++)
{
int flag = 0;
for (int j = 1;j < n - i;j++)
{
if (arr[j - 1] > arr[j])
{
int tmp = arr[j - 1];
arr[j - 1] = arr[j];
arr[j] = tmp;
flag = 1;
}
}
if (flag == 0)
{
return;
}
}
}
时间复杂度O(n^2),稳定
4.2 快速排序
4.2.1 Haore
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
动图展示:

具体操作:我们需要设置一个Key值,我们以数组第一个元素作为Key,然后设置两个元素left,right,分别指向数组最左以及最右的下标,我们先让右边先行,这里是有讲究的,**左边为Key值,那么右边先行,反之,左边先行,**原因我在后面会分析。我们要求左边要比key值小,右边比Key值大,如果左边遇到比Key值大的就停下来,右边亦然,两边数据进行交换,再重复上面操作,直至两边相遇,然后将相遇点的值与Key进行交换,这里相遇点的值一定是要比key小的,这样Key在数组中的位置就确定了,我们就以Key为基准,进行左右分区,每一个区又重复上面操作,直到每个区只剩一个元素。
时间复杂度:O(nlogn),不稳定,空间复杂度O(logn);
为什么左边为Key值,右边先行?
我们既然设置了左边为Key值,那么最后进行交换时要保证相遇点的值比Key小 。我们知道left、right不是同时走,左边找大,右边找小
相遇的场景分析:
L遇R:R先走,停下来,R停下条件是遇到比key小的值,R停的位置一定比key小,L没有找到大的,遇到R停下了
R先走,找小,没有找到比key小的,直接跟L相遇了。L停留的位置R遇L:是上一轮交换的位置,上一轮交换,把比key小的值,换到L的位置了
我们不一定要求以左为key,但是一定要保证以哪边为key,相反的一边先走。
代码展示:
cpp
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return;
int pkey = left;
int begin = left, end = right;
while (begin < end)
{
while (begin<end && arr[end]>=arr[pkey])
{
end--;
}
while (begin<end && arr[begin]<=arr[pkey])
{
begin++;
}
Swap(&arr[begin], &arr[end]);
}
Swap(&arr[begin], &arr[pkey]);
QuickSort(arr, left, begin - 1);
QuickSort(arr, begin+1,right);
}
其实在这基础上,代码还是可以进一步优化。
三数取中

我们的key值应该尽量使得分区是一半一半分的,否则不仅效率降低,还有因为递归层数较深,栈溢出的风险,所以我们尽量把key值取中间大小的数。
cpp
int Getmidnum(int* arr,int left, int right)
{
int mid = left + (right-left) / 2;
if (arr[mid] > arr[left])
{
if (arr[mid] < arr[right])
return mid;
else if (arr[left] > arr[right])
return left;
else
return right;
}
else
{
if (arr[mid] > arr[right])
return mid;
else if (arr[left] < arr[right])
return left;
else
return right;
}
}
我们得到下标之后,进行Swap(&arr[pkey], &arr[left]);这样对后面的操作就没有影响了。
小区间优化
我们可以想到,就算是理想状态下的快速排序,也不能避免随着递归的深入,每一层的递归次数会以2倍的形式快速增长。
为了减少递归树的最后几层递归,我们可以设置一个判断语句,当序列的长度小于某个数的时候就不再进行快速排序,转而使用其他种类的排序。小区间优化若是使用得当的话,会在一定程度上加快快速排序的效率,而且待排序列的长度越长,该效果越明显。
完整优化代码展示:
cpp
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return;
int pkey = Getmidnum(arr,left,right);
Swap(&arr[pkey], &arr[left]);
pkey = left;
int begin = left, end = right;
if (end - begin + 1 < 10)
{
InsertSort(arr + left, end - begin + 1);
}
else
{
while (begin < end)
{
while (begin < end && arr[end] >= arr[pkey])
{
end--;
}
while (begin < end && arr[begin] <= arr[pkey])
{
begin++;
}
Swap(&arr[begin], &arr[end]);
}
Swap(&arr[begin], &arr[pkey]);
QuickSort(arr, left, begin - 1);
QuickSort(arr, begin + 1, right);
}
}
4.2.2 挖坑法

挖坑法的单趟排序的基本步骤如下:
1、选出一个数据(一般是最左边或是最右边的)存放在key变量中,在该数据位置形成一个坑。
2、还是定义一个L和一个R,L从左向右走,R从右向左走。(若在最左边挖坑,则需要R先走;若在最右边挖坑,则需要L先走)。
3、在走的过程中,若R遇到小于key的数,则将该数抛入坑位,并在此处形成一个坑位,这时L再向后走,若遇到大于key的数,则将其抛入坑位,又形成一个坑位,如此循环下去,直到最终L和R相遇,这时将key抛入坑位即可。(选取最左边的作为坑位)
经过一次单趟排序,最终也使得key左边的数据全部都小于key,key右边的数据全部都大于key。然后也是将key的左序列和右序列再次进行这种单趟排序,如此反复操作下去,直到左右序列只有一个数据,或是左右序列不存在时,便停止操作。
cpp
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return;
int begin = left, end = right;
int key = arr[left];//在左边形成一个坑位
while (begin < end)
{
while (begin < end && arr[end] >= key)
{
end--;
}
arr[begin] = arr[end];//填坑
while (begin < end && arr[begin] <= key)
{
begin++;
}
arr[end] = arr[begin];//填坑
}
int pmeet = begin;
arr[pmeet] = key;
QuickSort2(arr, left, pmeet - 1);
QuickSort2(arr, pmeet + 1, right);
}
4.2.3 前后指针法

我们的核心思想依旧是经过单趟排序,使得key的左边全部小于key,key的右边全部大于key。
具体过程:
1.依旧是设置两个变量,prev指向left,cur指向prev+1
2.如果cur遇到的值小于key,那么prev++,同时交换prev与cur的值,cur++;如果大于key,那么cur++
3.直到cur越界,交换prev以及key的值,单趟排序完成
后面依旧将key的左序列和右序列再次进行这种单趟排序,如此反复操作下去,直到左右序列只有一个数据,或是左右序列不存在时,便停止操作。
cpp
void QuickSort3(int* arr, int left, int right)
{
if (left >= right)
return;
int pkey = Getmidnum(arr, left, right);
Swap(&arr[pkey], &arr[left]);
pkey = left;
int prev = left, cur = prev + 1;
while (cur <= right)//当cur没有越界
{
if (arr[cur] < arr[pkey])
{
prev++;
Swap(&arr[prev], &arr[cur]);
}
cur++;
}
int pmeet = prev;
Swap(&arr[pkey], &arr[prev]);
QuickSort3(arr, left, pmeet - 1);
QuickSort3(arr, pmeet + 1, right);
}
我们把单趟排序的过程写上之后,后续的就交给递归了,那如果没有递归,我们能否实现快排?
4.2.4 非递归实现
我们要使用非递归实现快排,就需要知道函数递归调用栈帧核心是什么?我们以Hoare方法来看看。
cpp
void QuickSort(int* arr, int left, int right)
我们先看看函数参数,其中arr是个整型指针,在函数递归过程中不会改变,而left以及right,是每次函数调用的区间,每一组区间都是不一样的,那么关键就在这里了,函数递归传递的核心就是这些区间,那我们只需要把每次分区的左右端点保存起来,再调用函数就实现了对应区间的排序。那我们应该用什么来存储左右端点?栈。深入想想,会发现用栈来存储左右端点几乎完美复原了函数递归的过程。
代码实现思路:
1.我们先将最初的左右端点入栈,先入右端点再入左端点。
2.我们可以选取三中方法中的任意一种思想来进行单趟排序,我这里选取的是Hoare的方法
3.每次取到栈顶数据之后,要删除栈顶数据。
4.得到相遇点的下标之后,需要将分区之后的左右端点再次入栈,但是要判断数据是否合理,依次循环直到栈为空。
代码展示:
cpp
int QuickSort1(int* arr, int left, int right)
{
int pkey = Getmidnum(arr, left, right);
Swap(&arr[pkey], &arr[left]);
pkey = left;
int begin = left, end = right;
while (begin < end)
{
while (begin < end && arr[end] >= arr[pkey])
{
end--;
}
while (begin < end && arr[begin] <= arr[pkey])
{
begin++;
}
Swap(&arr[begin], &arr[end]);
}
Swap(&arr[begin], &arr[pkey]);
return begin;
}
void QuickSortNonR(int* arr,int left,int right)
{
stack st;
StackInit(&st);
StackPush(&st, right);
StackPush(&st, left);
while (!Stackempty(&st))
{
int begin = StackTop(&st);//左端点
Stackpop(&st);
int end = StackTop(&st);//右端点
Stackpop(&st);
int pmeet=QuickSort1(arr, begin, end);
//这里就需要把分好的区的端点入栈
// [left, pmeet-1] pmeet [pmeet+1, right]
if (end > pmeet + 1)
{
StackPush(&st, end);
StackPush(&st, pmeet+1);
}
if (begin < pmeet - 1)//保证有两个点
{
StackPush(&st, pmeet - 1);//右端点先进,左端点就会先出
StackPush(&st, begin);
}
}
StackDestroy(&st);
}
5.归并排序
**基本思想:**归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。

动图展示:

核心思想:把数组一直分,最后分成一个数据,就可以看成有序了,让它与相邻的数据进行合并,合并成一个有序序列,合并之后再合并,最终变成一个有序序列。
递归实现
cpp
void _MergeSort(int* arr, int* tmp, int left, int right)
{
if (left >= right)
return;
int mid = (left + right) / 2;
//递归进行分割
//分割的区间为[left,mid],[mid+1,right]
//这里分割有也讲究,否则会陷入死循环
_MergeSort(arr, tmp, left, mid);
_MergeSort(arr, tmp, mid + 1, right);
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int i = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[i++] = arr[begin1++];
}
else
{
tmp[i++] = arr[begin2++];
}
}
while (begin1 <= end1)//把剩下的一边全部加到tmp数组中
{
tmp[i++]= arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
memcpy(arr+left,tmp+left,(right - left + 1) * sizeof(int));
}
void MergeSort(int* arr,int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc failed");
return;
}
_MergeSort(arr, tmp, 0, n - 1);
free(tmp);
tmp = NULL;
}
非递归实现
归并排序的非递归算法并不需要借助栈来完成,我们只需要控制每次参与合并的元素个数即可,最终便能使序列变为有序:

当然,以上例子是一个待排序列长度比较特殊的例子,我们若是想写出一个广泛适用的程序,必定需要考虑到某些极端情况:
情况一:
当最后一个小组进行合并时,第二个小区间存在,但是该区间元素个数不够gap个,这时我们需要在合并序列时,对第二个小区间的边界进行控制。
情况二:
当最后一个小组进行合并时,第二个小区间不存在,此时便不需要对该小组进行合并。
情况三:
当最后一个小组进行合并时,第二个小区间不存在,并且第一个小区间的元素个数不够gap个,此时也不需要对该小组进行合并。(可与情况二归为一类)
代码展示:
cpp
void MergeSortNonR(int* arr, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc failed");
return;
}
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 + 2 * gap - 1;
if (begin2 >= n)//这里说明整个第二区间都越界了,那么就不需要合并了
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
int j=begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[j++] = arr[begin1++];
}
else
{
tmp[j++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = arr[begin2++];
}
memcpy(arr + i, tmp + i, (end2-i + 1) * sizeof(int));
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
6.计数排序
计数排序,又叫非比较排序。顾名思义,该算法不是通过比较数据的大小来进行排序的,而是通过统计数组中相同元素出现的次数,然后通过统计的结果将序列回收到原来的序列中。

计数排序的特性总结:
计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
时间复杂度:O(MAX(N,范围))
空间复杂度:O(范围)
4.稳定性:稳定
代码展示:
cpp
void CountSort(int* arr, int n)
{
int min = arr[0], max = arr[0];
for (int i = 0;i < n;i++)
{
if (min > arr[i])
min = arr[i];
if (max < arr[i])
max = arr[i];
}
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
for (int i = 0;i < n;i++)//把arr中出现的数字进行计数,数字与下标的差值为min
{
count[arr[i] - min]++;//映射
}
int j = 0;
for (int i = 0;i < range;i++)
{
while(count[i]--)
{
arr[j++] = i + min;
}
}
free(count);
count = NULL;
}
特性总结

