引言:众所周知,快速排序算法是基于分治策略的一个排序算法,基本的算法在数据结构或算法设计与分析中都有讲解,本文不再赘述。本文主要总结的是快速排序的优化过程,即从一个基本的快速排序如何根据其中的缺陷一步一步优化来的。以下共有四个快排算法(qSort1、qSort2、qSort3、qSort4)依次由浅入深讨论。(动图源于网络)
1.基本快速排序法
如图所示:设基准为划分数组的第一个元素,命名为V
,此处有两个索引i
和j
,i
表示扫描的元素下标,j
是小于V
的元素集合的尾部下标。即小于V的集合∈[l+1,j]
,大于等于V的集合∈[j+1,i-1]
。
如把以下程序的if(a[i]<v)
条件换成if(a[i]<=v)
则划分如下图所示
程序如下:
private static void qSort1(int l,int r){
if(r>l){
int v = a[l];
int j = l;
for(int i = l+1;i<=r;i++)
if(a[i]<v)//比V小就和前面第一个大于等于V的元素交换位置
Swap(a,i,++j);
Swap(a,l,j);
qSort1(l,j-1);
qSort1(j+1,r);
}
}
运行结果如下:
排序前:49 38 65 97 76 13 27
排序后:13 27 38 49 65 76 97
由此可以发现第一个问题:如果是输入的是有序数组,则在划分基准上每次都会取边缘元素为划分基准(eg.1,2.3,4,5,6),致使快速排序最坏情况下时间复杂度由O(nlogn)降低至O(n²)。很不划算,有辱盛名。为解决有序数组下划分不均匀问题,而诞生了随机化优化,即在数组中随机选择一个元素和第一个元素交换位置,除此外后面算法程序和前面一样。
2.随机化快速排序法
随机化优化的产生如下图,可知基本快排最坏情况下算法复杂度退化到了O(n²),随机化为的是能在输入有序的数组下以较高的概率取到合适的基准,即在数组中随机选择一个元素和第一个元素交换位置,目的是选取的基准划分时尽可能均匀,除此外后面算法程序和前面一样。以下算法同时加入插排,因为当待排数组很少时使用插排比高级排序算法要快。
代码如下:
private static void qSort2(int l,int r){
if(r>l){
//1.若待排数量过小则用插入排序
if(r-l<=15){
insertSort(a,l,r); //插入排序
return;
}
//2.随机化
Random random = new Random();
int k = random.nextInt(r-l)+l;
Swap(a,l,k);
//以下和上述基本算法步骤一直
int v = a[l];
int j = l;
for(int i = l+1;i<=r;i++)
if(a[i]<v)
Swap(a,i,++j);
Swap(a,l,j);
qSort2(l,j-1);
qSort2(j+1,r);
}
}
运行结果如下:
排序前:49 38 65 97 76 13 27
排序后:13 27 38 49 65 76 97
附插入排序代码:
private static void insertSort(int[] a,int l,int r){
if(l<r){
for(int i=l+1;i<=r;i++){ //插入n-1次
int e = a[i];
int j;
for(j=i;(j>l)&&(a[j-1]>e);j--)
a[j] = a[j-1];
a[j] = e;
}
}
}
通过随机化优化解决了上述的问题一,即可使取得的基准为边缘元素的几率(尤其是在输入有序数组的情况下)变得很低,但基础快排法还遗留了另一个问题,也就是第二个问题:即使取得了合适的基准,但如果与此基准相同的元素过多,则会出现划分的某一端过多/少的情况(eg.小于等于基准的元素集中在左侧,大于基准的元素集中在右侧,则若与基准相同的元素过多,则划分下左侧元素会远多于右侧元素数量)。为避免在与基准相同元素过多情况下,划分的某一端元素数量出现极端化,从而诞生了二路快排法。
3.二路快速排序法
同样设基准为划分数组的第一个元素,命名为V
,此处有两个索引i
和j
,i
表示从左向右扫描的元素下标,其左边是小于基准V
的元素,j
表示从右向左扫描的元素下标,其右边是大于基准V
的元素。即小于V的集合∈[l+1,i)
,大于V的集合∈(j,r]
。
代码如下:
private static void qSort3(int l,int r){ //二路快排法
if(r>l){
//1.插排
if(r-l<=15){
insertSort(a,l,r);
return;
}
//2.随机化
Random random = new Random();
int k = random.nextInt(r-l)+l;
Swap(a,l,k);
//以下为二路快排
int v = a[l];
int i = l;
int j = r+1;
while(true){
while(a[++i]<v&&i<r);
while(a[--j]>v);
if(i>=j) break;
Swap(a,i,j);
}
Swap(a,l,j);
qSort3(l,j-1);
qSort3(j+1,r);
}
}
运行结果同上。
之所以称为二路快排是因为其中是不同于前两个算法的双向扫描,即通过两个索引分别从左和右向中扫描。这样做的好处是等于基准的元素可以均匀的被划分在基准两侧而不是极端的全部在一侧。此举在具有相同元素过多的情况下性能会远比基础快排好。这样在最坏的情况下时间复杂度依旧是O(nlogn)。在此通过qSort2和qSort3的递进下我们分别解决了基础快排法遗留下的两个问题。
但是产生了二路快排法后,我们很容易发现,当出现相同元素过多的情况下,二路快排所做的只是将与基准相同的元素均匀的划分在基准两侧,与基准相同的元素还需要经历后续的划分,那为何不直接把与基准相同的元素全部集中到一起,然后直接划分除此之外的两部分呢?答案是肯定的!为此,诞生了三路快排法。
4.三路快速排序法
同样设基准为划分数组的第一个元素,命名为V
,此处有三个索引lt
、gt
和i
。lt
表示小于V的最后一个元素下标,其左边是小于基准V
的元素,gt
表示大于V的第一个元素下标,其右边是大于基准V
的元素,i
表示当前索引。即小于V的集合∈[l+1,lt]
,等于V的集合∈[lt+1,i)
大于V的集合∈[gt,r]
。
代码如下:
private static void qSort4(int l,int r){ //三路快排法
if(r>l){
//1.插排
if(r-l<=15){
insertSort(a,l,r);
return;
}
//2.随机化
Random random = new Random();
int k = random.nextInt(r-l)+l;
Swap(a,l,k);
//以下为三路快排
int v = a[l];
int lt = l; //小于基准V的最后一个元素下标
int gt = r+1; //大于基准V的第一个元素下标
int i = l+1;
while(i<gt){
if(a[i]<v)
Swap(a,++lt,i++);
else if(a[i]>v)
Swap(a,--gt,i);
else //a[i]==v
i++;
}
Swap(a,l,lt);
qSort4(l,lt-1);
qSort4(gt,r);
}
}
运行结果同上。
通过将与基准相同的元素聚集在一起的方式,使在相同元素较多的情况下划分次数大大减少,极大的提高快排整体的效率。
在此,我将前几个优化过程中的快排算法总结如下:
1.基础快排法:易出现划分时某一侧数量极端化。
2.随机化快排法(+插排):在前者的基础上考虑极端化,但未考虑与基准相同元素过多时,相同元素全部被划分在某一侧的问题。
3.二路快排法:在前者的基础上通过双向扫描,使与基准相同的元素均匀被划分在两侧,在一定程度上减少划分次数。
4.三路快排法:在前者的基础上考虑相同元素聚集,根本的减少冗余元素划分,在重复元素很多的情况下能极大的减少划分次数。
补充:评价排序算法的好坏往往要看其在分别输入①乱序、②有序和③重复元素数组的情况下的运行时间。加入随机化是为了避免最坏划分,引入插排是为了高效处理有序数组,这两个举措都着重优化输入有序数组下的算法。
整体过程总结如下图: