十种常见的排序算法,面试算法必考
1.冒泡排序
已知一组无序数据a[1]、a[2]、……a[n],需将其按升序排列。首先比较a[1]与a[2]的值,若a[1]大于a[2]则交换两者的值,否则不变。再比较a[2]与a[3]的值,若a[2]大于a[3]则交换两者的值,否则不变。再比较a[3]与a[4],以此类推,最后比较a[n-1]与a[n]的值。这样处理一轮后,a[n]的值一定是这组数据中最大的。再对a[1]~a[n-1]以相同方法处理一轮,则a[n-1]的值一定是a[1]~a[n-1]中最大的。再对a[1]~a[n-2]以相同方法处理一轮,以此类推。共处理n-1轮后a[1]、a[2]、……a[n]就以升序排列了。
优点:稳定,比较次数已知;
缺点:慢,每次只能移动相邻两个数据,移动数据的次数多。
初始关键字 [49 38 65 97 76 13 27 49]
第一趟排序后 [38 49 65 76 13 27 49] 97
第二趟排序后 [38 49 65 13 27 49] 76 97
第三趟排序后 [38 49 13 27 49] 65 76 97
第四趟排序后 [38 13 27 49] 49 65 76 97
第五趟排序后 [38 13 27] 49 49 65 76 97
第六趟排序后 [13 27]38 49 49 65 76 97
第七趟排序后 [13] 27 38 49 49 65 76 97
最后排序结果 13 27 38 49 49 76 76 97
#include <iostream>
using namespace std;
void main()
{
int i,j,k;
int a[8]={49,38,65,97,76,13,27,49};
for(i=7;i>=0;i–)
{
for(j=0;j<i;j++)
{
if(a[j]>a[j+1])
{
k=a[j];
a[j]=a[j+1];
a[j+1]=k;
}
}
}
for(i=0;i<8;i++)
cout<<a<<endl;
}
2.选择排序
①初始状态:无序区为R[1..n],有序区为空。
②第1趟排序
在无序区R[1..n]中选出关键字最小的记录R[k],将它与无序区的第1个记录R[1]交换,使R[1..1]和R[2..n]分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区。
……
③第i趟排序
第i趟排序开始时,当前有序区和无序区分别为R[1..i-1]和R[i..n](1≤i≤n-1)。该趟排序从当前无序区中选出关键字最小的记录R[k],将它与无序区的第1个记录R交换,使R[1..i]和R[i+1..n]分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区.这样,n个记录的文件的直接选择排序可经过n-1趟直接选择排序得到有序结果。
优点:稳定,比较次数与冒泡排序一样;
缺点:相对之下还是慢。
初始关键字 [49 38 65 97 76 13 27 49]
第一趟排序后 13 [38 65 97 76 49 27 49]
第二趟排序后 13 27 [65 97 76 49 38 49]
第三趟排序后 13 27 38 [97 76 49 65 49]
第四趟排序后 13 27 38 49 [49 97 65 76]
第五趟排序后 13 27 38 49 49 [97 76 76]
第六趟排序后 13 27 38 49 49 76 [76 97]
第七趟排序后 13 27 38 49 49 76 76 [ 97]
最后排序结果 13 27 38 49 49 76 76 97
#include <iostream>
using namespace std;
void main()
{
int i,j,k,t;
int R[8]={49,38,65,97,76,13,27,49};
for(i=0;i<7;i++)
{
k=i;
for(j=i+1;j<8;j++)
if(R[j]<R[k])
k=j;
if(k!=i)
{
t=R[j];R[j]=R[k];R[k]=t;
}
}
for(i=0;i<8;i++)
cout<<R<<endl;
}
3.插入排序
已知一组,一组无序数据b[1]、b[2]、……b[m],需将其变成一个升序数列。先创建一个变量a。首先将不b[1]与b[2],如果b[1]大于b[2]则交换位置,否则不变;再比较b[2]与b[3],如果b[3]小于b[2],则将b[3]赋值给a,再将a与b[1]比较,如果a小于b[1];则将b[2],b[1]依次后移;在将a放在b[1]处以此类推,直到排序结束。
初始关键字 [49 38 65 97 76 13 27 59]
a b[1] b[2]? b[3]? b[4] b[5]? b[6]? b[7] b[8]
1—–49? 49 >? 38 65 97 ? 76? 13 ? 27 59
38 49 49 ……….
38 38 49 ……….
2—–38? 38 ? 49 < 65? 97 ? 76 13 27 59
3—–38? 38 ? 49? 65 <97 ? 76 13 27 59
4—-38? 38 49? 65 97> 76 13 27 59
76 38 49 65 97 97……..
76 38 49 65 76 97……..
以此类推
void insertSort(Type* arr,long len)
{
long i=0,j=0;
for(i=1;i<len;i++)
{
j=i;
tmpData=arr[j];//tmpData用来储存数据
while(tmpData<arr[j-1]&&j>0)
{
arr[j]=arr[j-1];
j–;
}
arr[j]=tmpData;
}
}
4.缩小增量排序(希尔排序)
由希尔在1959年提出,又称希尔排序。
已知一组无序数据a[1]、a[2]、……a[n],需将其按升序排列。发现当n不大时,插入排序的效果很好。首先取一增量d(d<n),将a[1]、a[1+d]、a[1+2d]……列为第一组,a[2]、a[2+d]、a[2+2d]……列为第二组……,a[d]、a[2d]、a[3d]……列为最后一组以次类推,在各组内用插入排序,然后取d’<d,重复上述操作,直到d=1。增量d(1, 3, 7,15, 31, …, 2^k-1)是使用最广泛的增量序列之一.
优点:快,数据移动少;
缺点:不稳定,d的取值是多少,应取多少个不同的值,都无法确切知道,只能凭经验来取。
初始:d=5
49 38 65? 97 76 13? 27 49 55? 44
一趟结果
13 27 49 55 44 49? 38 65 97 76
d=3 |———————-|———————-|———————|
二趟结果
13 44 49 38 27 49? 55 65 97? 76
d=1
三趟结果
13 27 38 44 49? 49 55 65 76? 97
#include <iostream>
using namespace std;
#define MAX 16
void shell_sort(int *x, int n)
{
inth, j, k, t;
for(h=n/2;h>0; h=h/2) /*控制增量*/
{
for(j=h; j<n; j++) /*这个实际上就是上面的直接插入排序*/
{
t= *(x+j);
for(k=j-h; (k>=0 && t<*(x+k)); k-=h)
{
*(x+k+h)= *(x+k);
}
*(x+k+h)= t;
}
}
}
void main()
{
int*p, i, a[MAX];
p= a;
cout<<”InputMAX number for sorting :”<<endl;
for(i=0; i<MAX; i++)
cin>>*p++;
p=a;
shell_sort(p,MAX);
for(p=a, i=0; i<MAX; i++)
{
cout<<*p++<<endl;
}
cout<<endl;
}
5.快速排序
快速排序是冒泡排序的改进版,是目前已知的最快的排序方法。
已知一组无序数据a[1]、a[2]、……a[n],需将其按升序排列。首先任取数据a[x]作为基准。比较a[x]与其它数据并排序,使a[x]排在数据的第k位,并且使a[1]~a[k-1]中的每一个数据<a[x],a[k+1]~a[n]中的每一个数据>a[x],然后采用分治的策略分别对a[1]~a[k-1]和a[k+1]~a[n]
两组数据进行快速排序。
优点:极快,数据移动少;
缺点:不稳定。
分段插入排序
void QuickSort(int *pData, int left, int right)
{
int i, j;
int middle,iTemp;
i = left;
j = right;
middle =pData[(left + right) / 2]; //求中间值
do
{
while((pData[i] < middle) && (i < right)) //从左扫描大于中值的数
i++;
while((pData[j] > middle) && (j > left)) //从右扫描小于中值的数
j–;
if (i <=j) //找到了一对值
{
//交换
iTemp =pData;
pData =pData[j];
pData[j] =iTemp;
i++;
j–;
}
} while (i<= j) ; //如果两边扫描的下标交错,就停止(完成一次)
//当左边部分有值(left<j),递归左半边
if(left<j)
QuickSort(pData,left,j);
//当右边部分有值(right>i),递归右半边
if(right>i)
QuickSort(pData,i,right);
}
?6.归并排序算法
合并排序(MERGESORT)是又一类不同的排序方法,合并的含义就是将两个或两个以上的有序数据序列合并成一个新的有序数据序列,因此它又叫归并算法。它的基本思想就是假设数组A有N个元素,那么可以看成数组A是又N个有序的子序列组成,每个子序列的长度为1,然后再两两合并,得到了一个 N/2? 个长度为2或1的有序子序列,再两两合并,如此重复,值得得到一个长度为N的有序数据序列为止,这种排序方法称为2—路合并排序。
例如数组A有7个数据,分别是: 49 38 65 97 76 13? 27,那么采用归并排序算法的操作过程如图7所示:
初始值 [49] [38]? [65] [97] [76]? [13] [27]
第一次合并之后 [38 ? 49] ? [65 ? 97]? [13 76] [27]
第二次合并之后 [38 ? 49 ? 65 ? 97]? [13 27 76]
第三次合并之后 [13 ? 27 ? 38 ? 49 ? 65 ? 76 ? 97]
合并算法的核心操作就是将一维数组中前后相邻的两个两个有序序列合并成一个有序序列。合并算法也可以采用递归算法来实现,形式上较为简单,但实用性很差。合并算法的合并次数是一个非常重要的量,根据计算当数组中有3到4个元素时,合并次数是2次,当有5到8个元素时,合并次数是3次,当有9到16个元素时,合并次数是4次,按照这一规律,当有N个子序列时可以推断出合并的次数是X(2? >=N,符合此条件的最小那个X)。
其时间复杂度为:O(nlogn).所需辅助存储空间为:O(n)
归并排序:
#include <stdio.h>
void merge(int a[],int p,int q,int r)
{
int n1=q-p+1,n2=r-q,i,j,k;
int l[1002],R[1002];
for (i=1;i<=n1;i++)l[i]=a[p+i-1];
for (j=1;j<=n2;j++)R[j]=a[q+j];
R[n2+1]=l[n1+1]=999999;
i=j=1;
for (k=p;k<=r;k++)
{
if (l[k]<=R[j])
{
a[k]=l[k];
i++;
}
else
{
a[k]=R[j];
j++;
}
}
}
void mergesort(int a[],int p,int r)
{
int q;
if (p<r)
{
q=(p+r)/2;
mergesort(a,p,q);
mergesort(a,q+1,r);
merge(a,p,q,r);
}
}
int main()
{
int a[1001],t,n,i;
scanf(“%d”,&t);
while (t–)
{
scanf(“%d”,&n);
for(i=1;i<=n;i++)scanf(“%d”,&a);
mergesort(a,1,n);
for (i=1;i<=n;i++)
{
printf(“%d”,a);
if (i!=n)printf(” “);
}
printf(“\n”);
}
return 0;
}
7. 堆排序
根结点(亦称为堆顶)的关键字是堆里所有结点关键字中最小者的堆称为小根堆。
根结点(亦称为堆顶)的关键字是堆里所有结点关键字中最大者,称为大根堆。
n个关键字序列Kl,K2,…,Kn称为堆,当且仅当该序列满足如下性质(简称为堆性质):
(1) ki≤K2i且ki≤K2i+1 或(2)Ki≥K2i且ki≥K2i+1(1≤i≤ )
堆排序利用了大根堆(或小根堆)堆顶记录的关键字最大(或最小)这一特征,使得在当前无序区中选取最大(或最小)关键字的记录变得简单。
(1)用大根堆排序的基本思想
① 先将初始文件R[1..n]建成一个大根堆,此堆为初始的无序区
② 再将关键字最大的记录R[1](即堆顶)和无序区的最后一个记录R[n]交换,由此得到新的无序区R[1..n-1]和有序区R[n],且满足R[1..n-1].keys≤R[n].key
③ 由于交换后新的根R[1]可能违反堆性质,故应将当前无序区R[1..n-1]调整为堆。然后再次将R[1..n-1]中关键字最大的记录R[1]和该区间的最后一个记录R[n-1]交换,由此得到新的无序区R[1..n-2]和有序区R[n-1..n],且仍满足关系R[1..n-2].keys≤R[n-1..n].keys,同样要将R[1..n-2]调整为堆。
直到无序区只有一个元素为止。
(2)大根堆排序算法的基本操作:
① 初始化操作:将R[1..n]构造为初始堆;
② 每一趟排序的基本操作:将当前无序区的堆顶记录R[1]和该区间的最后一个记录交换,然后将新的无序区调整为堆(亦称重建堆)。
注意:
①只需做n-1趟排序,选出较大的n-1个关键字即可以使得文件递增有序。
②用小根堆排序与利用大根堆类似,只不过其排序结果是递减有序的。堆排序和直接选择排序相反:在任何时刻,堆排序中无序区总是在有序区之前,且有序区是在原向量的尾部由后往前逐步扩大至整个向量为止。
3.具体算法
template<class T>
heapsort(T r[],int n) //n为文件的实际记录数,r[0]没有使用
{int i,m;node x;
for(i=/2;i>=1;i–)heappass(r,i,n);? //初建堆
//以下for语句为输出堆顶元素、调整堆操作
for(m=n-1;m>=1;m–)//逻辑堆尾下标m不断变小
{ cout<<r[1].key<<” “;
x=r[1];r[1]=r[m+1];r[m+1]=x; ? //堆顶与堆尾元素对换
heappass(r,1,m);//恢复堆
}
cout<<r[1].key<<endl;
}//heapsort
4.直接选择排序中,为了从R[1..n]中选出关键字最小的记录,必须进行n-1次比较,然后在R[2..n]中选出关键字最小的记录,又需要做n-2次比较。事实上,后面的n-2次比较中,有许多比较可能在前面的n-1次比较中已经做过,但由于前一趟排序时未保留这些比较结果,所以后一趟排序时又重复执行了这些比较操作.堆排序可通过树形结构保存部分比较结果,可减少比较次数。
八基数排序
箱排序(Bin Sort)
1、箱排序的基本思想
箱排序也称桶排序(Bucket Sort),其基本思想是:设置若干个箱子,依次扫描待排序的记录R[0],R[1],…,R[n-1],把关键字等于k的记录全都装入到第k个箱子里(分配),然后按序号依次将各非空的箱子首尾连接起来(收集)。
【例】要将一副混洗的52张扑克牌按点数A<2<…<J<Q<K排序,需设置13个”箱子”,排序时依次将每张牌按点数放入相应的箱子里,然后依次将这些箱子首尾相接,就得到了按点数递增序排列的一副牌。
2、箱排序中,箱子的个数取决于关键字的取值范围。
若R[0..n-1]中关键字的取值范围是0到m-1的整数,则必须设置m个箱子。因此箱排序要求关键字的类型是有限类型,否则可能要无限个箱子。
箱排序实用价值不大,仅适用于作为基数排序的一个中间步骤。
桶排序
箱排序的变种。为了区别于上述的箱排序,姑且称它为桶排序(实际上箱排序和桶排序是同义词)。
1、桶排序基本思想
桶排序的思想是把[0,1)划分为n个大小相同的子区间,每一子区间是一个桶。然后将n个记录分配到各个桶中。因为关键字序列是均匀分布在[0,1)上的,所以一般不会有很多个记录落入同一个桶中。由于同一桶中的记录其关键字不尽相同,所以必须采用关键字比较的排序方法(通常用插入排序)对各个桶进行排序,然后依次将各非空桶中的记录连接(收集)起来即可。
注意:
这种排序思想基于以下假设:假设输入的n个关键字序列是随机分布在区间[0,1)之上。若关键字序列的取值范围不是该区间,只要其取值均非负,我们总能将所有关键字除以某一合适的数,将关键字映射到该区间上。但要保证映射后的关键字是均匀分布在[0,1)上的。
基数排序
基数排序的基本思想是:从低位到高位依次对Kj(j=d-1,d-2,…,0)进行箱排序。在d趟箱排序中,所需的箱子数就是基数rd,这就是"基数排序"名称的由来。
假设原来有一串数值如下所示:
73, 22, 93, 43, 55, 14, 28, 65, 39, 81
首先根据个位数的数值,在走访数值时将它们分配至编号0到9的桶子中:
0
1 81
2 22
3 73 93 43
4 14
5 55 65
6
7
8 28
9 39
接下来将这些桶子中的数值重新串接起来,成为以下的数列:
81, 22, 73, 93, 43, 14, 55, 65, 28, 39
接着再进行一次分配,这次是根据十位数来分配:
0
1 14
2 22 28
3 39
4 43
5 55
6 65
7 73
8 81
9 93
接下来将这些桶子中的数值重新串接起来,成为以下的数列:
14, 22, 28, 39, 43, 55, 65, 73, 81, 93
这时候整个数列已经排序完毕;如果排序的对象有三位数以上,则持续进行以上的动作直至最高位数为止。
LSD的基数排序适用于位数小的数列,如果位数多的话,使用MSD的效率会比较好,MSD的方式恰与LSD相反,是由高位数为基底开始进行分配,其他的演算方式则都相同。
实作
* C
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int data[10] = {73, 22, 93, 43, 55, 14, 28, 65, 39, 81};
int temp[10][10] = ;
int order[10] = ;
int i, j, k, n, lsd;
k = 0;
n = 1;
printf(“\n排序前: “);
for(i = 0; i < 10; i++)
printf(“%d “, data);
putchar(‘\n’);
while(n <= 10) {
for(i = 0; i < 10; i++) {
lsd = ((data / n) % 10);
temp[lsd][order[lsd]] = data;
order[lsd]++;
}
printf(“\n重新排列: “);
for(i = 0; i < 10; i++) {
if(order != 0)
for(j = 0; j < order; j++) {
data[k] = temp[j];
printf(“%d “, data[k]);
k++;
}
order = 0;
}
n *= 10;
k = 0;
}
putchar(‘\n’);
printf(“\n排序后: “);
for(i = 0; i < 10; i++)
printf(“%d “, data);
return 0;
}
* Java
public class RadixSort {
public static void sort(int[] number, int d) {
int k = 0;
int n = 1;
int[][] temp = new int[number.length][number.length];
int[] order = new int[number.length];
while(n <= d) {
for(int i = 0; i < number.length; i++) {
int lsd = ((number / n) % 10);
temp[lsd][order[lsd]] = number;
order[lsd]++;
}
for(int i = 0; i < number.length; i++) {
if(order != 0)
for(int j = 0; j < order; j++) {
number[k] = temp[j];
k++;
}
order = 0;
}
n *= 10;
k = 0;
}
}
public static void main(String[] args) {
int[] data =
{73, 22, 93, 43, 55, 14, 28, 65, 39, 81, 33, 100};
RadixSort.sort(data, 100);
for(int i = 0; i < data.length; i++) {
System.out.print(data + ” “);
}
}
}
按平均时间将排序分为四类:
(1)平方阶(O(n2))排序
一般称为简单排序,例如直接插入、直接选择和冒泡排序;
(2)线性对数阶(O(nlgn))排序
如快速、堆和归并排序;
(3)O(n1+£)阶排序
£是介于0和1之间的常数,即0<£<1,如希尔排序;
(4)线性阶(O(n))排序
如桶、箱和基数排序。
各种排序方法比较
简单排序中直接插入最好,快速排序最快,当文件为正序时,直接插入和冒泡均最佳。
影响排序效果的因素
因为不同的排序方法适应不同的应用环境和要求,所以选择合适的排序方法应综合考虑下列因素:
①待排序的记录数目n;
②记录的大小(规模);
③关键字的结构及其初始状态;
④对稳定性的要求;
⑤语言工具的条件;
⑥存储结构;
⑦时间和辅助空间复杂度等。
不同条件下,排序方法的选择
(1)若n较小(如n≤50),可采用直接插入或直接选择排序。
当记录规模较小时,直接插入排序较好;否则因为直接选择移动的记录数少于直接插人,应选直接选择排序为宜。
(2)若文件初始状态基本有序(指正序),则应选用直接插人、冒泡或随机的快速排序为宜;
(3)若n较大,则应采用时间复杂度为O(nlgn)的排序方法:快速排序、堆排序或归并排序。
快速排序是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;
堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况。这两种排序都是不稳定的。
若要求排序稳定,则可选用归并排序。但本章介绍的从单个记录起进行两两归并的 排序算法并不值得提倡,通常可以将它和直接插入排序结合在一起使用。先利用直接插入排序求得较长的有序子文件,然后再两两归并之。因为直接插入排序是稳定的,所以改进后的归并排序仍是稳定的。
4)在基于比较的排序方法中,每次比较两个关键字的大小之后,仅仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程。
当文件的n个关键字随机分布时,任何借助于”比较”的排序算法,至少需要O(nlgn)的时间。
箱排序和基数排序只需一步就会引起m种可能的转移,即把一个记录装入m个箱子之一,因此在一般情况下,箱排序和基数排序可能在O(n)时间内完成对n个记录的排序。但是,箱排序和基数排序只适用于像字符串和整数这类有明显结构特征的关键字,而当关键字的取值范围属于某个无穷集合(例如实数型关键字)时,无法使用箱排序和基数排序,这时只有借助于”比较”的方法来排序。
若n很大,记录的关键字位数较少且可以分解时,采用基数排序较好。虽然桶排序对关键字的结构无要求,但它也只有在关键字是随机分布时才能使平均时间达到线性阶,否则为平方阶。同时要注意,箱、桶、基数这三种分配排序均假定了关键字若为数字时,则其值均是非负的,否则将其映射到箱(桶)号时,又要增加相应的时间。
(5)有的语言(如Fortran,Cobol或Basic等)没有提供指针及递归,导致实现归并、快速(它们用递归实现较简单)和基数(使用了指针)等排序算法变得复杂。此时可考虑用其它排序。
(6)本章给出的排序算法,输人数据均是存储在一个向量中。当记录的规模较大时,为避免耗费大量的时间去移动记录,可以用链表作为存储结构。譬如插入排序、归并排序、基数排序都易于在链表上实现,使之减少记录的移动次数。但有的排序方法,如快速排序和堆排序,在链表上却难于实现,在这种情况下,可以提取关键字建立索引表,然后对索引表进行排序。然而更为简单的方法是:引人一个整型向量t作为辅助表,排序前令t=i(0≤i<n),若排序算法中要求交换R和R[j],则只需交换t和t[j]即可;排序结束后,向量t就指示了记录之间的顺序关系:
R[t[0]].key≤R[t[1]].key≤…≤R[t[n-1]].key
若要求最终结果是:
R[0].key≤R[1].key≤…≤R[n-1].key
则可以在排序结束后,再按辅助表所规定的次序重排各记录,完成这种重排的时间是O(n)。
265,301,751,127,937,863,742,694,76,438
这十个数字进行:
直接插入,希尔(SHELL),冒泡,直接选择,归并排序,记数排序,要求写出第一趟排序结果,例如快排第一趟为:
76 127 265 751 937 863 742 694 301 438,/*265作为基*/;
做出其他的
直接插入:265 301751 127 937 863 742 694 76 438
希尔:265 742 69476 438 863 901 751 129 937 以5为距离
冒泡: 265 301127 751 863 742 694 76 438 937
直接选择:76 301751 265 937 863 742 694 127 438
归并排序:265 301127 751 863 937 694 742 301 438
记数排序:301 127937 438 742 751 76 265 863 694 以100为基距大小
?
?
二复杂度
同一问题可用不同算法解决,而一个算法的质量优劣将影响到算法乃至程序的效率。算法分析的目的在于选择合适算法和改进算法。一个算法的评价主要从时间复杂度和空间复杂度来考虑。
1、时间复杂度
(1)时间频度
一个算法执行所耗费的时间,从理论上是不能算出来的,必须上机运行测试才能知道。但我们不可能也没有必要对每个算法都上机测试,只需知道哪个算法花费的时间多,哪个算法花费的时间少就可以了。并且一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度。记为T(n)。
(2)时间复杂度
在刚才提到的时间频度中,n称为问题的规模,当n不断变化时,时间频度T(n)也会不断变化。但有时我们想知道它变化时呈现什么规律。为此,我们引入时间复杂度概念。
一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f (n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作T(n)=O(f(n)),称O(f(n))为算法的渐进时间复杂度,简称时间复杂度。
在各种不同算法中,若算法中语句执行次数为一个常数,则时间复杂度为O(1),另外,在时间频度不相同时,时间复杂度有可能相同,如T(n)=n^2+3n+4与T(n)=4n^2+2n+1它们的频度不同,但时间复杂度相同,都为O(n^2)。
按数量级递增排列,常见的时间复杂度有:
常数阶O(1),对数阶O(log2n),线性阶O(n),线性对数阶O(nlog2n),平方阶O(n^2),立方阶O(n^3),…,k次方阶O(nk),指数阶O(2n)。随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越低。
介绍
选择排序是说,每次从数列中找出一个最小的数放到最前面来,再从剩下的n-1个数中选择一个最小的,不断做下去。
插入排序是,每次从数列中取一个还没有取出过的数,并按照大小关系插入到已经取出的数中使得已经取出的数仍然有序。
冒泡排序分为若干趟进行,每一趟排序从前往后比较每两个相邻的元素的大小并在前面的那个数比紧接它后的数大时交换位置;进行足够多趟直到某一趟跑完后发现这一趟没有进行任何交换操作.事实上,在第一趟冒泡结束后,最后面那个数肯定是最大的了,于是第二
次只需要对前面n-1个数排序,这又将把这n-1个数中最大的数放到整个数列 的倒数第二个位置。这样下去,冒泡排序第i趟结束后后面i个数都已经到位了,第i+1趟实际上只考虑前n-i个数.
我们来算一下最坏情况下三种算法各需要多少次比较和赋值操作。
选择排序在第i次选择时赋值和比较都需要n-i次(在n-i+1个数中选一个出来作为当前最小值,其余n-i个数与当前最小值比较并不断更新当前最小值),然后需要一次赋值操作。总共需要n(n-1)/2次比较与n(n-1)/2+n次赋值。
插入排序在第i次寻找插入位置时需要最多i-1次比较(从后往前找到第一个比待插入的数小的数,最坏情况发生在这个数是所有已经取出的数中最小的一个的时候),在已有数列中给新的数腾出位置需要i-1次赋值操作来实现,还需要两次赋值借助临时变量把新取出的数搬进搬出。也就是说,最坏情况下比较需要n(n -1)/2次,赋值需要n(n-1)/2+2n次。
冒泡排序第i趟排序需要比较n-i次,n-1趟排序总共比较n(n-1)/2次。给出的序列逆序排列是最坏的情况,这时每一次比较都要进行交换操作。一次交换操作需要3次赋值实现,因此冒泡排序最坏情况下需要赋值3n(n-1)/2次。
按照渐进复杂度理论,忽略所有的常数,三种排序的最坏情况下复杂度都是一样的O(n^2)。但实际应用中三种排序的效率并不相同。实践证明,插入排序是最快的,因为每一次插入时寻找插入的位置多数情况只需要与已有数的一部分进行比较。你或许会说冒泡排序也可以在半路上完成,还没有跑到第n-1趟就已经有序。但冒泡排序的交换操作更费时,而插入排序中找到了插入的位置后移动操作只需要用赋值就能完成(你可能知道这还能用move)。
但大家想过吗,这只是最坏情况下的。在很多时候,复杂度没有这么大,因为插入和冒泡在数列已经比较有序的情况下需要的操作远远低于n^2次(最好情况下甚至是线性的)。抛开选择排序不说(因为它的复杂度是“死”的,对于选择排序没有什么 “好”的情况),我们下面探讨插入排序和冒泡排序在特定数据和平均情况下的复杂度。
你会发现,如果把插入排序中的移动赋值操作看作是把当前取出的元素与前面取出的且比它大的数逐一交换,那插入排序和冒泡排序对数据的变动其实都是相邻元素的交换操作。下面我们说明,若只能对数列中相邻的数进行交换操作,如何计算使得n个数变得有序最少需要的交换次数。
我们定义逆序对的概念。假设我们要把数列从小到大排序,一个逆序对是指的在原数列中,左边的某个数比右边的大。也就是说,如果找到了某个i和j使得 i<j且Ai>Aj,我们就说我们找到了一个逆序对。比如说,数列 3,1,4,2中有三个逆序对,而一个已经有序的数列逆序对个数为0。我们发现,交换两个相邻的数最多消除一个逆序对,且冒泡排序(或插入排序)中的一次交换恰好能消除一个逆序对。那么显然,原数列中有多少个逆序对冒泡排序(或插入排序)就需要多少次交换操作,这个操作次数不可能再少。
若给出的n个数中有m个逆序对,插入排序的时间复杂度可以说是O(m+n)的,而冒泡排序不能这么说,因为冒泡排序有很多“无用”的比较(比较后没有交换),这些无用的比较超过了O(m+n)个。从这个意义上说,插入排序仍然更为优秀,因为冒泡排序的复杂度要受到它跑的趟数的制约。一个典型的例子是这样的数列:8, 2, 3, 4, 5, 6, 7, 1。在这样的输入数据下插入排序的优势非常明显,冒泡排序只能哭着喊上天不公。
然而,我们并不想计算排序算法对于某个特定数据的效率。我们真正关心的是,对于所有可能出现的数据,算法的平均复杂度是多少。不
用激动了,平均复杂度并不会低于平方。下面证明,两种算法的平均复杂度仍然是O(n^2)的。
我们仅仅证明算法需要的交换次数平均为O(n^2)就足够了。前面已经说过,它们需要的交换次数与逆序对的个数相同。我们将证明,n个
数的数列中逆序对个数平均O(n^2)个。
计算的方法是十分巧妙的。如果把给出的数列反过来(从后往前倒过来写),你会发现原来的逆序对现在变成顺序的了,而原来所有的非
逆序对现在都成逆序了。正 反两个数列的逆序对个数加起来正好就是数列所有数对的个数,它等于n(n-1)/2。于是,平均每个数列有n(n-1)/4
个逆序对。忽略常数,逆序对平 均个数O(n^2)。
上面的讨论启示我们,要想搞出一个复杂度低于平方级别的排序算法,我们需要想办法能把离得老远的两个数进行操作。
人们想啊想啊想啊,怎么都想不出怎样才能搞出复杂度低于平方的算法。后来,英雄出现了,Donald Shell发明了一种新的算法,我们将
证明它的复杂度最坏情况下也没有O(n^2) (似乎有人不喜欢研究正确性和复杂度的证明,我会用实例告诉大家,这些证明是非常有意思的)。
他把这种算法叫做Shell增量排序算法(大家常说的希尔排 序)。
Shell排序算法依赖一种称之为“排序增量”的数列,不同的增量将导致不同的效率。假如我们对20个数进行排序,使用的增量为1,3,7
。那么,我们首先对这20个数进行“7-排序”(7-sortedness)。所谓7-排序,就是按照位置除以7的余数分组进行排序。具体地 说,我们将把
在1、8、15三个位置上的数进行排序,将第2、9、16个数进行排序,依此类推。这样,对于任意一个数字k,单看A(k),A(k+7), A(k+14), …
这些数是有序的。7-排序后,我们接着又进行一趟3-排序(别忘了我们使用的排序增量为1,3,7)。最后进行1-排序(即普通的排序)后整个
Shell算法完成。看看我们的例子:
1 8 15
37 9 0 5 1 6 8 4 2 0 6 1 5 7 3 4 9 8 2 <– 原数列
33 2 0 5 1 5 7 4 4 0 6 1 6 8 7 9 9 8 2 <– 7-排序后
00 1 1 2 2 3 3 4 4 5 6 5 6 8 7 7 9 8 9 <– 3-排序后
00 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 <– 1-排序后(完成)
在每一趟、每一组的排序中我们总是使用插入排序。仔细观察上面的例子你会发现是什么导致了Shell排序的高效。对,每一趟排序将使
得数列部分有序,从而使得以后的插入排序很快找到插入位置。我们下面将紧紧围绕这一点来证明Shell排序算法的时间复杂度上界。
只要排序增量的第一个数是1,Shell排序算法就是正确的。但是不同的增量将导致不同的时间复杂度。我们上面例子中的增量(1, 3, 7,
15, 31, …, 2^k-1)是使用最广泛的增量序列之一,可以证明使用这个增量的时间复杂度为O(n√n)。这个证明很简单,大家可以参看一些其它
的资料,我们今天不证 明它。今天我们证明,使用增量1, 2, 3, 4, 6, 8, 9,12, 16, …, 2^p*3^q,时间复杂度为O(n*(log n)^2)。
很显然,任何一个大于1的正整数都可以表示为2x+3y,其中x和y是非负整数。于是,如果一个数列已经是2-排序的且是3 -排序的,那么
对于此时数列中的每一个数A(i),它的左边比它大的只有可能是A(i-1)。A2绝对不可能比A12大,因为10可以表示为两个2和两个 3的和,则
A2<A4<A6<A9<A12。那么,在这个增量中的1-排序时每个数找插入位置只需要比较一次。一共有n个数,所 以1-排序是O(n)的。事实上,这个增
量中的2-排序也是O(n),因为在2-排序之前,这个数列已经是4-排序且6-排序过的,只看数列的奇数项或者 偶数项(即单看每一组)的话就又
成了刚才的样子。这个增量序列巧妙就巧妙在,如果我们要进行h-排序,那么它一定是2h-排序过且3h-排序过,于是处理 每个数A(i)的插入时
就只需要和A(i-h)进行比较。这个结论对于最开始几次(h值较大时)的h-排序同样成立,当2h、3h大于n时,按照定义,我 们也可以认为数列
是2h-排序和3h-排序的,这并不影响上述结论的正确性(你也可以认为h太大以致于排序时每一组里的数字不超过3个,属于常数级)。现 在,
这个增量中的每一趟排序都是O(n)的,我们只需要数一下一共跑了多少趟。也就是说,我们现在只需要知道小于n的数中有多少个数具有
2^p*3^q的形式。要想2^p*3^q不超过n,p的取值最多O(log n)个,q的取值最多也是O(log n)个,两两组合的话共有O(logn*logn)种情况。于
是,这样的增量排序需要跑O((logn)^2)趟,每一趟的复杂度O(n),总的复杂度为O(n*(log n)^2)。早就说过了,证明时间复杂度其实很有意
思。
我们自然会想,有没有能使复杂度降到O(nlogn)甚至更低的增量序列。很遗憾,现在没有任何迹象表明存在O(nlogn)的增量排序。但事实
上,很多时候Shell排序的实际效率超过了O(nlogn)的排序算法。
对于O(nlogn)的排序算法,我们详细介绍归并排序并证明归并排序的时间复杂度,然后简单介绍堆排序,之后给出快速排 序的基本思想和复杂
度证明。最后我们将证明,O(nlogn)在理论上已经达到了最优。为了保持系列文章的完整性,我还是花时间写了一下。
首先考虑一个简单的问题:如何在线性的时间内将两个有序队列合并为一个有序队列(并输出)?
A队列:1 3 5 7 9
B队列:1 2 7 8 9
看上面的例子,AB两个序列都是已经有序的了。在给出数据已经有序的情况下,我们会发现很多神奇的事,比如,我们将要输出的第一个
数一定来自于这两个序列各自最前面的那个数。两个数都是1,那么我们随便取出一个(比如A队列的那个1)并输出:
A队列:1 3 5 7 9
B队列:1 2 7 8 9
输出:1
注意,我们取出了一个数,在原数列中删除这个数。删除操作是通过移动队首指针实现的,否则复杂度就高了。
现在,A队列打头的数变成3了,B队列的队首仍然是1。此时,我们再比较3和1哪个大并输出小的那个数:
A队列:1 3 5 7 9
B队列:1 2 7 8 9
输出:1 1
接下来的几步如下:
A队列:1 3 5 79 A队列:1 3 5 79 A队列:1 3 5 79 A队列:1 3 5 7 9
B队列:1 2 7 8 9 ==> B队列:1 2 7 8 9 ==> B队列:1 2 7 8 9 ==> B队列:1 2 7 8 9 ……
输出:1 1 2 输出:1 1 2 3 输出:1 1 2 3 5 输出:1 1 2 3 5 7
我希望你明白了这是怎么做的。这个做法显然是正确的,复杂度显然是线性。
归并排序(Merge Sort)将会用到上面所说的合并操作。给出一个数列,归并排序利用合并操作在O(nlogn)的时间内将数列从小到大排序。
归并排序用的是分治(Divide and Conquer)的思想。首先我们把给出的数列平分为左右两段,然后对两段数列分别进行排序,最后用刚才的合
并算法把这两段(已经排过序的)数列合并为一 个数列。有人会问“对左右两段数列分别排序时用的什么排序”么?答案是:用归并排序。也
就是说,我们递归地把每一段数列又分成两段进行上述操作。你不需要关心实际上是怎么操作的,我们的程序代码将递归调用该过程直到数列
不能再分(只有一个数)为止。
初看这个算法时有人会误以为时间复杂度相当高。我们下面给出的一个图将用非递归的眼光来看归并排序的实际操作过程,供大家参考。
我们可以借助这个图证明,归并排序算法的时间复杂度为O(nlogn)。
3] [1] [4] [1] [5] [9] [2] [7]
\/ \ / \ / \ /
[1 3] [1 4] [5 9] [2 7]
\ / \ /
[11 3 4] [2 5 7 9]
\ /
[1 1 2 3 4 5 7 9]
上图中的每一个“ \ / ”表示的是上文所述的线性时间合并操作。上图用了4行来图解归并排序。如果有n个数,表示成上图显然需要
O(logn)行。每一行的合并操作复杂度总和都是O(n),那么logn行的总复杂度为O(nlogn)。这相当于用递归树的方法对归并排序的复杂度进行
了分析。假设,归并排序的复杂度为T(n),T (n)由两个T(n/2)和一个关于n的线性时间组成,那么T(n)=2*T(n/2)+O(n)。不断展开这个式子我
们可以同样可以得到T(n)=O(nlogn)的结论,你可以自己试试。如果你能在线性的时间里把分别计算出的两组不同数据的结果合并在一起,根
据T(n)=2*T(n/2)+O(n)=O(nlogn),那么我们就可以构造O(nlogn)的分治算法。这个结论后面经常用。我们将在计算几何部分举一大堆类似的
例子.
如果你第一次见到这么诡异的算法,你可能会对这个感 兴趣。分治是递归的一种应用。这是我们第一次接触递归运算。下面说的快速排
序也是用的递归的思想。递归程序的复杂度分析通常和上面一样,主定理 (Master Theory)可以简化这个分析过程。主定理和本文内容离得太
远,我们以后也不会用它,因此我们不介绍它,大家可以自己去查。有个名词在这里的话找学习资料将变得非常容易,我最怕的就是一个东西
不知道叫什么名字,半天找不到资料。
归并排序有一个有趣的副产品。利用归并排序能够在O (nlogn)的时间里计算出给定序列里逆序对的个数。你可以用任何一种平衡二叉树
来完成这个操作,但用归并排序统计逆序对更方便。我们讨论逆序对一般是说的一个排列中的逆序对,因此这里我们假设所有数不相同。假如
我们想要数1, 6, 3,2, 5, 4中有多少个逆序对,我们首先把这个数列分为左右两段。那么一个逆序对只可能有三种情况:两个数都在左边,
两个数都在右边,一个在左一个在右。在左右两段 分别处理完后,线性合并的过程中我们可以顺便算出所有第三种情况的逆序对有多少个。换
句话说,我们能在线性的时间里统计出A队列的某个数比B队列的某个数 大有多少种情况。
A队列:1 36 A队列:1 36 A队列:1 36 A队列:1 36 A队列:1 3 6
B队列:2 4 5 ==> B队列:2 4 5 ==> B队列:2 4 5 ==> B队列:2 4 5 ==> B队列:2 4 5 ……
输出: 输出:1 输出:1 2 输出:1 2 3 输出:1 2 3 4
每一次从B队列取出一个数时,我们就知道了在A队列中有多少个数比B队列的这个数大,它等于A队列现在还剩的数的个数。比如,当我们
从B队列中取出2时,我们同时知道了A队列的3和6两个数比2大。在合并操作中我们不断更新A队列中还剩几个数,在每次从B队列中取出一个数
时把当前A队列剩的数目加进最终答 案里。这样我们算出了所有“大的数在前一半,小的数在后一半”的情况,其余情况下的逆序对在这之前
已经被递归地算过了。
============================华丽的分割线============================
堆排序(Heap Sort)利用了堆(Heap)这种数据结构(什么是堆?)。 堆的插入操作是平均常数的,而删除一个根节点需要花费O(log n)的
时间。因此,完成堆排序需要线性时间建立堆(把所有元素依次插入一个堆),然后用总共O(nlogn)的时间不断取出最小的那个数。只要堆会
搞,堆 排序就会搞。堆在那篇日志里有详细的说明,因此这里不重复说了。
============================华丽的分割线============================
快速排序(Quick Sort)也应用了递归的思想。我们想要把给定序列分成两段,并对这两段分别进行排序。一种不错的想法是,选取一个数
作为“关键字”,并把其它数分割为两 部分,把所有小于关键字的数都放在关键字的左边,大于关键字的都放在右边,然后递归地对左边和右
边进行排序。把该区间内的所有数依次与关键字比较,我们就 可以在线性的时间里完成分割的操作。完成分割操作有很多有技巧性的实现方法
,比如最常用的一种是定义两个指针,一个从前往后找找到比关键字大的,一个从后往前找到比关键字小的,然后两个指针对应的元素交换位
置并继续移动指针重复刚才的过程。这只是大致的方法,具体的实现还有很多细节问题。
不像归并排序,快速排序的时间复杂度很难计算。我们可以看到,归并排序的复杂度最坏情况下也是O(nlogn)的,而快速排序的最坏情况
是O(n^2) 的。如果每一次选的关键字都是当前区间里最大(或最小)的数,那么这样将使得每一次的规模只减小一个数,这和插入排序、选择
排序等平方级排序没有区别。这 种情况不是不可能发生。如果你每次选择关键字都是选择的该区间的第一个数,而给你的数据恰好又是已经有
序的,那你的快速排序就完蛋了。显然,最好情况是每 一次选的数正好就是中位数,这将把该区间平分为两段,复杂度和前面讨论的归并排序
一模一样。根据这一点,快速排序有一些常用的优化。比如,我们经常从数列中随机取一个数当作是关键字(而不是每次总是取固定位置上的
数),从而尽可能避免某些特殊的数据所导致的低效。更好的做法是随机取三个数并选择这三个数的中位数作为关键字。而对三个数的随机取
值反而将花费更多的时间,因此我们的这三个数可以分别取数列的头一个数、末一个数和正中间那个数。另外,当递归到了一定深度发现当前
区间里的数只有几个或十几个时,继续递归下去反而费时,不如返回插入排序后的结果。这种方法同时避免了当数字太少时递归操作出错的可
能。
下面我们证明,快速排序算法的平均复杂度为O(nlogn)。不同的书上有不同的解释方法,这里我选用算法导论上的讲法。它更有技巧性一
些,更有趣一些,需要转几个弯才能想明白。
看一看快速排序的代码。正如我们提到过的那种分割方法,程序在经过若干次与关键字的比较后才进行一次交换,因此比较的次数比交换
次数更多。我们通过证明一 次快速排序中元素之间的比较次数平均为O(nlogn)来说明快速排序算法的平均复杂度。证明的关键在于,我们需要
算出某两个元素在整个算法过程中进行过 比较的概率。
我们举一个例子。假如给出了1到10这10个数,第一次选择关键字7将它们分成了{1,2,3,4,5,6}和 {8,9,10}两部分,递归左边时我们选择
了3作为关键字,使得左部分又被分割为{1,2}和{4,5,6}。我们看到,数字7与其它所有数都比较过一 次,这样才能实现分割操作。同样地,1
到6这6个数都需要与3进行一次比较(除了它本身之外)。然而,3和9决不可能相互比较过,2和6也不可能进行过比较,因为第一次出现在3和
9,2和6之间的关键字把它们分割开了。也就是说,两个数A(i)和A(j)比较过,当且仅当第一个满足A(i)<= x<=A(j)的关键字x恰好就是A(i)或
A(j) (假设A(i)比A(j)小)。我们称排序后第i小的数为Z(i),假设i<j,那么第一次出现在Z(i)和Z(j)之间的关键字恰好就是Z(i) 或Z(j)的
概率为2/(j-i+1),这是因为当Z(i)和Z(j)之间还不曾有过关键字时,Z(i)和Z(j)处于同一个待分割的区间,不管这个区间 有多大,不管递归
到哪里了,关键字的选择总是随机的。我们得到,Z(i)和Z(j)在一次快速排序中曾经比较过的概率为2/(j-i+1)。
现在有四个数,2,3,5,7。排序时,相邻的两个数肯定都被比较过,2和5、3和7都有2/3的概率被比较过,2和7之间被比较过有2/4的可能
。也就 是说,如果对这四个数做12次快速排序,那么2和3、3和5、5和7之间一共比较了12*3=36次,2和5、3和7之间总共比较了8*2=16次,2和
7之间平均比较了6次。那么,12次排序中总的比较次数期望值为36+16+6=58。我们可以计算出单次的快速排序平均比较了多少次:58/12= 29/6
。其实,它就等于6项概率之和,1+1+1+2/3+2/3+2/4=29/6。这其实是与期望值相关的一个公式。
同样地,如果有n个数,那么快速排序平均需要的比较次数可以写成下面的式子。令k=j-i,我们能够最终得到比较次数的期望值为
O(nlogn)。
这里用到了一个知识:1+1/2+1/3+…+1/n与log n增长速度相同,即Σ(1/n)=Θ(log n)。它的证明放在本文的最后。
在三种O(nlogn)的排序算法中,快速排序的理论复杂度最不理想,除了它以外今天说的另外两种算法都是以最坏情况O(nlogn)的复杂度进
行排序。 但实践上看快速排序效率最高(不然为啥叫快速排序呢),原因在于快速排序的代码比其它同复杂度的算法更简洁,常数时间更小。
快速 排序也有一个有趣的副产品:快速选择给出的一些数中第k小的数。一种简单的方法是使用上述任一种O(nlogn)的算法对这些数进行
排序并返回排序后数组 的第k个元素。快速选择(Quick Select)算法可以在平均O(n)的时间完成这一操作。它的最坏情况同快速排序一样,也
是O(n^2)。在每一次分割后,我们都可以知道比关键字小的 数有多少个,从而确定了关键字在所有数中是第几小的。我们假设关键字是第m小
。如果k=m,那么我们就找到了答案——第k小元素即该关键字。否则,我们递 归地计算左边或者右边:当k<m时,我们递归地寻找左边的元素
中第k小的;当k>m时,我们递归地寻找右边的元素中第k-m小的数。由于我们 不考虑所有的数的顺序,只需要递归其中的一边,因此复杂度大
大降低。复杂度平均线性,我们不再具体证了。
还有一种算法可以在最坏O(n)的时间里找出第k小元素。那是我见过的所有算法中最没有实用价值的算法。那个O(n)只有理论价值。
============================华丽的分割线============================
我们前面证明过,仅仅依靠交换相邻元素的操作,复杂度只能达到O(n^2)。于是,人们尝试交换距离更远的元素。当人们发现O(nlogn)的
排序算法似 乎已经是极限的时候,又是什么制约了复杂度的下界呢?我们将要讨论的是更底层的东西。我们仍然假设所有的数都不相等。
我们总是不断在数与 数之间进行比较。你可以试试,只用4次比较绝对不可能给4个数排出顺序。每多进行一次比较我们就又多知道了一
个大小关系,从4次比较中一共可以获知4个大 小关系。4个大小关系共有2^4=16种组合方式,而4个数的顺序一共有4!=24种。也就是说,4次比
较可能出现的结果数目不足以区分24种可能的顺 序。更一般地,给你n个数叫你排序,可能的答案共有n!个,k次比较只能区分2^k种可能,于
是只有2^k>=n!时才有可能排出顺序。等号两边取 对数,于是,给n个数排序至少需要log2(n!)次。注意,我们并没有说明一定能通过log2(n!)
次比较排出顺序。虽然2^5=32超过了4!,但这不足以说明5次比较一定足够。如何用5次比较确定4个数的大小关系还需要进一步研究。第一次
例外发生在n=12的时候,虽然2^29>12!,但 现已证明给12个数排序最少需要30次比较。我们可以证明log(n!)的增长速度与nlogn相同,即
log(n!)=Θ(nlogn)。这是排序所需 要的最少的比较次数,它给出了排序复杂度的一个下界。log(n!)=Θ(nlogn)的证明也附在本文最后。
这篇日志的 第三题中证明log2(N)是最优时用到了几乎相同的方法。那种“用天平称出重量不同的那个球至少要称几次”一类题目也可以
用这种方法来解决。事实上,这 里有一整套的理论,它叫做信息论。信息论是由香农(Shannon)提出的。他用对数来表示信息量,用熵来表示
可能的情况的随机性,通过运算可以知道你目 前得到的信息能够怎样影响最终结果的确定。如果我们的信息量是以2为底的,那信息论就变成
信息学了。从根本上说,计算机的一切信息就是以2为底的信息量(bits=binary digits),因此我们常说香农是数字通信之父。信息论和热力
学关系密切,比如熵的概念是直接 从热力学的熵定义引申过来的。和这个有关的东西已经严重偏题了,这里不说了,有兴趣可以去看《信息论
与编码理论》。我对这个也很有兴趣,半懂不懂的,很想 了解更多的东西,有兴趣的同志不妨加入讨论。物理学真的很神奇,利用物理学可以
解决很多纯数学问题,我有时间的话可以举一些例子。我他*的为啥要选文科呢。
后面将介绍的三种排序是线性时间复杂度,因为,它们排序时根本不是通过互相比较来确定大小关系的。
附1:Σ(1/n)=Θ(log n)的证明
首先我们证明,Σ(1/n)=O(log n)。在式子1+1/2+1/3+1/4+1/5+…中,我们把1/3变成1/2,使得两个1/2加起来凑成一个1;再把1/5,1/6
和1/7全部变成 1/4,这样四个1/4加起来又是一个1。我们把所有1/2^k的后面2^k-1项全部扩大为1/2^k,使得这2^k个分式加起来是一个1。现
在,1+ 1/2+…+1/n里面产生了几个1呢?我们只需要看小于n的数有多少个2的幂即可。显然,经过数的扩大后原式各项总和为log n。O(logn)
是Σ(1/n)的复杂度上界。
然后我们证明,Σ(1/n)=Ω(log n)。在式子1+1/2+1/3+1/4+1/5+…中,我们把1/3变成1/4,使得两个1/4加起来凑成一个1/2;再把
1/5,1/6和1/7全部 变成1/8,这样四个1/8加起来又是一个1/2。我们把所有1/2^k的前面2^k-1项全部缩小为1/2^k,使得这2^k个分式加起来是
一个 1/2。现在,1+1/2+…+1/n里面产生了几个1/2呢?我们只需要看小于n的数有多少个2的幂即可。显然,经过数的缩小后原式各项总和为
1/2*logn。Ω(logn)是Σ(1/n)的复杂度下界。
附2:log(n!)=Θ(nlogn)的证明
首先我们证明,log(n!)=O(nlogn)。显然n!<n^n,两边取对数我们得到log(n!)<log(n^n),而log(n^n)就等于nlogn。因此,O(nlogn)是
log(n!)的复杂度上界。
然后我们证明,log(n!)=Ω(nlogn)。n!=n(n-1)(n-2)(n-3)….1,把前面一半的因子全部缩小到n/2,后面一半因子全部 舍去,显然有
n!>(n/2)^(n/2)。两边取对数,log(n!)>(n/2)log(n/2),后者即Ω(nlogn)。因此,Ω (nlogn)是log(n!)的复杂度下界。转自:Matrix67
那么,有什么方法可以不用比较就能排出顺序呢?借助Hash表的思想,多数人都能想出这样一种排序算法来。
我们假设给出的数字都在一定范围中,那么我们就可以开一个范围相同的数组,记录这个数字是否出现过。由于数字有可能有重复,因此
Hash表的概念需要扩展,我们需要把数组类型改成整型,用来表示每个数出现的次数。
看这样一个例子,假如我们要对数列3 1 4 1 5 9 2 6 5 35 9进行排序。由于给定数字每一个都小于10,因此我们开一个0到9的整型数
组T,记录每一个数出现了几次。读到一个数字x,就把对应的T[x]加一。
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9
+—+—+—+—+—+—+—+—+—+—+
数字 i: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |8 | 9 |
+—+—+—+—+—+—+—+—+—+—+
出现次数T: | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 |0 | 2 |
+—+—+—+—+—+—+—+—+—+—+
最后,我们用一个指针从前往后扫描一遍,按照次序输出0到9,每个数出现了几次就输出几个。假如给定的数是n个大小不超过m的自然数
,显然这个算法的复杂度是O(m+n)的。
我曾经以为,这就是线性时间排序了。后来我发现我错了。再后来,我发现我曾犯的错误是一个普遍的错误。很多人都以为上面的这个算
法就是传说中的计数排序。 问题出在哪里了?为什么它不是线性时间的排序算法?原因是,这个算法根本不是排序算法,它根本没有对原数据
进行排序。
问题一:为什么说上述算法没有对数据进行排序?
STOP! You should think for a while.
我们班有很多MM。和身高相差太远的MM在一起肯定很别扭,接个吻都要弯腰才行(小猫矮 死了)。为此,我希望给我们班的MM的身高排
序。我们班MM的身高,再离谱也没有超过2米的,这很适合用我们刚才的算法。我们在黑板上画一个100到 200的数组,MM依次自曝身高,我负
责画“正”字统计人数。统计出来了,从小到大依次为141, 143, 143, 147, 152, 153, …。这算哪门子排序?就一排数字对我有什么用,我
要知道的是哪个MM有多高。我们仅仅把元素的属性值从小到大列了出来,但我们没有对元素本身进行排序。也 就是说,我们需要知道输出结果
问题二:怎样将线性时间排序后的输出结果还原为原数据中的元素?
STOP! You should think for a while.
同样借助Hash表的思想,我们立即想到了类似于开散列的方法。我们用链表把属性值相同的元素串起来,挂在对应的T上。每次读到一
个数,在增加T 的同时我们把这个元素放进T延伸出去的链表里。这样,输出结果时我们可以方便地获得原数据中的所有属性值为i的元
素。
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9
+—+—+—+—+—+—+—+—+—+—+
数字 i: | 0 | 1 | 2| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
+—+—+—+—+—+—+—+—+—+—+
出现次数T: | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 |0 | 2 |
+—+o–+-o-+-o-+-o-+-o-+–o+—+—+-o-+
| | | | | | |
+–+ +-+ | | +-+ +—+ |
| | A[1] | | | A[6]
A[2] A[7] | A[3] A[5] A[8] |
| | | A[12]
A[4] A[10] A[9]
|
A[11]
形象地说,我们在地上摆10个桶,每个桶编一个号,然后把数据分门别类放在自己所属的桶里。这种排序算法叫做桶式排序(Bucket
Sort)。本文最后你将看到桶式排序的另一个用途。
链表写起来比较麻烦,一般我们不使用它。我们有更简单的方法。
问题三:同样是输出元素本身,你能想出不用链表的其它算法么?
STOP! You should think for a while.
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9
+—+—+—+—+—+—+—+—+—+—+
数字 i: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |8 | 9 |
+—+—+—+—+—+—+—+—+—+—+
出现次数T: | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 |0 | 2 |
+—+—+—+—+—+—+—+—+—+—+
修改后的T: | 0 | 2 | 3 | 5 | 6 | 9 | 10| 10|10| 12|
+—+—+—+—+—+—+—+—+—+—+
所有数都读入后,我们修改T数组的值,使得T表示数字i可能的排名的最大值。比如,1最差排名第二,3最远可以排到第五。T数组
的最后一个数应该等于输入数据的数字个数。修改T数组的操作可以用一次线性的扫描累加完成。
我们还需要准备一个输出数组。然后,我们从后往前扫描A数组,依照T数组的指示依把原数据的元素直接放到输出数组中,同时T的
值减一。之所以从后 往前扫描A数组,是因为这样输出结果才是稳定的。我们说一个排序算法是稳定的(Stable),当算法满足这样的性质:属
性值相同的元素,排序后前后位置 不变,本来在前面的现在仍然在前面。不要觉得排序算法是否具有稳定性似乎关系不大,排序的稳定性在下
文的某个问题中将变得非常重要。你可以倒回去看看前面 说的七种排序算法哪些是稳定的。
例子中,A数组最后一个数9所对应的T[9]=12,我们直接把9放在待输出序列中的第12个位置,然后T[9]变成11(这样下一次再出现9时就
应该放在第11位)。
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9<–
T= 0, 2, 3, 5, 6, 9, 10, 10, 10, 11
Ans = _ _ _ _ _ _ _ _ _ _ _ 9
接下来的几步如下:
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5 <–
T= 0, 2, 3, 5, 6, 8, 10, 10, 10, 11
Ans = _ _ _ _ _ _ _ _ 5 _ _ 9
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3 <–
T= 0, 2, 3, 4, 6, 8, 10, 10, 10, 11
Ans = _ _ _ _ 3 _ _ _ 5 _ _ 9
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5 <–
T= 0, 2, 3, 4, 6, 7, 10, 10, 10, 11
Ans = _ _ _ _ 3 _ _ 5 5 _ _ 9
这种算法叫做计数排序(Counting Sort)。正确性和复杂度都是显然的。
问题四:给定数的数据范围大了该怎么办?
STOP! You should think for a while.
前面的算法只有在数据的范围不大时才可行,如果给定的数在长整范围内的话,这个算法是不可行的,因为你开不下这么大的数组。
Radix排序(Radix Sort)解决了这个难题。
昨天我没事翻了一下初中(9班)时的同学录,回忆了一下过去。我把比较感兴趣的MM的生日列在下面(绝对真实)。如果列表中的哪个
MM有幸看到了这篇日志(几乎不可能),左边的Support栏有我的电子联系方式,我想知道你们怎么样了。排名不分先后。
19880818
19880816
19890426
19880405
19890125
19881004
19881209
19890126
19890228
这就是我的数据了。现在,我要给这些数排序。假如我的电脑只能开出0..99的数组,那计数排序算法最多对两位数进行排序。我就把
个八位数两位两位地分成四段(图1),分别进行四次计数排序。地球人都知道月份相同时应该看哪一日,因此我们看月份的大小时应该事先保
证日已经有序。换 句话说,我们先对“最不重要”的部分进行排序。我们先对所有数的最后两位进行一次计数排序(图2)。注意观察1月26号
的MM和4月26号的MM,本次排 序中它们的属性值相同,由于计数排序是稳定的,因此4月份那个排完后依然在1月份那个的前头。接下来我们对
百位和千位进行排序(图3)。你可以看到两个 26日的MM在这一次排序中分出了大小,而月份相同的MM依然保持日数有序(因为计数排序是稳
定的)。最后我们对年份排序(图4),完成整个算法。大家都 是跨世纪的好儿童,因此没有图5了。
这种算法显然是正确的。它的复杂度一般写成O(d*(n+m)),其中n表示n个数,m是我开的数组大小(本例中m=100),d是一个常数因子(
本例中d=4)。我们认为它也是线性的。
问题五:这样的排序方法还有什么致命的缺陷?
STOP! You should think for a while.
即使数据有30位,我们也可以用d=5或6的Radix算法进行排序。但,要是给定的数据有无穷多位怎么办?有人说,这可能么。这是可能的
,比如给定的数据是小数(更准确地说,实数)。基于比较的排序可以区分355/113和π哪个大,但你不知道Radix排序需要精确到哪一位。这
下惨了,实数的出现把貌似高科技的线性时间排序打回了农业时代。这时,桶排序再度出山,挽救了线性时间排序悲惨的命运。
问题六:如何对实数进行线性时间排序?
STOP! You should think for a while.
我们把问题简化一下,给出的所有数都是0到1之间的小数。如果不是,也可以把所有数同时除以一个大整数从而转化为这种形式。我们依
然设立若干个桶,比如, 以小数点后面一位数为依据对所有数进行划分。我们仍然用链表把同一类的数串在一起,不同的是,每一个链表都是
有序的。也就是说,每一次读到一个新的数都要 进行一次插入排序。看我们的例子:
A[]= 0.12345, 0.111, 0.618, 0.9, 0.99999
+—+—+—+—+—+—+—+—+—+—+
十分位: | 0 | 1 |2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
+—+-o-+—+—+—+—+-o-+—+—+-o-+
| | |
A[2]=0.111 A[3]=0.618 A[4]=0.9
| |
A[1]=0.12345 A[5]=0.99999
假如再下一个读入的数是0.122222,这个数需要插入到十分位为1的那个链表里适当的位置。我们需要遍历该链表直到找到第一个比
0.122222大的数,在例子中则应该插入到链表中A[2]和A[1]之间。最后,我们按顺序遍历所有链表,依次输出每个链表中的每个数。
这个算法显然是正确的,但复杂度显然不是线性。事实上,这种算法最坏情况下是O(n^2)的,因为当所有数的十分位都相同时算法就是一
个插入排序。和原来一样,我们下面要计算算法的平均时间复杂度,我们希望这种算法的平均复杂度是线性的。
这次算平均复杂度我们用最笨的办法。我们将算出所有可能出现的情况的总时间复杂度,除以总的情况数,得到平均的复杂度是多少。
每个数都可能属于10个桶中的一个,n个数总的情况有10^n种。这个值是我们庞大的算式的分母部分。如果一个桶里有K个元素,那么只与
这个桶有关的操作 有O(K^2)次,它就是一次插入排序的操作次数。下面计算,在10^n种情况中,K0=1有多少种情况。K0=1表示,n个数中只有
一个数在0号桶,其 余n-1个数的十分位就只能在1到9中选择。那么K0=1的情况有C(n,1)*9^(n-1),而每个K0=1的情况在0号桶中将产生1^2的复
杂度。 类似地,Ki=p的情况数为C(n,p)*9^(n-p),复杂度总计为C(n,p)*9^(n-p)*p^2。枚举所有K的下标和p值,累加起来,这个 算式大家应
该能写出来了,但是这个……怎么算啊。别怕,我们是搞计算机的,拿出点和MO不一样的东西来。于是,Mathematica5.0隆重登场,我做数学
作业全靠它。它将帮我们化简这个复杂的式子。
我们遗憾地发现,虽然常数因子很小(只有0.1),但算法的平均复杂度仍然是平方的。等一下,1/10的那个10是我们桶的个数吗?那么
我们为什么不把桶的个数弄大点?我们干脆用m来表示桶的个数,重新计算一次:
化简出来,操作次数为O(n+n^2/m)。发现了么,如果m=Θ(n)的话,平均复杂度就变成了O(n)。也就是说,当桶的个数等于输入数据的个
数时,算法是平均线性的。
我们将在Hash表开散列的介绍中重新提到这个结论。
且慢,还有一个问题。10个桶以十分位的数字归类,那么n个桶用什么方法来分类呢?注意,分类的方法需要满足,一,一个数分到每个
桶里的概率相同(这样才 有我们上面的结论);二,所有桶里容纳元素的范围必须是连续的。根据这两个条件,我们有办法把所有数恰好分为
n类。我们的输入数据不是都在0到1之间么? 只需要看这些数乘以n的整数部分是多少就行了,读到一个数后乘以n取整得几就插入到几号桶里
。这本质上相当于把区间[0,1)平均分成n份。
问题七:有没有复杂度低于线性的排序算法
STOP! You should think for a while.
我们从O(n^2)走向O(nlogn),又从O(nlogn)走向线性,每一次我们都讨论了复杂度下限的问题,根据讨论的结果提出了更优的算法。这次
总 算不行了,不可能有比线性还快的算法了,因为——你读入、输出数据至少就需要线性的时间。排序算法之旅在线性时间复杂度这一站终止
了,所有十种排序算法到 这里介绍完毕了