快速排序-递归与非递归实现-如何单向划分 | 青训营笔记

171 阅读4分钟

前言

这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记。

快速排序实际上是基于分治策略和递归,先来了解分治策略。

分治策略和递归

分治策略: 是将规模比较大的问题可分割成规模较小的相同问题。问题不变,规模变小。 这自然导致递归过程的产生。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。

递归: 若一个函数直接地或间接地调用自己,则称这个函数是递归的函数。(简单地描述为“自己调用自己”)。

分治法所能解决的问题一般具有以下四个特征:

  1. 该问题的规模缩小到一定的程度就可以容易地解决。
  2. 该问题可以分解为若干个规模较小的相同问题。
  3. 使用小规模的解,可以合并成该问题原规模的解。
  4. 该问题所分解出的各个子规模是相互独立的。

分治策略步骤

在分治策略中递归地求解一个问题,在每层递归中应用如
下三个步骤:

  1. 分解︰将问题划分成一些子问题,子问题的形式与原问题一样,只是规模更小。
  2. 解决︰递归地求解子问题。如果子问题的规模足够小,则停止递归,直接求解。
  3. 合并︰将小规模的解组合成原规模问题的解。

快速排序

实际上快速排序用到的思想也是分治策略,将问题的规模缩小,本质不变
重点就是Partition函数。

partition划分函数

原理: 每次找到基准值,以基准值为分界线,左边的值全部比基准值小,右边的值全部比基准值大。

规则:

  1. 从右向左找比基准值小的数据,找到后放到左边;
  2. 从左向右找比基准值大的数据,找到后放到右边;
  3. 重复 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);
		}
	}
}
  1. 定义队列后,初始值先将第一个元素下标(0)入队列。
  2. 将最后一个元素下标(numsSize - 1)入队列。
  3. 循环条件为判断队列是否为空,如果为空,说明所有数据划分完成。
  4. 每次从队头取出两个下标,进入Partition函数划分。
  5. 如果左边至少有两个元素,就将两个下标入队。
  6. 如果右边至少有两个元素,就将两个下标入队。

改进方案-随机划法

如果数据比较有序,那么快速排序会变得很慢,接近于O(n^2),所以这第一个方法是让基准值变化,使序列不那么有序。

原始数据(比较有序),在普通划分后,遍历的过程接近于数组长度,时间复杂度接近于O(n^2)

图片.png

随机划分,交换基准值后:再进入Partition(),这样划分出来两边起码接近一半数据。

图片.png

图片.png

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;
}