一文弄懂树状数组之【求逆序数】

网上的文章对逆序数这个东西讲的都不是很清楚,有的掐头去尾,有的寥寥数语,有点含糊其辞。怎么说呢,作者本身肯定是懂的,但讲的不透彻的时候会造成很多困扰。下面我结合我的思考再阐述一下如何用树状数组求逆序数,以及为什么是用树状数组求逆序数

目录

先上一道模板题

求逆序数

代码如下

如何用树状数组求逆序数

为什么选择树状数组来做逆序数呢



先上一道模板题

求逆序数

时间限制: 2000 ms | 内存限制: 65535 KB

难度: 5

描述

在一个排列中,如果一对数的前后位置与大小顺序相反,即前面的数大于后面的数,那么它们就称为一个逆序。一个排列中逆序的总数就称为这个排列的逆序数。

现在,给你一个N个元素的序列,请你判断出它的逆序数是多少。

比如 1 3 2 的逆序数就是1。

输入

第一行输入一个整数T表示测试数据的组数(1<=T<=5)

每组测试数据的每一行是一个整数N表示数列中共有N个元素(2〈=N〈=1000000)

随后的一行共有N个整数Ai(0<=Ai<1000000000),表示数列中的所有元素。

数据保证在多组测试数据中,多于10万个数的测试数据最多只有一组。

输出

输出该数列的逆序数

样例输入

2
2 
1 1
3
1 3 2

样例输出

0
1

代码如下

#include<cstdio>
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
int tree[1000005];
int reflect[1000005];
int n;
struct node
{
	int val;
	int pos;
}a[1000005];
bool cmp(node n1, node n2)
{
	if(n1.val != n2.val)
	return n1.val < n2.val;
	return n1.pos < n2.pos;
}
void add(int k, int num)
{
	while(k <= n)
	{
		tree[k] += num;
		k += k & -k;
	}
}
long long query(int k)
{
	int sum = 0;
	while(k >= 1)
	{
		sum += tree[k];
		k -= k & -k;
	}
	return sum;
}
int main()
{
	int T, i, j;
	long long sum;
	cin >> T;
	while(T --)
	{
		cin >> n;
		sum = 0;
		memset(tree, 0, sizeof(tree));
		for(i = 1; i <= n; i++)
		{
			scanf("%d", &a[i].val);
			a[i].pos = i;
		}
		sort(a + 1, a + n + 1, cmp);//离散化,将数组按值从小到大排列 
		for(i = 1; i <= n; i++)
		{
			reflect[i] = a[i].pos;//映射数组,从值到位置的映射 
		}
		for(i = n; i >= 1; i--)//将数从大到小添加,每添加一个数,求该数前面有多少个数, 
		{
			add(reflect[i], 1);
			sum += query(reflect[i]) - 1;//-1是除去该数本身 
		}
		cout << sum <<endl;
	}
	return 0;
}

如何用树状数组求逆序数

将数从大到小添加,每添加一个数,求该数前面有多少个数, 相当于就是对原始数组对值进行排序,然后倒叙按照下标去插点(对应树状数组的单点修改),之所以为什么要倒序插点和倒序查找区间和,就是因为逆序数有两种方法:一是对每一个数往前寻找有多少个比他大的,然后全部加起来;二是要么对每一个数往后寻找有多少个比他小的,然后全部加起来。但是,两个方法只能选一个,不能都选。

下面的讲解中由三个关键词:原值 原下标 现下标

此处我们选前者,所以,倒序找,如果一共就5个数,原值5 3 2 4 1。对他的原下标编号1 2 3 4 5;所以当排序之后原下标就变成了现下标 5 3 2 4 1;那么我们把原值排个序变成1 2 3 4 5对应原下标 5 3 2 4 1【为什么要把原数组这样排一遍理解成让每个数字回到他原本应该在的地方即可】;当我们插初始值的第五个数时,他的下标是1,意味着原本原值大小排第五位的5应该在第五位,但是他的原下标时第一位,所以所有含由第一位的区间都会有这么一个逆序数,因为他比1之后的所有数都大;为什么倒叙查找,其实就是对每一位判断前面是否有数比他大,所以先插五,判断五;再插四,判断四【如果数值五的原坐标在四前面,那么getsum的时候自然会加1】【同时要注意减一,因为getsum会加到自己,所以要减去本身】【当我们每一步都是判断前面有多少个数大于自己时候,自然不用管自身坐标之后的存值情况,因为我只考虑我之前的】【同时5-5,4-(4,5),3-(3,4,5)。。。】每种情况判断的都是比自己大的数是否在之前。

为什么选择树状数组来做逆序数呢

所以算法的选择要搞清楚为什么要选择他,也就是树状数组跟逆序数的因果关系到底是什么?

如果正常思维的话,就是一个冒泡,每交换一次记录一下,就是一个逆序数,但是对于数据量大的情况,时间复杂度O(n^2)的规模根本不够。

那么我们就会想:逆序数,不就是累加每一个数前比它大的数的个数嘛。“每一个数前”——敏感的字眼一下子就让人联想到了“区间操作”——那么大数据量下的可更新动态的高效区间操作无疑两种——树状数组和线段树。那么可以是都可以,但是明显同样功能下树状数组会更高效且好写。那么选择树状数组。

——每一个数据结构都有他独特的魅力,当然也有他独特的功能(如果理解成技能的话),之后不同的开发都是在技能的花式使用上,当然这里也不例外。——树状数组独特功能就是lowbit技术加速下的单点更新和区间和——再纯粹一点就是其实就是加速的前缀和。独特的单点更新其实改变了更新方式而已,区别于正常数组的更新方式,此处为了维护前缀区间和不变所以要更新了该特殊路径上所有的的点而已,也是服务于区间前缀和而存在的。

——那么思路变成了:为了避免算法层面上的大量复用,思路应该由每一次的来回比较变成寻找每一位之前有几个数比他大,如果没有逆序数,逆序数为0的话,每一位前面的数都应该是0,也就是当从数值从大到小插的过程时,不会有数插到它本来应该在的位置之前。说 5 就应该占第五位 0 0 0 0 1,4就应该占第四位 0 0 0 1 1,3就应该占第三位  0 0 1 1 1 。。。循环过程中每一个位置前的数都为0,【意味着不存在后面的数占领前面的位置,例如 5 如果一开始就是原来的第一位的话,那么前面第一位必然有个1,例如第五位1 0 0 0 0,那后面如果都有序的话,第四位1 0 0 1 0,第三位 1 0 1 1 0。。。】,也正是这样导致冒泡算法会产生很大的冗余和重复,当把目光转向每一位之前的区间和,就避免了一个数不断地于其他数进行比较,也就是为什么有时候觉得冒泡很蠢的原因了(明明5放在第一位的时候,每次肯定都算一个逆序数啊,为啥还要把显而易见的东西进行比较呢——由此也引发了我对算法的思考,可能人觉得烦就是因为脑内嵌在着更高效的算法,但我们人却主观的使用了很low的算法,导致了大脑不喜欢的冗余,所以人就会很烦)。同时为了避免由于可能会出现个数和数值相差太大的不匹配造成的时间和空间冗余,所以采用离散化手段:例如可能就三个数,但这三个数分别为1,50000,1000000;要构建一个值到位置的映射,必须要解决值太分散且值的数据太大的问题,可以先将每个数的位置与值用结构体数组存起来,然后按照值的大小排序,然后用1, 2,3,4……代替原先的值,最后将reflect数组按倒序插入树状数组,每插入一个值,就求该值前面的和,即位置在该值前面且大于该值的数,即该值的逆序数
离散化对应为下标

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值