排序三:三种快速排序以及快速排序的优化
一. 快速排序的基本思想
快速排序使用分治的思想,通过一趟排序将待排序列分割成两部分,其中一部分记录的关键字均比另一部分记录的关键字小。之后分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
二. 快速排序的三个步骤
1) 选择基准:在待排序列中,按照某种方式挑出一个元素,作为 "基准"(pivot);
2) 分割操作:以该基准在序列中的实际位置,把序列分成两个子序列。此时,在基准左边的元素都比该基准小,在基准右边的元素都比基准大;
3) 递归地对两个序列进行快速排序,直到序列为空或者只有一个元素;
三. 选择基准元的方式
对于分治算法,当每次划分时,算法若都能分成两个等长的子序列时,那么分治算法效率会达到最大。也就是说,基准的选择是很重要的。选择基准的方式决定了两个分割后两个子序列的长度,进而对整个算法的效率产生决定性影响。
最理想的方法是,选择的基准恰好能把待排序序列分成两个等长的子序列。
方法一:固定基准元(基本的快速排序)
思想:取序列的第一个或最后一个元素作为基准元。
1 /// <summary>
2 /// 1.0 固定基准元(基本的快速排序)
3 /// </summary>
4 public static void QsortCommon(int[] arr, int low, int high)
5 {
6 if (low >= high) return; //递归出口
7 int partition = Partition(arr, low, high); //将 >= x 的元素交换到右边区域,将 <= x 的元素交换到左边区域
8 QsortCommon(arr, low, partition - 1);
9 QsortCommon(arr, partition + 1, high);
10 }
11
12 /// <summary>
13 /// 固定基准元,默认数组第一个数为基准元,左右分组,返回基准元的下标
14 /// </summary>
15 public static int Partition(int[] arr, int low, int high)
16 {
17 int first = low;
18 int last = high;
19 int key = arr[low]; //取第一个元素作为基准元
20 while (first < last)
21 {
22 while (first < last && arr[last] >= key)
23 last--;
24 arr[first] = arr[last];
25 while (first < last && arr[first] <= key)
26 first++;
27 arr[last] = arr[first];
28 }
29 arr[first] = key; //基准元居中
30 return first;
31 }
注意:基本的快速排序选取第一个或最后一个元素作为基准。但是,这是一直很不好的处理方法。
测试数据:
测试数据分析:如果输入序列是随机的,处理时间可以接受的。如果数组已经有序时,此时的分割就是一个非常不好的分割。因为每次划分只能使待排序序列减一,此时为最坏情况,快速排序沦为冒泡排序,时间复杂度为Θ(n^2)。而且,输入的数据是有序或部分有序的情况是相当常见的。因此,使用第一个元素作为基准元是非常糟糕的,为了避免这个情况,就引入了下面两个获取基准的方法。
方法二:随机基准元
思想:取待排序列中任意一个元素作为基准元。
引入的原因:在待排序列是部分有序时,固定选取基准元使快排效率底下,要缓解这种情况,就引入了随机选取基准元。
1 /// <summary>
2 /// 2.0 随机基准元
3 /// </summary>
4 public static void QsortRandom(int[] arr, int low, int high)
5 {
6 if (low >= high) return; //递归出口
7 PartitionRandom(arr, low, high); //随机基准元
8 int partition = Partition(arr, low, high); //将 >= x 的元素交换到右边区域,将 <= x 的元素交换到左边区域
9 QsortRandom(arr, low, partition - 1);
10 QsortRandom(arr, partition + 1, high);
11 }
12
13 /// <summary>
14 /// 随机基准元,将确定好的基准元与第一个数交换,无返回值
15 /// </summary>
16 public static void PartitionRandom(int[] arr, int low, int high)
17 {
18 Random rd = new Random();
19 int randomIndex = rd.Next() % (high - low) + low;//取数组中随机下标
20 Swap(arr, randomIndex, low); //与第一个数交换
21 }
测试数据:
测试数据分析::这是一种相对安全的策略。由于基准元的位置是随机的,那么产生的分割也不会总是会出现劣质的分割。在整个数组数字全相等时,仍然是最坏情况,时间复杂度是O(n^2)。实际上,随机化快速排序得到理论最坏情况的可能性仅为1/(2^n)。所以随机化快速排序可以对于绝大多数输入数据达到O(nlogn)的期望时间复杂度。一位前辈做出了一个精辟的总结:“随机化快速排序可以满足一个人一辈子的人品需求。”
方法三:三数取中
引入的原因:虽然随机选取基准时,减少出现不好分割的几率,但是还是最坏情况下还是O(n^2),要缓解这种情况,就引入了三数取中选取基准。
分析:最佳的划分是将待排序的序列分成等长的子序列,最佳的状态我们可以使用序列的中间的值,也就是第N/2个数。可是,这很难算出来,并且会明显减慢快速排序的速度。这样的中值的估计可以通过随机选取三个元素并用它们的中值作为基准元而得到。事实上,随机性并没有多大的帮助,因此一般的做法是使用左端、右端和中心位置上的三个元素的中值作为基准元。显然使用三数中值分割法消除了预排序输入的不好情形,并且减少快排大约14%的比较次数。
举例:待排序序列为:8 1 4 9 6 3 5 2 7 0
左边为:8,右边为0,中间为6
我们这里取三个数排序后,中间那个数作为枢轴,则枢轴为6
注意:在选取中轴值时,可以从由左中右三个中选取扩大到五个元素中或者更多元素中选取,一般的,会有(2t+1)平均分区法(median-of-(2t+1),三平均分区法英文为median-of-three。
具体思想:对待排序序列中low、mid、high三个位置上数据进行排序,取他们中间的那个数据作为基准,并用0下标元素存储基准。
即:采用三数取中,并用0下标元素存储基准。
1 /// <summary>
2 /// 3.0 三数取中
3 /// </summary>
4 public static void QsortMedianOfThree(int[] arr, int low, int high)
5 {
6 if (low >= high) return; //递归出口
7 PartitionMedianOfThree(arr, low, high); //三数取中
8 int partition = Partition(arr, low, high); //将 >= x 的元素交换到右边区域,将 <= x 的元素交换到左边区域
9 QsortMedianOfThree(arr, low, partition - 1);
10 QsortMedianOfThree(arr, partition + 1, high);
11 }
12
13 /// <summary>
14 /// 三数取中确定基准元,将确定好的基准元与第一个数交换,无返回值
15 /// </summary>
16 public static void PartitionMedianOfThree(int[] arr, int low, int high)
17 {
18 int mid = low + (high + -low) / 2;
19 if (arr[mid] > arr[high])
20 {
21 Swap(arr, mid, high);
22 }
23 if (arr[low] > arr[high])
24 {
25 Swap(arr, low, high);
26 }
27 if (arr[mid] > arr[low])
28 {
29 Swap(arr, mid, low);
30 } //将中间大小的数与第一个数交换
31 }
测试数据:
测试数据分析:使用三数取中优势还是很明显的,但是还是处理不了重复数组。
四. 两种优化的方法
优化一:当待排序序列的长度分割到一定大小后,使用插入排序
原因:对于很小和部分有序的数组,快排不如插排好。当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时可以使用插排而不是快排。
截止范围:待排序序列长度N = 10,虽然在5~20之间任一截止范围都有可能产生类似的结果,这种做法也避免了一些有害的退化情形。
----摘自《数据结构与算法分析》Mark Allen Weiness 著
1 /// <summary>
2 /// 4.0 三数取中+插排
3 /// </summary>
4 public static void QsortThreeInsert(int[] arr, int low, int high)
5 {
6 if (high - low + 1 < 10)
7 {
8 InsertSort(arr, low, high);
9 return;
10 } //插排,递归出口
11 PartitionMedianOfThree(arr, low, high); //三数取中
12 int partition = Partition(arr, low, high); //将 >= x 的元素交换到右边区域,将 <= x 的元素交换到左边区域
13 QsortMedianOfThree(arr, low, partition - 1);
14 QsortMedianOfThree(arr, partition + 1, high);
15 }
测试数据:
测试数据分析:针对随机数组,使用三数取中选择基准+插排,效率还是可以提高一点,真是针对已排序的数组,是没有任何用处的。因为待排序序列是已经有序的,那么每次划分只能使待排序序列减一。此时,插排是发挥不了作用的。所以这里看不到时间的减少。另外,三数取中选择基准+插排还是不能处理重复数组。
优化二:在一次分割结束后,可以把与Key相等的元素聚在一起,继续下次分割时,不用再对与key相等元素分割
举例:
待排序序列 1 4 6 7 6 6 7 6 8 6
三数取中选取基准:下标为4的数6
转换后,待分割序列:6 4 6 7 1 6 7 6 8 6
基准key:6
本次划分后,未对与key元素相等处理的结果:1 4 6 6 7 6 7 6 8 6
下次的两个子序列为:1 4 6 和 7 6 7 6 8 6
本次划分后,对与key元素相等处理的结果:1 4 6 6 6 6 6 7 8 7
下次的两个子序列为:1 4 和 7 8 7
经过对比,我们可以看出,在一次划分后,把与key相等的元素聚在一起,能减少迭代次数,效率会提高不少
具体过程:在处理过程中,会有两个步骤
第一步,在划分过程中,把与key相等元素放入数组的两端
第二步,划分结束后,把与key相等的元素移到枢轴周围
1 /// <summary>
2 /// 5.0 三数取中+插排+聚集相同元素
3 /// </summary>
4 public static void QsortThreeInsertGather(int[] arr, int low, int high)
5 {
6 if (high - low + 1 < 10)
7 {
8 InsertSort(arr, low, high);
9 return;
10 } //插排,递归出口
11 PartitionMedianOfThree(arr, low, high); //三数取中
12
13 //进行左右分组(处理相等元素)
14 int first = low;
15 int last = high;
16 int left = low;
17 int right = high;
18 int leftLength = 0;
19 int rightLength = 0;
20 int key = arr[first];
21 while (first < last)
22 {
23 while (first < last && arr[last] >= key)
24 {
25 if (arr[last] == key) //处理相等元素,将相等的元素放置数组两端
26 {
27 Swap(arr, last, right);
28 right--;
29 rightLength++;
30 }
31 last--;
32 }
33 arr[first] = arr[last];
34 while (first < last && arr[first] <= key)
35 {
36 if (arr[first] == key)
37 {
38 Swap(arr, first, left);
39 left++;
40 leftLength++;
41 }
42 first++;
43 }
44 arr[last] = arr[first];
45 }
46 arr[first] = key;
47
48 //一次快排结束
49 //把与基准元key相同的元素移到最终位置周围
50 int i = first - 1;
51 int j = low;
52 while (j < left && arr[i] != key)
53 {
54 Swap(arr, i, j);
55 i--;
56 j++;
57 }
58 i = last + 1;
59 j = high;
60 while (j > right && arr[i] != key)
61 {
62 Swap(arr, i, j);
63 i++;
64 j--;
65 }
66 QsortThreeInsertGather(arr, low, first - leftLength - 1);
67 QsortThreeInsertGather(arr, first + rightLength + 1, high);
68 }
测试数据:
测试数据分析:三数取中+插排+聚集相等元素的组合,效果竟然好的出奇。
原因:在数组中,如果有相等的元素,那么就可以减少不少冗余的划分。这点在重复数组中体现特别明显啊。
其实这里,插排的作用还是不怎么大的。
以下是全部的测试程序源码:
1 using System;
2 using System.Collections.Generic;
3 using System.Linq;
4 using System.Text;
5 using System.Threading.Tasks;
6 using System.Diagnostics;
7 using System.Threading;
8
9 namespace Sort
10 {
11 class Program
12 {
13 static void Main(string[] args)
14 {
15 //开启10M的堆栈空间的线程
16 ThreadStart ts = new ThreadStart(Sort.DoQsort);
17 Thread thread = new Thread(ts, 10000000);
18 thread.Start();
19 }
20 }
21
22 class Sort
23 {
24 public static void DoQsort()
25 {
26 int[] arr = new int[100000]; //10W个空间大小的数组
27
28 //Random rd = new Random();
29 //for (int i = 0; i < arr.Length; i++) //随机数组
30 //{
31 // arr[i] = rd.Next();
32 //}
33
34 //for (int i = 0; i < arr.Length; i++) //升序数组
35 //{
36 // arr[i] = i;
37 //}
38
39 //for (int i = 0; i < arr.Length; i++) //降序数组
40 //{
41 // arr[i] = arr.Length - 1 - i;
42 //}
43
44 for (int i = 0; i < arr.Length; i++) //重复数组
45 {
46 arr[i] = 5768461;
47 }
48
49 Stopwatch watch = new Stopwatch();
50 watch.Start(); //开始计时
51
52 //QsortCommon(arr, 0, arr.Length - 1); //固定基准元
53 //QsortRandom(arr, 0, arr.Length - 1); //随机基准元
54 //QsortMedianOfThree(arr, 0, arr.Length - 1); //三数取中
55 //QsortThreeInsert(arr, 0, arr.Length - 1); //三数取中+插排
56 QsortThreeInsertGather(arr, 0, arr.Length - 1); //三数取中+插排+聚集相同元素
57
58 watch.Stop(); //计时结束
59
60 Console.WriteLine(watch.ElapsedMilliseconds.ToString());
61 }
62
63 /// <summary>
64 /// 1.0 固定基准元(基本的快速排序)
65 /// </summary>
66 public static void QsortCommon(int[] arr, int low, int high)
67 {
68 if (low >= high) return; //递归出口
69 int partition = Partition(arr, low, high); //将 >= x 的元素交换到右边区域,将 <= x 的元素交换到左边区域
70 QsortCommon(arr, low, partition - 1);
71 QsortCommon(arr, partition + 1, high);
72 }
73
74 /// <summary>
75 /// 2.0 随机基准元
76 /// </summary>
77 public static void QsortRandom(int[] arr, int low, int high)
78 {
79 if (low >= high) return; //递归出口
80 PartitionRandom(arr, low, high);