快速排序
首先選一個基準 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 就結束了。
之後就用遞迴的寫法,對左右兩邊排序就好了。
最後還有兩個問題想和大家討論一下:
回到我們最初選擇 pivot的問題,每次都取最後一個,這樣做好不好?
答:並不好。
因為我們是想把陣列分割的更均勻,均勻的時間複雜度更低;但是如果這是一個有序的陣列,那麼總是取最後一個是最不均勻的取法。
所以應該隨機取 pivot,這樣就避免了因為陣列本身的特點總是取到最值的情況。
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