STL之sort函數詳解 ( 爲什麼sort要用插入排序? 爲什麼插入排序在大致有序的情況下效率會比較高 ? )

一朋友面試的時候被問到了STL裏的sort函數,被懟到懷疑人生,我聽了那些問題發現也不會,研究了好久,網上也沒有詳細解釋的,今天突然靈感爆發,想明白了幾個問題
可能有的人會覺得sort這麼簡單, 有什麼好問的, 那你可以看看如下幾個問題你能否答得上來

sort是用什麼排序實現的?(或者說sort如何優化?)

實際上,STL中的sort是一種混合排序,它應用了快速排序、堆排序和插入排序,以下是各個排序應用時的情況:

  • 開始時使用的是快速排序
  • 當遞歸深度超過logn時,爲了防止快速排序退化,sort會改用爲堆排序
  • 當遞歸深度小於logn時,但是區間長度小於等於16時,改用插入排序

當你回答出這些時,面試官就可以繼續深入問下去了

快速排序是怎麼實現的?(時空複雜度)

這個問題我一般都這麼回答:快速排序是基於分治法的一種排序法,對於整個排序區間,先找一個樞紐,比如這個區間的第一個數,然後把比這個樞紐小的數放左邊,比樞紐大的放右邊,對樞紐的左右兩個區間進行剛剛相同的步驟,當區間長度都爲1時,就排好序了

時間複雜度爲nlogn,空間複雜度爲logn

快速排序最差複雜度?如何優化?

快速排序最差複雜度爲n^2

快速排序優化就只有一個地方,那就時樞紐的選擇,優化方法有兩種,一種是在區間內隨機選取一個樞紐,第二種就是STL中sort快速排序部分使用的優化:取區間第一個數,中間的數以及最後一個數的中位數作爲樞紐

快速排序中如何實現你說的“小的放樞紐左邊,大的放樞紐右邊”這個操作?

實際上這個操作在STL裏有個函數可以專門實現——partition函數,它的複雜度是o(n)的,它的實現過程如下:

取區間第一個數,中間的數以及最後一個數的中位數作爲樞紐,把該樞紐與區間第一個數位置交換一下,用一個臨時變量儲存樞紐,然後使用雙指針的思想,i 指針指向區間第一個數,j 指針指向區間最後一個數,接下來有兩個操作

  1. j 指針從後往前跑,當找到一個比樞紐小的數,便將該數放到i指針的位置(直接覆蓋)
  2. i 指針從前往後跑,當找到一個比樞紐大的數,便將該數放到j指針的位置(直接覆蓋)

交替重複以上兩個操作,當i和j指針相遇時,把樞紐放入相遇位置就行了

爲什麼sort要用堆排序?

這個時候就不要再回答爲了防止快速排序退化了,其實面試官想問的是nlogn複雜度的排序算法還有比如歸併排序,那爲什麼要選擇堆排序?

這個時候就要從空間複雜度上回答了,堆排序是可以原地實現的,空間複雜度爲o(1),而歸併排序空間複雜度爲o(n)

堆排序具體怎麼實現的?

這時候就不要說什麼用堆實現,不要就講一下堆的結構什麼的,面試官都問具體實現了,那麼建堆操作也是要具體講清楚的,不多解釋,直接上代碼

//代碼來自https://github.com/huihut/interview/blob/master/Algorithm/HeapSort.cpp

#include <iostream>
#include <algorithm>
using namespace std;

// 堆排序:(最大堆,有序區)。從堆頂把根卸出來放在有序區之前,再恢復堆。

void max_heapify(int arr[], int start, int end) {
	//建立父節點指標和子節點指標
	int dad = start;
	int son = dad * 2 + 1; //它數組從0開始,所以堆中父親左右兒子是dad*2+1和dad*2+2
	while (son <= end) { //若子節點指標在範圍內才做比較
		if (son + 1 <= end && arr[son] < arr[son + 1]) //先比較兩個子節點大小,選擇最大的
			son++;
		if (arr[dad] > arr[son]) //如果父節點大於子節點代表調整完畢,直接跳出函數
			return;
		else { //否則交換父子內容再繼續子節點和孫節點比較
			swap(arr[dad], arr[son]);
			dad = son;
			son = dad * 2 + 1;
		}
	}
}

void heap_sort(int arr[], int len) {
	//初始化,i從最後一個父節點開始調整(就是從葉子的父親開始調整)
	for (int i = len / 2 - 1; i >= 0; i--)
		max_heapify(arr, i, len - 1);
	//先將第一個元素和已經排好的元素前一位做交換,再從新調整(剛調整的元素之前的元素),直到排序完畢(想到於不斷地把堆頂取出來放後面,類似選擇排序的過程,只不過用堆進行了優化)
	for (int i = len - 1; i > 0; i--) {
		swap(arr[0], arr[i]);
		max_heapify(arr, 0, i - 1);
	}
}

int main() {
	int arr[] = { 3, 5, 3, 0, 8, 6, 1, 5, 8, 6, 2, 4, 9, 4, 7, 0, 1, 8, 9, 7, 3, 1, 2, 5, 9, 7, 4, 0, 2, 6 };
	int len = (int) sizeof(arr) / sizeof(*arr);
	heap_sort(arr, len);
	for (int i = 0; i < len; i++)
		cout << arr[i] << ' ';
	cout << endl;
	return 0;
}

爲什麼不一開始就用堆排序?

爲什麼面試官會這麼問呢?因爲他已經問了你堆排序和快速排序的實現,這個時候你對他們的時空複雜度已經很清楚了,這時候你意識到其實堆排序的空間複雜度比快速排序還要低(堆排序o(1),快速排序o(logn) ),而且堆排序還不會退化。

所以面試官想知道爲什麼快速排序效率要比堆排序高?

這個問題如果沒去了解過,真的很難回答

實際上我們學習算法,研究的都是理論複雜度,而實際工程應用時,還會考慮算法實際的效率,這不僅涉及到算法的理論複雜度,還涉及到硬件、操作系統等問題

而快速排序就是一個典型的例子

同樣是nlogn的時間複雜度,爲什麼堆排序和歸併排序整體效率不如快速排序呢?這是因爲計算機硬件中有一個高速緩存區(cache),它的訪問讀取速度非常快(比內存還要快很多),它通常集成在CPU上,所以其容量十分有限,通常CPU會把經常訪問到的數暫存到緩存裏,CPU找數據也會先從緩存裏找。

快速排序因爲其用到了一個樞紐,這個樞紐的訪問次數非常之多,那麼其就會被放入緩存中,那麼訪問樞紐的效率就會非常高,所以快速排序整體效率會比其他幾個排序要高。

爲什麼sort要用插入排序? (或者說爲什麼插入排序在大致有序的情況下效率會比較高 ?)

當數列大致有序時,比如我們現在每個區間的大小已經排好了,但是區間內16個數字還沒有排好,這時候插入排序的表現會更好。

但是爲什麼呢?插入排序的複雜度爲n^2,即使區間長度比較小,但是其複雜度並不會因此降低啊,這個問題困擾了我很久,但是突然有一天我想明白了

插入排序複雜度爲n^2,那我們考慮最壞情況,每個區間的複雜度都是
15+14++1+0=15(15+1)/21 15 + 14 + …… + 1 + 0 = 15 * (15 + 1)/2………………………………(1)
即插入排序移動次數之和

那麼這時候其實就只有 n / 16個大小爲16的區間,那麼實際上用插入排序 排序這n / 16個區間的複雜度爲
(1)n/16=7.5n (1) * n / 16 = 7.5n
平均複雜度假設是最壞複雜度的一半,即
7.5n/2=3.25n2 7.5n / 2 = 3.25n………………………………(2)
再回到快速排序部分,按一般情況,遞歸到區間長度爲16時候的複雜度爲
n(lognlog16)3 n*(logn - log16)………………………………(3)
如果n比較大,(3)可以約等於nlogn

那麼總的複雜度就是
(2)+(3)=(0.75+logn)n4 (2) + (3) = (-0.75 + logn)*n………………………………(4)

是不是有點意想不到?以上推導很多博客都只是說一筆略過,說什麼插入排序在大致有序的情況下效率更高,但是爲什麼卻沒有講,很多東西不清楚原理,面試的時候就很容易露餡了

總結

沒怎麼接觸工程的時候,我們總是僅僅考慮算法的理論複雜度,實際上,理論複雜度往往只是工程代碼設計上的一個參考,要考慮整體效率,往往還要考慮諸如計算機硬件、編譯器、操作系統等,再對算法進行優化

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章