首先先来看逆序对的概念,可能具体到每一道题的时候会有细微的不同,但是总体的概念还是比较清楚的,对于一串长度为n的排列{A_n},存在这样两个数a_i,a_j,使得1 <= i <= j <= n时,a_i > a_j,那么我们就把这两个数叫做一组逆序对,说白了就是一组数据中前面数的比后面数的大这样的一对数就叫逆序对(……),排列中逆序对的总数就称为这个排列的逆序数。
让我们来举个栗子:
排列: 1 3 4 6 2 5,有四组逆序对: {3, 2}, {4, 2}, {6, 2}, {6, 5)
我们可以很明显的看到,对于n个不同数字组成的排列中,单调递减的排列是逆序对最多的,逆序数有∑(i : 1 -> n - 1) i。
目前我只学会了怎么求逆序数(还没学全),逆序对的题会怎么出我也不知道,而且我也没遇到过。。。先走一步算一步看着办吧。
恩我们开始步入正题,一组排列中逆序数的三种求法:
一:暴力枚举
直接两重循环遍历,暴力枚举每一对数然后计数,时间复杂度是O(n * n),非常丑陋。
for(int i = 1; i <= n; i ++){ for(int j = i + 1; j <= n; j ++){ if(ma[i] > ma[j]){ cnt ++; } } }
或者(至于我为什么要多写一个是因为我个人觉得这个对线段树法的理解有帮助)
for(int i = 1; i <= n; i ++){ for(int j = 1; j <= i - 1; j ++){ if(ma[i] < ma[j]){ cnt ++; } } }
然后下面就没有了
二:归并排序
好像我刚开始学归并排序的时候做过一题,那个时候对归并排序的理解还不够,想了好久(根本没往归并排序方面去想),那段时间只会做裸题,对算法根本没有去认真思考。
唔好像跑题了。。。。
先复习一下归并排序,归并排序是一种分治的思想(这个思想以后好像很重要),我们要让一组无序排列变有序,我们可以先让它前半组和后半组(从中间划开)两分别有序,然后用一种神奇的方法(等等具体说)让前后两个有序排列合并成一个有序的排列,于是子问题就成了怎么把前后两半组变得有序,这其实就是一个新的排列的排序问题,然后我们再前后分割。。。这样最多可以迭代log次,而终止条件就是当排列只有一个元素的时候,即本身有序。
然后我们再看如何让两个有序的排列ma,mb合并成一个有序的排列mc。
其实也很简单,两个指针分别指向两个有序数列的首元素,然后比较两个指针指向的元素的大小,把更小的那个元素丢到mc里,然后对应的指向更小元素的指针往后移一位,
再次进行比较,直到两个指针都指向两个排列的末尾。
恩上代码理解一下
#include<cstdio> #include<iostream> #include<algorithm> #define LL long long using namespace std; const int N = 100010; int n, m; int ma[N]; int now[N];void amazing_sort(int l, int r){if(l == r) return; ///终止条件:只有一个元素,此时自带有序int mid = l + ((r - l) >> 1);int p1, p2, p; ///三个指针amazing_sort(l, mid); ///递归调用,对前半段进行排序amazing_sort(mid + 1, r); ///递归调用,对后半段进行排序p1 = l;p2 = mid + 1;p = 1;while(p1 <= mid || p2 <= r){ ///合并if(p1 <= mid && p2 <= r){if(ma[p2] < ma[p1]){now[p] = ma[p2];p ++;p2 ++;}else{now[p] = ma[p1];p ++;p1 ++;}}else if(p1 <= mid && p2 > r){now[p] = ma[p1];p ++;p1 ++;}else if(p2 <= r && p1 > mid){now[p] = ma[p2];p ++;p2 ++;}}int t = r - l + 1;for(int i = 0; i < t; i ++){ma[l + i] = now[1 + i];}}int main(){while(scanf("%d", &n) == 1){for(int i = 1; i <= n; i ++){scanf("%d", &ma[i]);}amazing_sort(1, n); for(int i = 1; i <= n; i ++){printf("%d ", ma[i]);}printf("\n");}return 0;}
我们LGD是不可战胜的!(2017 - 8 - 6 - TI7) 我去看比赛了
归并排序的思路大致就是这样,然后我们试着把它和逆序对联系在一起。
一句话总结就是: 前半组的数字的位置编号是严格小于后半组的数字的位置编号的,所以当后半组的数字小于前半组的数字的时候,就构成一组逆序对。
不理解?让我们再举一个栗子
对于一个我们已经使其前半段和后半段分别有序的排列 {1, 3, 4, 6, 7, 2, 5, 8, 0};
前有序排列ma = {1, 3, 4, 6, 7},长度len1 = 5;
后有序排列mb = {2, 5, 8, 0}, 长度len2 = 4;
(不要在意为什么前序列比后序列更长这个问题=。= 这只是一个栗子)
初始情况ma, mb的两个指针均指向排列首元素(以用红色标明)
ma:
mb:
很明显1 < 2,所以我们把1丢到mc里,然后将指针往后移一位
ma:
mb:(保持原样)
于是我们接着进行比较3和2,这个时候我们会发现这时属于mb中的2小于属于ma中的3,而{3, 2}其实是这组数据中的一个逆序对,进一步比较我们会发现{4, 2}, {6, 2},{7, 2}都是逆序对,也是这个排列中所有包含2的逆序对,个数是(len1 - "3所在的位置编号" + 1)。
然后我们接着将mb的指针往后移动一位,继续进行比较……
恩直接来一道题
[HDU] 4911
给出一个n个数的排列和至多进行k次操作,每次操作互换两个相邻的数的位置,问最少能在已知的排列构造中出多少组逆序对(即最小逆序数)。
唔有一个概念就是如果我们互换两个相邻数本身就是逆序对,那么逆序数会减一,反之本身不是逆序对,逆序数会加一,所以我们只要求出本身的逆序数,减去操作数k就好了,要注意特判减去以后是否小于0以及数据会爆int.,这道题的数字允许重复,不过没有影响。
定理:当十七的代码WRONG ANSWER时,一定存在至少两个问题!!
其实也就是上之前的归并排序代码加了一个cnt计数
#include<cstdio> #include<iostream> #include<algorithm> #define LL long long using namespace std; const int N = 100010; int n, m; int ma[N]; LL cnt; void amazing_sort(int l, int r) { if(l == r) return; int mid = l + ((r - l) >> 1); int p1, p2, p; int now[N]; amazing_sort(l, mid); amazing_sort(mid + 1, r); p1 = l; p2 = mid + 1; p = 1; while(p1 <= mid || p2 <= r){ if(p1 <= mid && p2 <= r){ if(ma[p2] < ma[p1]){ cnt += (mid - p1 + 1); ///多了一个介个,别的都一样 now[p] = ma[p2]; p ++; p2 ++; } else{ now[p] = ma[p1]; p ++; p1 ++; } } else if(p1 <= mid && p2 > r){ now[p] = ma[p1]; p ++; p1 ++; } else if(p2 <= r && p1 > mid){ now[p] = ma[p2]; p ++; p2 ++; } } int t = r - l + 1; for(int i = 0; i < t; i ++){ ma[l + i] = now[1 + i]; } } int main() { while(scanf("%d%d", &n, &m) == 2){ cnt = 0; for(int i = 1; i <= n; i ++){ scanf("%d", &ma[i]); } amazing_sort(1, n); printf("%lld\n", max((LL)0, cnt - m)); ///特判 } return 0; }
三:数据结构
1:线段树
线段树真是一个很神奇的东西
关于线段树求逆序对,我们会有一个计数数组,记录每个数字的出现次数,初始均为0。所以如果要用线段树数据范围不能太大,例如本文上一题。
然后我们看暴力法的第二个代码,实质其实就是每当我们读取一个数x的时候,我们就计算,在读取这个数之前(即排在x前面的数中)比x大的数的个数,即对计数数组中
x + 1 ~ n的部分进行求和。
所以我们在读取题目所给排列的时候不断用线段树更新这个计数数组并进行区间求和,就能得出逆序数。
上题目
[HDU] 1394
给你n个数的排列(0 ~ n - 1),然后你可以不断的把首元素移动到末尾,求在移动过程中这个排列的最小逆序数
恩让我们来讨论一下在n个连续且不重复的元素的排列中,把首元素丢到队尾对逆序数的影响。
我们这样考虑:
当一个数x在排列首的时候,那么和他有关的逆序对一定有x个(因为比他小的x - 1个元素一定在他屁股后面而且是从 0 开始标号)
当一个数x在排列尾的时候,那么和他有关的逆序对一定有n - x - 1个(因为比他大的n - x个元素一定在他前面)
所以我们每当我们移动一次,逆序数会减少 x 并增加 n - x - 1 个
所以一重循环枚举求最小值就OK了
#include<cstdio> #include<iostream> #include<algorithm> #include<cstring> #define LL long long using namespace std; const int N = 100010; struct Tree { int l; int r; LL sum; }; LL n; LL ma[N]; LL ans; Tree tree[N * 4]; void up(int x) { tree[x].sum = tree[x << 1].sum + tree[x << 1 | 1].sum; } void build(int x, int l, int r) { tree[x].l = l; tree[x].r = r; tree[x].sum = 0; if(l == r){ tree[x].sum = ma[l]; } else{ int mid = l + ((r - l) >> 1); build(x << 1, l, mid); build(x << 1 | 1, mid + 1, r); up(x); } } void update(int x, int p, LL v) { int ll = tree[x].l; int rr = tree[x].r; if(ll == p && rr == p){ tree[x].sum += v; } else{ int mid = ll + ((rr - ll) >> 1); if(p <= mid){ update(x << 1, p, v); } if(p > mid){ update(x << 1 | 1, p, v); } up(x); } } LL query(int x, int l, int r) { if(l > r) return 0; int ll = tree[x].l; int rr = tree[x].r; if(ll == l && rr == r){ return tree[x].sum; } else{ LL ans = 0; int mid = ll + ((rr - ll) >> 1); if(l <= mid){ ans += query(x << 1, l, min(mid, r)); } if(r > mid){ ans += query(x << 1 | 1, max(mid + 1, l), r); } up(x); return ans; } } int main() { LL minn; while(scanf("%lld", &n) == 1){ memset(ma, 0, sizeof(ma)); ans = 0; build(1, 0, n - 1); for(int i = 1; i <= n; i ++){ scanf("%lld", &ma[i]); update(1, ma[i], 1); ans += query(1, ma[i] + 1, n - 1); } minn = ans; for(int i = 1; i <= n; i ++){ ans -= ma[i]; //减少的逆序数 ans += n - ma[i] - 1; minn = min(minn, ans); } printf("%lld\n", minn); } return 0; }
2:树状数组
(不会,过几天补)
