【数据结构与算法】——排序算法篇

    由于研究生考试的需要,加上我对算法的情有独钟,这段时间一直在研究算法。跟大家分享一些我的经验和想法:一、欢迎大家批评指正我错误的地方;二、欢迎大家补偿自己的见解进来,我如果发现有独到见解的评论,我会编辑添加到文章中来,并注明。希望给大家带来好的知识分享!

    为什么我们需要排序?存放数据就像我们在日常生活中存放东西一样,时不时需要整理一下,你下次拿东西的时候才方便。如果你的东西是一堆乱麻,你自己找个东西估计是很费时间的。

    我什么情况下需要排序?其实很多的情况下,是否使用排序是一个重要的策略问题。很早以前人们使用排序,多数情况下是希望能够使用二分查找在logn的时间内取得想要的数据。乱序的情况下,只能使用顺序查找,需要n的时间才能够完成,平均情况下也是n/2,与logn差距太大。于是 排序+二分查找 成为了早期程序员的数据管理标准配置。但是随着算法理论的推进。现在的情况发生了相当巨大的变化。正如《孙子兵法》中阐述的那样,战争的最高境界是【不战而屈人之兵】,那么排序的最高境界就是【不排】:

    1.【如果仅仅是为了取得数据方便】,那么Hash才是最佳的选择,因为如果使用好的解决Hash冲突方法,能够做到1+a/2 时间内取得数据,其中a为Hash表的填装因子。这远远好于二分查找带给你的logn时间复杂度。当然,你别想在Hash表找最大值,最小值,或者最大的10个数之类的问题,如果你需要这些操作,Hash不应该是你的选择。但通常情况下,人们存放用户数据的时候,往往关心的是如何取出而已。这种单纯的存取关系下,Hash绝对是最好的选择!

    2.【非重复关键字数据】,其实现实生活中这种情况是非常多见的,例如电话号码、身份证数据,他们往往可以成为关键字,而且排序往往是有意义的。如电话号码中0826可能是某个特定的地区,身份证511可能有特定的含义,对这些数据的如果是有序的,往往可以从中取得有用的信息。但是,这些数据真的需要排序吗?在《编程珠玑》中记载了这样一个案例,Boss需要程序员在一台老机子上对1,000,000条电话号码进行排序,要求不超过0.5秒就要完成,可用的内存为1M。问题来了?一来是,那个年代的老机子,1百万条数据,0.5秒排完,即使是快速排序都不可能。二者是,1M内存,就算电话号码是integer都存不下1百万条。面对这样的一个难题,作者是如何解决的呢?答案是根本不排序!首先,使用bit表示电话号码,比如有个电话是 000 000 03 那么就是第3个bit为1,相应的没有电话号码时为0,后面类推。其次,在读取数据放到内存的过程中,电话号码,就能够做到已经有序了,因为 100 100 10 一定是存放在 100 100 11前面的,这样就是天然有序的,所以完全不需要排序了!而我们的程序中,其时有非常多的情况下是非重复关键字的,这种情况下,是可以有更好的解决方案的,不是吗?

    3.【大规模频繁变更数据排序】,有时候我们存了相当大规模的数据, 而且随时可能有新的数据插入进来,或者旧的数据需要更新值,而我们又需要这个数据集是有序的,于是我们会经常调用排序算法来排序。这个大规模数据的情况下,性能往往是关键而敏感的,于是我们开始疯狂的优化排序算法。我们开始尝试各种混合优化排序方法,以期能够提升整个程序的性能。但是这样的工作真的是可取的吗?这样的大规模数据情况下,使用 树 这样的结构其实往往是更加科学的选择。因为诸如 红黑树 一类的高级树形结构往往能够在插入的过程中保持树本身的性质,而不需要调用排序算法。这种自动排序结构比优化排序算法更能从本质上改成程序的效率。

    说了这么多,但很多时候我们还是需要一个高效的排序算法的,至少在我们不明确需求情况下,我们有可用的解决方案,也许以后可用找到更加特定的方案,但我们需要先将一个程序跑起来不是?那么先来看看我们有些什么排序算法:

    上图传说是来自《大话数据结构》一书,但我没看过,这里借来引用特此说明。总体说来,我们有四大类排序算法:插入、选择、交换、合并。这是根据这些算法的基本操作来分类的。其实这上述的算法都是基于“比较” 的算法,还有一类特殊的算法是 基数排序。基于“比较”的算法,算法理论证明至少需要“log(n!) 上取整”次比较,而基数排序可以做到线性时间排序。但是基数排序在实践中并没有很好的表现,而且实现起来比较复杂,所以一直很少应用于实际编程。而我们这里讨论的也主要是“比较”排序。下面先来初看下这些“比较”排序的特征:

    上表同样据说来自《大话数据结构》,特此说明。我们可以看出,最主要的平均时间复杂度上,n²  和 nlogn 是两个重要的分水岭。通常n²  复杂度得排序算法被称为简单排序算法,因为通常能够比较简单地编写出来。而nlogn级的排序算法,被称为高级排序算法,因为通常需要一定算法基础的程序员才能够编写。下面我们来一一分析:

    前奏:引入一个简单的操作函数,交换swap,功能是交换传入的两个值,这个简单的操作可以方便后面的程序编码:

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


    上面的 ^ 是 异或 操作,这个交换实现是一种不使用中间变量进行交换的hack code,娱乐性质和实用性质都有一点儿。

  

1.冒泡排序

   

void bubblesort(int *arr,int n)
{
	for( int i=0; i arr[j+1] )
				swap(arr[j],arr[j+1]);
		}	
	}
}


 这个版本的冒泡排序是正统的实现方式,其实可以实现地更加简洁好看一点儿:

void bubblesort(int *arr,int n)
{
	for( int i=0; i arr[j+1] )
				swap(arr[j],arr[j+1]);
}


     是不是很有层次感,是我最喜欢的风格,也许是受python的影响吧,我觉得短代码时,不要括号更加具有可读性。所以在C语言编程时,在短代码情况下,我也会偶尔这样去掉括号来增强可读性和层次感。冒泡排序应该是大家比较熟悉的,其特点如下:

   (1)稳定的,即如果有 [...,5,5...] 这样的序列,排完序后,这两个5的顺序一定不会改变,这在一般情况下是没有意义的,但当 5 这个节点不仅仅是一个数值,是一个结构体或者类实例,而结构体有附带数据的时候,这两个节点的顺序往往是有意义的,如果能够稳定有时候是关键的。因为如果不稳定则可能破坏附带数据的顺序结构。

    (2)比较次数恒定,总是比较 n²/2 次,哪怕数据已经完全有序了

    (3)最好的情况下,一个数据也不用移动,冒泡排序的最好情况是: 【数据已经有序】

    (4)最坏的情况下,移动 n²/2 次数据,冒泡排序的最坏情况是:【数据是逆序的】。

      需要说明的是的 n²/2  这个结果是:1+2+3+...+n-1 = n(n-1)/2,等差数列求和得到。

 

2.简单选择排序

   不准备分析,因为我比较懒,但有可能后续会补上。

 

3.直接插入排序

void insertsort(int *arr,int n)
{
	for(int i=1; i=0 && temp


直接插入排序,是一种十分有用的简单排序算法,由于其一些优秀的特性,在高级排序中往往会混合 直接插入排序,那么我们就来详细看看,直接插入排序的特点:

(1)稳定的,这点不多做解释,参见冒泡排序的说明

(2)最好情况下,只做 n-1 次比较,不做任何移动,比如 [ 1, 2, 3, 4, 5 ] 这个序列,算法a.检查2 能否插入1 前==>不能;b.检查3能否插入到2前==>不能;...以此类推,只需做完 n-1 次比较就完成排序,0次移动数据操作。直接插入排序的最好情况是【数据完全有序】

(3)最坏情况下,做 n²/2 次比较,做 n²/2 次移动数据操作,比如 [ 5, 4, 3, 2, 1 ]这个序列,4需要插入到5前,3需要插入到4,5前,...1需要插入到2,3,4,5前,同样由等差数列求和公式,可得比较次数和移动次数都是n(n-1)/2,简记为n²/2。直接插入排序的最好情况是【数据完全逆序】

(4)有人说直接插入排序是在序列越有序表现越好,数据越逆序表现越差,其实这种说法是错误的。举个例子说明,序列a [ 6,1,2,3,4,0 ] ,数据其实已经基本有序,只是0,6的位置不对,简单0,6交换即可得到正确序列,但插入排序会把 1,2,3,4以此插入到6前,在把0插入到1,2,3,4,6前,几乎有2n次移动操作。可见直接插入排序要想达到高效率,要求的有序不是基本有序,而前半部分完全有序,只有尾部有部分数据无序,例如 [ 0,1,2,3,4,5,5,6,7,8,9,........,107,99,96,101] 对这样一个只有尾部有部分数据无序,且尾部数据不会干扰到序列首部的 [0,1,2,3,4....] 的位置时,直接插入排序 是其他任何算法都无法匹敌的。

 

4.希尔排序

   这是一个神奇的排序,shell排序起初的设计目的就是改进直接插入排序(另外有一种二分插入排序也是对直接插入排序的改进),因为直接插入排序在诸如 [ 6,1,2,3,4,5,0 ] 这样的基本有序数列上表现不佳,人们设想是不是可以让插入的步长更大一些,比如步长为3,则相当于将序列分组为  [ 6,3 ,0 ]  [1,4 ] [ 2,5 ]这样三个子序列进行插入排序,这样[ 6,3,0 ] 一组可以很快地变换到 [ 0,3,6 ] 于是整个序列都很快有序了。

void shellsort(int *arr,int n)
{
	const int dltalen = 9;
	/*The best known sequence according to research by Marcin Ciura is
	  1, 4, 10, 23, 57, 132, 301, 701, 1750.*/
	int dlta[dltalen] = {1750,701,301,132,57,23,10,4,1};
	int temp;

	for(int t=0; t=0 && temp
 
  

希尔排序最有趣的地方在于她的步长序列选择上,步长序列选择的好坏直接决定了算法的效率,这也是为什么希尔排序效率是一个n²/2 ~nlog²n的原因,纠正一下传说来自《大话数据结构》的表中将希尔排序记作了n²/2 ~nlogn,这是不对的,目前的理论研究证明的希尔排序最好效率是nlog²n,这个logn上的平方是不能少的,差距很大的。上面的希尔排序中使用一个特殊的序列,是Marcin Ciura发布的研究报告中得到的目前已知最好序列,在使用这个特别的步长序列时,希尔排序的效率是nlog²n。论文的原文在:http://oeis.org/A102549,大家可以详细研究一下。那么希尔排序有哪些特点呢?

(1)希尔排序是不稳定的

(2)希尔排序特别适合于,大部分数据基本有序,只有少量数据无序的情况下,如 [ 6,1,2,3,4,5,0 ] 希尔排序能迅速定位到无序数据,从而迅速完成排序

(3)希尔排序的步长序列,无论如何选择最后一个必须是1,因为希尔排序的最后一步本质上就是直接插入排序,只是通过前面的步长排序,将序列尽量调整到直接插入排序的最高效状态。

(4)研究表明优良的步长序列选择下,在中小规模数据排序时,希尔排序是可以快过快速排序的。因为希尔排序的最佳步长下效率是 n*logn*logn*a(非常小常数因子) ,而快速排序的效率是 n*logn*b(小常数因子),在 n 小于一定规模时,logn*a 是可能小于b的,比如 a=0.25,b=4,n = 65535;此时logn*a<4 ,b=4;当然我一直没有看到希尔排序的确切常数因子报告,倒是隐约记得在什么地方看到快速排序的常数因子是4,但无法确定,如果谁知道快速排序的确切常数因子,麻烦告知。

 

5.堆排序

    堆排序是由于其最坏情况下nlogn时间复杂度,以及o(1)的空间复杂度而被人们记住的。在数据量巨大的情况下,堆排序的效率在慢慢接近快速排序。下面先看正统的堆排序实现:

void heapAdjust( int *heap, int low, int high )
{
	int temp = heap[low];
	for( int i=low*2+1; i<=high; i*=2)
	{                                     /************************/
		if( i=即可  */
		low = i;                          /************************/
	}
	heap[low] = temp;
}
void heapSort( int *heap, int size )
{
	for(int i=size/2-1; i>=0; --i )
	{	heapAdjust(heap,i,size-1);}
	for(int i=size-1  ; i>0 ; --i )
	{
		swap( heap[0], heap[i] );
		heapAdjust(heap,0,i-1);
	}
}
    代码由两部分组成,heapAdjust的调整 [ low , high ] 区间内的元素满足堆性质,代码正确工作的前提条件是只有heap[low]一个元素是不满足堆性质。heapSort 第一个for 循环是将 [ 0 ,size-1 ] 区间内的元素建成一个“大顶堆”。很多人不明白为什么建堆的时候是从 size/2-1 开始,到0结束,而显然这个时候 [ size/2, size ] 这接近一半的区间都是不满足堆性质的。这是因为这一半的区间在开始的时候不需要满足堆性质,因为你如果把整个堆看做一颗二叉树,那么这一半的区间就一定是树叶,树叶之间不需要满足特定的性质,而重要的是树叶和上层的树枝之间满足堆性质即可,这就是为什么heapAdjust是在 [ size/2-1, 0 ] 这个区间进行的原因。heapSort 的第二个for 循环是每次从堆顶取出元素,然后重新调整堆。整个排序就是在不断地从堆顶取元素,不断地重新调整堆。

    上面的堆排序是正统直观的实现方式,当然里面已经包含了一些精巧的特点,例如A点和B点的 <= 如果是换成< 也是可以工作的,但效率会低一些。B点的 <= 其实比较好理解,就是在 = 的情况下,减少一次不必要的赋值;但A点的 <= 中的 = 将直接影响堆调整时元素下降的速度。所以这个正统的版本其实已经是蛮经得起推敲的一个写法了。下面给出的则是一个更加优化的版本:

void heapadjust( int *heap, int low, int high )
{
	int temp = heap[low];
	for( int i=(low<<1)+1; i<=high; i=(i<<1))
	{                                     /************************/
		if( i,B点<=改为>= */
		low = i;                          /************************/
	}
	heap[low] = temp;
}
void heapsort( int *heap, int size )
{
	for(int i=(size>>1)-1; i>=0; --i )
	{	heapadjust(heap,i,size-1);}
	for(int i=size-1  ; i>0 ; --i )
	{
		swap( heap[0], heap[i] );
		heapadjust(heap,0,i-1);
	}
}
    这个版本的堆排序主要优化了两点,所有乘2、除2操作都优化成了位移,另外在heapadjust函数中,if( i

(1)堆排序是不稳定的

(2)堆排序在最坏的情形下都能保证nlogn的时间复杂度,这是因为对于深度为k的堆,调整算法(heapadjust)至多比较2(k-1)次,建立n个元素,深度为h的堆时,总共比较次数不超过4n;另外,n个结点的完全二叉树深度为 [logn]+1,在抽取堆顶,重新调整过程中调用调整算法n-1次,求和的值小于2n[ logn ];总共的比较次数一定小于4n+2n[logn]

(3)堆排序在任何时候都表现出出色的稳定性,这种稳定大概可以这样解释:当遇到基本有序、基本逆序的序列时,堆排序和插入排序、希尔排序表现得接近;当遇到大规模数据的时候,堆排序表现得和快速排序接近。也就是说:在任何某种情形下,堆排序都基本不是表现最好的,但一定和表现最好的算法差距不大,相应地一定远远好于这种情形下表现较差的算法,没有任何序列能够使堆排序进入所谓特别糟糕的情形。堆排序永远是那么稳定,按照你所期望性能运行,永远不是最好,永远都接近最好的算法;如果非要用句话形容堆排序就是:【万年老二】

(4)堆排序的建堆策略是影响性能的一项重要因素,举例说明:你可以使用建立“小顶堆”来完成“降序”排序,你也可以使用建立“大顶堆”来完成“升序”排序;但这两种策略都是极其低效的;你相当于你建立了一个基本逆序的序列,你最后要得到一个顺序的序列。正确的策略应该是“小顶堆”来完成“升序”,“大顶堆”来完成“降序”,注意这种策略的代码不易编写,我上面给出示范代码是“大顶堆”完成“升序”的排序方式,而大部分的教材也采用的是这种较易实现的方式。后续可能补充"大顶堆"完成“降序”的算法。



6.归并排序

    还在优化代码,我想写出尽量 简单 可读 高效 的代码给大家分享。分析也相应延后。

 

7.快速排序

   快速排序是实践工作中,最常用的一种排序算法了,被普遍认为是一般情况下的最高效算法。

void qsort_op(int *arr,int n)
{
	if( n > 1 )
	{
		int low    = 0;
		int high   = n-1;
		int pivot  = arr[low];/* 取第1个数作为中轴,进行划分 */
		while( low < high )
		{
			while( low < high && arr[high] >= pivot )
				--high;
			arr[low] = arr[high];

			while( low < high && arr[low] <= pivot )
				++low;
			arr[high] = arr[low];	
		}
		arr[low] = pivot;
		qsort_op(arr,low);		
		qsort_op(arr+low+1,n-low-1);
	}
}


    上述的代码,是我能够写出的最简洁的快速排序实现了,使用的是严蔚敏老师的《数据结构》的快速排序实现思量,没有采用《算法导论》的实现思路,因为综合比较后,发现严蔚敏老师的思路能显著减少移动次数,是一种更好的实现思路,但本质上两者是共通的。通常情况下的快速排序一般都是递归版本的。而关于快速排序,其实还可以有非递归的实现方式。因为本质上,大部分的递归都可以用栈来模拟。下面给出快速排序的非递归实现版本:

void xp_sort(int *arr,int size)
{
	int begin = 0;
	int end = size -1;

	if( begin < end )
	{
		const int stack_deepth = 65536;
		int stack[stack_deepth];
		int top = 0;
		stack[top++] = begin;
		stack[top++] = end;

		while( top != 0 )
		{
			int end_temp   = stack[--top];
			int begin_temp = stack[--top];

			int high = end_temp;
			int low  = begin_temp;

			if( low < high )
			{
				int pivot = arr[low];
				while( low < high )
				{
					while( low= arr[low]  )
					{ ++low; }
					arr[high] = arr[low];
				}
				arr[low] = pivot;

				stack[top++] = begin_temp;
				stack[top++] = low-1;
				stack[top++] = low+1;
				stack[top++] = end_temp;
			}
		}	
	}
}


   快速排序这么受欢迎的究竟是为什么呢?因为快速排序在通常意义下确实是最快的,请不要带之一。人们总是乐于找到最快的算法,人们也常常好奇于第一名是谁,大多数人都对第二名不感兴趣,冠军总是荣耀的,亚军总是默默无闻的。这也是为什么人们通常喜欢讨论“快速排序vs堆排序vs归并排序vs希尔排序”的原因。而他们间的较量结果大致是这样的:中小规模下,希尔排序可能更快(注意可能),大规模下快速排序最快但可能发生最坏情况n²,归并排序可以多线程而且是唯一稳定的高效排序,堆排序可以保证最坏情况下都是nlogn,对了弱弱地提一句,快速排序的最坏情况可能并不是n²的时间复杂度,而是"error:stack over flow",一个明明应该正常工作的排序,在某个时候就莫名地溢栈了,这让人情何以堪啊!下面具体给出快速排序的特征:

(1)快速排序是不稳定的

(2)快速排序在大多数情况下都是最快的

(3)快速排序的最坏情况是:【数据基本有序】【数据基本逆序】,例如 [ 1,2,3,4,5,...............,10000 ] 这样一个10000个数的有序数列,如果你让快速排序排序的话,那么明确地说,快速排序的递归深度将达到 10000 ,你能想象10000 层嵌套的递归是什么感觉吗?写 if 写10000 层嵌套都够人崩溃的,更别说 10000 层的递归的,程序空间那点可怜的栈空间就这样被压榨得over flow了,所以你的快速排序如果没有保护措施还是不要随便使用。而诸如 [ 10000,9999,.........,3,2,1 ] 这样基本逆序的序列也是一样的。明确地说,快速排序如果没有保护措施,就是一个定时Bug,他会在你的程序中,在你做着好梦的某个晚上突然让程序挂掉!

(4)快速排序的优化措施大概分为三类:一、更好地选择中转轴,让划分更加趋近每次都是二分划分;二、递归过程中每次选择先处理短的区间,这样可以将递归深度限制在logn的级别上;或者使用栈模拟递归,灵活设定可允许的递归深度;三、划分过后的小区间交给诸如插入排序这样的算法处理,因为如果一个区间一共就五六个数,还需要递归调用快速排序,快排也变成慢排了。

 

综合上述,得到的各种情况下的最优排序分别是:

(1)序列完全有序,或者序列只有尾部部分无序,且无序数据都是比较大的值时,                                                       【直接插入排序】最佳(哪怕数据量巨大,这种情形下也比其他任何算法快)

(2)数据基本有序,只有少量的无序数据零散分布在序列中时,【希尔排序】最佳

(3)数据基本逆序,或者完全逆序时,                                                                                                                            【希尔排序】最佳(哪怕是数据量巨大,希尔排序处理逆序数列,始终是最好的,当然三数取中优化的快速排序也工作良好)

(4)数据包含大量重复值,【希尔排序】最佳(来自实验测试,直接插入排序也表现得很好)

(5)数据量比较大或者巨大,单线程排序,且较小几率出现基本有序和基本逆序时,【快速排序】最佳

(6)数据量巨大,单线程排序,且需要保证最坏情形下也工作良好,【堆排序】最佳

(7)数据量巨大,可多线程排序,不在乎空间复杂度时,【归并排序】最佳

    当然这些结论是针对基本版本的排序算法,不包含特殊优化技巧的排序版本。例如采用三数取中方法的快速排序在基本有序和基本逆序时表现出和直插、希尔相似的效率。但是这种版本,仍然有其他无数种可能使算法进入最坏状态的序列,三数取中只是消除了其中基本有序、基本逆序的一小部分。直接插入可以优化到二分插入排序或者多路插入排序。希尔排序,将来继续出现什么神奇的序列也是有可能的,研究远远没有结束。

    所以,很早人们就意识到,远远没有哪种排序是完美的,所以混合各种排序才是目前研究水平下的真正高效算法。那么如何混合使用这些排序,使得各种排序尽量发挥各自的特长呢?下面就介绍一种经典的混合排序,程序员经常使用的std::sort:

    下面简单介绍一下std::sort的实现,因为最近在做一个排序算法,目的就是想要超越std::sort这座大山(目前还未实现这个目标)。这里要纠正两个极端观点:一、std::sort由于是容器算法,所以是低效的;二、std::sort是大牛写的,无法超越。对于第一点,很多人认为std::sort的效率不高,说随便写个冒泡都比std::sort快,理由貌似是std::sort是容器算法,涉及到容器内部判断和容器操作,所以一般比较慢,而且还拿出了测试数据,说明在简单情况下,std::sort还不如冒泡。但是面对这些同志,windows平台下的VC请开到Release版本,谢谢!Linux版本下C程序员一般都认为gcc的std::sort比较高效,所以基本不会出现这种问题。你会惊讶地发现,在简单情况下,想要快过std::sort都是不容易的。对于第二点,std::sort无敌论者们,你们知道std::sort是怎么工作的吗?就毅然决然地说std::sort是无敌的,这是一种盲目地崇拜,永远这样盲目地仰望大牛们,自己不尝试做到更好,那么自己就永远活着大牛的光辉或者阴影下!

    分解std::sort,其内部机制可以简单阐述如下:

(1)主控制流程是一个改进的 快速排序 ,称为内省排序(introsort),中转轴采用“三数取中法”, 取的是 arrr[ low ]   arr[ (low+high)/2 ]   arr[ high ] 三者的中值

(2)在快速划分区间后,如果区间比较小,那么交给一个改进的 插入排序,称为(final_insert_sort)

(3)在递归过程中,有一个deepth 参数控制递归深度,如果 递归深度过深,算法中限定为 logn ,比如子序列长度为 128,那么最多允许 递归深度达到 7 ,如果递归深度达到上限,算法认为这种情况是恶化的递归,为防止快速排序的最坏情况发生,算法自动转为进行 堆排序(heapsort),这也是为什么称为内省排序的原因,内省就是内部自我反省的意思。

(4)算法中包含了一些 hack trick 来帮助算法更加高效,例如有专门的三数取中函数,(low+high)/2表示为(low+high)>>1等。在VC++中的release版的std::sort甚至可以找到汇编代码。

 

   大家,集思广益,想想怎样超越这样的一个排序算法吧!

 

 

 

 

 

 

 

 
  
 
  
 
  
 
  
 
 

你可能感兴趣的:(数据结构与算法)