1. 程式人生 > >常見排序演算法的基本原理、程式碼實現和時間複雜度分析

常見排序演算法的基本原理、程式碼實現和時間複雜度分析

  排序演算法無論是在實際應用還是在工作面試中,都扮演著十分重要的角色。最近剛好在學習演算法導論,所以在這裡對常見的一些排序演算法的基本原理、程式碼實現和時間複雜度分析做一些總結 ,也算是對自己知識的鞏固。
說明:
1.本文所有的結果均按照非降序排列;
2.本文所有的程式均用c++實現,但由於自己剛開始學習c++,所以程式碼中存在很多的C風格,而且可能也有對C++語法運用不恰當的地方,歡迎批評指正。

一、插入排序

1、原理:

  插入排序將一個位置陣列seq分為兩個部分:已排序的部分seq1和未排序的部分seq2。程式依次遍歷seq2中的元素seq2[i],在seq1中尋找合適的位置插入。

2、實現:

  為了方便元素插入時的移位操作,首先將seq2中待插入的元素seq2[i]儲存為key。然後在seq1中從後往前地依次比較key與seq1[i]的大小,每遍歷一個大於等於key的元素,就將其向後移動一位;當找到一個小於key的元素時,該元素不再移動,其後一個位置就是key要插入的位置。

void  InsertionSort(vector<int> &seq) 
{
    for(unsigned int i = 1; i != seq.size(); i++){
        int key = seq[i];
        unsigned int j = i - 1;
        while(j >= 0 && seq[j] > key){
            seq[j + 1] = seq[j];
            j--;
        }
        seq[j + 1] = key;
    }
}

3、時間複雜度分析:

  插入排序的時間複雜度分析比較簡單。從原理中可以看出,插入排序最多需要進行兩層遍歷,即每遍歷到一個元素,均需要對它前面的有序元素進行一次遍歷,找到相應的插入位置;從程式碼中也可以看出程式需要執行兩層迴圈,故插入排序的時間複雜度為O(n2)。

二、快速排序

1、原理:

  快速排序採用“分治”的思想,即首先從輸入的陣列seq中選出一個元素x作為主元,然後將所有小於等於x的元素放在x的左邊,組成一個子陣列;大於x的元素放在x的右邊,組成另一個子陣列。接著對這兩個子陣列重複上述的步驟,直至子陣列中只有一個元素。由於在“分”的過程中,各個元素的大小順序就已經確定,故不需要合併的操作。

2、實現:

  根據原理,快速排序的基本過程用虛擬碼可表示為:

QuickSort(A, p, r)
	if p < r
		q = Partition(A, p, r)
		QuickSort(A, p, q-1)
		QuickSort(A, q+1, r)

  可見快速排序的實現關鍵就在於對陣列的分割Partition的實現。下面介紹兩種方法來實現這一步驟。

(1)法一:

  將陣列分為四個區域,從前往後依次為小於等於x的區域一,大於x的區域二,待操作的區域三以及主元x所在的區域四(即陣列的最後一個位置)。從前往後依次遍歷待操作的區域三的所有元素,若元素大於x,則位置不變,此時區域二就向後延長了一個位置,如下圖中(a)所示;若元素小於等於x,則將該元素與區域一的後一個元素(即區域二的第一個元素)的位置互換(注意:區域一的所有元素均小於x,但區域一的元素並不是按從小到大排序的,故進行位置交換時不需要將整個區域向後移動),此時區域一就向後延長了一個位置,區域二整體向後移動了一個位置,如下圖中(b)所示。將區域三所有的元素遍歷之後,區域三即消失,此時再將主元x與區域二的第一個元素位置互換。至此,實現了以x為主元對陣列的分割。
說明:為了防止陣列的排列使演算法的時間時間度最大的情況出現,可在每次分割前,隨機從陣列中選擇一個數作為主元,再將主元與陣列的最後一個元素進行位置互換(由於小於等於x的區域和大於x的區域是動態增長的,因此在進行分割操作前,x的位置一定位於陣列的第一個或最後一個位置)。
avatar

程式碼實現如下:

void QuickSort1(vector<int> &seq, int left, int right)   //left和right是下標
{
    if(left < right){
    	/*隨機選擇一個元素作為主元,並將其與陣列最後一個元素位置互換*/
        srand((unsigned int)time(0));
        int tmp = rand()%(right - left + 1) + left;  
        exchange(seq[tmp], seq[right]);
        
        int x = seq[right];    //陣列的最後一個元素作為主元
        int i = left - 1;      //seq[i]為區域一的最後一個元素
        for(int j = left; j < right; j++){  //seq[j]為區域三的第一個元素
        /*若seq[j]小於等於x,則將其與區域二的第一個元素位置互換*/
            if(seq[j] <= x){
                i = i + 1;
                exchange(seq[i], seq[j]); 
            }
        }
        exchange(seq[i + 1], seq[right]); //將主元插在區域二的第一個位置

        QuickSort1(seq, left, i);    //主元的位置為i+1
        QuickSort1(seq, i + 2, right);
    }
}

(2)法二:

  法二來自於網上看到的一篇部落格,其基本思想與法一有一些類似。選擇待排序陣列的第一個元素作為主元,陣列從前往後依次為x所在的區域一、小於等於x的區域二、待操作的區域三和大於x的區域四。先從後往前的遍歷區域三,直至找到一個小於x的元素,則該元素之後的區域三的所有位置均可劃到區域四中,然後將該元素插入到區域三的第一個位置,則區域二向後延長了一個位置;再從前往後的遍歷區域三,直至找到一個大於等於x的元素,則該元素之前區域三的所有位置均可劃到區域二中,再將該元素插入到區域三的最後一個位置,則區域四向前延長了一個位置。當區域二和區域四相遇時,區域三消失,所有的元素均完成遍歷操作,將主元插到區域二的最後一個位置。
  原文連結:https://blog.csdn.net/MoreWindows/article/details/6684558
  程式碼實現如下:

void QuickSort2(vector<int> &seq, int left, int right)
{
    if(left < right){
    	/*隨機產生一個主元*/
        srand((unsigned int)time(0));
        int tmp = rand()%(right - left + 1) + left; 
        exchange(seq[tmp], seq[left]);
        
        int x = seq[left];   //陣列的第一個元素作為主元
        int i = left;
        int j = right;

        while(i < j){
            /*從後往前查詢小於主元的數*/
            while(j > i && seq[j] >= x)
                j--;
            if(i < j){
                seq[i++] = seq[j];  //seq[i]為區域二的第一個元素
            }
            /*從前往後查詢大於主元的數*/
            while(i < j && seq[i] < x)
                i++;
            if(i < j){
                seq[j--] = seq[i];    //seq[j]為區域二的最後一個元素
            }   //每一次迴圈結束,均有s[i] = s[j+1]
        }   	//至此,小於i的位置均小於主元,大於j的位置均大於等於主元
        seq[i] = x;	//將主元插入到區域一的最後一個位置

        QuickSort2(seq, left, i - 1);	//主元的位置為i
        QuickSort2(seq, i + 1, right);
    }
}

3、時間複雜度分析:

  設規模為n的問題的時間複雜度為T(n),則由前面的分析可知,T(n) = T(q) + T(n-q-1) + θ(n)成立,其中q為小於等於主元的子陣列規模,θ(n)為Partition函式(本文將Partition整合到了快速排序的整個程式中了)的時間複雜度。求解快速排序的時間複雜度的關鍵在於瞭解Partition中每一個元素被比較的次數。對於該式的求解較為複雜,在此直接給出結論,對詳細的求解過程有興趣的讀者可參考《演算法導論》第七章《快速排序》的相關內容。
  快速排序排序的平均時間複雜度為O(nlgn)。
  快速排序的效能依賴於輸入資料的排列情況,也即在“分”的過程中對陣列的劃分情況。下面簡單的說明最好和最壞的情況劃分。
  用遞迴樹來分析前面的遞推式,假設原問題的代價為cn,其中c為常數,n為問題規模,將原問題以常數比例(假設為9:1)劃分為兩個子問題,再將這兩個子問題分別按照原比例劃分,重複該過程,直至問題的規模降為1。過程如下圖所示:

avatar

  由上圖可知,遞迴樹的每一層問題的總代價最大均為cn,則原問題的代價就取決於遞迴樹的層數,層數越多,問題的代價就越大。顯然,當遞迴樹為滿二叉樹時,層數最少,為lgn,此時總代價為cnlgn;考慮極端情況,當遞迴樹退化為線性結構,即每次將問題規模劃分為0和n-1兩個子問題時,層數最多,為n,此時總代價為cn2。故只要問題的規模按照常數比例劃分,快速排序的時間複雜度均為O(nlgn),當問題規模按照0和n-1的比例劃分時,快速排序的效能最差,時間複雜度為O(n2)。
  為避免最壞的情況出現,我們在選擇主元時進行了隨機化處理。雖然在理論上仍然有可能出現最壞情況,但可能性已經微乎其微。當然,我們要避免讓快速排序處理元素完全相同的輸入序列。

三、歸併排序

1、原理:

  歸併排序同樣採用“分治”的思想。演算法將待排序的序列平均分為兩個子序列,然後將這兩個子序列再分別平均分為兩個子序列,重複該過程,直至序列中只有一個元素,此時序列是有序的。最後再將兩個已排好序的子序列合併,產生已排序的結果。過程如下圖所示:
avatar
用偽程式碼表示該過程為:

MergeSort(A, p, r)
	if p < r
		q = (p + r) / 2
		MergeSort(A, p, q)
		MergeSort(A, q+1, r)
		Merge(A, p, q, r)

  由上述分析可知,歸併排序的重點在於如何實現對兩個有序子陣列的合併。

2、實現:

  Merge函式的輸入序列是一個由兩個長度相同的有序序列seq1和seq2組成的序列seq。首先比較seq1和seq2的第一個元素的大小,將其中較小的元素(假設為seq1[0])儲存到臨時序列tmp中,並將指向seq1元素的指標i向後移動一位,再比較seq1[1]與seq2[0]的大小,將其中較小的元素儲存到tmp中seq1[0]之後的位置,重複該過程,直至seq1和seq2其中的一個序列的所有元素均被儲存到tmp中,假設該序列為seq1,則此時seq2中元素可能沒有被完全遍歷,這些沒有被遍歷到的元素一定都大於此時tmp中的所有元素且是有序排列的,因此將seq2中這些沒有遍歷到的元素順序不變的儲存到tmp的尾部,即實現了對seq1和seq2這兩個有序序列的有序合併。最後用tmp中的元素覆蓋seq中的元素,就實現了對seq中所有元素的有序排列。程式碼實現如下:

void Merge(vector<int> &seq, int left, int mid, int right, vector<int> *tmp)
{
    int i = left, j = mid + 1;  //mid是前半個陣列的終點
    
	/*依次比較兩個字序列每個元素的大小*/
    while(i <= mid && j <= right){
        if(seq[i] <= seq[j]){
            (*tmp).push_back(seq[i++]);
        }
        else{
            (*tmp).push_back(seq[j++]);
        }
    }
    
    /*將未完全遍歷的元素接到tmp的尾部*/
    for(; i <= mid; i++)
        (*tmp).push_back(seq[i]);
    for(; j <= right; j++)
        (*tmp).push_back(seq[j]);

	/*用tmp中的有序序列覆蓋seq中的為排序序列*/
    for(unsigned int k = 0; k != (*tmp).size(); k++)
        seq[left + k] = (*tmp)[k];
    (*tmp).clear();	//不可漏,否則tmp會儲存每次遞迴過程中tmp儲存的所有元素,導致溢位
}

void MSort(vector<int> &seq, int left, int right, vector<int> *tmp)
{
    if(left < right){
        int mid = (right - left) / 2 + left;  
        MSort(seq, left, mid, tmp);
        MSort(seq, mid + 1, right, tmp);
        Merge(seq, left, mid, right, tmp);
    }
}

void MergeSort(vector<int> &seq, int left, int right)
{
    vector<int> *tmp = new vector<int>[seq.size()];

    if(!tmp)
        cout << "ERROR!" << endl;
    else
        MSort(seq, left, right, tmp);

    delete[] tmp;
}

3、時間複雜度分析:

  用遞迴樹來分析歸併排序的時間複雜度,如下圖所示:
avatar
  由上圖可知,遞迴樹高度為1 + lgn,每層的總代價為cn,則原問題的總代價為cnlgn + cn,故歸併排序的時間複雜度可表示為θ(nlgn)。

四、氣泡排序

1、原理:

  氣泡排序應該是我們最早接觸的,也是最為簡單的排序演算法。它從前向後地遍歷除最末尾的數的陣列中的每一個數,當遍歷到某一個數seq[i]時,便與它後面的一個數seq[i+1]作比較,若seq[i]較小,則seq[i]的位置不變,再遍歷下一個數seq[i+1];若seq[i]較大,則將它的位置與seq[i+1]的位置對調,再遍歷下一個數seq[i+1]。

2、實現:

  氣泡排序的實現較為簡單。每次遍歷之後,都會找出待遍歷數中最大的一個數,並將其放在待遍歷數的最後,則在下一次遍歷時,就不再遍歷之前已經被篩選出來的數。所以我們在編寫程式時,要注意一下遍歷結束時元素的下標。下面給出三種效率不同的實現方法。

(1)法一:

  第一種方法最為簡單,暴力遍歷每一個元素。

void BubbleSort1(vector<int> &seq)
{
    for(unsigned int i = 0; i != seq.size() - 1; i++){
        for(unsigned int j = 0; j != seq.size() - i; j++){
            if(seq[j] > seq[j + 1]){
                exchange(seq[j], seq[j + 1]);
            }
        }
    }
}

(2)法二:

  第一種方法不依賴於輸入的序列,無論輸入的序列怎樣排列,時間複雜度均為θ(n2),顯然對於某些輸入序列,這種方法會產生時間的浪費。如果在某一次遍歷中沒有發生元素位置的交換,則說明所有的元素已經按照從小到大的順序排列,那麼排序工作就已經完成,不需要進行下一次遍歷了。具體實現如下。

void BubbleSort2(vector<int> &seq)
{
    int flag = true;    //在一次遍歷中,若發生交換,則flag記為true,否則記為false
    unsigned int j = seq.size();

    while(flag){
        flag = false;	//每次遍歷開始之前均為發生元素的交換
        for(unsigned int i = 0; i != j; i++){
            if(seq[i] > seq[i + 1]){
                exchange(seq[i], seq[i + 1]);
                flag = true;
            }
        }
        j--;    //陣列末尾是已經排好序的,不再遍歷
    }
}

(3)法三:

  在前兩種方法中,只要某一次遍歷開始了,就一定會遍歷完所有待遍歷的元素。如果待遍歷的元素中有一部分已經是按照從小到大的順序排列了,則遍歷這部分元素顯然會產生時間上的浪費,故可對第二種方法繼續優化。
  在某一次遍歷中,我們將最後一次元素交換髮生的位置記為position,則position之後的元素一定是排好序的,且均大於position之前的元素。因此,在下一次遍歷中,我們就不再遍歷這部分元素。

void BubbleSort3(vector<int> &seq)
{
    unsigned int position = seq.size() - 1; //第一次遍歷要將所有元素遍歷一遍
    while(position > 0){
        unsigned int j = position;   //每次遍歷只需遍歷到上一次遍歷最後一次發生交換的位置
        position = 0;   //清理上一次遍歷產生的交換記錄
        for(unsigned int i = 0; i != j; i++){
            if(seq[i] > seq[i + 1]){
                exchange(seq[i], seq[i + 1]);
                position = i;  //記錄最後一次交換髮生的位置
            }
        }
    }
}

3、時間複雜度分析:

  法一的方法完全不依賴輸入的序列,無論輸入的序列如何排列,時間複雜度均為θ(n2)。法二和法三的時間複雜度依賴於輸入序列的排列情況,當輸入序列的情況較好,即存在部分已經排好序的序列,則執行時間會降低;排列情況最差,即輸入序列中的所有元素按照從大到小排列時,時間複雜度為θ(n2)。故三種氣泡排序方法的時間複雜度可統一為O(n2)。

  待更。。。。。。

五、堆排序

1、原理:

2、實現:

六、計數排序

1、原理:

2、實現:

七、基數排序

1、原理:

2、實現:

八、桶排序

1、原理:

2、實現: