1. 程式人生 > >array和list排序演算法對比(二):歸併排序

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,這一步二分和後面複雜度為O(n)的合併操作,是使歸併排序複雜度在O(nlogn)的兩個關鍵操作。然而,合併操作的空間複雜度同樣為O(n),因此陣列歸併排序的空間複雜度同樣為O(nlogn)。這是個不小的空間開銷(相比之下,快排是O(logn),堆排序是O(1)),有可能會限制歸併排序的應用。
接下來,我們可以對照陣列的歸併排序,給出連結串列的歸併排序。由於連結串列的記憶體組織結構與陣列不同,連結串列的二分和歸併兩步操作都需要採用完全不同的形式來完成。

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,因此這個引數的獲取可以認為是O(1)的複雜度;
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的時間複雜度為O(n),而空間複雜度只有O(1),因此連結串列歸併排序的時間複雜度為O(nlogn),空間複雜度為O(logn),比陣列排序要小。因此,對於連結串列更加適合用歸併排序。