排序算法(五)快速排序多种版本

来源:互联网 发布:linux启动oracle实例 编辑:程序博客网 时间:2024/05/09 07:46
 快速排序,就像它的名称一样,是时间复杂度比较低的一种排序算法。
     我们知道,快速排序是通过分治的方法,将一个大的区间划分成小区间(找一个枢纽,将大的数放置在枢纽的右边,小的数放置在枢纽左边),然后对左右的两个小区间进行排序的过程。所以,快速排序的主要就是将区间进行划分,也就是单趟排序。单趟排序有以下的几种方法:
注明:以下3种方法中的GetMidNum(),下文予以解释~
1.左右指针法

     实现思路:找出一个枢纽(或区间开始的元素,或区间结束的元素),从左边开始进行遍历,找到一个大于枢纽的值停下来,然后从右边开始找小于枢纽的值,然后停下来,将左右找到的值进行交换 (左边找到的是大于枢纽的值,右边找到的是小于枢纽的值)。当左边与右边相遇,说明所有的元素已经处理完成。然后将相遇点的元素与枢纽值进行交换。下边给出代码实现:

//左右指针法int PartSort1(int* a,int begin,int end){    int midNumIndex = GetMidNum(a,begin,end);    if(end != midNumIndex)       swap(a[end],a[midNumIndex]);    int key = a[end];    int left = begin;    int right = end - 1;        while(left < right)    {       while(left < right && a[left] <= key)//在左边找大于key的值       {           ++left;       }       while(left < right && a[right] >= key)//在右边找小于key的值       {           --right;       }       swap(a[left],a[right]);    }    //如果left一直没有找到大于key的值,会停在end的前一个位置,此时并不需要交换    if(a[left] > a[end])       swap(a[left],a[end]);    return left;}


如果你仔细阅读代码,你就会发现,出了while循环并不是直接进行交换,而是先进行判断,这究竟是为了处理哪一种情况呢??

 5     6    2     1       4     0    3     8

我们约定:枢纽值给定的是区间右边的值,right从枢纽值的前一个位置开始。当left一直向后遍历,遇到的元素都是小于枢纽值的。当left走到3那个元素的时候,与right是相等的,第二个while循环不会进入。如果我们没有判断直接进行交换,那样就是不对的。所以,只有当相遇点的值大于end处的值才进行交换。
如果一开始就让right从end开始遍历,如果枢纽前的数据都是小于枢纽值的,那么left会一直走到枢纽处,与right相遇,此时left和end是同一个位置,交换不交换都无所谓,所以这种情况下就是不需要判断可以直接交换的。
2.挖坑法:
其实挖坑法的思想是类似于左右指针法的。
实现思路:先将区间最右边的值保存下来(这个也就是我们的枢纽值),也就是区间最右边的这个位置可以被随意覆盖,此时区间的最右边就形成了一个坑。我们开始从左向右进行遍历,如果找到大于枢纽值的元素,将其填充在右边的坑里,此时left这个位置就变成了一个坑,然后从右向左找小于枢纽值的元素,找到之后,将其填充在左边的坑里,就这样,以此类推。
下边给出代码实现:
//挖坑法int PartSort2(int* a,int begin,int end){    int midNumIndex = GetMidNum(a,begin,end);    if(end != midNumIndex)       swap(a[end],a[midNumIndex]);    int key = a[end];    int left = begin;    int right = end;    while(left < right)    {       while(left < right && a[left] <= key)       {           ++left;       }       a[right] = a[left];//将找到的值填到预留的坑       while(left < right && a[right] >= key)       {           --right;       }       a[left] = a[right];    }    a[left] = key;    return left;}


出了while循环就是左右指针相遇的情况,将枢纽值直接放置在left和right相遇点。那么,像前边的左右指针法一样,如果一开始right就指向end的前一个位置。

 5     6    2     1       4     0    3     8

right一开始指向的元素是3,left一直向右遍历,都没有找到大于枢纽值的元素。当left到3的那个元素时,left和right相遇,将枢纽值放在left位置是不合适的。
鉴于处理的情况多,所以我们最好将right从end位置开始遍历。
关于挖坑法的另外一种写法(用swap操作代替赋值操作):
int PartSort2(int* a,int begin,int end){    int midNumIndex = GetMidNum(a,begin,end);    if(end != midNumIndex)       swap(a[end],a[midNumIndex]);    int key = a[end];    int left = begin;    int right = end;    while(left < right)    {       while(left < right && a[left] <= key)       {           ++left;       }       swap(a[right] , a[left]);//将找到的值填到预留的坑       while(left < right && a[right] >= key)       {           --right;       }       swap(a[right] , a[left]);    }    return left;}


我将这两种方法进行书面推导分析,不懂的读者可以自行阅读。

3.前后指针法:
实现思路:开始让prev指向begin的前一个位置,cur指向begin位置,从左向右,当找到cur指向的值小于枢纽值,将prev后移,然后prev所指向的值与cur所指向的值进行交换。代码实现如下;
//前后指针法int PartSort3(int* a,int begin,int end){    int midNumIndex = GetMidNum(a,begin,end);    if(end != midNumIndex)       swap(a[end],a[midNumIndex]);    int key = a[end];    int prev = begin - 1;    int cur = begin;    while(cur < end)    {       if(a[cur] < key && ++prev != cur)//prev指向大于key的值       {           swap(a[prev],a[cur]);       }       ++cur;    }    swap(a[++prev],a[end]);    return prev;}


下边,我依然图解代码。

文章开始就提出了这么一个函数GetMidNum(),其实它是进行单趟排序的优化。我们知道如果枢纽值找的合适的话(恰好是中位数或者接近中位数),快排的效率就是很高的了,相反,如果枢纽值是最大值或者最小值或者接近最大最小值时,排序效率比较低。我们的这个函数GetMidNum()就是为了尽量不要得到最大数或者最小数。具体方法就是从给定的区间的首尾和中间的三个元素中取出处于中间位置的数。这样,最差情况得到的只会是次大数或者次小数。下边给出GetMidNum()实现代码:
int GetMidNum(int* a,int begin,int end){    int mid = begin + (end - begin)/2;    //找出个数中处于中间位置的数    // a[begin] > a[mid]    if(a[begin] > a[mid])    {       if(a[mid] > a[end])//a[begin] > a[mid] > a[end]           return mid;       //a[begin] > a[mid] < a[end]       else if(a[begin] > a[end])//a[begin] > a[end] > a[mid]           return end;       else  //  a[end] > a[begin]> a[mid]           return begin;    }    //a[mid]> a[begin]    else    {       if(a[begin] > a[end])//a[mid]> a[begin]> a[end]           return begin;       //a[mid]> a[begin]< a[end]       else if(a[mid] > a[end])//a[mid] > a[end]> a[begin]           return end;       else     //a[end] > a[mid] > a[begin]           return mid;    }}


通过这样的方法,我们可以进行单趟排序的优化。
下边我们用递归实现快速排序:
void QuickSort(int* a,int begin,int end){    if(begin < end)    {       int key = PartSort2(a,begin,end);       QuickSort(a,begin,key-1);       QuickSort(a,key + 1,end);    }}


我们知道,递归代码虽然看起来比较简单,但是递归时的函数进行压栈的开销是比较大的,效率很低,所以,我们可以对排序进行优化:如果区间比较小时,我们可以采用插入排序。下边给出代码实现:
//快排优化版本void QuickSortOP(int* a,int begin,int end){    //由于递归太深会导致栈溢出,效率低,所以,当区间比较小时采用插入排序。    if(end - begin > 13)    {       int key = PartSort3(a,begin,end);       QuickSort(a,begin,key-1);       QuickSort(a,key + 1,end);    }    else       InsertSort(a+begin,end-begin + 1);}


经过前人的研究,区间大小的标准定为13比较合适。
上边已经提到递归的缺陷,那么我们是否可以将快速排序写成非递归的呢?其实,所以的递归代码都是可以通过栈来实现非递归,有些递归(尾递归)代码可以写成循环,有些则不能。
下边给出快速排序的非递归实现
void QuickSortNonR(int* a,int begin,int end){    stack<int> s;    if(begin < end)    {//先将区间尾放进栈里       s.push(end);       s.push(begin);       while(!s.empty())       {           int low = s.top();           s.pop();           int high = s.top();           s.pop();           int mid = PartSort1(a, low, high);           if(low < mid-1)           {                s.push(mid-1);                s.push(low);                     }           if(mid+1 < high)           {                s.push(high);                s.push(mid+1);           }       }    }}


关于快速排序的各种实现方法也就要整理完了,那么快速排序究竟是有多快?它的时间复杂度是多少?
我们知道,快速排序的时间复杂度取决于它的递归的深度。在最优的情况下,每次的单趟排序都很均匀。每次都可以将区间均分。
T(n) <= 2T(n / 2) + n 
T(n) <= 2(2T(n/4) + n/2) + n
.....
T(n) = O(nlogn)
那么最坏情况下,也就是每次选的枢纽值是(接近)最大值或者最小值,也就是我们给定的待排序的数组是(接近)升序或者逆序,这时我们就需要n-1次递归调用。
总的比较次数:n-1 + n-2 + n-3 +...+1 = n(n-1)/2
(第一次需要比较n-1次,第二次需要n-2次,等等,以此类推)
所以,快速排序的最坏的时间复杂度就是O(N*N)。
我们之前说过,算法的时间复杂度说的就是最坏情况的时间复杂度。
然而,有例外:
有时时间复杂度并不看最坏情况,而看最好情况,比如哈希表的查找,快速排序。
关于快速排序就整理到这里~







2 0