array和list排序演算法對比(二):歸併排序
接著上一篇文章,這裡簡單討論陣列和連結串列的歸併排序在演算法設計上的區別。
歸併排序的特點是採用二分的策略,將陣列的子陣列進行排序,然後將兩個有序的子數組合併成一個大的有序陣列。如果採用陣列結構,二分是非常簡單的操作,但二分後的合併空間開銷相對較大。如果採用連結串列結構,合併的空間開銷是相對較小的,但二分則需要精心設計。這也造成了兩種資料結構在演算法設計上會有一定的差別,複雜度也不同。
再次說明:
1. 排序演算法的約定:
sort(begin, end)
表示對[begin, end)
之間的元素排序,包含begin,但不包含end。
2. 在連結串列中,頭部head和尾部tail是不儲存資料的。所以,連結串列的begin對應head->next,end對應tail。
1 陣列的歸併排序
陣列的歸併排序演算法如下:
void merge_sort(int *begin, int *end){
if(end - begin <= 1)
return;
int *mid = begin + (end - begin) / 2;
//split
merge_sort(begin, mid);
merge_sort(mid, end);
//merge
merge(begin, mid, end);
}
//merge函式
void merge(int *begin, int *mid , int *end){
int len = end - begin;
int *tmp = new int[len];
int *cur = tmp;
int *left = begin;
int *right = mid;
//將較小的數放前面,如果相等,則左邊的數放前面
while(left < mid && right < end){
if(*left <= *right){
*cur++ = *left++;
}else{
*cur ++ = *right++;
}
}
//剩餘的數
while(left < mid){
*cur++ = *left++;
}
while(right <end){
*cur++ = *right++;
}
//複製到原陣列
for(int i = 0; i < len; i++){
begin[i] = tmp[i];
}
//刪除臨時陣列
delete[] tmp;
}
可見,歸併排序的第一步就是int *mid = begin + (end - begin) / 2
,這一步二分和後面複雜度為
接下來,我們可以對照陣列的歸併排序,給出連結串列的歸併排序。由於連結串列的記憶體組織結構與陣列不同,連結串列的二分和歸併兩步操作都需要採用完全不同的形式來完成。
2 連結串列的歸併排序
在連結串列的歸併排序中,最讓人頭疼的是連結串列作為非隨機訪問的資料結構,很難對齊進行二分操作。如果直接尋找連結串列的中點,雖然複雜度在漸近意義上不變,但開銷仍然讓人不能滿意。
其實,對連結串列進行歸併排序,並不需要首先找出連結串列的中點,只需要預先給出連結串列的長度即可。可以想象,如果我們對連結串列的前半部分進行排序,排序完成後,自然就獲得了連結串列的中點。而為了對連結串列的前半部分排序,我們可以先對連結串列的前1/4部分進行排序……以此類推,我們在排序的過程中,不斷後移待排序陣列的指標,就可以免去直接查詢中點的問題。
在這裡,我們給出連結串列排序函式的介面:
Node* merge_sort(Node *begin, int size);//給出第一個元素和連結串列的size,而不是給出連結串列尾部
void merge(Node *begin, Node *middle, Node *end);//[begin, middle)和[middle, end)合併
這裡merge_sort
的第二個引數是連結串列的長度,而非連結串列尾部end
,這是為了更好地進行遞迴操作。有幾點需要注意:
1. 在設計連結串列的時候,通常會直接維護連結串列的size
,因此這個引數的獲取可以認為是
2. 在遞迴過程中,size
長度可能小於連結串列的長度,表示的是對從begin
元素開始的size
個元素進行歸併排序。
3. merge_sort
的返回值是連結串列的尾部,即end
,這就是前面提到的“在排序結束時給出連結串列的end節點”。
給出了這些介面,就可以正式給出連結串列排序的演算法了:
Node* merge_sort(Node *begin, int size){
if(size == 0){
return begin;
}else if(size == 1){
return begin->next;
}else{
Node *begin_prev = begin->prev;
int left_size = size / 2;
int right_size = size - left_size;
Node *middle = merge_sort(begin, left_size);
Node *middle_prev = middle->prev;
Node *end = merge_sort(middle, right_size);
//注意:在兩次merge_sort之後,begin和middle指向的節點不再是起點和中點(見merge函式),所以要先儲存它們前面的節點,用於找回merge_sort之後的起點和中點。
merge(begin_prev->next, middle_prev->next, end);
return end;
}
}
void merge(Node *begin, Node *middle, Node *end){
Node *cur1 = begin;
Node *cur2 = middle;
//迴圈結束條件:cur1 == cur2 || cur2 == end
//cur1 == cur2表示左邊列表的指標追上右邊,即左邊連結串列遍歷結束;
//cur2 == end表示右邊指標到達end,即右邊列表遍歷結束
while(cur1 != cur2 && cur2 != end){
if(*cur1 <= *cur2){
cur1 = cur1->next; //直接後移cur1指標
}else{
//備份cur2指標,然後向後移動cur2指標
Node *tmp = cur2;
cur2 = cur2->next;
//在原連結串列中移除tmp
tmp->prev->next = tmp->next;
tmp->next->prev = tmp->prev;
//將tmp移動到cur1的前面
cur1->prev->next = tmp;
tmp->prev = cur1->prev;
tmp->next = cur1;
cur1->prev = tmp;
}
}
}
以上即為連結串列歸併排序的演算法,可以看出,歸併排序的二分過程中,並未實際尋找連結串列的中點,而是在前半部分排序結束後給出連結串列的中點。這個思路可以避免不必要的開銷。
同時,可以發現,由於merge的時間複雜度為