資料結構和演算法面試題系列—排序演算法之快速排序

ssjhust發表於2018-09-28

這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在CSDN。現整理為一個系列給需要的朋友參考,如有錯誤,歡迎指正。本系列完整程式碼地址在 這裡

0 概述

快速排序也是基於分治模式,類似歸併排序那樣,不同的是快速排序劃分最後不需要merge。對一個陣列 A[p..r] 進行快速排序分為三個步驟:

  • 劃分: 陣列 A[p...r] 被劃分為兩個子陣列 A[p...q-1]A[q+1...r],使得 A[p...q-1] 中每個元素都小於等於 A[q],而 A[q+1...r] 每個元素都大於 A[q]。劃分流程見下圖。
  • 解決: 通過遞迴呼叫快速排序,對子陣列分別排序即可。
  • 合併:因為兩個子陣列都已經排好序了,且已經有大小關係了,不需要做任何操作。

快速排序劃分

快速排序演算法不算複雜的演算法,但是實際寫程式碼的時候卻是最容易出錯的程式碼,寫的不對就容易死迴圈或者劃分錯誤,本文程式碼見 這裡

1 樸素的快速排序

這個樸素的快速排序有個缺陷就是在一些極端情況如所有元素都相等時(或者元素本身有序,如 a[] = {1,2,3,4,5}等),樸素的快速演算法時間複雜度為 O(N^2),而如果能夠平衡劃分陣列則時間複雜度為 O(NlgN)

/**
 * 快速排序-樸素版本
 */
void quickSort(int a[], int l, int u)
{
    if (l >= u) return;

    int q = partition(a, l, u);
    quickSort(a, l, q-1);
    quickSort(a, q+1, u);
}

/**
 * 快速排序-劃分函式
 */
int partition(int a[], int l, int u)
{
    int i, q=l;
    for (i = l+1; i <= u; i++) {
        if (a[i] < a[l])
            swapInt(a, i, ++q);
    }
    swapInt(a, l, q);
    return q;
}
複製程式碼

2 改進-雙向劃分的快速排序

一種改進方法就是採用雙向劃分,使用兩個變數 iji 從左往右掃描,移過小元素,遇到大元素停止;j 從右往左掃描,移過大元素,遇到小元素停止。然後測試i和j是否交叉,如果交叉則停止,否則交換 ij 對應的元素值。

注意,如果陣列中有相同的元素,則遇到相同的元素時,我們停止掃描,並交換 ij 的元素值。雖然這樣交換次數增加了,但是卻將所有元素相同的最壞情況由 O(N^2) 變成了差不多 O(NlgN) 的情況。比如陣列 A={2,2,2,2,2}, 則使用樸素快速排序方法,每次都是劃分 n 個元素為 1 個和 n-1 個,時間複雜度為 O(N^2),而使用雙向劃分後,第一次劃分的位置是 2,基本可以平衡劃分兩部分。程式碼如下:

/**
 * 快速排序-雙向劃分函式
 */
int partitionLR(int a[], int l, int u, int pivot)
{
    int i = l;
    int j = u+1;
    while (1) {
        do {
            i++;
        } while (a[i] < pivot && i <= u); //注意i<=u這個判斷條件,不能越界。

        do {
            j--;
        } while (a[j] > pivot);

        if (i > j) break;

        swapInt(a, i, j);
    }

    // 注意這裡是交換l和j,而不是l和i,因為i與j交叉後,a[i...u]都大於等於樞紐元t,
    // 而樞紐元又在最左邊,所以不能與i交換。只能與j交換。
    swapInt(a, l, j);

    return j;
}

/**
 * 快速排序-雙向劃分法
 */
void quickSortLR(int a[], int l, int u)
{
    if (l >= u) return;

    int pivot = a[l];
    int q = partitionLR(a, l, u, pivot);
    quickSortLR(a, l, q-1);
    quickSortLR(a, q+1, u);
}
複製程式碼

雖然雙向劃分解決了所有元素相同的問題,但是對於一個已經排好序的陣列還是會達到 O(N^2) 的複雜度。此外,雙向劃分還要注意的一點是程式碼中迴圈的寫法,如果寫成 while(a[i]<t) {i++;} 等形式,則當左右劃分的兩個值都等於樞紐元時,會導致死迴圈。

3 繼續改進—隨機法和三數取中法取樞紐元

為了解決上述問題,可以進一步改進,通過隨機選取樞紐元或三數取中方式來獲取樞紐元,然後進行雙向劃分。三數取中指的就是從陣列A[l... u]中選擇左中右三個值進行排序,並使用中值作為樞紐元。如陣列 A[] = {1, 3, 5, 2, 4},則我們對 A[0]、A[2]、A[4] 進行排序,選擇中值 A[4](元素4) 作為樞紐元,並將其交換到 a[l] ,最後陣列變成 A[] = {4 3 5 2 1},然後跟之前一樣雙向排序即可。

/**
 * 隨機選擇樞紐元
 */
int pivotRandom(int a[], int l, int u)
{
    int rand = randInt(l, u);
    swapInt(a, l, rand); // 交換樞紐元到位置l
    return a[l];
}

/**
 * 三數取中選擇樞紐元
 */
int pivotMedian3(int a[], int l, int u)
{
     int m = l + (u-l)/2;

     /*
      * 三數排序
      */
     if( a[l] > a[m] )
        swapInt(a, l, m);

     if( a[l] > a[u] )
        swapInt(a, l, u);

     if( a[m] > a[u] )
        swapInt(a, m, u);

     /* assert: a[l] <= a[m] <= a[u] */
     swapInt(a, m, l); // 交換樞紐元到位置l

     return a[l];
}
複製程式碼

此外,在資料基本有序的情況下,使用插入排序可以得到很好的效能,而且在排序很小的子陣列時,插入排序比快速排序更快,可以在陣列比較小時選用插入排序,而大陣列才用快速排序。

4 非遞迴寫快速排序

非遞迴寫快速排序著實比較少見,不過練練手總是好的。需要用到棧,注意壓棧的順序。程式碼如下:

/**
 * 快速排序-非遞迴版本
 */
void quickSortIter(int a[], int n)
{
    Stack *stack = stackNew(n);
    int l = 0, u = n-1;
    int p = partition(a, l, u);

    if (p-1 > l) { //左半部分兩個邊界值入棧
        push(stack, p-1); 
        push(stack, l);
    }

    if (p+1 < u) { //右半部分兩個邊界值入棧
        push(stack, u);
        push(stack, p+1);
    }

    while (!IS_EMPTY(stack)) { //棧不為空,則迴圈劃分過程
        l = pop(stack);
        u = pop(stack);
        p = partition(a, l, u);

        if (p-1 > l) {
            push(stack, p-1);
            push(stack, l);
        }

        if (p+1 < u) {
            push(stack, u);
            push(stack, p+1);
        }
    }
}
複製程式碼

參考資料

  • 《資料結構和演算法-C語言實現》
  • 《演算法導論》

相關文章