数据结构-快速排序

08月23日 收藏 0 评论 0 java开发

数据结构-快速排序

转载声明:文章来源https://blog.csdn.net/z_x_m_m_q/article/details/82220884

快速排序
快速排序是C.R.A.Hoare于1962年提出的一种划分交换排序。它采用了一种分治的策略,通常称其为分治法(Divide-and-ConquerMethod)。

基本思想:
快速排序使用分治的思想,从待排序序列中选取一个记录的关键字为key,通过一趟排序将待排序列分割成两部分,其中一部分记录的关键字不大于key,另一部分记录的关键字不小于key,之后分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

快速排序算法的基本步骤为(从小到大):
选择关键字:从待排序序列中,按照某种方式选出一个元素作为 key 作为关键字(也叫基准)。
置 key 分割序列:通过某种方式将关键字置于一个特殊的位置,把序列分成两个子序列。此时,在关键字 key 左侧的元素小于或等于 key,右侧的元素大于 key(这个过程称为一趟快速排序)。
对分割后的子序列按上述原则进行分割,直到所有子序列为空或者只有一个元素。此时,整个快速排序完成。
选择关键字 key (基准)的方式:
选择待排序序列的哪个元素为基准是非常重要的,因为基准关系到分割后两个子序列的长度。对于分治算法,当每次划分时,算法若都能分成两个等长的子序列时,那么分治算法效率会达到最大。
总之,基准对整个算法的效率有着决定性影响。

我们特意选择基准是为了面对大多数待排序序列时能把待排序序列分成两个等长的子序列或者尽量往这个理想的方向靠拢。
这里介绍三种选择基准的方式:

方法一:取头尾位置值法
取待排序序列的第一个或最后一个位置元素作为基准

int SelectKey_1(int a[],int lift,int right)
{
return a[right]; //选择序列最后一个元素为基准(关键字)
}

分析:如果待排序序列是随机的,这种方法下的处理时间是可以接受的。如果待排序序列已经有序时,此时对快排的分割是非常不利的,因为每次划分只能使待排序序列减一。此时为最坏情况,快速排序等效于没有一点优化的冒泡排序,时间复杂度为Θ(n^2)。待排序序列是有序或部分有序的情况是相当常见的,使用头尾元素作为基准是非常糟糕的,为了避免这个情况,就引入了下面两个获取基准的方法。

方法二:随机选取基准法
取待排序序列任意位置元素作为基准(注意一下下面代码里的注释)

int SelectKey_2(int a[],int lift,int right)
{
srand((unsigned)time(NULL));
int key = rand() % (right - lift) + left;

swap(&a[key],&a[right]); //互换一下位置,为了调用划分函数时与上面方式下的代码保持统一
return a[right];
}

分析:由于选取基准的位置是随机产生的,所以分割也不会总是会出现劣质的分割。随机化快速排序得到理论最坏情况的可能性是极小的。当待排序序列都相等时,此中情况下排序的时间复杂度仍为O(N^2),但是,随机化快速排序对于绝大多数排序序列达到 O(nlogn)的期望时间复杂度。

方法三:三数取中法
一般的做法是使用左端、右端和中心位置上的三个元素的中值作为基准

int SelectKey_3(int a[], int left, int right)
{
int mid = left + ((right - left) >> 1); //计算数组中间的元素的下标

if (a[left] < a[mid])
swap(&a[left], &a[mid]);
if (a[left] < a[right])
swap(&a[left], &a[right]);
if (a[mid] > a[right])
swap(&a[mid], &a[right]); //a[left]>=a[right]>=a[mid]

//分割时可以直接使用right位置的元素作为基准,而不用改变分割函数了
return a[right];
}

分析: 使用三数中值分割法消除了预排序序列有序情况下造成的极大消耗,同时也是对随机数法下有可能出现的小概率事件的完善(当待排序序列有序的情况下随机数法仍然有可能给我们的基准是序列头尾的元素)。当待排序序列都相等时,此中情况下排序的时间复杂度仍为O(N^2)。

三种置 key 分割法:

方法一:挖坑法
以一个数组序列 a[] = {72,6,57,88,85,42,83,73,48,60} 作为示例先来看挖坑法的过程(取区间最后一个数为关键字 key):

现将下表为 right 的元素作为关键字存于 key,key = a[right] = 60,此时该位置就是一个坑,意味着可将其他元素填充到这里来

从左向右找比 key 大的数,找到后将该数填入上面形成的坑中,显然 a[0] = 72 满足条件,将它填入坑中,它的位置就是一个新的坑了

从右向左找比 key 小的数,找到后将该数填入上面形成的坑中,显然 a[8] = 48 满足条件,将它填入坑中,它的位置就是一个新的坑了

 从左向右找比 key 大的数,找到后将该数填入上面形成的坑中,显然 a[3] = 88 满足条件,将它填入坑中,它的位置就是一个新的坑了

从右向左找比 key 小的数,找到后将该数填入上面形成的坑中,显然 a[5] = 42 满足条件,将它填入坑中,它的位置就是一个新的坑了

 从左向右找比 key 大的数,找到后将该数填入上面形成的坑中,显然 a[4] = 85 满足条件,将它填入坑中,它的位置就是一个新的坑了

接下来的一步应该是从右往左找比 key 小的数用来填坑。此时,当 end 向左走时发现 begin = end 了,这说明坑左边的数不大于 key,坑右边的数不小于 key,这是没必要再走了,直接将 key 填入坑中即可

以上过程就是一次快速排序,在这一次快速排序中关键字 key 将数组整个区间分成了以下两个子区间

对子区间处理过程和上面的的过程完全类似,对子区间的快速排序的细节不做解释了。。。接下来对左右子区间继续进行以上操作,直至所有子区间只有一个数或为空时结束,这时整个序列的排序也就完成了。

挖坑法对应的代码如下:

//挖坑法
int PartSort(int*arr, int left, int right)
{
assert(arr);

int begin = left, end = right;
int key = arr[right];
while (begin < end)
{
while (begin < end && arr[begin] <= key)
++begin;

arr[end] = arr[begin];

while (begin < end && arr[end] >= key)
--end;

arr[begin] = arr[end];
}

arr[begin] = key;

return begin;
}

void QuickSort(int*arr,int left,int right)
{
assert(arr);

if (left >= right) //1 2 3 4这时就会出现 left>right 的情况
return;

int div = PartSort1(arr,left,right);

QuickSort(arr,left,div-1);
QuickSort(arr,div+1,right);
}

方法二:左右指针法
关于左右指针法,直接看下面这张图就行了 

左右指针法置key过程

以上过程就是一次快速排序,在这一次快速排序中关键字 key 将数组整个区间同样分成了分成了以下子区间 

接下来就是用同样的方式处理子区间的过程了,直至所有子区间只有一个数或为空时结束,这时整个序列的排序也就完成了

左右指针法对应的代码如下:

//左右指针法
int PartSort(int* arr, int left, int right)
{
assert(arr);

int key = arr[right];
int begin = left, end = right;
while (begin < end)
{
while (begin < end && arr[begin] <= key)
++begin;

while (begin < end && arr[end] >= key) //这里等号必须要有
--end;

if (begin < end)
Swap(&arr[begin],&arr[end]);
}
Swap(&arr[begin], &arr[right]);

return begin;
}

void QuickSort(int*arr,int left,int right)
{
assert(arr);

if (left >= right) //1 2 3 4这时就会出现 left>right 的情况
return;

int div = PartSort(arr,left,right);

QuickSort(arr,left,div-1);
QuickSort(arr,div+1,right);
}

 方法三:前后指针法

前后指针法较以上两种方法更抽象,理解起来是有难度的,但算法本身逻辑很严谨,就是说当你理解了前后指针法的思想后写出的代码很少会出现因为边界等问题考虑不全而引起错误,理解这个算法最好自己认认真真的走一下它的过程。

以一个数组序列 a[] = {2,4,5,7,8,9,1,0,3,6} 作为示例先来看前后指针法的过程(取区间最后一个数为关键字 key):

一些初始设置如下表所示

因为 a[cur] =2 < key,所以让 prev 往后走上一步
此时 a[cur] == a[prev],二者不交换位置
让 cur 往后继续走一步

因为 a[cur] = 4 < key,所以让 prev 往后走上一步
此时 a[cur] == a[prev],二者不交位置换
让 cur 往后继续走上一步

因为 a[cur] = 5 < key,所以让 prev 往后走上一步
此时 a[cur] == a[prev],二者不交换位置
让 cur 往后继续走上一步

因为 a[cur] = 7 >= key,所以  prev 这次在原位置保持不动
让 cur 往后继续走上一步  

因为 a[cur] = 8 >= key,所以 prev 在原位置保持不动
让 cur 往后继续走上一步 

因为 a[cur] = 9 >= key,所以 prev 在原位置保持不动
让 cur 往后继续走上一步 

因为 a[cur] = 1 < key,所以让 prev 往后走上一步
此时 a[cur] != a[prev],二者交换位置
让 cur 往后继续走上一步

因为 a[cur] = 0 < key,所以让 prev 往后走上一步
此时 a[cur] != a[prev],二者交换位置
让 cur 往后继续走上一步

因为 a[cur] = 3 < key,所以让 prev 往后走上一步
此时 a[cur] != a[prev],二者交换位置
让 cur 往后继续走上一步

 当 cur == right 时,prev 往后走上一步,交换 prev 与 right 位置的值即可

以上过程就是一次快速排序,在这一次快速排序中关键字 key 将数组整个区间同样分成了分成了以下子区间

接下来的就是用同样的方式处理子区间的过程了,直至所有子区间只有一个数或为空时结束,这时整个序列的排序也就完成了
前后指针法代码:

//前后指针法
int PartSort(int*arr, int left, int right)
{
assert(arr);

int key = arr[right];
int prev = left - 1, cur = left;
while (cur < right)
{
if (arr[cur] < key && ++prev != cur)
Swap(&arr[cur],&arr[prev]);

++cur;
}

Swap(&arr[++prev],&arr[right]);

return prev;
}

void QuickSort(int*arr,int left,int right)
{
assert(arr);

if (left >= right) //1 2 3 4这时就会出现 left>right 的情况
return;

int div = PartSort(arr,left,right);

QuickSort(arr,left,div-1);
QuickSort(arr,div+1,right);
}

三种优化方式:

优化一:小区间优化
当快排不断递归处理子区间时,随着子区间的不断缩短,子区间数量快速增加,用快排处理这些区间很小且数量很多的子区间时,系统要为每次的函数调用分配栈帧空间,这对我们是很不利的。当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时使用插排对其优化
截止范围:待排序序列长度N = 10,虽然在5~20之间任一截止范围都有可能产生类似的结果,这种做法也避免了一些有害的退化情形。摘自《数据结构与算法分析》Mark Allen Weiness 著

if (right - left + 1 < 10)
{
InsterSort(arr,right,lift);
return;
}
else //执行快排

分析:在面对待排序序列都相等的情况是,以上提到的所有解决方法都不能降低算法的时间复杂度,此时三数取中和小区间优化在时间效率上根本起不到作用。

优化二:在一次分割结束后,可以把与Key相等的元素聚在一起,继续下次分割时,不用再对与key相等元素分割

优化思想:
在划分过程中,把与key相等元素放入数组的两端
划分结束后,把与key相等的元素移到基准周围
分析:在数组中,如果有相等的元素,那么就可以减少不少冗余的划分。这点在重复数组中体现特别明显啊。
时间复杂度和空间复杂度:

稳定性:
快排是不稳定的算法

C 0条回复 评论

帖子还没人回复快来抢沙发