今天看到 V2EX 上有人討論 社招還會問 “請手寫選擇排序演算法” 嗎?,看來還是有很多人關心的。結合自己最近面試的經歷,我可以明確的告訴大家,類似這種問題,只要你的工作經驗小於 10 年,基本上逃不掉。勸大家不如抽點時間早做準備。
簡式快排
面試中遇到問快排的,如上面那個帖子中的情況。你就可以上一份簡式快排了,何謂簡式?最短的程式碼表述快排的思想。
快排的思想,實質是分治法。基於什麼來分?找一個支點來分,通常稱之為 pivot, 而這個分的過程稱之為 partition, 基於以上兩點,我們用遞迴的方式描述快排:
void quicksort(int arr[], int l, int r) {
if (l < r) {
int pivot = partition(arr, l, r);
quicksort(arr, l, pivot-1);
quicksort(arr, pivot+1, r);
}
}
如何?簡單吧。有人說面試的時候手寫快排,如果提前沒有背下來的話,肯定歇菜。我不認為這樣基礎的演算法是需要背的,上面這個遞迴,如此簡潔,如此美,真的需要硬記?
有人說,這個好理解,關鍵在於 partition
如何實現。的確,partition
是快排的靈魂。CLRS 裡採用了以尾巴為支點的策略,我在這裡與其保持一致:
int partition(int arr[], int l, int r) {
int k = l, pivot = arr[r];
for (int i = l; i < r; ++i)
if (arr[i] <= pivot) std::swap(arr[i], arr[k++]);
std::swap(arr[k], arr[r]);
return k;
}
這演算法用白話說,就是從頭到尾迭代,和支點比較,大的不管,小的換。換了才往後看。最後支點戳中間,算是分界線。
如
3,7,8,5,2,1,9,5,4
這麼來一下,就成了:
3,2,1,4,7,8,9,5,5
^^^^^ | ^^^^^^^^^
然後同樣的手法分別解決兩邊,這樣遞迴的解下去。
來份三明治
其實上面的那份,已經可以解決面試中的問題了。但其實有很大的缺陷,如當所有元素都相同的情況下,partition
將一直返回 r
, 遞迴的深度高達 N
,每一次遞迴中 partition
又迴圈 N
次,時間複雜度直接飆到了 O(N^2). 這顯然非常的不值當。
於是我們覺得分兩份太粗,分三份試試?
如
5 7 4 3 1 2 6 5 5
也來那麼一下,成為:
4 3 1 2 5 5 5 7 6
^^^^^^^ | | ^^^
這就是三明治的原理了,左邊是小於 pivot 的,中間是等於 pivot 的,右邊是大於 pivot 的。中間部分不參與遞迴,分治的是兩邊。
我們需稍稍改變下 partition
的實現,顯然我們這次希望返回的兩個支點(左右邊界):
// 3-way partition
std::pair<int, int> partition(int arr[], int l, int r) {
int k = l, p = r;
for (int i = l; i < p; )
if (arr[i] < arr[r]) std::swap(arr[i++], arr[k++]);
else if (arr[i] == arr[r]) std::swap(arr[i], arr[--p]);
else ++i;
// move pivots to centre
int m = std::min(p-k, r-p+1);
for (int i = 0; i < m; ++i)
std::swap(arr[k+i], arr[r-i]);
return std::make_pair(k, r-p+k);
}
而 quicksort
也需要稍稍改變一點:
void quicksort(int arr[], int l, int r) {
if (l < r) {
auto pivot = partition(arr, l, r);
quicksort(arr, l, pivot.first-1);
quicksort(arr, pivot.second+1, r);
}
}
如果在遇到全部元素相同的情況,時間複雜度成功的減少到 O(N). 算是一個突破吧。
東北亂燉
其實 CLRS 隨後還提到了以隨機位置為 pivot 的思路,我稱之為東北亂燉。那是隻針對 pivot 選取的改變,基於上述程式碼,改造起來是非常容易的。這裡就不做過多實現,留作感興趣者自己練習吧。
上述三道菜,基本能夠解決面試中可能遇到的種種情況了。讓面試官吃飽,很有必要~