排序——快速排序

这里是快排的两种常见解法,还有一种快慢指针的解法,有兴趣的同学可以参考快慢指针实现快排

快速排序用的恰到好处时,它是迄今为止所有内排序算法中最快的一种。快速排序算法(QSA)本身在不断对小数组进行排序,是分治法的副产品,它是不稳定的。QSA应用广泛,典型应用是UNIX系统调用库函数例程中的qsort函数。QSA有时会由于最差时间代价的性能而在某些应用中无法采用。相对于用二叉树进行排序来说,QSA以一种更有效的方式实现了“分治法”的思想。

二叉树排序,是将所有的节点放到一个二叉查找树中,然后再按中序方法遍历,结果得到一个有序数组。

缺点:存一棵二叉树要占用大量节点空间;把结点插入二叉树中需要花很多时间。

优点:二叉查找树隐含地实现了分治法(Divide and Conquer);二叉查找树的根结点将树分为两部分,所有比它小的记录结点都在左子树,所有比它大的记录结点都位于其右子树。(对其左右子树分别进行处理)

快速排序算法

首先选择一个轴值:Find Pivot;

然后进行数组分割:Partition;

QSA再对轴值的左右子数组分别进行类似操作。

选择轴值的方法

最简单的是使用第一个记录的关键码。这种方法的缺点在于如果输入的数组是正序的或者逆序的,就会将所有节点分到轴值的一边。较好的是随机选取轴值,优点是减少原始输入对排序的影响,缺点是随机选取轴值,开销太大。可以用选取数组中间点的方法代替。

函数Partition

由于事先并不知道有多少关键码比中心点(轴值)小,我们可以用一种较为巧妙的方法分割:从数组的两端移动下标,必要时交换记录,直到数组两端的下标相遇为止。

假如事先知道有多少个节点比轴值小,Partition只需将key比轴值小的 k 个节点放到数组的 k 个位置上,关键码比轴值大的元素放到最后即可。

数组的分割

假设输入的数组中有 k-1 个小于轴值的节点,于是这些节点被放在数组的最左边的 k-1 个位置上,而大于轴值的节点被放在数组最右边的n-k个位置上。

在给定分割中的节点不必被排序,只要求所有节点都放在了正确的分组位置中。而轴值的位置就是下标k。

Partition程序解析

partition函数的具体代码如下,

int partition(int arr[], int i, int r, int nPivot)
{
	int m = i - 1;
	do {
		while (arr[++m] < nPivot);
		while (r >= i && arr[--r] > nPivot);
		swap(arr[m], arr[r]);
	} while (m < r);

	swap(arr[m], arr[r]);
	return m;
}

分析:

m = i - 1;        保证了从数组 arr[i] 开始处理。

arr[--r]保证了 arr[j] 没有被处理。开始时边界参数 m 和 r 在数组的实际边界之外,每一轮外层do循环,都将它们向数组中间移动,直到它们相遇为止。

每层内层while循环,边界下标都先移动,之后再与轴值比较。保证了每个while循环都有所进展,即使当最后一次do循环中两个被交换的值都等于轴值时也同样被处理。

第二个while循环中保持 r >= i,保证了当轴值所分割出来的左半部分的长度为 0 时,r 不至于会超出数组的下界(下溢出)。

函数返回右半部分的第一个下标值,因此我们可以确定递归调用QuickSort的子数组的边界。

快速排序中需要注意的是,调用 partition 函数之前,轴值已被放在数组的最后一个位置上。函数partition 将返回值 k,这是分割后的右半部分的起始位置。函数分割一定不能影响到数组中 j 所指的记录,然后轴值被放到下标为 k 的位置上,这就是它在最终排序好的数组中的位置。

要做到上面这一点,必须保证在递归调用QuickSort函数的过程中轴值不再移动。即使是在最差的情况下选择了一个不好的轴值,导致分割出了一个空子数组,而另一个子数组起码有n-1个记录。这种情况逆序输出可好

前面所述,算法中选择最右边的元素位置存放轴值,再把数据分成两个部分后,再和右半部分最左边的值交换,从而把轴值交换到中间位置。也可选择最左边的一元素位置作为轴值,并暂存轴值,空出此位置给高端不合适的值搬移到此处,然后高端又会空出一个位置...如此循环,直到高低端指针相遇,空出的位置恰好放回轴值。

代码示例如下:

#include<iostream>
using namespace std;

void swap(int &a, int &b)
{
	int tmp;
	tmp = a; a = b; b = tmp;
}

int FindPivot(int i, int j)
{
	return (i + j) / 2;
}

int partition(int arr[], int i, int r, int nPivot)
{
	int m = i - 1;
	do {
		while (arr[++m] < nPivot);
		while (r >= i && arr[--r] > nPivot);
		swap(arr[m], arr[r]);
	} while (m < r);

	swap(arr[m], arr[r]);
	return m;
}

void QuickSort(int arr[], int i, int j)
{
	int nPivotIdx = FindPivot(i, j);
	swap(arr[nPivotIdx], arr[j]);
	int k = partition(arr, i, j, arr[j]);
	swap(arr[k], arr[j]);
	if (k - i > 1)	QuickSort(arr, i, k - 1);
	if (j - k > 1)	QuickSort(arr, k + 1, j);
}

int main()
{
	int arr[10];
	cout << "input arr elements please." << endl;
	for (int i = 0; i < 10; i++)
	{
		cin >> arr[i];
	}
	cout << "after sorting:";
	
	QuickSort(arr, 0, 9);

	for (int i = 0; i < 10; i++)
	{
		cout << arr[i] << " ";
	}
	cout << endl;
	system("pause");
	return 0;
}

后一种策略是在不断的变动轴值的位置,直到轴值到合适的位置(高低指针相遇)。

代码示例如下,

#include<iostream>
using namespace std;

int partition(int arr[], int low, int high)
{
	arr[0] = arr[low];
	while (low < high)
	{
		while (low < high && arr[high] >= arr[0])
			high--;
		arr[low] = arr[high];
		while (low < high && arr[low] < arr[0])
			low++;
		arr[high] = arr[low];
	}
	arr[low] = arr[0];
	return low;
}

void QuickSort(int arr[], int low, int high)
{
	int nPivotIdx;
	if (low < high)		// count more than 1
	{
		nPivotIdx = partition(arr, low, high);
		// recursion
		QuickSort(arr, low, nPivotIdx - 1);
		QuickSort(arr, nPivotIdx + 1, high);
	}
}

int main()
{
	int arr[9];
	cout << "input arr elements please." << endl;
	for (int i = 1; i <= 8; i++)
	{
		cin >> arr[i];
	}
	cout << "after sorting:";
	
	QuickSort(arr, 1, 8);

	for (int i = 1; i <= 8; i++)
	{
		cout << arr[i] << " ";
	}
	cout << endl;
	system("pause");
	return 0;
}

分析快速排序的函数过程

首先看对长度为k的子数组进行 FindPivot 和 Partition 操作的例子。知道了 FindPivot 和 Partition 的时间,就可以分析快速排序的时间复杂度。

最差情况:出现在轴值未能很好的分割数组,即一个子数组中无节点,而另一个数组中有 n-1 个节点。下一次处理的子数组只比原数组小1。如果上述这种情况发生在每一次分割过程中,那总时间代价为1 + 2 + 3 + ... + n = O(n*n)。这种情况仅在每个轴值都未能将数组分割好时出现,并没多大可能发生。所以这种最差情况并不影响快排的工作。

最佳情况:每个轴值都将数组分成相等的两部分,此时要分割 log2n 次,最上层原始待排序数组中有 n 个记录,第二层分割的数组是2个长度各为n/2的子数组,第三层分割的数组是4个长度各为n/4的子数组,以此类推,所以每层所有分割步骤之和为n。时间复杂度是O(nlog2n)。

平均情况:轴值将数组分成长度为0和n-1、1和n-2、...,以此类推。这些分组的概率是相等的,O(nlog2n)

快速排序的改进

改变常数因子

1.寻找函数FindPivot

三者取中法

random

看当前子数组中第一个,中间一个及最后一个位置的数组

2.事实上,当 n 很小时,快排很慢。

用处理小数组较快的方法来替换快排,如插入排序和选择排序。但有一种更有效更简单的优化方法:

当快排的子数组小于某个阈值时,什么也不做。尽管那些子数组中的数值是无序的,但此时左边数组key都小于右边,所以虽然快排只是大致将排序码移到了接近正确的位置,不过已经基本有序,这样的待排序数组适用插入排序,最后一步仅是调用一下插入排序过程将整个数组排序。最好的组合方式是当子数组的长度小于9时就选用插入排序。

3.缩短运行时间与递归调用有关

由于每个快排操作都要对2个子序列排序,所以无法使用一种简单方法转换为等价的循环算法。但当需要存储的信息不是很多时,可以使用栈模拟递归调用,实现快排。

实际上,我们没有必要存储子数组的拷贝,只需将子数组的边界存起来。如果注意调整快排的递归调用顺序,堆栈的深度可以保持较小。可将函数FindPivot和Partition代码直接内嵌到算法中,直接编码,减少函数调用。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值