定义:
在排序问题中,我们通常将数据元素称为记录。所以,可以将排序看成是线性表的一种操作。 排序的依据是关键字之间的大小关系。
0、准备基础
排序用到的结构与函数
顺序表结构
#define MAXSIZE 10 /*用于要排序数组个数最大值,可根据需要修改*/
typedef struct
{
int r[MAXSIZE+1]; /*用于存储要排序数组,r[0]用作哨兵或临时变量*/
int length; /*用于记录顺序表的长度*/
}SqList;
排序最最常用到的操作是数组两元素的交换,封装成函数
/*交换L中数组r的下标为i和j的值*/
void swap ( SqList*L,int i,int j)
{
int temp=L->r[i];
L->r[i]=L->r[j];
L->r[j]=temp;
}
1、冒泡排序
冒泡排序(Bubble Sort)一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
//外层循环表示总共的排序轮数
for(let i=0;i<len-1;i++){
//内层循环表示该轮的对比次数
for(let j=0;j<len-1-i;j++){
if(arr[j]>arr[j+1]){
let temp=arr[j]
arr[j]=arr[j+1]
arr[j+1]=temp
}
}
}
/* 对顺序表L作冒泡排序 */
void BubbleSort(SqList *L)
{
int i,j;
for(i=1;i<L->length;i++)
{
for(j=L->length-1;j>=i;j--) /* 注意j是从后往前循环 */
{
if(L->r[j]>L->r[j+1]) /* 若前者大于后者(注意这里与上一算法的差异)*/
{
swap(L,j,j+1);/* 交换L->r[j]与L->r[j+1]的值 */
}
}
}
}
/* 对顺序表L作改进冒泡算法 */
void BubbleSort2(SqList *L)
{
int i,j;
Status flag=TRUE; /* flag用来作为标记 */
for(i=1;i<L->length && flag;i++) /* 若flag为true说明有过数据交换,否则停止循环 */
{
flag=FALSE; /* 初始为False */
for(j=L->length-1;j>=i;j--)
{
if(L->r[j]>L->r[j+1])
{
swap(L,j,j+1); /* 交换L->r[j]与L->r[j+1]的值 */
flag=TRUE; /* 如果有数据交换,则flag为true */
}
}
}
}
时间复杂度:O( n 2 n^2 n2)
2、简单选择排序
选择排序的基本思想是每一趟在n-i+1(i=1,2,…,n-1)个记录中选取关键字最小的记录作为有序序列的第i个记录。
**简单选择排序法(Simple Selection Sort)**就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i (1=<i<=n)个记录交换之。
/* 对顺序表L作简单选择排序 */
void SelectSort(SqList *L)
{
int i,j,min;
for(i=1;i<L->length;i++)
{
min = i; /* 将当前下标定义为最小值下标 */
for (j = i+1;j<=L->length;j++)/* 循环之后的数据 */
{
if (L->r[min]>L->r[j]) /* 如果有小于当前最小值的关键字 */
min = j; /* 将此关键字的下标赋值给min */
}
if(i!=min) /* 若min不等于i,说明找到最小值,交换 */
swap(L,i,min); /* 交换L->r[i]与L->r[min]的值 */
}
}
时间复杂度:O(
n
2
n^2
n2)
3、直接插入排序
直接插入排序(Straight Insertion Sort)的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。
/* 对顺序表L作直接插入排序 */
void InsertSort(SqList *L)
{
int i,j;
for(i=2;i<=L->length;i++)
{
if (L->r[i]<L->r[i-1]) /* 需将L->r[i]插入有序子表 */
{
L->r[0]=L->r[i]; /* 设置哨兵 */
for(j=i-1;L->r[j]>L->r[0];j--)
L->r[j+1]=L->r[j]; /* 记录后移 */
L->r[j+1]=L->r[0]; /* 插入到正确位置 */
}
}
}
1.程序开始运行,此时我们传入的SqList参数的值为length=6,r[6]=
{0,5,3,4,6,2},其中 r[0]=0将用于后面起到哨兵的作用。
2.第4~13行就是排序的主循环。i 从2开始的意思是我们假设r[1]=5已经放好位置,后面的牌其实就是插入到它的左侧还是右侧的问题。
时间复杂度:O( n 2 n^2 n2)
4、希尔排序
如何让待排序的记录个数较少呢?很容易想到的就是将原本有大量记录数的记录进行分组。分割成若干个子序列,此时每个子序列待排序的记录个数就比较少了,然后在这些子序列内分别进行直接插入排序,当整个序列都==基本有序==时,注意只是基本有序时,再对全体记录进行一次直接插入排序。
所谓的基本有序,就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间,像{2,1,3,6,4,7,5,8,9}这样可以称为基本有序了。但像{1,5,9,3,7,8,2,4,6}这样的9在第三位,2在倒数第三位就谈不上基本有序。
问题其实也就在这里,我们分割待排序记录的目的是减少待排序记录的个数,并使整个序列向基本有序发展。而如上面这样分完组后就各自排序的方法达不到我们的要求。因此,我们需要采取跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
/* 对顺序表L作希尔排序 */
void ShellSort(SqList *L)
{
int i,j,k=0;
int increment=L->length;
do
{
increment=increment/3+1;/* 增量序列 */
for(i=increment+1;i<=L->length;i++)
{
if (L->r[i]<L->r[i-increment])/* 需将L->r[i]插入有序增量子表 */
{
L->r[0]=L->r[i]; /* 暂存在L->r[0] */
for(j=i-increment;j>0 && L->r[0]<L->r[j];j-=increment)
L->r[j+increment]=L->r[j]; /* 记录后移,查找插入位置 */
L->r[j+increment]=L->r[0]; /* 插入 */
}
}
printf(" 第%d趟排序结果: ",++k);
print(*L);
}
while(increment>1);
}
希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。
这里“增量”的选取就非常关键了。我们在代码中第7行,是用increment=increment/3+1;的方式选取增量的,可究竟应该选取什么样的增量才是最好,目前还是一个数学难题,迄今为止还没有人找到一种最好的增量序列。不过大量的研究表明,当增量序列为 dlta[k]= 2 t − k + 1 2^{t-k+1} 2t−k+1-1 (0≤k≤t≤[log 2 _2 2(n+1)])时,可以获得不错的效率,其时间复杂度为O( n 3 / 2 n^{3/2} n3/2),要好于直接排序的 0(n 2 ^2 2)。需要注意的是,增量序列的最后一个增量值必须等于1才行。另外由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法。
5、堆排序
堆排序(Heap Sort),就是对简单选择排序进行的一种改进,这种改进的效果是非常明显的。
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
根结点一定是堆中所有结点最大(小)值。
具体请看二叉树的性质5。
对于有n个结点的二叉树而言,他的i值自然小于等于[n/2]了。(i指的是非叶结点)。
如果将大顶堆和小顶堆用层序遍历存入数组,则一定满足上面的关系表达:
堆排序(Heap Sort)就是利用堆(假设利用大顶堆)进行排序的方法。它的 基本思想 是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次小值。如此反复执行,便能得到一个有序序列了。
由基本思想推出,我们需要解决两个问题:
1.如何由一个无序序列构建成一个堆?
2.如果在输出堆顶元素后,调整剩余元素成为一个新的堆?
/* 对顺序表L进行堆排序 */
void HeapSort(SqList *L)
{
int i;
for(i=L->length/2;i>0;i--) /* 把L中的r构建成一个大顶堆 */
HeapAdjust(L,i,L->length);
for(i=L->length;i>1;i--)
{
swap(L,1,i); /* 将堆顶记录和当前未经排序子序列的最后一个记录交换 */
HeapAdjust(L,1,i-1); /* 将L->r[1..i-1]重新调整为大顶堆 */
}
}
第4行,i从4([9/2])开始:
弄清楚i的变化是在调整哪些元素?
我们所谓的将待排序的序列构建成为一个大顶堆,其实就是从下往上、从右到左,将每个非终端结点(非叶结点)当作根结点,将其和其子树调整成大顶堆。i的4一3→2→1的变量变化,其实也就是30,90,10、50的结点调整过程。
/* 堆排序********************************** */
/* 已知L->r[s..m]中记录的关键字除L->r[s]之外均满足堆的定义, */
/* 本函数调整L->r[s]的关键字,使L->r[s..m]成为一个大顶堆 */
void HeapAdjust(SqList *L,int s,int m)
{
int temp,j;
temp=L->r[s];
for(j=2*s;j<=m;j*=2) /* 沿关键字较大的孩子结点向下筛选 */
{
if(j<m && L->r[j]<L->r[j+1])
++j; /* j为关键字中较大的记录的下标 */
if(temp>=L->r[j])
break; /* rc应插入在位置s上 */
L->r[s]=L->r[j];
s=j;
}
L->r[s]=temp; /* 插入 */
}
它的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。
所以总体来说,堆排序的时间复杂度为O(nlogn)。
空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。
另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。
6、归并排序
所谓的全省排名,其实也就是每个市、每个县、每个学校、每个班级的排名合并后再排名得到的。注意我这里用到了合并一词。
前面我们讲了堆排序,因为它用到了完全二叉树,充分利用了完全二叉树的深度是[log 2 _2 2n]+1 的特性,所以效率比较高。
为了更清晰地说清楚这里的思想,大家来看下图所示,我们将本是无序的数组序列{16,7,13,10,9,15,3,2,5,8,12,1,11,4,6,14},通过两两合并排序后再合并,最终获得了一个有序的数组。注意仔细观察它的形状,你会发现,它像极了一棵倒置的完全二叉树,通常涉及到完全二叉树结构的排序算法,效率一般都不低的——这就是我们要讲的归并排序法。
归并排序(Merging Sort) 就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2] (「x]表示不小于x的最小整数)个长度为2或1的有序子序列;再两两归并,……,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。
6.1、归并排序的递归实现
javascript
function MergeSort(arr)
{
let result=[]
MSort(arr,result,0,arr.length-1);
}
/* 递归法 */
/* 将SR[s..t]归并排序为TR1[s..t] */
function MSort(SR,TR1,s, t)
{
let m;
let TR2[MAXSIZE+1];
if(s==t)
TR1[s]=SR[s];
else
{
m=(s+t)/2; /* 将SR[s..t]平分为SR[s..m]和SR[m+1..t] */
MSort(SR,TR2,s,m); /* 递归地将SR[s..m]归并为有序的TR2[s..m] */
MSort(SR,TR2,m+1,t); /* 递归地将SR[m+1..t]归并为有序的TR2[m+1..t] */
Merge(TR2,TR1,s,m,t); /* 将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t] */
}
}
/* 归并排序********************************** */
/* 将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n] */
function Merge(SR,TR,i,m, n)
{
let j,k,l;
for(j=m+1,k=i;i<=m && j<=n;k++) /* 将SR中记录由小到大地并入TR */
{
if (SR[i]<SR[j])
TR[k]=SR[i++];
else
TR[k]=SR[j++];
}
if(i<=m)
{
for(l=0;l<=m-i;l++)
TR[k+l]=SR[i+l]; /* 将剩余的SR[i..m]复制到TR */
}
if(j<=n)
{
for(l=0;l<=n-j;l++)
TR[k+l]=SR[j+l]; /* 将剩余的SR[j..n]复制到TR */
}
}
c语言
/* 对顺序表L作归并排序 */
void MergeSort(SqList *L)
{
MSort(L->r,L->r,1,L->length);
}
/* 递归法 */
/* 将SR[s..t]归并排序为TR1[s..t] */
void MSort(int SR[],int TR1[],int s, int t)
{
int m;
int TR2[MAXSIZE+1];
if(s==t)
TR1[s]=SR[s];
else
{
m=(s+t)/2; /* 将SR[s..t]平分为SR[s..m]和SR[m+1..t] */
MSort(SR,TR2,s,m); /* 递归地将SR[s..m]归并为有序的TR2[s..m] */
MSort(SR,TR2,m+1,t); /* 递归地将SR[m+1..t]归并为有序的TR2[m+1..t] */
Merge(TR2,TR1,s,m,t); /* 将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t] */
}
}
/* 归并排序********************************** */
/* 将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n] */
void Merge(int SR[],int TR[],int i,int m,int n)
{
int j,k,l;
for(j=m+1,k=i;i<=m && j<=n;k++) /* 将SR中记录由小到大地并入TR */
{
if (SR[i]<SR[j])
TR[k]=SR[i++];
else
TR[k]=SR[j++];
}
if(i<=m)
{
for(l=0;l<=m-i;l++)
TR[k+l]=SR[i+l]; /* 将剩余的SR[i..m]复制到TR */
}
if(j<=n)
{
for(l=0;l<=n-j;l++)
TR[k+l]=SR[j+l]; /* 将剩余的SR[j..n]复制到TR */
}
}
总的时间复杂度为O(nlogn)
由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为log
2
_2
2n的栈空间,因此空间复杂度为O(n+logn).
另外,对代码进行仔细研究,发现 Merge函数中有if (SR[i]<SR[j])语句,这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。
也就是说,归并排序是一种比较占用内存,但却效率高且稳定的算法。
6.2、归并排序的非递归方法
上述的归并排序大量引用了递归,这会造成时间和空间上的性能损耗。将递归转化成迭代。
JavaScript
/* 非递归法 */
/* 将SR[]中相邻长度为s的子序列两两归并到TR[] */
function MergePass(SR,TR,s, n)
{
let i=1;
let j;
while(i <= n-2*s+1)
{/* 两两归并 */
Merge(SR,TR,i,i+s-1,i+2*s-1);
i=i+2*s;
}
if(i<n-s+1) /* 归并最后两个序列 */
Merge(SR,TR,i,i+s-1,n);
else /* 若最后只剩下单个子序列 */
for(j =i;j <= n;j++)
TR[j] = SR[j];
}
/* 对顺序表arr作归并非递归排序 */
function MergeSort2(arr)
{
let TR=[]
let k=1;
while(k<arr.length)
{
MergePass(arr,TR,k,arr.length);
k=2*k;/* 子序列长度加倍 */
MergePass(TR,arr,k,arr.length);
k=2*k;/* 子序列长度加倍 */
}
}
c语言
/* 非递归法 */
/* 将SR[]中相邻长度为s的子序列两两归并到TR[] */
void MergePass(int SR[],int TR[],int s,int n)
{
int i=1;
int j;
while(i <= n-2*s+1)
{/* 两两归并 */
Merge(SR,TR,i,i+s-1,i+2*s-1);
i=i+2*s;
}
if(i<n-s+1) /* 归并最后两个序列 */
Merge(SR,TR,i,i+s-1,n);
else /* 若最后只剩下单个子序列 */
for(j =i;j <= n;j++)
TR[j] = SR[j];
}
/* 对顺序表L作归并非递归排序 */
void MergeSort2(SqList *L)
{
int* TR=(int*)malloc(L->length * sizeof(int));/* 申请额外空间 */
int k=1;
while(k<L->length)
{
MergePass(L->r,TR,k,L->length);
k=2*k;/* 子序列长度加倍 */
MergePass(TR,L->r,k,L->length);
k=2*k;/* 子序列长度加倍 */
}
}
从代码中,我们能够感受到,非递归的迭代做法更加直截了当,从最小的序列开始归并直至完成。不需要像归并的递归算法一样,需要先拆分递归,再归并退出递归。
非递归的迭代方法,避免了递归时深度为log2n 的栈空间,空间只是用到申请归并临时用的 TR数组,因此空间复杂度为O(n),并且避免递归也在时间性能上有一定的提升,应该说,使用归并排序时,尽量考虑用非递归方法。(分治法)
7、快速排序(排序算法王者)
快速排序(Quick Sort)的基本思想 是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
以数组{50,10,90,30,70,40,80,60,20}为例:
javascript
function swap(arr,i,j){
let temp=arr[i]
arr[i]=arr[j]
arr[j]=temp
}
//分治
function partition(arr,low,high){
let value=arr[low]
while(low<high){
while(low<high&&arr[high]>=value){
high--
}
swap(arr,low,high)
while(low<high&&arr[low]<=value){
low++
}
swap(arr,low,high)
}
return low//找到枢轴值的位置
}
//递归
function MSort(arr,low,high){
let key;
if(low<high){
key=partition(arr,low,high)
MSort(arr,low,key-1)
MSort(arr,key+1,high)
}
}
function quickSort(arr){
MSort(arr,0,arr.length-1)
return arr
}
c语言
/* 快速排序******************************** */
/* 交换顺序表L中子表的记录,使枢轴记录到位,并返回其所在位置 */
/* 此时在它之前(后)的记录均不大(小)于它。 */
//快速排序最关键的Partition函数实现
int Partition(SqList *L,int low,int high)
{
int pivotkey;
pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
while(low<high) /* 从表的两端交替地向中间扫描 */
{
while(low<high&&L->r[high]>=pivotkey)
high--;
swap(L,low,high);/* 将比枢轴记录小的记录交换到低端 */
while(low<high&&L->r[low]<=pivotkey)
low++;
swap(L,low,high);/* 将比枢轴记录大的记录交换到高端 */
}
return low; /* 返回枢轴所在位置 */
}
/* 对顺序表L中的子序列L->r[low..high]作快速排序 */
void QSort(SqList *L,int low,int high)
{
int pivot;
if(low<high)
{
pivot=Partition(L,low,high); /* 将L->r[low..high]一分为二,算出枢轴值pivot */
QSort(L,low,pivot-1); /* 对低子表递归排序 */
QSort(L,pivot+1,high); /* 对高子表递归排序 */
}
}
/* 对顺序表L作快速排序 */
void QuickSort(SqList *L)
{
QSort(L,1,L->length);
}
这一段代码的核心是“pivot=Partition(L,low,high);” 在执行它之前,L.r的数组值为{50,10,90,30,70,40,80,60,20}。Partition 函数要做的,就是先选取当中的一个关键字,比如选择第一个关键字50,然后想尽办法将它放到一个位置,使得它左边的值都比它小,右边的值比它大,我们将这样的关键字称为 枢轴(pivot)。
Partition函数,其实就是将选取的pivotkey不断交换,将比它小的换到它的左边,比它大的换到它的右边,它也在交换中不断更改自己的位置,直到完全满足这个要求为止。
快速排序的时间性能取决于快速排序递归的深度。
时间复杂度:最优情况:O(nlogn)
最坏情况:O(n
2
^2
2)
就空间复杂度来说,主要是递归造成的栈空间的使用,最好情况,递归树的深度为log 2 _2 2n,其空间复杂度也就为O(logn),最坏情况,需要进行n-1递归调用,其空间复杂度为O(n),平均情况,空间复杂度也为O(logn).
可惜的是,由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。
8、大总结
从算法的简单性来看,我们将7种算法分为两类:
简单算法:冒泡、简单选择、直接插入。
改进算法:希尔、堆、归并、快速。