快速排序為什麼這麼快?

碼農田小齊發表於2020-11-05

快速排序

首先選一個基準 pivot,然後過一遍陣列,

  • 把小於 pivot 的都挪到 pivot 的左邊,
  • 把大於 pivot 的都挪到 pivot 的右邊。

這樣一來,這個 pivot 的位置就確定了,也就是排好了 1 個元素。

然後對 pivot 左邊 ? 的數排序,
對 pivot 右邊 ? 的數排序,
就完成了。

那怎麼排左邊和右邊?

答:同樣的方法。

所以快排也是用的分治法的思想。

「分」

選擇一個 pivot,就把問題分成了

  • pivot 左邊
  • pivot 右邊

這兩個問題。

「治」

就是最開始描述的方法,直到每個區間內沒有元素或者只剩一個元素就可以返回了。

「合」

放在一起自然就是。

但是如何選擇這個 pivot?

取中間的?

取第一個?

取最後一個?

舉個例子:{5, 2, 1, 0, 3}.

比如選最後一個,就是 3.

然後我們就需要把除了 3 之外的數分成「比 3 大」和「比 3 小」的兩部分,這個過程叫做 partition(劃分)

這裡我們仍然使用「擋板法」的思想,不用真的弄兩個陣列來存這兩部分,而是用兩個擋板,把區間劃分好了。

我們用「兩個指標」(就是擋板)把陣列分成「三個區間」,那麼

  • 左邊的區間用來放小於 pivot 的元素;
  • 右邊的區間用來放大於 pivot 的元素;
  • 中間是未排序區間。

那麼初始化時,我們要保證「未排序區間」能夠包含除了 3 之外的所有元素,所以

  • 未排序區間 = [i, j]

這樣左邊和右邊的區間就成了:

  • [0, i):放比 3 小的數;
  • (j, array.length -2]:放比 3 大的數

注意 ⚠️ i, j 是不包含在左右區間裡的呢。

那我們的目的是 check 未排序區間裡的每一個數,然後把它歸到正確的區間裡,以此來縮小未排序區間,直到沒有未排序的元素。

從左到右來 check:

Step1.

5 > 3, 所以 5 要放在右區間裡,所以 5 和 j 指向的 0 交換一下:

這樣 5 就排好了,指標 j --,這樣我們的未排序區間就少了一個數;

Step2.

0 < 3,所以就應該在左邊的區間,直接 i++;

Step3.

2 < 3,同理,i++;

Step4.

1 < 3,同理,i++;

所以當兩個指標錯位的時候,我們結束迴圈。

但是還差了一步,3 並不在正確的位置上呀。所以還要把它插入到兩個區間中間,也就是和指標 i 交換一下。

齊姐宣告:這裡並不鼓勵大家把 pivot 放最左邊。

基本所有的書上都是放右邊,既然放左右都是一樣的,我們就按照大家預設的、達成共識的來,沒必要去“標新立異”。

就比如圍棋的四個星位,但是講究棋道的就是先落自己這邊的星位,而不是伸著胳膊去夠對手那邊的。

那當我們把 pivot 換回到正確的位置上來之後,整個 partition 就結束了。

之後就用遞迴的寫法,對左右兩邊排序就好了。

最後還有兩個問題想和大家討論一下:

  1. 回到我們最初選擇 pivot的問題,每次都取最後一個,這樣做好不好?

答:並不好。

因為我們是想把陣列分割的更均勻均勻的時間複雜度更低;但是如果這是一個有序的陣列,那麼總是取最後一個是最不均勻的取法。

所以應該隨機取 pivot,這樣就避免了因為陣列本身的特點總是取到最值的情況。

  1. pivot 放在哪

隨機選取之後,我們還是要把這個 pivot 放到整個陣列的最右邊,這樣我們的未排序區間才是連續的,否則每次走到 pivot 這裡還要想著跳過它,心好累哦。

class Solution {
  public void quickSort(int[] array) {
    if (array == null || array.length <= 1) {
      return;
    }
    quickSort(array, 0, array.length - 1);
  }
  private void quickSort(int[] array, int left, int right) {
    // base case
    if (left >= right) {
      return;
    }

    // partition
    Random random = new Random(); // java.util 中的隨機數生成器
    int pivotIndex = left + random.nextInt(right - left + 1);
    swap(array, pivotIndex, right);

    int i = left;
    int j = right-1;
    while (i <= j) {
      if (array[i] <= array[right]) {
        i++;
      } else {
        swap(array, i, j);
        j--;
      }
    }
    swap(array, i, right);

    //「分」
    quickSort(array, left, i-1);
    quickSort(array, i+1, right);
  }
  private void swap(int[] array, int x, int y) {
    int tmp = array[x];
    array[x] = array[y];
    array[y] = tmp;
  }
}

這裡的時空複雜度和分的是否均勻有很大關係,所以我們分情況來說:

1. 均分

時間複雜度

如果每次都能差不多均勻分,那麼

  • 每次迴圈的耗時主要就在這個 while 迴圈裡,也就是 O(right - left);
  • 均分的話那就是 logn 層;
  • 所以總的時間是 O(nlogn).

空間複雜度

  • 遞迴樹的高度是 logn,
  • 每層的空間複雜度是 O(1),
  • 所以總共的空間複雜度是 O(logn).

2. 最不均勻

如果每次都能取到最大/最小值,那麼遞迴樹就變成了這個樣子:

時間複雜度

如上圖所示:O(n^2)

空間複雜度

這棵遞迴樹的高度就變成了 O(n).

3. 總結

實際呢,大多數情況都會接近於均勻的情況,所以均勻的情況是一個 average case.

為什麼看起來最好的情況實際上是一個平均的情況呢?

因為即使如果沒有取到最中間的那個點,比如分成了 10% 和 90% 兩邊的數,那其實每層的時間還是 O(n),只不過層數變成了以 9 為底的 log,那總的時間還是 O(nlogn).

所以快排的平均時間複雜度是 O(nlogn)。

穩定性

那你應該能看出來了,在 swap 的時候,已經破壞了元素之間的相對順序,所以快排並不具有穩定性。

這也回答了我們開頭提出的問題,就是

  • 為什麼對於 primitive type 使用快排

    • 因為它速度最快;
  • 為什麼對於 object 使用歸併

    • 因為它具有穩定性且快。

以上就是快排的所有內容了,也是很常考的內容哦!那下一篇文章我會講幾道從快排引申出來的題目,猜猜是什麼??

如果你喜歡這篇文章,記得給我點贊留言哦~你們的支援和認可,就是我創作的最大動力,我們下篇文章見!

我是小齊,紐約程式媛,終生學習者,每天晚上 9 點,雲自習室裡不見不散!

更多幹貨文章見我的 Github: https://github.com/xiaoqi6666/NYCSDE

相關文章