基本概念
- 排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
- 稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i] = r[j],且 r[i] 在 r[j] 之前,而在排序后的序列中,r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。
- 内部排序:数据元素全部放在内存中的排序。
- 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
常见排序算法
算法实现
本代码中所有的算法都是执行升序排序;写一个外部交换函数,在下面的代码中会经常出现,为防止大家疑惑,先写在下面:
//交换函数,在该项目中出现了多次交换,所以写个外部函数,简化代码
void swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
插入排序
- 方法:
- 给已有序的序列中插入新的数据,所以首先假设第一个数据已有序;
- 从剩余元素首元素开始向后遍历,将每一个元素当做带插入元素;
- 拿到元素之后从有序序列的最后一个位置开始向前遍历,找到第一个小于带插入数据的位置;
- 将带插入的数据放到找到位置的下一个位置;
- 重复步骤 2 ~ 4 直到元素全部插入完成;
- 特性:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
- 演示:
- 代码:
void InsertSqrt(int* arr, int n) {
//假设第一个数据有序,从第二个元素开始进行插入排序,直到结束
for (int idx = 1; idx < n; idx++) {
//先保留待排序数据,以防数组后移时将其覆盖,导致丢失
int temp = arr[idx];
//待排序元素从有序元素的末尾开始比较
int end = idx - 1;
//找一个小于等于待排序元素的数据,将待排序元素插入其后
while (end >= 0 && temp < arr[end]) {
arr[end + 1] = arr[end];
end--;
}
arr[end + 1] = temp;
}
}
希尔排序
- 方法:
- 希尔排序其实就是插入排序的优化算法,因为数据越有序,那么插入排序的性能越高;
- 提高数据的有序性的方法就是,增加数据移动的步长,从整体上减少数据移动的次数,将数据变得更有序;
- 将待排序的数组元素按下标的一定增量分组 ,分成多个子序列,然后对各个子序列进行插入排序算法排序;
- 然后依次缩减增量再进行排序,直到增量为1时,进行最后一次直接插入排序,排序结束。
- 特性:
- 希尔排序是对直接插入排序的优化。
- 当 gap > 1 时都是预排序,目的是让数组更接近于有序。当 gap == 1 时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
- 希尔排序的时间复杂度不好计算,需要进行推导,推导出来平均时间复杂度:O(N1.3 — N2)
- 稳定性:不稳定
- 演示:
- 代码:
void ShellSort(int* arr, int n) {
//每组中元素个数
int group = n;
while (group > 1) {
//对分组进行变化,且必须保证最后一组间隔为1,每个数据都在同一组
group = group / 3 + 1;
//一趟希尔排序
//假设每组的第一个数据有序,从每组的第二个元素开始进行插入排序,直到结束
for (int idx = group; idx < n; idx++) {
//先保留待排序数据,以防数组后移时将其覆盖,导致丢失
int temp = arr[idx];
//待排序元素从有序元素的末尾开始比较
int end = idx - group;
//找一个小于等于待排序元素的数据,将待排序元素插入其后
while (end >= 0 && temp < arr[end]) {
arr[end + group] = arr[end];
end -= group;
}
arr[end + group] = temp;
}
}
}
选择排序
- 方法:
- 在元素集合 array[i] — array[n-1] (i 从0开始) 中选择关键码最大的数据元素;
- 若它不是这组元素中的最后一个元素,则将它与这组元素中的最后一个元素交换;
- 在剩余的 array[i] — array[n-2] 集合中,重复上述步骤,直到集合剩余 1 个元素;
- 特性:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
- 演示:
- 代码:
void SelectSort(int* arr, int n) {
//循环遍历从待排序数组中找到一个最小值
for (int begin = 0; begin < n - 1; begin++) {
//最小元素的下标,从未排序数组第一个元素开始
int idx = begin;
//循环走到的待比较位置,从未排序数组第二个元素开始
int cur = begin + 1;
//从未排序数组中找到最小值
while (cur < n) {
//找到最小值,更新变量idx
if (arr[idx] > arr[cur]) {
idx = cur;
}
cur++;
}
//将未排序数组首元素和找到的最小值交换
swap(arr + begin, arr + idx);
}
}
- 改进:在循环的时候同时找最大值和最小值,将最大值、最小值放到合适的位置,这样可以减少算法排序的时间;
void SelectSort1(int* arr, int n) {
//循环遍历从待排序数组中找到一个最小值和一个最大值
for (int begin = 0, end = n - 1; begin < end; begin++,end--) {
//最小元素的下标,从未排序数组第一个元素开始
int min = begin;
//最大元素的下标,从未排序数组第一个元素开始
int max = begin;
//循环走到的待比较位置,从未排序数组第二个元素开始
int cur = begin + 1;
//从未排序数组中找到最小值
while (cur <= end) {
//找到最小值,更新变量min
if (arr[min] > arr[cur]) {
min = cur;
}
//找到最大值,更新变量max
if (arr[max] < arr[cur]) {
max = cur;
}
cur++;
}
//将未排序数组首元素和找到的最小值交换
swap(arr + begin, arr + min);
//如果最大值元素刚好出现在为排序数组首位置,那么上面的交换将会把最大值元素换到原先最小值的位置,所以需要做个判断
if (max == begin) {
max = min;
}
//将未排序数组尾元素和找到的最大值交换
swap(arr + end, arr + max);
}
}
堆排序
- 方法:
- 将初始待排序序列 R[1] — R[n] 通过向下调整算法构建成大堆 (升序排列建大堆,降序排列建小堆);
- 将堆顶元素与最后一个元素交换,此时将堆的区间调整为将堆尾元素排除之外的区间,也就是区间 R[1] — R[n - 1];
- 对此时的堆顶元素做向下调整算法,将堆保持为大堆;
- 循环执行 2 – 3 步骤;
- 如果大家对堆的概念不够清楚,那么可以看看我的二叉树这篇博客中的对部分详解;
- 特性:
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
- 演示:
- 代码:
//向下调整算法
void AdjustDwon(int* arr, int n, int root) {
//从某个非叶子节点开始走循环
while (root <= n / 2 - 1) {
//先拿到该节点的左右孩子
int left = root * 2 + 1;
int right = left + 1;
//然后拿到左右孩子中的最大值,如果没有右孩子,那么就拿到左孩子
int cur = left;
if (right < n) {
cur = arr[left] > arr[right] ? left : right;
}
//将拿到的值和当前节点比较
if (arr[root] < arr[cur]) {
//如果当前节点小于拿到的值,那么交换
swap(arr + root, arr + cur);
//并更新当前节点为交换的孩子结点
root = cur;
}
//如果没有交换,那么就说明不需要再调整了,直接结束循环
else {
break;
}
}
}
void HeapSort(int* arr, int n) {
//从数组的最后一个非叶子节点开始向下调整,直到首元素结束
for (int root = n / 2 - 1; root >= 0; root--) {
AdjustDwon(arr, n, root);
}
//然后将堆顶元素和堆尾元素交换,然后将堆尾元素排除堆外,对剩下的元素进行调整
//一直循环到堆内元素只有一个即可
int end = n - 1;
while (end > 0) {
swap(arr, arr + end);
AdjustDwon(arr, end--, 0);
}
}
冒泡排序
- 方法:
- R[1] — R[n] 区间的相邻元素进行比较,将较大的元素向后交换,直至序列结尾;
- 此时待排序区间为 R[1] — R[n-1];
- 重复执行 1 – 2 步骤,直到剩余元素为一个,则排序完成;
- 特性:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
- 演示:
- 代码:
void BubbleSort(int* arr, int n) {
//相邻元素进行比较,循环遍历的区间为第一个元素到未排序的最后一个元素
//外循环记录未比较的最后一个位置
for (int end = n - 1; end > 0; end--) {
//内循环从头开始遍历到未比较的最后一个,大的向后交换
for (int begin = 0; begin < end; begin++) {
if (arr[begin] > arr[begin + 1]) {
swap(arr + begin, arr + begin + 1);
}
}
}
}
- 改进:
- 如果上面代码中,里面一层循环在某次扫描中没有执行交换,则说明此时数组已经全部有序列,无需再扫描了,因此,增加一个标记,每次发生交换,就标记,如果某次循环完没有标记,则说明已经完成排序;
- 在上面的改进中,如果哪次循环没有数据交换,就说明已经有序,但是如果在某一轮排序中,在数组中后一段位置的元素没有发生数据交换,前一段中发生了数据交换,那么此时就可以判定后面这一段有序,不用比较了,所以改进方法为记录下最后一次发生交换的位置,作为下一次循环的结束为止即可。
void BubbleSort1(int* arr, int n) {
//相邻元素进行比较,循环遍历的区间为第一个元素到未排序的最后一个元素
//外循环记录未比较的最后一个位置
for (int end = n - 1; end > 0; end--) {
//增加标记位
int flag = 0;
//内循环从头开始遍历到未比较的最后一个,大的元素向后交换
for (int begin = 0; begin < end; begin++) {
if (arr[begin] > arr[begin + 1]) {
swap(arr + begin, arr + begin + 1);
flag = begin + 1;
}
}
//如果上面代码中,里面一层循环在某次扫描中没有执行交换,则说明此时数组已经全部有序列,无需再扫描了。
//因此,增加一个标记,每次发生交换,就标记,如果某次循环完没有标记,则说明已经完成排序。
if (flag == 0) {
return;
}
//如果上一轮排序中,在数组后一段的元素没有发生数据交换那么就可以判定这一段不用在进行比较了
end = flag;
}
}
快速排序
- 方法:
- hoare 法:①选取一个基准值(选取区间首元素),然后从剩余元素中开始遍历;②从后往前找到第一个小于基准值的数据,③从前往后找到第一个大于基准值的数据,④二者交换,⑤再循环执行前面的②③步骤,直到前后相遇,将基准值与相遇位置交换;
- 挖坑法:①先选取一个基准值(选取区间首元素),并将其保存,那么该位置相当于是一个坑,然后从剩余元素中遍历,②从后往前找一个小于基准值的数据,然后填坑,此时当前位置形成一个坑,③从前往后找一个大于基准值的数据,然后填坑,此时当前位置形成一个坑,④直到前后相遇,用基准值填坑;
- 前后指针法:①选取一个基准值(选取区间首元素),然后设置两个位置指针,prev 为上一个小于基准值的位置,cur 为当前位置,②当 cur 走到一个小于基准值的位置时,③如果 prev 和 cur 连续,那么二者向前更新,④如果 prev 和 cur 不连续,那么更新 prev,然后交换二者数据,更新 cur;
- 上面三种方法都涉及到了一个基准值的选取,如果随便选取首元素,就有可能会导致算法效率变低,那么我么可以采用三数取中法,也就是在待划分区间的[首元素----中间元素----尾元素]这三个数中取一个中间值,然后将其和首元素互换,此时待排序区间的首元素就为基准值;
- 上面三种方法是划分区间的过程,也就是将一段区间划分成 [小于基准值的区间----基准值----大于基准值的区间],然后返回基准值的位置,那么还需要一个函数来调用它,来给上面三种方法一个划分的区间,这有两种方法:递归法、非递归法;上面三种方法以及递归和非递归法我都会在下面写出代码的;
- 特性:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
- 时间复杂度:O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
- 演示:由于有三种划分的方法,所以没有找到合适的演示图,下面为我自己画的执行一次划分的过程,剩余的划分就一一演示了;
- hoare 法:
- 挖坑法:
- 前后指针法:
- hoare 法:
- 代码:
- 1- 三数取中法:
int GetMid(int* arr, int begin, int end) {
//就是要拿到首元素,中间元素,尾元素这三个中数值排中间的下标值
//取中间位置
int mid = (begin + end) / 2;
//先保存这三个位置的值到数组中
int carr[3] = { arr[begin],arr[mid],arr[end] };
//如果carr数组中首元素比中间元素大,那么就交换carr数组中的值,并交换在arr数组中对应的位置下标
if (carr[0] > carr[1]) {
swap(carr, carr + 1);
swap(&begin, &mid);
}
//在上面比较之后,如果carr数组中中间元素比尾间元素大,那么就交换carr数组中的值,并交换在arr数组中对应的位置下标
if (carr[1] > carr[2]) {
swap(carr + 1, carr + 2);
swap(&mid, &end);
}
//此时经过上面两次比较,carr[2]就是最大值,对应的下标为end,那么只要在carr[0]和carr[1]中找到次大值,那返回其对应下标即可找到中间值
return carr[0] > carr[1] ? begin : mid;
}
- 1- hoare 法:
int PartSort1(int* arr, int begin, int end) {
//先通过三数取中法拿到中间值的下标
int mid = GetMid(arr, begin, end);
//再与首元素交换即可
swap(arr + begin, arr + mid);
//保存基准值,方便后续比较操作
int key = arr[begin];
//保存首元素位置,方便 begin 与 end 相遇时的交换操作
int start = begin;
//开始循环遍历,结束条件就是前后相遇
while (begin < end) {
//从后向前找一个小于基准值的值
while (begin < end && arr[end] >= end) {
end--;
}
//从前向后找一个大于基准值的值
while (begin < end && arr[begin] <= end) {
begin++;
}
//二者交换
swap(arr + begin, arr + end);
}
//遍历结束后交换基准值和相遇值
swap(arr + start, arr + begin);
//返回划分之后的中间下标
return begin;
}
- 2- 挖坑法:
int PartSort2(int* arr, int begin, int end) {
//先通过三数取中法拿到中间值的下标
int mid = GetMid(arr, begin, end);
//再与首元素交换即可
swap(arr + begin, arr + mid);
//保存基准值,挖出坑
int key = arr[begin];
//开始循环遍历,结束条件就是前后相遇
while (begin < end) {
//从后向前找一个小于基准值的值
while (begin < end && arr[end] >= end) {
end--;
}
//找到之后填坑
arr[begin] = arr[end];
//从前向后找一个大于基准值的值
while (begin < end && arr[begin] <= end) {
begin++;
}
//找到之后填坑
arr[begin] = arr[end];
}
//遍历结束后用基准值填坑
arr[begin] = key;
//返回划分之后的中间下标
return begin;
}
- 3- 前后指针法:
int PartSort3(int* arr, int begin, int end) {
//先通过三数取中法拿到中间值的下标
int mid = GetMid(arr, begin, end);
//再与首元素交换即可
swap(arr + begin, arr + mid);
//选取基准值
int key = arr[begin];
//设置两个指针
int prev = begin;
int cur = begin + 1;
//只要 cur 没走完序列,就继续循环
while (cur <= end) {
//如果找到比基准值小的且 cur 与 prev 不连续,那就交换 cur 与 prev 的后一个位置
if (arr[cur] < key && ++prev != cur) {
swap(arr + prev, arr + cur);
}
//更新变量
cur++;
}
//最终交换基准值和最后一个小于基准值的值,也就是 prev 的值
swap(arr + begin, arr + prev);
//返回划分之后的中间位置下标
return prev;
}
- 1- 递归调用:
void QuickSort(int* arr, int begin, int end) {
//如果首元素位置大于等于尾元素位置,那么就结束递归
if (begin >= end) {
return;
}
//获取到划分之后的中间位置
int div = PartSort1(arr, begin, end);
//再对中间位置的左右区间进行快速排序
QuickSort(arr, begin, div - 1);
QuickSort(arr, div + 1, end);
}
- 2- 非递归调用
void QuickSortNonR(int* arr, int n) {
//非递归实现需要一个容器,这里我们选择队列
//创建队列
QHead q;
//初始化队列
QueueInit(&q);
//首先将序列区间放入,按照先放开始位置,再放结束位置的顺序存入
QueuePush(&q, 0);
QueuePush(&q, n - 1);
//循环遍历队列,直到队列为空
while (!QueueEmpty(&q)) {
//先拿到的是开始位置,然后出队
int left = QueueFront(&q);
QueuePop(&q);
//接着拿到结束为止,然后出队
int right = QueueFront(&q);
QueuePop(&q);
//然后对区间进行划分
int div = PartSort1(arr, left, right);
//如果划分之后的两个区间元素个数大于一,那么就按照上面的方式存入
if (left < div - 1) {
QueuePush(&q, left);
QueuePush(&q, div - 1);
}
if (div + 1 < right) {
QueuePush(&q, div + 1);
QueuePush(&q, right);
}
}
}
归并排序
- 方法:
- 将序列按照数据的位置,均匀划分为两个子序列,直到所有序列中只有一个数据;
- 当只有一个数据时,每个序列都是有序的,然后将相邻的两个有序序列进行有序合并,直到所有序列合并完为止;
- 这个代码的书写也分为递归和非递归的,我会将代码一一写出的;
- 特性:
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
- 演示:
- 代码:
-
- 递归法:
//递归调用函数
void Merge(int* arr, int begin, int end, int* temp) {
//递归结束条件,当序列区间只有一个元素的时候就结束递归
if (begin >= end) {
return;
}
//找到序列划分的中间位置
int mid = (begin + end) / 2;
int midpp = mid + 1;
//对以 mid 进行划分的左右区间进行深度划分
Merge(arr, begin, mid, temp);
Merge(arr, midpp, end, temp);
//用来记录该存放进temp的位置
int count = begin;
//用来记录开始的位置,方便后面从temp拷贝到arr找到起始位置
int start = begin;
//合并两个有序数组,两个有序数组为[begin, mid] 和 [midpp, end]
while (begin <= mid && midpp <= end) {
//从两个数组头开始,找那个数更小,小的先放入temp,等到某个数组全部拷贝完,那么就结束循环
if (arr[begin] <= arr[midpp]) {
temp[count++] = arr[begin++];
}
else {
temp[count++] = arr[midpp++];
}
}
//此时判断是否否拷贝完,如果没有,那么就将剩余元素依次拷贝过去
if (begin <= mid) {
memcpy(temp + count, arr + begin, sizeof(int) * (mid - begin + 1));
}
if (midpp <= end) {
memcpy(temp + count, arr + midpp, sizeof(int) * (end - midpp + 1));
}
//最终将临时空间temp中的有序数据放回arr数组中
memcpy(arr + start, temp + start, sizeof(int) * (end - start + 1));
}
// 归并排序递归实现
void MergeSort(int* arr, int n) {
//创建临时空间
int* temp = (int*)malloc(sizeof(int) * n);
//开始递归调用
Merge(arr, 0, n - 1, temp);
//最终释放掉自己开辟的空间
free(temp);
}
-
- 非递归法:
void MergeSortNonR(int* arr, int n) {
//先开辟临时空间
int* temp = (int*)malloc(sizeof(int) * n);
//组别的划分,从下至上依次为1,2,4,8...
int group = 1;
//开始循环设置组别
while (group < n) {
//先拿到第一段有序区间的开始位置begin,结束位置mid
int begin = 0;
int mid = begin + group - 1;
//再拿到第二段有序区间的开始位置midpp
int midpp = mid + 1;
//开始合并两端有序区间,如果第二段有序区间的开始位置超出了数组大小,那就说明没有第二段,只剩一段有序区间,那也就不需要合并了
while (midpp < n) {
//用来记录该存放进temp的位置
int count = begin;
//用来记录开始的位置,方便后面从temp拷贝到arr找到起始位置
int start = begin;
//拿到第二段有序区间的结束位置end
int end = midpp + group - 1;
//判断是否超出数组大小,如果超出,那么就设置为最后一个数据的位置
if (end >= n) {
end = n - 1;
}
//合并两个有序数组,两个有序数组为[begin, mid] 和 [midpp, end]
while (start <= mid && midpp <= end) {
//从两个数组头开始,找那个数更小,小的先放入temp,等到某个数组全部拷贝完,那么就结束循环
if (arr[start] <= arr[midpp]) {
temp[count++] = arr[start++];
}
else {
temp[count++] = arr[midpp++];
}
}
//此时判断是否否拷贝完,如果没有,那么就将剩余元素依次拷贝过去
if (start <= mid) {
memcpy(temp + count, arr + start, sizeof(int) * (mid - start + 1));
}
if (midpp <= end) {
memcpy(temp + count, arr + midpp, sizeof(int) * (end - midpp + 1));
}
//最终将临时空间temp中的有序数据放回arr数组中
memcpy(arr + begin, temp + begin, sizeof(int) * (end - begin + 1));
//更新变量
begin = end + 1;
mid = begin + group - 1;
midpp = mid + 1;
}
//更新组别
group *= 2;
}
//释放空间
free(temp);
}
计数排序
- 方法:
- 找出待排序的数组中最大和最小的元素;
- 创建数组 arr,数组 arr 的起始位置为最小值,结束位置是最大值;
- 统计待排序数组中每个值出现的次数,存入数组 arr 对应下标的位置;
- 将 arr 数组的下标依次放回原数组中,arr 下标对应的数字是多少,那就在原数组中填入多少个;
- 特性:
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
- 时间复杂度:O(MAX(N,范围))
- 空间复杂度:O(范围)
- 稳定性:稳定
- 演示:
- 代码:
void CountSort(int* arr, int n) {
//最大最小值
int min = arr[0];
int max = arr[0];
//使用一次选择排序,选出最大值和最小值
for (int i = 0; i < n; i++) {
if (arr[i] < min) {
min = arr[i];
}
if (arr[i] > max) {
max = arr[i];
}
}
//申请辅助空间的空间
int* arry = (int*)calloc(max - min + 1, sizeof(int));
//将待排序数遍历,将数值为 i 的数,在辅助数组的 i + min 位置加加
for (int i = 0; i < n; i++) {
arry[arr[i] - min]++;
}
//将辅助空间下标写入原数组,写入的数量靠下标对应值决定
int idx = 0;
for (int i = 0; i < max - min + 1; i++) {
//只要下标对应的数不为零,就将下标写入到原数组
while (arry[i]--) {
arr[idx++] = i + min;
}
}
//释放空间
free(arry);
}
性能比较
在同一个项目中,使用十万个随机数据对这八种算法进行性能测试,方法就是:生成十万个随机数据,然后拷贝八份,再使用这些算法进行排序,用clock()
函数记录每个算法排序开始和结束的时间,再相减得到时间差来比较性能,时间单位是毫秒。
完整代码
请看另一篇博客:排序算法