经典排序算法
Jackie

经典排序算法

可视化数据结构和算法

旧金山大学数据结构可视化

Hello算法

本文部分内容参考自《2026年数据结构考研复习指导》王道论坛著

  • 本文凡是没有特殊注明的,通常默认排序结果为非递减有序序列

  • 每小节的时间复杂度表示均为平均时间复杂度

  • 为使代码更简洁,使用函数swap()两两交换元素

    void swap(int &a,int &b){
    int temp=a;
    a=b;
    b=temp;
    }

各个算法时间复杂度

image

村里有两只做事很稳的动物:插帽龟统计鸡。插帽龟喜欢去插人家堆起来的帽子,统计鸡喜欢做加减乘除。但有天插帽龟挑选帽子插的时候,恩姓长老看见就慌了,恩老大喊:“快点归还给堆”

image

一、直接插入排序(Direct Insertion Sort)

原理

每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成

步骤

  1. 从第一个元素开始,该元素可以认为已经被排序
  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描
  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置
  4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
  5. 将新元素插入到该位置后
  6. 重复步骤2~5

动画演示

image

代码实现

void InsertSort(ElemType A[],int n){
int i,j;
for(i=2;i<=n;i++){//依次将A[2]~A[n]插入前面已排序序列
if(A[i]<A[i-1]){//A[i]关键码小于其前驱,将A[i]插入有序表
A[0]=A[i];//复制为哨兵,A[0]不存放元素
for(j=i-1;A[0]<A[j];--j){//从后往前查找插入位置
A[j+1]=A[j];//向后挪位
}
A[j+1]=A[0];//复制到插入位置
}
}
}
  • 时间复杂度:O(n2)
  • 空间复杂度:O(1)
  • 稳定
  • 适用性:适用于顺序存储和链式存储的线性表,采用链式存储时无须移动元素

二、折半插入排序(Binary Insertion Sort)

原理

通过二分查找在已排序区间中快速定位插入位置,再移动元素完成插入

步骤

  1. 从第一个元素开始,该元素可以认为已经被排序
  2. 取出下一个元素作为待插入元素key
  3. 在已排序的子序列(A[1…i-1])中使用二分查找确定key的插入位置pos
  4. 将 pos位置及其后的所有元素依次向后移动一位
  5. 将 key插入到pos位置
  6. 重复步骤2~5

动画演示

参考以下三个链接

可视化数据结构和算法

旧金山大学数据结构可视化

Hello算法

代码实现

void BinaryInsertSort(ElemType A[],int n){
int i,j,low,high,mid;
for(i=2;i<=n;i++){//依次将A[2]~A[n]插入前面的已排序序列
A[0]=A[i];//将A[i]暂存到A[0]
low=1;//设置折半查找的范围
high=i-1;
while(low<=high){//折半查找(默认递增有序)
mid=(low+high)/2;//取中间点
if(A[mid]>A[0]){//查找左半子表
high=mid-1;
}else{
low=mid+1;//查找右半子表
}
}
for(j=i-1;j>=high+1;--j){
A[i+1]=A[j];//统一后移元素,空出插入位置
}
A[high+1]=A[0];//插入操作
}
}
  • 时间复杂度:O(n2)
  • 空间复杂度:O(1)
  • 稳定
  • 适用性:仅适用于顺序存储的线性表

三、希尔排序(Shell Sort)

原理

希尔排序又叫缩小增量排序,也是一种插入排序方法(通常快于直接插入法),具体做法是将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序

步骤

  1. 取一个小于n的增量d1,把表中的全部记录分成d1组,所有距离为d1的倍数的记录放在同一组,在各组内进行直接插入排序
  2. 取第二个增量d2<d1,重复上述过程
  3. 直到所取到的di=1,即所有记录已放在同一组中,再进行直接插入排序

动画演示

image

代码实现

void ShellSort(ElemType A[],int n){
int d,i,j;
for(d=n/2;d>=1;d=d/2){//此处以n/2为增量
for(i=d+1;i<=n;++i){
if(A[i]<A[i-d]){//需将A[i]插入有序增量子表
A[0]=A[i];//暂存在A[0]
for(j=i-d;j>0&&A[0]<A[j];j-=d){
A[j+d]=A[j];//记录后移,查找插入位置(先内后外)
}
A[j+d]=A[0];//插入
}
}
}
}
  • 时间复杂度:O(n1.3)
  • 空间复杂度:O(1)
  • 不稳定
  • 适用性:仅适用于顺序存储的线性表

四、冒泡排序(Bubble Sort)

原理

从后往前(或从前往后)两两比较相邻元素的值,若为逆序(A[i-1]>A[i])则交换它们,直到序列比较完,关键字最小的元素如气泡一般逐渐往上“漂浮”至“水面”(或关键字最大的元素如石头一般下沉至水底),每趟冒泡的结果是把序列中的最小元素(或最大元素)放到了序列的最终位置,直到所有元素排序完成

冒泡排序中所产生的有序子序列一定是全局有序的(不同于直接插入排序),即有序子序列中的所有元素的关键字一定小于(或大于)无序子序列中所有元素的关键字,这样每趟排序都会将一个元素放置到其最终的位置上

步骤

  1. 比较相邻的元素。如果第一个比第二个大,就交换它们两个
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数
  3. 针对所有的元素重复步骤1~2,除了最后一个元素,直到排序完成

动画演示

image

代码实现

void BubbleSort(ElemType A[],int n){
for(int i=0;i<n-1;i++){
bool flag=false;//表示本趟冒泡是否发生交换
for(int j=n-1;j>i;j--){//一趟冒泡过程
if(A[j-1]>A[j]){//若为逆序
swap(A[j-1],A[j]);//使用封装的swap函数交换
flag=true;
}
if(flag=false){
return;//本趟遍历后没有发生交换,说明已经有序
}
}
}
}
  • 时间复杂度:O(n2)
  • 空间复杂度:O(1)
  • 稳定
  • 适用性:适用于顺序存储和链式存储的线性表

五、快速排序(Quick Sort)

原理

在待排序表L[1…n]中任取一个元素pivot作为枢轴(或称基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分L[1…k-1]和L[k+1…n],使得L[1…k-1]中的所有元素小于pivot,L[k+1…n]中的所有元素大于或等于pivot,则pivot放在了其最终位置L(k)上,分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或为空位置,即所有元素放在了其最终位置上

步骤

快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:

  1. 选取基准元素(pivot)
  2. 划分,根据选取的pivot将数组划分成小于pivot的部分和大于pivot的部分
  3. 递归求解小于pivot和大于pivot的部分

基准元素可以选择第一个元素或者最后一个元素即Lomuto Partition Scheme,但是这样划分成两部分的时候有一部分是空的,这样可能造成死循环;从中间划分可以保证两部分都不为空,即Hoare Partition Scheme

对n个元素进行第一趟快速排序后,会确定一个基准元素,根据这个基准元素在数组中的位置,有两种情况:

①基准元素在数组的首端或尾端,接下来对剩下的n-1个元素构成的子序列进行第二趟快速排序,再确定一个基准元素。这样,在两趟排序后就至少能确定两个元素的最终位置,其中至少有一个元素是在数组的首端或尾端

②基准元素不在数组的首端或尾端,第二趟快速排序对基准元素划分开的两个子序列分别进行一次划分,两个子序列各确定一个基准元素。这样,两趟排序后就至少能确定三个元素的最终位置

动画演示

image

代码实现

void QuickSort(ElemType A[],int low,int high){
if(low<high){//递归跳出条件
int pivotpos=Partition(A,low,high);//划分
QuickSort(A,low,pivotpos-1);//依次对两个子表进行递归排序
QuickSort(A,pivotpos+1,high);
}
}

int Partition(ElemType A[],int low,int high){//划分操作,将表A[low...high]划分为满足上述条件的两个子表
ElemType pivot=A[low];//将当前表中第一个元素设为枢轴,对表进行划分
while(low<high){//循环跳出条件
while(low<high&&A[high]>=pivot){
--high;
}
A[low]=A[high];//将比枢轴小的元素移动到左端
while(low<high&&A[low]<=pivot){
++low;
}
A[high]=A[low];//将比枢轴大的元素移动到右端
}
A[low]=pivot;//枢轴元素存放到最终位置
return low;//返回存放枢轴的最终位置
}
  • 时间复杂度:O(nlog2n)
  • 空间复杂度:O(log2n)
  • 不稳定
  • 适用性:适用于顺序存储的线性表
  • 快速排序的边界问题

快排属于分治算法,最怕的就是n分成0和n,或n分成n和0,导致死循环

  1. 以j为划分时,x不能选q[r](若以i为划分,则x不能选q[l])

    假设x=q[r]

    关键句子quick_sort(q,l,j),quick_sort(q,j+1,r)

    由于j的最小值是l,所以q[j+1…r]不会造成无限划分

    但q[l…j](即quick_sort(q,l,j))却可能造成无限划分,因为j可能为r

    举例来说,若x选为q[r],数组中q[l…r-1]<x,

    那么这一轮循环结束时i=r,j=r,显然会造成无限划分

  2. do i++;while q[i]<x和do j–;whileq[j]>x不能用q[i]<=x和q[j]>=x

    假设q[l…r]全相等

    则执行完do i++;while(q[i]<=x);之后,i会自增到r+1

    然后继续执行q[i]<=x判断条件,造成数组下标越界但这貌似不会报错但这貌似不会报错

    并且如果之后的q[i]<=x此时i>r此时i>r条件也不幸成立,

    就会造成一直循环下去亲身实验亲身实验,造成内存超限MemoryLimitExceeded

  3. if(i<j)swap(q[i],q[j])能否使用i<=j

    可以使用if(i<=j)swap(q[i],q[j])

    因为i=j时,交换一下q[i],q[j]无影响,因为马上就会跳出循环了

  4. 最后一句能否改用quick_sort q,l,j−1,quick_sort q,j,r作为划分用i做划分时也是同样的道理,用i做划分时也是同样的道理,

    不能根据之前的证明,最后一轮循环可以得到这些结论

    j<=i和q[l…i-1]<=x,q[i]>=x和q[j+1…r]>=x,q[j]<=x

    所以,q[l…j-1]<=x是显然成立的,

    但quick_sort(q,j,r)中的q[j]却是q[j]<=x,这不符合快排的要求

    另外一点,注意quick_sort(q,l,j-1),quick_sort(q,j,r)可能会造成无限划分

    当x选为q[l]时会造成无限划分,报错为MLE,

    如果手动改为x=q[r],可以避免无限划分

    但是上面所说的q[j]<=x的问题依然不能解决,这会造成WA WrongAnswer

  5. j的取值范围为[l…r-1]

    假设j最终的值为r,说明只有一轮循环(两轮的话j至少会自减两次)

    说明q[r]<=x因为要跳出do−while循环因为要跳出do-while循环

    说明i>=r(while循环的结束条件),i为r或r+1必不可能成立必不可能成立

    说明i自增到了r,说明q[r]>=x和q[l…r-1]<x,

    得出q[r]=x和q[l…r-1]<x的结论,但这与x=q[l+r>>1]矛盾

    反证法得出j<r

    假设j可能小于l说明q[l…r]>x,矛盾

    反证法得出j>=l

所以j的取值范围为[l…r-1],不会造成无限划分和数组越界。

六、选择排序(Selection Sort)

原理

假设排序表为L[1…n],第i趟排序即从L[i…n]中选择关键字最小的元素与L(i)交换,每一趟排序可以确定一个元素的最终位置,这样经过n-1趟排序就可使得整个排序表有序

步骤

n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:

  1. 初始状态:无序区为R[1…n],有序区为空
  2. 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1…i-1]和R(i…n)。该趟排序从当前无序区中选出关键字最小的记录R[k],将它与无序区的第1个记录R交换,使R[1…i]和R[i+1…n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区
  3. n-1趟结束,数组有序化了

动画演示

image

代码实现

void SelectSort(ElemType A[],int n){
for(int i=0;i<n-1;i++){//一共进行n-1趟
int min=i;//记录最小元素位置
for(int j=i+1;j<n;j++){//在A[i...n-1]中选择最小的元素
if(A[j]<A[min]){
min=j;//更新最小元素位置
}
}
if(min!=i){
swap(A[i],A[min]);
}
}
}
  • 时间复杂度:O(n2)
  • 空间复杂度:O(1)
  • 不稳定
  • 适用性:适用于顺序存储和链式存储的线性表,以及关键字较少的情况

七、堆排序(Heap Sort)

原理

堆是一种特殊的树形数据结构,即完全二叉树。堆分为大根堆和小根堆,大根堆为根节点的值大于两个子节点的值;小根堆为根节点的值小于两个子节点的值,同时根节点的两个子树也分别是一个堆

image

堆排序是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:子结点的键值或索引总是小于(或者大于)它的父节点

首先将存放在L[1…n]中的n个元素建成初始堆,因为堆本身的特点(以大顶堆为例),所以堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足大顶堆的性质,堆被破坏,将堆顶元素向下调整使其继保大该堆的性质,再输出堆顶元素。如此重复,直到堆中仅剩一个元素为止

步骤

  1. 构建堆:将待排序序列构建成一个堆H[0…n-1],从最后一个非叶子结点开始,从左至右,从下至上进行调整。根据升序或降序需求选择大顶堆或小顶堆,此时的堆顶元素,为最大或者最小元素
  2. 把堆顶元素和堆尾元素互换,调整堆,重新使堆有序,此时堆顶元素为第二大元素
  3. 重复以上步骤,直到堆变空

动画演示

image

代码实现

//以建立大根堆为例
void BuildMaxHeap(ElemType A[],int len){
for(int i=len/2;i>0;i--){//从i=[n/2]~1,反复调整堆
HeadAdjust(A,i,len);
}
}

void HeadAdjust(ElemType A[],int k,int len){//对以k为根的子树进行调整
A[0]=A[k];//A[0]暂存子树的根结点
for(int i=2*k;i<=len;i*=2){//沿key较大的子结点向下筛选
if(i<len&&A[i]<A[i+1]){
i++;//取key较大的子结点的下标
}
if(A[0]>A[i]){
break;//筛选结束
}else{
A[k]=A[i];//将A[i]调整到双亲结点上
k=i;//修改i值,以便继续向下筛选
}
}
A[k]=A[0];//被筛选结点的值放入最终位置
}

void HeapSort(ElemType A[],int len){
BuildMaxHeap(A,len);//初始建堆
for(int i=len;i>1;i--){//n-1趟的交换和建堆过程
Swap(A[i],A[1]);//输出堆顶元素(和堆底元素交换)
HeadAdjust(A,1,i-1);//把剩余的i-1个元素整理成堆
}
}
  • 时间复杂度:O(nlog2n)
  • 空间复杂度:O(1)
  • 不稳定
  • 适用性:适用于顺序存储的线性表

八、归并排序(Merge Sort)

原理

该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2路归并

对N个元素进行k路归并排序时,排序趟数m满足km=N,从而m=logkN,考虑到m为整数,因此

m=logkNm=\left \lceil log_kN \right \rceil

步骤

  1. 把长度为n的输入序列分成两个长度为n/2的子序列
  2. 对这两个子序列分别采用归并排序
  3. 将两个排序好的子序列合并成一个最终的排序序列

动画演示

image

代码实现

ElemType *B=(ElemType *)malloc((n+1)*sizeof(ElemType));//辅助数组B

//表A的两段A[low...mid]和A[mid+1...high]各自有序,将它们合并成一个有序表
void Merge(ElemType A[],int low,int mid,int high){
int i,j,k;
for(k=low;k<=high;k++){
B[k]=A[k];//将A中所有元素复制到B中
for(i=low;j=mid+1;k=i;i<mid&&j<=high;k++){
if(B[i]<=B[j]){//比较B的两个段中的元素
A[k]=B[i++];//将较小值复制到A中
}else{
A[k]=B[j++];
}
}
while(i<=mid){//两个while循环只有一个会执行
A[k++]=B[i++];//若第一个表未检测完,复制
}
while(j<=high){
A[k++]=B[j++];//若第二个表未检测完,复制
}
}
}

void MergeSort(ElemType A[],int low,int high){
if(low<high){
int mid=(low+high)/2;//从中间划分两个子序列
MergeSort(A,low,mid);//对左侧子序列进行递归排序
MergeSort(A,mid+1,high);//对右侧子序列进行递归排序
Merge(A,low,mid,high);//归并
}
}
  • 时间复杂度:O(nlog2n)
  • 空间复杂度:O(n)
  • 稳定
  • 适用性:适用于顺序存储和链式存储的线性表

九、基数排序(Radix Sort)

原理

基数排序是一种很特别的排序算法,它不依赖于元素间的比较,而基于关键字各位的大小进行排序。基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法

假设长度为n的线性表中,每个结点aj的关键字由d个分量(kd-1j,kd-2j,…,k1j,k0j)组成,满足0≤kij≤r-1(0≤j<n,0≤i≤d-1)。其中kd-1j为最高位关键字,k为最低位关键字

为实现多关键字排序,通常有两种方法:第一种是最高位优先(MSD)法,按关键字位权重递减依次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序序列;第二种是最低位优先(LSD)法,按关键字位权重递增依次进行排序,最后形成一个有序序列

步骤

以下描述以基数r进行的最低位优先基数排序过程。算法使用r个队列Q0,Q1,…,Qr-1。对每一位i=0,1,…,d-1,依次执行一次分配和收集操作(每轮本质上是一次稳定的排序):

  1. 分配:初始化所有队列为空,然后依次扫描线性表中的每个结点aj(j=0,1,…,n-1),若aj在当前位上的关键字值为k,则将其加入队列Qk
  2. 收集:按Q0到Qr-1的顺序,依次将各队列中的结点连接起来,形成新的线性表

动画演示

image

代码实现

int maxbit()
{
int maxv = a[0];
for (int i = 1; i < n; i ++ )
if (maxv < a[i])
maxv = a[i];
int cnt = 1;
while (maxv >= 10) maxv /= 10, cnt ++ ;

return cnt;
}
void RadixSort()
{
int t = maxbit();
int radix = 1;

for (int i = 1; i <= t; i ++ )
{
for (int j = 0; j < 10; j ++ ) count[j] = 0;
for (int j = 0; j < n; j ++ )
{
int k = (a[j] / radix) % 10;
count[k] ++ ;
}
for (int j = 1; j < 10; j ++ ) count[j] += count[j-1];
for (int j = n-1; j >= 0; j -- )
{
int k = (a[j] / radix) % 10;
temp[count[k]-1] = a[j];
count[k] -- ;
}
for (int j = 0; j < n; j ++ ) a[j] = temp[j];
radix *= 10;
}

}
  • 时间复杂度:O(d(n+r))
  • 空间复杂度:O(r)
  • 稳定
  • 适用性:适用于顺序存储和链式存储的线性表,链式结构尤其适合

十、计数排序(Counting Sort)

原理

核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数

计数排序是一种用空间换时间的排序。对每个待排序元素x,统计小于x的元素个数,从而确定x在有序序列中的位置。当存在重复元素时,需对算法稍做调整,以保证排序的稳定性

步骤

  1. 找出待排序的数组中最大和最小的元素
  2. 统计数组中每个值为i的元素出现的次数,存入数组C的第i项
  3. 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
  4. 反向填充目标数组:将每个元素i放在新数组的第Ci项,每放一个元素就将Ci减去1

动画演示

image

代码实现

void CountSort(ElemType A[],ElemType B[],int m,int k){
int i,C[k];
for(i=0;i<k;i++){
C[i]=0;//初始化计数数组
}
for(i=0;i<n;i++){//遍历输入数组,统计每个元素出现的次数
C[A[i]]=C[A[i]]+1;//C[A[i]]保存的是等于A[i]的元素个数
}
for(i=1;i<k;i++){
C[i]=C[i]+C[i-1];//C[x]保存的是小于或等于x的元素总数
}
for(i=n-1;i>=0;i--){//从后往前遍历输入数组
B[C[A[i]]-1]=A[i];//将元素A[i]放置到输出数组B[]的正确位置
C[A[i]]=C[A[i]]-1;//更新计数数组,确保相同元素的相对顺序
}
}
  • 时间复杂度:O(n+k)
  • 空间复杂度:O(n+k)
  • 稳定
  • 适用性:适用于顺序存储的线性表;序列中的元素是整数且元素范围(0~k-1)不能太大,否则会造成辅助空间的浪费

十一、桶排序(Bucket Sort)

原理

遍历原始序列确定最大值maxval和最小值minval,并确定桶的个数n,然后将待排序集合中处于同一个值域的元素存入同一个桶中,在桶内使用各种现有的算法进行排序;最后按照从小到大的顺序依次收集桶中的每一个元素,即为最终结果

步骤

  1. 设置一个定量的数组当作空桶
  2. 遍历输入数据,并且把数据一个一个放到对应的桶里去
  3. 对每个不是空的桶进行排序
  4. 从不是空的桶里把排好序的数据拼接起来

桶排序是一种用空间换时间的排序。桶的个数和大小都是我们人为设置的,而每个桶又要避免空桶的情况,所以我们在使用桶排序的时候即需要对待排序数列要求偏均匀,又要要求桶的设计兼顾效率和空间;数要相对均匀分布,桶的个数也要合理设计。在设计桶排序时,需要知道输入数据的上界和下界

动画演示

image

代码实现

//桶排序 
void BucketSort(int a[], int n){
int minval = a[0], maxval = a[0];
for(int i = 0; i < n; i ++){//寻找原序列数组元素的最大值和最小值
minval = min(minval, a[i]);
maxval = max(maxval, a[i]);
}

int bnum = 10;//桶中元素个数
int m = (maxval - minval) / bnum + 1;//桶的个数
vector< vector<int> > bucket(m);

//收集,将元素入相应的桶中. 减偏移量是为了将元素映射到更小的区间内,省内存
for(int i = 0; i < n; i ++) bucket[(a[i] - minval) / bnum].push_back(a[i]);

//将桶内元素排序
for(int i = 0; i < m; i ++) sort(bucket.begin(), bucket.end());

//收集, 将各个桶中的元素收集到一起
for(int i = 0, k = 0; i < m; i ++){
for(int j = 0; j < bucket[i].size(); j ++){
data[k ++] = bucket[i][j];
}
}
}
  • 时间复杂度:O(n+k)
  • 空间复杂度:O(n+k)
  • 稳定
  • 适用性:适用于顺序存储的线性表
 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
总字数 157.3k