前一篇给大家介绍了《优化的直接插入排序(二分查找插入排序,希尔排序)》,现在继续介绍其他排序算法
本博文介绍两个最常被提起的排序算法:冒泡排序和快速排序。冒泡排序是入门排序算法,思路比较常规,但确是最耗时的排序算法,所以听到冒泡排序笑一笑就好了,千万不要拿来装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个代各自的回收次数)
随机快排和平衡快排都比较稳定高效。顺序率约高,平衡快排的优势越明显。
更加详细的测试报告以及整个源代码,会在写完基础排序算法后,写一篇总结性博文分享。
喜欢这个系列的小伙伴,还请多多推荐啊……