C++ 快速排序(左右指针法,快慢指针法,三路划分法)

快速排序(quick sort)可能是应用最广泛的排序算法了。快排流行的原因是它实现简单、适用于各种不同的输人数据且在一般应用中比其他排序算法都要快得多。快速排序的优点包括:

① 它是原地排序 (只需要一个很小的辅助栈,平均空间复杂度为O(log N) )

② 它的平均时间复杂度是O(N log(N))。

快排的主要缺点是非常脆弱,在实现时要非常小心才能避免低劣的性能。已经有无数例子显示许多种错误都能致使它在实际中的性能只有平方级别(即 O(N²) )。不过这些根据这些错误我们都能够找到对应的方法来对快排进行改进。
 

一,基础思想

快速排序是一种分治的排序算法。它将一个数组划分成两个子数组,将两部分独立地排序。这点与归并排序很相似,我们可以来对比一下快速排序和归并排序在基础思想上的不同:

Ⅰ 归并排序

① 归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序;

② 递归调用发生在处理整个数组之前;

③ 在归并排序中,一个数组被等分为两半;

Ⅱ 快速排序

① 快速排序将数组排序的方式是当左右两边的两个子数组都有序时整个数组也就自然有序了。

② 递归调用发生在处理整个数组之后。

③ 在快速排序中,划分 (partition) 的位置取决于数组的内容。

示意图:

二,具体实现

快速排序递归地将子数组 array[begin......end] 排序,先用 partition() 方法将我们所选取的 pivot 放到一个合适位置,然后再用递归调用将其他位置的元素排序。

该方法的关键在于切分,这个过程使得数组满足下面三个条件:

① 对于我们选定好的 pivot,pivot 在数组中的位置是正确、已经排定的

② begin 到 pivot -1 中的所有元素都不大于 pivot
③ pivot + 1 到 end 中的所有元素都不小于 pivot 


我们就是通过递归地调用切分来排序的。因为切分过程总是能排定一个元素,用归纳法不难证明递归能够正确地将数组排序:如果左子数组和右子数组都是有序的,那么由左子数组、切分元素、右子数组组成的结果数组也一定是有序的。想要实现快速排序,我们就得先实现划分方法。要完成这个实现,需要实现切分方法。

1,左右指针法

一般策略是先随意地取 begin 作为切分元素 (或者叫基准元素,pivot),然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素。这两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针 left 的左侧元素都不大于切分元素,右指针 right 的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将 begin 所指向的值与左子数组中最右侧的元素进行交换后返回 pivot (即基准元素) 的最终位置即可。我们把这种划分方法称作左右指针划分法。

示意图:

具体实现:

// 二路划分,将 begin 作为 pivot 进行二路划分,返回划分完后的 pivot 迭代器
// 左右指针划分法
template<class iter>
iter partition_LR_ptr(iter begin, iter end) {
	iter left = begin + 1, right = end - 1;
	while (left <= right) {
		while (left <= right and *left <= *begin)
			++left;
		while (left <= right and *right >= *begin)
			--right;
		if (left < right)
			swap(*left, *right);
	}
	swap(*begin, *right);
	return right;
}

template<class iter>
void sort(iter begin, iter end) {
	if (begin >= end) return;

	iter idx = partition_LR_ptr(begin, end);
	sort(begin, idx);
	sort(idx + 1, end);
}

左右指针法的具体实现中所包含的细节比较多,非常容易出错(不信的话大家可以自己动手去试试),所以就有人提出了另一种简单一点的实现方法:快慢指针法。

2,快慢指针法

快慢指针方法的思想是通过两个指针,一个快指针(fast)和一个慢指针(slow),在遍历数组的过程中,将小于基准的元素交换到 slow 的左边,大于基准的元素留在 slow 的右边。当 fast 遍历到数组末尾时,慢指针指向的位置就是基准元素的最终位置。此时我们将 begin 的值与 slow 的值进行交换后返回 slow 即可。

具体步骤:

① 首先选择一个基准元素(通常选择数组的第一个元素)。

② 初始化一个快指针(fast)和一个慢指针(slow),分别指向数组的第二个元素和第一个元素。

③ 遍历数组,当 fast 指向的元素小于基准元素时,将其与 slow 指向的元素交换,并将 slow 向后移动一位。

④ 当 fast 遍历到数组末尾(即 end 处)时,交换 begin 和 slow 指向的元素,使得 slow 左边的元素都小于基准,右边的元素都大于等于基准。

⑤ 返回 slow 指向的位置作为基准元素的最终位置。

示意图:

具体代码:

// 二路划分,将 begin 作为 pivot 进行二路划分,返回划分完后的 pivot 迭代器
// 快慢指针划分
template<class iter>
iter partition_FS_ptr(iter begin, iter end) {
	iter fast = begin + 1, slow = begin;
	while (fast < end) {
		while (fast < end and *fast >= *begin)
			++fast;
		if (fast < end)
			std::swap(*(++slow), *(fast++));
	}
	std::swap(*begin, *slow);
	return slow;
}

template<class iter>
void sort(iter begin, iter end) {
	if (begin >= end) return;

	iter idx = partition_FS_ptr(begin, end);
	sort(begin, idx);
	sort(idx + 1, end);
}
	

三,进行优化

前面我们有提到,快速排序非常脆弱,在实现时要非常小心才能避免低劣的性能。现在我们来讨论一下什么情况会导致快速排序性能的低劣。

1,选取 pivot

因为我们是随意地取 begin 作为 pivot,使用在数组十分接近有序的情况下会导致切分的不平衡,从而使得排序的性能极为低效,例如:如果第一次从最小的元素切分,第二次从第二小的元素切分,如此这般,这会导致一个大的子数组需要切分很多次,从而使得递归的层数增加。

我们可以通过在选取 pivot 的时候下点功夫来改进此问题,我们还可以在数组的范围内选取好 pivot 之后再将 begin 的值与 pivot 的值进行交换,这样我们在 partition 函数中就照样能使用 begin 来作为 pivot 了。

① 随机法选取 pivot

// 随机选 pivot
template<class iter>
void pivot_random(iter begin, iter end) {
	iter pivot = begin + rand() % std::distance(begin, end);
	std::swap(*begin, *pivot);
}

② 三数取中法选取 pivot

改进快速排序性能的第二个办法是使用子数组的一小部分元素的中位数来切分数组。这样做的切分效果更好,但代价是需要计算中位数。人们发现将取样大小设为 3 并用大小居中的元素切分的效果最好。

具体一点就是我们选择数组中的第一个元素、最后一个元素、在数组中间的一个元素作为样本,我们拿这三个样本的中位数来当 pivot。

// 三数取中选 pivot
template<class iter>
void pivot_middle(iter begin, iter end) {
	iter left = begin, right = end - 1;
	iter mid = begin + std::distance(begin, end) / 2;
	// 利用冒泡排序的思想将 left、mid、right 所指向的元素排好序
	if (*left > *mid) std::swap(*left, *mid);
	if (*mid > *right) std::swap(*mid, *right);
	if (*left > *mid) std::swap(*left, *mid);
	// 排好序之后的 mid 就是我们想要的 pivot
	std::swap(*begin, *mid);
}

改进选取 pivot 方法后的代码:

template<class iter>
void sort(iter begin, iter end) {
	if (begin >= end) return;

    // 我们可以选择随机法也可以选择三数取中法
	pivot_random(begin, end);    
	// pivot_middle(begin, end);

	iter idx = partition_FS_ptr(begin, end);	
	sort(begin, idx);
	sort(idx + 1, end);
}

2,使用插入排序优化

和大多数递归排序算法一样,改进快速排序性能的一个简单办法基于以下两点:

① 对于小数组,快速排序比插入排序慢;
② 因为递归,快速排序的sort()方法在小数组中也能够调用到插入排序。

因此,在排序小数组时应该切换到插人排序。简单地改动算法就可以做到这一点

template<class iter>
void sort(iter begin, iter end) {
	if (begin >= end) return;

    // 这里的 N 在 5~15 之间的任意值在大多数情况下都能令人满意。
	if (distance(begin, end) <= N) {
		insert_sort(begin, end);
		return;
	}

	// 我们可以选择随机法也可以选择三数取中法
	// pivot_middle(begin, end);
    pivot_random(begin, end);

	iter idx = partition_FS_ptr(begin, end);	
	sort(begin, idx);
	sort(idx + 1, end);
}

3,三路划分法

当数组内有大量的重复元素时,快排的性能会十分的低效,而实际应用中经常会出现含有大量重复元素的数组,例如:我们可能需要将大量人员资料按照生日排序,或是按照性别区分开来。

在这些情况下,我们实现的快速排序有着巨大的改进空间。例如:一个元素全部重复的子数组就不需要继续排序了,但我们的算法还会继续将它切分为更小的数组。在有大量重复元素的情况下,快速排序的递归性会使元素全部重复的子数组经常出现。有人就通过改进,使用三路划分法将当前实现的线性对数级的性能提高到了(特殊情况下的)线性级别。

三路划分法是快速排序的一种优化方法,特别适用于处理含有大量重复元素的数组。我们通过三个指针,一个左指针 lt (less than)一个右指针 gt (greater than)一个当前指针 cur (current),在遍历数组的过程中,将小于基准的元素移到数组的左边,大于基准的元素移到数组的右边,等于基准的元素放在中间。当 cur 遍历到数组末尾时,整个数组就被划分成了三个部分:小于基准的部分、等于基准的部分和大于基准的部分。

具体做法:

Ⅰ,首先选择一个基准元素(通常选择数组的第一个元素)
Ⅱ,初始化三个指针
Ⅲ,当 cur 小于 gt 时,进行以下操作:
        ① 如果 cur 指向的元素小于基准元素,则将 cur 与 lt 指向的元素交换,并将 lt 和 cur 向后移动一位。
        ② 如果 cur 指向的元素大于基准元素,则将 cur 与 gt 指向的元素交换,并将 gt 向前移动一位。
        ③ 如果 cur 指向的元素等于基准元素,则将 cur 向后移动一位。
Ⅳ,当 cur >= gt 时,整个数组就被划分成了三个部分。
Ⅴ,返回 lt 与 gt 指针的位置,分别作为小于基准和大于基准的部分的边界。

示意图:

代码实现:

// 三路划分
template<class iter>
std::pair<iter, iter> quick_sort::partition_three_way(iter begin, iter end) {
	// 最终目的: 
	// 使 lt 的左边元素都小于 pivot (不包括 lt)
	// 使 gt 的右边元素都大于 pivot (包括 gt)
	iter lt = begin, gt = end;
	iter cur = begin + 1;
	while (cur < gt) {
		if (*cur < *begin)
			std::swap(*(++lt), *(cur++));
		else if (*cur > *begin)
			std::swap(*(--gt), *cur);
		else
			++cur;
	}
	std::swap(*begin, *lt);
	// 最后 lt, gt 分别为 equal_range 的 begin 与 end
	return make_pair(lt, gt);
}


template<class iter>
void sort(iter begin, iter end) {
	if (begin >= end) return;
	if (distance(begin, end) <= N) {
		insert_sort(begin, end);
		return;
	}
	pivot_random(begin, end);

	std::pair<iter, iter> range = partition_three_way(begin, end);
	sort(begin, range.first);
	sort(range.second, end);
}

四,完整代码

我们可以将快速排序给封装成一个类:

#include<iostream>

template<class iter>
void insert_sort(iter begin, iter end) {
	// 省略具体实现...
}

class quick_sort {
private:

	// pivot 系列函数将选取 pivot 并将其与 begin 的位置交换,
	// 在后续的划分方式中我们都以 begin 作为 pivot
	template<class iter>
	void pivot_random(iter begin, iter end);		// 随机选 pivot
	template<class iter>
	void pivot_middle(iter begin, iter end);		// 三数取中选 pivot


	// 二路划分,将 begin 作为 pivot 进行二路划分,返回划分完后的 pivot 迭代器
	template<class iter>
	iter partition_LR_ptr(iter begin, iter end);	// 左右指针划分
	template<class iter>
	iter partition_FS_ptr(iter begin, iter end);	// 快慢指针划分

	// 三路划分,将 begin 作为 pivot 进行三路划分,返回划分完后与 *pivot 值相同的范围
	template<class iter>
	std::pair<iter, iter> partition_three_way(iter begin, iter end);

public:
	template<class iter>
	void sort(iter begin, iter end) {
		if (begin >= end) return;
		if (distance(begin, end) <= N) {
			insert_sort(begin, end);
			return;
		}
		pivot_random(begin, end);
		std::pair<iter, iter> range = partition_three_way(begin, end);
		sort(begin, range.first);
		sort(range.second, end);
	}

};

// 随机选 pivot
template<class iter>
void quick_sort::pivot_random(iter begin, iter end) {
	iter pivot = begin + rand() % std::distance(begin, end);
	std::swap(*begin, *pivot);
}

// 三数取中选 pivot
template<class iter>
void quick_sort::pivot_middle(iter begin, iter end) {
	iter left = begin, right = end - 1;
	iter mid = begin + std::distance(begin, end) / 2;
	// 利用冒泡排序的思想将 left、mid、right 所指向的元素排好序
	if (*left > *mid) std::swap(*left, *mid);
	if (*mid > *right) std::swap(*mid, *right);
	if (*left > *mid) std::swap(*left, *mid);
	// 排好序之后的 mid 就是我们想要的 pivot
	std::swap(*begin, *mid);
}


// 左右指针划分
template<class iter>
iter quick_sort::partition_LR_ptr(iter begin, iter end) {
	iter left = begin + 1, right = end - 1;
	while (left <= right) {
		while (left <= right and *left <= *begin)
			++left;
		while (left <= right and *right >= *begin)
			--right;
		if (left < right)
			swap(*left, *right);
	}
	swap(*begin, *right);
	return right;
}

// 快慢指针划分
template<class iter>
iter quick_sort::partition_FS_ptr(iter begin, iter end) {
	iter fast = begin + 1, slow = begin;
	while (fast < end) {
		while (fast < end and *fast >= *begin)
			++fast;
		if (fast < end) {
			std::swap(*(++slow), *(fast++));
		}
	}
	std::swap(*begin, *slow);
	return slow;
}

// 三路划分
template<class iter>
std::pair<iter, iter> quick_sort::partition_three_way(iter begin, iter end) {
	// 最终目的: 
	// 使 lt 的左边元素都小于 pivot (不包括 lt)
	// 使 gt 的右边元素都大于 pivot (包括 gt)
	iter lt = begin, gt = end;
	iter cur = begin + 1;
	while (cur < gt) {
		if (*cur < *begin)
			std::swap(*(++lt), *(cur++));
		else if (*cur > *begin)
			std::swap(*(--gt), *cur);
		else
			++cur;
	}
	std::swap(*begin, *lt);
	// 最后 lt, gt 分别为 equal_range 的 begin 与 end
	return make_pair(lt, gt);
}

  • 12
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值