天天看點

再談快速排序

前幾天同僚提到一個問題,為什麼lua中table.sort中的第二個參數在相等的情況下必須要傳回false,而不能傳回true?

當時一下沒反應過來,我就記得要傳回false,具體為什麼竟一時無語。

在lua中table.sort使用的的快速排序。

于是再次溫習快速排序 記錄如下。

(參考http://zh.wikipedia.org/wiki/%E5%BF%AB%E9%80%9F%E6%8E%92%E5%BA%8F ,

http://zh.wikipedia.org/wiki/%E5%8E%9F%E5%9C%B0%E7%AE%97%E6%B3%95

http://zh.wikipedia.org/wiki/%E5%B0%BE%E8%B0%83%E7%94%A8)

基本的快速排序的核心思想就是一句話:找一個參考點(樞點),比我大得扔右邊,比我小的扔左邊。

(P.S.在這裡為了實作友善我都使用vector來替代數組 [] , 這裡隻是為了原理分析,實際情況中使用什麼視情況而定,也并沒有考慮效率問題)

先定義輔助函數:

void prtvector( const vector<int>& v )
{
	for( int i = 0; i < v.size(); ++i )
	{
		printf( "%d ", v[i] );
	}
	printf( "\n" );
}

void swap( vector<int>& in, int a , int b )
{
	if ( a == b )
	{
		return;
	}
	int t = in[a];
	in[a] = in[b];
	in[b] = t;
}

bool less_than_int( int a , int b  )
{
	return a < b;
};
           

基于快速排序的核心思想于是有了第一種簡單粗暴的寫法:

vector<int> concatenate( vector<int>& left , int pivot , vector<int>& right)
{
	vector<int> ret;
	if ( ! left.empty() )
	{
		ret.insert( ret.end(), left.begin(),left.end() );
	}
	ret.push_back( pivot );
	if ( ! right.empty() )
	{
		ret.insert( ret.end(),right.begin(),right.end() );
	}
	return ret;
}

vector<int> quick_sort_1( vector<int>& in )
{
	if (in.size() <= 1)
	{
		return in;
	}
	int p = in.size() / 2; //初始樞點
	int pivot = in[p]; //樞點值
	vector<int> left;
	vector<int> right;
	/*周遊把小于樞點的扔到left,反之扔到right*/
	for ( int i = 0; i < in.size(); ++i )
	{
		if ( i == p ) //跳過樞點
		{
			continue;
		}
		if ( less_than_int( in[i] , pivot ) )
		{
			left.push_back( in[i] ); 
		}
		else
		{
			right.push_back( in[i] );
		}
	}
	//樞點不用繼續參與排序
	return concatenate( quick_sort_1(left) , pivot , quick_sort_1(right) ); //連接配接左邊和樞點和右邊
}
           

這種實作缺陷很明顯,每次都需要額外的配置設定很多記憶體來存儲(Ω(n))

要改進快速排序就要提到in-place算法的概念,于是快速排序基于原地算法有了第二種實作:

//分割函數
int partition( vector<int>& in,int left,int right,int pivotindex)
{
	int pivot = in[pivotindex];
	swap( in, pivotindex, right ); //樞點放至末尾
	int storeindex = left;
	for ( int i = left; i < right; ++i )//從左至右單向的周遊
	{
		/*如果目前值比樞點小,那麼把這個值和記錄點交換*/
		if ( less_than_int( in[i] ,pivot ) )  
		{
			swap( in , storeindex , i );
			++storeindex;
		}
	}
	swap( in,right , storeindex ); /*最終得到在樞點左邊的比樞點小,在樞點右邊的比樞點大*/
	return storeindex;
}

void quick_sort_2( vector<int>& in, int left , int right )
{
	if ( right > left )
	{
		int p = in.size() / 2; //選擇樞點
		int pivotnew = partition( in , left, right ,p );
		/*在左右兩邊分别遞歸,樞點不用繼續參與排序*/
		quick_sort_2( in,left,pivotnew-1 ); 
		quick_sort_2( in,pivotnew+1,right );
	}
}
           

在第二種方式的基礎又衍生了另一種in-place的寫法(雙向周遊):

<pre name="code" class="cpp">void quick_sort_3 (vector<int>& in, size_t left,size_t right) 
{
	int p = (left + right) / 2; //初始樞點
	int pivot = in[p]; //樞點值
	int i = left,j = right;
	/*這裡的實作并沒有直接使用swap*/
	/*p所在的位置相當于一個臨時位置*/
	for ( ; i < j;) {
		/*從最左邊開始周遊,如果i的值比樞點的值大 */
		while (! (i>= p || less_than_int( pivot ,in[i] )))
			++i;
		if (i < p) {
			in[p] = in[i];
			p = i;
		}
		/*從最右邊開始周遊,如果j的值比樞點的值小*/
		while (! (j <= p || less_than_int(in[j] , pivot )))
			--j;
		if (j > p) {
			in[p] = in[j];
			p = j;
		}
	}
	in[p] = pivot; //把樞點的值設回來。
	if (p - left > 1)
		quick_sort_3(in, left, p - 1); //遞歸樞點左邊
	if (right - p > 1) 
		quick_sort_3(in, p + 1, right);//遞歸樞點右邊
};
           

這三種方式都展現了快速排序的核心思想:分而治之。

上面是快速排序的實作。接下來考慮:

我們假設如果 在相等的情況傳回true,也就是

bool less_than_int( int a , int b  )
{
	return a <= b;
};
           

以上3種實作會怎樣呢?

在quick_sort_3中 less(a,b),less(b,a)同時使用,相等的情況下傳回true,本來語意上就有問題,我比你大,你比我大,同時為真,明顯有問題。實際情況中,如果同時為true那麼當 出現in[j] == in[j] == pivot 情形時,将會陷入死循環,因為++i和--j都不會被執行,p 就在 i 和j 上陷入死循環。 

在第一種實作和第二種實作中就不會出現這個問題,因為這兩種實作中并沒有同時使用到less(a,b)和less(b,a)的情況,每排序一次都會至少有一個元素被排序(樞點),是以這兩種方式可以讓排序在有限的次數内完成。是以這兩種實作方式是可以在兩個元素相等的情形下傳回true的。

接下來就來讨論最初的問題:為什麼lua中table.sort中的第二個參數在相等的情況下必須要傳回false,而不能傳回true?

table.sort( table [ , func ] ); 第2個參數為一個函數,定義了就相當于我們的less_than函數。

在Ltablib.c的sort函數中我們看到lua實際使用auxsort函數實作了lua的排序。接下來我把它翻譯為C++語言實作:

void auxsort( vector<int>& in,int l, int u )
{
	while( l < u ) /*為什麼是while,稍候解釋!*/
	{
		int i,j;
		//先對 左 中 右 3個值排序
		if ( less_than_int( in[u] , in[l] ) )
		{
			swap( in,u,l );
		}
		if ( u - l == 1 ) break; //隻有兩個
		i = (u+l) / 2;
		if ( less_than_int(in[i] , in[l]) )
		{
			swap(in,i,l);
		}
		else
		{
			if ( less_than_int( in[u] ,in[i] ) )
			{
				swap( in , u , i );
			}
		}
		if ( u - l== 2 ) break;//隻有三個
		/*至此,左中右三個值已經是有序的了in[l] <= in[i] <= in[u]*/

		int pivotvalue = in[i]; //選擇中點為樞點
		/*把中點值和u-1交換, 此時有in[l]<=in[u-1]<=in[u],隻需要對[l+1,u-2]之間的元素接着進行分區*/
		swap( in , i , u - 1 );
		i = l;
		j = u - 1;
		for(;;)
		{
			/*從左(l+1)至右周遊, 直到找到一個比樞點值大的i*/
			while( ++i,less_than_int( in[i],pivotvalue ) )
			{
				if ( i > u )
				{
					assert( !"invalid order function for sorting" ); //lua error!
				}
			}
			/*從右(u-2)至左周遊,直到找到一個比樞點值小的j*/
			while( --j,less_than_int( pivotvalue, in[j] ) ) 
			{
				if (j < l)
				{
					assert( !"invalid order function for sorting" ); //lua error!
				}
			} 
			/*周遊完整個[l+1,u-2]時,結束循環*/
			if ( j < i )
			{
				break;
			}
			/*把左邊那比樞點值大的和右邊找到的比樞點值小的交換*/
			swap( in , i , j );
		}
		/*把樞點值放回i,至此i的左邊都比in[i]小,i的右邊比in[i]大,即:in[l..i-1]<=in[i]==P<=a[i+1..u]*/
		swap( in, u-1,i ); 
		/*至此分區(partition)結束*/

		/*
		*到這裡了本來是對左右分區分别遞歸調用(auxsort)(即調用兩次auxsort,可是這裡卻隻調用了一次!
		*這裡用了一個技巧:尾遞歸(tail recursive)。
		*首先比較左邊和右邊誰的分區更大 i-l 表示左邊的分區的大小, u -i 表示右邊的分區的大小
		*對較小的分區進行遞歸(調用auxsort),對較大的分區采用尾遞歸(其實是尾遞歸對應的疊代,并不是真正意義上的尾遞歸,是一種思路)的方式。
		*這樣做的意義目測是:在一定程度上降低棧的深度,以緩解遞歸帶來的棧溢出問題(stack over flow)
		*/
		if ( i - l < u - i )
		{/*對[i+1,u]尾遞歸,[l,i-1]遞歸*/
			j = l;i = i - 1;l = i + 2;
		}
		else
		{/*對[l,i-1]尾遞歸,[i+1,u]遞歸*/	
			j = i + 1;i = u;u = j - 2;
		}
		auxsort( in , j , i ); //遞歸
	}//尾遞歸
}
           

auxsort的翻譯完完全全遵照了lua中的實作,對一些了解比較困難的地方也寫了注釋友善自己以後查閱。

如注釋中所說,除了使用尾遞歸,其他的也就是和quick_sort_3沒有太大差別(這裡使用了swap而quick_sort_3沒有)。

假設在比較函數中a b 相等時傳回true,那麼在分區的時候,在less_than( in[i] , pivotvalue )相等時傳回true,那麼會造成錯誤的++i調用(或--j)可能會導緻:

   1. 如果是在數組的邊界 就會造成數組越界(對i處是大于size,對j處是小于0),lua中表現為傳入的比較參數為空值,func(a,b) 正常情況a,b都不會為空,但是如果在i處越界就會表現為func(nil,b),在j處越界就會表現為func(a,nil)

   2. 如果是在數組的分區的邊界 就會抛出 Invalid order function for sorting 錯誤

   3.這種也是最隐晦的錯誤,因為lua将不會抛出錯誤,這會導緻輸出結果不正确。考慮以下情況當分區經過三點排序後為{ 1 2 1 3 },

此時樞點選擇為2,第一次交換{ 1 1 2 3  } 經過分區将得到分區點i = 3 交換回來{ 1 1 3 2 },因為在樞點的最後一次比較中,就是自己和自己比較less( pivot , pivot ) 這裡傳回true,那麼把樞點後面的一個值給換到樞點前面來,這也違背了快速排序的基本原則。

對于具體是哪一種錯誤将會受到元素本身的組成影響。是以當在lua中出現以上錯誤時,應該優先檢視比較函數的是否有此類問題。

綜上在快速排序中元素值相等時都應該盡量的傳回false,一來這更符合我們的邏輯,二來将會避免不必要的錯誤。

P.S. :關于樞點的選擇:

在快速排序中樞點的選擇對快速排序的速度影響很大,但我們看到4中方案中都統一的采用了中點來作為樞點。 搜尋了很久也沒有找到相關的文章,但是我記得大學時的資料結構教材上好像說的是經驗所得。

是以在沒有更好的選擇的情況下,我們都采用中點作為樞點的選擇方案( low + high ) / 2

繼續閱讀