前言
这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记。
快速排序实际上是基于分治策略和递归,先来了解分治策略。
分治策略和递归
分治策略: 是将规模比较大的问题可分割成规模较小的相同问题。问题不变,规模变小。 这自然导致递归过程的产生。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。
递归: 若一个函数直接地或间接地调用自己,则称这个函数是递归的函数。(简单地描述为“自己调用自己”)。
分治法所能解决的问题一般具有以下四个特征:
- 该问题的规模缩小到一定的程度就可以容易地解决。
- 该问题可以分解为若干个规模较小的相同问题。
- 使用小规模的解,可以合并成该问题原规模的解。
- 该问题所分解出的各个子规模是相互独立的。
分治策略步骤
在分治策略中递归地求解一个问题,在每层递归中应用如
下三个步骤:
- 分解︰将问题划分成一些子问题,子问题的形式与原问题一样,只是规模更小。
- 解决︰递归地求解子问题。如果子问题的规模足够小,则停止递归,直接求解。
- 合并︰将小规模的解组合成原规模问题的解。
快速排序
实际上快速排序用到的思想也是分治策略,将问题的规模缩小,本质不变。
重点就是Partition函数。
partition划分函数
原理: 每次找到基准值,以基准值为分界线,左边的值全部比基准值小,右边的值全部比基准值大。
规则:
- 从右向左找比基准值小的数据,找到后放到左边;
- 从左向右找比基准值大的数据,找到后放到右边;
- 重复 1、2操作,如果 left == right (下标),循环退出,再将基准值放到nums[left]或nums[right]。
代码
int Partition(int* nums, int left, int right)
{
int key = nums[left];
while (left < right)
{
while (left < right && nums[right] > key)
{
--right;
}
if (left < right) nums[left] = nums[right];
while (left < right && nums[left] <= key)
{
++left;
}
if (left < right) nums[right] = nums[left];
}
nums[left] = key;
return left;
}
有了partition函数,那么递归就很好写
void PassQuick(int* nums, int left, int right)
{
if (left < right)
{
int pos = Partition(nums, left, right);
PassQuick(nums, left, pos - 1);
PassQuick(nums, pos + 1, right);
}
}
void QuickSort(int* nums, int numsSize)
{
if (nums == nullptr || numsSize <= 1) return;
PassQuick(nums, 0, numsSize - 1);
}
非递归形式
非递归形式可以引入栈或者队列,根据这种数据结构的特性,来达到循环过程中进行划分
void QuickSort2(int* nums, int numsSize)
{
if (nums == nullptr || numsSize < 2) return;
queue<int> q;
q.push(0); // 1
q.push(numsSize - 1); // 2
while (!q.empty()) // 3
{
int left = q.front(); q.pop();
int right = q.front(); q.pop();
int pos = Partition(nums, left, right); // 4
if (left < pos - 1) // 5
{
q.push(left);
q.push(pos - 1);
}
if (pos + 1 < right) // 6
{
q.push(pos + 1);
q.push(right);
}
}
}
- 定义队列后,初始值先将第一个元素下标(0)入队列。
- 将最后一个元素下标(numsSize - 1)入队列。
- 循环条件为判断队列是否为空,如果为空,说明所有数据划分完成。
- 每次从队头取出两个下标,进入Partition函数划分。
- 如果左边至少有两个元素,就将两个下标入队。
- 如果右边至少有两个元素,就将两个下标入队。
改进方案-随机划法
如果数据比较有序,那么快速排序会变得很慢,接近于O(n^2),所以这第一个方法是让基准值变化,使序列不那么有序。
原始数据(比较有序),在普通划分后,遍历的过程接近于数组长度,时间复杂度接近于O(n^2)
随机划分,交换基准值后:再进入Partition(),这样划分出来两边起码接近一半数据。
int RandPartition(int* nums, int left, int right)
{
srand(time(nullptr));
int pos = rand() % (right - left + 1) / 2 + left;
std::swap(nums[left], nums[pos]);
return Partition(nums, left, right);
}
单向划分
像上面所提到的划分函数,实际上是左右双指针在移动,那如果只让单向走动,或者让对单链表进行快排(没有prev前驱指针),这该如何编写?
方法: 利用两个指针指向数组开头,定义完基准值后,一个指针先走,遇到小于基准值的元素,就将另一个指针往后移一位,并将两指针对应的元素交换。直到指针越界退出。最后将慢指针所指向的元素与首元素基准值交换,最终结果就是:基准值左边元素均小于它,右边元素均大于它。 返回值为最后的基准值所在下标。
代码示例:
// 单向划分
int LeftPartition(int* nums, int left, int right)
{
int key = nums[left];
int i = left, j = left - 1;
while (i <= right)
{
if (nums[i] <= key)
{
++j;
std::swap(nums[i], nums[j]);
}
++i;
}
std::swap(nums[j], nums[left]);
return j;
}