快速排序是Hoare于1962年提出的一种基于二叉树结构的交换排序算法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该基准值将待排序列分为两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右序列重复该过程,直到所有元素都排列在相应位置上为止。
对于如何将待排序列分为两个子序列,常见的方式有以下三种:
- hoare的直接排序:基值不用单独记录,[begin+1,end] 是需要操作的内容,基值只在最后交换一次。
- 挖坑法:基值需要单独变量记录否则会被覆盖,[begin,end] 是操作的内容,多一个变量所以有“坑”。
- 前后指针法:基值无需单独记录,[begin+1,end] 是需要操作的内容,基值只在最后交换一次。这里两个指针走向相同,cur一定会遍历整个数组(左右指针的方式除非已排序,否则不会遍历全数组)
在之前博客中我有讲到八大排序算法的具体内容以及代码实现,可查看博客【八种排序算法】。下面我就只介绍快排的思想和实现的代码。
1.递归实现快速排序
以排升序为例,且“基值”为a[begin],他们时间复杂度都是O(NlogN)
1.1 hoare的直接排序
单趟排序过程的步骤如下:
1、选出一个key,一般是最左边或是最右边的;我选择最左边的值为key。
2、定义一个Left和一个Right,Right从右向左走,Left从左向右走。
注意:选择最左边的数据作为key,则Right先走;若选择最右边的数据作为key就Left先走
3、Right向左走到对应的值小于key时停下,Left开始向右走到对应的值大于key时停下此时将Left和Right的内容进行交换,交换后Right继续向左走,如此进行下去,直到Left和Right最终相遇(left==right),此时将相遇点对应的值与key交换即可。
上面的步骤完成便经过了一次单趟排序,使得key左边的内容全部都小于key,key右边的内容全部都大于key。接下来,我们将key的左序列和右序列再次分别进行这种单趟排序,如此反复的操作下去,直到数组的左右序列只有一个值,或是左右序列不存在时,我们的排序就完成了。
代码实现如下:
//快速排序(直接排序)
void QuickSort1(vector<int>& v,int left,int right) //直接排序
{
if (left>=right) //当只有一个数据或者序列不存在时,不用操作
return ;
int key = left; //以左边的第一个数为基值
while (left<right)
{
while (left<right&&v[right] >= v[key]) //先从右向左找小于key的
{ right--; }
while (left<right&&v[left] <= v[key]) //再从左向右找大于key的
{ left++; }
if (left < right)
{ swap(v[left], v[right]); } //找到left和right后交换两个值
}
swap(v[left], v[key]); //left== right ,将基值与相遇点的值交换
int meet= left; //划分开基值的左右部分
QuickSort(v, left, meet-1); //对左序列递归排序
QuickSort(v, meet+1, right); //对右右序列递归排序
}
1.2 挖坑法
单趟排序过程的步骤如下:
1、选出一个数据(一般是最左边或是最右边的)存放在key变量后,便在该数据位置形成一个坑。这里我选择最左边为key。
2、定义一个Left和一个Right(标记当前值下标);Left从左向右走,Right从右向左走。
注意:选择最左边的数据作为key,则Right先走;若选择最右边的数据作为key就Left先走
3、Right向左走到自己对应的值小于key时停下,并将对应的值抛入坑位,此时在Right处形成一个坑位;这时Left向右走到自己对应值大于key时停下,再将对应值抛入坑位,这时再Left处形成一个坑位。如此循环下去,直到最终Left和Right相遇,这时将key抛入坑位即可。
上面的步骤完成便经过了一次单趟排序,使得key左边的内容全部都小于key,key右边的内容全部都大于key。接下来,我们将key的左序列和右序列再次分别进行这种单趟排序,如此反复的操作下去,直到数组的左右序列只有一个值,或是左右序列不存在时,我们的排序就完成了。
代码实现如下:
//快速排序(挖坑法)
void quickSort2(vector<int> &num, int left, int right)
{
if (left >= right) //先检查左右条件
return;
int i = left, j = right, x = num[left]; //选择最左边的值为坑位
while (i < j) {
while (i < j && num[j] >= x)//从右向左找到第一个小于x的
j--;
if (i < j)
num[i++] = num[j];//填坑之后,查找下一位
while (i < j && num[i] <= x)//从左向右找第一个大于x的数
i++;
if (i < j)
num[j--] = num[i];
}
num[i] = x; //把最开始取出来的值放到新坑位
quickSort(num, left, i - 1);//以i为中间值,分左右两部分递归调用
quickSort(num, i + 1, right);
}
1.3 前后指针法
单趟排序过程的步骤如下:
1、选出一个key,一般是最左边或是最右边的。我选择最左边的
2、定义两个指针。起始时,prev指针指向序列开头,cur指针指向prev+1。
3、两指针都向右走,cur 先走。若cur指向的内容小于key,则prev先向后移动一位,然后交换prev和cur指针指向的内容;cur指针继续向右走到下一个小于key时交换prev后移一位的内容。如此进行下去,直到cur指针越界,此时将key和prev指针指向的内容交换即可。
上面的步骤完成便经过了一次单趟排序,使得key左边的内容全部都小于key,key右边的内容全部都大于key。接下来,我们将key的左序列和右序列再次分别进行这种单趟排序,如此反复的操作下去,直到数组的左右序列只有一个值,或是左右序列不存在时,我们的排序就完成了。
代码实现如下:
//快速排序(前后指针法)
void QuickSort3(int* a, int begin, int end)
{
if (begin >= end)//当只有一个数据或是序列不存在时,不需要进行操作
return;
//三数取中
int midIndex = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[midIndex]);
int prev = begin;
int cur = begin + 1;
int keyi = begin;
while (cur <= end) //当cur未越界时继续
{
if (a[cur] < a[keyi] && ++prev != cur) //cur指向的内容小于key
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
int meeti = prev; //cur越界时,prev的位置
Swap(&a[keyi], &a[meeti]); //交换key和prev指针指向的内容
QuickSort3(a, begin, meeti - 1); //key的左序列进行此操作
QuickSort3(a, meeti + 1, end); //key的右序列进行此操作
}
2.非递归实现快速排序
当我们需要将一个用递归实现的算法改为非递归时,一般需要借用一个数据结构 – 栈。例如二叉树的非递归遍历也是同样的实现方式。
于是我们可以先将直接排序、挖坑法和前后指针法的单趟排序单独封装起来。然后写一个非递归的快速排序,在函数内部调用单趟排序的函数即可。封装过程以前后指针法的单趟排序为例:
//前后指针法(单趟排序)
int PartSort4(int* a, int left, int right)
{
int prev = left;
int cur = left + 1;
int keyi = left;
while (cur <= right)//当cur未越界时继续
{
if (a[cur] < a[keyi] && ++prev != cur)//cur指向的内容小于key
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
int meeti = prev;//cur越界时,prev的位置
Swap(&a[keyi], &a[meeti]);//交换key和prev指针指向的内容
return meeti;//返回key的当前位置
}
封装完成后,我们就需要运用栈的知识实现快速排序的非递归算法,实现思路如下:
1、先将待排序列的第一个元素的下标和最后一个元素的下标入栈。
2、当栈不为空时,读取栈中的信息(一次读取两个:一个是L,另一个是R),然后调用某一版本的单趟排序,排完后获得了key的下标,然后判断key的左序列和右序列是否还需要排序,若还需要排序,就将相应序列的L和R入栈;若不需排序了(序列只有一个元素或是不存在),就不需要将该序列的信息入栈。
3、反复执行步骤2,直到栈为空为止
代码实现如下:
//快速排序(非递归实现)
void QSortNonR(int* a, int left, int right)
{
Stack st;
StackInit(&st);
StackPush(&st, right); //待排序列的right
StackPush(&st, left); //待排序列的left
while (StackEmpty(&st) != 0)
{
int begin = StackTop(&st); //读取当前栈顶left
StackPop(&st);
int end = StackTop(&st); //读取当前栈顶right
StackPop(&st);
int div = PartSort4(a, begin, end); //单趟排序完成,且返回本次排序基值的位置
//划分区间
if (begin < (div-1)) //左序列此时仍需要继续排序
{
StackPush(&st, div-1); //左序列的right入栈
StackPush(&st, begin); //左序列的left入栈
}
if ((div + 1) < end) //右序列继续排序
{
StackPush(&st, end);
StackPush(&st, div+1);
}
}
}
3.快速排序中的两个优化
快速排序的时间复杂度是O(NlogN),是我们在理想情况下计算的结果。在理想情况下,我们认为每次进行完单趟排序后,key的左序列与右序列的长度都相同,然而谁能保证每次选取的基值都是数组中的中位数呢?
当待排序列本就是一个有序的序列时,我们若是依然每次都选取最左边或是最右边的数作为key,那么快速排序的效率将达到最低,时间复杂度退化为O(N2)。
所以我们会发现,对快速排序效率影响最大的就是选取的key,若选取的key越接近中间位置,则效率越高。
因此我们针对取key值的方法,发现有以下两个可优化的点:
- 取key值时,尽量取到待排数据的中位数:三数取中法
- 递归层级太多时,尽量减少递归层级,可以让小区间使用其他排序方式
3.1 三数取中法
这里三数指的是最右边、最右边、中间位置的三个数,取中即表示取三个数中的中位数。这样我们就能保证,每次取到的key值不会是最大值/最小值,从而提高排序效率。
//三数取中
int GetMidIndex(int* a, int left, int right)
{
int mid = left + (right - left) / 2;
if (a[mid] > a[left])
{
if (a[mid] < a[right])
return mid;
else if (a[left]>a[right])
return left;
else
return right;
}
else
{
if (a[mid] > a[right])
return mid;
else if (a[left] > a[right])
return right;
else
return left;
}
}
注意:我们取到的中位数可能并不在数组的最左/最右边,那么我们应该通过swap(&a[begin], &a[midIndex]);
将它换到最左边/最右边。不然的话,我们排序时的循环可能就出不来了~~
3.2小区间优化
因为快排大多数时候是递归实现的,若待排数据太大不免会降低效率。因此我们可以通过减少递归深入的层级来解决快排的效率问题。具体做法是,我们可以设置一个判断语句,当序列的长度小于某个数的时候就不再进行快速排序,转而使用其他种类的排序。小区间优化若是使用得当的话,会在一定程度上加快快速排序的效率,而且待排序列的长度越长,该效果越明显。
//优化后的快速排序
void QuickSort5(int* a, int begin, int end)
{
if (begin >= end)//当只有一个数据或是序列不存在时,不需要进行操作
return;
if (end - begin + 1 > 20)//可自行调整
{
//可调用快速排序的单趟排序三种中的任意一种
//int keyi = PartSort1(a, begin, end);
//int keyi = PartSort2(a, begin, end);
int keyi = PartSort3(a, begin, end);
QuickSort(a, begin, keyi - 1);//key的左序列进行此操作
QuickSort(a, keyi + 1, end);//key的右序列进行此操作
}
else
{
//HeapSort(a, end - begin + 1);
ShellSort(a, end - begin + 1);//当序列长度小于等于20时,使用希尔排序
}
}
以上就是我理解的快排的全部内容。在【用C++实现快速排序】这里,也有完整运行的结果哦,我可没骗你呢