快速排序-常見中軸(主元pivot)選擇方法及實現程式碼(末位/隨機/三數中值/..)
一、選取最後一個元素
在我們的課本中,看到最多的就是選擇第一個元素作為中軸,但是在很多書上卻選擇最後一個元素作為中軸。下面就讓我們來一睹選取最後一個元素作為中軸的快排。
注:本文中的所有演算法都採用雙向掃描法,即,設兩個下標i和j,i和右掃描,j向左掃描,直到i不小於j。而當下標為i的數小於中軸時,跳過並繼續向右掃描,否則停止掃描,並開始j的向左掃描,相對地,當下標為j的數大於中軸時,跳過並繼續向右掃描,否則停止掃描,然後交換下標為i和j的兩個數,並從下一個位置繼續兩個方向的掃描,直到i不小於j。最後把中軸與下標為i的元素交換即完成一趟的快排。
下面就以最後一個元素作為中軸來說明。
初始資料為: 8 1 4 9 0 3 5 2 7 6
中軸為:6,第一趟快排的情景如下:
8 1 4 9 0 3 5 2 7 6
↑ ↑
i→ ←j
第一次交換後:
2 1 4 9 0 3 5 8 7 6
↑ ↑
i j
第二次交換後:
2 1 4 5 0 3 9 8 7 6
↑ ↑
i j
第三次交換前:
2 1 4 5 0 3 9 8 7 6
↑ ↑
j i
將下標為i的元素與最後一個元素(中軸)交換
即,一趟快排後:
2 1 4 5 0 3 6 8 7 9
對左邊的陣列和右邊的陣列重複上述過程。
選取最後一個元素作為中軸的好處是簡單直觀,操作一致。因為我們通常把下標為i的元素與中軸元素作為交換,而i則總是指向比中軸大的元素,而把大的元素放到後面總是合理的。
二、雙向描述法中的越界問題
快速排序中都非常喜歡使用雙向掃描法,然而這個方法卻存在一個越界問題,考慮如下的情況:
8 1 4 6 9 3 5 2 7 0
i→ ←j
當我們選取最後一個元素作為中軸時,j向左掃描,一直到找到一個比0小的數,但是它卻是陣列中的最小值,所以j會一直向左走,直到越界。
這個問題無論是使用第一個元素作為中軸,還是使用最後一個元素作為中軸都會存在。所以,在雙向描述中,必然有一個方向(遠離中軸的方法)要做越界檢查。
選取最後一個元素作為中軸的快排的關鍵程式碼
- void QSort(DataType *data, int left, int right)
- {
- //如果資料的個小數為1或0則不需要排序
- if(left >= right)
- return;
- //取最後一個元素作為樞紐
- DataType centre = data[right];
- int i = left;
- int j = right-1;
- while(true)
- {
- //從前向後掃描,找到第一個小於樞紐的值,
- //在到達陣列末尾前,必定結果迴圈,因為最後一個值為centre
- while(data[i] < centre)
- ++i;
- //從後向前掃描,此時要檢查下標,防止陣列越界
- while(j >= left && data[j] > centre)
- --j;
- //如果沒有完成一趟交換,則交換
- if(i < j)
- Swap(data[i++], data[j--]);
- else
- break;
- }
- //把樞紐放在正確的位置
- Swap(data[i], data[right]);
- QSort(data, left, i - 1);
- QSort(data, i + 1, right);
- }
三、隨機選元法
我們知道,在快排中,中軸的選擇對於演算法的效率是非常重要的,選擇一個好的中軸選擇策略會使演算法的效率顯著提高。
無論是前面說的選取第一個元素還是最後一個元素作為中軸,其實都是一個壞的選元方法。因為當元素基本有序時,這兩種方法都會使演算法的效率非常低,最壞情況下,是O(n^2)。
隨機選元法的思路:使用隨機數生成函式生成一個隨機數rand,隨機數的範圍為[left, right],並用此隨機數為下標對應的元素a[rand]作為中軸,並與最後一個元素a[right]交換,然後進行與選取最後一個元素作為中軸的快排一樣的演算法即可。
隨機選元法仍然存在掃描越界問題,所以在遠離中軸的方法上仍然需要檢查下標。
隨機選元法的關鍵程式碼
- void QSort(DataType *data, int left, int right)
- {
- //如果資料的個小數為1或0則不需要排序
- if(left >= right)
- return;
- //隨機選取一個元素作為樞紐,並與最後一個元素交換
- int ic = Random(left, right);
- Swap(data[ic], data[right]);
- DataType centre = data[right];
- int i = left;
- int j = right-1;
- while(true)
- {
- //從前向後掃描,找到第一個小於樞紐的值,
- //在到達陣列末尾前,必定結果迴圈,因為最後一個值為centre
- while(data[i] < centre)
- ++i;
- //從後向前掃描,此時要檢查下標,防止陣列越界
- while(j >= left && data[j] > centre)
- --j;
- //如果沒有完成一趟交換,則交換
- if(i < j)
- Swap(data[i++], data[j--]);
- else
- break;
- }
- //把樞紐放在正確的位置
- Swap(data[i], data[right]);
- QSort(data, left, i - 1);
- QSort(data, i + 1, right);
- }
- inlineint Random(int begin, int end)
- {
- //產生begin至end,包括begin和end的隨機數,即[begin, end]範圍的整數
- return rand()%(end - begin + 1) + begin;
- }
從上面的程式碼也可以看出,隨機選法與選取最後一個元素作為中軸的方法是非常相近的,只是多了把隨機的中軸放到最後的位置的操作。
四、三數中值分割法
一組N個數的中值是第N/2個最大數,中軸的最好選擇就是陣列的中值。不幸的是,這很難算出。但這樣的中值的估計量可以通過隨機選取三個元素並用它們的中值作為中軸而得到。事實上,隨機性並沒有多大的幫助,因此一般的做法是使用左端、右端和中心位置上的三個元素的中值作為中軸。
分割策略:假設陣列被排序的範圍為left和right,center=(left+right)/2,對a[left]、a[right]和a[center]進行適當排序,取中值為中軸,將最小者放a[left],最大者放在a[right],把中軸元與a[right-1]交換,並在分割階段將i和j初始化為left+1和right-2。然後使用雙向描述法,進行快排。
三數中值分割法的實現
初始資料: 6 1 8 9 4 3 5 2 7 0
對三個數進行排序後: 0 1 8 9 4 3 5 2 7 6
中軸與a[right-1]交換: 0 1 8 9 7 3 5 2 4 6
開始掃描: i→ ←j
第一次交換後: 0 1 2 9 7 3 5 8 4 6
i =2 j=7
第二次交換後: 0 1 2 3 7 9 5 8 4 6
i=3 j=5
第三次交換前: 0 1 2 3 7 9 5 8 4 6
i=4,j= 3 3=j i=4
第三次交換後: 0 1 2 3 4 9 5 8 7 6
(與a[right-1]交換)
分割策略的好處
1)將三元素中最小者被分到a[left]、最大者分到a[right]是正確的,因為當快排一趟後,比中軸小的放到左邊,而比中軸大的放到右邊,這樣就在分割的時候把它們分到了正確的位置,減少了一次比較和交換。
2)在前面所說的所有演算法中,都有雙向掃描時的越界問題,而使用這個分割策略則可以解決這個問題。因為i向右掃描時,必然會遇到不小於中軸的數a[right-1],而j在向左掃描時,必然會遇到不大於中軸的數a[left],這樣,a[right-1]和a[left]提供了一個警戒標記,所以不需要檢查下標越界的問題。
關鍵實現程式碼
- DataType Median3(DataType *data, int left, int right)
- {
- //取資料的頭、尾和中間三個數,並對他們進行排序
- //排序結果直接儲存在陣列中
- int centre = (left + right)/2;
- if(data[left] > data[centre])
- Swap(data[left], data[centre]);
- if(data[left] > data[right])
- Swap(data[left], data[right]);
- if(data[centre] > data[right])
- Swap(data[centre], data[right]);
- //把中值,即樞紐與陣列倒數第二個元素交換
- swap(data[centre], data[right - 1]);
- return data[right - 1];//返回樞紐
- }
- void QSort(DataType *data, int left, int right)
- {
- //如果需要排序的資料大於3個則使用快速排序
- if(right - left >= 3)
- {
- //取得樞紐的值
- DataType centre = Median3(data, left, right);
- int begin = left;
- int end = right - 1;
- while(true)
- {
- //向後掃描陣列
- //由於在選擇樞紐時,已經把比樞紐值大的資料放在right位置
- //所以不會越界
- while(data[++begin] < centre);
- //向前掃描陣列
- //由於在選擇樞紐時,已經把比樞紐值小的資料放在left位置
- //所以不會越界
- while(data[--end] > centre);
- //把比樞紐小的資料放在前部,大的放到後部
- if(begin < end)
- Swap(data[begin], data[end]);
- else
- {
- //已經對要排序的資料都與樞紐比較了一次
- //把中樞紐儲存在適當的位置,因為begin的數一定比樞紐大
- //所以把這個數放在陣列後面
- Swap(data[begin], data[right - 1]);
- break;
- }
- }
- QSort(data, left, begin - 1);
- QSort(data, begin + 1, right);
- }
- else//如果要排序的資料很少,少於等於3個,則直接使用氣泡排序
- {
- BubbleSort(data+left, right - left + 1);
- }
- }
五、與中軸相等時的操作
當i或j在掃描中遇到與中軸相等的元素時,是停止掃描還是繼續?
我們仍然採用雙向掃描法,而且從實踐中我們知道,i與j的操作應該相同(停止或繼續)否則,一趟快排後,會出現中軸偏向一邊的現象。
下面以一下極端的例子來說明:
採用三數中值分割法
初始資料: 2 2 2 2 2 2 2 2 2
分割後: 2 2 2 2 2 2 2 2 2
i→ ←j
1)繼續掃描(先不考慮越界)