基于比较的排序算法
- 希尔排序(缩小增量排序)
希尔排序可以看作是插入排序的改进版,他的时间复杂度比 要好,在数据规模中等时的表现甚至优于快速排序。
我们回忆插入排序的过程:插入排序要求我们维护一个逐渐增长的有序序列,并把待排序数组中的每一个元素插入进去。这无疑是非常非常慢的:我们每插入一个元素,就要把后面所有的元素全都后移一位。因此很显然的一点是:我们的数组越有序,这个向后移动的步骤就越少。而希尔排序使我们可以同时维护多个有序序列(在局部使用插入排序),因此缩短了每一个序列的长度,使得移动元素的代价更小;同时增大了有序程度。具体来说,我们选择一组单调递减的有穷正整数列 ,并把每一个数字依次作为间隔,将原数组划分为子数组排序。等到间隔为 1 时,我们也就实现了整个数组的有序。
不妨举一个例子,对于下面这一组数据 :
- 对于传统的插入排序,这样的数据能够使复杂度退化成 ,这是因为每一次新插入的元素都恰好是有序序列中最小的元素,因此我们每次都要把有序序列中的所有元素都后移一遍,直到它们在若干次后移中到达应有的位置。容易看出问题的核心是:对于一个离它应该在的位置很远的元素(比如 1 本来应该在第一个,但是跑到最后一个去了),插入排序使它归位的代价很大,我们要依次后移在它们之间的所有元素。
- 对于希尔排序,我们可以先用较小的代价将每一个元素移到一个更加靠近的位置,因此在“依次后移在它们之间的所有元素”的过程中,我们移动的次数变少了。若我们选择 ,第一次我们用插入排序将 排序为 ,此时原数组内部的顺序为 ;第二次我们用插入排序将 排序为 ,此时数组内部的顺序为 (其实已经有序了);第三次的间隔为一,我们对原数组进行插入排序,而插入排序对于有序的情况是非常快的,因此我们可以很快得出结果。
在第一步中,可以看出数组的有序程度大大提高了,但是我们只付出了移动五个元素的代价,这就是希尔排序的精妙之处了:将元素以较小的代价送到一个更好的位置。
实际上,希尔排序的效率和 的选择关系极大。如果我们使用不断减半的增量,算法仍存在退化到 的可能。 的最坏复杂度为 ,而 的最坏复杂度为 ,这个算法的复杂度下界是 。
代码实现:
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");
}
计数排序的时间复杂度比上述任何一种算法都要更优,为 。它的空间复杂度为 。
- 桶排序(bucket sort)
计数排序其实是桶排序的一种最简单的形式。桶排序也要求我们知道元素大致的数据范围,我们按照大小将所有的元素分成若干类(存入若干个桶),对于每一类元素分别排序(在每一个桶内部使用其他排序算法),并最终进行合并。由于我们是按照值的大小划分桶的,因此任意两个桶中元素的大小关系是已知的,所以最后的合并其实并不需要额外的步骤,只要按照桶的顺序读取就好。这其实就是做了类似于快速排序中的划分,减小了问题的规模。
桶排序的时间复杂度和每个桶内部的排序方式以及桶的个数相关。假如说我们有 n 个数字,我们将它分成 k 个桶,并用快速排序对每个桶进行排序,那么每一个桶内部排序的时间复杂度为 ,总时间复杂度为 。如果我们选择足够多的桶(k 接近 n),这个算法的时间复杂度就可以认为是 。当然,选择的桶越多,空间复杂度也越大;当我们选择 n 个桶时,这其实就是计数排序了。另外,由于我们是按顺序扫描并加入桶的,因此桶排序是稳定的。
思考:如何在线性时间内实现双关键字排序?
答案:先对第二关键字作桶排序,再将得到的结果对第一关键字作桶排序。由于桶排序的稳定性,对于第一关键字相同的情况,我们的第一步保证了第二关键字是有序的。排序的稳定性能将上一步排序的结果保留下来。
- 基数排序(radix sort)
如果我们待排序的数字范围非常大,这个时候使用桶排序的空间复杂度是不可接受的。我们可以使用基数排序:
在上面的思考题中,我们发现排序算法的稳定性可以用来实现多关键字排序。而数字的排序也可以看作是多关键字的:我们将最低位作为最后一个关键字,倒数第二位作为倒数第二个关键字......这就相当于我们从高位到低位比较数位。具体来说,我们将所有数字在前面补零调整至数位相等,从低位到高位依次进行排序,在我们对最高位完成排序后,整个数组就是有序的。我们一共进行的排序次数等于最大数字的位数。对于每一位,我们只需要处理十个数字的排序,而这是桶排序(实际上是计数排序了)所擅长的领域:分配十个桶依次对应十个数字。
上述从低到高的做法称为 LSD(Least Significant Digit First),还有一种通常来说更慢的 MSD(Most Significant Digit First),它的步骤是递归进行的:在一个桶中对下一位进行递归排序。也就是说,我们在分配好桶之后并不返回,而是直接在桶的内部对下一个数位再次进行基数排序,最后连接每一个桶。
为什么要叫基数排序?英文中的 radix 也有“进制”的意思,我们也不一定要在十进制下对每一个数位进行排序。对于一个 k 进制数 num,它的位数为 ,假设共有 n 个数:
我们每一次排序的时间复杂度为 ,排序次数为 ,k 通常远小于 n,因此总时间复杂度的级别是 。
我们可以对进制进行优化吗?实际上大进制也对应着大常数,严格意义上来说,如果我们的进制足够大,那么我们就有了对任何序列的线性时间排序算法,但是常数太大,没有优势。
不过我们仍然可以考虑以二的幂次为基数的情况(具体实现上还是转化为二进制,但是从一位一位考虑变成多位多位考虑)。
之前已经学习了常见的几种排序,接下来让我们研究一个概念:逆序对。
逆序对
在一个序列 num 中,如果存在:,那么有序对 称为 num 的一个逆序对,num 的逆序对个数称为 num 的逆序数。
容易知道:对一个序列进行排序的过程,就是减少该序列中逆序对个数的过程。对于长度为 n 的序列,逆序数是 的,如果我们一次只能消除一个逆序对,那么排序算法就是 的。
以插入排序为例:假设有序序列中比待插入元素大的元素个数为 k,我们的这次插入可以消除 k 个逆序对,但同时我们也移动了 k 次元素。对于插入排序的升级版希尔排序,在间隔较大时,我们可以用较少的移动次数将小元素移动到前端,继而一次消除大量逆序对。
- 求逆序数
对于有序数组,它的逆序数为零。因此对于原数组,它的逆序数就是我们在执行排序算法时减少的逆序对个数,继而我们只要统计每一步交换减少的逆序对个数并求和,就可以求解逆序数。
最直接的就是冒泡排序了,对于每一次交换,都意味着我们消除了一个逆序对。
我们考虑复杂度优秀的排序算法:希尔排序涉及元素的远距离交换,如果我们要统计一次交换涉及的逆序对,我们需要额外的信息(扫描两个元素之间的元素),这显然是不可接受的。
归并排序和快速排序都涉及一个子区间内元素的处理,子区间内元素的重整并不会改变和外部元素相关的逆序对,因此我们只需要统计子区间内逆序数的变化情况。
对于快速排序,当随机化选择哨兵时,我们同样需要额外的信息来维护涉及哨兵的逆序对的变化情况;更致命的是,对于右面的区间,我们是逆序插入的,因此这会改变逆序数。
而对于归并排序,逆序对只存在于两个有序表的元素之间,我们很容易根据单调性求解逆序对的个数。
接下来我们要做的就是确定归并时减少的逆序数了:
假设我们在序列 上对 和 进行归并。
这时 和 已经是递增序列了,所有的逆序对只存在于这两个区间的元素之间。
因此对于 中的每一个元素,我们要确定 中比它小的元素个数并求和;我们也可以反过来:对于 中的每一个元素,我们要确定 中比它大的元素个数并求和。
当 pointer1 指向的元素大于 pointer2 指向的元素时,我们知道 里的元素必定和它构成逆序对,但是我们尚且不清楚 pointer2 之后是否有元素仍然小于它。换句话说,我们无法在这里确定“第一个元素为 num[pointer1]”的逆序对个数。但是如果我们统计“第二个元素为 num[pointer2]”的逆序对则不会遇到这种情况,这是因为 中的元素必定和它构成逆序对,并且,若在 pointer1 之前有元素和它构成逆序对,假设 ,那么 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];
}