排序算法之——歸并排序和快速排序

冒泡排序、插入排序、選擇排序這三種算法的時間復雜度都為 O(n^2),只適合小規模的數據。今天,我們來認識兩種時間復雜度為 O(nlogn) 的排序算法——歸并排序(Merge Sort)和快速排序(Quick Sort),他們都用到了分治思想,非常巧妙。

1. 歸并排序(Merge Sort)?

1.1. 歸并排序算法實現

  • 歸并排序的核心思想其實很簡單,如果要排序一個數組,我們先把數組從中間分成前后兩部分,然后分別對前后兩部分進行排序,再將排好序的兩部分數據合并在一起就可以了。
歸并排序
  • 歸并排序使用的是分治思想,分治也即是分而治之,將一個大問題分解為小的子問題來解決。分治算法一般都是用遞歸來實現的。分治是一種解決問題的處理思想,遞歸是一種編程技巧

  • 如果要對數組區間 [p, r] 的數據進行排序,我們先將數據拆分為兩部分 [p, q] 和 [q+1, r],其中 q 為中間位置。對兩部分數據排好序后,我們再將兩個子數組合并在一起。當數組的起始位置小于等于終止位置時,說明此時只有一個元素,遞歸也就結束了。

遞推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))

終止條件:
p >= r 不用再繼續分解
  • 對兩個子數組進行合并的過程如下所示,我們先建立一個臨時數組,然后從兩個子數組的起始位置開始比較,將較小的元素一個一個放入臨時數組,直到其中一個子數組比較完畢,再將剩下的另一個子數組余下的值全部放到臨時數組后面。最后我們需要將臨時數組中的數據拷貝到原數組對應的位置。
數組合并
  • 代碼實現
// O(n(logn))
void Merge_Sort(float data[], int left, int right, float sorted_data[])
{
    if(left < right)
    {
        int mid = (left + right) / 2;
        Merge_Sort(data, left, mid, sorted_data);
        Merge_Sort(data, mid+1, right, sorted_data);
        Merge_Array(data, left, mid, right, sorted_data);
    }
}

void Merge_Array(float data[], int left, int mid, int right, float temp[])
{
    int i = left, j = mid + 1;
    int k = 0;

    // 從子數組的頭開始比較
    while(i <= mid && j <= right)
    {
        if (data[i] <= data[j])
        {
            temp[k++] = data[i++];
        }
        else
        {
            temp[k++] = data[j++];
        }
    }

    // 判斷哪個子數組還有元素,并拷貝到 temp 后面
    while(i <= mid)
    {
        temp[k++] = data[i++];
    }
    while(j <= right)
    {
        temp[k++] = data[j++];
    }

    // 將 temp 中的數據拷貝到原數組對應位置
    for(i = 0; i < k; i++)
    {
        data[left+i] = temp[i];
    }
}

/*哨兵簡化*/
void Merge_Array(float data[], int left, int mid, int right, float temp[])
{
    int max_num = INT_MAX;
    int len = right - left + 1;
    int data_left = new int[mid-left+2];
    int data_right = new int[right-mid+1];
    int i = 0, j = 0, k = 0;

    // 復制左半部分元素,放置哨兵在末尾
    for(int k = left; k <= mid; k++)
    {
        data_left[k-left] = data[k];
    }
    data_left[k-left] = max_num;

    // 復制右半部分元素,放置哨兵在末尾
    for(int k = mid + 1; k <= right; k++)
    {
        data_right[k-mid-1] = data[k];
    }
    data_right[k-mid-1] = max_num;

    for (int k = 0; k < len; k++)
    {
        if (data_left[i] <= data_right[j])
        {
            data[k+left] = data_left[i++];
        }
        else
        {
            data[k+left] = data_right[j++];
        }
    }
}

1.2. 歸并排序算法分析

  • 歸并排序是一個穩定的排序算法,在進行子數組合并的時候,我們可以設置當元素大小相等時,先將前半部分的數據放入臨時數組,這樣就可以保證相等元素在排序后依然保持原來的順序。

  • 不僅遞歸求解的問題可以寫成遞推公式,遞歸代碼的時間復雜度也可以寫成遞歸公式

  • 如果我們對 n 個元素進行歸并排序所需要的時間是 T(n),那分解成兩個子數組排序的時間都是 T(\frac{n}{2}),而合并兩個子數組的時間復雜度為 O(n)。所以,歸并排序的時間復雜度計算公式為:

T(1) = C
T(n) = 2*T(\frac{n}{2}) + n, n>1

  • n = 1 時,只需要常量級的執行時間,所以表示為 C。

T(n) = 2*T(\frac{n}{2}) + n
= 2*[2*T(\frac{n}{4}) + \frac{n}{2}] + n = 4*T(\frac{n}{4}) + 2*n
= 4*[2*T(\frac{n}{8}) + \frac{n}{4}] + 2*n = 8*T(\frac{n}{8}) + 3*n
......
= 2^k * T(\frac{n}{2^k}) + k * n
......
\frac{n}{2^k} = 1時, k = log_2n,代入上式得:
T(n) = n * C + nlog_2n
用大 O 標記法來表示,歸并排序的時間復雜度為 O(nlogn)

  • 從我們的分析可以看出,歸并排序的執行效率與原始數據的有序程度無關,其時間復雜度是非常穩定的,不管是最好情況、最壞情況,還是平均情況,時間復雜度都是 O(nlogn)

  • 歸并排序有一個缺點,那就是它不是原地排序算法。在進行子數組合并的時候,我們需要臨時申請一個數組來暫時存放排好序的數據。因為這個臨時空間是可以重復利用的,因此歸并排序的空間復雜度為 O(n),最多需要存放 n 個數據。


2. 快速排序(Quick Sort)?

1.1. 快速排序算法實現

  • 快速排序的思想是這樣的,如果要對數組區間 [p, r] 的數據進行排序,我們先選擇其中任意一個數據作為 pivot(分支點),一般為區間最后一個元素。然后遍歷數組,將小于 pivot 的數據放到左邊,將大于 pivot 的數據放到右邊。接著,我們再遞歸對左右兩邊的數據進行排序,直到區間縮小為 1 ,說明所有的數據都排好了序。
    快速排序
遞推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)

終止條件:
p >= r
  • 歸并排序是由下向上的,先處理子數組然后再合并。而快速排序正好相反,它的過程是由上向下的,先分出兩個子區間,再對子區間進行排序。歸并排序是穩定的時間復雜度為 O(n),但它是非原地算法,而快排則是原地排序算法。
歸并排序和快速排序
  • 快速排序的分區過程如下所示,從左到右依次遍歷數組,如遇到小于 pivot 的元素,則進行數據交換 ,否則繼續往前進行,最后再放置 pivot。


    快排分區
  • 代碼實現

// O(n(logn))
void Quick_Sort(float data[], int left, int right)
{
    if (left < right)
    {
        int i = left, j = left;
        int pivot = data[right];

        for (j = left; j < right; j++)
        {
            if (data[j] < pivot)
            {
                int temp = data[i];
                data[i] = data[j];
                data[j] = temp;
                i++;
            }
        }

        data[j] = data[i];
        data[i] = pivot;
        Quick_Sort(data, left, i-1);
        Quick_Sort(data, i+1, right);
    }
}
  • 快速排序的另一種實現方式如下所示,先取出一個元素作為 pivot(假設是最后一個),這時 pivot 位置可以看作為空,然后從左到右查找第一個比 pivot 大的元素放在 pivot 的位置,此時空的地方變成了這第一個比 pivot 大的元素位置。然后從右到左查找第一個比 pivot 小的元素放在剛才空的位置,依次循環直到從左到右和從右到左都查找到了同一位置,這時候再把 pivot 放置在最后一個空位。這個過程可以形象的被稱為“挖坑填坑”。
快速排序
  • 代碼實現
// O(n(logn))
void Quick_Sort(float data[], int left, int right)
{
    if (left < right)
    {
        int i = left, j = right;
        int pivot = data[j];
        while(i < j)
        {
            while(i < j && data[i] <= pivot) // 從左往右找到第一個比 pivot 大的數
            {
                i++;
            }
            if(i < j)
            {
                data[j--] = data[i];
            }
            while(i < j && data[j] >= pivot) // 從右往左找到第一個比 pivot 小的數
            {
                j--;
            }
            if(i < j)
            {
                data[i++] = data[j];
            }
        }
        data[i] = pivot; // i=j
        Quick_Sort(data, left, i-1);
        Quick_Sort(data, i+1, right);
    }
}

2.2. 快速排序算法分析

  • 如果快速排序每次都將數據分成相等的兩部分,則快排的時間復雜度和歸并排序相同,也是 O(nlogn),但這種情況是很難實現的。如果數據原來已經是有序的,則每次的分區都是不均等的,我們需要進行 n 次分區才能完成整個排序,此時快排的時間復雜度就退化成了 O(n^2)

  • 平均時間復雜度的求解也可以通過遞歸樹來分析,這個問題留待我們以后再解決。我們現在只需要知道,在大部分情況下,快速排序的時間復雜度都可以做到 O(nlogn),只有在極端情況下,才會退化成 O(n^2)

  • 快速排序是一個原地排序算法,是一個不穩定的排序算法,因為其在數據交換過程中可能會改變相等元素的原始位置。


3. 小結

  • 歸并排序和快速排序都是利用分治的思想,代碼都通過遞歸來實現,過程非常相似。
  • 歸并排序非常穩定,時間復雜度始終都是 O(nlogn),但不是原地排序;快速排序雖然最壞情況下時間復雜度為 O(n^2),但平均情況下時間復雜度為 O(nlogn),最壞情況發生的概率也比較小,而且是原地排序算法,因此應用得更加廣泛。

參考資料-極客時間專欄《數據結構與算法之美》

獲取更多精彩,請關注「seniusen」!


seniusen
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容