【学习笔记+习题集】(树状数组)(9473字)

目录

板块一:树状数组

引子:lowbit

1、存入数据(单点修改)

2、区间查询

练习1:hdoj1541

3、区间修改和单点查询(差分数组)

练习1:hdoj 1556

练习2:洛谷P3368

4、求逆序对(两种版本)

练习题1:二次离散化+映射,求逆序对

5、二维的树状数组

6、树状数组求区间最大值

练习1:hdoj 1754

7、树状数组求第k大的数(???做到了再说)

板块二:线段树

前言:

1、建树:

2、区间修改+区间查询


板块一:树状数组

树状数组又加二进制搜索树(binary search tree)

据说树状数组写起来比线段树简单,不过对于初学者的博主来说还是很抽象。

这是一个非常神奇的数据结构,虽然是一维的数组但能存储树形的结构。

首先定义N为数组的长度,那么从1到N我们可以这样分类,从下往上,我们可以得到这样几种类型的数。

用我自己好理解的方式思考。

第零层:最多是2^0的倍数(奇数):*****1

第一层:最多是2^1的倍数***10

第二层:最多是2^2的倍数*****100

………………

首先,树状数组是完备的。奇数+2的倍数+ ………… = 奇数 + 偶数 = 所有下标。

这些数是有限的,因为有上界N,有种筛法的味道。

这些数字包含了所有可以引用的下标。

根据最低位数,我们将其分层。

引子:lowbit

为了方便索引树状数组里面的下标,我们构造一种函数lowbit,来获取最低位的数,也就是判断究竟是2的几次幂的倍数,是第几层。

再计算机中数字用补码储存。

1:补码000001, -1:11111110(这里位数不做考虑了)

1和-1的原码是一毛一样的,就是符号位不同。正数的补码是本身,负数的补码是原码取反(除符号位)加1,虽然符号位和1取反码加一不同,但是后面&后就没有区别了。按位取与。

x & -x 和 x & (~x + 1)是一样的,只不过前者写起来简洁。

如果原来的数是0*********1*1000000000000000000

那么按相反数是1*********0*1000000000000000000

那么这结果就是0000000001000000000000000000

就先不管符号位,因为后面会去掉。这样相反数的原码是一样的,求负数补码的过程中,先各位取反,那么最低位1的位置是零,其后的都是1,加上1之后,进位,一直进到最低位,此刻其他位都是相反的,这时候按位取与就可以得到是几的倍数。

板子:

int lowbit(int x) {
	return x & -x;
}

1、存入数据(单点修改)

数据结构首先它能存储数据,我们要如何存入数据?

其实存入数据也是一种单点修改的过程,就是把原来的0改成了某个特定的数罢了。

有序的数据结构,意味着我们要有序的存入数据,从而实现数据结构的维护。

树状数组父节点和子节点的关系:

也就是第0层和第1层之间的关系是如何的,我们要想使得最多是2的倍数,变成最多是4的倍数我们就需要将最低位的1变成零。也就是+1, +2, +4,刚好是加最低位所代表的数。

为什么不能加别的呢?如果是加别的,比如奇数,那么就无法有序的查询下一个父节点了。

通过这样,我们可以有序得查询下一个父节点。

树状数组的性质:

每一层是上一层通过进位得到,因而每层的长度都是上一层的两倍,这也是为什么可以得到logn的查询效率的原因,数学上证明博主不懂为什么这么构造,还需要慢慢体会。

板子:

void add(int x, int k) {
	while (x <= n) {//不能超出上界
		tree[x] += k;//父节点
		x += lowbit(x);
	}
}

2、区间查询

像台阶一样一直去掉最低位,一级级得到几项和,最终得到前几项和。

没时间画图。

板子:

int ask(x){
	int sum = 0;
	for(int i=x;i;i-=lowbit(i)){
		sum+=t[i];
	}
	return sum;
}

得到前几项和,就可以通过两次调用,做差得到某个区间的和。

练习1:hdoj1541

我只想说这是一道毒题,毒点是这里没有说多组数据,但是hdoj上默认输入多组数据,也是醉了。

wa了n次,哭死。

题目的意思是让我们求星星下方,左方所有的其他的星星。

批注:这道题由于x和y都是按照升序排的,所以后面的星星一定在前面的星星上面,或者同一个位置,所以我们只需要考虑先前放的x即可。

还有一件事,就是注意add/updata函数的上界的范围,数组开的大小!!。

这里没有给出坐标的上界,而这里树状数组存储又是横坐标,所以只能一直到坐标最大值,不要把星星的个数当作是上界!!!。

注意,树状数组坐标为0的话,sum会缺失,所有要加一。

还要就是,先求sum再加,这样就不用减一了。

代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 32005;
int tree[N], n, ans[N];
int lowbit(int x) {
	return x & -x;
}
void add(int x, int k) {
	while (x <= N) {
		tree[x] += k;
		x += lowbit(x);
	}
}
int sum(int x) {
	int res = 0;
	while (x > 0) {
		res += tree[x];
		x -= lowbit(x); 
	}
	return res;
}
int main() {
	ios::sync_with_stdio(false);
	cin.tie(0);
	while (cin >> n) {
    	memset(tree, 0, sizeof(tree));
    	memset(ans, 0, sizeof(ans));
    	for (int i = 0; i < n; i++) {
	     	int x, y;
	    	cin >> x >> y;
	    	x++;//防止坐标为0,如果坐标为零的话sum的时候可能有问题。
	    	ans[sum(x)]++;
	    	add(x, 1);
    	}
     	for (int i = 0; i < n; i++) {
	    	cout << ans[i] << '\n';
    	}
	}
	return 0;
} 

3、区间修改和单点查询(差分数组)

终于悟了。

在使用区间修改的时候,我们的树状数组不再是普通的数组了,其实存储的是差分,是区间左端和区间右端加一的差值。

首先,对于某个区间而言,内部都加上或者减去一个量,区间内的差分不变,差值不变。

例如:0 0 0 1 1 1 1 1 3 3 3》》》0 0 0 2 2 2 2 2 3 3 3。

而且,两侧区间内部的的差值不变。

然后,我们从树的最底层来看,我们考察相邻两个数的差值,我们发现,改变的只有,第一个1和第一个3,由于更新的传递性,所有包含这个点的父节点的两端差值都受到了影响,我们只需进行两次单点更新即可。

最后,为了获取该点的真实值,我们只需要进行前缀求和即可。着实妙哉。

练习1:hdoj 1556

下面是代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int tree[N], n;
int lowbit(int x) {
	return x & -x;
}
int sum(int x) {
	int res = 0;
	while (x > 0) {
		res += tree[x];
		x -= lowbit(x);
	}
	return res;
}
void add(int x, int k) {
	while (x <= n) {
		tree[x] += k;
		x += lowbit(x);
	}
}
int main() {
	ios::sync_with_stdio(false);
	cin.tie(0);
	while (cin >> n, n) {
		for (int i = 0; i <= n; i++) {
			tree[i] = 0;
		}
		int a, b;
		for (int i = 1; i <= n; i++) {
			cin >> a >> b;
			add(a, 1);
			add(b + 1, -1);
		}
		for (int i = 1; i <= n; i++) {
			if (i == 1) {
				cout << sum(i);
			} else {
				cout << ' ' << sum(i);
			}
		}
		cout << '\n';
	}
	return 0;
}

练习2:洛谷P3368

 批注:这里和上面那道题都是从零开始不一样,这里有初始值,我们不能简单的add这个点,因为我们需要的是差分数组,所以,我们要把一个点看成区间长度为1的区间修改。

代码:

#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int N = 5e5 + 5;
int tree[N] = {0}, n;
int lowbit(int x) {
	return x & -x;
}
void add(int x, int k) {
	while (x <= n) {
		tree[x] += k;
		x += lowbit(x);
	}
}
int sum (int x) {
	int sum = 0;
	while (x > 0) {
		sum += tree[x];
		x -= lowbit(x);
	}
	return sum;
}
int main() {
	ios::sync_with_stdio(false);
	cin.tie(0);
	int m;
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		int c;
		cin >> c;
		add(i, c);
		add(i + 1, -c);
	}
	while (m--) {
		int flag;
		cin >> flag;
		if (flag == 1) {
			int x, y, k;
			cin >> x >> y >> k;
			add(x, k);
			add(y + 1, -k);
		} else {
			int c;
			cin >> c;
			cout << sum(c) << '\n';
		}
	}
	return 0;
}

4、求逆序对(两种版本)

大量阅读后发现两种板子,个人觉得第一种写起来更加简洁而且我更好理解。

公共部分:单点修改和前缀和查询

int lowbit(int x) {
	return x & -x;
}//最低位
void add(int x, int k) {
	while (x <= n) {
		tree[x] += k;
		x += lowbit(x);
	}
} //单点修改
int sum(int x) {
	int sum = 0;
	while (x > 0) {
		sum += tree[x];
		x -= lowbit(x);
	}//前缀查询
	return sum;
}

 两种版本的思想都是一样的,本质都是离散化。

版本A:间接排序+从大到小

bool cmp(int x,int y) {
	if(a[x] == a[y]) return x > y;
	return a[x] > a[y];
}
int main() {
	long long ans=0;
	cin >> n;
	for(int i = 1;i <= n; i++)
	cin >> a[i], d[i] = i;
	sort(d + 1,d + n + 1,cmp);	
	for(int i = 1;i <= n; i++) {
		add(d[i]);
		ans += sum(d[i] - 1);
	}
	cout << ans;
	return 0;
}

解读:

所谓的逆序对或者是逆序数,呃呃,这个就是线性代数里面内容,不过逆序数也就是,冒泡排序所需要交换的次数。对于一个数的逆序我们应该怎么求?我们只需要看看前面有几个数比这个数大就可以了,这样我们就可以通过相邻两个数交换把这个数放到正确的位置,这也是冒泡排序的原理。

一个序列的逆序数是每一个数的逆序数之和。

我发现很多人都不喜欢讲清楚数组的含义,直接上代码,真心累。

这里a数组是原始数据,自始至终没有变动,d数组存的a数组的编号。

首先,通过排序。这里是间接排序,根据a数组内的大小来进行排序,貌似这里面有种指针的联系,尽管是两个数组,但是在排序的过程中始终都是有联系的。注意,这里相同元素的处理,因为我们这里是从大到小排序,所以对于相同的元素,我们取大的,后面讲。

然后,排序完之后,我们得到一个d的数组,里面的对于d[i] = x,i是代表这是第几大的数,x是a中对应数的下标。

我们首先拿第一大的数,更新该元素原来位置的下标x,x象征这原来数组的各个元素的位置,而i则是优先级体现。

然后,我们记录前缀和,注意,后面更新的数一定比前面更新的数要小!!!,所以说,只要前面存在更新过的点,只要前面有数,说明,比这个数大的数的位置在这个数前面!!!,那么这个数的逆序数就是区间0到x里面所有的数,也就是sum(x),但是这是个闭区间,我们也把x的存在放进去了,所以要减一。

依次求和,我们就得到了整个序列的逆序,long long!!!。

回答问题,如果说元素是一样大的话,我们默认,后面更新的比前面更新的要小,但是这是一样的,实际上两者之间没有逆序关系,如果说我们把标号小的放前面,会导致其被计入后面标号大的区间内,导致多计算了逆序数。

可以简单的概括为,求d数组内部的正序数,因为只要是正序的就意味着被计入下一个数的区间内。

大功告成。

B:结构体+从小到大

struct point
{
    int num,val;
} a[500010];
bool cmp(point q,point w)
{
    if(q.val == w.val)
        return q.num < w.num;
    return q.val < w.val;
}
int main()
{
    scanf("%d",&n);
    for(int i = 1;i <= n; i++)
        scanf("%d", &a[i].val),a[i].num = i;
    sort(a+1, a+1+n, cmp);
    for(int i = 1; i <= n; i++)
        ranks[a[i].num] = i;
    for(int i = 1; i <= n; i++)
    {
        insert(ranks[i],1);
        ans += i-query(ranks[i]);
    }
    printf("%lld",ans);
    return 0;
} 

这个感觉有点绕。

这里是从小到大排序,利用了结构体。

这里ranks数组里面存储的是对应下标的优先级ranks[i] = x这里的i才是下标,注意!。

首先放入原来数组下标为1的数的优先级。这里因为是第一个数,所以,它前面的数(含本身)为一。最小,优先级最高rank1

那么对于第i个数,它前面的数(含本身)共有i个数,我们要找出其中比它大的数,我们是减去其中比它小的数,query(ranks[i])。

第一个数更新的时候,这里的树状数组存的是优先级和上面的相反,比它优先级低(rank100,是值小的)的都会受到影响,因为会被包含在较低优先级的区间内部。

所以query查询的是在这个树前面优先级比它低的数(含本身),也就是比它小的数,相减得到。

同样,这里如果有相同的元素的话,小的放前面,rank会小,这样可以多计算一次,以便于和i同时增加相互抵消。否则i增加会带来麻烦。i表示下标!!。

练习题1:二次离散化+映射,求逆序对

首先要关注离散化后的数组的含义,数组的含义是第i大的数的编号,我们对两个数组都进行离散化处理,接下来,我们要把一个数组的优先级反映到另一个数组的。构建一个哈希表,讲同样是第i大的数的编号对应起来。从而每个数的标号都得到了对应,接下来枚举被排序的数组的标号,以另一个数组的标号为优先级,求逆序数,这里必须反着求逆序数,sum求出来的是优先级小的和,只有后面的数在前面的数,本应当放在被排序的数组里较前的位置的时候,后面的数居然优先级比它小就要加逆序数。反之,如果如果正着求的话,被排序数组后面的数本来就是在后面,而且优先级大,那么求出来的就是正序数。当然也可以向前面那样i-巴拉巴拉,不过这样写的话就不需要再sum的时候减一了。

#include <bits/stdc++.h>
#define p 99999997
using namespace std;
int n;
const int N = 1e5 + 5;
int a[N], b[N], c[N], mp[N], tree[N];
int lowbit(int x) {
	return x & -x;
}
void add(int x, int k) {
	while (x <= n) {
		tree[x] += k;
		x += lowbit(x);
	}
}
long long sum(int x) {
	long long sum = 0;
	while (x > 0) {
		sum += tree[x];
		x -= lowbit(x);
	}
	return sum; 
}
bool cmp(int x, int y) {
	return a[x] > a[y];
}
int main() {
	ios::sync_with_stdio(false);
	cin.tie(0);
	long long ans = 0;
	cin >> n;
	for (int i = 1; i <= n; i++) {
		cin >> a[i];
		b[i] = i;
	}
	sort(b + 1, b + 1 + n, cmp);
	for (int i = 1; i <= n; i++) {
		cin >> a[i];
		c[i] = i;
	}
	sort(c + 1, c + 1 + n, cmp);
	for (int i = 1; i <= n; i++) {
		mp[c[i]] = b[i];
	}
	for (int i = n; i >= 1; i--) {
		add(mp[i], 1);
		ans += sum(mp[i] - 1) % p;
//    cout << mp[i] << ' ';
	}
	cout << ans % p << '\n';
	return 0;
}

5、二维的树状数组

一维的树状数组已经是二维的了,再增加一维可能就是三维的数据结构了,树状数组真心累。

可能不靠谱的想象,可以把二维树状数组看成,两个树状数组垂直正交构成的十字形的“树”。

二维树状数组里面存的是矩阵,我想里面应该也可以读取一维数组,也就是某一矩阵的某一行。不过这样就大材小用了,这里主要目的是为了求出前i行,前j列,的子矩阵之和。

板子:

单点更新。

void update(int i, int j, int num){
    for(int x = i; x< first; x += lowbit(x))
        for(int y = j; y < last; y += lowbit(y))
            c[x][y] += num;
}

前缀求和:

int sum(int i, int j){
    int s = 0;
    for(int x = i; x > 0; x -= lowbit(x)) {
        for(int y = j; y > 0; y -= lowbit(y)) {
            s += c[x][y];
        }
    }
    return s;
}

大概是这样的,三维空间里面大大小小的方块,下面投影的是原始的二维数据矩阵。

6、树状数组求区间最大值

至此,我们有了三种不同类型的树状数组,前缀和类型,差分类型,最值类型,为什么花样这么多QAQ。

首先,如何维护最大值,肯定是不可能想前缀和和差分类型一样维护的,具有不同性质的树状数组我们就要保证区间性质的方法来维护。

板子:

单点更新(区间维护):

void add(int x) {
	while (x <= n) {
		tree[x] = a[x];
		int lx = lowbit(x);
		for (int i = 1; i < lx; i <<= 1) {
			tree[x] = max(tree[x], tree[x-i]);
		}
     	x += lowbit(x);
	}		
}

 为什么这里需要一个for循环呢?如果说,我们直接单点tree[x] = max(tree[x], k),加lowbit逐级更新所有的父节点的的话。

会导致一个问题,就是,如果说我们修改的那个数的刚好的原来的最大值的话,而且,新的数比原来的数要小的话,这样就会导致,树状数组里面存了一个虚假的最大值,只是历史曾经存在过的最大值,但是并不是当前真实的最大值。

为了避免这个问题的出现,就不得不查看所有的不包含这个数的区间,但是庆幸的是,由于树状数组特殊的性质,能够更新到这个区间的子区间一定是某个数加上一个lowbit,所以我们只需要查看所有能够通过加上一个lowbit得到这个数的区间即可。

也就是这个数减去一些lowbit,而且也不需要减去所有的lowbit,因为树状数组里面的每一层的lowbit都是相同的,所以子节点的下标的lowbit一定比x要小。

区间查询:

int query(int x, int y)
{
	int ans = 0;
	while (y >= x)
	{
		ans = max(a[y], ans);
		y--;//!!!
		for (; y - lowbit(y) >= x; y -= lowbit(y))
			ans = max(tree[y], ans);
	}
	return ans;
}

模板解读:我们要查询区间[x, y]上的最大值,tree[y]表示的是某个以a[y]为末端的区间,这个区间可能很长可能很短,但是,我们要求,这个区间不能超过x否则最大值就可能取到外面了。

如果减去lowbit还在区间内说明原来的区间长度小于[x, y],直接max综合求最大值,一直求到无法这么求为止,然后再减1取掉当前的元素求最大值,继续操作,依次类推。

由于lowbit的二进制特性,这样操作可以大大加快区间的检索。

练习1:hdoj 1754

批注:注意一下,memset函数其实和for循环的速度是一样的,这里不建议用,每次清空全部可能会超时。

注意了,这里数状数组里存的最值,函数输入的是下标。

下面是代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
int a[N], tree[N], n;
int lowbit(int x) {
	return x & -x;
}
void update(int x) {
	while (x <= n) {
		tree[x] = a[x];
		for (int i = 1; i < lowbit(x); i <<= 1) {
			tree[x] = max(tree[x], tree[x - i]);
		}
		x += lowbit(x);
	}
}
int query(int x, int y) {
	int ans = 0;
	while (y >= x) {
		ans = max(a[y], ans);
		y--;
		while (y - lowbit(y) >= x) {
			ans = max(tree[y], ans);
			y-= lowbit(y);
		}
	}
	return ans;
}
int main() {
	ios::sync_with_stdio(false);
	cin.tie(0);
	int m;
	while (cin >> n >> m) {
		for (int i = 0; i <= n; i++) {
			a[i] = 0;
			tree[i] = 0;
		}
		for (int i = 1; i <= n; i++) {
			cin >> a[i];
			update(i);
		}
		while (m--) {
			char ch;
			int x, y;
			cin >> ch >> x >> y;
			if (ch == 'Q') {
				cout << query(x, y) << '\n';
			} else {
				a[x] = y;//注意看,这里要先更改a数组
				update(x);
			}
		}
	}
	return 0;
}

7、树状数组求第k大的数(???做到了再说)

板块二:线段树

线段树拥有所有树状数组的具备的功能,但是树状数组不一定具备线段树的操作。

前言:

线段树和树状数组的类似之处,都是通过一维的的数组来表示树的数据结构。

胡乱分析:

                  1
               10   11
       100    101   110     111
1000 1001 1010 1011 1100 1101 1110 1111

首先用二进制可以发现,各个区间的标号之间的关系,当标号乘二时会得到左儿子,当标号乘二加一的时候会得到右儿子。

而且,每一层的可以存储的区间都是二的倍数增加,每一层的二进制数的位数是一样。因此,所有的标号都是存在对应区间的(如果有区间的话),这些标号是稠密的,而不是稀疏的。

因为,每层的二进制数的最大可能性是有限的,而且和当前层数的标号是一一对应的。 

1、建树:

板子:

void build(int s, int t, int p) {//s是开始点,t是结束点,p是初始标号1
  if (s == t) {//当区间长度为1的时候
    d[p] = a[s];//直接将数组里的数存入
    return;
  }
  int m = s + t >> 1;//取中间
  build(s, m, p * 2);//左儿子,左儿子的下标是母节点的下标的两倍
  build(m + 1, t, p * 2 + 1);//右儿子,右儿子的下标是母节点的两倍加1
  d[p] = d[p * 2] + d[(p * 2) + 1];//递归,从叶子节点开始,逐层更新
}

2、区间修改+区间查询

树状数组的是单点修改加区间查询,或者是区间修改单点查询,实际上,树状数组还是只能单点修改,就算是区间修改,也不过是通过差分数组实现两端两点修改来模拟区间修改。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值