前言
排序作为生产环境中常见的需求之一,对整个产品有举足轻重的影响,可以说使用一个合适的排序算法是业务逻辑中比较重要的一部分。今天我们就来介绍常见的排序算法以及实现
排序
所谓排序无非就是按照特定的规则对一组数据就行顺序化。
常见的排序有升序和降序。
对于数字类型的数据常见的规则是按照其大小排序。如果学过Java的朋友应该知道,Java引入了对象的概念,对象的排序规则一般需要程序员自己定义,实现Comparable或者Comparator接口,在在接口内部实现排序的逻辑
除排序本身的定义外,我们还需要了解一点关于排序的性质
- 稳定性:当“大小”一致的两个数据应该如何规定两者的顺序呢?比如一个班级中有两位考100分的同学,我们应该如何规定两者的顺序呢?显然,一个常见的想法是谁先交卷谁是第一名。这种保证“交卷顺序”的排序算法,我们可以描述为稳定的排序算法。稳定性即保证排序后“大小”一致的数据顺序与排序前一致
- 内/外排序:这是一组相对的概念。内部排序要求排序的数据全部在内存中完成排序。外部排序要求排序的数据在硬盘内存中移动来完成排序,常用于数据量巨大或者内存不够的时候使用。
常见的排序算法
常见的排序算法有比较类的排序:冒泡排序,插入排序,希尔排序,选择排序,堆排序,快速排序,归并排序
非比较类的排序:基数排序,计数排序,桶排序
下面我们就一个一个来了解上述算法的思想和代码实现,以下笔者均以排升序举例。
笔者在排序算法的实现中可能会用到交换算法,在这里先实现,后面不在介绍
//交换元素
void swap(int* p1, int* p2) {
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
这里是一个简答的交换,我们只是把它封装一层,实现代码的复用
冒泡排序
冒牌排序,思路是相邻两个数据就行比较,遍历一遍为一趟排序。一趟排序后可以保证本趟最大值位于合适的位置。由此,每趟排序后下一趟的比较次数可以优化。也可以定义一个标记,如果本趟没有交换,整个序列均有序
实现
//冒泡排序
void BubbleSort(int* a, int n);
void BubbleSort(int* a, int n)
{
for (int y = 0; y < n - 1; y++)
{
int flag = 1;
int len = n - y;
for (int i = 0; i < len - 1; i++)
{
if (a[i] > a[i + 1])
{
swap(a + i + 1, a + i);
flag = 0;
}
}
if (flag) return;
}
}
1. 冒泡排序是一种非常容易理解的排序
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:稳定
插入排序
插入排序,思路是假设前n个数据已经有序,第n+1个数据插入到有序的序列中。这个算法的适应性很强
实现
//插入排序
void InsertSort(int* a, int n);
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:稳定
希尔排序
希尔排序是对插入排序的优化。插入排序的缺陷在于,在将降序排序成升序时,后面的较小值越来越难以移动到前面的位置,因为移动的次数由1,2,3……往上自增。希尔排序的优化在于增加跨度的方让序列更快的接近有序。 引入一个gap变量,即数据分为gap组,以组为单位进行插入排序。不断改变gap的值,有两个常用的gap算式 gap=gap/2 和 gap=gap/3+1。这两个算式均可以保证最后一次gap为1,即保证最后一次希尔排序转换为插入排序(此时序列接近有序,前面介绍插入排序时介绍插入排序的适应性很强,表现在排序一个接近有序的序列时效率很高)。当然也可以自定义gap算式,但是需要保证gap最后一次的取值是1
实现
//希尔排序
void ShellSort(int* a, int n);
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
int i = 0;
while (i < gap)
{
int y = i;
while (1)
{
if (y >= n || y + gap >= n)
break;
if (a[y + gap] < a[y]) {
int x = y;
int tmp = a[y + gap];
while (x >= 0 && tmp < a[x])
{
a[x + gap] = a[x];
x -= gap;
}
a[x + gap] = tmp;
}
y += gap;
}
i++;
}
}
}
1. 可以理解为gap!=1时是预排序,gap=1时是插入排序。希尔排序是对插入排序的优化
2. 时间复杂度:O(N^1.3)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
选择排序
选择排序是一个比较简单的排序算法,主要的逻辑就是每一趟找出最小的值,和本趟开头位置的元素呼唤。这里可以有一点优化,就是在一趟遍历的时候,同时找最大值和最小值。
实现
这里我们提供两个版本的实现,优化和非优化的版本(说是优化,其实性能并没有提升多少)
//选择排序
void SelectSort(int* a, int n);//优化版,同时选择大小
void SelectSort1(int* a, int n);//只选择小
void SelectSort1(int* a, int n)//取小交换
{
for (int y = 0; y < n - 1; y++) {
int i = y;
int tmp = y + 1;
for (; tmp < n; tmp++)
{
if (a[tmp] < a[i])
i = tmp;
}
swap(a + i, a + y);
}
}
void SelectSort(int* a, int n)//优化,取大小交换
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int min = begin;
int max = begin;
for (int i = begin + 1; i <= end; i++)
{
if (a[i] < a[min])
min = i;
else if (a[i] > a[max])
max = i;
}
swap(a + begin, a + min);
if (begin == max)
max = min;
swap(a + end, a + max);
begin++;
end--;
}
}
1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
堆排序
堆排序是依赖于堆这个数据结构实现的一种排序算法。什么是堆这里不再展开。我们想要用堆排序算法实现升序,需要用所有的数据建立一个大根堆,然后不断的pop堆顶的元素与堆末尾(逻辑上是堆,实际上是数组)元素交换,调整堆……知道堆中只剩一个元素,此时排序完成。
实现
//堆排序
void HeapSort(int* a, int n);
void xiangXiaTiaoZheng(int* arr, int pr, int k) {//向下调整
int ch = pr * 2 + 1;
while (ch < k) {
if (ch + 1 < k && arr[ch + 1] > arr[ch]) ch++;
if (arr[ch] > arr[pr]) {
swap(arr + ch, arr + pr);
pr = ch;
ch = pr * 2 + 1;
}
else return;
}
}
void jianDui(int* arr, int k)//建堆
{
for (int i = (k - 2) / 2; i >= 0; i--) {
xiangXiaTiaoZheng(arr, i, k);
}
}
void HeapSort(int* a, int n)
{
jianDui(a, n);
int end = n - 1;
while (end > 0) {
swap(a, a + end);
xiangXiaTiaoZheng(a, 0, end);
end--;
}
}
1. 堆排序使用堆来选数,效率就高了很多(topK问题)。向下调整建堆的算法要比向上调整建堆的算法效率高
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
快速排序
快速排序的思想是以每趟开始的元素为基准,将大于基准的元素放在基准数的右边,将小于基准的元素放在基准的左边,此时可以保证基准位于合适的位置。然后再分别处理基准的左半边和右半边,知道所有的元素都位于合适的位置上。
我们可以将实现分类为递归实现和非递归实现
递归实现
递归实现的大体逻辑是相同的,只不过在每一趟的实现有可以分为不同的版本。这里我们介绍三种不同的实现方式——hoare版本、挖坑版本、双指针版本
hoare版本
这里单趟的逻辑是以左侧开始位置的元素为基准元素,右边开始向左遍历在第一个小于基准元素的位置停下,左边开始向右遍历在第一个大于基准元素的位置停下,交换左右的元素,以上逻辑循环直到左右相遇,退出循环再交换相遇位置和起始位置的元素。
void QuickSort(int* a, int begin, int end);//hoare版本
void QuickSort(int* a, int begin, int end)//hoare版本
{
if (begin >= end)
return;
/*
if ((end - begin + 1) < 10)
{
InsertSort(a + begin, end - begin + 1);
return;
}
int z = QuZhong(a, begin, end);
swap(a + begin, a + z);
*/
int tmp = a[begin];
int left = begin;
int right = end;
while (left < right)
{
while (left < right && a[right] >= tmp)
{
right--;
}
while (left < right && a[left] <= tmp)
{
left++;
}
swap(a + left, a + right);
}
swap(a + begin, a + left);
QuickSort(a, begin, left - 1);
QuickSort(a, left + 1, end);
}
此版本是最初实现快速排序是使用的版本。
为了方便理解,后面再开发出双指针法和挖坑法
挖坑版本
挖坑法单趟的思路是以左侧开始位置的元素为基准元素,同时将左侧开始位置的元素记录到tmp中,设置此位置为坑。右边开始向左遍历在第一个小于基准元素的位置停下,将此位置的元素放到坑中,再将此位置设置为坑。将左边开始向右遍历在第一个大于基准元素的位置停下,将此位置的元素放到坑中,再将此位置设置为坑。以上逻辑循环直到左右相遇,退出循环,此时相遇的位置是坑位,将tmp添入坑位中
void QuickSort1(int* a, int begin, int end);//挖坑法版本
void QuickSort1(int* a, int begin, int end)
{
if (begin >= end)
return;
/*
if ((end - begin + 1) < 10)
{
InsertSort(a + begin, end - begin + 1);
return;
}
int z = QuZhong(a, begin, end);
swap(a + begin, a + z);
*/
int tmp = a[begin];
int tmpi = begin;
int left = begin;
int right = end;
while (left < right)
{
while (left < right && a[right] >= tmp)
{
right--;
}
if (left < right)
{
swap(a + tmpi, a + right);
tmpi = right;
left++;
}
while (left < right && a[left] <= tmp)
{
left++;
}
if (left < right)
{
swap(a + tmpi, a + left);
tmpi = left;
right--;
}
}
QuickSort1(a, begin, left - 1);
QuickSort1(a, left + 1, end);
}
双指针版本
以本趟开始位置的元素为基准元素。定义两个指针变量,第一个指针prev指向本趟开始的元素,第二个指针cut指向本趟的第二个元素。如果cut指向的元素小于等于基准,则prev指针+1,prev与cut元素交换,cut指针+1:否则cut指针+1。直到cut指向空指针,退出循环,将基准元素与prev指向的元素互换。
void QuickSort2(int* a, int begin, int end);//双指针版本
void QuickSort2(int* a, int begin, int end)
{
if (begin >= end)
return;
/*
if ((end - begin + 1) < 10)
{
InsertSort(a + begin, end - begin + 1);
return;
}
int z = QuZhong(a, begin, end);
swap(a + begin, a + z);
*/
int prev = begin;
int cut = begin + 1;
int tmp = a[begin];
while (cut <= end)
{
while (cut <= end && a[cut] <= tmp)
{
prev++;
cut++;
}
while (cut <= end && a[cut] > tmp)
{
cut++;
}
if (cut <= end)
{
prev++;
swap(a + prev, a + cut);
}
}
swap(a + begin, a + prev);
QuickSort2(a, begin, prev - 1);
QuickSort2(a, prev + 1, end);
}
非递归实现
递归版本在内存的开销上有可能会造成栈溢出,此时改成非递归是常见的需求。该非递归的核心思路就是用栈模拟递归的过程,利用栈存储关键的信息。
非递归需要依赖于栈这个数据结构,这里也不在展开
栈:
#pragma once
#include <stdio.h>
#include <stdlib.h>
// 支持动态增长的栈
typedef int STDataType;
typedef struct Stack
{
STDataType* _a;
int _top; // 栈顶
int _capacity; // 容量
}Stack;
// 初始化栈
void StackInit(Stack* ps);
// 入栈
void StackPush(Stack* ps, STDataType data);
// 出栈
void StackPop(Stack* ps);
// 获取栈顶元素
STDataType StackTop(Stack* ps);
// 获取栈中有效元素个数
int StackSize(Stack* ps);
// 检测栈是否为空,如果为空返回非零结果,如果不为空返回0
int StackEmpty(Stack* ps);
// 销毁栈
void StackDestroy(Stack* ps);
#define _CRT_SECURE_NO_WARNINGS 1
#include "Stack.h"
void StackInit(Stack* ps)
{
ps->_a = NULL;
ps->_top = -1;
ps->_capacity = 0;
}
void StackPush(Stack* ps, STDataType data)
{
if (ps->_capacity == 0) {
ps->_a = (STDataType*)malloc(sizeof(STDataType) * 4);//默认初始化长度为4
ps->_capacity = 4;
ps->_top = 0;
}
else if (ps->_top == ps->_capacity)
{
ps->_capacity *= 2;
ps->_a = (STDataType*)realloc(ps->_a, sizeof(STDataType) * ps->_capacity);//默认扩容至原先的两倍
}
(ps->_a)[ps->_top++] = data;
}
void StackPop(Stack* ps)
{
if (ps->_top <= 0) return;
ps->_top--;
}
STDataType StackTop(Stack* ps) {
if (ps->_top <= 0) return 0;
return (ps->_a)[--ps->_top];
}
int StackSize(Stack* ps)
{
return ps->_top;
}
int StackEmpty(Stack* ps)
{
if (ps->_top <= 0) return 1;
return 0;
}
void StackDestroy(Stack* ps)
{
free(ps->_a);
ps->_a = NULL;
ps = NULL;
}
void QuickSortNonR(int* a, int begin, int end);//非递归版本
void QuickSortNonR(int* a, int begin, int end) //非递归版本
{
Stack sk;
StackInit(&sk);
StackPush(&sk, end);
StackPush(&sk, begin);
while (!StackEmpty(&sk))
{
int left = StackTop(&sk);
int right = StackTop(&sk);
int rembegin = left;
int remend = right;
int tmp = a[left];
while (left < right)
{
while (left < right && a[right] >= tmp)
{
right--;
}
while (left < right && a[left] <= tmp)
{
left++;
}
swap(a + left, a + right);
}
swap(a + rembegin, a + left);
if (left + 1 < remend) {
StackPush(&sk, remend);
StackPush(&sk, left + 1);
}
if (rembegin < left - 1) {
StackPush(&sk, left - 1);
StackPush(&sk, rembegin);
}
}
StackDestroy(&sk);
}
优化
在此基础上可以对快速排序展开两个简单的优化
- 小区间优化(当排序元素小于10可以转换为插入排序,减少递归的深度,减少开销)
将代码中注释的部分放开即可以实现小区间优化
- 三数取中(避免出现向一边递归,算法退化为N^2的情况)
int QuZhong(int* arr, int first, int last)//优化,三数取中
{
int z = (first + last) / 2;
int a = arr[first];
int b = arr[z];
int c = arr[last];
if (b > c)
{
if (c > a)
return last;
else
{
if (a < b)
return first;
else
return z;
}
}
else
{
if (b > a)
return z;
else
{
if (a < c)
return first;
else
return last;
}
}
}
1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)
4. 稳定性:不稳定
归并排序
归并排序是一种典型的分治思想,将大问题拆分成不能再分割的小问题。思路在将待排序不断对半分割分割,分割到不能再分割时将相邻两个组合并为一个有序的大组,再将两个相邻的两个大组合并为一个有序的大大组……
这里依然是提供两个版本的实现,递归版和非递归版
这里非递归的实现,是直接将单个元素视为递归下来不能再分割的最小单位,实现往上回归的部分
//归并排序
void MergeSort(int* a, int begin, int end);//递归版本
void MergeSortNonR(int* a, int begin, int end);//非递归版本
void MergeSort(int* a, int begin, int end)//归并排序
{
int n = end - begin + 1;
if (n < 2) return;
int min = begin + n / 2;
MergeSort(a, begin, min - 1);
MergeSort(a, min, end);
int* arr = (int*)malloc(sizeof(int) * n);
int left = begin;
int right = min;
int i = 0;
while (left < min && right <= end)
{
if (a[left] < a[right])
{
arr[i++] = a[left++];
}
else
{
arr[i++] = a[right++];
}
}
if (left == min) {
while (right <= end) {
arr[i++] = a[right++];
}
}
else
{
while (left < min) {
arr[i++] = a[left++];
}
}
i = 0;
for (int y = begin; y <= end; y++)
{
a[y] = arr[i++];
}
free(arr);
}
void MergeSortNonR(int* a, int begin, int end)//非递归版本
{
int gap = 1;
int n = end - begin + 1;
int* arr = (int*)malloc(sizeof(int) * n);
while (gap < n)
{
for (int y = 0; y < n; y += gap * 2)
{
int left = y;
int right = y + gap;
if (right >= n) {
for (int z = left; z < n; z++)
{
arr[z] = a[z];
}
break;
}
int i = y;
int end1 = y + gap;
int end2 = right + gap;
if (end2 >= n)
end2 = n;
while (left < end1 && right < end2)
{
if (a[left] < a[right])
{
arr[i++] = a[left++];
}
else
{
arr[i++] = a[right++];
}
}
if (left == end1) {
while (right < end2) {
arr[i++] = a[right++];
}
}
else
{
while (left < end1) {
arr[i++] = a[left++];
}
}
}
for (int z = 0; z < n; z++)
{
a[z] = arr[z];
}
gap *= 2;
}
free(arr);
}
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定
计数排序
计数排序是一种非比较的排序,适用于对整数进行排序。思想是得到序列中的最大值和最小值,计算得到中间最多有多少个元素,遍历一遍序列,将元素映射到计数表中,再从计数表中依次读取出元素。
//计数排序
void CountSort(int* a, int n);
void CountSort(int* a, int n)
{
int min = a[0];
int max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] < min)
min = a[i];
else if (a[i] > max)
max = a[i];
}
int countN = max - min + 1;
int* arr = (int*)calloc(countN, sizeof(int));
for (int i = 0; i < n; i++)
{
arr[a[i] - min]++;
}
int y = 0;
for (int i = 0; i < countN&&y<n; i++)
{
int sz = arr[i];
while (sz--)
{
a[y++] = i + min;
}
}
}
1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2. 时间复杂度:O(MAX(N,范围))
3. 空间复杂度:O(范围)
4. 稳定性:稳定
桶排序和基数排序
桶排序和基数排序不常用,这里只简单介绍思想不介绍具体实现
桶排序工作的原理是将数组分到有限数量的桶里。每个桶再分别排序,最后依次把各个桶中的记录列出来记得到有序序列
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较
排序算法的比较
结语
排序这部分重点掌握不同算法的思想,把握一趟是如何实现的,其他每一趟都是一样的思想
以上便是今天的全部内容。如果有帮助到你,请给我一个免费的赞。
因为这对我很重要。
编程世界的小比特,希望与大家一起无限进步。
感谢阅读!