1. 排序–快速排序算法(Quicksort)
1.1 定义
快速排序由C. A. R. Hoare在1962年提出。快速排序是对冒泡排序的一种改进,采用了一种分治的策略。
1.2. 基本思想
通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
1.3. 步骤
- 先从数列中取出一个数作为基准数。
- 分区过程,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边。
- 再对左右区间重复第二步,直到各区间只有一个数。
1.4. C语言实现代码
int Partition(SqList *L,int low,int high)
{
int pivotkey;
int i = 0;
pivotkey = L->r[low]; /* 用子表的第一个记录作枢轴记录 */
while (low < high) {/* 从表的两端交替地向中间扫描 */
while(low < high && L->r[high] >= pivotkey) {
high--;
}
swap(L,low,high);/* 将比枢轴记录小的记录交换到低端 */
PList(*L);
while (low<high && L->r[low] <= pivotkey) {
low++;
}
swap(L,low,high);/* 将比枢轴记录大的记录交换到高端 */
PList(*L);
}
return low; /* 返回枢轴所在位置 */
}
void QSort(SqList *L,int low,int high)
{
int pivot;
DEBUG("快速排序:");
PList(*L);
if (low < high) {
pivot = Partition(L, low, high);
QSort(L, low, pivot-1);
QSort(L, pivot+1, high);
}
}
分析:
排序的数组为:50,30,90,10,70,40,80,60,20
01_Sort.c:QSort:300: 快速排序:
L[9]:50,30,90,10,70,40,80,60,20
01_Sort.c:Partition:277: -------------------------------------------------------
01_Sort.c:Partition:278: 第0次 pivotkey: 50 low:1 high:9
L H 高
L[9]:20,30,90,10,70,40,80,60,50
L H 低
L[9]:20,30,50,10,70,40,80,60,90
01_Sort.c:Partition:277: -------------------------------------------------------
01_Sort.c:Partition:278: 第1次 pivotkey: 50 low:3 high:9
L H
L[9]:20,30,40,10,70,50,80,60,90. 高
L H
L[9]:20,30,40,10,50,70,80,60,90 低
01_Sort.c:Partition:277: -------------------------------------------------------
01_Sort.c:Partition:278: 第2次 pivotkey: 50 low:5 high:6
L[9]:20,30,40,10,50,70,80,60,90
L[9]:20,30,40,10,50,70,80,60,90
思考如果:pivotkey恰好为10???
1.2 那么我们该如何选取枢纽元呢?
那么我们该如何选取枢纽元呢?有两种常见的做法,一种是使用随机数生成器随机选取(但我们要注意到随机数的选取是昂贵的),另一种做法是选取3个数的中位数,这种选取方式总的来说是好的稳妥的
https://blog.csdn.net/wzy_1988/article/details/8544871
2 归并排序
2.1 基本思想
归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题分(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案"修补"在一起,即分而治之)。
- 分而治之
可以看到这种结构很像一棵完全二叉树,本文的归并排序我们采用递归去实现(也可采用迭代的方式去实现)。分阶段可以理解为就是递归拆分子序列的过程,递归深度为log2n。
2.2 合并相邻有序子序列
再来看看治阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤。
2.3 C语言代码实现
/* 将有序的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;
DEBUG("i = %d m= %d n= %d", i, m, n);
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 */
}
for (i = 0; i < sizeof(TR); i++ ) {
printf("%d ", TR[i]);
}
printf("\n");
for (i = 0; i < sizeof(SR); i++ ) {
printf("%d ", SR[i]);
}
printf("\n");
}
/* 递归法 */
/* 将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) {
DEBUG("SR[%d] = %d", s, SR[s]);
TR1[s]=SR[s];
} else {
m=(s+t)/2; /* 将SR[s..t]平分为SR[s..m]和SR[m+1..t] */
DEBUG("s= %d m= %d", s,m);
MSort(SR,TR2,s,m); /* 递归地将SR[s..m]归并为有序的TR2[s..m] */
DEBUG("s= %d m= %d", s,m);
MSort(SR,TR2,m+1,t); /* 递归地将SR[m+1..t]归并为有序的TR2[m+1..t] */
DEBUG("s= %d m= %d", s,m);
Merge(TR2,TR1,s,m,t); /* 将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t] */
}
}
3. 堆排序
3.1 堆介绍
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为 Ο(nlogn)。
很明显,我们可以发现它们都是二叉树,如果观察仔细些,还能看出它们都是完全二叉树。左图中根结点是所有元素中最大的,右图的根结点是所有元素中最小的。再细看看,发现左图每个结点都比它的左右孩子要大,右图每个结点都比它的左右孩子要小。这就是我们要讲的堆结构。
堆是具有下列性质的完全二叉树:
每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆(例如图9-7-2左图)
每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆(例如图9-7-2右图)。
这里需要注意从堆的定义可知,根结点一定是堆中所有结点最大(小)者。较大(小)的结点靠近根结点(但也不绝对,比如右图小顶堆中60、40均小于70,但它们并没有70靠近根结点)
如果按照层序遍历的方式给结点从1开始编号,则结点之间满足如下关系:
这里为什么i要小于等于⌊n/2⌋呢?相信大家可能都忘记了二叉树的性质5(详见本书6.6节),其实忘记也不奇怪,这个性质在我们讲完之后,就再也没有提到过它。可以说,这个性质仿佛就是在为堆准备的。性质5的第一条就说一棵完全二叉树,如果i=1,则结点i是二叉树的根,无双亲;如果i>1,则其双亲是结点⌊i/2⌋。那么对于有n个结点的二叉树而言,它的i值自然就是小于等于⌊n/2⌋了。性质5的第二、三条,也是在说明下标i与2i和2i+1的双亲子女关系。如果完全忘记的同学不妨去复习一下。
如果将图9-7-2的大顶堆和小顶堆用层序遍历存入数组,则一定满足上面的关系表达。如图9-7-3。
3.2. 算法步骤
-
创建一个堆 H[0……n-1];
-
把堆首(最大值)和堆尾互换;
-
把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
-
重复步骤 2,直到堆的尺寸为 1。
3.3. 算法解析
3.3.1 代码段一
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]) { /* 1 */
++j; /* j为关键字中较大的记录的下标 */
}
if(temp>=L->r[j]) { /* 2 */
break; /* rc应插入在位置s上 */
}
L->r[s] = L->r[j];
s = j;
}
L->r[s] = temp; /* 插入 */
}
解释1:
如图所示,r[s] = 30, r[j] = 10, r[j+1] = 20
- L->r[j] < L->r[j+1], 所以++j,此时右子节点最大值为r[j] = 20,
- 再比较节点和 与子节点的大小,r[s] > r[j]
解释2:
如图所示,r[s] = 5, r[j] = 10, r[j+1] = 20
- L->r[j] < L->r[j+1], 所以++j,此时右子节点最大值为r[j] = 20,
- 再比较节点和 与子节点的大小,r[s] < r[j]
- r[s]和r[j]交换
3.3.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]重新调整为大根堆 */
}
}
从代码中也可以看出,整个排序过程分为两个for循环。第一个循环要完成的就是将现在的待排序序列构建成一个大顶堆。第二个循环要完成的就是逐步将每个最大值的根结点与末尾元素交换,并且再调整其成为大顶堆。
假设我们要排序的序列是{50,10,90,30,70,40,80,60,20} ,那么L.length=9,第一个for循环,代码第4行,i是从⌊9/2⌋=4开始,4→3→2→1的变量变化。为什么不是从1到9,或者从9到1,而是从4到1呢?其实我们看了图9-7-5就明白了,它们都有什么规律?它们都是有孩子的结点。注意灰色结点的下标编号就是1、2、3、4。
我们所谓的将待排序的序列构建成为一个大顶堆,其实就是从下往上,从右到左,将每个非终端结点(非叶结点)当作根结点,将其和其子树调整成大顶堆。i的4→3→2→1的变量变化,其实也就是30,90,10、50的结点调整过程。
4. 冒泡排序
4.1 介绍
冒泡排序(Bubble Sort)也是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。
作为最简单的排序算法之一,冒泡排序给我的感觉就像 Abandon 在单词书里出现的感觉一样,每次都在第一页第一位,所以最熟悉。冒泡排序还有一种优化算法,就是立一个 flag,当在一趟序列遍历中元素没有发生交换,则证明该序列已经有序。但这种改进对于提升性能来
说并没有什么太大作用。
4.2 算法步骤
比较相邻的元素。如果第一个比第二个大,就交换他们两个。
对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
针对所有的元素重复以上的步骤,除了最后一个。
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
4.3 C语言实现
/* 对顺序表L作交换排序(冒泡排序初级版) */
void BubbleSort0(SqList *L)
{
int i,j;
for(i=1;i<L->length;i++)
{
for(j=i+1;j<=L->length;j++)
{
if(L->r[i]>L->r[j])
{
swap(L,i,j);/* 交换L->r[i]与L->r[j]的值 */
}
}
}
}
/* 对顺序表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 */
}
}
}
}
5. 选择排序
5.1 选择排序介绍
选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。
5.2 算法步骤
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
重复第二步,直到所有元素均排序完毕。
5.3 C语言实现
/* 对顺序表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]的值 */
}
}
6. 插入排序
6.1 插入排序介绍
插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。
6.2 算法步骤
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
6.3 C语言实现
/* 对顺序表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]; /* 插入到正确位置 */
}
}
}
7. 希尔排序
7.1 算法简介
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。
7.2. 算法步骤
选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
按增量序列个数 k,对序列进行 k 趟排序;
每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
7.3. C语言代码
/* 对顺序表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);}
总结回顾
我们将排序记录是否全部被放置在内存中,将排序分为内排序与外排序,外排序需要在内外存之间多次交换数据才能进行。我们本章主要讲的是内排序的算法。
根据排序过程中借助的主要操作,我们将内排序分为:插入排序、交换排序、选择排序和归并排序四类。之后介绍的七种排序法,就分别是各种分类的代表算法。
事实上,目前还没有十全十美的排序算法,有优点就会有缺点,即使是快速排序法,也只是在整体性能上优越,它也存在排序不稳定、需要大量辅助空间、对少量数据排序无优势等不足。因此我们就来从多个角度来剖析一下提到的各种排序的长与短。
我们将七种算法的各种指标进行对比,如表9-10-2所示。
排序情况 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
简单选择 | O(n2) | O(n2) | O(n2) | O(1) | 稳性 |
直接插入 | O(n2) | O(n) | O(n2) | O(1) | 稳性 |
希尔 | O(nlogn) ~ O(n2) | O(n13) | O(n2) | O(1) | 不稳性 |
堆 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳性 |
归并 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳性 |
快速 | O(nlogn) | O(nlogn) | O(n2) | O(logn) ~ O(n) | 不稳性 |
从算法的简单性来看,我们将七种算法分为两类.
1) 简单算法:冒泡、简单选择、直接插入。
2) 改进算法:希尔、堆、归并、快速。
从平均情况来看,显然后三种改进算法要胜过希尔排序,并远远胜过前三种简单算法。
从最好情况看,反而冒泡和直接插入排序要更胜一筹,也就是说,如果你的待排序序列总是基本有序,反而不应该考虑四种复杂的改进算法。
从最坏情况看,堆排序与归并排序又强过快速排序以及其他简单排序。
从这三组时间复杂度的数据对比中,我们可以得出这样一个认识。堆排序和归并排序就像两个参加奥数考试的优等生,心理素质强,发挥稳定。而快速排序像是很情绪化的天才,心情好时表现极佳,碰到较糟糕环境会变得差强人意。但是他们如果都来比赛计算个位数的加减法,它们反而算不过成绩极普通的冒泡和直接插入。
从空间复杂度来说,归并排序强调要马跑得快,就得给马吃个饱。快速排序也有相应的空间要求,反而堆排序等却都是少量索取,大量付出,对空间要求是O(1)。如果执行算法的软件所处的环境非常在乎内存使用量的多少时,选择归并排序和快速排序就不是一个较好的决策了。
从稳定性来看,归并排序独占鳌头,我们前面也说过,对于非常在乎排序稳定性的应用中,归并排序是个好算法。
从待排序记录的个数上来说,待排序的个数n越小,采用简单排序方法越合适。反之,n越大,采用改进排序方法越合适。这也就是我们为什么对快速排序优化时,增加了一个阀值,低于阀值时换作直接插入排序的原因。
从上表的数据中,似乎简单选择排序在三种简单排序中性能最差,其实也不完全是,比如说如果记录的关键字本身信息量比较大(例如关键字都是数十位的数字),此时表明其占用存储空间很大,这样移动记录所花费的时间也就越多,我们给出三种简单排序算法的移动次数比较,如表9-10-3所示。