1. 程式人生 > >演算法第七記-快速排序

演算法第七記-快速排序

今天來記錄一下快速排序的思路,效率分析,以及相關的面試演算法題:

快速排序與前面講過的歸併排序有著一部分相同的思想,基於分治+ partion操作。這個組成和歸併排序很像,我們之前講過歸併=分治+合併。所以講快排時我們同樣先講這個partion操作:

基本思路:假設我們取序列第一個元素為劃分基準,我們定義一個邊界控制邊界左邊的值都是小於這個基準元素,右邊都是大於這個基準元素然後我們定義一個指標從頭開始遍歷這個序列,如果遇到小於基準元素的就與左邊界+1這個位置進行交換,同時左邊界也向右移動了一格。直到遍歷到序列尾部才結束。

比如序列開始時是:5,3,4,6,7,1,0,2,2第一趟劃分之後將以5為邊界2,3,4,1,0,2,5,6,7

int partion(int arr[],int low, int high)
{
	int val = arr[low];
	int left = low - 1;
	for(int i=low;i<=high;i++)
	  if (arr[i] <= val)
	  {
			left++;
			swap(arr[left], arr[i]);
		}
	swap(arr[low], arr[left]);
	return left;
}

另外還有一種partion的寫法:這種寫法的思路大體一樣,它是分別從兩邊一起向中間靠定義兩個邊界指標向左,向右開始我們將第一個元素定為基準元素,然後如果右邊界的元素是大於基準元素的話,則右邊界向左移動一個位置,如果小的話與當前的左邊界進行交換。通俗來講,我們假設每個元素都由一個坑裝著,我們先把第一個坑裡的元素拿走,此時我們有一個坑是空的,所以我們需要從其他地方找東西來填補,這個填補的東西就是右邊那些比基準元素小的值。每當我們從右邊拿走一個值去補左邊時,右邊又會多出一個坑來,就這樣不斷重複。直到左邊界與右邊界相遇。

int partion(int arr[],int low,int high)
{
	int val = arr[low];
	int left = low;
	int right = high;
	while (left <right)
	{
		while (left < high&&arr[right] >= val)
			right--;
		if (left < right)
			arr[left] = arr[right];
		while (left < right&&arr[left] <=val)
			left++;
		if (left < right)
			arr[right]=arr[left];
	}
	arr[left] = val;
	return left;
}

那麼這一步partion的作用是什麼呢?每一次partion我們都可以使得一個元素到達最後排序完成的最終位置。

接下來我們講講分治做了什麼:

    每一次劃分子序列,我們都是根據之前得到的partion返回值,繼續劃分子序列。所以由於partion的位置不能確定,所以劃分的子序列大小並不一定相同,這與歸併排序有所不同,歸併排序每次都是從中間劃分,所以歸併排序劃分子序列的時候兩個子序列各佔原序列一半。

  

void QuickSort_core(int arr[], int low, int high)
{
	if (high - low > 0)
	{
		int pos = partion(arr, low, high);
		QuickSort_core(arr,low, pos - 1);
		QuickSort_core(arr, pos + 1, high);
	}
}
void QuickSort(int arr[], int length)
{
	if (!arr || length <= 0)
		return;
	QuickSort_core(arr, 0, length - 1);
}

下面我們來分析一下快速排序的效率:快速排序由於沒有使用額外的空間,所以它的空間複雜度是O(1),由於快速排序會進行左右交換,自然就不能保證穩定性比如3 0 1 2 2 4以3進行劃分2 0 1 2 3 4靠後面的那個2換到了前面,使得它們的相對位置發生了改變。關於時間複雜度的分析,我們可以參考之前歸併排序的遞迴樹分析法,每一次劃分都會有兩個子序列,歸併是對半分的,而由於partion得到的位置時不確定的,那麼從這個方向出發,我們可以思考一下快速排序的最好最壞情況分別在什麼時候。

對於遞迴樹的每一層都需要消耗為O(n)的時間,假設遞迴樹的高度為ħ的話,那麼快速排序的時間複雜度就是O(H * n)時,那麼所有的問題就落在了這個ħ的確定上了。最好情況下,得到的遞迴樹是一棵完全二叉樹,它的高度接近O(logn)時間時間。那麼最好的時候就是O(nlogn),那麼最壞情況呢?我們會想到高度最高的時候,樹在什麼時候高度會最高?左斜樹或者右斜樹。那麼遞迴樹在什麼情況下會變成左斜樹或者右斜樹?答案是有序!因為我們每次都是拿序列中第一個元素作為基準,比如第一次劃分的時候我們就只能劃分為兩個子序列,一個子序列只有一個元素,另一個子序列有n-1個個元素。以此類推我們不難發現樹的高度變成了為O(N),那麼最終的時間複雜度就上升到了O(N²)。那麼平均而言如果我們劃分的序列大小之間的比例不隨ñ變化,是一 O(nlogn)。通常來說,我們選取​​基準元素的方式有很多種,選第一個,最後一個,隨機選。而我們一般考慮演算法的隨機性,我們會採用隨機選擇基準元素。

下面我們總結一些利用到了快排思路的演算法題:

1.給定一個序列,編寫演算法使得偶數在奇數前面

答:這個題用到了我們之前講的partion的思路,定義邊界控制,定義一個新的指標遍歷如果遇到偶數與邊界右邊的位置的值進行交換,然後邊界向右移動一個位置。

void partion_odd_even(int arr[], int length)
{
	if (!arr || length <= 0)
		return;
	int left = -1;
	int p = 0;
	while (p < length)
	{
		if (arr[p] % 2== 0)
		{
			left++;
			swap(arr[left], arr[p]);
		}
		p++;
	}

}

2.有一個只由0,1,2三種元素構成的整數陣列,請使用交換,原地排序而不是使用計數進行排序。

給定一個只含0,1,2整數的陣列及它的大小,請返回排序後的陣列。保證陣列大小小於等於500。

測試樣例:

[0,1,1,0,2,2],6
返回:[0,0,1,1,2,2]

答:這其實就是一個“荷蘭國旗問題”,與上題思路類似,不過它要控制兩個邊界,一個左邊界控制0,一個右邊界控制2,然後一個指標從頭開始遍歷,遍歷到0與左邊界1的位置的值交換,然後左邊界1,遍歷到2就與右邊界-1的位置的值進行交換,然後右邊界-1。此時由於從右邊換過來的元素我們並不知道它是0,或是1或是2所以此時負責遍歷的指標不能向前移動。

void partion_0_1_2(int arr[], int length)
{
	int left = -1;
	int right = length;
	int p = 0;
	for(int p=0;p<right;p++)
	{
		if(arr[p]==1)
			continue;
		if (arr[p] == 0)
		{
			left++;
			swap(arr[left], arr[p]);
		}
		if (arr[p] == 2)
		{
			right--;
			swap(arr[right], arr[p]);
			p--;
		}
	}
}

3.求陣列第我大或者第我小的數(思路類似)

答:。利用我們之前用到過的partion方法,我們可以將序列分為兩個子序列因此根據這個思路我們可以不斷呼叫partion直到當前劃分的位置為我們所指定的我這裡我們為了使得partion這個演算法更具隨機性普遍性。我們選取基準元素採用範圍隨機數選取。

與快速排序一樣,我們依然把序列進行遞迴劃分。有所不同的是,快排會遞迴處理兩邊。而這個演算法只需要處理一邊就行。最差情況就是遍歷整個序列為O(n),例如一個有序序列我們的運氣很倒黴使用了隨機採取基準元素依然每次都取到了左邊緣的元素,假設我們要找第n大的數字,那麼找的過程就成了之前我們快排類似的最壞情況了。最壞是O(N²),但是由於我們採取的是隨機採取基準元素,所以遇到最壞情況的概率很低,所以平均來說時間複雜度是O(N)。

int partion(int arr[],int low,int high)
{
	int val = arr[low];
	int left = low;
	int right = high;
	while (left <right)
	{
		while (left < high&&arr[right] >= val)
			right--;
		if (left < right)
			arr[left] = arr[right];
		while (left < right&&arr[left] <=val)
			left++;
		if (left < right)
			arr[right]=arr[left];
	}
	arr[left] = val;
	return left;
}
int random_partion(int arr[], int low, int high)
{
	int i = random(low, high);
	swap(arr[i], arr[low]);
	return partion(arr, low, high);
}
int partion_mid_select(int arr[],int low,int high,int i)
{
	if (high == low)
		return arr[low];
	int pos=random_partion(arr, low, high);
	int k = pos - low + 1;//如果我們的序列長度為9我們的i可能是[1,9],而陣列座標下標是[0,8],
//例如下標6這個位置 其實是第七個元素。所以如果我們得到的pos實際上是i-1。然而我們要判斷這個pos這個位置是否是第i個元素,需要加1,並與我們實際的i進行比較,比較符合的話,返回的應該是原來的pos
//3 2 0 6 3 4  找第1個元素 當0位於第一個位置時 實際上下標為0,而我們要找的是第一個元素 所以需要pos-low+1(此時等於1)與i進行比較 相等就返回arr[0]
	if (k == i)
		return arr[pos];
	else if (i < k)
		return partion_mid_select(arr, low, pos - 1, i);
	else
		return partion_mid_select(arr, pos + 1, high, i - k);
}

4.找出陣列中出現次數超過一半的數字

答:。當一個數在數組裡出現次數超過一半的時候,那它本身也就是中位數也就是與第五題類似這裡我貼上劍指報價上的解法

需要注意的是我們需要注意非法的輸入,如果頻率最高的元素都沒有達到一半的標準。這就是後面函式的作用。

int MoreThanHalfNum(int arr[],int length)
{
   if(CheckInvailedArray(arr,length))
      return 0;
   int mid=length/2;
   int start=0;
   int end=length-1;
   int index=partion(arr,strat,end);
   while(index!=mid)
   {
      if(index<mid)
       {
         start=index+1;
         index=partion(arr,start,end);
      }
      else{
           end=index-1;
         index=partion(arr,low,end);
       }  
 }
     int result=arr[mid];
    if(!CheckMoreThanHalf(arr,length,result))
      return 0;
   return result;
}
bool input_vaild=false;
bool CheckInvaildArray(int arr[],int length)
{
    input_vaild=false;
    if(!arr||length<=0)
      return input_vaild;
    input_vaild=true;
}
bool CheckMoreThanHalf(int arr[],int length,int number)
{
    int times=0;
   for(int i=0;i<length;i++)
     if(arr[i]==number)
        times++;
  bool is_MoreThanHalf=true;
  if(2*times<length)
   {
      is_MoreThanHalf=false;
      input_vaild=true;
   }
   return is_MoreThanHalf;
}

5.求資料流中的中位數

答:求資料流中的中位數的思路是,如果序列大小為奇數時,排序後的序列排在中間的數字就是中位數,如果為偶數的話,則在中間的兩個數的平均值是中位數。所以我們先判斷序列的大小,然後我們可以使用之前的快排partion思路找到第ķ小的數字的值,如果是大小為奇數只要找到第n / 2個小的數就行,如果是偶數找到第n / 2個小,第N / 2 + 1小的兩個數求平均值就行。

6.連結串列的快速排序

答:單鏈表由於它的物理儲存性質使得它不能使用我們上面提到過的從兩邊向中間靠的partion,但是可以使用第一種只控制一個邊界,然後不斷遍歷,如果是比基準元素小的則與邊界交換,邊界向右移動一個位置。唯一的不同就是連結串列的判斷結束條件與陣列是不同的,這點需要注意。

list_node* get_position(list_node* beg, list_node* end)
{
	list_node* p = beg;
	list_node* q = p->next;
	int val = beg->num;
	while (q!=end)
	{
		if (q->num < val)
		{
			swap(p->next->num, q->num);//
			p = p->next;
		}
		q = q->next;
	}
	swap(p->num, beg->num);
	return p;
}
void quick_sort(list_node* beg,list_node* end)
{
	if (beg != end)
	{
		list_node* pos = get_position(beg, end);
		quick_sort(beg, pos);
		quick_sort(pos->next, end);
	}
}