演算法的入門級研究一般都是從“排序”和“尋找”開始的。“排序演算法”和她的姊妹“尋找演算法”是很多複雜演算法的基礎,也是很多複雜系統的基礎。比如Linux中最複雜的虛擬記憶體管理就是基於“紅-黑樹”尋找演算法的;Solaris是基於AVL樹尋找演算法;MySQL是基於B樹尋找演算法;P2P技術是基於DHT雜湊演算法……(待補充。。。)
今天推出的中級程式員必須懂的20大基礎演算法專題,通過介紹各種基本排序和尋找演算法,梳理一下電腦演算法的基礎,為後面介紹那些複雜演算法做鋪墊。這裡首先從各種排序演算法的梳理開始.。
對於排序的演算法我想先做一點簡單的比較,首先是感性比較(比較符合我的性格一點),通過編程把《資料結構》中9大經典排序演算法的時間效能進行比較,我的環境是
VC6.0(Release)+win2000pro+128MDDR+P4(1.6G)
因為在多任務作業系統下,系統將進行進程式調度,影響實驗結果。以下是經過稍微修正過的值。如果要取得更準確的值,我們得多次實驗求其平均值
。
排序演算法實驗比較(單位:秒)
|
方法 |
1K |
10K |
100K |
200K |
100K |
|
正序 |
逆序 |
|
冒泡排序 |
0 |
0.422 |
44.790 |
188.462 |
0 |
31.459 |
|
冒泡排序2 |
0 |
0.281 |
30.335 |
131.771 |
0 |
27.568 |
|
快速排序 |
0 |
0 |
0.016 |
0.047 |
5.095 |
7.002 |
|
直接選擇排序 |
0 |
0.141 |
16.878 |
79.332 |
16.785 |
33.242 |
|
堆排序 |
0 |
0 |
0.031 |
0.109 |
0.031 |
0.015 |
|
直接插入排序 |
0 |
0.047 |
8.705 |
57.800 |
0 |
24.865 |
|
Shell排序 |
0 |
0 |
0.047 |
0.110 |
0.015 |
0.015 |
|
歸併排序 |
0 |
0 |
0.031 |
0.094 |
0.032 |
0.032 |
|
基數排序 |
0 |
0 |
0.47 |
0.109 |
0.047 |
0.046 |
有了感性認識,我們再來理性認識它:
(1)穩定性比較
插入排序、冒泡排序、二叉樹排序、二路歸併排序及其他線形排序是穩定的
選擇排序、希爾排序、快速排序、堆排序是不穩定的
(2)時間複雜性比較
插入排序、冒泡排序、選擇排序的時間複雜性為O(n^2)
其它非線形排序的時間複雜性為O(nlog2n)
線形排序的時間複雜性為O(n)(線性排序包括計數排序、基數排序後面會介紹);
(3)輔助空間的比較
線形排序、二路歸併排序的輔助空間為O(n),其它排序的輔助空間為O(1);
(4)其它比較
插入、冒泡排序的速度較慢,但參加排序的序列局部或整體有序時,這種排序能達到較快的速度。反而在這種情況下,快速排序反而慢了。
當n較小時,對穩定性不作要求時宜用選擇排序,對穩定性有要求時宜用插入或冒泡排序。
若待排序的記錄的關鍵字在一個明顯有限範圍內時,且空間允許是用桶排序。
當n較大時,關鍵字元素比較隨機,對穩定性沒要求宜用快速排序。
當n較大時,關鍵字元素可能出現本身是有序的,對穩定性有要求時,空間允許的情況下。宜用歸併排序。
當n較大時,關鍵字元素可能出現本身是有序的,對穩定性沒有要求時宜用堆排序。
綜上,我們編程時,最常見的情況是n較大時,元素比較隨機,但對穩定性一般不作要求,所以引出快速排序。
快速排序的演算法思想如下:首先檢查資料列表中的資料數,如果小於兩個,則直接退出程式。如果有超過兩個以上的資料,就選擇一個分割點將資料分成兩個部分,小於分割點的資料放在一組,其餘的放在另一組,然後分別對兩組資料排序。 通常分割點的資料是隨機選取的。這樣無論你的資料是否已被排列過,你所分割成的兩個字列表的大小是差不多的。而只要兩個子列表的大小差不多。
#include <iostream.h> void run(int* pData,int left,int right) { int i,j; int middle,iTemp; i = left; j = right; middle = pData[(left+right)/2]; //求中間值 do{ while((pData[i]<middle) && (i<right))//從左掃描大於中值的數 i++; while((pData[j]>middle) && (j>left))//從右掃描大於中值的數 j--; if(i<=j)//找到了一對值 { //交換 iTemp = pData[i]; pData[i] = pData[j]; pData[j] = iTemp; i++; j--; } }while(i<=j);//如果兩邊掃描的下標交錯,就停止(完成一次) //當左邊部分有值(left<j),遞迴左半邊 if(left<j) run(pData,left,j); //當右邊部分有值(right>i),遞迴右半邊 if(right>i) run(pData,i,right); } void QuickSort(int* pData,int Count) { run(pData,0,Count-1); } void main() { int data[] = {10,9,8,7,6,5,4}; QuickSort(data,7); for (int i=0;i<7;i++) cout<<data[i]<<" "; cout<<"/n"; }
快速排序是冒泡排序的改進,它通過一次交換能消除多個逆序,這樣可以減少逆序時所消耗的掃描和資料交換次數。在最優情況下,它的排序時間複雜度為O(nlog2n)。即每次劃分序列時,能均勻分成兩個子串。但最差情況下它的時間複雜度將是O(n^2)。即每次劃分子串時,一串為空白,另一串為m-1(程式中的100K正序和逆序就正是這樣,如果程式中採用每次取序列中部資料作為劃分點,那將在正序和逆時達到最優)。從100K中正序的結果上看“快速排序”會比“冒泡排序”更慢,這主要是“冒泡排序”中採用了提前結束排序的方法。有的書上這解釋“快速排序”,在理論上講,如果每次能均勻劃分序列,它將是最快的排序演算法,因此稱它作快速排序。雖然很難均勻劃分序列,但就平均效能而言,它仍是基於關鍵字比較的內部排序演算法中速度最快者。
冒泡排序的核心思想是掃描資料清單,尋找出現亂序的兩個相鄰的項目。當找到這兩個項目後,交換項目的位置然後繼續掃描。重複上面的操作直到所有的項目都按順序排好:
#include <iostream.h> void BubbleSort(int* pData,int Count) { int iTemp; for(int i=1;i<Count;i++) { for(int j=Count-1;j>=i;j--) { if(pData[j]<pData[j-1]) { iTemp = pData[j-1]; pData[j-1] = pData[j]; pData[j] = iTemp; } } } } void main() { int data[] = {10,9,8,7,6,5,4}; BubbleSort(data,7); for (int i=0;i<7;i++) cout<<data[i]<<" "; cout<<"/n"; }
在最優情況下只需要經過n-1次比較即可得出結果,(這個最優情況那就是序列己是正序,從100K的正序結果可以看出結果正是如此),但在最壞情況下,即倒序(或一個較小值在最後),下沉演算法將需要n(n-1)/2次比較。所以一般情況下,特別是在逆序時,它很不理想。它是對資料有序性非常敏感的排序演算法。
通常的冒泡是單向的,還有一種雙向的冒泡演算法,也就是說還要進行反向的工作。雙向冒泡代碼看起來複雜,仔細理一下就明白了,是一個來回震蕩的方式。寫這段代碼的作者認為這樣可以在冒泡的基礎上減少一些交換:
#include <iostream.h> void Bubble2Sort(int* pData,int Count) { int iTemp; int left = 1; int right =Count -1; int t; do { //正向的部分 for(int i=right;i>=left;i--) { if(pData[i]<pData[i-1]) { iTemp = pData[i]; pData[i] = pData[i-1]; pData[i-1] = iTemp; t = i; } } left = t+1; //反向的部分 for(i=left;i<right+1;i++) { if(pData[i]<pData[i-1]) { iTemp = pData[i]; pData[i] = pData[i-1]; pData[i-1] = iTemp; t = i; } } right = t-1; }while(left<=right); } void main() { int data[] = {10,9,8,7,6,5,4}; Bubble2Sort(data,7); for (int i=0;i<7;i++) cout<<data[i]<<" "; cout<<"/n"; }
它是冒泡排序的改良(一次下沉再一次上浮),最優情況和最壞情況與冒泡排序差不多,但是一般情況下它要好過冒泡排序,它一次下沉,再一次上浮,這樣避免了因一個數的逆序,而造成巨大的比較。如(2,3,4,…,n-1,n,1),用冒泡排序需要n(n-1)/2次比較,而此排序只要3輪,共比較(n-1)+(n-2)+(n-3)次,第一輪1將上移一位,第二輪1將移到首位,第三輪將發現無資料交換,序列有序而結束。但它同樣是一個對資料有序性非常敏感的排序演算法,只適合於資料基本有序的排序。
介紹完了快速排序以及其穩定版的冒泡排序,接下來附帶介紹三個簡單的比較排序。這三個方法很簡單,作為跟快速排序對比的排序方法。
(1)直接選擇排序
演算法思想:首先找到資料清單中的最小的資料,然後將這個資料同第一個資料交換位置;接下來找第二小的資料,再將其同第二個資料交換位置,以此類推。
這種方法類似我們人為的排序習慣:從資料中選擇最小的同第一個值交換,在從省下的部分中選擇最小的與第二個交換,這樣往複下去。
#include <iostream.h> void SelectSort(int* pData,int Count) { int iTemp; int iPos; for(int i=0;i<Count-1;i++) { iTemp = pData[i]; iPos = i; for(int j=i+1;j<Count;j++) { if(pData[j]<iTemp) { iTemp = pData[j]; iPos = j; } } pData[iPos] = pData[i]; pData[i] = iTemp; } } void main() { int data[] = {10,9,8,7,6,5,4}; SelectSort(data,7); for (int i=0;i<7;i++) cout<<data[i]<<" "; cout<<"/n"; }
簡單的選擇排序,它的比較次數一定:n(n-1)/2。也因此無論在序列何種情況下,它都不會有優秀的表現(從上100K的正序和反序資料可以發現它耗時相差不多,相差的只是資料移動時間),可見對資料的有序性不敏感。它雖然比較次數多,但它的資料交換量卻很少。所以我們將發現它在一般情況下將快於冒泡排序。
(2)直接插入排序
插入法較為複雜,它的基本工作原理是抽出牌,在前面的牌中尋找相應的位置插入,然後繼續下一張。演算法思想:經過i-1遍處理後,L[1..i-1]己排好序。第i遍處理僅將L[i]插入L[1..i-1]的適當位置,使得L[1..i]又是排好序的序列。要達到這個目的,我們可以用順序比較的方法。首先比較L[i]和L[i-1],如果L[i-1]≤ L[i],則L[1..i]已排好序,第i遍處理就結束了;否則交換L[i]與L[i-1]的位置,繼續比較L[i-1]和L[i-2],直到找到某一個位置j(1≤j≤i-1),使得L[j]
≤L[j+1]時為止。
#include <iostream.h> void InsertSort(int* pData,int Count) { int iTemp; int iPos; for(int i=1;i<Count;i++) { iTemp = pData[i]; iPos = i-1; while((iPos>=0) && (iTemp<pData[iPos])) { pData[iPos+1] = pData[iPos]; iPos--; } pData[iPos+1] = iTemp; } } void main() { int data[] = {10,9,8,7,6,5,4}; InsertSort(data,7); for (int i=0;i<7;i++) cout<<data[i]<<" "; cout<<"/n"; }
簡單的插入排序,每次比較後最多移掉一個逆序,因此與冒泡排序的效率相同。但它在速度上還是要高點,這是因為在冒泡排序下是進行值交換,而在插入排序下是值移動,所以直接插入排序將要優於冒泡排序。直接插入法也是一種對資料的有序性非常敏感的一種演算法。在有序情況下只需要經過n-1次比較,在最壞情況下,將需要n(n-1)/2次比較。
(3)歸併排序
歸併演算法(merge),也叫合并演算法,指的是將兩個已經排序的序列合并成一個序列的操作。
如 設有數列{6,202,100,301,38,8,1}
初始狀態: [6] [202] [100] [301] [38] [8] [1]
比較次數 i=1 [6 202 ] [ 100 301] [ 8 38] [ 1 ] 3
i=2 [ 6 100 202 301 ] [ 1 8 38 ] 4
i=3 [ 1 6 8 38 100 202 301 ] 4
總計: 11次
void MergeSort(int array[], int first, int last){ int mid = 0; if(first<last) { mid = (first+last)/2; MergeSort(array, first, mid); MergeSort(array, mid+1,last); Merge(array,first,mid,last); }}
我們可以看到,歸併演算法運用了遞迴非常簡單,歸併排序是一種非就地排序,將需要與待排序序列一樣多的輔助空間。在使用它對兩個己有序的序列歸併,將有無比的優勢。其時間複雜度無論是在最好情況下還是在最壞情況下均是O(nlog2n)。對資料的有序性不敏感。若資料節點資料量大,那將不適合。但可改造成索引操作,效果將非常出色。
最後,我在網上找到一個最漂亮的快速排序代碼,使用C++模板類來寫的,以作為本篇文章的結尾:
//MyData.h檔案 /////////////////////////////////////////////////////// class CMyData { public: CMyData(int Index,char* strData); CMyData(); virtual ~CMyData(); int m_iIndex; int GetDataSize(){ return m_iDataSize; }; const char* GetData(){ return m_strDatamember; }; //這裡重載了操作符: CMyData& operator =(CMyData &SrcData); bool operator <(CMyData& data ); bool operator >(CMyData& data ); private: char* m_strDatamember; int m_iDataSize; }; //////////////////////////////////////////////////////// MyData.cpp檔案 //////////////////////////////////////////////////////// CMyData::CMyData(): m_iIndex(0), m_iDataSize(0), m_strDatamember(NULL) { } CMyData::~CMyData() { if(m_strDatamember != NULL) delete[] m_strDatamember; m_strDatamember = NULL; } CMyData::CMyData(int Index,char* strData): m_iIndex(Index), m_iDataSize(0), m_strDatamember(NULL) { m_iDataSize = strlen(strData); m_strDatamember = new char[m_iDataSize+1]; strcpy(m_strDatamember,strData); } CMyData& CMyData::operator =(CMyData &SrcData) { m_iIndex = SrcData.m_iIndex; m_iDataSize = SrcData.GetDataSize(); m_strDatamember = new char[m_iDataSize+1]; strcpy(m_strDatamember,SrcData.GetData()); return *this; } bool CMyData::operator <(CMyData& data ) { return m_iIndex<data.m_iIndex; } bool CMyData::operator >(CMyData& data ) { return m_iIndex>data.m_iIndex; } /////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////// //主程式部分 #include <iostream.h> #include "MyData.h" template <class T> void run(T* pData,int left,int right) { int i,j; T middle,iTemp; i = left; j = right; //下面的比較都調用我們重載的操作符函數 middle = pData[(left+right)/2]; //求中間值 do{ while((pData[i]<middle) && (i<right))//從左掃描大於中值的數 i++; while((pData[j]>middle) && (j>left))//從右掃描大於中值的數 j--; if(i<=j)//找到了一對值 { //交換 iTemp = pData[i]; pData[i] = pData[j]; pData[j] = iTemp; i++; j--; } }while(i<=j);//如果兩邊掃描的下標交錯,就停止(完成一次) //當左邊部分有值(left<j),遞迴左半邊 if(left<j) run(pData,left,j); //當右邊部分有值(right>i),遞迴右半邊 if(right>i) run(pData,i,right); } template <class T> void QuickSort(T* pData,int Count) { run(pData,0,Count-1); } void main() { CMyData data[] = { CMyData(8,"xulion"), CMyData(7,"sanzoo"), CMyData(6,"wangjun"), CMyData(5,"VCKBASE"), CMyData(4,"jacky2000"), CMyData(3,"cwally"), CMyData(2,"VCUSER"), CMyData(1,"isdong") }; QuickSort(data,8); for (int i=0;i<8;i++) cout<<data[i].m_iIndex<<" "<<data[i].GetData()<<"/n"; cout<<"/n"; }