排序(Sorting)是計算機程序設計中的一種重要操作,其功能是對一個數據元素集合或序列重新排列成一個按數據元素某個項值有序的序列。作爲排序依據的數據項稱爲“排序碼”,也即數據元素的關鍵碼。爲了便於查找,通常希望計算機中的數據表是按關鍵碼有序的。如有序表的折半查找,查找效率較高。還有,二叉排序樹、B-樹和B+樹的構造過程就是一個排序過程。若關鍵碼是主關鍵碼,則對於任意待排序序列,經排序後得到的結果是唯一的;若關鍵碼是次關鍵碼,排序結果可能不唯一,這是因爲具有相同關鍵碼的數據元素,這些元素在排序結果中,它們之間的的位置關係與排序前不能保持。
若對任意的數據元素序列,使用某個排序方法,對它按關鍵碼進行排序:若相同關鍵碼元素間的位置關係,排序前與排序後保持一致,稱此排序方法是穩定的;而不能保持一致的排序方法則稱爲不穩定的。
內排序:指待排序列完全存放在內存中所進行的排序過程,適合不太大的元素序列。
外排序:指排序過程中還需訪問外存儲器,足夠大的元素序列,因不能完全放入內存,只能使用外排序。
2.1直接插入排序
設有n個記錄,存放在數組r中,重新安排記錄在數組中的存放順序,使得按關鍵碼有序。即
r[1].key≤r[2].key≤……≤r[n].key
設1<j≤n,r[1].key≤r[2].key≤……≤r[j-1].key,將r[j]插入,重新安排存放順序,使得r[1].key≤r[2].key≤……≤r[j].key,得到新的有序表,記錄數增1。
① r[0]=r[j]; //r[j]送r[0]中,使r[j]爲待插入記錄空位
i=j-1; //從第i個記錄向前測試插入位置,用r[0]爲輔助單元,可免去測試i<1。
②若r[0].key≥r[i].key,轉④。 //插入位置確定
③若r[0].key < r[i].key時,
r[i+1]=r[i];i=i-1;轉②。 //調整待插入位置
④ r[i+1]=r[0];結束。 //存放待插入記錄
【例1】向有序表中插入一個記錄的過程如下:
r[1] r[2] r[3] r[4] r[5] 存儲單元
2 10 18 25 9 將r[5]插入四個記錄的有序表中,j=5
r[0]=r[j];i=j-1;初始化,設置待插入位置
2 10 18 25 □ r[i+1]爲待插入位置
i=4,r[0] < r[i],r[i+1]=r[i];i--;調整待插入位置
2 10 18 □ 25
i=3,r[0] < r[i],r[i+1]=r[i];i--;調整待插入位置
2 10 □ 18 25
i=2,r[0] < r[i],r[i+1]=r[i];i--;調整待插入位置
2 □ 10 18 25
i=1,r[0] ≥r[i],r[i+1]=r[0];插入位置確定,向空位填入插入記錄
2 9 10 18 25 向有序表中插入一個記錄的過程結束
void InsertSort(S_TBL &p)
{ for(i=2;i<=p->length;i++)
if(p->elem[i].key < p->elem[i-1].key) /*小於時,需將elem[i]插入有序表*/
{ p->elem[0].key=p->elem[i].key; /*爲統一算法設置監測*/
for(j=i-1;p->elem[0].key < p->elem[j].key;j--)
p->elem[j+1].key=p->elem[j].key; /*記錄後移*/
p->elem[j+1].key=p->elem[0].key; /*插入到正確位置*/
}
}
【效率分析】
空間效率:僅用了一個輔助單元。
時間效率:向有序表中逐個插入記錄的操作,進行了n-1趟,每趟操作分爲比較關鍵碼和移動記錄,而比較的次數和移動記錄的次數取決於待排序列按關鍵碼的初始排列。
最好情況下:即待排序列已按關鍵碼有序,每趟操作只需1次比較2次移動。
總比較次數=n-1次
總移動次數=2(n-1)次
最壞情況下:即第j趟操作,插入記錄需要同前面的j個記錄進行j次關鍵碼比較,移動記錄的次數爲j+2次。
平均情況下:即第j趟操作,插入記錄大約同前面的j/2個記錄進行關鍵碼比較,移動記錄的次數爲j/2+2次。
由此,直接插入排序的時間複雜度爲O(n2)。是一個穩定的排序方法。
直接插入排序的基本操作是向有序表中插入一個記錄,插入位置的確定通過對有序表中記錄按關鍵碼逐個比較得到的。平均情況下總比較次數約爲n2/4。既然是在有序表中確定插入位置,可以不斷二分有序表來確定插入位置,即一次比較,通過待插入記錄與有序表居中的記錄按關鍵碼比較,將有序表一分爲二,下次比較在其中一個有序子表中進行,將子表又一分爲二。這樣繼續下去,直到要比較的子表中只有一個記錄時,比較一次便確定了插入位置。
二分判定有序表插入位置方法:
① low=1;high=j-1;r[0]=r[j]; // 有序表長度爲j-1,第j個記錄爲待插入記錄
//設置有序表區間,待插入記錄送輔助單元
②若low>high,得到插入位置,轉⑤
③ low≤high,m=(low+high)/2; // 取表的中點,並將表一分爲二,確定待插入區間*/
④若r[0].key<r[m].key,high=m-1; //插入位置在低半區
否則,low=m+1; // 插入位置在高半區
轉②
⑤ high+1即爲待插入位置,從j-1到high+1的記錄,逐個後移,r[high+1]=r[0];放置待插入記錄。
void InsertSort(S_TBL *s)
{ /* 對順序表s作折半插入排序 */
for(i=2;i<=s->length;i++)
{ s->elem[0]=s->elem[i]; /* 保存待插入元素 */
low=i;high=i-1; /* 設置初始區間 */
while(low<=high) /* 該循環語句完成確定插入位置 */
{ mid=(low+high)/2;
if(s->elem[0].key>s->elem[mid].key)
low=mid+1; /* 插入位置在高半區中 */
else high=mid-1; /* 插入位置在低半區中 */
}/* while */
for(j=i-1;j>=high+1;j--) /* high+1爲插入位置 */
s->elem[j+1]=s->elem[j]; /* 後移元素,留出插入空位 */
s->elem[high+1]=s->elem[0]; /* 將元素插入 */
}/* for */
}/* InsertSort */
【時間效率】
確定插入位置所進行的折半查找,關鍵碼的比較次數至多爲,次,移動記錄的次數和直接插入排序相同,故時間複雜度仍爲O(n2)。是一個穩定的排序方法。
2.3表插入排序
直接插入排序、折半插入排序均要大量移動記錄,時間開銷大。若要不移動記錄完成排序,需要改變存儲結構,進行表插入排序。所謂表插入排序,就是通過鏈接指針,按關鍵碼的大小,實現從小到大的鏈接過程,爲此需增設一個指針項。操作方法與直接插入排序類似,所不同的是直接插入排序要移動記錄,而表插入排序是修改鏈接指針。用靜態鏈表來說明。
#define SIZE 200
typedef struct{
ElemType elem; /*元素類型*/
int next; /*指針項*/
}NodeType; /*表結點類型*/
typedef struct{
NodeType r[SIZE]; /*靜態鏈表*/
int length; /*表長度*/
}L_TBL; /*靜態鏈表類型*/
假設數據元素已存儲在鏈表中,且0號單元作爲頭結點,不移動記錄而只是改變鏈指針域,將記錄按關鍵碼建爲一個有序鏈表。首先,設置空的循環鏈表,即頭結點指針域置0,並在頭結點數據域中存放比所有記錄關鍵碼都大的整數。接下來,逐個結點向鏈表中插入即可。
【例2】表插入排序示例
0 - - - - - - - -
1 0 - - - - - - -
2 0 1 - - - - - -
2 3 1 0 - - - - -
2 3 1 4 0 - - - -
2 3 1 5 0 4 - - -
6 3 1 5 0 4 2 - -
6 3 1 5 0 4 7 2 -
6 8 1 5 0 4 7 2 3
表插入排序得到一個有序的鏈表,查找則只能進行順序查找,而不能進行隨機查找,如折半查找。爲此,還需要對記錄進行重排。
重排記錄方法:按鏈表順序掃描各結點,將第i個結點中的數據元素調整到數組的第i個分量數據域。因爲第i個結點可能是數組的第j個分量,數據元素調整僅需將兩個數組分量中數據元素交換即可,但爲了能對所有數據元素進行正常調整,指針域也需處理。
【算法3】
1. j=l->r[0].next;i=1; //指向第一個記錄位置,從第一個記錄開始調整
2. 若i=l->length時,調整結束;否則,
a. 若i=j,j=l->r[j].next;i++;轉(2) //數據元素應在這分量中,不用調整,處理下一個結點
b. 若j>i,l->r[i].elem<-->l->r[j].elem; //交換數據元素
p=l->r[j].next; // 保存下一個結點地址
l->r[j].next=l->[i].next;l->[i].next=j; // 保持後續鏈表不被中斷
j=p;i++;轉(2) // 指向下一個處理的結點
c. 若j<i,while(j<i) j=l->r[j].next;//j分量中原記錄已移走,沿j的指針域找尋原記錄的位置
轉到(a)
【例3】對錶插入排序結果進行重排示例
6 8 1 5 0 4 7 2 3
6 (6) 1 5 0 4 8 2 3
6 (6) (7) 5 0 4 8 1 3
6 (6) (7) (7) 0 4 8 5 3
6 (6) (7) (7) (6) 4 0 5 3
6 (6) (7) (7) (6) (8) 0 5 4
6 (6) (7) (7) (6) (8) (7) 0 4
6 (6) (7) (7) (6) (8) (7) (8) 0
【時效分析】
表插入排序的基本操作是將一個記錄插入到已排好序的有序鏈表中,設有序表長度爲i,則需要比較至多i+1次,修改指針兩次。因此,總比較次數與直接插入排序相同,修改指針總次數爲2n次。所以,時間複雜度仍爲O(n2)
希爾排序又稱縮小增量排序,是1959年由D.L.Shell提出來的,較前述幾種插入排序方法有較大的改進。
直接插入排序算法簡單,在n值較小時,效率比較高,在n值很大時,若序列按關鍵碼基本有序,效率依然較高,其時間效率可提高到O(n)。希爾排序即是從這兩點出發,給出插入排序的改進方法。
希爾排序方法:
1. 選擇一個步長序列t1,t2,…,tk,其中ti>tj,tk=1;
2. 按步長序列個數k,對序列進行k趟排序;
3. 每趟排序,根據對應的步長ti,將待排序列分割成若干長度爲m的子序列,分別對各子表進行直接插入排序。僅步長因子爲1時,整個序列作爲一個表來處理,表長度即爲整個序列的長度。
步長因子分別取5、3、1,則排序過程如下:
p=5 39 80 76 41 13 29 50 78 30 11 100 7 41 86
└─────────┴─────────┘
└─────────┴──────────┘
└─────────┴──────────┘
└─────────┴──────────┘
└─────────┘
子序列分別爲{39,29,100},{80,50,7},{76,78,41},{41,30,86},{13,11}。
p=3 29 7 41 30 11 39 50 76 41 13 100 80 78 86
└─────┴─────┴─────┴──────┘
└─────┴─────┴─────┴──────┘
└─────┴─────┴──────┘
子序列分別爲{29,30,50,13,78},{7,11,76,100,86},{41,39,41,80}。
第二趟排序結果:
p=1 13 7 39 29 11 41 30 76 41 50 86 80 78 100
此時,序列基本“有序”,對其進行直接插入排序,得到最終結果:
7 11 13 29 30 39 41 41 50 76 78 80 86 100
void ShellInsert(S_TBL &p,int dk)
{ /*一趟增量爲dk的插入排序,dk爲步長因子*/
for(i=dk+1;i<=p->length;i++)
if(p->elem[i].key < p->elem[i-dk].key) /*小於時,需elem[i]將插入有序表*/
{ p->elem[0]=p->elem[i]; /*爲統一算法設置監測*/
for(j=i-dk;j>0&&p->elem[0].key < p->elem[j].key;j=j-dk)
p->elem[j+dk]=p->elem[j]; /*記錄後移*/
p->elem[j+dk]=p->elem[0]; /*插入到正確位置*/
}
}
{ /*按增量序列dlta[0,1…,t-1]對順序表*p作希爾排序*/
for(k=0;k<t;t++)
ShellSort(p,dlta[k]); /*一趟增量爲dlta[k]的插入排序*/
}
希爾排序時效分析很難,關鍵碼的比較次數與記錄移動次數依賴於步長因子序列的選取,特定情況下可以準確估算出關鍵碼的比較次數和記錄的移動次數。目前還沒有人給出選取最好的步長因子序列的方法。步長因子序列可以有各種取法,有取奇數的,也有取質數的,但需要注意:步長因子中除1外沒有公因子,且最後一個步長因子必須爲1。希爾排序方法是一個不穩定的排序方法。
交換排序主要是通過兩兩比較待排記錄的關鍵碼,若發生與排序要求相逆,則交換之。
3.1冒泡排序(Bubble Sort)
先來看看待排序列一趟冒泡的過程:設1<j≤n,r[1],r[2],···,r[j]爲待排序列,
通過兩兩比較、交換,重新安排存放順序,使得r[j]是序列中關鍵碼最大的記錄。一趟冒泡方法爲:
① i=1; //設置從第一個記錄開始進行兩兩比較
②若i≥j,一趟冒泡結束。
③比較r[i].key與r[i+1].key,若r[i].key≤r[i+1].key,不交換,轉⑤
④當r[i].key>r[i+1].key時, r[0]=r[i];r[i]=r[i+1];r[i+1]=r[0];
將r[i]與r[i+1]交換
⑤ i=i+1;調整對下兩個記錄進行兩兩比較,轉②
冒泡排序方法:對n個記錄的表,第一趟冒泡得到一個關鍵碼最大的記錄r[n],第二趟冒泡對n-1個記錄的表,再得到一個關鍵碼最大的記錄r[n-1],如此重複,直到n個記錄按關鍵碼有序的表。
【算法6】
① j=n; //從n記錄的表開始
②若j<2,排序結束
③ i=1; //一趟冒泡,設置從第一個記錄開始進行兩兩比較,
④若i≥j,一趟冒泡結束,j=j-1;冒泡表的記錄數-1,轉②
⑤比較r[i].key與r[i+1].key,若r[i].key≤r[i+1].key,不交換,轉⑤
⑥當r[i].key>r[i+1].key時, r[i]<-->r[i+1];將r[i]與r[i+1]交換
⑦ i=i+1;調整對下兩個記錄進行兩兩比較,轉④
【效率分析】
空間效率:僅用了一個輔助單元。
時間效率:總共要進行n-1趟冒泡,對j個記錄的表進行一趟冒泡需要j-1次關鍵碼比較。
移動次數:
最好情況下:待排序列已有序,不需移動。
3.2快速排序
快速排序是通過比較關鍵碼、交換記錄,以某個記錄爲界(該記錄稱爲支點),將待排序列分成兩部分。其中,一部分所有記錄的關鍵碼大於等於支點記錄的關鍵碼,另一部分所有記錄的關鍵碼小於支點記錄的關鍵碼。我們將待排序列按關鍵碼以支點記錄分成兩部分的過程,稱爲一次劃分。對各部分不斷劃分,直到整個序列按關鍵碼有序。
一次劃分方法:
設1≤p<q≤n,r[p],r[p+1],...,r[q]爲待排序列
① low=p;high=q; //設置兩個搜索指針,low是向後搜索指針,high是向前搜索指針
r[0]=r[low]; //取第一個記錄爲支點記錄,low位置暫設爲支點空位
②若low=high,支點空位確定,即爲low。
r[low]=r[0]; //填入支點記錄,一次劃分結束
否則,low<high,搜索需要交換的記錄,並交換之
③若low<high且r[high].key≥r[0].key //從high所指位置向前搜索,至多到low+1位置
high=high-1;轉③ //尋找r[high].key<r[0].key
r[low]=r[high]; //找到r[high].key<r[0].key,設置high爲新支點位置,
//小於支點記錄關鍵碼的記錄前移。
④若low<high且r[low].key<r[0].key //從low所指位置向後搜索,至多到high-1位置
low=low+1;轉④ //尋找r[low].key≥r[0].key
r[high]=r[low]; //找到r[low].key≥r[0].key,設置low爲新支點位置,
//大於等於支點記錄關鍵碼的記錄後移。
轉② //繼續尋找支點空位
int Partition(S_TBL *tbl,int low,int high) /*一趟快排序*/
{ /*交換順序表tbl中子表tbl->[low…high]的記錄,使支點記錄到位,並反回其所在位置*/
/*此時,在它之前(後)的記錄均不大(小)於它*/
tbl->r[0]=tbl->r[low]; /*以子表的第一個記錄作爲支點記錄*/
pivotkey=tbl->r[low].key; /*取支點記錄關鍵碼*/
while(low<higu) /*從表的兩端交替地向中間掃描*/
{ while(low<high&&tbl->r[high].key>=pivotkey) high--;
tbl->r[low]=tbl->r[high]; /*將比支點記錄小的交換到低端*/
while(low<high&&tbl-g>r[high].key<=pivotkey) low++;
tbl->r[low]=tbl->r[high]; /*將比支點記錄大的交換到低端*/
}
tbl->r[low]=tbl->r[0]; /*支點記錄到位*/
return low; /*反回支點記錄所在位置*/
}
【例 5】一趟快排序過程示例
r[1] r[2] r[3] r[4] r[5] r[6] r[7] r[8] r[9] r[10] 存儲單元
49 14 38 74 96 65 8 49 55 27 記錄中關鍵碼
low=1;high=10;設置兩個搜索指針, r[0]=r[low];支點記錄送輔助單元,
□ 14 38 74 96 65 8 49 55 27
↑ ↑
low high
第一次搜索交換
從high向前搜索小於r[0].key的記錄,得到結果
27 14 38 74 96 65 8 49 55 □
↑ ↑
low high
從low向後搜索大於r[0].key的記錄,得到結果
27 14 38 □ 96 65 8 49 55 74
↑ ↑
low high
第二次搜索交換
從high向前搜索小於r[0].key的記錄,得到結果
27 14 38 8 96 65 □ 49 55 74
↑ ↑
low high
從low向後搜索大於r[0].key的記錄,得到結果
27 14 38 8 □ 65 96 49 55 74
↑ ↑
low high
第三次搜索交換
從high向前搜索小於r[0].key的記錄,得到結果
27 14 38 8 □ 65 96 49 55 74
↑↑
low high
從low向後搜索大於r[0].key的記錄,得到結果
27 14 38 8 □ 65 96 49 55 74
↑↑
low high
low=high,劃分結束,填入支點記錄
27 14 38 8 49 65 96 49 55 74
void QSort(S_TBL *tbl,int low,int high) /*遞歸形式的快排序*/
{ /*對順序表tbl中的子序列tbl->[low…high]作快排序*/
if(low<high)
{ pivotloc=partition(tbl,low,high); /*將表一分爲二*/
QSort(tbl,low,pivotloc-1); /*對低子表遞歸排序*/
QSort(tbl,pivotloc+1,high); /*對高子表遞歸排序*/
}
}
【效率分析】
空間效率:快速排序是遞歸的,每層遞歸調用時的指針和參數均要用棧來存放,遞歸調用層次數與上述二叉樹的深度一致。因而,存儲開銷在理想情況下爲O(log2n),即樹的高度;在最壞情況下,即二叉樹是一個單鏈,爲O(n)。
時間效率:在n個記錄的待排序列中,一次劃分需要約n次關鍵碼比較,時效爲O(n),若設T(n)爲對n個記錄的待排序列進行快速排序所需時間。
理想情況下:每次劃分,正好將分成兩個等長的子序列,則
≤cn+2(cn/2+2T(n/4))=2cn+4T(n/4)
≤2cn+4(cn/4+T(n/8))=3cn+8T(n/8)
······
≤cnlog2n+nT(1)=O(nlog2n)
快速排序是通常被認爲在同數量級(O(nlog2n))的排序方法中平均性能最好的。但若初始序列按關鍵碼有序或基本有序時,快排序反而蛻化爲冒泡排序。爲改進之,通常以“三者取中法”來選取支點記錄,即將排序區間的兩個端點與中點三個記錄關鍵碼居中的調整爲支點記錄。快速排序是一個不穩定的排序方法。
4選擇排序
選擇排序主要是每一趟從待排序列中選取一個關鍵碼最小的記錄,也即第一趟從n個記錄中選取關鍵碼最小的記錄,第二趟從剩下的n-1個記錄中選取關鍵碼最小的記錄,直到整個序列的記錄選完。這樣,由選取記錄的順序,便得到按關鍵碼有序的序列。
4.1 簡單選擇排序
操作方法:第一趟,從n個記錄中找出關鍵碼最小的記錄與第一個記錄交換;第二趟,從第二個記錄開始的n-1個記錄中再選出關鍵碼最小的記錄與第二個記錄交換;如此,第i趟,則從第i個記錄開始的n-i+1個記錄中選出關鍵碼最小的記錄與第i個記錄交換,直到整個序列按關鍵碼有序。
void SelectSort(S_TBL *s)
{ for(i=1;i<s->length;i++)
{ /* 作length-1趟選取 */
for(j=i+1,t=i;j<=s->length;j++)
{ /* 在i開始的length-n+1個記錄中選關鍵碼最小的記錄 */
if(s->elem[t].key>s->elem[j].key)
t=j; /* t中存放關鍵碼最小記錄的下標 */
}
s->elem[t]<-->s->elem[i]; /* 關鍵碼最小的記錄與第i個記錄交換 */
}
}
按照錦標賽的思想進行,將n個參賽的選手看成完全二叉樹的葉結點,則該完全二叉樹有2n-2或2n-1個結點。首先,兩兩進行比賽(在樹中是兄弟的進行,否則輪空,直接進入下一輪),勝出的再兄弟間再兩兩進行比較,直到產生第一名;接下來,將作爲第一名的結點看成最差的,並從該結點開始,沿該結點到根路徑上,依次進行各分枝結點子女間的比較,勝出的就是第二名。因爲和他比賽的均是剛剛輸給第一名的選手。如此,繼續進行下去,直到所有選手的名次排定。
將第一名的結點置爲最差的,與其兄弟比賽,勝者上升到父結點,勝者兄弟間再比賽,直到根結點,產生第二名83。比較次數爲4,即log2n次。其後各結點的名次均是這樣產生的,所以,對於n個參賽選手來說,即對1,故時間複雜度爲O(nlog2n)。該方法佔用空間較多,除需輸出排序結果的n個單元外,尚需n-1個輔助單元。-n+1)log2n-n個記錄進行樹形選擇排序,總的關鍵碼比較次數至多爲(n
設有n個元素的序列 k1,k2,…,kn,當且僅當滿足下述關係之一時,稱之爲堆。
設有n個元素,將其按關鍵碼排序。首先將這n個元素按關鍵碼建成堆,將堆頂元素輸出,得到n個元素中關鍵碼最小(或最大)的元素。然後,再對剩下的n-1個元素建成堆,輸出堆頂元素,得到n個元素中關鍵碼次小(或次大)的元素。如此反覆,便得到一個按關鍵碼有序的序列。稱這個過程爲堆排序。
因此,實現堆排序需解決兩個問題:
1. 如何將n個元素的序列按關鍵碼建成堆;
2. 輸出堆頂元素後,怎樣調整剩餘n-1個元素,使其按關鍵碼成爲一個新堆。
首先,討論輸出堆頂元素後,對剩餘元素重新建成堆的調整過程。
調整方法:設有m個元素的堆,輸出堆頂元素後,剩下m-1個元素。將堆底元素送入堆頂,堆被破壞,其原因僅是根結點不滿足堆的性質。將根結點與左、右子女中較小(或小大)的進行交換。若與左子女交換,則左子樹堆被破壞,且僅左子樹的根結點不滿足堆的性質;若與右子女交換,則右子樹堆被破壞,且僅右子樹的根結點不滿足堆的性質。繼續對不滿足堆性質的子樹進行上述交換操作,直到葉子結點,堆被建成。稱這個自根結點到葉子結點的調整過程爲篩選。
【例6】
建堆方法:對初始序列建堆的過程,就是一個反覆進行篩選的過程。n個結點的完全
子樹成爲堆,之後向前依次對各結點爲根的子樹進行篩選,使之成爲堆,直到根結點。
【例7】
堆排序:對n個元素的序列進行堆排序,先將其建成堆,以根結點與第n個結點交換;調整前n-1個結點成爲堆,再以根結點與第n-1個結點交換;重複上述操作,直到整個序列有序。
【算法10】
void HeapAdjust(S_TBL *h,int s,int m)
{/*r[s…m]中的記錄關鍵碼除r[s]外均滿足堆的定義,本函數將對第s個結點爲根的子樹篩選,使其成爲大頂堆*/
rc=h->r[s];
for(j=2*s;j<=m;j=j*2) /* 沿關鍵碼較大的子女結點向下篩選 */
{ if(j<m&&h->r[j].key<h->r[j+1].key)
j=j+1; /* 爲關鍵碼較大的元素下標*/
if(rc.key<h->r[j].key) break; /* rc應插入在位置s上*/
h->r[s]=h->r[j]; s=j; /* 使s結點滿足堆定義 */
}
h->r[s]=rc; /* 插入 */
}
{ for(i=h->length/2;i>0;i--) /* 將r[1..length]建成堆 */
HeapAdjust(h,i,h->length);
for(i=h->length;i>1;i--)
{ h->r[1]<-->h->r[i]; /* 堆頂與堆低元素交換 */
HeapAdjust(h,1,i-1); /*將r[1..i-1]重新調整爲堆*/
}
}
+ … +û2)-log2(në + û1)-log2(në( 2 )ûlog22 < nlog2n2
而建堆時的比較次數不超過4n次,因此堆排序最壞情況下,時間複雜度也爲O(nlog2n)。
10.5二路歸併排序
二路歸併排序的基本操作是將兩個有序表合併爲一個有序表。
設r[u…t]由兩個有序子表r[u…v-1]和r[v…t]組成,兩個子表長度分別爲v-u、t-v+1。合併方法爲:
⑴ i=u;j=v;k=u; //置兩個子表的起始下標及輔助數組的起始下標
⑵若i>v 或 j>t,轉⑷ //其中一個子表已合併完,比較選取結束
⑶ //選取r[i]和r[j]關鍵碼較小的存入輔助數組rf
如果r[i].key<r[j].key,rf[k]=r[i]; i++; k++;轉⑵
否則,rf[k]=r[j]; j++; k++;轉⑵
⑷ //將尚未處理完的子表中元素存入rf
如果i<v,將r[i…v-1]存入rf[k…t] //前一子表非空
如果j<=t,將r[i…v]存入rf[k…t] //後一子表非空
⑸合併結束。
void Merge(ElemType *r,ElemType *rf,int u,int v,int t)
{
for(i=u,j=v,k=u;i<v&&j<=t;k++)
{ if(r[i].key<r[j].key)
{ rf[k]=r[i];i++;}
else
{ rf[k]=r[j];j++;}
}
if(i<v) rf[k…t]=r[i…v-1];
if(j<=t) rf[k…t]=r[j…t];
}
1個元素的表總是有序的。所以對n個元素的待排序列,每個元素可看成1個有序子
【算法12】
void MergeSort(S_TBL *p,ElemType *rf)
{ /*對*p表歸併排序,*rf爲與*p表等長的輔助數組*/
ElemType *q1,*q2;
q1=rf;q2=p->elem;
for(len=1;len<p->length;len=2*len) /*從q2歸併到q1*/
{ for(i=1;i+2*len-1<=p->length;i=i+2*len)
Merge(q2,q1,i,i+len,i+2*len-1); /*對等長的兩個子表合併*/
if(i+len-1<p->length)
Merge(q2,q1,i,i+len,p->length); /*對不等長的兩個子表合併*/
else if(i<=p->length)
while(i<=p->length) /*若還剩下一個子表,則直接傳入*/
q1[i]=q2[i];
q1<-->q2; /*交換,以保證下一趟歸併時,仍從q2歸併到q1*/
if(q1!=p->elem) /*若最終結果不在*p表中,則傳入之*/
for(i=1;i<=p->length;i++)
p->elem[i]=q1[i];
}
}
【算法13】
void MSort(ElemType *p,ElemType *p1,int s,int t)
{ /*將p[s…t]歸併排序爲p1[s…t]*/
if(s==t) p1[s]=p[s]
else
{ m=(s+t)/2; /*平分*p表*/
MSort(p,p2,s,m); /*遞歸地將p[s…m]歸併爲有序的p2[s…m]*/
MSort(p,p2,m+1,t); /*遞歸地將p[m+1…t]歸併爲有序的p2[m+1…t]*/
Merge(p2,p1,s,m+1,t); /*將p2[s…m]和p2[m+1…t]歸併到p1[s…t]*/
}
}
{ /*對順序表*p作歸併排序*/
MSort(p->elem,p->elem,1,p->length);
}
【效率分析】
需要一個與表等長的輔助元素數組空間,所以空間複雜度爲O(n)。
對n個元素的表,將這n個元素看作葉結點,若將兩兩歸併生成的子表看作它們的父結點,則歸併過程對應由葉向根生成一棵二叉樹的過程。所以歸併趟數約等於二叉樹的高度-1,即log2n,每趟歸併需移動記錄n次,故時間複雜度爲O(nlog2n)。
基數排序是一種藉助於多關鍵碼排序的思想,是將單關鍵碼按基數分成“多關鍵碼”進行排序的方法。
撲克牌中52張牌,可按花色和麪值分成兩個字段,其大小關係爲:
花色:梅花 < 方塊 < 紅心 < 黑心
面值: 2 < 3 < 4 < 5 < 6 < 7 < 8 < 9 < 10 < J < Q < K < A
梅花2,3,...,A,方塊2,3,...,A,紅心2,3,...,A,黑心2,3,...,A
即兩張牌,若花色不同,不論面值怎樣,花色低的那張牌小於花色高的,只有在同花色情況下,大小關係才由面值的大小確定。這就是多關鍵碼排序。
爲得到排序結果,我們討論兩種排序方法。
方法1:先對花色排序,將其分爲4個組,即梅花組、方塊組、紅心組、黑心組。再對每個組分別按面值進行排序,最後,將4個組連接起來即可。
方法2:先按13個面值給出13個編號組(2號,3號,...,A號),將牌按面值依次放入對應的編號組,分成13堆。再按花色給出4個編號組(梅花、方塊、紅心、黑心),將2號組中牌取出分別放入對應花色組,再將3號組中牌取出分別放入對應花色組,……,這樣,4個花色組中均按面值有序,然後,將4個花色組依次連接起來即可。
設n個元素的待排序列包含d個關鍵碼{k1,k2,…,kd},則稱序列對關鍵碼{k1,k2,…,kd}有序是指:對於序列中任兩個記錄r[i]和r[j](1≤i≤j≤n)都滿足下列有序關係:
其中k1稱爲最主位關鍵碼,kd稱爲最次位關鍵碼。
多關鍵碼排序按照從最主位關鍵碼到最次位關鍵碼或從最次位到最主位關鍵碼的順序
逐次排序,分兩種方法:
最高位優先(Most Significant Digit first)法,簡稱MSD法:先按k1排序分組,同一組中記錄,關鍵碼k1相等,再對各組按k2排序分成子組,之後,對後面的關鍵碼繼續這樣的排序分組,直到按最次位關鍵碼kd對各子組排序後。再將各組連接起來,便得到一個有序序列。撲克牌按花色、面值排序中介紹的方法一即是MSD法。
最低位優先(Least Significant Digit first)法,簡稱LSD法:先從kd開始排序,再對kd-1進行排序,依次重複,直到對k1排序後便得到一個有序序列。撲克牌按花色、面值排序中介紹的方法二即是LSD法。
將關鍵碼拆分爲若干項,每項作爲一個“關鍵碼”,則對單關鍵碼的排序可按多關鍵碼排序方法進行。比如,關鍵碼爲4位的整數,可以每位對應一項,拆分成4項;又如,關鍵碼由5個字符組成的字符串,可以每個字符作爲一個關鍵碼。由於這樣拆分後,每個關鍵碼都在相同的範圍內(對數字是0~9,字符是'a'~'z'),稱這樣的關鍵碼可能出現的符號個數爲“基”,記作RADIX。上述取數字爲關鍵碼的“基”爲10;取字符爲關鍵碼的“基”爲26。基於這一特性,用LSD法排序較爲方便。
基數排序:從最低位關鍵碼起,按關鍵碼的不同值將序列中的記錄“分配”到RADIX個隊列中,然後再“收集”之。如此重複d次即可。鏈式基數排序是用RADIX個鏈隊列作爲分配隊列,關鍵碼相同的記錄存入同一個鏈隊列中,收集則是將各鏈隊列按關鍵碼大小順序鏈接起來。
圖(a):初始記錄的靜態鏈表。
圖(c):第一趟收集:將各隊列鏈接起來,形成單鏈表。
圖(d):第二趟按十位數分配,修改結點指針域,將鏈表中的記錄分配到相應鏈隊列中。
圖(e):第二趟收集:將各隊列鏈接起來,形成單鏈表。
圖(g):第三趟收集:將各隊列鏈接起來,形成單鏈表。此時,序列已有序。
【算法14】
#define MAX_KEY_NUM 8 /*關鍵碼項數最大值*/
#define RADIX 10 /*關鍵碼基數,此時爲十進制整數的基數*/
#define MAX_SPACE 1000 /*分配的最大可利用存儲空間*/
typedef struct{
KeyType keys[MAX_KEY_NUM]; /*關鍵碼字段*/
InfoType otheritems; /*其它字段*/
int next; /*指針字段*/
}NodeType; /*表結點類型*/
typedef struct{
NodeType r[MAX_SPACE]; /*靜態鏈表,r[0]爲頭結點*/
int keynum; /*關鍵碼個數*/
int length; /*當前表中記錄數*/
}L_TBL; /*鏈表類型*/
typedef int ArrayPtr[radix]; /*數組指針,分別指向各隊列*/
{ /*靜態鏈表ltbl的r域中記錄已按(kye[0],keys[1],…,keys[i-1])有序)*/
/*本算法按第i個關鍵碼keys[i]建立RADIX個子表,使同一子表中的記錄的keys[i]相同*/
/*f[0…RADIX-1]和e[0…RADIX-1]分別指向各子表的第一個和最後一個記錄*/
for(j=0;j<RADIX;j++) f[j]=0; /* 各子表初始化爲空表*/
for(p=r[0].next;p;p=r[p].next)
{ j=ord(r[p].keys[i]); /*ord將記錄中第i個關鍵碼映射到[0…RADIX-1]*/
if(!f[j]) f[j]=p;
else r[e[j]].next=p;
e[j]=p; /* 將p所指的結點插入到第j個子表中*/
}
}
{/*本算法按keys[i]自小到大地將f[0…RADIX-1]所指各子表依次鏈接成一個鏈表*e[0…RADIX-1]爲各子表的尾指針*/
for(j=0;!f[j];j=succ(j)); /*找第一個非空子表,succ爲求後繼函數*/
r[0].next=f[j];t=e[j]; /*r[0].next指向第一個非空子表中第一個結點*/
while(j<RADIX)
{ for(j=succ(j);j<RADIX-1&&!f[j];j=succ(j)); /*找下一個非空子表*/
if(f[j]) {r[t].next=f[j];t=e[j];} /*鏈接兩個非空子表*/
}
r[t].next=0; /*t指向最後一個非空子表中的最後一個結點*/
}
void RadixSort(L_TBL *ltbl)
{ /*對ltbl作基數排序,使其成爲按關鍵碼升序的靜態鏈表,ltbl->r[0]爲頭結點*/
for(i=0;i<ltbl->length;i++) ltbl->r[i].next=i+1;
ltbl->r[ltbl->length].next=0; /*將ltbl改爲靜態鏈表*/
for(i=0;i<ltbl->keynum;i++) /*按最低位優先依次對各關鍵碼進行分配和收集*/
{ Distribute(ltbl->r,i,f,e); /*第i趟分配*/
Collect(ltbl->r,i,f,e); /*第i趟收集*/
}
}
【效率分析】
時間效率:設待排序列爲n個記錄,d個關鍵碼,關鍵碼的取值範圍爲radix,則進行鏈式基數排序的時間複雜度爲O(d(n+radix)),其中,一趟分配時間複雜度爲O(n),一趟收集時間複雜度爲O(radix),共進行d趟分配和收集。
空間效率:需要2*radix個指向隊列的輔助空間,以及用於靜態鏈表的n個指針。
7.1外部排序的方法
外部排序基本上由兩個相互獨立的階段組成。首先,按可用內存大小,將外存上含n個記錄的文件分成若干長度爲k的子文件或段(segment),依次讀入內存並利用有效的內部排序方法對它們進行排序,並將排序後得到的有序子文件重新寫入外存。通常稱這些有序子文件爲歸併段或順串;然後,對這些歸併段進行逐趟歸併,使歸併段(有序子文件)逐漸由小到大,直至得到整個有序文件爲止。
顯然,第一階段的工作已經討論過。以下主要討論第二階段即歸併的過程。先從一個例子來看外排序中的歸併是如何進行的?
文件,首先通過10次內部排序得到
10個初始歸併段 R1~R10 ,其中每
一段都含1000個記錄。然後對它們
作如圖11所示的兩兩歸併,直至
得到一個有序文件爲止。
從圖11可見,由10個初始歸併段到一個有序文件,共進行了四趟歸併,每一趟
一般情況下,外部排序所需總時間=
內部排序(產生初始歸併段)所需時間 m*tis
+外存信息讀寫的時間 d*tio
+內部歸併排序所需時間 s*utmg
其中:tis是爲得到一個初始歸併段進行的內部排序所需時間的均值;tio是進行一次外存讀/寫時間的均值;utmg是對u個記錄進行內部歸併所需時間;m爲經過內部排序之後得到的初始歸併段的個數;s爲歸併的趟數;d爲總的讀/寫次數。由此,上例10000個記錄利用2-路歸併進行排序所需總的時間爲:
10*tis+500*tio+4*10000tmg
其中tio取決於所用的外存設備,顯然,tio較tmg要大的多。因此,提高排序效率應主要着眼於減少外存信息讀寫的次數d。
下面來分析d和“歸併過程”的關係。若對上例中所得的10個初始歸併段進行5-平衡歸併(即每一趟將5個或5個以下的有序子文件歸併成一個有序子文件),則從下圖可見,僅需進行二趟歸併,外部排序時總的讀/寫次數便減少至2×100+100=300,比2-路歸併減少了200次的讀/寫。
R1 R2 R3 R4 R5 R6 R7 R8 R9 R10
└─┴─┼─┴─┘ └─┴─┼─┴─┘
R1' R2'
└────┬────┘
有序文件
圖12
可見,對同一文件而言,進行外部排序時所需讀/寫外存的次數和歸併的趟數s成正比。而在一般情況下,對m個初始歸併段進行k-路平衡歸併時,歸併的趟數
可見,若增加k或減少m便能減少s。下面分別就這兩個方面討論之。
7.2多路平衡歸併的實現
從上式可見,增加k可以減少s,從而減少外存讀/寫的次數。但是,從下面的討論中又可發現,單純增加k將導致增加內部歸併的時間utmg。那末,如何解決這個矛盾呢?
先看2-路歸併。令u個記錄分佈在兩個歸併段上,按Merge函數進行歸併。每得到歸併後的含u個記錄的歸併段需進行u-1次比較。
再看k-路歸併。令u個記錄分佈在k個歸併段上,顯然,歸併後的第一個記錄應是k個歸併段中關鍵碼最小的記錄,即應從每個歸併段的第一個記錄的相互比較中選出最小者,這需要進行k-1次比較。同理,每得到歸併後的有序段中的一個記錄,都要進行k-1次比較。顯然,爲得到含u個記錄的歸併段需進行(u-1)(k-1)次比較。由此,對n個記錄的文件進行外部排序時,在內部歸併過程中進行的總的比較次數爲s(k-1)(n-1)。假設所得初始歸併段爲m個,則可得內部歸併過程中進行比較的總的次數爲
k而減少外存信息讀寫時間所得效益,這是我們所不希望的。然而,若在進行k-路歸併時利用“敗者樹”(Tree of Loser),則可使在k個記錄中選出關鍵碼最小的記錄時僅需進
何謂“敗者樹”?它是樹形選擇排序的一種變型。相對地,我們可稱圖10.5和圖10.6中二叉樹爲“勝者樹”,因爲每個非終端結點均表示其左、右子女結點中“勝者”。反之,若在雙親結點中記下剛進行完的這場比賽中的敗者,而讓勝者去參加更高一層的比賽,便可得到一棵“敗者樹”。
【例9】
(a) (b)
圖13 實現5-路歸併的敗者樹
typedef int LoserTree[k]; /*敗者樹是完全二叉樹且不含葉子,可採用順序存儲結構*/
KeyType key;
}ExNode,External[k]; /*外結點,只存放待歸併記錄的關鍵碼*/
{ /*利用敗者樹ls將編號從0到k-1的k個輸入歸併段中的記錄歸併到輸出歸併段*/
/*b[0]到b[k-1]爲敗者樹上的k個葉子結點,分別存放k個輸入歸併段中當前記錄的關鍵碼*/
for(i=0;i<k;i++) input(b[i].key); /*分別從k個輸入歸併段讀入該段當前第一個記錄的*/
/*關鍵碼到外結點*/
CreateLoserTree(ls); /*建敗者樹ls,選得最小關鍵碼爲b[0].key*/
while(b[ls[0]].key!=MAXKEY)
{ q=ls[0]; /*q指示當前最小關鍵碼所在歸併段*/
output(q); /*將編號爲q的歸併段中當前(關鍵碼爲b[q].key的記錄寫至輸出歸併段)*/
input(b[q].key); /*從編號爲q的輸入歸併段中讀入下一個記錄的關鍵碼*/
Adjust(ls,q); /*調整敗者樹,選擇新的最小關鍵碼*/
}
output(ls[0]); /*將含最大關鍵碼MAXKEY的記錄寫至輸出歸併段*/
}
void Adjust(LoserTree *ls,int s) /*選得最小關鍵碼記錄後,從葉到根調整敗者樹,選下一個最小關鍵碼*/
{ /*沿從葉子結點b[s]到根結點ls[0]的路徑調整敗者樹*/
t=(s+k)/2; /*ls[t]是b[s]的雙親結點*/
while(t>0)
{ if(b[s].key>b[ls[t]].key) s<-->ls[t]; /*s指示新的勝者*/
t=t/2;
}
ls[0]=s;
}
{ /*已知b[0]到b[k-1]爲完全二叉樹ls的葉子結點存有k個關鍵碼,沿從葉子到根的k條路徑*/
/*將ls調整爲敗者樹*/
b[k].key=MINKEY; /*設MINKEY爲關鍵碼可能的最小值*/
for(i=0;i<k;i++) ls[i]=k; /*設置ls中“敗者”的初值*/
for(i=k-1;k>0;i--) Adjust(ls,i); /*依次從b[k-1],b[k-2],…,b[0]出發調整敗者*/
}