1. 程式人生 > 實用技巧 >快速排序常見三種寫法

快速排序常見三種寫法

排序的基本知識

排序是很重要的,一般排序都是針對陣列的排序,可以簡單想象一排貼好了標號的箱子放在一起,順序是打亂的因此需要排序。

排序的有快慢之分,常見的基於比較的方式進行排序的演算法一般有六種。

  • 氣泡排序(bubble sort)
  • 選擇排序(selection sort)
  • 插入排序(insertion sort)
  • 歸併排序(merge sort)
  • 堆排序(heap sort)
  • 快速排序(quick sort)

前三種屬於比較慢的排序的方法,時間複雜度在\(O(n^2)\)級別。後三種會快一些。但是也各有優缺點,比如歸併排序需要額外開闢一段空間用來存放陣列元素,也就是\(O(n)\)的空間複雜度。

快速排序的三種實現

這裡主要說說快速排序,通常有三種實現方法:

  • 順序法
  • 填充法
  • 交換法

下面的程式碼用java語言實現

可以用下面的測試程式碼,也可以參考文章底部的整體的程式碼。

public class Test {
    public static void main(String[] args) {
        int[] nums = {7,8,4,9,3,2,6,5,0,1,9};
        QuickSort quickSort = new QuickSort();
        quickSort.quick_sort(nums, 0, nums.length-1);
        System.out.println(Arrays.toString(nums));
    }
}

遞迴基本框架

所有的快速排序幾乎都有著相同的遞迴框架,先看下程式碼

    public void quick_sort(int[] array, int start, int end) {
        if(start < end){
            int mid = partition(array, start, end);
            quick_sort(array, start, mid-1);
            quick_sort(array, mid+1, end);
        }
    }

程式碼有如下特點

  • 因為快速排序是原地排序(in-place sort),所以不需要返回值,函式結束後輸入陣列就排序完成
  • 傳入quick_sort函式的引數有陣列array,起始下標start和終止下標end。這樣方便對子陣列進行操作。
  • 程式碼使用了分治(divide and conquer)的思想,並用遞迴來完成

單看遞迴的框架,思路其實很簡單。

  1. 利用partition函式在陣列中找到一個mid下標,通過移動元素,使得mid左邊的元素都比mid小,右邊的都比mid大。
  2. 遞迴地,對mid左邊和右邊的元素進行快速排序。
  3. 不斷進行下去,區間會越來越小,函式如果start==end說明區間只有一個元素,也就不用排序,這就是終止條件。

快速排序的副產品就是快速選擇演算法,因為partition函式實際上返回的mid值就是array[mid]在已經排序的array裡面所處的順位。

因此真正的難點就落在了怎麼樣對陣列進行劃分了,首先介紹順序法

順序法

    public int partition(int[] array, int start, int end) {
        int firstHigh = start; // > pivot
        int pivot = array[end];
        for(int j = start; j < end; j++) {
            if(array[j] <= pivot) {
                swap(array, firstHigh, j);
                firstHigh++;
            }
        }
        swap(array, firstHigh, end);
        return firstHigh;
    }

2-3行是一些指標的定義,這裡選取最後一個元素作為主元(pivot),

因此任務也就變成了:調整陣列使得pivot左側的元素都比pivot小,右側的都比pivot大。

partition的目標
小於或者等於pivot pivot 大於pivot
array[start...mid-1] array[mid] array[mid+1...end]

4-9行是迴圈體,代表著如何將給定範圍的陣列變成表格中的樣子,不斷迴圈最終做到上面表格中的樣子,其中firstHigh變數也大於pivot。

迴圈過程
小於或者等於pivot firstHigh 大於pivot unexplored
array[start...firstHigh-1] array[firstHigh] array[firstHigh...j-1] array[j...end]
  1. 初開始迴圈j=start,整個陣列都是unexplored的狀態,firstHigh也等於start。
  2. 逐個遍歷陣列元素與pivot比較,
  3. 如果array[j] > pivot,可知array[j]處於pivot右邊的高區,容易知道j始終大於等於firstHigh,此時不需要做額外操作,j向右移動一位,高區多一個元素。
  4. 如果array[j] <= pivot,在左邊,array[j]應該移動到左邊的低區,只需與firstHigh交換即可,然後firstHigh向右移動一位,低區多一個元素
  5. j==end迴圈結束,除了pivot所有的元素都被遍歷,小於等於pivot都在firstHigh左邊,大於都在firstHigh右邊。
  6. 此時需要考慮pivot的具體位置,pivot還在最後一位沒有動,注意到firstHigh左側都比pivot小,因此array[end]array[firstHigh]交換即可,也就是10行
  7. 返回的下標就是firstHigh,這就是pivot的位置

這個方法算是比較常見的吧,但也有點不好寫。

填充法

public int insertPartition(int[] array, int start, int end) {
    int pivot = array[start];
    int low = start;
    int high = end;
    while (low < high) {
        while (low < high && array[high] >= pivot) {
            high--;
        }
        array[low] = array[high];
        while (low < high && array[low] < pivot) {
            low++;
        }
        array[high] = array[low];
    }
    array[low] = pivot;
    return low;
}

接下來一頭一尾兩個指標往中間走的做法了,2-4行做基本的設定。主元選在start位置,其實選end也行,留作練習吧。low和high是兩個指標分別指向開頭和末尾。

這裡的partition目標有一些變化,按照那個標準也能寫,無非就是小改動。

partition的目標
小於pivot pivot 大於或者等於pivot
array[start...mid-1] array[mid] array[mid+1...end]

5-14行使用while迴圈,因為使用賦值操作,所以叫做填充法

  1. 首先從high指標開始,如果high指向的元素array[high] >= pivot,high本來就是從右邊開始的,說明此時不需要交換,high所處的位置已經滿足條件,內部是未知狀態,high左移一位。
  2. 但是當array[high] < pivot時,因此array[high]要放到左邊去,注意到已經array[low]已經用pivot變數儲存了,這個位置可以認為是空出來了(雖然沒有真正空出來),因此第9行array[low] = array[high];,就這樣放到了左邊。
  3. low指標同理,但是當執行過array[low]=array[high]後,array[high]相當於也空了出來。
  4. 因此當array[low] >= pivot時,需要向右邊填充就去找array[high]

注意到外層while迴圈的條件被附帶到了內層,這是很常見的,內層迴圈改變變數的值,有的時候不可避免就不滿足外層迴圈的條件了。內外層迴圈都附帶low<high的條件,保證退出迴圈一定low=high

low與high撞上的時候,這裡實質上是一個空位,因為當迴圈結束時,空位左邊都小於pivot,空位右邊都大於等於pivot,並且迴圈遍歷了除了pivot以外所有的元素。因此填入pivot即可。

返回low或者high都可以,這就是pivot應該在的位置。

交換法

感覺這個方法見的最多。先給一個不常見的寫法

一個不常見的寫法

public int swapPartition(int[] array, int start, int end) {
    int pivot = array[end];
    int low = start;
    int high = end-1;
    while (low < high) {
        while (low < high && array[high] >= pivot) {
            high--;
        }
        while (low < high && array[low] < pivot) {
            low++;
        }
        if (low < high) {
            swap(array, low, high);
        }
    }
    if (pivot > array[low]) {
        low++;
    }
    swap(array, end, low);
    return low;
}

2-4行,設定pivot為最後一個元素,一般都是第一個元素,這裡最後一個也無妨。如果是第一個可以當成作業完成。既然pivot已經指定,high可以從end-1開始往回走。

partition的目標
小於pivot pivot 大於或者等於pivot
array[start...mid-1] array[mid] array[mid+1...end]

partition的目標仍然與填充法相同。

5-15行是迴圈體,很容易看懂

  1. 右邊找小的,左邊找大的,兩邊都找到就交換,low往右邊走,high往左邊走。可以保證low左邊都比pivot小,high右邊都大於等於pivot
  2. low,high指標都要往中間走,碰上迴圈結束。
  3. 碰上的時候,左側一定都小於pivot,右邊都大於等於pivot,那麼中間是不是應該填pivot?於是與array[end]交換?

不能想的太直接,16-18行加了一個判斷,當pivot <= array[low]時,那當然可以把array[low]換到右邊去,因為大一些嗎。如果pivot > array[low]呢?

...... array[low] array[low+1] ...... array[end]
....... low==high low+1 ...... end

那麼array[low]應該在左邊,可以考慮變通以下,array[end]與low+1位置的元素交換,這樣就仍然滿足條件。最後返回low+1即可,low++本身就是low自增,所以如16-20行所示。

low++會陣列越界嗎?不會。因為判斷條件為array[high] >= pivot,因此high指標一定向左邊移動一步

為了方便與下一節相對比,給出pivot取array[start]的另一個版本

public int swapPartition1(int[] array, int start, int end) {
    int pivot = array[start];
    int low = start + 1;
    int high = end;
    while (low < high) {
        while (low < high && array[low] <= pivot) {
            low++;
        }
        while (low < high && array[high] > pivot) {
            high--;
        }
        if (low < high) {
            swap(array, low, high);
        }
    }
    if (pivot < array[low]) {
        low--;
    }
    swap(array, start, low);
    return low;
}

一個常見的寫法

public int swapPartition1(int[] array, int start, int end) {
    int pivot = array[start];
    int low = start;
    int high = end;
    while (low < high) {
        while (low < high && array[high] > pivot) {
            high--;
        }
        while (low < high && array[low] <= pivot) {
            low++;
        }
        if (low < high) {
            swap(array, low, high);
        }
    }
    swap(array, start, low);
    return low;
}

這裡pivot又換成開頭了,但是不管在哪裡,都要思路清晰,知道該做什麼改動能讓程式依舊正確。

但是這個寫法很嚴格,幾乎一點不能改動的。

流程與一個不常見的寫法一節中基本類似,但是

  1. low=start不能動,必須是這個,不能是low=start+1,這個舉一個只有兩個元素的陣列的例子就知道了
  2. 6-8行的內迴圈和9-11行的內迴圈不能對調
  3. 兩個內迴圈中的判斷必須如上面所寫,array[high] > pivotarray[low] <= pivot,>和<=不能輕易改成別的。也就是partition的目標是固定的。(一旦改動,low必須是start+1,但是這樣寫不可避免最後swap又要加判斷,反而和不常見的寫法一樣了。)
partition的目標
小於或者等於pivot pivot 大於pivot
array[start...mid-1] array[mid] array[mid+1...end]

如此才能捨去一個不常見的寫法中16-18行的判斷條件

最關鍵的點在於迴圈位置不能對調。迴圈終止,low與high碰上,有以下幾種情況

  1. high先停住,low增加碰上high停止。high停下來是因為array[high] <= pivot當然能與array[start]交換換到左邊去
  2. low先停住,high減少碰上low停止。low停下來是因為array[low] > pivot,但是緊跟著swap(array, low, high);,所以low先停下來也無妨,low的位置的元素仍然滿足array[low] <= pivot,換到左邊去沒有問題。

鑑於這個寫法太過嚴格不建議這麼寫

完整程式碼

import java.util.Arrays;

public class QuickSort {
    public static void main(String[] args) {
        int[] nums = {7,8,4,9,3,2,6,5,0,1,9,4,7,3};
        quickSort(nums,0, nums.length-1);
        System.out.println(Arrays.toString(nums));
    }

    public static void quickSort(int[] array, int start, int end) {
        if(start < end){
            int mid = swapPartition1(array, start, end);
            quickSort(array, start, mid-1);
            quickSort(array, mid+1, end);
        }
    }
    public static int partition(int[] array, int start, int end) {
        int firstHigh = start;
        int pivot = array[end];
        for(int j=start; j < end; j++) {
            if(array[j] <= pivot) {
                swap(array, firstHigh, j);
                firstHigh++;
            }
        }
        swap(array, firstHigh, end);
        return firstHigh;
    }

    public static int insertPartition(int[] array, int start, int end) {
        int pivot = array[start];
        int low = start;
        int high = end;
        while (low < high) {
            while (low < high && array[high] >= pivot) {
                high--;
            }
            array[low] = array[high];
            while (low < high && array[low] < pivot) {
                low++;
            }
            array[high] = array[low];
        }
        array[low] = pivot;
        return low;
    }

    /* 為啥這是正確的 */
    public static int swapPartition1(int[] array, int start, int end) {
        int pivot = array[start];
        int low = start;
        int high = end;
        while (low < high) {
            while (low < high && array[high] > pivot) {
                high--;
            }
            while (low < high && array[low] <= pivot) {
                low++;
            }
            if (low < high) {
                swap(array, low, high);
            }
        }
        swap(array, start, low);
        return low;
    }

    /*這個肯定是正確的*/
    public static int swapPartition(int[] array, int start, int end) {
        int pivot = array[end];
        int low = start;
        int high = end;
        while (low < high) {
            while (low < high && array[low] < pivot) {
                low++;
            }
            while (low < high && array[high] >= pivot) {
                high--;
            }
            if (low < high) {
                swap(array, low, high);
            }
        }
        if (pivot > array[low]) {
            low++;
        }
        swap(array, end, low);
        return low;
    }

    public static void swap(int[] array, int i, int j) {
        int tmp;
        tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;
    }
}