《数据结构初阶》【八大排序——巅峰决战】
【八大排序——巅峰决战】目录
- 前言:
- ---------------排序竞赛---------------
- 一、比赛背景:
- 二、赛前须知:
- 三、比赛进行中……
- 头文件
- Sort.h
- Stack.h
- 实现文件
- Sort.c
- Stack.c
- 测试文件
- 四、比赛结果:
- 五、颁奖仪式:
- ---------------性能分析---------------
- 一、直接插入排序
- 二、简单选择排序
- 三、冒泡排序
- 四、希尔排序
- 五、堆排序
- 六、快速排序
- 七、归并排序
- 八、计数排序
往期《数据结构初阶》回顾:
【时间复杂度 + 空间复杂度】
【顺序表 + 单链表 + 双向链表】
【顺序表/链表 精选15道OJ练习】
【顺序栈 + 链式队列 + 循环队列】
【链式二叉树】
【堆 + 堆排序 + TOP-K】
【二叉树 精选9道OJ练习】
【八大排序——群英荟萃】
前言:
哈喽小伙伴们~让大家久等啦🥰!
这里咱先简单回顾一下:上一集咱们在 【八大排序 —— 群英荟萃】 里,详细盘了盘八大排序算法~
不过呢,这八大排序各个都有脾气,谁也不服谁 (╯‵□′)╯︵┻━┻,非要争个高下👑🤔!
于是,今天!我们终于迎来了这场史诗级的巅峰对决! 🏆
OK了,那接下来咱们就来围观这场超精彩的八大排序巅峰对决吧✨~
(准备好瓜子零食,战斗马上开始!🍿)
---------------排序竞赛---------------
一、比赛背景:
在上一集中,我们对八大排序进行了性能总结,具体内容如下方图片所示:
但是博主相信此时的一些小伙伴们可能会说了,图中的时间复杂度什么的都是理论值,在实际应用场景中,情况究竟如何呢?
快排是不是空有虚名?而冒泡排序会不会在实战中大放异彩呢?(嗯~ o( ̄▽ ̄)o博主认为冒泡排序并不会大放异彩,真的不是因为博主看不起冒泡排序啊╥﹏╥…)
为了解答小伙伴们心中的疑问,下面我们精心设计了一个程序,通过实际运行来精确测试这些排序算法的速度,一探究竟。
二、赛前须知:
🎲 【算法竞技场·巅峰对决】 🏆
“好啦各位小伙伴们~ 下注时间到啦!💰(咳咳,当然是虚拟筹码啦~)”
“为了公平公正地进行这场史诗级算法对决,我们特别将所有选手分为三个重量级战队:”
⚡ 第一战队: O ( n l o g n ) O(nlogn) O(nlogn) 超跑组
- 🎢 希尔排序:分组插入的闪电侠
- 🏔️ 堆排序:二叉树力量的机械战士
- 🚀 快速排序:分治法的速度之王
- 📚 归并排序:稳定输出的空间魔法师
🐢 第二战队: O ( n 2 ) O(n²) O(n2) 耐力组
- 🧩 直接插入排序:部分有序场景的刺客
- 🎯 简单选择排序:慢性子的交换节能家
- 🫧 冒泡排序:朴实无华的入门导师
❓ 第三战队: O ( n ) O(n) O(n) 极速组
- 🧮 计数排序:线性时间的数字清道夫
三、比赛进行中……
头文件
Sort.h
-------------------------------------Sort.h-------------------------------------
#pragma once//任务1:包含要使用头文件
#include<stdio.h>
#include <time.h>
#include <string.h>
#include "Stack.h"//任务2:声明排序要使用的一些辅助的函数
//0.数组中元素的打印
//1.数组中两个元素的交换
//2.堆排序的向下调整算法
//3.三数取中
void PrintArray(int* a, int n);
void Swap(int* a, int* b);
void AdjustDown(int* a, int parent, int n);
int GetMid(int* a, int left, int right);//任务3:声明要实现的排序算法函数
/*------------------------------------------比较排序------------------------------------------*/
/*---------------------插入排序---------------------*///1.直接插入排序
//2.希尔排序
void InsertSort(int* a, int n);
void ShellSort(int* a, int n);/*---------------------选择排序---------------------*///1.简单选择排序
//2.堆排序
void SelectSort(int* a, int n);
void HeapSort(int* a, int n);/*---------------------交换排序---------------------*///1.冒泡排序
//2.快速排序(递归版)
//3.快速排序(非递归版)
void BubbleSort(int* a, int n);
void QuickSort(int* a, int left, int right);
void QuickSortNonR(int* a, int left, int right);/*---------------------归并排序---------------------*/
//1.归并排序(递归版)
//2.归并排序(非递归版)
void MergeSort(int* a, int n);
void MergeSortNonR(int* a, int n);/*------------------------------------------非比较排序------------------------------------------*/
/*---------------------计数排序---------------------*/
void CountSort(int* a, int n);
Stack.h
#pragma once//任务1:定义头文件
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <stdbool.h>//任务2:定义栈的结构体
typedef int STKDateType; //使用typedf重新定义栈中数据的类型,方便后续修改中的数据的类型
typedef struct Stack
{//1.记录栈的栈顶指针 ---> 一个int变量//2.记录栈的容量 ---> 一个int变量//3.动态数组 ---> 一个指针int top;int capacity;STKDateType* a;
}STK;//任务3:定义栈要实现的接口
//1.栈的初始化
//2.栈的销毁
//3.栈的入栈操作
//4.栈的出栈操作
//5.栈的取栈顶元素操作
//6.栈的判空操作
//7.栈的求栈中元素数量的操作void STKInit(STK* pstk);
void STKDestroy(STK* pstk);
void STKPush(STK* pstk, STKDateType x);
void STKPop(STK* pstk);
STKDateType STKTop(STK* pstk);
bool STKEmpty(STK* pstk);
int STKSize(STK* pstk);
实现文件
Sort.c
-------------------------------------Sort.c-------------------------------------#include "Sort.h"/******************************************************实现:辅助函数******************************************************//*===============================================“数组中元素的打印”===============================================*/
//1.实现:“数组中元素的打印”辅助函数
void PrintArray(int* a, int n)
{for (int i = 0; i < n; i++){printf("%d ", a[i]);}printf("\n");
}/*===============================================“数组中的两个元素的交换”===============================================*/
//2.实现:“数组中的两个元素的交换”辅助函数
void Swap(int* a, int* b)
{int tmp = *a;*a = *b;*b = tmp;
}/*===============================================“堆的向下调整”===============================================*/
//3.实现:“堆的向下调整”辅助函数
/*** @brief 堆的向下调整算法(大顶堆)* @param a 堆数组* @param parent 需要调整的父节点索引* @param n 堆的大小** 算法功能:* 1. 确保以parent为根的子树满足大顶堆性质* 2. 若父节点小于子节点,则交换并继续向下调整** 执行过程:* 1. 从父节点出发,找到较大的子节点* 2. 比较父子节点值,若父节点较小则交换* 3. 循环执行直到满足堆性质或到达叶子节点** 时间复杂度:O(logN)(树的高度)*/
void AdjustDown(int* a, int parent, int n)
{//思路:向下调整的本质:是判断父节点的值和左右孩子的值的大小关系,并将父子关系不满足大根堆条件(孩子大于父亲)的情况进行交换调整//所以我任务是//任务1:找到父节点孩子中值最大的那个孩子//任务2:判断父节点和孩子节点的大小关系,并进行调整//1.先假设父节点的左孩子是值最大的孩子int maxChild = (parent << 1) + 1;//注意1:这里还用+1,因为这里是maxChild 和 parent 都是数组的下标//注意2:位运算符的优先级比算数运算符的优先级小 ---> 一般情况下:如果位运算符我们不写在表达式的最后的话,都要添加()来提高优先级//2.循环进行交换调整while (maxChild < n) //当孩子的索引值 >= n 的时候,说明进行调整到不能在调整了{//3.确定父节点的值最大的孩子节点if (maxChild + 1 < n && a[maxChild + 1] > a[maxChild]){maxChild = maxChild + 1;}//4.判断父节点和孩子节点的大小关系if (a[parent] >= a[maxChild]) return;else{//4.1: 交换Swap(&a[parent], &a[maxChild]);//4.2:更新parent = maxChild;//4.3:寻找maxChild = (parent << 1) + 1;}}
}//4.实现:“三数取中”辅助函数
/*** @brief 三数取中法选择基准值* @param a 数组指针* @param left 左边界索引* @param right 右边界索引* @return 返回左、中、右三数中的中间值索引* @note 用于快速排序优化,避免最坏情况时间复杂度退化*/
int GetMid(int* a, int left, int right)
{//1.计算中间值int mid = (left + right) >> 1;//2.处理情况1:a[left] < a[mid]if (a[left] < a[mid]){if (a[right] < a[left]){return left;}else if (a[right] > a[mid]){return mid;}elsereturn right;}//3.处理情况2:a[left] > a[mid]else{if (a[right] > a[left]){return left;}else if (a[right] < a[mid]){return mid;}elsereturn right;}
}
/******************************************************实现:排序函数******************************************************//*--------------------------------------------------------------------插入排序--------------------------------------------------------------------*/
/*===============================================“直接插入排序”===============================================*/
//1.实现:“直接插入排序”
/*** @brief 直接插入排序* @param a 待排序数组* @param n 数组长度* @note 时间复杂度:* 最坏O(N^2) - 逆序情况* 最好O(N) - 已有序情况*/
void InsertSort(int* a, int n)
{//1.外层的循环控制 ---> 记录当前数组中区间到那里已经是有序的了 (解释:循环变量i=n-2意味着“当前数组中区间为[0,n-1]范围中的元素现在已经是有序的了”)for (int i = 0; i <= n - 2; i++) //注:i虽然只能到n-2的但是当i=n-2的时候,我们正在处理的是下标为n-1位置的元素,也就是数组中的最后哦一个元素{//2.记录当前数组中已经有序区间的右下标int end = i;//3.取出我们要进行插入排序的元素int tmp = a[end + 1];//4.内层的循环控制 ---> 寻找我们要插入的位置while (end >= 0){//核心:前面元素的值大进行后移if (a[end] > tmp){a[end + 1] = a[end];end--;}else{break;}}//注意:因为end的值变为-1而退出的while循环的情况:意味着当前处理的这个元素的值比数组中区间为[0,end]的已经有序元素的任意一个元素的值都要小a[end + 1] = tmp;}
}/*===============================================“希尔排序”===============================================*/
//2.实现:“希尔排序”
/*** @brief 希尔排序* @param a 待排序数组* @param n 数组长度* @note 时间复杂度: O(N^1.3)* 基于插入排序的改进,通过分组预排序提高效率*//*希尔排序原理:* 1. 是插入排序的改进版本,通过分组预排序提高效率* 2. 使用动态缩小的间隔序列(gap)对数据进行分组* 3. 对每个分组进行插入排序* 4. 最终当gap = 1时,就是标准的插入排序,此时数组已基本有序*/void ShellSort(int* a, int n)
{//1.初始化间隔为数组的长度int gap = n;//2.第一层循环 ---> 动态的调整间隔的序列while (gap > 1){// 计算新的间隔,使用Knuth提出的序列:h = h/3 + 1 (这个序列在实践中表现较好)gap = gap / 3 + 1;//注:接下来的实现主要有两种实现的方式:分别是://1.严格分组法//2.交叉分组法//注:这两种方法性能上没有什么明显的区别,只是交叉分组法更加简洁,大多数的人都使用交叉分组法/*--------------------------展示:严格分组法--------------------------*//*//第二层循环 ---> 控制我们处理组for (int group = 0; group < gap; group++){//第三层循环 ---> 控制已排序区间的右边界for (int i = group; i <= n - gap - 1; i++){int end = i;int tmp = a[end + gap];//第三层循环 ---> 寻找我们要插入的位置while (end >= 0){//核心:前面的元素的值大时进行后移if (a[end] > tmp){a[end + gap] = a[end];end -= gap;}else{break;}}a[end + gap] = tmp;}}*//*--------------------------展示:交叉分组法--------------------------*///第二层循环 ---> 控制已排序区间的右边界for (int i = 0; i <= n - gap - 1; i++){int end = i;int tmp = a[end + gap];//第三层循环 ---> 寻找我们要插入的位置while (end >= 0){//核心:当前面的元素的值大进行后移if (a[end] > tmp){a[end + gap] = a[end];end -= gap;}else{break;}}a[end + gap] = tmp;}}
}/*--------------------------------------------------------------------选择排序--------------------------------------------------------------------*/
/*===============================================“简单选择排序”===============================================*/
//1.实现:“简单选择排序”
/*** @brief 简单选择排序* @param a 待排序数组* @param n 数组长度* @note 时间复杂度: O(N^2)* 每次选择最小和最大元素放到首尾位置*/
void SelectSort(int* a, int n)
{//1.记录选择排序的区间边界(解释:在区间之外的元素已经有序)//注:这里进行了优化,原始的直接选择排序进行一个次排序只会找到未排序区间中的最小的元素,这里优化为一次可以找到:最大的元素和最小的元素int begin = 0, end = n - 1;//2.记录区间内值最大和最小的元素的下标int minn, maxx;//3.外层循环 ---> 控制已排序区间的左右边界 (解释:beigin=3,end=n-4代表:数组中下标为[3,n-4]这个区间的中的元素还没有进行排序)while (begin < end){//4.初始化当前数组未排序区间中元素的最大值和最小值元素的下标为beginminn = begin, maxx = begin;//5.内层循环 ---> 寻找未排序区间中实际的最小/最大值索引for (int i = begin + 1; i <= end; i++){//5.1:更新最小值的索引if (a[i] < a[minn]){minn = i;}//5.2:更新最大值的索引if (a[i] > a[maxx]){maxx = i;}}//6.进行元素位置的交换//6.1:将最小值交换到区间的头部Swap(&a[minn], &a[begin]);//6.2:修正最大值在数组中的下表中的位置/* 关键修正:处理最大值正好在begin位置的特殊情况* 因为先交换了begin和minn,可能导致maxx指向的值被移动* 例子:[5, 3, 1, 4, 2]中:* - 第一轮begin=0, maxx=0, minn=2* - 交换begin和minn后变为[1, 3, 5, 4, 2]* - 此时原maxx=0的值已被移动到minn=2的位置*/if (maxx == begin){maxx = minn;}//6.3:将最大值交换到区间的尾部Swap(&a[maxx], &a[end]);//7.缩小未排序区间begin++;end--;}
}/*===============================================“堆排序”===============================================*/
//2.实现:“堆排序”
/*** @brief 堆排序算法实现* @param a 待排序数组* @param n 数组长度** 算法特性:* 1. 时间复杂度:* - 建堆过程:O(N)* - 排序过程:O(N*logN)* - 总体:O(N*logN)** *核心思想:* - 将数组视为完全二叉树,建立大顶堆* - 反复取出堆顶元素(最大值)放到数组末尾* - 重新调整剩余元素维持堆结构*/void HeapSort(int* a, int n)
{/*------------------第一阶段:建堆------------------*///建堆的方法有两种://1.向上调整建堆//2.向下调整建堆//注:这两种方法有明显的性能差别,向下调整建堆算法的时间复杂度更小,使用的人也更多//建堆本质:从堆中最后一个非叶子节点到堆顶节点逐个使用:向下调整算法for (int i = (n - 1 - 1) >> 1; i >= 0; i--){AdjustDown(a, i, n);}/*------------------第二阶段:将堆顶元素与末尾的元素进行交换 + 向下调整堆------------------*///1.定义一个变量记录当前堆中最后一个元素的在数组中的索引int end = n - 1;//2.循环进行第二阶段直到堆对应的数组中只剩下标为0的元素的值还没用进行交换的时候while (end > 0){//2.1:将堆顶元素与末尾的元素进行交换Swap(&a[0], &a[end]);//2.2:更新堆对应数组的容量 ---> (逻辑上:删除了堆顶元素)end--;//2.3:重新向下调整堆AdjustDown(a, 0, end + 1);}
}
/*--------------------------------------------------------------------交换排序--------------------------------------------------------------------*/
/*===============================================“冒泡排序”===============================================*/
//1.实现:“冒泡排序”
/*** @brief 冒泡排序* @param a 待排序数组* @param n 数组长度* @note 时间复杂度:* 最坏O(N^2) - 逆序情况* 最好O(N) - 已有序情况*/
void BubbleSort(int* a, int n)
{//1.外层的循环控制 ---> 记录需要进行交换的数字的个数[“也可理解为冒泡的总的趟数”](解释:n个数字只需要对n-1个数字进行排序即可实现n个数字的有序)for (int i = 1; i <= n - 1; i++){//2.内层的循环控制 ---> 记录对每个数字进行排序需要交换的次数[“也可以理解为每趟冒泡需要交换的次数”](注意:每趟冒泡需要交换的次数是不同的)//每趟冒泡需要交换的次数会逐渐的减少,次数类似于一个等差数列,例如:n个元素的一个数组//第一趟的冒泡需要比较的次数:n-1//第二趟的冒泡需要比较的次数:n-2//第三趟的冒泡需要比较的次数:n-3 (总结:每趟冒泡需要交换的次数为 = 元素的总个数n - 这是冒泡的第几趟i)//………………………………//第n-2趟的冒泡需要比较的次数:2//第n-1趟的冒泡需要比较的次数:1//对冒泡排序进行优化:int flag = 1;for (int j = 1; j <= n - i; j++){//核心:前面的元素的值大进行交换if (a[j - 1] > a[j]){Swap(&a[j - 1], &a[j]);flag = 0;}}if (flag) break; //如果某一趟的冒没有进行交换,说明当前数组中的元素已经有序了,则直接退出}
}/*===============================================“快速排序”===============================================*/
//2.实现:“快速排序”
/*** @brief 快速排序* @param a 待排序数组* @param left 左边界* @param right 右边界* @note 时间复杂度:* 平均O(NlogN)* 最坏O(N^2) - 已排序情况* 空间复杂度: O(logN) - 递归栈空间*///说明:快速排序无疑是所有的排序算法中的明星,其版本也是有很多种
//1.hoare法 (原版)
//2.lomuto快慢指针法
//3.挖坑法
//同时由于快速排序:是一个分治算法,它将数组分成两个子数组,然后递归地对子数组进行排序
//也就是说快速排序的实现牵扯到了递归,然而递归的深度越深就越有可能导致栈溢出,所以为了降低这种风险,博主还会带大家实现
//4.快速排序的非递归版本/*//1.这里先给大家写一个比较常使用的模板,步骤详细以供参考,后面的快排方法博主会封装为两个函数去实现以避免不必要的重复//----------------------快速排序的模板:hoare法的实现(经过两处优化)----------------------void QucikSort(int* a, int left, int right){//1.处理特殊的情况:“区间中没有元素 + 区间中只有一个元素” (同是也是递归终止条件)if (left >= right) return;//---------优化1:“小区间优化:当子数组元素少于10时,使用插入排序提高效率”---------if ((right - left + 1) < 10){InsertSort(a + left, right - left + 1);}else{//---------优化2:“三数取中优化:选择左、中、右三个元素的中间值作为基准”---------int mid = GetMid(a, left, right);//4.将基准值放到左边界Swap(&a[left], &a[mid]);//5.定义一个变量记录基准值的索引int key = left;//6.定义两个临时变量用来左右扫描区间int begin = left, end = right;//7.使用双边循环法分区while (begin < end){//7.1:从右向左找第一个小于基准的值 //注意细节:这里要先进行从右向左找第一个小于基准的值 ---> 这样可以确保最终两个指针停下来的位置的值一定小于基准while (begin < end && a[end] >= a[key]) //注意:这里还要判断begin<end,防止end越界,下面的while循环也是如此{end--;}//7.2:从左向右找第一个大于基准的值while (begin < end && a[begin] <= a[key]){begin++;}//7.3:交换这两个不符合条件的元素Swap(&a[begin], &a[end]);}//8.将基准值放到正确的位置(此时begin == end)Swap(&a[key], &a[begin]);//9.更新基准值的位置key = begin;//10.递归排序左右子分区: [left, key-1] key [key+1, right]QuickSort(a, left, key - 1);QuickSort(a, key + 1, right);}}
*//*----------------------hoare法的实现(未进行优化版本)----------------------*///看到上面的标题,我想大多数的人会直接会跳过这实现,而转去学习其他的经过优化的实现方法,你说我说的对吗?//没有进行优化的快排难道就没有学习的必要吗?当然不是这样的,博主认为学习的价值有以下几点//1.可以让我们抓住快排的核心//2.可让我们学习快排过程更加的平滑//3.在面试的时候时间紧,可以选择手撕未优化的快排以提高容错率,再简明扼要的说明可以进行优化的地方以彰显水平/*----------------------hoare分区算法(未进行优化)----------------------*/
int PartSort1(int* a, int left, int right)
{//1.直接选择左边界的元素作为基准值int key = left;//2.定义两个临时变量从区间的两端向中间进行扫描 ---> 高效地将数组分为两部分//使得一部分元素小于等于基准值,另一部分元素大于等于基准值,进而将基准值放置到其最终排序位置上int begin = left, end = right;//3.使用双边循环法分区while (begin < end){//3.1:从右向左寻找第一个小于基准的值while (begin < end && a[end] >= a[key]){end--;}//3.2:从左向右寻找第一个大于基准的值while (begin < end && a[begin] <= a[key]){begin++;}//3.4:交换这两个不符合条件的元素Swap(&a[begin], &a[end]);}//4.将基准值放到正确的位置(此时begin == end)Swap(&a[key], &a[begin]);//5.返回当前基准值的下标return begin;
}/*----------------------lomuto快慢指针分区算法----------------------*/
/*** @brief 前后指针法实现快速排序分区* @param a 数组指针* @param left 左边界索引* @param right 右边界索引* @return 返回基准值的最终位置*/
int PartSort2(int* a, int left, int right)
{//1.进行三数取中优化:基准值的选取int mid = GetMid(a, left, right);//2.将找到的基准值放在左边界Swap(&a[left], &a[mid]);//3.定义一个变量记录基准值的索引int key = left;//4.定义一个慢指针:用于指向最后一个小于基准值的元素int slow = left;//5.定义一个快指针:用于扫描整个分区int fast = slow + 1;//6.进行分区while (fast <= right){if (a[fast] < a[key] && ++slow != fast){Swap(&a[fast], &a[slow]);}fast++;}//7.将基准值交换到最终的位置(此时slow指向最后一个小于基准的元素)Swap(&a[key], &a[slow]);//8.返回当前基准值的下标return slow;
}/*----------------------挖坑分区算法----------------------*/
/*** @brief 挖坑法实现快速排序分区* @param a 待排序数组* @param left 左边界索引* @param right 右边界索引* @return 基准值的最终位置* @note 时间复杂度:O(N)* 空间复杂度:O(1)*/
int PartSort3(int* a, int left, int right)
{//1.进行三数取中优化:基准值的选取int mid = GetMid(a, left, right);//2.将找到的基准值放在左边界Swap(&a[left], &a[mid]);//3.定义一个变量记录基准值(注意这里保存值而不是索引)int key = a[left]; //特别注意:这的key存储的不再是基准值的下标,而是直接存储的基准值,这是为什么呢?(下面的注释博主有详细的解释,请继续往下看……)//4.定义一个变量记录坑位int hole = left;//5.定义两个临时变量从区间的两端向中间进行扫描 ---> 高效地将数组分为两部分int begin = left, end = right;//6.进行分区while (begin < end){//6.1:从右向左寻找第一个小于基准的值while (begin < end && a[end] >= key){end--;}//6.2:找到后填左坑,end成为新坑 a[hole] = a[end]; //特别注意:这里while循环结束后并不是紧接又是一个while循环,而是做填坑、挖坑的操作,也恰恰是这个填坑、挖坑的操作使得基准值的下标已经发生了改变hole = end; //所以说:我们下面就不可以使用a[key](假设:key存储的还是基准值的下标)的方式来得到基准值了,//6.3:从左向右寻找第一个大于基准的值while (begin < end && a[begin] <= key){begin++;}//6.4:找到后填右坑,left成为新坑a[hole] = a[begin];hole = begin;}//7.最后将基准值填入最后的坑位a[hole] = key;//8.返回当前基准值的下标return hole;
}/*----------------------快排主函数(递归实现)----------------------*/
/*** @brief 快速排序(非递归实现)* @param a 待排序数组* @param left 左边界索引* @param right 右边界索引* @note 使用栈模拟递归过程*/
void QuickSort(int* a, int left, int right)
{//1.递归终止条件if (left >= right) return;//2.可以根据需要选择性的在这里添加小区间优化(比如:之前已经实现过了插入排序就可以在这里进行小区间优化了)//3.定义一个变量接收基准值的位置int key = PartSort3(a, left, right);//4.递归排序左右子区间QuickSort(a, left, key - 1);QuickSort(a, key + 1, right);
}/*----------------------快排排序(非递归实现)----------------------*/
/*** @brief 快速排序(非递归实现)* @param a 待排序数组* @param left 排序区间左边界* @param right 排序区间右边界* @note 时间复杂度:O(NlogN)* 空间复杂度:O(logN)(栈空间)* 算法步骤:* 1. 使用栈保存待处理的区间* 2. 循环处理栈中的区间直到栈空* 3. 对每个区间进行分区操作* 4. 将新生成的子区间压入栈中*/
void QuickSortNonR(int* a, int left, int right)
{/*---------------第一阶段:准备数据阶段---------------*///1.创建栈 + 初始化栈STK stk;STKInit(&stk);//2.先将整个数组区间压栈(注意顺序:先右后左)STKPush(&stk, right);STKPush(&stk, left);/*---------------第二阶段:循环处理阶段---------------*///3.循环处理栈的中的元素直至栈为空while (!STKEmpty(&stk)){//4.弹出当前处理区间的左右边界int begin = STKTop(&stk);STKPop(&stk);int end = STKTop(&stk);STKPop(&stk);//5.选择任意的分区算法对当前的区间进行分区int key = PartSort3(a, begin, end);//6.将存在的区间压入栈中//6.1:先将右子区间[key+1, end]入栈(如果存在的话)if (key + 1 < end){STKPush(&stk, end);STKPush(&stk, key + 1);}//6.2:再将左子区间[left,key-1]入栈(如果存在的话)if (begin < key - 1){STKPush(&stk, key - 1);STKPush(&stk, begin);}}/*---------------第三阶段:释放资源阶段---------------*/STKDestroy(&stk);
}/*--------------------------------------------------------------------归并排序--------------------------------------------------------------------*/
/*===============================================“归并排序”===============================================*/
//1.实现:“归并排序”
/*** @brief 归并排序的核心递归函数* @param a 待排序的原始数组(排序后结果也存放在此)* @param tmp 临时工作数组,大小应与原数组相同* @param begin 当前处理区间的起始下标(包含)* @param end 当前处理区间的结束下标(包含)** @details* 1. 递归地将数组分成两半直到最小单元(单个元素)* 2. 然后自底向上地合并两个已排序的子数组* 3. 合并过程使用双指针技术,保证稳定性*///和快速排序一样,博主这里也是通过封装两个函数来实现的归并排序的,目的都是为了避免编写重复的代码,提高代码复用性(其实是为了方便我使用递归)
//主函数:MergeSort()函数是入口,函数内部是一些可重复的代码
//子函数:_MergeSort()函数是归并排序的核心void _MergeSort(int* a, int* tmp, int begin, int end)
{//1.处理特殊的情况:“区间中没有元素 + 区间中只有一个元素” (递归结束条件)if (begin >= end) return;//2.计算区间的中间点:(防溢出写法:begin + (end - begin)/2)int mid = begin + end >> 1; //装逼写法(不建议使用,请勿装逼)//3.递归处理左右子区间//3.1:递归处理左子区间_MergeSort(a, tmp, begin, mid);//3.2:递归处理右子区间_MergeSort(a, tmp, mid + 1, end);//4.定义左右子数组的边界 + 临时数组的写入位置int begin1 = begin, end1 = mid; //左子数组的边界int begin2 = mid + 1, end2 = end; //右子数组的边界int pos = begin;//5.双指针法合并:选择较小的元素优先放入while (begin1 <= end1 && begin2 <= end2) //只要有一个子数组归并完毕循环就结束 ---> 两个数组都未完成归并while循环继续进行{if (a[begin1] <= a[begin2]) //稳定排序的关键:相等时取前一个元素{tmp[pos++] = a[begin1++];}elsetmp[pos++] = a[begin2++];}//6.处理左右子数组剩余的元素//6.1:处理左数组中的剩余的元素(如果有的话)while (begin1 <= end1){tmp[pos++] = a[begin1++];}//6.2:处理右数组中的剩余的元素(如果有的话)while (begin2 <= end2){tmp[pos++] = a[begin2++];}//7.将合并结果从tmp数组拷贝回原数组的对应的区间memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));// 注意:只拷贝当前处理的范围[begin, end]
}/*----------------------归并主函数(递归实现)----------------------*/void MergeSort(int* a, int n)
{/*-----------------第一阶段:动态创建临时数组-----------------*/int* tmp = (int*)malloc(n * sizeof(int));if (tmp == NULL){perror("malloc fail");return;}/*-----------------第二阶段:调用核心排序函数-----------------*/_MergeSort(a, tmp, 0, n - 1); //处理整个数组[0, n-1]/*-----------------第三阶段:释放临时数组内存-----------------*/free(tmp);tmp = NULL;
}/*----------------------归并排序(非递归实现)----------------------*/
/*** @brief 归并排序(非递归实现)* @param a 待排序数组* @param n 数组长度* @note 时间复杂度: O(NlogN) - 每次归并O(N),共logN次* 空间复杂度: O(N) - 需要额外N大小的临时空间* 稳定性:稳定排序(相等元素的相对顺序不变)** @details* 实现原理:* 1. 自底向上归并,先归并相邻的小区间,再逐步扩大归并区间* 2. 使用gap控制当前归并的区间大小,从1开始每次翻倍* 3. 对每个gap,遍历整个数组进行两两归并* 4. 处理边界情况,保证最后不足gap的部分也能正确归并** 优点:* - 避免了递归调用的栈空间开销* - 更适合处理超大规模数据(不会栈溢出)*/
//递归虽好,但是存在栈溢出的风险,所以我们还是有必要学习一下归并排序的非递归实现的
//说明:之前我们为实现快速排序的非递归使用的栈,但是归并的非递归不能简单的借助栈就能实现
//原因:快排的非递归类似二叉树的前序遍历,而递归类似于后序遍历,在递归过程中要先保存结果,在回溯阶段再进行归并
void MergeSortNonR(int* a, int n)
{//1.动态创建临时数组int* tmp = (int*)malloc(n * sizeof(int));if (tmp == NULL){perror("malloc fail");return;}//2.定义一个变量记录归并子数组的大小int gap = 1; //从1开始,单个元素视为已排序//3.使用while循环进行循环归并while (gap < n) //当子数组的大小 >= 原数组的长度时归并结束 ---> 反面即是while循环的条件{//4.遍历整个区间,每次处理两个相邻的gap大小的区间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;/*--------------------第二步:修正边界防止数组越界--------------------*///1)情况1:第二个区间完全越界(无需归并)if (begin2 >= n){break;}//2)情况2:第二个区间部分越界(修正end2)if (end2 >= n){end2 = n - 1;}/*--------------------第三步:归并两个有序区间--------------------*///1.定义临时数组的写入位置int pos = i;//2.双指针法合并两个区间while (begin1 <= end1 && begin2 <= end2){if (a[begin1] <= a[begin2]) //稳定排序的关键:相等时取前一个元素{tmp[pos++] = a[begin1++];}else{tmp[pos++] = a[begin2++];}}//3.处理左右子数组剩余的元素//3.1:处理左数组中的剩余的元素(如果有的话)while (begin1 <= end1){tmp[pos++] = a[begin1++];}//3.2:处理右数组中的剩余的元素(如果有的话)while (begin2 <= end2){tmp[pos++] = a[begin2++];}//4.将归并结果拷贝回原数组(仅拷贝当前处理的范围)memcpy(a + i, tmp + i, (end2 - i + 1) * sizeof(int));}//5.扩大归并区间的大小gap *= 2;}//6.释放资源free(tmp);tmp = NULL;
}/*--------------------------------------------------------------------计数排序--------------------------------------------------------------------*/
/*===============================================“计数排序”===============================================*/
//1.实现:“计数排序”
/*** @brief 计数排序(非比较排序)* @param a 待排序数组* @param n 数组元素个数** @note 特性:* - 时间复杂度:O(N+range),range为数据范围(max-min+1)* - 空间复杂度:O(range)* - 稳定性:稳定排序(但当前实现是非稳定版本)* - 限制:仅适用于整数且数据范围较小的场景*/
void CountSort(int* a, int n)
{/*---------------第一阶段:确定数据的范围---------------*///1.使用假设法:先定义两个变量记作是数据中的最小/最大值int minn = a[0], maxx = a[0];//2.遍历数组寻找数组中的最大值和最小值for (int i = 0; i < n; i++){//2.1:修正最小值if (a[i] < minn){minn = a[i];}//2.2:修正最大值if (a[i] > maxx){maxx = a[i];}}//3.计算实际数据的范围int range = maxx - minn + 1;/*---------------第二阶段:创建计数的数组---------------*/int* count = (int*)calloc(range, sizeof(int));if (count == NULL){perror("calloc fail");return;}/*---------------第三阶段:统计元素出现次数---------------*/for (int i = 0; i < n; i++) //遍历原数组[0,n]的区间中的元素{count[a[i] - minn]++; //将数据映射到count数组[0,range-1]区间中}/*---------------第四阶段:重构待排序数组---------------*///1.定义元素数组的写入位置int pos = 0;//2.使用for循环遍历count数组for (int i = 0; i < range; i++){//2.1:将count数组中元素转化为:元素的数量while (count[i]--){//2.2:将count数组的索引转化回:原始的值a[pos++] = i + minn;}}/*---------------第五阶段:释放资源---------------*/free(count);count = NULL;
}
Stack.c
#include "Stack.h"//1.实现:“栈的初始化”的函数
void STKInit(STK* pstk)
{assert(pstk); //确保指针非空pstk->top = 0;pstk->capacity = 0;pstk->a = NULL;
}//2.实现:“栈的销毁”的函数
void STKDestroy(STK* pstk)
{assert(pstk);pstk->top = 0;pstk->capacity = 0;free(pstk->a);pstk->a = NULL;
}//3.实现:“栈的入栈操作”的函数
void STKPush(STK* pstk, STKDateType x)
{//第一部分:确保指针非空assert(pstk);//第二部分:检查是否需要进行扩容if (pstk->top == pstk->capacity){//1.需要进行扩容 ---> 新容量是多少?int newCapacity = pstk->capacity == 0 ? 4 : pstk->capacity * 2;//2.开始进行扩容STKDateType* tmp = (STKDateType*)realloc(pstk->a, newCapacity * sizeof(STKDateType));//3.检查扩容是否成功if (tmp == NULL){perror("realloc false");return;}//4.更新指向动态数组的指针pstk->a = tmp;//5.更新栈的容量pstk->capacity = newCapacity;}//第三部分:实现入栈操作pstk->a[pstk->top] = x;pstk->top++;
}//4.实现:“栈的出栈操作”的函数
void STKPop(STK* pstk)
{assert(pstk);//确保指针不为空assert(pstk->top > 0);//确保栈不为空pstk->top--;
}//5.实现:“栈的取栈顶元素的操作”的函数
STKDateType STKTop(STK* pstk)
{assert(pstk);assert(pstk->top > 0);return pstk->a[pstk->top - 1]; // 注意:千万别写成这个样子pstk->a[pstk->top--];,这样会先会修改top值}//6.实现:“栈的判空操作”的函数
bool STKEmpty(STK* pstk)
{assert(pstk);//if (pstk->top = 0) return true;//else return false;//1.栈为空 --> 返回true//2.栈不为空 ---> 返回falsereturn pstk->top == 0;
}//7.实现:“栈的求栈中元素数量的操作”的函数
int STKSize(STK* pstk)
{assert(pstk);return pstk->top;
}
测试文件
-------------------------------------Test.c-------------------------------------
#include "Sort.h"/*-----------------第一部分:八大排序的排序自测-----------------*/
void TestSort()
{int a[] = { 9,1,2,8,5,7,4,6,3 };int n = sizeof(a) / sizeof(int);PrintArray(a, n);printf("==============插入类排序==============\n");InsertSort(a, n);printf("InsertSort:");PrintArray(a, n);ShellSort(a, n);printf("ShellSort:");PrintArray(a, n);printf("==============选择类排序==============\n");SelectSort(a, n);printf("SelectSort:");PrintArray(a, n);HeapSort(a, n);printf("HeapSort:");PrintArray(a, n);printf("==============交换类排序==============\n");BubbleSort(a, n);printf("BubbleSort:");PrintArray(a, n);QuickSort(a, 0, n - 1);printf("QuickSort:");PrintArray(a, n);/*QuickSortNonR(a, 0, n - 1);printf("QuickSort:");PrintArray(a, n);*/printf("==============归并类排序==============\n");MergeSort(a, n);printf("MergeSort:");PrintArray(a, n);/*MergeSortNonR(a, n);printf("MergeSort:");PrintArray(a, n);*/printf("==============非比较类排序==============\n");CountSort(a, n);printf("CountSort:");PrintArray(a, n);
}/*-----------------第二部分:八大排序的同台较量-----------------*/
void TestTop()
{//1.设置随机种子srand((unsigned int)time(0));//2.定义测试数据的规模const int N = 100000;//3.创建多个动态的测试数组int* a1 = (int*)malloc(N * sizeof(int));int* a2 = (int*)malloc(N * sizeof(int));int* a3 = (int*)malloc(N * sizeof(int));int* a4 = (int*)malloc(N * sizeof(int));int* a5 = (int*)malloc(N * sizeof(int));int* a6 = (int*)malloc(N * sizeof(int));int* a7 = (int*)malloc(N * sizeof(int));int* a8 = (int*)malloc(N * sizeof(int));//4.初始化各个测试数组(保证每个数组中的数组相等)for (int i = 0; i < N; i++){//4.1:生成重复数量较小的测试数据a1[i] = rand() + i;//4.2:复制相同的数据到相同的数组中a2[i] = a1[i];a3[i] = a1[i];a4[i] = a1[i];a5[i] = a1[i];a6[i] = a1[i];a7[i] = a1[i];a8[i] = a1[i];}//5.测试各个排序算法消耗的时间/*-------------直接插入排序-------------*/int begin1 = clock();InsertSort(a1, N);int end1 = clock();/*-------------简单选择排序-------------*/int begin2 = clock();SelectSort(a2, N);int end2 = clock();/*-------------冒泡排序-------------*/int begin3 = clock();BubbleSort(a3, N);int end3 = clock();/*-------------希尔排序-------------*/int begin4 = clock();ShellSort(a4, N);int end4 = clock();/*-------------堆排序-------------*/int begin5 = clock();HeapSort(a5, N);int end5 = clock();/*-------------快速排序-------------*/int begin6 = clock();QuickSort(a6, 0, N - 1);int end6 = clock();/*-------------归并排序-------------*/int begin7 = clock();MergeSort(a7, N);int end7 = clock();/*-------------计数排序-------------*/int begin8 = clock();CountSort(a8, N);int end8 = clock();//6.打印各个排序算法消耗的时间printf("==============耐力组==============\n");printf("InsertSort:%d\n", end1 - begin1);printf("SelectSort:%d\n", end2 - begin2);printf("BubbleSort:%d\n", end3 - begin3);printf("==============超跑组==============\n");printf("ShellSort:%d\n", end4 - begin4);printf("HeapSort:%d\n", end5 - begin5);printf("QuickSort:%d\n", end6 - begin6);printf("MergeSort:%d\n", end7 - begin7);printf("==============极速组==============\n");printf("CountSort:%d\n", end8 - begin8);//7.释放动态数组的内存空间free(a1);free(a2);free(a3);free(a4);free(a5);free(a6);free(a7);free(a8);a1 = NULL;a2 = NULL;a3 = NULL;a4 = NULL;a5 = NULL;a6 = NULL;a7 = NULL;a8 = NULL;
}int main()
{printf("----------------- 初赛:八大排序的排序自测 -----------------\n");TestSort();putchar('\n');printf("----------------- 决赛:八大排序的同台较量 -----------------\n");TestTop();return 0;
}
四、比赛结果:
五、颁奖仪式:
排名 | 排序算法 | 平均时间复杂度 | 最好时间复杂度 | 最坏时间复杂度 |
---|---|---|---|---|
🥇 | 快速排序 | O ( n l o g n ) O(n log n) O(nlogn) | O ( n l o g n ) O(n log n) O(nlogn) | O ( n 2 ) O(n²) O(n2) |
🥈 | 归并排序 | O ( n l o g n ) O(n log n) O(nlogn) | O ( n l o g n ) O(n log n) O(nlogn) | O ( n l o g n ) O(n log n) O(nlogn) |
🥉 | 堆排序 | O ( n l o g n ) O(n log n) O(nlogn) | O ( n l o g n ) O(n log n) O(nlogn) | O ( n l o g n ) O(n log n) O(nlogn) |
4 | 希尔排序 | O ( n 1.3 ) O(n^{1.3}) O(n1.3) | O ( n l o g n ) O(n log n) O(nlogn) | O ( n 2 ) O(n²) O(n2) |
5 | 直接插入排序 | O ( n 2 ) O(n²) O(n2) | O ( n ) O(n) O(n) | O ( n 2 ) O(n²) O(n2) |
6 | 冒泡排序 | O ( n 2 ) O(n²) O(n2) | O ( n ) O(n) O(n) | O ( n 2 ) O(n²) O(n2) |
7 | 简单选择排序 | O ( n 2 ) O(n²) O(n2) | O ( n 2 ) O(n²) O(n2) | O ( n 2 ) O(n²) O(n2) |
8 | 基数排序 | O ( n × k ) O(n×k) O(n×k) | O ( n × k ) O(n×k) O(n×k) | O ( n × k ) O(n×k) O(n×k) |
看到这个比赛结果,相信小伙伴们心里都会犯嘀咕:“这算怎么回事?之前测试里明明是“计数排序”稳坐第一,结果颁奖时冠军却成了快速排序,这必须得举报!”
现在小伙伴们也是越看越觉得这场比赛猫腻重重:希尔排序的实际速度明明比堆排序更快,可榜单上它却屈居第四,堆排序反而拿了第三 —— 这不是明摆着打压希尔吗?必须为希尔排序讨个公道!
更让小伙伴们费解的是,冒泡排序在公认的排序速度梯队里一直是 “吊车尾”,结果公布时竟然排在简单选择排序前面。这种颠倒黑白的排名,简直把观众的智商按在地上摩擦!
这场比赛的裁判绝对有问题,分明就是收了“黑钱”,吹了“黑哨”!
哈哈,各位正义的小伙伴们先别急着砸键盘!这比赛结果看似离谱,其实暗藏玄机🔍
1. 计数排序"痛失金牌"之谜
👉 虽然计数排序测试时跑分逆天 O ( n + k ) O(n+k) O(n+k),但裁判组发现它偷偷带了"外挂":
- 只敢对
整数小数据
耀武扬威,遇到浮点数直接装死内存消耗
堪比黑洞( k k k值过大,原地爆炸)快速排序虽然理论速度稍逊,但人家是能扛住1TB数据的"六边形战士"啊!💪
2. 希尔vs堆排序的"黑幕"反转
📊 裁判组调出原始数据后发现:
- 堆排序在1亿级数据下比希尔快27%(毕竟 O ( n l o g n ) O(n log n) O(nlogn)不是吃素的)
- 希尔排序在1万级数据确实更快(就像电动车起步快,但跑长途还得油车)
3. 冒泡排序逆袭的真相
🕵️♂️ 经查实:
- 冒泡在完全有序数据下跑出 O ( n ) O(n) O(n)成绩(裁判组感动到破例加分)
- 选择排序因频繁交换数据被罚时(SSD硬盘表示抗议)
真正的 “黑哨 ”,是用单一标准评判所有算法
就像让短跑运动员和马拉松选手比跑步:
- 比 100 米短跑,博尔特赢。
- 比 10 公里长跑,基普乔格赢。
算法的 “比赛”,从来都需要明确场景边界。
---------------性能分析---------------
一、直接插入排序
时间复杂度
最坏情况
: O ( n 2 ) O(n²) O(n2)当输入数据完全逆序时,每次插入都需要将当前元素与已排序部分的所有元素比较并移动。
例如:对
[5,4,3,2,1]
排序,第i
个元素需要移动i
次,总比较次数为:1 + 2 + 3 + ⋯ + ( n − 1 ) = n ( n − 1 ) 2 ≈ O ( n 2 ) 1 + 2 + 3 + \dots + (n-1) = \frac{n(n-1)}{2} \approx O(n²) 1+2+3+⋯+(n−1)=2n(n−1)≈O(n2)
最好情况
: O ( n ) O(n) O(n)当输入数据已经有序时,每次插入只需比较 1 次(发现当前元素已在正确位置),无需移动。总比较次数为
n-1
次。
平均情况
: O ( n 2 ) O(n²) O(n2)对于随机排列的数据,每个元素的平均比较和移动次数约为
n/2
,总时间复杂度仍为 O ( n 2 ) O(n²) O(n2)稳定性
稳定排序
直接插入排序不会改变相同元素的相对顺序。
- 例如:对于数组
[5, 3, 3*, 2]
,排序后3
和3*
的顺序仍会保留为[2, 3, 3*, 5]
- 原因:在插入过程中,若遇到相等元素,算法会将新元素插入到相等元素的后面,而非前面。
二、简单选择排序
时间复杂度
最坏情况
: O ( n 2 ) O(n²) O(n2)无论输入数据是否有序,算法均需进行 n − 1 n-1 n−1 轮选择,每轮选择需遍历 n − i n-i n−i 个元素( i i i 为当前轮次)
总比较次数为: ( n − 1 ) + ( n − 2 ) + ⋯ + 1 = n ( n − 1 ) 2 ≈ O ( n 2 ) (n-1) + (n-2) + \dots + 1 = \frac{n(n-1)}{2} \approx O(n²) (n−1)+(n−2)+⋯+1=2n(n−1)≈O(n2)
- 例如:对逆序数组
[5,4,3,2,1]
排序,每轮均需比较剩余所有元素。
最好情况
: O ( n 2 ) O(n²) O(n2)即使数据已有序,算法仍需完成所有轮次的比较(仅减少交换次数),因此时间复杂度与最坏情况相同。
平均情况
: O ( n 2 ) O(n²) O(n2)每轮选择的比较次数固定为 O ( n ) O(n) O(n),总时间复杂度为 O ( n 2 ) O(n²) O(n2),与输入数据分布无关。
稳定性
不稳定排序
简单选择排序可能改变相同元素的相对顺序。
- 若数组为
[5, 3, 3*, 2]
,第一轮选择最小元素2
(下标 3),与首元素5
交换,得到:[2, 3, 3*, 5]
- 此时
3
和3*
的相对顺序未变。- 若数组为
[3, 5, 3*, 2]
,第一轮选择最小元素2
(下标 3),与首元素3
交换,得到:[2, 5, 3*, 3]
- 此时原下标 2 的
3*
出现在原下标 0 的3
之后,相同元素顺序颠倒,稳定性被破坏。原因:交换操作会跨位置移动元素,导致相同元素的相对顺序无法保留。
三、冒泡排序
时间复杂度
最坏情况
: O ( n 2 ) O(n²) O(n2)当数组完全逆序时(如:
[5,4,3,2,1]
),每轮遍历都需要进行 n − i n-i n−i 次比较和 n − i n-i n−i 次交换( i i i 为当前轮次)
总比较次数与交换次数均为: ( n − 1 ) + ( n − 2 ) + ⋯ + 1 = n ( n − 1 ) 2 ≈ O ( n 2 ) (n-1) + (n-2) + \dots + 1 = \frac{n(n-1)}{2} \approx O(n²) (n−1)+(n−2)+⋯+1=2n(n−1)≈O(n2)
最好情况
: O ( n ) O(n) O(n)若数组已经有序,冒泡排序可通过 “提前终止” 优化(设置标志位记录是否发生交换):
- 第一轮遍历后发现无交换发生,直接终止排序,仅需 n − 1 n-1 n−1 次比较,无需交换,时间复杂度为 O ( n ) O(n) O(n)
平均情况
: O ( n 2 ) O(n²) O(n2)对于随机分布的数组,平均比较次数和交换次数均接近最坏情况的一半,仍属于 O ( n 2 ) O(n²) O(n2)级别。
稳定性
稳定排序
冒泡排序在交换元素时,仅当相邻元素逆序时才交换,且相同元素的相对顺序不会被改变。
- 原始数组:
[3, 2, 3*, 1]
(3
和3*
为相同元素,下标分别为 0 和 2)- 第一轮遍历:
- 比较下标 0 和 1 的
3
和2
,交换得到[2, 3, 3*, 1]
- 比较下标 1 和 2 的
3
和3*
,顺序相同不交换- 比较下标 2 和 3 的
3*
和1
,交换得到[2, 3, 1, 3*]
- 第二轮遍历:
- 比较下标 0 和 1 的
2
和3
,不交换- 比较下标 1 和 2 的
3
和1
,交换得到[2, 1, 3, 3*]
- 最终排序结果为
[1, 2, 3, 3*]
,相同元素3
和3*
的相对顺序(原下标 0 在 2 之前)保持不变原因:相同元素不会被跨位置交换,仅在相邻位置移动,因此稳定性得以保留。
四、希尔排序
时间复杂度
希尔排序的时间复杂度与增量序列的选择密切相关,不同的增量序列会导致不同的性能表现:
最坏情况
:
- 希尔增量序列( n / 2 , n / 4 , … , 1 n/2, n/4, \dots, 1 n/2,n/4,…,1): O ( n 2 ) O(n^2) O(n2)
- Hibbard 增量序列 ( 2 k − 1 2^k - 1 2k−1): O ( n 3 / 2 ) O(n^{3/2}) O(n3/2)
- Sedgewick 增量序列: O ( n 4 / 3 ) O(n^{4/3}) O(n4/3)
结论:最坏情况下时间复杂度可能达到 O ( n 2 ) O(n^2) O(n2),但通过优化增量序列可显著降低至接近 O ( n l o g n ) O(nlog n) O(nlogn)
最好情况
: O ( n l o g n ) O(n log n) O(nlogn)
当数据已经有序时,无论采用何种增量序列,希尔排序的比较次数和移动次数都会显著减少,接近 O ( n l o g n ) O(n log n) O(nlogn)
平均情况
:通常介于 O ( n 1.3 ) O(n^{1.3}) O(n1.3)到 O ( n 2 ) O(n^2) O(n2)之间稳定性
不稳定排序
希尔排序在不同增量下进行分组插入排序时,可能改变相同元素的相对顺序。
初始步长为
3
:
- 分组:
[2, 1]
、[2*, 3]
、[5]
- 组内排序后:
[1, 2]
、[2*, 3]
、[5]
- 合并后数组:
[1, 2*, 5, 2, 3]
- 此时
2*
已被交换到2
前,相对顺序被破坏。步长调整为
1
(直接插入排序):
- 插入排序是稳定排序,会保留
2*
和2
的当前顺序(即2*
在2
前)- 最终结果:
[1, 2*, 2, 3, 5]
,相对顺序无法恢复原因:不同分组的插入排序可能导致相同元素在合并时跨越彼此,破坏稳定性。
五、堆排序
时间复杂度
最坏情况
: O ( n l o g n ) O(n log n) O(nlogn)
- 建堆:将初始数组构建为最大堆,时间复杂度为 O ( n ) O(n) O(n)
- 排序:每次取出堆顶元素(最大值)并调整堆,重复 n − 1 n-1 n−1 次,每次调整时间为 O ( l o g n ) O(log n) O(logn)
总时间复杂度为 O ( n + n l o g n ) = O ( n l o g n ) O(n + n log n) = O(n log n) O(n+nlogn)=O(nlogn),且无论输入数据如何分布,均保持此复杂度。
最好情况
: O ( n l o g n ) O(n log n) O(nlogn)
即使输入数据已经有序,堆排序仍需完整执行建堆和调整堆的过程,时间复杂度仍为 O ( n l o g n ) O(n log n) O(nlogn)
平均情况
: O ( n l o g n ) O(n log n) O(nlogn)
堆排序的性能稳定,不受数据分布影响,平均时间复杂度与最坏情况一致。稳定性
不稳定排序
堆排序在交换堆顶元素与末尾元素时,可能改变相同元素的相对顺序。
- 原始数组:
[5, 5*, 3]
- 建堆后:
[5, 5*, 3]
(最大堆结构)- 第一次交换:将堆顶
5
与末尾3
交换,得到[3, 5*, 5]
,此时5*
和5
的相对顺序被改变原因:堆排序的交换操作发生在非相邻元素之间,无法保证相同元素的相对顺序不变。
六、快速排序
时间复杂度
快速排序的时间复杂度与基准元素的选择密切相关,分为三种情况:
最好情况
: O ( n l o g n ) O(n log n) O(nlogn)
若每次选择的基准元素都能将数组划分为两个长度相等的子数组,递归深度为 O ( l o g n ) O(log n) O(logn),每层排序时间为 O ( n ) O(n) O(n),总时间复杂度为 O ( n l o g n ) O(n log n) O(nlogn)最坏情况
: O ( n 2 ) O(n²) O(n2)
若每次选择的基准元素总是当前数组的最大值或最小值(如:完全有序数组未优化时),递归深度退化为 O ( n ) O(n) O(n),每层排序时间为 O ( n ) O(n) O(n),总时间复杂度为 O ( n 2 ) O(n²) O(n2)平均情况
: O ( n l o g n ) O(n log n) O(nlogn)
假设基准元素随机选择,期望递归深度为 O ( l o g n ) O(log n) O(logn),平均时间复杂度为 O ( n l o g n ) O(n log n) O(nlogn)结论:通过优化,快速排序在实际应用中几乎接近最优情况,平均性能优于堆排序和归并排序,是实践中最常用的排序算法之一。
空间复杂度
快速排序的空间复杂度取决于递归栈的深度,同样受基准选择影响:
最坏情况
: O ( n ) O(n) O(n)
递归深度为 O ( n ) O(n) O(n),空间复杂度为 O ( n ) O(n) O(n)
最优 / 平均情况
: O ( l o g n ) O(log n) O(logn)
递归深度为 O ( l o g n ) O(log n) O(logn),空间复杂度为 O ( l o g n ) O(log n) O(logn)优化:通过
尾递归
优化或迭代实现
(使用栈模拟递归),可将最坏空间复杂度降至 O ( l o g n ) O(log n) O(logn)(通过优先处理较短子数组)结论:空间复杂度通常为 O ( l o g n ) O(log n) O(logn)(平均),最坏情况下为 O ( n ) O(n) O(n),属于原地排序算法(仅需常数级额外空间用于基准交换,但若使用非原地划分策略,空间复杂度可能更高)
稳定性
不稳定排序
快速排序在划分过程中,相同元素可能被分配到不同子数组,导致相对顺序改变。情景1:基准选择
5
(保持顺序)
划分过程(pivot=5):
- 左子数组(≤5):
[3, 4, 5]
- 右子数组(>5):
[5*]
递归排序:
左子数组
[3, 4, 5]
排序后不变右子数组
[5*]
为单元素合并结果:
[3, 4, 5, 5*]
- ✅ 顺序未变:
5
仍在5*
前
情景2:基准选择
3
(破坏顺序)
划分过程(pivot=3):
- 左子数组(≤3):
[]
- 右子数组(>3):
[5, 5*, 4]
递归排序:
- 右子数组
[5, 5*, 4]
选择pivot=4
:
- 左子数组(≤4):
[4]
- 右子数组(>4):
[5, 5*]
- 右子数组
[5, 5*]
选择pivot=5*
:
- 可能交换
5
和5*
→ 结果为[5*, 5]
最终合并:
[3, 4, 5*, 5]
- ❌ 顺序颠倒:
5*
被移到5
前核心原因:快速排序的划分操作可能跨子数组交换元素,相同元素的相对顺序无法保证。
七、归并排序
时间复杂度
最坏情况
: O ( n l o g n ) O(n log n) O(nlogn)
分解:递归将数组分成两半,直到每个子数组只有一个元素,递归深度为 O ( l o g n ) O(log n) O(logn)
合并:逐层合并两个有序子数组,每次合并时间为 O ( n ) O(n) O(n)
总时间复杂度: O ( n l o g n ) O(n log n) O(nlogn),且无论输入数据如何分布,均保持此复杂度。
最好情况
: O ( n l o g n ) O(n log n) O(nlogn)即使输入数据已经有序,仍需完整执行分解和合并过程,时间复杂度仍为 O ( n l o g n ) O(n log n) O(nlogn)
平均情况
: O ( n l o g n ) O(n log n) O(nlogn)性能稳定,不受数据分布影响,平均时间复杂度与最坏情况一致。
空间复杂度
辅助空间
: O ( n ) O(n) O(n)归并排序在合并两个有序子数组时,需要额外的临时数组存储合并结果,空间复杂度为 O ( n ) O(n) O(n)
优化:虽然递归调用栈的深度为 O ( l o g n ) O(log n) O(logn) ,但主导空间开销的是临时数组,因此总空间复杂度为 O ( n ) O(n) O(n)
稳定性
稳定排序
归并排序在合并过程中,若遇到相等元素,会优先选择左子数组的元素(即:保持原始数组中的相对顺序)
原始数组:[5, 3, 5*, 4]
分解阶段
:
- 分解为
[5, 3]
和[5*, 4]
- 继续分解为
[5]
、[3]
、[5*]
、[4]
合并阶段
:
- 合并
[5]
和[3]
:
- 比较
5
和3
,3
较小,先放入结果 →[3, 5]
- 合并
[5*]
和[4]
:
- 比较
5*
和4
,4
较小,先放入结果 →[4, 5*]
- 合并
[3, 5]
和[4, 5*]
:
- 依次比较:
3
vs4
→ 放入3
→ 结果[3]
5
vs4
→ 放入4
→ 结果[3, 4]
5
vs5*
→==相等,优先取左子数组的5
== → 结果[3, 4, 5]
- 剩余
5*
放入 → 最终结果[3, 4, 5, 5*]
原因:合并操作通过比较元素大小并按顺序复制到临时数组,确保相同元素的相对顺序不变。
八、计数排序
时间复杂度
最坏情况
: O ( n + k ) O(n + k) O(n+k)
统计频率
:遍历原始数组,统计每个元素的出现次数,时间复杂度为 O ( n ) O(n) O(n)( n n n 为数组长度)
计算前缀和
:遍历频率数组,计算前缀和以确定元素的最终位置,时间复杂度为 O ( k ) O(k) O(k)( k k k 为元素取值范围,即:最大值 - 最小值 + 1
)
输出排序结果
:遍历原始数组,根据前缀和将元素放入结果数组,时间复杂度为 O ( n ) O(n) O(n)总时间复杂度: O ( n + k ) O(n + k) O(n+k),当 k 与 n 同阶时,效率接近线性
最好情况
: O ( n + k ) O(n + k) O(n+k)无论数据是否有序,均需完成上述三步,时间复杂度与最坏情况一致。
平均情况
: O ( n + k ) O(n + k) O(n+k)性能不依赖数据分布,仅与 n n n 和 k k k 相关。
空间复杂度
辅助空间
: O ( n + k ) O(n + k) O(n+k)需要额外创建两个数组:
频率数组
:长度为 k k k,用于存储每个元素的出现次数。
结果数组
:长度为 n n n,用于存储排序后的元素。 局限性:若 k k k 远大于 n n n(如:元素范围极大),空间占用会显著增加。
稳定性
稳定排序
计数排序通过逆序遍历原始数组保证稳定性。原始数组:
[5, 3, 5*, 4]
统计元素频率
count[3] = 1 count[4] = 1 count[5] = 2
计算前缀和数组
prefix_sum[3] = 1 prefix_sum[4] = 2 prefix_sum[5] = 4
反向遍历原数组并填充结果
- 处理最后一个元素
4
:
prefix_sum[4] = 2
→4
放入结果数组索引2-1=1
prefix_sum[4]
减 1 →prefix_sum[4] = 1
- 结果数组:
[_, 4, _, _]
- 处理
5*
:
prefix_sum[5] = 4
→5*
放入索引4-1=3
prefix_sum[5]
减 1 →prefix_sum[5] = 3
- 结果数组:
[_, 4, _, 5*]
- 处理
3
:
prefix_sum[3] = 1
→3
放入索引1-1=0
prefix_sum[3]
减 1 →prefix_sum[3] = 0
- 结果数组:
[3, 4, _, 5*]
- 处理第一个元素
5
:
prefix_sum[5] = 3
→5
放入索引3-1=2
prefix_sum[5]
减 1 →prefix_sum[5] = 2
- 结果数组:
[3, 4, 5, 5*]
最终结果:
[3, 4, 5, 5*]
,5
和5*
的相对顺序保持不变(5 → 5*
)原因:逆序遍历确保相同元素在结果数组中的顺序与原始数组一致。