笔记 排序(二)

基于比较的排序算法

  • 希尔排序(缩小增量排序)

希尔排序可以看作是插入排序的改进版,他的时间复杂度比 O(n^2) 要好,在数据规模中等时的表现甚至优于快速排序。

我们回忆插入排序的过程:插入排序要求我们维护一个逐渐增长的有序序列,并把待排序数组中的每一个元素插入进去。这无疑是非常非常慢的:我们每插入一个元素,就要把后面所有的元素全都后移一位。因此很显然的一点是:我们的数组越有序,这个向后移动的步骤就越少。而希尔排序使我们可以同时维护多个有序序列(在局部使用插入排序),因此缩短了每一个序列的长度,使得移动元素的代价更小;同时增大了有序程度。具体来说,我们选择一组单调递减的有穷正整数列 \{ h_{n} \},并把每一个数字依次作为间隔,将原数组划分为子数组排序。等到间隔为 1 时,我们也就实现了整个数组的有序。

不妨举一个例子,对于下面这一组数据 \{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10\}

  1. 对于传统的插入排序,这样的数据能够使复杂度退化成 O(n^2),这是因为每一次新插入的元素都恰好是有序序列中最小的元素,因此我们每次都要把有序序列中的所有元素都后移一遍,直到它们在若干次后移中到达应有的位置。容易看出问题的核心是:对于一个离它应该在的位置很远的元素(比如 1 本来应该在第一个,但是跑到最后一个去了),插入排序使它归位的代价很大,我们要依次后移在它们之间的所有元素。
  2. 对于希尔排序,我们可以先用较小的代价将每一个元素移到一个更加靠近的位置,因此在“依次后移在它们之间的所有元素”的过程中,我们移动的次数变少了。若我们选择 \{ h_{3} \}=\{ 5, 2, 1\},第一次我们用插入排序将 \{ 10, 5\} \ \{ 9, 4\} \ \{ 8, 3\} \ \{ 7, 2\} \ \{ 6, 1\} 排序为 \{ 5, 10\} \ \{ 4, 9\} \ \{ 3, 8\} \ \{ 2, 7\} \ \{ 1, 6\},此时原数组内部的顺序为 \{ 5, 4, 3, 2, 1, 10, 9, 8, 7, 6\};第二次我们用插入排序将 \{ 5, 3, 1, 9, 7\} \ \{ 4, 2, 10, 8, 6\} 排序为 \{ 1, 3, 5, 7, 9\} \ \{ 2, 4, 6, 8, 10\},此时数组内部的顺序为 \{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10\} (其实已经有序了);第三次的间隔为一,我们对原数组进行插入排序,而插入排序对于有序的情况是非常快的,因此我们可以很快得出结果。

在第一步中,可以看出数组的有序程度大大提高了,但是我们只付出了移动五个元素的代价,这就是希尔排序的精妙之处了:将元素以较小的代价送到一个更好的位置。

实际上,希尔排序的效率和 \{ h_{n} \} 的选择关系极大。如果我们使用不断减半的增量,算法仍存在退化到 O(n^2) 的可能。\{ 2^k - 1\} 的最坏复杂度为 O(n^\frac{3}{2}),而 \{ 4^k + 3\cdot 2^{k-1}+1 \} 的最坏复杂度为 O(n^\frac{4}{3}),这个算法的复杂度下界是 O(nlogn)

代码实现:

const int gap[] = {65921, 16577, 4193, 1073, 281, 77, 23, 8, 1, 0};

void shell_sort(int num[], int n) {
	int pos;
	for (pos = 0; gap[pos] >= n; ++pos);
	for (; gap[pos]; ++pos) {
		for (int start = 0; start < gap[pos]; ++start) {
			for (int now = start; now < n; now += gap[pos]) {
				int tmp = num[now], order = start;
				for (; order < now; order += gap[pos])
					if (num[order] > num[now]) break;
				for (int i = now - gap[pos]; i >= order; i -= gap[pos]) num[i + gap[pos]] = num[i];
				num[order] = tmp;
			}
		}
	}
}

(没有参照别人的写法自己搞的,等有时间和其他写法对一对力求化简) (开了O2优化以后居然过了洛谷的快速排序板子题,希尔排序NB!)

不基于比较的排序算法

有些时候,我们对于要排序的对象不只是定义了小于号这么简单。对象本身的一些性质也可以被我们利用,以排序数字为例:

  • 计数排序

假如说我们要排序元素的数据范围集中在一个较小的区间中,(比如说,对一亿个 1,2,3 进行排序,限制时间为一秒),那么我们可以使用计数排序,直接统计每个元素出现的次数。容易看出这种方法适合元素大量重复出现的情况,我们也要事先确定元素的数据范围。

代码实现:

void count_sort(int num[], int n, int minV, int maxV) {
	static int counter[100005];
	memset(counter, 0, sizeof(counter));
	for (int i = 0; i < n; ++i) ++counter[num[i] - minV];
	for (int i = minV; i <= maxV; ++i) while (counter[i]--) printf("%d ", i);
	printf("\n");
}

计数排序的时间复杂度比上述任何一种算法都要更优,为 O(n+maxV-minV)。它的空间复杂度为 O(maxV-minV)

  • 桶排序(bucket sort)

计数排序其实是桶排序的一种最简单的形式。桶排序也要求我们知道元素大致的数据范围,我们按照大小将所有的元素分成若干类(存入若干个桶),对于每一类元素分别排序(在每一个桶内部使用其他排序算法),并最终进行合并。由于我们是按照值的大小划分桶的,因此任意两个桶中元素的大小关系是已知的,所以最后的合并其实并不需要额外的步骤,只要按照桶的顺序读取就好。这其实就是做了类似于快速排序中的划分,减小了问题的规模。

桶排序的时间复杂度和每个桶内部的排序方式以及桶的个数相关。假如说我们有 n 个数字,我们将它分成 k 个桶,并用快速排序对每个桶进行排序,那么每一个桶内部排序的时间复杂度为 O(\frac{n}{k}log\frac{n}{k}),总时间复杂度为 O(n + nlog\frac{n}{k})。如果我们选择足够多的桶(k 接近 n),这个算法的时间复杂度就可以认为是 O(n)。当然,选择的桶越多,空间复杂度也越大;当我们选择 n 个桶时,这其实就是计数排序了。另外,由于我们是按顺序扫描并加入桶的,因此桶排序是稳定的。

思考:如何在线性时间内实现双关键字排序?

答案:先对第二关键字作桶排序,再将得到的结果对第一关键字作桶排序。由于桶排序的稳定性,对于第一关键字相同的情况,我们的第一步保证了第二关键字是有序的。排序的稳定性能将上一步排序的结果保留下来。

  • 基数排序(radix sort)

如果我们待排序的数字范围非常大,这个时候使用桶排序的空间复杂度是不可接受的。我们可以使用基数排序:

在上面的思考题中,我们发现排序算法的稳定性可以用来实现多关键字排序。而数字的排序也可以看作是多关键字的:我们将最低位作为最后一个关键字,倒数第二位作为倒数第二个关键字......这就相当于我们从高位到低位比较数位。具体来说,我们将所有数字在前面补零调整至数位相等,从低位到高位依次进行排序,在我们对最高位完成排序后,整个数组就是有序的。我们一共进行的排序次数等于最大数字的位数。对于每一位,我们只需要处理十个数字的排序,而这是桶排序(实际上是计数排序了)所擅长的领域:分配十个桶依次对应十个数字。

上述从低到高的做法称为 LSD(Least Significant Digit First),还有一种通常来说更慢的 MSD(Most Significant Digit First),它的步骤是递归进行的:在一个桶中对下一位进行递归排序。也就是说,我们在分配好桶之后并不返回,而是直接在桶的内部对下一个数位再次进行基数排序,最后连接每一个桶。

为什么要叫基数排序?英文中的 radix 也有“进制”的意思,我们也不一定要在十进制下对每一个数位进行排序。对于一个 k 进制数 num,它的位数为 \left \lfloor log_{k}num \right \rfloor,假设共有 n 个数:

我们每一次排序的时间复杂度为 O(n+k),排序次数为 \left \lfloor log_{k}max(val) \right \rfloor,k 通常远小于 n,因此总时间复杂度的级别是 O(nlog_{k}maxV)

我们可以对进制进行优化吗?实际上大进制也对应着大常数,严格意义上来说,如果我们的进制足够大,那么我们就有了对任何序列的线性时间排序算法,但是常数太大,没有优势。

不过我们仍然可以考虑以二的幂次为基数的情况(具体实现上还是转化为二进制,但是从一位一位考虑变成多位多位考虑)。

之前已经学习了常见的几种排序,接下来让我们研究一个概念:逆序对。

逆序对

在一个序列 num 中,如果存在:a<b,\ num[a]>num[b],那么有序对 (num[a], num[b]) 称为 num 的一个逆序对,num 的逆序对个数称为 num 的逆序数。

容易知道:对一个序列进行排序的过程,就是减少该序列中逆序对个数的过程。对于长度为 n 的序列,逆序数是 O(n^2) 的,如果我们一次只能消除一个逆序对,那么排序算法就是 O(n^2) 的。

以插入排序为例:假设有序序列中比待插入元素大的元素个数为 k,我们的这次插入可以消除 k 个逆序对,但同时我们也移动了 k 次元素。对于插入排序的升级版希尔排序,在间隔较大时,我们可以用较少的移动次数将小元素移动到前端,继而一次消除大量逆序对。

  • 求逆序数

对于有序数组,它的逆序数为零。因此对于原数组,它的逆序数就是我们在执行排序算法时减少的逆序对个数,继而我们只要统计每一步交换减少的逆序对个数并求和,就可以求解逆序数。

最直接的就是冒泡排序了,对于每一次交换,都意味着我们消除了一个逆序对。

我们考虑复杂度优秀的排序算法:希尔排序涉及元素的远距离交换,如果我们要统计一次交换涉及的逆序对,我们需要额外的信息(扫描两个元素之间的元素),这显然是不可接受的。

归并排序和快速排序都涉及一个子区间内元素的处理,子区间内元素的重整并不会改变和外部元素相关的逆序对,因此我们只需要统计子区间内逆序数的变化情况。

对于快速排序,当随机化选择哨兵时,我们同样需要额外的信息来维护涉及哨兵的逆序对的变化情况;更致命的是,对于右面的区间,我们是逆序插入的,因此这会改变逆序数。

而对于归并排序,逆序对只存在于两个有序表的元素之间,我们很容易根据单调性求解逆序对的个数。

接下来我们要做的就是确定归并时减少的逆序数了:

假设我们在序列 num[left, right) 上对 num[left: mid) 和  num[mid:right) 进行归并。

这时 num[left: mid)num[mid:right) 已经是递增序列了,所有的逆序对只存在于这两个区间的元素之间。

因此对于 num[left: mid) 中的每一个元素,我们要确定 num[mid:right) 中比它小的元素个数并求和;我们也可以反过来:对于 num[mid:right) 中的每一个元素,我们要确定 num[left: mid) 中比它大的元素个数并求和。

当 pointer1 指向的元素大于 pointer2 指向的元素时,我们知道 num[mid:pointer2] 里的元素必定和它构成逆序对,但是我们尚且不清楚 pointer2 之后是否有元素仍然小于它。换句话说,我们无法在这里确定“第一个元素为 num[pointer1]”的逆序对个数。但是如果我们统计“第二个元素为 num[pointer2]”的逆序对则不会遇到这种情况,这是因为 num[pointer1:mid) 中的元素必定和它构成逆序对,并且,若在 pointer1 之前有元素和它构成逆序对,假设 num[pointer1-1] > num[pointer2] ,那么 num[pointer2] 在这一步就会被插入序列,因此我们统计的一定是“第二个元素为 num[pointer2]”的逆序对总数。

代码实现:

long long cnt;

void merge_sort(int num[], int left, int right) {
	if (right - left == 1) return;
	static int helper[500005];
	int mid = (left + right) / 2;
	merge_sort(num, left, mid);
	merge_sort(num, mid, right);
	int pointer1 = left, pointer2 = mid, tot = 0;
	while (pointer1 < mid && pointer2 < right) {
		if (num[pointer1] <= num[pointer2]) helper[tot++] = num[pointer1++];
		else {
			cnt += mid - pointer1;
			helper[tot++] = num[pointer2++];
		}
	}
	while (pointer1 < mid) helper[tot++] = num[pointer1++];
	while (pointer2 < right) helper[tot++] = num[pointer2++];
	for (int i = 0; i < tot; ++i) num[i + left] = helper[i];
}

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值