各種排序算法總結

排序


Sorting

排序問題的輸入是一個線性表,該線性表的元素屬於一個偏序集;要求對該線性表的元素做某種重排,使得線性表中除表尾外的每個元素都小於等於(或大於等於)它的後繼。

R爲非空集合A上的二元關係,如果R滿足自反性(對於每一個xA(x,x)R )反對稱性((x,y)R(y,x)R→x=y )傳遞性((x,y)R(y,x)R→(x,z)R),則稱RA上的偏序關係,記作。如果(x,y)R則記作x≤y,讀作“x小於等於y”。存在偏序關係的集合A稱爲偏序集

注意,這裏的不是指數的大小,而是指在偏序關係中的順序性。x≤y的含義是:按照這個序,x排在y前面。根據不同的偏序定義,有不同的解釋。例如整除關係是偏序關係3≤6的含義是3整除6。大於或等於關係也是偏序關係,針對這個關係5≤4是指在大於或等於關係中5排在4的前面,也就是說54大。

在實際應用中,經常遇到的偏序關係是定義在一個記錄類型的數據集合上的。在該記錄類型中有一個主鍵keykey域的類型是某一個偏序集,記錄的其他域稱爲衛星數據。比較線性表中的兩個元素LiLj的大小,實際上是比較Li.keyLj.key的大小(這種比較當然也是偏序集中的比較)。舉例而言,某公司的數據庫裏記 錄了員工的數據,每一項紀錄包括姓名,編號,年齡,工資等幾個域,如果以編號爲key域對員工記錄排序,則是將員工記錄按照編號排序;如果以工資爲key域對員工記錄排序,則是將員工記錄按照工資高低排序;如果以姓名爲key域對員工記錄排序,則是以員工姓名的漢語拼音按照字典順序排序。

關於偏序集的具體概念和應用,請參見離散數學的相關資料。

如果一個排序算法利用輸入的線性表在原地重排其中元素,而沒有額外的內存開銷,這種排序算法叫做原地置換排序算法(in place sort)如果排序後並不改變表中相同的元素原來的相對位置,那麼這種排序算法叫做穩定排序算法(stable sort)

排序問題一般分爲內排序( internal sorting )外排序( external sorting )兩類:

1.            內排序:待排序的表中記錄個數較少,整個排序過程中所有的記錄都可以保留在內存中;

2.            外排序:待排序的記錄個數足夠多,以至於他們必須存儲在磁帶、磁盤上組成外部文件,排序過程中需要多次訪問外存。

排序問題的計算複雜性

對排序算法計算時間的分析可以遵循若干種不同的準則,通常以排序過程所需要的算法步數作爲度量,有時也以排序過程中所作的鍵比較次數作爲度量。特別是當作一次鍵比較需要較長時間,例如,當鍵是較長的字符串時,常以鍵比較次數作爲排序算法計算時間複雜性的度量。當排序時需要移動記錄,且記錄都很大時,還應該考慮記錄的移動次數。究竟採用哪種度量方法比較合適要根據具體情況而定。在下面的討論中我們主要考慮用比較的次數作爲複雜性的度量。

爲了對有n元素的線性表進行排序,至少必須掃描線性表一遍以獲取這n元素的信息,因此排序問題的計算複雜性下界爲Ω(n)

如果我們對輸入的數據不做任何要求,我們所能獲得的唯一信息就是各個元素的具體的值,我們僅能通過比較來確定輸入序列<a1,a2,..,an>的元素間次序。即給定兩個元素aiaj,通過測試ai<ajai≤a,ai=a,ai≥a,ai>a中的哪一個成立來確定aiaj間的相對次序。這樣的排序算法稱爲比較排序算法。下面我們討論一下比較排序算法在最壞情況下至少需要多少次比較,即比較排序算法的最壞情況複雜性下界。

我們假設每次比較只測試ai≤a,如果ai≤a成立則ai排在a前面,否則ai排在a後面。任何一個比較排序算法可以描述爲一串比較序列:

 (ai,aj),(ak,al),..,(am,an),...

表示我們首先比較(ai,aj),然後比較(ak,al)...,比較(am,an)...,直到我們獲取了足夠的信息可以確定所有元素的順序。顯而易見,如果我們對所有的元素兩兩進行一次比較的話(總共比較了Cn2),就一定可以確定所有元素的順序。但是,如果我們運氣足夠好的話,我們可能不必對所有元素兩兩進行一次比較。比如說對於有三個元素a1,a2,a3的線性表進行排序,如果我們先比較a1a2,得到a1≤a2;然後比較a2a3,得到a2≤a3;則不必比較a1a3,因爲根據偏序集的傳遞性,必有a1≤a3;但是如果a2≥a3,我們還必須比較a1a3才能確定a1a3的相對位置。如果我們適當的安排比較的次序的話,也可以減少比較的次數。這樣我們可以用一棵二叉樹表示比較的順序,如下圖所示:

該樹的每一個非葉節點表示一次比較,每一根樹枝表示一種比較結果,每一個葉節點表示一種排列順序。這樣的一棵二叉樹叫做決策樹,它用樹枝表示了每次決策做出的選擇。如此我們可以將任何一個比較排序算法用一棵決策樹來表示。

請注意上圖只表明了對三個元素的一種比較算法,這種比較算法依次比較(a1,a2)(a2,a3)(a1,a3),一旦中間某步得到足夠的信息就可以停止比較,但是當算法執行完後(三次比較後),一定可以確定三個元素間的次序。因此我們有理由將算法在最壞情況下的比較次數作爲算法複雜性的度量,對於本例該算法在最壞情況下要進行C32=3次比較。

顯然,一棵決策樹中最高葉節點的高度就是該決策樹對應的算法在最壞情況下所需的比較次數,而決策樹中最低葉節點的高度就是該決策樹對應的算法在最好情況下所需的比較次數。

我們的問題就變爲:對於任意一棵決策樹(任意一種比較排序算法),它的最高的樹葉的高度是多少?這個高度就對應於比較排序算法所需的最多比較次數(在運氣最壞的情況下);換句話說,對於任何一個輸入,該算法至少需要比較多少次就可以對元素進行排序。

我們發現,決策樹的每個葉節點對應一個n元素的排列,其中可能有重複的;但是由於決策樹表明了所有可能遇到的情況,因而n元素的所有排列都在決策樹中出現過。n元素共有n!種排列,即決策樹的葉節點數目至少n!。又因爲一棵高度爲h的二叉樹(指二叉樹的最高樹葉高度爲h)的葉節點數目最多爲2h(這時正好是滿二叉樹,即每個非葉節點都有兩個子節點),因此n!≤2h,得到h≥log(n!),其中log2爲底。根據Stirling公式有n!>(n/e)n,於是h>nlogn-nloge,即h=Ω(nlogn)

這樣我們就證明了對於任意一種利用比較來確定元素間相對位置的排序算法,其最壞情況下複雜性爲Ω(nlogn)

在下文中我們將討論幾種比較排序算法,其中快速排序在平均情況下複雜性爲O(nlogn),最壞情況下複雜性爲O(n2);堆排序和合並排序在最壞情況下複雜性爲O(nlogn)因此堆排序和合並排序是漸進最優的比較排序算法。

排序算法是否還能夠改進呢?從前文我們知道,如果要改進排序算法的效率,就不能只利用比較來確定元素間相對位置。因此我們還需要知道元素的其他附加信息,光知道元素的大小信息是不夠的。下文中我們介紹的計數排序,基數排序和桶排序是具有線性時間複雜性的排序算法,這些算法無一例外地對輸入數據作了某些附加限制,從而增加已知的信息,因此可以不通過比較來確定元素間的相對位置。

比較排序算法

通過比較來確定輸入序列<a1,a2,..,an>的元素間相對次序的排序算法稱爲比較排序算法。

在下面討論的排序算法中,冒泡排序、選擇排序和插入排序的比較次數爲O(n2),快速排序在平均情況下複雜性爲O(nlogn),堆排序和合並排序在最壞情況下複雜性爲O(nlogn)。可見,合併排序和堆排序是比較排序算法中時間複雜度最優算法。

§     冒泡排序

§     選擇排序

§     插入排序

§     快速排序

§     合併排序

§     Shell排序

§     堆排序

冒泡排序 Bubble Sort

最簡單的排序方法是冒泡排序方法。這種方法的基本思想是,將待排序的元素看作是豎着排列的氣泡,較小的元素比較輕,從而要往上浮。在冒泡排序算法中我們要對這個氣泡序列處理若干遍。所謂一遍處理,就是自底向上檢查一遍這個序列,並時刻注意兩個相鄰的元素的順序是否正確。如果發現兩個相鄰元素的順序不對,即的元素在下面,就交換它們的位置。顯然,處理一遍之後,最輕的元素就浮到了最高位置;處理二遍之後,次輕的元素就浮到了次高位置。在作第二遍處理時,由於最高位置上的元素已是最輕元素,所以不必檢查。一般地,第i遍處理時,不必檢查第i高位置以上的元素,因爲經過前面i-1遍的處理,它們已正確地排好序。這個算法可實現如下。

procedure Bubble_Sort(var L:List);
var
i,j:position;
begin
1 for i:=First(L) to Last(L)-1 do
2  for j:=First(L) to Last(L)-i do
3     if L[j]>L[j+1] then 
4           swap(L[j],L[j+1]);   //交換L[j]和L[j+1]
end;

上述算法將較大的元素看作較重的氣泡,每次最大的元素沉到表尾。其中First(L)Last(L)分別表示線性表L的第一個元素和最後一個元素的位置,swap(x,y)交換變量x,y的值。上述算法簡單地將線性表的位置當作整數用for循環來處理,但實際上線性表可能用鏈表實現;而且上述算法將線性表元素的值當作其鍵值進行處理。不過這些並不影響表達該算法的基本思想。今後如果不加說明,所有的算法都用這種簡化方式表達。

容易看出該算法總共進行了n(n-1)/2次比較。如果swap過程消耗的時間不多的話,主要時間消耗在比較上,因而時間複雜性爲O(n2)。但是如果元素類型是一個很大的紀錄,則Swap過程要消耗大量的時間,因此有必要分析swap執行的次數。

顯然算法Bubble_Sort在最壞情況下調用n(n-1)/2Swap過程。我們假設輸入序列的分佈是等可能的。考慮互逆的兩個輸入序列L1=k1,k2,..,kn和L2=kn,kn-1,..,k1。我們知道,如果ki>kj,且ki在表中排在kj前面,則在冒泡法排序時必定要將kj換到ki前面,即kj向前浮的過程中一定要穿過一次ki,這個過程要調用一次Swap。對於任意的兩個元素kikj,不妨設ki>kj,或者在L1ki排在kj前面,或者L2在中ki排在kj前面,兩者必居其一。因此對於任意的兩個元素kikj,在對L1L2排序時,總共需要將這兩個元素對調一次。n元素中任取兩個元素有Cn種取法,因此對於兩個互逆序列進行排序,總共要調用Cn=n(n-1)/2Swap,平均每個序列要調用n(n-1)/4Swap。那麼算法Bubble_Sort調用Swap的平均次數爲n(n-1)/4

可以對冒泡算法作一些改進,如果算法第二行的某次內循環沒有進行元素交換,則說明排序工作已經完成,可以退出外循環。可以用一個布爾變量來記錄內循環是否進行了記錄交換,如果沒有則終止外循環。

冒泡法的另一個改進版本是雙向掃描冒泡法(Bi-Directional Bubble Sort)。設被排序的表中各元素鍵值序列爲:

483 67 888 50 255 406 134 592 657 745 683

對該序列進行3次掃描後會發現,第3此掃描中最後一次交換的一對紀錄是L[4]L[5]

50 67 255 134 | 406 483 592 657 683 745 888

顯然,第3次掃描(i=3)結束後L[5]以後的序列都已經排好序了,所以下一次掃描不必到達Last(L)-i=11-4=7,即第2行的for 循環j不必到達7,只要到達4-1=3就可以了。按照這種思路,可以來回地進行掃描,即先從頭掃到尾,再從尾掃到頭。這樣就得到雙向冒泡排序算法:

procedure Bi-Directional_Bubble_Sort(var L:List);
var
low,up,t,i:position;
begin
1  low:=First(L);up:=Last(L);
2  while up>low do
    begin
3     t:=low;
4     for i:=low to up-1 do
5       if L[i]>L[i+1] then
          begin
6           swap(L[i],L[i+1]);
7           t:=i;
          end;
8     up:=t;
9     for i:=up downto low+1 do
10      if L[i]< L[i-1] then
          begin
11          swap(L[i],L[i-1]);
12          t:=i;
          end;
13    low:=t;   
    end; 
end;

算法利用兩個變量lowup記錄排序的區域L[low..up],用變量記錄最近一次交換紀錄的位置,4-7行從前向後掃描,9-12行從後向前掃描,每次掃描以後利用t所記錄的最後一次交換記錄的位置,並不斷地縮小需要排序的區間,直到該區間只剩下一個元素。

直觀上來看,雙向冒泡法先讓重的氣泡沉到底下,然後讓輕的氣泡上來,然後再讓較大氣泡沉下去,讓較輕氣泡上來,依次反覆,直到排序結束。

雙向冒泡排序法的性能分析比較複雜,目前暫缺,那位朋友知道請告訴我

冒泡排序法和雙向冒泡排序法是原地置換排序法,也是穩定排序法,如果算法Bubble_Sort中第3行的比較條件L[j]>L[j+1]改爲L[j]>= L[j+1],則不再是穩定排序法

選擇排序 Selection Sort

選擇排序的基本思想是對待排序的記錄序列進行n-1遍的處理,第i遍處理是將L[i..n]中最小者與L[i]交換位置。這樣,經過i遍處理之後,前i記錄的位置已經是正確的了。

選擇排序算法可實現如下。

procedure Selection_Sort(var L:List);
var
i,j,s:position;
begin
1  for i:=First(L) to Last(L)-1 do
    begin
2        s:=i;
3        for j:=i+1 to Last(L) do
4          if L[j]< L[s] then 
5                  s:=j;             //記錄L[i..n]中最小元素的位置
6        swap(L[i],L[s]);       //交換L[i],L[s]
       end
end;

算法Selection_Sort中裏面的一個for循環需要進行n-i次比較,所以整個算法需要

次比較。

顯而易見,算法Selection_Sort中共調用了n-1swap過程。選擇排序法是一個原地置換排序法,也是穩定排序法

插入排序 Insertion Sort

插入排序的基本思想是,經過i-1遍處理後,L[1..i-1]己排好序。第i遍處理僅L[i]插入L[1..i-1]的適當位置,使得L[1..i]又是排好序的序列。要達到這個目的,我們可以用順序比較的方法。首先比較L[i]L[i-1],如果L[i-1]≤ L[i],則L[1..i]已排好序,第i遍處理就結束了;否則交換L[i]L[i-1]的位置,繼續比較L[i-1]L[i-2],直到找到某一個位置j(1≤j≤i-1),使得L[j] ≤L[j+1]時爲止。圖1演示了對4個元素進行插入排序的過程,共需要(a),(b),(c)三次插入。

4個元素進行插入排序

在下面的插入排序算法中,爲了寫程序方便我們可以引入一個哨兵元素L[0],它小於L[1..n]中任記錄。所以,我們設元素的類型ElementType中有一個常量-∞,它比可能出現的任何記錄都小。如果常量-∞不好事先確定,就必須在決定L[i]是否向前移動之前檢查當前位置是否爲1若當前位置已經爲1時就應結束第i遍的處理。另一個辦法是在第i遍處理開始時,就將L[i]放入L[0]中,這樣也可以保證在適當的時候結束第i遍處理。下面的算法中將對當前位置進行判斷。

插入排序算法如下:

procedure Selection_Sort(var L:List);
var
i,j:position;
v:ElementType;
begin
1 for i:=First(L)+1 to Last(L) do
    begin
2     v:=L[i];
3     j:=i;
4     while (j<>First(L))and(L[j-1]< v) do  //循環找到插入點
        begin
5         L[j]:=L[j-1];  //移動元素
6         j:=j-1;
        end;
7     L[j]:=v;    //插入元素
    end;
end;

下面考慮算法Insertion_Sort的複雜性。對於確定的i,內while循環的次數爲O(i),所以整個循環體內執行了O(i)=O(∑i),其中i2n即比較次數爲O(n2)。如果輸入序列是從大到小排列的,那麼內while循環次數爲i-1次,所以整個循環體執行了∑(i-1)=n(n-1)/2次。由此可知,最壞情況下,Insertion_Sort要比較Ω(n2)次。

如果元素類型是一個很大的紀錄,則算法第5行要消耗大量的時間,因此有必要分析移動元素的次數。經過分析可知,平均情況下第5行要執行n(n-1)/4次,分析方法與冒泡排序的分析相同。

如果移動元素要消耗大量的時間,則可以用鏈表來實現線性表,這樣Insertion_Sort可以改寫如下(當然前一個算法同樣也適用於鏈表,只不過沒下面這個好,但是下面算法這個比較複雜)

注意:在下面的算法中鏈表L增加了一個哨兵單元,其中的元素爲-∞,即線性表L的第一個元素是L^.next^

procedure Selection_Sort_II(var L:PList);
var
i,j,tmp:Position;
begin
1  if L^.next=nil then exit; //如果鏈表L爲空則直接退出
2  i:=L^.next;  //i指向L的第一個元素,注意,L有一個哨兵元素,因此L^.next^纔是L的第一個元素
3  while i^.next<>nil do
     begin
4      tmp:=i^.next;  //tmp指向L[i]的下一個位置
5      j:=L;
6      while (j<>i)and(tmp^.data>=j^.next^.data) do //從前向後找到tmp的位置,tmp應該插在j後面
7      j:=j^.next;
8      if j<>i then  //j=i說明不需要改變tmp的位置
         begin                             
9          i^.next:=tmp^.next;  //將tmp從i後面摘除
10         tmp^.next:=j^.next;  //在j後面插入tmp
11         j^.next:=tmp;
         end
12     else i:=i^.next;  //否則i指向下一個元素
    end;
end;

上述改進算法主要是利用鏈表刪除和插入元素方便的特性,對於數組則不適用。

插入排序法是一個原地置換排序法,也是一個穩定排序法。插入法雖然在最壞情況下複雜性爲θ(n2),但是對於小規模輸入來說,插入排序法是一個快速的原地置換排序法。許多複雜的排序法,在規模較小的情況下,都使用插入排序法來進行排序,比如快速排序和桶排序

快速排序 Quick Sort

我們已經知道,在決策樹計算模型下,任何一個基於比較來確定兩個元素相對位置的排序算法需要Ω(nlogn)計算時間。如果我們能設計一個需要O(n1ogn)時間的排序算法,則在漸近的意義上,這個排序算法就是最優的。許多排序算法都是追求這個目標。

下面介紹快速排序算法,它在平均情況下需要O(nlogn)時間。這個算法是由C.A.R.Hoare發明的。

算法的基本思想

快速排序的基本思想是基於分治策略的。對於輸入的子序列L[p..r],如果規模足夠小則直接進行排序,否則分三步處理:

§ 分解(Divide):將輸入的序列L[p..r]劃分成兩個非空子序列L[p..q]L[q+1..r],使L[p..q]中任元素的值不大於L[q+1..r]中任元素的值。

§ 遞歸求解(Conquer):通過遞歸調用快速排序算法分別對L[p..q]L[q+1..r]進行排序。

§ 合併(Merge):由於對分解出的兩個子序列的排序是就地進行的,所以在L[p..q]L[q+1..r]都排好序後不需要執行任何計算L[p..r]就已排好序。

這個解決流程是符合分治法的基本步驟的。因此,快速排序法是分治法的經典應用實例之一。

算法的實現

算法Quick_Sort的實現:

注意:下面的記號L[p..r]代表線性表L從位置p到位置r的元素的集合,但是L並不一定要用數組來實現,可以是用任何一種實現方法(比如說鏈表),這裏L[p..r]只是一種記號。

procedure Quick_Sort(p,r:position;var L:List);
const
e=12;
var
q:position;
begin
1  if r-p<=e then Insertion_Sort(L,p,r)//若L[p..r]足夠小則直接對L[p..r]進行插入排序
     else begin
2            q:=partition(p,r,L);//將L[p..r]分解爲L[p..q]和L[q+1..r]兩部分
3            Quick_Sort(p,q,L);  //遞歸排序L[p..q]
4            Quick_Sort(q+1,r,L);//遞歸排序L[q+1..r]          
          end;
end;

對線性表L[1..n]進行排序,只要調用Quick_Sort(1,n,L)就可以了。算法首先判斷L[p..r]是否足夠小,若足夠小則直接對L[p..r]進行排序,Sort可以是任何一種簡單的排序法,一般用插入排序。這是因爲,對於較小的表,快速排序中劃分和遞歸的開銷使得該算法的效率還不如其它的直接排序法好。至於規模多小纔算足夠小,並沒有一定的標準,因爲這跟生成的代碼和執行代碼的計算機有關,可以採取試驗的方法確定這個規模閾值。經驗表明,在大多數計算機上,取這個閾值爲12較好,也就是說,當r-p<=e=12L[p..r]的規模不大於12時,直接採用插入排序法對L[p..r]進行排序(參見 Sorting and Searching Algorithms: A Cookbook)。當然,比較方便的方法是取該閾值1當待排序表只有一個元素時,根本不用排序(其實還剩兩個元素時就已經在Partition函數中排好序了),只要把第1行的if語句該爲if p=r then exit else ...。這就是通常教科書上看到的快速排序的形式。

注意:算法Quick_Sort中變量q值一定不能等於r,否則該過程會無限遞歸下去,永遠不能結束。因此下文中在partition函數里加了限制條件,避免q=r情況的出現。

算法Quick_Sort中調用了一個函數partition,該函數主要實現以下兩個功能:

1.     L[p..r]中選擇一個支點元素pivot;

2.     L[p..r]中的元素進行整理,使得L[p..q]分爲兩部分L[p..q]L[q+1..r],並且L[p..q]中的每一個元素的值不大於pivotL[q+1..r]中的每一個元素的值不小於pivot但是L[p..q]L[q+1..r]中的元素並不要求排好序

快速排序法改進性能的關鍵就在於上述的第二個功能,因爲該功能並不要求L[p..q]L[q+1..r]中的元素排好序。

函數partition可以實現如下。以下的實現方法是原地置換的,當然也有不是原地置換的方法,實現起來較爲簡單,這裏就不介紹了。

function partition(p,r:position;var L:List):position;
var
pivot:ElementType;
i,j:position;
begin
1  pivot:=Select_Pivot(p,r,L); //在L[p..r]中選擇一個支點元素pivot
2  i:=p-1;
3  j:=r+1;
4  while true do
     begin
5      repeat j:=j-1 until L[j]<=pivot;  //移動左指針,注意這裏不能用while循環
6      repeat i:=i+1 until L[i]>=pivot;  //移動右指針,注意這裏不能用while循環
7      if i< j then swap(L[i],L[j])  //交換L[i]和L[j]
8              else if j<>r then return j        //返回j的值作爲分割點
9                           else return j-1;     //返回j前一個位置作爲分割點
     end;
end;

該算法的實現很精巧。其中,有一些細節需要注意。例如,算法中的位置ij不會超出A[p..r]的位置界,並且該算法的循環不會出現死循環,如果將兩個repeat語句換爲while則要注意當L[i]=L[j]=pivoti<jij的值都不再變化,會出現死循環。

另外,最後一個if..then..語句很重要,因爲如果pivot取的不好,使得Partition結束時j正好等於r,則如前所述,算法Quick_Sort會無限遞歸下去;因此必須判斷j是否等於r,若j=r則返回j的前驅。

以上算法的一個執行實例如圖1所示,其中pivot=L[p]=5

1 Partition過程的一個執行實例

PartitionL[p..r]進行劃分時,以pivot作爲劃分的基準,然後分別從左、右兩端開始,擴展兩個區域L[p..i]L[j..r],使得L[p..i]中元素的值小於或等於pivot,而L[j..r]中元素的值大於或等於pivot。初始時i=p-1,且j=i+1,從而這兩個區域是空的。在while循環體中,位置j逐漸減小,i逐漸增大,直到L[i]≥pivot≥L[j]。如果這兩個不等式是嚴格的,則L[i]不會是左邊區域的元素,而L[j]不會是右邊區域的元素。此時若ij之前,就應該交換L[i]L[j]的位置,擴展左右兩個區域。 while循環重複至i不再j之前時結束。這時L[p..r]己被劃分成L[p..q]L[q+1..r],且滿足L[p..q]中元素的值不大於L[q+1..r]中元素的值。在過程Partition結束時返回劃分點q

尋找支點元素select_pivot有多種實現方法,不同的實現方法會導致快速排序的不同性能。根據分治法平衡子問題的思想,我們希望支點元素可以使L[p..r]儘量平均地分爲兩部分,但實際上這是很難做到的。下面我們給出幾種尋找pivot的方法。

1.     選擇L[p..r]的第一個元素L[p]的值作爲pivot

2.     選擇L[p..r]的最後一個元素L[r]的值作爲pivot

3.     選擇L[p..r]中間位置的元素L[m]的值作爲pivot

4.     選擇L[p..r]的某一個隨機位置上的值L[random(r-p)+p]的值作爲pivot

按照第4種方法隨機選擇pivot的快速排序法又稱爲隨機化版本的快速排序法,在下面的複雜性分析中我們將看到該方法具有平均情況下最好的性能,在實際應用中該方法的性能也是最好的。

性能分析

下面我們就最好情況,最壞情況和平均情況對快速排序算法的性能作一點分析。

注意:這裏爲方便起見,我們假設算法Quick_Sort範圍閾值1(即一直將線性表分解到只剩一個元素),這對該算法複雜性的分析沒有本質的影響。

我們先分析函數partition的性能,該函數對於確定的輸入複雜性是確定的。觀察該函數,我們發現,對於有n元素的確定輸入L[p..r],該函數運行時間顯然爲θ(n)

最壞情況

無論適用哪一種方法來選擇pivot,由於我們不知道各個元素間的相對大小關係(若知道就已經排好序了),所以我們無法確定pivot的選擇對劃分造成的影響。因此對各種pivot選擇法而言,最壞情況和最好情況都是相同的。

我們從直覺上可以判斷出最壞情況發生在每次劃分過程產生的兩個區間分別包含n-1個元素和1個元素的時候(設輸入的表有n元素)。下面我們暫時認爲該猜測正確,在後文我們再詳細證明該猜測。

對於有n元素的表L[p..r],由於函數Partition的計算時間爲θ(n),所以快速排序在序壞情況下的複雜性有遞歸式如下:

T(1)=θ(1), T(n)=T(n-1)+T(1)+θ(n)             (1)

用迭代法可以解出上式的解爲T(n)=θ(n2)

這個最壞情況運行時間與插入排序是一樣的。

下面我們來證明這種每次劃分過程產生的兩個區間分別包含n-1個元素和1個元素的情況就是最壞情況。

T(n)是過程Quick_Sort作用於規模爲n的輸入上的最壞情況的時間,則

T(n)=max(T(q)+T(n-q))+θ(n) ,其中1≤q≤n-1    (2)

我們假設對於任何k<n,總有T(k)≤ck,其中c爲常數;顯然當k=1時是成立的。

將歸納假設代入(2),得到:

T(n)≤max(cq2+c(n-q)2)+θ(n)=c*max(q2+(n-q)2)+θ(n)

因爲在[1,n-1]q2+(n-q)2關於q遞減,所以當q=1q2+(n-q)2有最大值n2-2(n-1)。於是有:

T(n)≤cn2-2c(n-1)+θ(n)≤cn2

只要c足夠大,上面的第二個小於等於號就可以成立。於是對於所有的n都有T(n)≤cn

這樣,排序算法的最壞情況運行時間爲θ(n2),且最壞情況發生在每次劃分過程產生的兩個區間分別包含n-1個元素和1個元素的時候。

最好情況

如果每次劃分過程產生的區間大小都爲n/2,則快速排序法運行就快得多了。這時有:

T(n)=2T(n/2)+θ(n), T(1)=θ(1)     (3)

解得: T(n)=θ(nlogn)

快速排序法最佳情況下執行過程的遞歸樹如下圖所示,圖中lgn表示以2位底的對數,而本文中用logn表示以2位底的對數.

2  快速排序法最佳情況下執行過程的遞歸樹

 

由於快速排序法也是基於比較的排序法,其運行時間爲Ω(nlogn),所以如果每次劃分過程產生的區間大小都爲n/2,則運行時間θ(nlogn)就是最好情況運行時間。

但是,是否一定要每次平均劃分才能達到最好情況呢?要理解這一點就必須理解對稱性是如何在描述運行時間的遞歸式中反映的。我們假設每次劃分過程都產生9:1的劃分,乍一看該劃分很不對稱。我們可以得到遞歸式:

T(n)=T(n/10)+T(9n/10)+θ(n) , T(1)=θ(1)         (4)

這個遞歸式對應的遞歸樹如下圖所示:

3  (4)式對應的遞歸樹

 

請注意該樹的每一層都有代價n,直到在深度log10n=θ(logn)處達到邊界條件, 以後各層代價至多爲n。遞歸於深度log10/9n=θ(logn)處結束。這樣,快速排序的總時間代價爲T(n)=θ(nlogn),從漸進意義上看就和劃分是在中間進行的一樣。事實上,即使是99:1的劃分時間代價也爲θ(nlogn)。其原因在於,任何一種按常數比例進行劃分所產生的遞歸樹的深度都爲θ(nlogn),其中每一層的代價爲O(n),因而不管常數比例是什麼,總的運行時間都爲θ(nlogn),只不過其中隱含的常數因子有所不同。(關於算法複雜性的漸進階,請參閱算法的複雜性)

平均情況

我們首先對平均情況下的性能作直覺上的分析。

要想對快速排序的平均情況有個較爲清楚的概念,我們就要對遇到的各種輸入作個假設。通常都假設輸入數據的所有排列都是等可能的。後文中我們要討論這個假設。

當我們對一個隨機的輸入數組應用快速排序時,要想在每一層上都有同樣的劃分是不太可能的。我們所能期望的是某些劃分較對稱,另一些則很不對稱。事實上,我們可以證明,如果選擇L[p..r]的第一個元素作爲支點元素,Partition所產生的劃分80%以上都比9:1更對稱,而另20%則比9:1差,這裏證明從略。

平均情況下,Partition產生的劃分中既有好的,又有差的。這時,與Partition執行過程對應的遞歸樹中,好、差劃分是隨機地分佈在樹的各層上的。爲與我們的直覺相一致,假設好、差劃分交替出現在樹的各層上,且好的劃分是最佳情況劃分,而差的劃分是最壞情況下的劃分,圖4(a)表示了遞歸樹的連續兩層上的劃分情況。在根節點處,劃分的代價爲n,劃分出來的兩個子表的大小爲n-11,即最壞情況。在根的下一層,大小爲n-1子表按最佳情況劃分成大小各爲(n-1)/2的兩個子表。這兒我們假設含1個元素的子表的邊界條件代價爲1

(a)

(b)

快速排序的遞歸樹劃分中的兩種情況

在一個差的劃分後接一個好的劃分後,產生出三個子表,大小各爲1(n-1)/2(n-1)/2,代價共爲2n-1=θ(n)。這與圖4(b)中的情況差不多。該圖中一層劃分就產生出大小爲(n-1)/2+1(n-1)/2的兩個子表,代價爲n=θ(n)。這種劃分差不多是完全對稱的,比9:1的劃分要好。從直覺上看,差的劃分的代價θ(n)可被吸收到好的劃分的代價θ(n)中去,結果是一個好的劃分。這樣,當好、差劃分交替分佈劃分都是好的一樣:仍是θ(nlogn),但θ記號中隱含的常數因子要略大一些。關於平均情況的嚴格分析將在後文給出。

在前文從直覺上探討快速排序的平均性態過程中,我們已假定輸入數據的所有排列都是等可能的。如果輸入的分佈滿足這個假設時,快速排序是對足夠大的輸入的理想選擇。但在實際應用中,這個假設就不會總是成立。

解決的方法是,利用隨機化策略,能夠克服分佈的等可能性假設所帶來的問題。

一種隨機化策略是:與對輸入的分佈作假設不同的是對輸入的分佈作規定。具體地說,在排序輸入的線性表前,對其元素加以隨機排列,以強制的方法使每種排列滿足等可能性。事實上,我們可以找到一個能在O(n)時間內對含n元素的數組加以隨機排列的算法。這種修改不改變算法的最壞情況運行時間,但它卻使得運行時間能夠獨立於輸入數據已排序的情況。

另一種隨機化策略是:利用前文介紹的選擇支點元素pivot的第四種方法,即隨機地在L[p..r]中選擇一個元素作爲支點元素pivot。實際應用中通常採用這種方法。

快速排序的隨機化版本有一個和其他隨機化算法一樣的有趣性質:沒有一個特別的輸入會導致最壞情況性態。這種算法的最壞情況性態是由隨機數產生器決定的。你即使有意給出一個壞的輸入也沒用,因爲隨機化排列會使得輸入數據的次序對算法不產生影響。只有在隨機數產生器給出了一個很不巧的排列時,隨機化算法的最壞情況性態纔會出現。事實上可以證明幾乎所有的排列都可使快速排序接近平均情況性態,只有非常少的幾個排列纔會導致算法的近最壞情況性態。

一般來說,當一個算法可按多條路子做下去,但又很難決定哪一條保證是好的選擇時,隨機化策略是很有用的。如果大部分選擇都是好的,則隨機地選一個就行了。通常,一個算法在其執行過程中要做很多選擇。如果一個好的選擇的獲益大於壞的選擇的代價,那麼隨機地做一個選擇就能得到一個很有效的算法。我們在前文已經瞭解到,對快速排序來說,一組好壞相雜的劃分仍能產生很好的運行時間。因此我們可以認爲該算法的隨機化版本也能具有較好的性態。

在前文我們從直覺上分析了快速排序在平均情況下的性能爲θ(nlogn),我們將在下面定量地分析快速排序法在平均情況下的性能。爲了滿足輸入的數據的所有排列都是等可能的這個假設,我們採用上面提到的隨機選擇pivot的方法,並且在Select_pivot函數中將選出的pivotL[p]交換位置(這不是必需的,純粹是爲了下文分析的方便,這樣L[p]就是支點元素pivot)。那種基於對輸入數據加以隨機排列的隨機化算法的平均性態也很好,只是比這兒介紹的這個版本更難以分析。

我們先來看看Partition的執行過程。爲簡化分析,假設所有輸入數據都是不同的。即使這個假設不滿足,快速排序的平均情況運行時間仍爲θ(nlogn),但這時的分析就要複雜一些。

Partition返回的值q僅依賴於pivotL[p..r]中的(rank),某個數在一個集合中的是指該集合中小於或等於該數的元素的個數。如果設nL[p..r]的元素個數,將L[p]L[p..r]中的一個隨機元素pivot交換就得rank(pivot)=i(i=1,2,..,n)的概率爲l/n

下一步來計算劃分過程不同結果的可能性。如果rank(pivot)=1,即pivotL[p..r]中最小的元素,則Partition的循環結束時指針i停在i=p處,指針j停在k=p處。當返回q時,劃分結果的"低區"中就含有唯一的元素L[p]=pivot。這個事件發生的概率爲1/n,因爲rank(pivot)=i的概率爲1/n

如果rank(pivot)≥2,則至少有一個元素小於L[p],故在外循環while循環的第一次執行中,指針i停於i=p處,指針j則在達到p之前就停住了。這時通過交換就可將L[p]置於劃分結果的高區中。當Partition結束時,低區的rank(pivot)-1個元素中的每一個都嚴格小於pivot(因爲假設輸入的元素不重複)。這樣,對每個i=1,2,..,n-1,當rank(pivot)≥2時,劃分的低區中含i個元素的概率爲 l/n

把這兩種情況綜合起來,我們的結論爲:劃分的低區的大小爲1的概率爲2/n低區大小i的概率爲1/ni=2,3,..n-1

現在讓我們來對Quick_Sort的期望運行時間建立一個遞歸式。設T(n)表示排序含n元素的表所需的平均時間,則:

   5

其中T(1)=θ(1)

q的分佈基本上是均勻的,但是q=1的可能性是其他值的兩倍。根據前面作的最壞情況的分析有:

T(1)=θ(1),T(n-1)=θ(n2),所以

這可被(5)式中的θ(n)所吸收,所以(5)式可簡化爲:

           (6)

注意對k=1,2,..,n-1,和式中每一項T(k)T(q)T(n-q)的機會各有一次,把這兩項迭起來有:

                        (7)

 

我們用代入法解上述遞歸方程。歸納假設T(n)≤a*nlogn+b,其中a>0,b>0爲待定常數。可以選擇足夠大的a,b使anlogn+b>T(1),對於n>1有:

          8

下面我們來確定和式

                    9

的界。

因爲和式中每一項至多是nlogn則有界

這是個比較緊的界,但是對於解遞歸式(8)來說還不夠強。爲解該遞歸式,我們希望有界

爲了得到這個界,可以將和式(9)分解爲兩部分,這時有:

等號右邊的第一個和式中的logk可由log(n/2)=logn-1從上方限界。第二個和式中的logk可由logn從上方限界,這樣,

對於n≥2成立。即:

     (10)

(10)代入(8)式得

      11

 

因爲我們可以選擇足夠大的a使a*n/4能夠決定θ(n)+b,所以快速排序的平均運行時間爲θ(nlogn)

計數排序 Counting Sort

計數排序是一個非基於比較的線性時間排序算法。它對輸入的數據有附加的限制條件:

1. 輸入的線性表的元素屬於有限偏序集S

2. 設輸入的線性表的長度爲n|S|=k(表示集合S中元素的總數目爲k),則k=O(n)

在這兩個條件下,計數排序的複雜性爲O(n)

計數排序算法的基本思想是對於給定的輸入序列中的每一個元素x,確定該序列中值小於x的元素的個數。一旦有了這個信息,就可以將x直接存放到最終的輸出序列的正確位置上。例如,如果輸入序列中只有17個元素的值小於x的值,則x可以直接存放在輸出序列的第18個位置上。當然,如果有多個元素具有相同的值時,我們不能將這些元素放在輸出序列的同一個位置上,因此,上述方案還要作適當的修改。

假設輸入的線性表L的長度爲nL=L1,L2,..,Ln;線性表的元素屬於有限偏序集S|S|=kk=O(n)S={S1,S2,..Sk};則計數排序算法可以描述如下:

1.             掃描整個集合S,對每一個SiS,找到在線性表L中小於等於Si的元素的個數T(Si)

2.             掃描整個線性表L,對L中的每一個元素Li,將Li放在輸出線性表的第T(Li)位置上,並將T(Li)1

具體的實現如下。

注意:在以下的討論中,爲了方便,我們假設線性表是用數組來實現的,並且假設線性表的元素類型TElement爲整型,其值在1..k之間,線性表的長度爲n,且k=O(n)。這些假設對計數排序算法沒有實質的影響,但是可以使以下介紹的算法看起來容易理解。

在下面的計數排序算法中,我們假設L爲輸入的長度爲n的線性表,輸出的排序結果存放在線性表R中。算法中還用到一個輔助表tmp用於對輸入元素進行計數。

Type
TElement=1..k;
TList=array [1..maxlength] of TElement;
TPosition=integer;
 
procedure Counting_Sort(var L,R:TList);
var
i,j:integer;
tmp:TList;
begin
1 for i:=1 to k do tmp[i]:=0; 
2 for j:=1 to n do inc(tmp[L[j]]);
   //執行完上面的循環後,tmp[i]的值是L中等於i的元素的個數
3 for i:=2 to k do tmp[i]:=tmp[i]+tmp[i-1];
   //執行完上面的循環後,tmp[i]的值是L中小於等於i的元素的個數
4 for j:=n downto 1 do  //注意這裏的downto保證了排序的穩定性
   begin
  R[tmp[L[j]]]:=L[j];//L[j]存放在輸出數組R的第tmp[L[j]]位置上
  dec(tmp[L[j]]); //tmp[L[j]]表示L中剩餘的元素中小於等於L[j]的元素的個數
   end;
end;

1所示的是Counting_Sort作用於一個輸入數組L[1..8]上的過程,其中L的每一個元素都是不大於k=6的正整數。

計數排序算法演示

容易理解,算法的第(l)行是對數組tmp初始化。第(2)行檢查每個輸入元素。如果輸入元素的鍵值爲i,則tmp[i]1。因此,在第(2)行執行結束後,tmp[i]存放着值等於i的輸入元素個數,i=1,2,..,k。算法的第(3)行,對每個i=1,2,..,i,統計值小於或等於i的輸入元素個數。最後在(4)-(8)行中,將每個元素L[j]存放到輸出數組R中相應的最終位置上。如果所有n元素的值都不相同,則共有tmp[L[j]]元素的鍵值小於或等於L[j],而小於L[j]的元素有tmp[L[j]]-1個,因此tmp[L[j]]就是L[j]在輸出數組R中的正確位置。當輸入元素有相同的值時,每將一個L[j]存放到數組R時,tmp[L[j]]就減1,使下
值等於L[j]的元素存放在輸出數組R中存放元素L[j]的前一個位置上。

計數排序算法的計算時間複雜性很容易分析。其中,第(1)行需要O(k)時間;第(2)行需要O(n)時間,第(3)行需要O(k)時間;第(4)-(8)行的for循環需要O(n)時間。這樣,整個算法所需的計算間爲O(n+k)。當k=O(n)時,算法的計算時間複雜性爲O(n)

我們看到,計數排序算法沒有用到元素間的比較,它利用元素的實際值來確定它們在輸出數組中的位置。因此,計數排序算法不是一個基於比較的排序算法,從而它的計算時間下界不再是Ω(nlogn)。另一方面,計數排序算法之所以能取得線性計算時間的上界是因爲對元素的取值範圍作了一定限制,即k=O(n)。如果k=n2,n3,..,就得不到線性時間的上界。此外,我們還看到,由於算法第4行使用了downto語句,經計數排序,輸出序列中值相同的元素之間的相對次序與他們在輸入序列中的相對次序相同,換句話說,計數排序算法是一個穩定的排序算法,但顯然不是原地置換排序算法

基數排序 Radix Sort

基數排序是一種用在老式穿卡機上的算法。一張卡片有80列,每列可在12個位置中的任一處穿孔。排序器可被機械地"程序化"以檢查每一迭卡片中的某一列,再根據穿孔的位置將它們分放12個盒子裏。這樣,操作員就可逐個地把它們收集起來。其中第一個位置穿孔的放在最上面,第二個位置穿孔的其次,等等。

對十進制數字來說,每列中只用到10個位置(另兩個位置用於編碼非數值字符)。一個d位數佔用d列。因爲卡片排序器一次只能查看一個列,要對n張片上d位數進行排序就要有個排序算法。

直感上,大家可能覺得應該按最重要的一位排序,然後對每個盒子中的數遞歸地排序,最後把結果合併起來。不幸的是,爲排序每一個盒子中的數,10個盒子中的9個必須先放在一邊,這個過程產生了許多要加以記錄的中間卡片堆。

與人們的直感相反,基數排序是首先按最不重要的一位數字排序來解決卡片排序問題的。同樣,把各堆卡片收集成一迭,其中0號盒子中的在1號盒子中的前面,後者又在2號盒子中的前面,等等。然後對整個一迭卡片按次重要位排序,並把結果同樣地合併起來。重複這個過程,直到對所有的d位數字都進行了排序。所以,僅需要n遍就可將一迭卡片排好序。圖1說明了基數排序作一迭”7個三位數的過程。第一列爲輸入,其餘各列示出了對各個數位進行逐次排序後表的情形。垂直向上的箭頭指示了當前要被加以排序的數位。

  

基數排序作用於一個由73位數組成的表上的過程

關於這個算法很重要的一點就是按位排序要穩定。由卡片排序器所故的排序是穩定的,但操作員在把卡片從盒子裏拿出來時不能改變他們的次序,即使某一盒子中所有卡片在給定列上的穿孔位置都相同。

在一臺典型的順序隨機存取計算機上,有時採用基數排序來對有多重域關鍵字的記錄進行排序。例如,假設我們想根據三個關鍵字處、月和日來對日期排序。對這個問題,可以用帶有比較函數的排序算法來做。給定兩個日期,先比較年份,如果相同,再比較月份,如果再相同,再比較日。這兒我們可以採用另一個方法,即用一種穩定的排序方法對所給信息進行三次排序:先對日,其次對月,再對年。

基數排序的代碼是很簡單的、下面的過程假設長度爲n的數組A中的每個元素都有d位數字,其中第1位是最低的,第d位是最高位。

procedure Radix_Sort(var L:List;d:integer);
var
i:integer;
begin
1  for i:=1 to d do
2     使用一種穩定的排序方法來對數組L按數字i進行排序;
end;

基數排序的正確性可以通過對正在被排序的列進行歸納而加以證明。對本算法時間代價的分析要取決於選擇哪種穩定的中間排序算法。當每位數字都界於lk之間,且k不太大時,可以選擇計數排序。對nd位數的每一遍處理的時間爲O(n+k),共有d遍,故基數排序的總時間爲θ(dn+kd)。當d爲常數,k=O(n)時,基數排序有線性運行時間。

某些計算機科學家傾向於把一個計算機字中所含位數看成是θ(lgn)。具體一點說,假設共有dlgn位數字,d爲正常數。這樣,如果待排序的每個數恰能容於一個計算機字內,我們就可以把它視爲一個以n爲基數的d位數。看一個例子:對一百萬個64位數排序。通過把這些數當作是以216爲基數的四位數,用基數排序四遍就可完成排序。這與一個典型的O(nlgn)比較排序相比要好得多,後者對每一個參加排序的數約要lgn=20次操作。但有一點不理想,即採用計數排序作爲中間穩定排序算法的基數排序版本不能夠進行原地置換排序,而很多O(nlgn)比較排序算法卻是可以的。因此,當內存比較緊張時,一般來說選擇快速排序更合適些。

桶排序 Bin Sort

平均情況下桶排序以線性時間運行。像計數排序一樣,桶排序也對輸入作了某種假設, 因而運行得很快。具體來說,計數排序假設輸入是由一個小範圍內的整數構成,而桶排序則 假設輸入由一個隨機過程產生,該過程將元素一致地分佈在區間[01)上。

桶排序的思想就是把區間[01)劃分成n相同大小的子區間,或稱桶,然後將n輸入數分佈到各個桶中去。因爲輸入數均勻分佈在[01)上,所以一般不會有很多數落在 一個桶中的情況。爲得到結果,先對各個桶中的數進行排序,然後按次序把各桶中的元素列 出來即可。

在桶排序算法的代碼中,假設輸入是個含n元素的數組A,且每個元素滿足0≤ A[i]<1。另外還需要一個輔助數組B[O..n-1]來存放鏈表實現的桶,並假設可以用某種機制來維護這些表。

桶排序的算法如下,其中floor(x)是地板函數,表示不超過x的最大整數。

procedure Bin_Sort(var A:List);
begin
1  n:=length(A);
2  for i:=1 to n do
3    將A[i]插到表B[floor(n*A[i])]中;
4  for i:=0 to n-1 do
5    用插入排序對錶B[i]進行排序;
6  將表B[0],B[1],...,B[n-1]按順序合併;
end;

 

1 Bin_Sort的操作

1演示了桶排序作用於有10個數的輸入數組上的操作過程。(a)輸入數組A[1..10](b)在該算法的第5行後的有序表()數組B[0..9]。桶i中存放了區間[i/10(i+1)/10]上的值。排序輸出由表B[O]B[1]...B[9]的按序並置構成。

要說明這個算法能證確地工作,看兩個元素A[i]A[j]。如果它們落在同一個桶中,則它們在輸出序列中有着正確的相對次序,因爲它們所在的桶是採用插入排序的。現假設它們落到不同的桶中,設分別B[i']B[j']。不失一般性,假設i'<j'。在算法的代碼中,當第6行中將B中的表並置起來時,桶B[i']中的元素先於桶B[j']中的元素,因而在輸出序列中A[i]先於A[j]。現在要證明A[i]≤A[j]。假設情況正好相反,我們有:

 i'=floor(n*A[i])≥floor(n*A[j])=j'

得矛盾 (因爲i'<j'),從而證明桶排序能正確地工作。

現在來分析算法的運行時間。除第5行外,所有各行在最壞情況的時間都是O(n)。第5行中檢查所有桶的時間是O(n)。分析中唯一有趣的部分就在於5行中插人排序所花的時間。

爲分析插人排序的時間代價,設ni爲表示桶B[i]中元素個數的隨機變量。因爲插入排序以二次時間運行,故爲排序桶B[i]中元素的期望時間爲E[O(ni2)]=O(E[ni2]),對各個桶中的所有元素排序的總期望時間爲:

(1)

爲了求這個和式,要確定每個隨機變量ni的分佈。我們共有n元素,n桶。某個元素落到桶B[i]的概率爲l/n,因爲每個桶對應於區間[01)l/n。這種情況與投球的例子很類似:有n (元素)n盒子 (),每次投球都是獨立的,且以概率p=1/n落到任一桶中。這樣,ni=k的概率就服從二項分佈B(k;n,p),其期望值爲E[ni]=np=1,方差V[ni]=np(1-p)=1-1/n。對任意隨機變量X,有:

  (2)

將這個界用到(1)式上,得出桶排序中的插人排序的期望運行時間爲O(n)。因而,整個桶排序的期望運行時間就是線性的。

下面的Java Applet程序演示了桶排序的基本思想。

在該演示程序中,線性表的元素類型爲整型,桶的標號爲整數,算法將值爲i的元素放入標號爲i的桶中,再按照桶的標號的順序將元素依次取出,就得到了最終的排序結果。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章