前言
在本文中,我們將對元素的數組排序問題做一些簡單的歸納。爲簡單起見,在接下來的例子中假設數組至包含整數,雖然更復雜的結構顯然也是可能的。本文的大部分內容,我們還假設整個排序工作能夠在主存中完成。因此,元素的個數相對來說比較小(小於10^6)。當然,不能再主存中完成而必須在磁盤或磁帶上完成的排序也相當重要。這種類型的排序叫做外部排序(external sorting),將在本文末尾進行討論。
我們描述的算法都將是可以互換的,每個算法都將接收一個含有元素的數組和一個包含元素個數的整數。
我們將假設N是傳遞到我們的排序歷程中的元素個數,它已經被檢查過,是合法的。按照C的約定,對於所有的排序,數據都將在位置0處開始。
我們還假設“<”和“>”運算符存在,他們可以用於將相容的序放到輸入中。除賦值運算符外,這兩種運算是僅有的允許對輸入數據進行的操作。在這些條件下的排序叫做基於比較的排序。
正文
插入排序
最簡單的排序算法之一是插入排序(insertion sort),插入排序由N-1趟(pass)排序組成。對於P = 1 趟到 P = N - 1 趟,插入排序保證從位置 0 到位置 P 上的元素爲已排序狀態。插入排序利用了這樣的事實:對位置 P 處元素排序,則位置 0 到位置 P - 1上的元素都是已排過序的。
例:
void insertionSort (ElementType A[ ], int N)
{
int j, P;
ElementType Tmp;
for (P = 1; P < N; P++) {
Tmp = A[P];
for (j = P; j > 0 && A[j - 1] > Tmp; j--) {
A[j] = A[j - 1];
A[j - 1] = Tmp;
}
}
}
//php版
private function InsertSort(array $arr=[]){
$count = count($arr);
for($i=1;$i<$count;$i++){
$t = $arr[$i];
$j = $i-1;
while ($j>=0 && $arr[$j]>$t){
$arr[$j+1] = $arr[$j];
$j--;
}
$arr[$j+1] = $t;
}
return $arr;
}
分析:
由於嵌套循環的每一個都花費 N 次迭代,因此插入排序的時間複雜度爲 O(N^2),而且這個界是精準的,因爲以反序輸入可以到達該界。另一方面,如果輸入數據已預先排好序,那麼運行時間爲 O(N),因爲內層for循環的檢測總是立即判定不成立而終止。
選擇排序
選擇排序也是一種非常簡單的排序算法,由 N - 1 趟循環組成。選擇排序的原理是:遍歷數組,將數組中最小的值換到第一個,然後再遍歷數組剩餘元素,將剩餘元素中最小的值換到數組的第二個位置,以此類推…(對於P = 1 趟到 P = N - 1 趟,選擇排序仍然保證從位置 0 到位置 P 上的元素爲已排序狀態。)
例:
void selectionSort (ElementType A[ ], int N)
{
int j, P;
ElementType Tmp;
for (P = 0; P < N - 1; P++) {
Tmp = A[P];
for (j = P + 1; j < N; j++) {
if (Tmp > A[j]) {
Tmp = A[j];
}
}
A[P] = Tmp;
}
}
//PHP版
private function selectSort (array $arr) : array
{
$count = count($arr);
for ($i = 0; $i < $count; $i++){
$k = $i;
for($j = $i + 1; $j < $count; $j++){
if ($arr[$j] < $arr[$k]){
$k = $j;
}
}
if ($i != $k){
$t = $arr[$i];
$arr[$i] = $arr[$k];
$arr[$k] = $t;
}
}
return $arr;
}
分析:由於嵌套循環的每一個都花費 N 次迭代,因此插入排序的時間複雜度總是 O(N^2),而且這個界是精準的,因爲以反序輸入可以到達該界。
冒泡排序
另一種非常簡單的排序算法叫冒泡排序,由 N - 1 趟循環組成。冒泡排序的原理是:比較兩個相鄰的元素,將值大的元素交換到右邊。(對於P = 1 趟到 P = N - 1 趟,每進行一趟排序,就會少比較一次,因爲每進行一趟排序都會找出一個較大值。)
例:
void BubbleSort (ElementType A[ ], int N)
{
int j, P;
ElementType Tmp;
for (P = 0; P < N - 1; P++) {
for (j = 0; j < N - 1 - P; j++) {
if (A[j] > A[j+1]) {
Tmp = A[j];
A[j] = A[j+1];
A[j+1] = Tmp;
}
}
}
}
//PHP版
function bubbleSort($numbers) {
$cnt = count($numbers);
for ($i = 0; $i < $cnt - 1; $i++) {
for ($j = 0; $j < $cnt - $i - 1; $j++) {
if ($numbers[$j] > $numbers[$j + 1]) {
$temp = $numbers[$j];
$numbers[$j] = $numbers[$j + 1];
$numbers[$j + 1] = $temp;
}
}
}
return $numbers;
}
分析:時間複雜度爲 O(N^2)。第一趟比較之後,數組中最大的那個數排到了最後,第二趟排序的時候,只需要比較除了最後一個數以外的其他的數,同樣也能找出一個最大的數排在參與第二趟比較的數後面,第三趟比較的時候,只需要比較除了最後兩個數以外的其他的數,以此類推……也就是說,每進行一趟比較,下一趟就少比較一次,一定程度上減少了算法的量。
希爾排序
希爾排序是衝破二次時間屏障的第一批算法,又稱“縮小增量排序”。是直接插入排序算法的一種更高效的改進版本。希爾排序是非穩定排序算法。它通過比較相距一定間隔的元素來工作;希爾排序是把記錄按下標的一定增量分組,對每組使用直接插入排序算法排序;隨着增量逐漸減少,每組包含的關鍵詞越來越多,當增量減至1時,整個文件恰被分成一組,算法便終止(希爾排序的思想是使數組中任意間隔爲h的元素都是有序的。)
例:
//根據當前增量進行插入排序
void shellInsert(int array[],int n,int dk)
{
int i,j,temp;
for(i=dk;i<n;i++)//分別向每組的有序區域插入
{
temp=array[i];
for(j=i-dk;(j>=i%dk)&&array[j]>temp;j-=dk)//比較與記錄後移同時進行
array[j+dk]=array[j];
if(j!=i-dk)
array[j+dk]=temp;//插入
}
}
//計算Hibbard增量
int dkHibbard(int t,int k)
{
return (int)(pow(2,t-k+1)-1);
}
//希爾排序
void shellSort(int array[],int n,int t)
{
void shellInsert(int array[],int n,int dk);
int i;
for(i=1;i<=t;i++)
shellInsert(array,n,dkHibbard(t,i));
}
//此寫法便於理解,實際應用時應將上述三個函數寫成一個函數。
//PHP版
function shell_sort(&$arr) {
if(!is_array($arr)) return;
$n = count($arr);
for ($gap = floor($n/2); $gap > 0; $gap = floor($gap/=2)) {
for($i = $gap; $i < $n; ++$i) {
for($j = $i - $gap; $j >= 0 && $arr[$j + $gap] < $arr[$j]; $j -= $gap) {
$temp = $arr[$j];
$arr[$j] = $arr[$j + $gap];
$arr[$j + $gap] = $temp;
}
}
}
}
分析:
初始時,假設有一個大小爲 10 的無序序列。
(1)在第一趟排序中,我們不妨設 gap1 = N / 2 = 5,即相隔距離爲 5 的元素組成一組,可以分爲 5 組。
(2)接下來,按照直接插入排序的方法對每個組進行排序。
在第二趟排序中,我們把上次的 gap 縮小一半,即 gap2 = gap1 / 2 = 2 (取整數)。這樣每相隔距離爲 2 的元素組成一組,可以分爲 2 組。
(3)按照直接插入排序的方法對每個組進行排序。
(4)在第三趟排序中,再次把 gap 縮小一半,即gap3 = gap2 / 2 = 1。 這樣相隔距離爲 1 的元素組成一組,即只有一組。
(5)按照直接插入排序的方法對每個組進行排序。此時,排序已經結束。
堆排序
(堆是具有以下性質的完全二叉樹:每個結點的值都大於或等於其左右孩子結點的值,稱爲大頂堆;或者每個結點的值都小於或等於其左右孩子結點的值,稱爲小頂堆。)
例:
#include <stdio.h>
#include <stdlib.h>
void swap(int* a, int* b)
{
int temp = *b;
*b = *a;
*a = temp;
}
void max_heapify(int arr[], int start, int end)
{
//建立父節點指標和子節點指標
int dad = start;
int son = dad * 2 + 1;
while (son <= end) //若子節點指標在範圍內才做比較
{
if (son + 1 <= end && arr[son] < arr[son + 1])
//先比較兩個子節點大小,選擇最大的
son++;
if (arr[dad] > arr[son]) //如果父節點大於子節點代表調整完畢,直接跳出函數
return;
else //否則交換父子內容再繼續子節點和孫節點比較
{
swap(&arr[dad], &arr[son]);
dad = son;
son = dad * 2 + 1;
}
}
}
//主例程
void heap_sort(int arr[], int len)
{
int i;
//初始化,i從最後一個父節點開始調整
for (i = len / 2 - 1; i >= 0; i--)
max_heapify(arr, i, len - 1);
//先將第一個元素和已排好元素前一位做交換,再重新調整,直到排序完畢
for (i = len - 1; i > 0; i--)
{
swap(&arr[0], &arr[i]);
max_heapify(arr, 0, i - 1);
}
}
分析:
如圖:(圖片來源:https://github.com/hustcc/JS-Sorting-Algorithm)
歸併排序
歸併排序(MERGE-SORT)是建立在歸併操作上的一種有效的排序算法,該算法是採用分治法(Divide and Conquer)的一個非常典型的應用。將已有序的子序列合併,得到完全有序的序列;即先使每個子序列有序,再使子序列段間有序。若將兩個有序表合併成一個有序表,稱爲二路歸併。歸併排序是一種穩定的排序方法。
例:
void Merge(int sourceArr[],int tempArr[], int startIndex, int midIndex, int endIndex)
{
int i = startIndex, j=midIndex+1, k = startIndex;
while(i!=midIndex+1 && j!=endIndex+1)
{
if(sourceArr[i] > sourceArr[j])
tempArr[k++] = sourceArr[j++];
else
tempArr[k++] = sourceArr[i++];
}
while(i != midIndex+1)
tempArr[k++] = sourceArr[i++];
while(j != endIndex+1)
tempArr[k++] = sourceArr[j++];
for(i=startIndex; i<=endIndex; i++)
sourceArr[i] = tempArr[i];
}
//內部使用遞歸
void MergeSort(int sourceArr[], int tempArr[], int startIndex, int endIndex)
{
int midIndex;
if(startIndex < endIndex)
{
midIndex = startIndex + (endIndex-startIndex) / 2;//避免溢出int
MergeSort(sourceArr, tempArr, startIndex, midIndex);
MergeSort(sourceArr, tempArr, midIndex+1, endIndex);
Merge(sourceArr, tempArr, startIndex, midIndex, endIndex);
}
}
/*** PHP版 ****/
//merge函數將指定的兩個有序數組(arr1,arr2)合併並且排序
//我們可以找到第三個數組,然後依次從兩個數組的開始取數據哪個數據小就先取哪個的,然後刪除掉剛剛取過///的數據
function al_merge($arrA,$arrB)
{
$arrC = array();
while(count($arrA) && count($arrB)){
//這裏不斷的判斷哪個值小,就將小的值給到arrC,但是到最後肯定要剩下幾個值,
//不是剩下arrA裏面的就是剩下arrB裏面的而且這幾個有序的值,肯定比arrC裏面所有的值都大所以使用
$arrC[] = $arrA['0'] < $arrB['0'] ? array_shift($arrA) : array_shift($arrB);
}
return array_merge($arrC, $arrA, $arrB);
}
//歸併排序主程序
function al_merge_sort($arr){
$len = count($arr);
if($len <= 1)
return $arr;//遞歸結束條件,到達這步的時候,數組就只剩下一個元素了,也就是分離了數組
$mid = intval($len/2);//取數組中間
$left_arr = array_slice($arr, 0, $mid);//拆分數組0-mid這部分給左邊left_arr
$right_arr = array_slice($arr, $mid);//拆分數組mid-末尾這部分給右邊right_arr
$left_arr = al_merge_sort($left_arr);//左邊拆分完後開始遞歸合併往上走
$right_arr = al_merge_sort($right_arr);//右邊拆分完畢開始遞歸往上走
$arr = al_merge($left_arr, $right_arr);//合併兩個數組,繼續遞歸
return $arr;
}
分析:歸併排序是速度僅次於快速排序的一種算法。雖然歸併排序的運行時間是O(N logN),但是它很難用於主存排序,主要問題在於合併兩個排序的表需要線性附加內存,在整個算法中還要花費將數據拷貝到臨時數組再拷貝回來這樣一些附加的工作,其結果嚴重放慢了排序的速度。不過,這種合併的思想被大量用於外部排序算法中,詳見下文 ’外部排序‘ 部分
快速排序
描述:正如他的名字一樣,快速排序是在實踐中最快的已知排序算法,他的平均運行時間是O(NlogN)。該算法之所以特別快,主要是由於非常精煉和高度優化的內部循環。它的最壞情形的性能爲O(N^2).。不過對於很小的數組(N ≤ 20),快速排序不如插入排序好,不僅如此,因爲快速排序是遞歸的,所以這樣的情形還經常發生。通常的解決辦法是對於小數組不遞歸地使用快速排序,而使用諸如插入排序這樣的對小數組有效的排序算法。一種好的截至範圍是 N = 10.
例:
ElementType
Median3(ElementType A[], int Left, int Right)
{
int Center = (Left + Right) / 2;
if (A[Left] > A[Center])
Swap(&A[Left],&A[Center]);
if (A[Left] > A[Right])
Swap(&A[Left],&A[Right]);
if (A[Center] > A[Right])
Swap(&A[Center],&A[Right]);
/** invariant: A[Left] ≤ A[Center] ≤ A[Right]*/
Swap(&A[Center],&A[Right - 1]); /** hide Pivot 防止越界*/
return A[Right - 1];
}
#define Cutoff(3)
Void
Qsort(ElementType A[], int Left, int Right)
{
int i, j;
ElementType Pivot;
if (Left + Cutoff <= Right)
{
Pivot = Median3(A, Left, Right);
i = Left; j = Right - 1;
for( ; ; ) {
while (A[++i] < Pivot){}
while (A[--j] < Pivot){}
if (i < j)
Swap(&A[i], &A[j]);
else
break;
}
Swap(&A[i], &A[Right - 1]); /** restore Pivot */
Qsort(A, Left, i - 1);
Qsort(A, i+1, Right);
} else {
InsertionSort(A + Left, Right - Left + 1); /** 使用前文的插入排序*/
}
}
//PHP版
private function QuickSort (array $arr) : array
{
$count = count($arr);
if ($count < 2){
return $arr;
}
$k = $arr[0]; //選擇第一個元素作爲基準點,實際應用中應該避免,詳見下文 '選取樞紐元' 部分
$left = [];
$right = [];
for ( $i = 1; $i < $count; $i++){
if ($arr[$i] < $k){
array_push($left,$arr[$i]);
}else{
array_push($right,$arr[$i]);
}
}
$left = $this->QuickSort($left);
$right = $this->QuickSort($right);
return array_merge($left,[$k],$right);
}
分析:像歸併排序一樣,快速排序也是一種分治的遞歸算法。將數組S排序的基本算法由下列簡單的四步組成:
1.如果S中元素個數是0或1,則返回。
2.取S中任一元素v,稱之爲樞紐元(pivot)。[樞紐元會影響算法的排序時間,詳見下文解釋]
3.將S - {v}(S中其餘元素)分成兩個不相交的集合(大於等於v的集合、小於等於v的集合)
4.返回{quicksort(S1)後,繼隨v,繼而quicksort(S2)}。
選取樞紐元
樞紐元的選擇會影響到算法排序的時間,所以一個合理的樞紐元是快速排序不可或缺的部分。最理想的情況是每一次都選擇到集合的中位數,但往往這是不現實的,因爲計算集合的中位數需要時間。雖然無論選擇哪個數作爲樞紐元都能完成排序工作,但是有些選擇顯然更優:
- 一種錯誤的做法:
通常的、沒有經過充分考慮的選擇是將第一個元素用作樞紐元。如果輸入是隨機的,那麼這種做法無可厚非。但是如果輸入是預排序的或者是反序的,那麼快速排序花費的時間將是二次的,這會使人相當尷尬,應該立即放棄這種想法。另一種方法是選擇前兩個互異的關鍵字中的較大者作爲樞紐元,不過這和選取第一個元素作爲樞紐元具有相同的害處。不要使用這兩種選擇樞紐元的策略。 - 一種安全的做法:
一種安全的做法是隨機選擇樞紐元。一把來說這種策略非常安全,除非隨機數生成器有問題(它可能不像你想象的那麼罕見),因爲隨機的樞紐元不可能總在接連不斷的產生劣質的分割。但是另一方面,隨機數的生成一般是昂貴的,根本減少不了算法其餘部分的平均運行時間。 - 三數中值分割法:
此法的原理是隨機選擇三個元素並用它們的中值作爲樞紐元。事實上,隨機性並沒有多大幫助,因此一般的做法是使用左端,右端和中心位置上的三個元素的中值作爲樞紐元。顯然使用此法消除了預排序輸入的壞情形。