前一篇給大家介紹了《必知必會的冒泡排序和快速排序(面試必知)》,現在繼續介紹排序算法
本博文介紹首先介紹直接選擇排序,然后針對直接選擇排序的缺點改進的“堆排序”,堆排序非常適合:數組規模非常大(數百萬或更多) + 嚴格要求輔助空間的場景。
直接選擇排序
(一)概念及實現
直接選擇排序的原理:將整個數組視為虛擬的有序區和無序區,重復的遍歷數組,每次遍歷從無序區中選出一個最小(或最大)的元素,放在有序區的最后,每一次遍歷排序過程都是有序區元素個數增加,無序區元素個數減少的過程,直到無序區元素個數位0。
具體如下(實現為升序):
設數組為a[0…n-1]。
1. 將原序列分成有序區和無序區。a[0…i-1]為有序區,a[i…n-1]為無序區。初始化有序區為0個元素。
2. 遍歷無序區元素,選出最小元素,放在有序區序列最后(即與無序區的第一個元素交換)
3. 重復步驟2,直到無序區元素個數為0。
實現代碼:
public static void Sort<T>(IList<T> arr) where T : IComparable<T> { if (arr == null) throw new ArgumentNullException("arr"); int length = arr.Count(); if (length > 1) { int minValueIndex = 0; T minValue = default(T); // 循環length - 2次,最后一個元素無需再比較 for (int i = 0; i < length - 1; i++) { minValueIndex = i; minValue = arr[i]; // 內部循環,查找本次循環的最小值 for (int j = i + 1; j < length; j++) { if (minValue.CompareTo(arr[j]) > 0) { minValueIndex = j; minValue = arr[j]; } } if (minValueIndex == i) continue; // 交換:將本次循環選出的最小值,順序放在有序區序列的最后(即與無序區的第一個元素交換) arr[minValueIndex] = arr[i]; arr[i] = minValue; } } }
示例:
89,-7,999,-89,7,0,-888,7,-7
排序的過程:
[-888] [-7 999 -89 7 0 89 7 -7]
[-888 -89] [999 -7 7 0 89 7 -7]
[-888 -89 -7] [999 7 0 89 7 -7]
[-888 -89 -7 -7] [7 0 89 7 999]
……
……
[-888 -89 -7 -7 0 7 7 89 999] []
(二)算法復雜度
1. 時間復雜度:O(n^2)
直接選擇排序耗時的操作有:比較 + 交換賦值。時間復雜度如下:
1) 最好情況:序列是升序排列,在這種情況下,需要進行的比較操作需n(n-1)/2次。交換賦值操作為0次。即O(n^2)
2) 最壞情況:序列是降序排列,那么此時需要進行的比較共有n(n-1)/2次。交換賦值n-1 次(交換次數比冒泡排序少多了),直接選擇排序的效率比較穩定,最好情況和最壞情況差不多。即O(n^2)
3) 漸進時間復雜度(平均時間復雜度):O(n^2)
2. 空間復雜度:O(1)
從實現原理可知,直接選擇插入排序是在原輸入數組上進行交換賦值操作的(稱“就地排序”),所需開辟的輔助空間跟輸入數組規模無關,所以空間復雜度為:O(1)
(三)穩定性
直接選擇排序是不穩定的。
因為每次遍歷比較完后會使用本次遍歷選擇的最小元素和無序區的第一個元素交換位置,所以如果無序區第一個元素后面有相同元素的,則可能會改變相同元素的相對順序。
(四)優化改進
1. 相同元素:如果數組元素重復率高,可以考慮使用輔助空間在每一次循環的時候,將本次選擇的數及相同元素的索引記錄下來,一起處理。
2. 堆排序:直接選擇排序中,為了從a[0..n-1]中選出關鍵字最小的記錄,必須進行n-1次比較,然后在a[1..n-1]中選出關鍵字最小的記錄,又需要做n-2次比較。事實上,后面的n-2次比較中,有許多比較可能在前面的n-1次比較中已經做過,但由於前一趟排序時未保留這些比較結果,所以后一趟排序時又重復執行了這些比較操作。堆排序可通過樹形結構保存部分比較結果,可減少比較次數。(這種效果在數組規模越大越能體現效果)
堆排序
(一)概念及實現
堆排序(Heapsort)的原理:是指利用“二叉堆”這種數據結構所設計的一種排序算法,可以利用數組的特點快速定位指定索引的元素。
1. 二叉堆
是完全二叉樹或者是近似完全二叉樹,它有兩種形式:最大堆(大頂堆、大根堆)和最小堆(小頂堆、小根堆)。
2. 二叉堆滿足二個特性
1) 父結點的鍵值總是大於或等於(小於或等於)任何一個子節點的鍵值。
2) 每個結點的左子樹和右子樹都是一個二叉堆(最大堆或最小堆)。
3. 二叉堆一般用數組來表示
如果根節點在數組中的位置是0,第n個位置的子節點分別在2n+1和 2n+2,其父節點的下標是 (n-1)/2 。
4. 示例
原數組:
初始化為最大堆:
具體如下(實現為升序):
設數組為a[0…n-1]。
1. 將原序列分成有序區和無序區。a[0…i-1]為無序區,a[i…n-1]為有序區。初始化有序區為0個元素。
2. (從下往上)從數組最后一個根節點開始 (maxIndex - 1)/2 ,將原數組初始化為最大堆。(如上圖)
3. (從上往下)將堆頂元素與無序區的最后一個元素交換(即插入有序區的第一個位置),將剩余的無序區元素重建最大堆。
4. 重復步驟3,每一次重復都是有序區元素個數增加,無序區元素個數減少的過程,直到無序區元素個數位0
實現代碼:
/// <summary> /// 堆排序 /// </summary> public class Heap { public static void Sort<T>(IList<T> arr) where T : IComparable<T> { if (arr == null) throw new ArgumentNullException("arr"); int length = arr.Count(); if (length > 1) { // 1、初始化最大堆 InitMaxHeap<T>(arr, length - 1); // 2、堆排序 // 將堆頂數據與末尾數據交換,再將i=N-1長的堆調整為最大堆;不斷縮小待排序范圍直到,無序區元素為0。 for (int i = length - 1; i > 0; i--) { // 2.1 將堆頂數據與末尾數據交換 Swap<T>(arr, 0, i); // 2.2 縮小數組待排序范圍 i - 1 ,重新調整為最大堆 AdjustMaxHeap<T>(arr, 0, i - 1); } } } /// <summary> /// 構建最大堆 (還未進行排序) /// </summary> /// <param name="arr">待排序數組</param> /// <param name="maxIndex">待排序數組最大索引</param> private static void InitMaxHeap<T>(IList<T> arr, int maxIndex) where T : IComparable<T> { // 從完全二叉樹最后一個非葉節點 : // 如果根節點在數組中的位置是0,第n個位置的子節點分別在2n+1和 2n+2,其父節點的下標是 (n-1)/2 。 for (int i = (maxIndex - 1) / 2; i >= 0; i--) { AdjustMaxHeap<T>(arr, i, maxIndex); } } /// <summary> /// 調整指定父節點的二叉樹為最大堆 /// </summary> /// <param name="arr">待排序數組</param> /// <param name="parentNodeIndex">指定父節點</param> /// <param name="maxIndex">待排序數組最大索引</param> private static void AdjustMaxHeap<T>(IList<T> arr, int parentNodeIndex, int maxIndex) where T : IComparable<T> { if (maxIndex > 0) // 只有堆頂一個元素,就不用調整了 { int resultIndex = -1; // 下標為i的節點的子節點是2i + 1與2i + 2 int leftIndex = 2 * parentNodeIndex + 1; int rightIndex = 2 * parentNodeIndex + 2; if (leftIndex > maxIndex) { // 該父節點沒有左右子節點 return; } else if (rightIndex > maxIndex) resultIndex = leftIndex; else // 比較左右節點。 resultIndex = Max<T>(arr, leftIndex, rightIndex); // 父節點與較大的子節點進行比較 resultIndex = Max<T>(arr, parentNodeIndex, resultIndex); if (resultIndex != parentNodeIndex) { // 如果最大的不是父節點,則交換。 Swap<T>(arr, parentNodeIndex, resultIndex); // 交換后子樹可能不是最大堆,所以需要重新調整交換元素的子樹 AdjustMaxHeap<T>(arr, resultIndex, maxIndex); } } } /// <summary> /// 獲取較大數的數組索引 /// </summary> /// <param name="arr">待排序數組</param> /// <param name="leftIndex">左節點索引</param> /// <param name="rightIndex">右節點索引</param> /// <returns>返回較大數的數組索引</returns> private static int Max<T>(IList<T> arr, int leftIndex, int rightIndex) where T : IComparable<T> { // 相等,以左節點為大 return arr[leftIndex].CompareTo(arr[rightIndex]) >= 0 ? leftIndex : rightIndex; } /// <summary> /// 數組元素交換 /// </summary> /// <typeparam name="T"></typeparam> /// <param name="arr">數組</param> /// <param name="i">交換元素1</param> /// <param name="j">交換元素2</param> private static void Swap<T>(IList<T> arr, int i, int j) { T temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } }
示例:
89,-7,999,-89,7,0,-888,7,-7
排序的過程:
初始化最大堆
將堆頂元素999移到有序區過程:(紅色為需要調節的元素,黃色為有序區元素)
同理,(再將堆頂元素89移到有序區,即與-89交換。)我們不斷縮小無序區的范圍,擴大有序區的元素,最后結果如下:
(二)算法復雜度
1. 時間復雜度:O(nlog2n)
堆排序耗時的操作有:初始堆 + 反復調整堆。時間復雜度如下:
1) 初始堆(從下往上):每個父節點會和左右子節點進行最多2次比較和1次交換,所以復雜度跟父節點個數有關。根據2^x<=n(x為n個元素可以折半的次數,也就是父節點個數),得出x = log2n。即O(log2n)
2) 反復調整堆(從上往下):由於初始化堆過程中,會記錄數組比較結果,所以堆排序對原序列的數組順序並不敏感,最好情況和最壞情況差不多。需要抽取 n-1 次堆頂元素,每次取堆頂元素都需要重建堆(O(重建堆) < O(初始堆))。所以小於 O(n-1) * O(log2n)
3) 漸進時間復雜度(平均時間復雜度):O(nlog2n)
4) 使用建議:由於初始化堆需要比較的次數較多,因此,堆排序比較適合於數據量非常大的場合(百萬數據或更多)。並且在由於高效的快速排序是基於遞歸實現的,所以在數據量非常大時會發生堆棧溢出錯誤。
2. 空間復雜度:O(1)
從實現原理可知,堆排序是在原輸入數組上進行交換賦值操作的(稱“就地排序”),所需開辟的輔助空間跟輸入數組規模無關,所以空間復雜度為:O(1)
(三)穩定性
堆排序是不穩定的。
因為在初始化堆時,相同元素可能被分配到不同的父節點下,所以在反復調整堆過程中,可能會改變相同元素的相對順序。
性能測試
測試步驟:
1. 隨機生成10個測試數組。
2. 每個數組中包含5000個元素。
3. 對這個數組集合進行本博文中介紹的兩種排序。(另外加入快速排序測試結果《快速排序源碼在這》)
4. 重復執行1~3步驟。執行20次。
5. 部分順序測試用例:順序率5%。
共測試 10*20 次,長度為5000的數組排序
參數說明:
(Time Elapsed:所耗時間。CPU Cycles:CPU時鍾周期。Gen0+Gen1+Gen2:垃圾回收器的3個代各自的回收次數)
從這個比較結果看:快速排序的性能幅度較大。而堆排序對原數組的序列不敏感,所以效率穩定性很高。
更加詳細的測試報告以及整個源代碼,會在寫完基礎排序算法后,寫一篇總結性博文分享。
評論中討論的問題
1. 關於log2n、logn、lgn的討論
討論結果我直接引用“《算法復雜度分析》”中的結論:
1) 注1:快速的數學回憶,logab = y 其實就是 a^y = b。所以,log24 = 2,因為 22 = 4。同樣 log28 = 3,因為 23 = 8。我們說,log2n 的增長速度要慢於 n,因為當 n = 8 時,log2n = 3。
2) 注2:通常將以 10 為底的對數叫做常用對數。為了簡便,N 的常用對數 log10 N 簡寫做 lg N,例如 log10 5 記做 lg 5。
3) 注3:通常將以無理數 e 為底的對數叫做自然對數。為了方便,N 的自然對數 loge N 簡寫做 ln N,例如 loge 3 記做 ln 3。
4) 注4:在算法導論中,采用記號 lg n = log2 n ,也就是以 2 為底的對數。改變一個對數的底只是把對數的值改變了一個常數倍,所以當不在意這些常數因子時,我們將經常采用 "lg n"記號,就像使用 O 記號一樣。計算機工作者常常認為對數的底取 2 最自然,因為很多算法和數據結構都涉及到對問題進行二分。
喜歡這個系列的小伙伴,還請多多推薦啊…
…
求助……非常不明白,這個問題我弄了兩個晚上,也不清楚問題出在哪。這個問題不解決,后續的大數據性能測試沒辦法做……
1. 第一組非部分排序的數據可以正常跑
2. “部分排序”的排序率為5%,所以有非常多數據是排序好的。(測試中排序率小就不會出現這個問題,比如改為0.05%)
3. 從遞歸次數來看,第一組跑完的遞歸次數比“部分排序”拋出異常時的遞歸次數多很多,但是第一組沒有報錯
4. 自己檢查代碼沒有發現死循環
5. 直接快速排序和平衡排序會報錯。。隨機排序不會報錯。所以問題是基准值選取的問題,但是看了理論上基准值代碼是對的。是和數組特征沖突導致的堆棧溢出,但是我沒查出問題。。。。
6. 解決方案在這下載:快速排序,堆棧溢出,問題項目.rar
然后,我再將生成數組集合個數和循環執行次數都設置為1,將單個數組元素個數設置為1000000(一百萬)。