前一段時間,實驗室的一哥們突然跑過來跟我說,“我自己寫了個C的快速排序,排了一個10000000個int的數組,貌似比C庫中是qsort算法要快,咋回事?C++的STL中快排(quick sort)算法的效率如何?”。
聽他這么一說,我就立即做了個實驗,寫了如下代碼:
#include <iostream> #include <algorithm> #include <time.h> using namespace std; #define MAX_LEN 10000000 int arri[MAX_LEN]; int compare(const void* i, const void* j) { return (*(int*)i - *(int*)j); } int main() { for (int i = 0; i < MAX_LEN; i++) { arri[i] = rand() % MAX_LEN; } ::clock_t start, finish; //STL sort start = ::clock(); sort(arri, arri + MAX_LEN); finish = ::clock(); cout << "STL sort:\t" << finish - start << "ms" << endl; for (int i = 0; i < MAX_LEN; i++) { arri[i] = rand() % MAX_LEN; } //C qsort start = ::clock(); qsort(arri, MAX_LEN, sizeof(arri[0]), compare); finish = ::clock(); cout << "C qsort:\t\t" << finish - start << "ms" << endl; return 0; }
我機器上裝的是CodeBlocks(10.05)+MinGW(4.7.2)的環境。
首先,在debug模式下,CodeBlocks顯示出當前的編譯器命令:
mingw32-g++.exe -Wall -fexceptions -g -std=c++0x -c D:\Workspaces\CodeBlocks\TestSortQ\main.cpp -o obj\Debug\main.o
mingw32-g++.exe -o bin\Debug\TestSortQ.exe obj\Debug\main.o
運行結果:
運行結果讓我大吃一驚,STL不可能這么慢啊!后來仔細一想,這是debug模式,沒有加任何優化,加上優化看看什么結果:
mingw32-g++.exe -Wall -fexceptions -O2 -std=c++0x -c D:\Workspaces\CodeBlocks\TestSortQ\main.cpp -o obj\Release\main.o
mingw32-g++.exe -o bin\Release\TestSortQ.exe obj\Release\main.o -s
運行結果:
果然,O2選項一加上,STL sort瞬間完成了逆襲,運行時間優化了75%,而C qsort優化前后變化不是很明顯,大概減少了10%。
問題來了,為什么C++標准庫的快排的優化效果如此明顯,而C庫的快排優化不是很明顯呢?
答案是inline。
我們知道,STL是泛型編程的傑出成果,里面的容器、迭代器、算法幾乎都是通過泛型實現的,使得STL的通用性很強。泛型編程的一個負面效果就是破壞了接口與實現的分離,即頭文件中聲明,源文件中實現,源文件單獨編譯成庫,用戶只需要拿到頭文件和庫就可以使用了,看不到具體實現,這就是所謂的ABI,也是C的傳統做法。有人會問,為什么不能做到接口與實現的分離,因為泛型編程中的函數和類,在沒有接受一個模版參數之前,是沒辦法實例化的,只有當用戶給定了模版參數的時候,編譯器才會去實例化一個具體的類或函數。
如,C++ STL中的快速排序算法定義:
template< class RandomIt > void sort( RandomIt first, RandomIt last );
sort(arri, arri + MAX_LEN);
編譯器通過自動類型推導,知道了RandomIt其實是一個int*,於是產生這個函數:
sort(int*, int*);
具體實現中的RandomIt已經都替換成相應的int*,一個完整的函數就產生了。
關鍵的問題出現了!編譯器在實例化一個函數(類也一樣)的時候,它必須知道具體的實現代碼,才能夠產生完整的函數。這也是為什么如果大家自己寫模版的時候,.h文件和.cpp文件的關系變得十分奇怪的原因,一般做飯是在.h文件末尾,#include xx.cpp,其中xx.cpp中實現了.h文件中的函數聲明,或者干脆直接在.h文件中寫實現。否則,如果按照一般的.h和.cpp的關系,編譯器會報錯,說找不到函數的實現。寫過模版的程序猿應該都知道這個。
事實上,C++標准委員會為了解決這個問題,曾經引入了export關鍵字,來試圖解決這個問題,但很少有編譯器實現了(估計是實現難度較大,且增加了復雜度,得不償失),所以這個關鍵字后來基本上費了。
大家或許會問,你是不是走題了,剛開始不是討論C++的效率問題嗎?怎么說了半天泛型和模版的事情了?
答案是,真是由於泛型的這個“副作用”,使得編譯器可以做更多的優化!
既然編譯器知道具體的實現,那么inline是編譯器可以在優化上大顯身手的一個手段,sort函數中需要一個compare函數(在C++中還可以通過函數對象或者操作符重載實現)來知道如何比較兩個元素的大小,sort函數每次比較的時候,都會調用這個函數。對於一個10000000個元素的數組,一共會調用多少次這個compare函數是可想而知的(具體數目可以算出來),而一次函數調用的開銷比較大,如棧的分配等等,這就很大程度上限制了C庫中的qsort的威力,因為qsort的實現是在編譯在庫中的,它所調用的函數就沒法inline到qsort函數里面去。但是STL是可以做到的,所以它的優化效果非常明顯。
另外一個STL sort效率高的原因,在於算法的實現,不僅僅是快速排序算法,估計這也是為什么名字叫sort而不叫qsort的原因吧。在SGI STL(GNU所使用的STL)的實現中,sort函數一共采用了三種排序算法,分別是quick sort,heap sort和insert sort。使用策略如下:
1、函數主體為quick sort,但是在遞歸調用的時候,加上了一個參數記錄迭代層數,如果迭代次數超過一定數目,轉而采用heap sort。原因是如果迭代次數過多,很可能意味着quick sort落入了最壞情況(O(n2)),而heap sort的最壞情況依然是O(nlogn)。
2、當數組划分到很小的一段時,采用insert sort。原因是對於小數據量采用quick sort有些不划算(quick sort適合處理大量數據),因為quick sort本身是遞歸的,遞歸就是一次函數調用,開銷較大。而insert sort在較小數據量的情況下,表現很好。
具體算法可參考SGI STL和侯捷的《STL源碼剖析》。
說了這么一大堆,其實想表達的一點就是,C++為了提高效率,可以說無所不用其極,無論是STL算法實現,還是編譯器的優化(語言本身為了編譯器能做優化也下了很多功夫),都體現了C++的三大設計思想(或者叫做約束,可參考孟岩《關於C++復雜性的零碎思考》)之一:最高性能。
回到那位哥們的問題,為什么他的快排效率比C庫的要高?我不否認他的水平,但是我感覺最大的原因還是,自己寫的函數,編譯器可以將compare的功能內斂到函數里面去,所以效率比C庫的qsort效率要高。
關於C++的高效,我還想繼續寫一些文章,這篇博客且當作一個開始吧。