1. 程式人生 > >[C++]五種常見排序

[C++]五種常見排序

目錄

希爾排序    快速排序    歸併排序    桶排序    氣泡排序


引用:https://zh.wikipedia.org/wiki/%E5%B8%8C%E5%B0%94%E6%8E%92%E5%BA%8F

           https://zh.wikipedia.org/wiki/%E5%BF%AB%E9%80%9F%E6%8E%92%E5%BA%8F

           

https://zh.wikipedia.org/wiki/%E5%BD%92%E5%B9%B6%E6%8E%92%E5%BA%8F

           https://zh.wikipedia.org/wiki/%E6%A1%B6%E6%8E%92%E5%BA%8F

           https://zh.wikipedia.org/wiki/%E5%86%92%E6%B3%A1%E6%8E%92%E5%BA%8F


希爾排序

希爾排序,也稱遞減增量排序演算法,是插入排序

的一種更高效的改進版本。希爾排序是非穩定排序演算法。

希爾排序是基於插入排序的以下兩點性質而提出改進方法的:

  • 插入排序在對幾乎已經排好序的資料操作時,效率高,即可以達到線性排序的效率
  • 但插入排序一般來說是低效的,因為插入排序每次只能將資料移動一位

演算法實現

原始的演算法實現在最壞的情況下需要進行O(n2)的比較和交換。V. Pratt的書[1]對演算法進行了少量修改,可以使得效能提升至O(nlog2 n)。這比最好的比較演算法的O(n log n)要差一些。

希爾排序通過將比較的全部元素分為幾個區域來提升

插入排序的效能。這樣可以讓一個元素可以一次性地朝最終位置前進一大步。然後演算法再取越來越小的步長進行排序,演算法的最後一步就是普通的插入排序,但是到了這步,需排序的資料幾乎是已排好的了(此時插入排序較快)。

假設有一個很小的資料在一個已按升序排好序的陣列的末端。如果用複雜度為O(n2)的排序(氣泡排序插入排序),可能會進行n次的比較和交換才能將該資料移至正確位置。而希爾排序會用較大的步長移動資料,所以小資料只需進行少數比較和交換即可到正確位置。

一個更好理解的希爾排序實現:將陣列列在一個表中並對列排序(用插入排序)。重複這過程,不過每次用更長的列來進行。最後整個表就只有一列了。將陣列轉換至表是為了更好地理解這演算法,演算法本身僅僅對原陣列進行排序(通過增加索引的步長,例如是用i += step_size而不是i++)。

例如,假設有這樣一組數[ 13 14 94 33 82 25 59 94 65 23 45 27 73 25 39 10 ],如果我們以步長為5開始進行排序,我們可以通過將這列表放在有5列的表中來更好地描述演算法,這樣他們就應該看起來是這樣:

13 14 94 33 82
25 59 94 65 23
45 27 73 25 39
10

然後我們對每列進行排序:

10 14 73 25 23
13 27 94 33 39
25 59 94 65 82
45

將上述四行數字,依序接在一起時我們得到:[ 10 14 73 25 23 13 27 94 33 39 25 59 94 65 82 45 ].這時10已經移至正確位置了,然後再以3為步長進行排序:

10 14 73
25 23 13
27 94 33
39 25 59
94 65 82
45

排序之後變為:

10 14 13
25 23 33
27 25 59
39 65 73
45 94 82
94

最後以1步長進行排序(此時就是簡單的插入排序了)。

步長序列

步長的選擇是希爾排序的重要部分。只要最終步長為1任何步長序列都可以工作。演算法最開始以一定的步長進行排序。然後會繼續以一定步長進行排序,最終演算法以步長為1進行排序。當步長為1時,演算法變為普通插入排序,這就保證了資料一定會被排序。

Donald Shell最初建議步長選擇為\frac{n}{2}並且對步長取半直到步長達到1。雖然這樣取可以比{\mathcal {O}}(n^{2})類的演算法(插入排序)更好,但這樣仍然有減少平均時間和最差時間的餘地。可能希爾排序最重要的地方在於當用較小步長排序後,以前用的較大步長仍然是有序的。比如,如果一個數列以步長5進行了排序然後再以步長3進行排序,那麼該數列不僅是以步長3有序,而且是以步長5有序。如果不是這樣,那麼演算法在迭代過程中會打亂以前的順序,那就不會以如此短的時間完成排序了。

步長序列 最壞情況下複雜度
{n/2^i} \mathcal{O}(n^2)
2^k - 1 \mathcal{O}(n^{3/2})
2^i 3^j \mathcal{O}( n\log^2 n )

已知的最好步長序列是由Sedgewick提出的(1, 5, 19, 41, 109,...),該序列的項來自9\times 4^{i}-9\times 2^{i}+12^{{i+2}}\times (2^{{i+2}}-3)+1這兩個算式[1]。這項研究也表明“比較在希爾排序中是最主要的操作,而不是交換。”用這樣步長序列的希爾排序比插入排序要快,甚至在小陣列中比快速排序堆排序還快,但是在涉及大量資料時希爾排序還是比快速排序慢。

另一個在大陣列中表現優異的步長序列是(斐波那契數列除去0和1將剩餘的數以黃金分割比的兩倍的進行運算得到的數列):(1, 9, 34, 182, 836, 4025, 19001, 90358, 428481, 2034035, 9651787, 45806244, 217378076, 1031612713,…)[2]

程式程式碼

template <typename _Tp>
void shell_sort(_Tp *arr, int length) {
    int h = 1;
    while (h < length / 3) {
        h = 3 * h + 1;
    }
    while (h >= 1) {
        for (int i = h; i < length; ++i) {
            for (int j = i; j >= h && arr[j] < arr[j - h]; j -= h) {
                swap(arr[j], arr[j - h]);
            };
        };
        h = h / 3;
    };
};

快速排序

快速排序(英語:Quicksort),又稱劃分交換排序(partition-exchange sort),簡稱快排,一種排序演算法,最早由東尼·霍爾提出。在平均狀況下,排序n個專案要{\displaystyle \ O(n\log n)}大O符號)次比較。在最壞狀況下則需要{\displaystyle O(n^{2})}次比較,但這種狀況並不常見。事實上,快速排序{\displaystyle \Theta (n\log n)}通常明顯比其他演算法更快,因為它的內部迴圈(inner loop)可以在大部分的架構上很有效率地達成。

演算法實現

快速排序使用分治法(Divide and conquer)策略來把一個序列(list)分為兩個子序列(sub-lists)。

步驟為:

  1. 從數列中挑出一個元素,稱為“基準”(pivot),
  2. 重新排序數列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面(相同的數可以到任何一邊)。在這個分割結束之後,該基準就處於數列的中間位置。這個稱為分割(partition)操作。
  3. 遞迴地(recursively)把小於基準值元素的子數列和大於基準值元素的子數列排序。

遞迴到最底部時,數列的大小是零或一,也就是已經排序好了。這個演算法一定會結束,因為在每次的迭代(iteration)中,它至少會把一個元素擺到它最後的位置去。

正規分析

從一開始快速排序平均需要花費{\displaystyle O(n\log n)}時間的描述並不明顯。但是不難觀察到的是分割運算,陣列的元素都會在每次迴圈中走訪過一次,使用{\displaystyle O(n)}的時間。在使用結合(concatenation)的版本中,這項運算也是{\displaystyle O(n)}

在最好的情況,每次我們執行一次分割,我們會把一個數列分為兩個幾近相等的片段。這個意思就是每次遞迴呼叫處理一半大小的數列。因此,在到達大小為一的數列前,我們只要作\log n次巢狀的呼叫。這個意思就是呼叫樹的深度是{\displaystyle O(\log n)}。但是在同一層次結構的兩個程式呼叫中,不會處理到原來數列的相同部分;因此,程式呼叫的每一層次結構總共全部僅需要{\displaystyle O(n)}的時間(每個呼叫有某些共同的額外耗費,但是因為在每一層次結構僅僅只有{\displaystyle O(n)}個呼叫,這些被歸納在{\displaystyle O(n)}係數中)。結果是這個演算法僅需使用{\displaystyle O(n\log n)}時間。

另外一個方法是為{\displaystyle T(n)}設立一個遞迴關係式,也就是需要排序大小為n的數列所需要的時間。在最好的情況下,因為一個單獨的快速排序呼叫牽涉了{\displaystyle O(n)}的工作,加上對{\displaystyle n/2}大小之數列的兩個遞迴呼叫,這個關係式可以是:

{\displaystyle T(n)=O(n)+2T(n/2)}

解決這種關係式型別的標準數學歸納法技巧告訴我們{\displaystyle T(n)=O(n\log n)}

事實上,並不需要把數列如此精確地分割;即使如果每個基準值將元素分開為99%在一邊和1%在另一邊,呼叫的深度仍然限制在{\displaystyle 100\log n},所以全部執行時間依然是{\displaystyle O(n\log n)}

然而,在最壞的情況是,兩子數列擁有大各為1 和{\displaystyle n-1},且呼叫樹(call tree)變成為一個n個巢狀(nested)呼叫的線性連串(chain)。第i 次呼叫作了{\displaystyle O(n-i)}的工作量,且\sum _{i=0}^{n}(n-i)=O(n^{2})遞迴關係式為:

{\displaystyle T(n)=O(n)+T(1)+T(n-1)=O(n)+T(n-1)}

這與插入排序選擇排序有相同的關係式,以及它被解為{\displaystyle T(n)=O(n^{2})}。 

程式程式碼

template <typename _Tp>
void quick_sort(int start, int end, _Tp *arr) {
	int i = start, j = end; _Tp = arr[start];
	if (i >= j) return;
	while (i != j) {
		while (i < j && arr[j] >= pivot) --j;
		while (i < j && arr[i] <= pivot) ++i;
		if (i < j) swap(arr[i], arr[j]);	
	};
	swap(arr[i], arr[start]);
	quick_sort(start, i - 1, arr);
	quick_sort(i + 1, end, arr);
};

歸併排序

歸併排序(英語:Merge sort,或mergesort),是建立在歸併操作上的一種有效的排序演算法效率{\displaystyle O(n\log n)}大O符號)。1945年由約翰·馮·諾伊曼首次提出。該演算法是採用分治法(Divide and Conquer)的一個非常典型的應用,且各層分治遞迴可以同時進行。

歸併操作

歸併操作(merge),也叫歸併演算法,指的是將兩個已經排序的序列合併成一個序列的操作。歸併排序演算法依賴歸併操作。

遞迴法(Top-down)

  1. 申請空間,使其大小為兩個已經排序序列之和,該空間用來存放合併後的序列
  2. 設定兩個指標,最初位置分別為兩個已經排序序列的起始位置
  3. 比較兩個指標所指向的元素,選擇相對小的元素放入到合併空間,並移動指標到下一位置
  4. 重複步驟3直到某一指標到達序列尾
  5. 將另一序列剩下的所有元素直接複製到合併序列尾

迭代法(Bottom-up)

原理如下(假設序列共有n個元素):

  1. 將序列每相鄰兩個數字進行歸併操作,形成{\displaystyle ceil(n/2)}個序列,排序後每個序列包含兩/一個元素
  2. 若此時序列數不是1個則將上述序列再次歸併,形成{\displaystyle ceil(n/4)}個序列,每個序列包含四/三個元素
  3. 重複步驟2,直到所有元素排序完畢,即序列數為1

程式程式碼 

template <typename _Tp>
void merge_sort(int start, int end, _Tp *arr) {
	if (start == end) return;
	int mid = (start + end) / 2;
	merge_sort(start, mid, arr);
	merge_sort(mid + 1, end, arr);
	int i = start, j = mid + 1, k = start;
	while (i <= mid && j <= end) if (arr[i] <= arr[j]) rad[k] = arr[i], ++k, ++i; else rad[k] = arr[j], ++k, ++j;
	while (i <= mid) rad[k] = arr[i], ++k, ++i;
	while (j <= end) rad[k] = arr[j], ++k, ++j;
	for (int i = start; i <= end; ++i) arr[i] = rad[i];
};

桶排序

桶排序(Bucket sort)或所謂的箱排序,是一個排序演算法,工作的原理是將陣列分到有限數量的桶裡。每個桶再個別排序(有可能再使用別的排序演算法或是以遞迴方式繼續使用桶排序進行排序)。桶排序是鴿巢排序的一種歸納結果。當要被排序的陣列內的數值是均勻分配的時候,桶排序使用線性時間({\displaystyle \Theta (n)}大O符號))。但桶排序並不是比較排序,他不受到{\displaystyle O(n\log n)}下限的影響。

桶排序以下列程式進行:

  1. 設定一個定量的陣列當作空桶子。
  2. 尋訪序列,並且把專案一個一個放到對應的桶子去。
  3. 對每個不是空的桶子進行排序。
  4. 從不是空的桶子裡把專案再放回原來的序列中。

程式程式碼 

假設資料範圍分佈在[0, 100)之間

桶排序簡單實現方法:

for (int i = 0; i < n; ++i) {
	cin >> x; ++b[x];	
};
for (int i = 0; i < 100; ++i)
	while (b[i] > 0) {
		cout << i << ' '; --b[i];	
	};

桶排序STL實現,每個桶內部用連結串列表示,在資料入桶的同時插入排序。然後把各個桶中的資料合併:

#include <iterator>
#include <iostream>
#include <vector>
using namespace std;
const int BUCKET_NUM = 10;
struct ListNode {
	explicit ListNode(int i = 0) : mData(i), mNext(NULL) {}
	ListNode* mNext;
	int mData;
};
ListNode* insert(ListNode* head, int val) {
	ListNode dummyNode;
	ListNode *newNode = new ListNode(val);
	ListNode *pre, *curr;
	dummyNode.mNext = head;
	pre = &dummyNode;
	curr = head;
	while(NULL != curr && curr->mData <= val) {
		pre = curr;
		curr = curr->mNext;
	};
	newNode->mNext = curr;
	pre->mNext = newNode;
	return dummyNode.mNext;
};
ListNode* Merge(ListNode *head1,ListNode *head2) {
	ListNode dummyNode;
	ListNode *dummy = &dummyNode;
	while (NULL != head1 && NULL != head2) {
		if (head1->mData <= head2->mData) {
			dummy->mNext = head1;
			head1 = head1->mNext;
		} else {
			dummy->mNext = head2;
			head2 = head2->mNext;
		};
		dummy = dummy->mNext;
	};
	if (NULL != head1) dummy->mNext = head1;
	if (NULL != head2) dummy->mNext = head2;	
	return dummyNode.mNext;
};
void BucketSort(int n,int arr[]) {
	vector <ListNode*> buckets(BUCKET_NUM,(ListNode*)(0));
	for (int i = 0; i < n; ++i) {
		int index = arr[i] / BUCKET_NUM;
		ListNode *head = buckets.at(index);
		buckets.at(index) = insert(head, arr[i]);
	};
	ListNode *head = buckets.at(0);
	for (int i = 1; i < BUCKET_NUM; ++i) {
		head = Merge(head,buckets.at(i));
	};
	for (int i = 0; i < n; ++i) {
		arr[i] = head->mData;
		head = head->mNext;
	};
};

氣泡排序

氣泡排序(英語:Bubble Sort)是一種簡單的排序演算法。它重複地走訪過要排序的數列,一次比較兩個元素,如果他們的順序錯誤就把他們交換過來。走訪數列的工作是重複地進行直到沒有再需要交換,也就是說該數列已經排序完成。這個演算法的名字由來是因為越小的元素會經由交換慢慢“浮”到數列的頂端。

氣泡排序對n個專案需要O(n^{2})的比較次數,且可以原地排序。儘管這個演算法是最簡單瞭解和實現的排序演算法之一,但它對於包含大量的元素的數列排序是很沒有效率的。

氣泡排序是與插入排序擁有相等的執行時間,但是兩種演算法在需要的交換次數卻很大地不同。在最壞的情況,氣泡排序需要O(n^{2})次交換,而插入排序只要最多O(n)交換。氣泡排序的實現(類似下面)通常會對已經排序好的數列拙劣地執行(O(n^{2})),而插入排序在這個例子只需要O(n)個運算。因此很多現代的演算法教科書避免使用氣泡排序,而用插入排序取代之。氣泡排序如果能在內部迴圈第一次執行時,使用一個旗標來表示有無需要交換的可能,也可以把最優情況下的複雜度降低到O(n)。在這個情況,已經排序好的數列就無交換的需要。若在每次走訪數列時,把走訪順序反過來,也可以稍微地改進效率。有時候稱為雞尾酒排序,因為演算法會從數列的一端到另一端之間穿梭往返。

氣泡排序演算法的運作如下:

  1. 比較相鄰的元素。如果第一個比第二個大,就交換他們兩個。
  2. 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。這步做完後,最後的元素會是最大的數。
  3. 針對所有的元素重複以上的步驟,除了最後一個。
  4. 持續每次對越來越少的元素重複上面的步驟,直到沒有任何一對數字需要比較。

由於它的簡潔,氣泡排序通常被用來對於程式設計入門的學生介紹演算法的概念。

氣泡排序

Bubble sort animation.gif

使用氣泡排序為一列數字進行排序的過程

分類 排序演算法 資料結構 陣列 最壞時間複雜度 O(n^{2}) 最優時間複雜度 O(n) 平均時間複雜度 O(n^{2}) 最壞空間複雜度 總共 O(n),需要輔助空間 O(1)

程式程式碼

template <typename _Tp>
void bubble_sort(int start, int end, _Tp *arr) {
    for (int i = start; i < end - 1; ++i)
        for (int j = start; j < end - 1 - i; ++j)
            if (arr[j] > arr[j + 1])
                swap(arr[j], arr[j + 1]);
};