8.2 再谈排序与检索
如何排序
下面将介绍排序函数的工作原理
8.2.1 归并排序
第一种高效算法是归并排序。按照分治三步法,对归并排序算法介绍如下:
划分问题:把序列分成元素个数尽量相等的两半
递归求解:把两半元素分别排序
合并问题:把两个有序表合并成一个
这边的关键在于合并问题的实现,代码实现如下:
点击查看代码
void merge_sort(int* A, int x, int y, int* T) {
if(y-x > 1) {
int m = x + (y-x)/2;//划分,说明不是数组元(也就是最小的单位数组)
int p = x, q = m, i = x;
merge_sort(A, x, m, T);//递归求解,实现[x,m)的排序
merge_sort(A, m, y, T);//递归求解,实现[m,y)的排序
while(p < m || q < y) {//两边都到达边界才是真正的边界条件
if(q >= y || (p < m && A[p] <= A[q])) T[i++] = A[p++];
//从左半数组复制到临时空间,注意这边的q>=y
else T[i++] = A[q++];//从右半数组复制到临时空间
}
for(i = x; i < y; i++) A[i] = T[i];//从辅助空间复制回A数组
}
}
这边有两个关键,首先只要有一个序列非空,就要继续合并,因此在比较时不能直接比较A[p]和A[q],因为可能其中一个序列为空,从而A[p]或者A[q]代表的是一个实际不存在的元素,正确的方式如下:
1.如果第二个序列为空(此时第一个序列一定非空),复制A[p]
2.否则(第二个序列非空),当且仅当第一个序列也非空,且A[p]<=A[q]的时候,才复制A[p]
上面的代码巧妙地利用短路运算符"||"将两个条件连接起来,如果条件1满足,那么就不会执行条件2,如果条件1不满足,才会执行条件2
T[i++] = P[p++],复制后移动下标的方式挺重要的
这个时间复杂度和最大连续和的分治算法是O(nlogn)
以下是对归并的一种拓展
逆序对问题
如果n很大,很明显O(n^2)的枚举会超时,受到归并排序的启发,我们同样可以采用,划分问题,递归求解,合并问题的思路来解决
1.把序列元素尽量分成相等的两半
2.递归求解是统计i和j均在左边或者均在右边的逆序对个数
3.统计i在左边,但j在右边的逆序对个数
本题的关键在于对于合并的理解,统计的常见技巧是分类,下面按照j的不同把这些跨越两边的逆序对进行分类,只要对于右边的每个j,统计左边比他大的元素个数f(j),则所有f(j)之和便是答案
归并排序可以顺便完成f(j)的计算(感觉该递归求解的过程,改变小区域,但是不影响大区域的答案,同时进行分类,非常巧妙,对于子问题的改造,可能简化父问题)
由于合并操作是从小到大进行的,当右边的A[j]复制到T中,左边还没来得及复制到T的那些数就是左边所有比A[j]大的数,此时在累加器中加上左边元素个数m-p即可,左边所剩的元素在区间[p,m),因此元素个数为m-p
代码实现如下:
点击查看代码
#include
using namespace std;
typedef unsigned long long ull;
constexpr int MAXN = 5*10e5 + 10;
int n, num[MAXN];
ull merge(int x, int y) {
if(x+1 == y) return 0;
int m = x + (y-x)/2;
ull cnt = 0, l = merge(x, m), r = merge(m, y);
int pos = 0, i = x, j = m, temp[y-x];
while(i < m || j < y) {
if(j == y || (i < m && num[i] <= num[j])) { cnt += j-m; temp[pos++] = num[i++]; }
else { temp[pos++] = num[j++]; }
}
for(int i = 0; i < pos; i++) num[x+i] = temp[i];
return l+r+cnt;
}
int main() {
scanf("%d", &n);
for(int i = 0; i < n; i++) scanf("%d", &num[i]);
cout << merge(0, n) << endl;
return 0;
}
归并排序的时间复杂度为O(nlogn),对该算法稍加修改,可以统计序列中的逆序对的个数,时间复杂度不变