前一篇給大家介紹了《優化的直接插入排序(二分查找插入排序,希爾排序)》,現在繼續介紹其他排序算法
本博文介紹兩個最常被提起的排序算法:冒泡排序和快速排序。冒泡排序是入門排序算法,思路比較常規,但確是最耗時的排序算法,所以聽到冒泡排序笑一笑就好了,千萬不要拿來裝B。另一個是被譽為“20世紀最偉大的十大經典算法”的面試必知算法快速排序,以及針對數組特征進行優化的“隨機快排”和“平衡快排”。
冒泡排序
(一)概念及實現
冒泡排序的原理:重復的遍歷要排序的數組,每次遍歷過程中從頭至尾比較兩個相鄰的元素,若順序錯誤則交換兩個元素。
具體如下(實現為升序):
設數組為a[0…n]。
1. 從頭至尾比較相鄰的元素。如果第一個元素比第二個元素大,就交換。
2. 重復步驟1,每次遍歷都將冒出一個最大數,直到排序完成。
實現代碼:
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) { bool isSorted = false; // 循環n-2次,每循環完一次,冒泡的一個最大值 for (int i = 0; i < length - 1; i++) { // 如果一次循環沒有發生交換,則說明剩余的元素是有序的。 isSorted = true; for (int j = 1; j <= length - 1 - i; j++) { // 相鄰兩個元素比較,將較大的交換到右邊arr[j]中 if (arr[j - 1].CompareTo(arr[j]) > 0) { isSorted = false; Swap(arr, j - 1, j); } } if (isSorted) break; } } } /// <summary> /// 數組元素交換 /// </summary> /// <typeparam name="T"></typeparam> /// <param name="arr">數組</param> /// <param name="i">交換元素1</param> /// <param name="j">交換元素2</param> 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
排序的過程:
-7 89 -89 7 0 -888 7 -7 [999]
-7 -89 7 0 -888 7 -7 [89] 999
……
……
-888 [-89] -7 -7 0 7 7 89 999
(二)算法復雜度
1. 時間復雜度:O(n^2)
冒泡排序耗時的操作有:比較 + 交換(每次交換兩次賦值)。時間復雜度如下:
1) 最好情況:序列是升序排列,在這種情況下,需要進行的比較操作為(n-1)次。交換操作為0次。即O(n)
2) 最壞情況:序列是降序排列,那么此時需要進行的比較共有n(n-1)/2次。交換操作數和比較操作數一樣。即O(n^2)
3) 漸進時間復雜度(平均時間復雜度):O(n^2)
2. 空間復雜度:O(1)
從實現原理可知,冒泡排序是在原輸入數組上進行比較交換的(稱“就地排序”),所需開辟的輔助空間跟輸入數組規模無關,所以空間復雜度為:O(1)
(三)穩定性
冒泡排序是穩定的,不會改變相同元素的相對順序。
(四)優化改進
1. 有序優化:在進行相鄰元素比較時,可以記錄下循環中沒有發生交換的多個連續索引對(起始索引和結束索引),在下次輪詢時直接對有序區間的最大值進行比較。
2. 雙向冒泡:參考資料過程中看到了雙向冒泡,不同之處在於“從左至右與從右至左兩種冒泡方式交替執行”,個人認為不能提高算法效率並且增加代碼復雜度。
快速排序
(一)概念及實現
思想:分治策略。
快速排序的原理:通過一趟排序將要排序的數據分割成獨立的兩部分,其中一部分的所有數據都比另外一部分的所有數據都要小,然后再按同樣的方法對這兩部分數據分別進行快速排序。
"保證列表的前半部分都小於后半部分"就使得前半部分的任何一個數從此以后都不再跟后半部分的數進行比較了,大大減少了數字間的比較次數。
具體如下(實現為升序):
設數組為a[0…n]。
1. 數組中找一個元素做為基准(pivot),通常選數組的第一個數。
2. 對數組進行分區操作。使基准元素左邊的值都小於pivot,基准元素右邊的值都大於等於pivot。
3. 將pivot值調整到分區后的正確位置。
4. 將基准兩邊的分區序列,分別進行步驟1~3。(遞歸)
5. 重復步驟1~4,直到排序完成。
實現代碼:
/// <summary> /// 快速排序 /// </summary> /// <param name="arr">待排序數組</param> /// <param name="left">左邊界索引</param> /// <param name="right">右邊界索引</param> /// <param name="SelectPivot">選擇基准元素委托,並且將基元素與左邊界元素交換</param> private static void DoSort<T>(IList<T> arr, int left, int right , Action<IList<T>, int, int> SelectPivot) where T : IComparable<T> { count++; if (left < right) { // 選擇基准元素委托 if (SelectPivot != null) SelectPivot(arr, left, right); // 臨時存儲基准元素,以讓其他數組元素來填充該位置 T pivot = arr[left]; int low = left; int high = right; while (low < high) { // 從右向左找第一個小於 基數 的數 while (low < high && arr[high].CompareTo(pivot) >= 0) high--; if (low < high) arr[low++] = arr[high]; // 從左向右找第一個大於等於 基數 的數 while (low < high && arr[low].CompareTo(pivot) < 0) low++; if (low < high) arr[high--] = arr[low]; } arr[low] = pivot; // 此時 low = high // 兩個分區遞歸。基准元素無需在參與下一次快速排序 // 加入if判斷可以減少50%-55%的遞歸次數 if (left < low - 1) DoSort<T>(arr, left, low - 1, SelectPivot); if (low + 1 < right) DoSort<T>(arr, low + 1, right, SelectPivot); } }
特別注意在遞歸前的判斷語句,他減少了50%-55%的遞歸次數。
示例:
89,-7,999,-89,7,0,-888,7,-7
排序的過程:
(以數組第一個元素做為基准值。藍色代表被分區出來的子數組,綠色代表本次分區基准值)
(二)算法復雜度
1. 時間復雜度:O(nlog2n)
快速排序耗時的操作有:比較 + 交換(每次交換兩次賦值)。時間復雜度如下:
1) 最好情況:選擇的基准值剛好是中間值,分區后兩分區包含元素個數接近相等。因為,總共經過x次分區,根據2^x<=n得出x=log2n,每次分區需用n-1個元素與基准比較。所以O(nlog2n)
2) 最壞情況:每次分區后,只有一個分區包含除基准元素之外的元素。這樣就和冒泡排序一樣慢,需n(n-1)/2次比較。即O(n^2)
3) 漸進時間復雜度(平均時間復雜度):O(nlog2n)
2. 空間復雜度:O(1)
從實現原理可知,快速排序是在原輸入數組上進行比較分區的(稱“就地排序”),所需開辟的輔助空間跟輸入數組規模無關,所以空間復雜度為:O(1)
(三)穩定性
快速是不穩定的,會改變相同元素的相對順序。如示例,以第一個基准89排序時,首先將最后一個元素-7移到了第一個分區的第一個位置上。改變了與第二個-7的相對順序。
(四)優化改進
當每次分區后,兩個分區的元素個數相近時,效率最高。所以找一個比較有代表性的基准值就是關鍵。通常會采取如下方式:
1. 選取分區的第一個元素做為基准值。這種方式在分區基本有序情況下會分區不均。
2. 隨機快排:每次分區的基准值是該分區的隨機元素,這樣就避免了有序導致的分布不均的問題
3. 平衡快排:取開頭、結尾、中間3個數據,通過比較選出其中的中值。
根據改進方案,寫了如下基准值選取委托:
static Random random = null; public static void Sort<T>(IList<T> arr, QuickType quickType = QuickType.Normal) where T : IComparable<T> { if (arr == null) throw new ArgumentNullException("arr"); switch (quickType) { case QuickType.Normal: { DoSort<T>(arr, 0, arr.Count() - 1, (subArr, left, right) => { // 默認以第1個數為基數。 // 所以,什么都不用做 } ); } break; case QuickType.Random: // 隨機快排 { DoSort<T>(arr, 0, arr.Count() - 1, (subArr, left, right) => { // 2個元素就取默認第一個元素 if ((right - left + 1) > 2) { // 隨機化快排:隨機數組中一個元素做基准 if (random == null) random = new Random(new Guid().GetHashCode()); int index = random.Next(left, right); T temp = subArr[left]; subArr[left] = subArr[index]; subArr[index] = temp; } } ); } break; case QuickType.Balance: // 平衡快排 { DoSort<T>(arr, 0, arr.Count() - 1, (subArr, left, right) => { // 2個元素就取默認第一個元素 if ((right - left + 1) > 2) { int index = -1; // 平衡快排:取開頭、結尾、中間3個數據,通過比較選出其中的中值 int middle = (left + right) / 2; int maxIndex = -1; for (int i = 0; i <= 1; i++) { if (i == 0) // 找最大值 { if (subArr[middle].CompareTo(subArr[left]) >= 0) { maxIndex = middle; } else { maxIndex = left; } if (subArr[maxIndex].CompareTo(subArr[right]) >= 0) { // maxIndex本身為最大值 } else { maxIndex = right; } } if (i == 1) // 找第二大值 { if (maxIndex == left) { if (subArr[middle].CompareTo(subArr[right]) >= 0) index = middle; else index = right; } else if (maxIndex == middle) { if (subArr[left].CompareTo(subArr[right]) >= 0) index = left; else index = right; } else if (maxIndex == right) { if (subArr[middle].CompareTo(subArr[left]) >= 0) index = middle; else index = left; } } } // 交換 T temp = subArr[left]; subArr[left] = subArr[index]; subArr[index] = temp; } } ); } break; } }
性能測試
測試步驟:
1. 隨機生成10個測試數組。
2. 每個數組中包含5000個元素。
3. 對這個數組集合進行本博文中介紹的三種排序。
4. 重復執行1~3步驟。執行20次。
5. 部分順序測試用例:順序率5%。
共測試 10*20 次,長度為5000的數組排序
參數說明:
(Time Elapsed:所耗時間。CPU Cycles:CPU時鍾周期。Gen0+Gen1+Gen2:垃圾回收器的3個代各自的回收次數)
隨機快排和平衡快排都比較穩定高效。順序率約高,平衡快排的優勢越明顯。
更加詳細的測試報告以及整個源代碼,會在寫完基礎排序算法后,寫一篇總結性博文分享。
喜歡這個系列的小伙伴,還請多多推薦啊…
…