线段树——《挑战程序设计竞赛》

3.3.1 线段树

线段树是一种二叉搜索树,它将一个区间划分成一些单元区间,每个单元区间对应线段树中的一个叶结点(并非完全二叉树!!!)。实际应用时一般还要开4N的数组以免越界。

线段树的构造代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
using namespace std;

const int maxind = 256;
int segTree[maxind * 4 + 10];
int array[maxind];
/* 构造函数,得到线段树 */
//区间为[begin, end]
void build(int node, int begin, int end)
{
if (begin == end)
segTree[node] = array[begin]; /* 只有一个元素,节点记录该单元素 */
else
{
/* 递归构造左右子树 */
build(2*node, begin, (begin+end)/2);
build(2*node+1, (begin+end)/2+1, end);

/* 回溯时得到当前node节点的线段信息,保存最小值 */
if (segTree[2 * node] <= segTree[2 * node + 1])
segTree[node] = segTree[2 * node];
else
segTree[node] = segTree[2 * node + 1];
}
}

int main()
{
array[0] = 1, array[1] = 2,array[2] = 2, array[3] = 4, array[4] = 1, array[5] = 3;
build(1, 0, 5);
return 0;
}

线段树的主要操作包括区间查询(查询给定区间的最小值)和给定节点的更新。

区间查询 int query(int node, int begin, int end, int left, int right);
时间复杂度$O(\log n)$。
(其中node为当前查询节点,begin,end为当前节点存储的区间,left,right为此次query所要查询的区间,实际上我们只想要查询[left, right)的最小值其他的参数是为了计算方便传入的)
线段树区间查询的主要思想是把所要查询的区间[a,b]划分为线段树上的节点,然后将这些节点代表的区间合并起来得到所需信息:(对于节点存储对应区间最小值的线段树来说)

  • 如果所查询区间和当前节点对应的区间完全没有交集,那么就返回一个不影响答案的值
  • 如果所查询的区间包含了当前节点对应的区间,那么就返回当前节点的值
  • 以上两种情况都不满足的话,就对两个儿子递归处理,返回两个结果中的较小值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    int query(int node, int begin, int end, int left, int right)  
    {
    int p1, p2;

    /* 查询区间和要求的区间没有交集 */
    if (left > end || right < begin)
    return -1;

    /* if the current interval is included in */
    /* the query interval return segTree[node] */
    if (begin >= left && end <= right)
    return segTree[node];

    /* compute the minimum position in the */
    /* left and right part of the interval */
    p1 = query(2 * node, begin, (begin + end) / 2, left, right);
    p2 = query(2 * node + 1, (begin + end) / 2 + 1, end, left, right);

    /* return the expect value */
    if (p1 == -1)
    return p2;
    if (p2 == -1)
    return p1;
    if (p1 <= p2)
    return p1;
    return p2;
    }

单节点更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

void Updata(int node, int begin, int end, int ind, int add)/*单节点更新*/
{
if( begin == end )
{
segTree[node] += add;
return ;
}
int m = ( left + right ) >> 1;
if(ind <= m)
Updata(node * 2,left, m, ind, add);
else
Updata(node * 2 + 1, m + 1, right, ind, add);
/*回溯更新父节点*/
segTree[node] = min(segTree[node * 2], segTree[node * 2 + 1]);

}


Crane

有n根长度不尽相同的棍子,初始时它们首尾垂直相连,标号为1—n,第一根棍子的下端坐标为(0,0),上端坐标为(0,len[1]),其余棍子依次类推。接下来执行C此旋转,每次输入一个编号num和角度rad,使得第num根棍子和第num+1跟棍子间的逆时针角度变为rad度,求每次旋转后第n根棍子端点的坐标。

解题思路源于[https://www.cnblogs.com/staginner/archive/2012/04/07/2436436.html]

如果我们将其中某一个线段旋转β角,那么这个线段上方的所有线段都会旋转β角,这就很类似线段树中的对区间加上一个常数的问题了,于是不妨向着线段树的思路去想。

接下来一个问题就是β角是相对于谁的,换句话说我们所谓的每个线段都会旋转β角,那么是绕谁旋转的?实际上,如果我们局限于把线段的旋转就会看成是绕某个定点的,这个点就是我们旋转的线段和它下面那个不动的线段的交点,再这样想下去我们就没法处理了,因为每个旋转操作所绕的定点不是唯一的,我们没办法把所有的旋转操作都统一到一起,那么我们就没办法把旋转操作叠加,这样就没法使用线段树了。

但如果换个思路的话,实际上β角还等于这个线段旋转后所在的直线和未旋转前所在的直线的夹角,而直线的夹角是可以用向量的夹角表示的,如果我们把线段看成一个向量的话那么β角就是这个向量旋转的角度。如果这么看的话,所有的旋转操作就可以统一到一起了,也可以叠加了,因为这样不会局限于绕哪个定点,只需要把向量自身旋转一下就OK。

那么我们维护下面两个值:

  • 把对应线段集合转到垂直方向(也就是整体旋转,让第一条线段垂直之后,注意并不是单独旋转第一条线段),从第一条线段的起点指向最后一条线段的终点的向量。
  • (如果该节点有儿子节点)两个儿子节点对应的部分连接之后,右儿子需要转动的角度(因为s和s+1的角度改变,如果s在左儿子中,那么在全局坐标系内,右儿子也会相应的需要旋转改变坐标)

也就是说,如果节点i表示的向量是$vx_i, vy_i$,角度是$ang_i$,两个儿子节点是chl和chr,那么就有:

对向量旋转的解释可参照[https://www.2cto.com/kf/201610/556382.html]

这样每次更新可在$O(\log n)$的时间内完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <iostream>
#include<stdio.h>
#include<math.h>
using namespace std;

const int maxind = 10010;
const double PI = acos(-1.0);
int array[maxind];
int S[maxind], A[maxind];

double vx[maxind * 4 + 10], vy[maxind * 4 + 10];
double ang[maxind * 4 + 10];

double prv[maxind];
/* 构造函数,得到线段树 */
void build(int node, int begin, int end)
{
ang[node] = vx[node] = 0;
if (begin == end)
cin>>vy[node];
else
{
/* 递归构造左右子树 */
build(2*node, begin, (begin+end)/2);
build(2*node+1, (begin+end)/2+1, end);
/* 回溯时得到当前node节点的线段信息,保存最小值 */
vy[node] = vy[2*node] + vy[2*node+1];
}
}

void change(int s, double a, int node, int begin, int end)
{
if(s<begin) return;
else if(begin == s && begin == end) //区间只用一个棍子的情况
{
return;
}
else if(s < end){
int chl = node*2, chr = node*2+1;
int mid = (begin+end)>>1;
change(s,a,chl,begin,mid);
change(s,a,chr,mid+1,end);
if(s<=mid) ang[node]+=a; //(1, pi/2, 1, 1, 2) 这时mid就为1,s也为1,是最细粒度的情况,需要给ang赋值
/*
s<=m,s在左儿子中,那么右边的线段也会因为s和s+1的角度改变而全局的坐标发生改变,当前这个节点node左右儿子之间的角度也会发生变化(变化a),所以是if(s<=mid) ang[node]+=a,之后根据变化后的角度,改变当前node维护的vx和vy
*/
double sine = sin(ang[node]), cosine = cos(ang[node]);
//cout<<"sine is "<<sine<<", cosine is "<<cosine<<endl;
vx[node] = vx[chl] + (cosine * vx[chr] - sine * vy[chr]);
vy[node] = vy[chl] + (sine * vx[chr] + cosine * vy[chr]);

}
}

int main()
{
int N, C;
int t=0;
while(scanf("%d%d", &N, &C) == 2)
{

build(1,1,N);
for(int i=0;i<C;i++)
{
cin>>S[i]>>A[i];
}


if(t++)
printf("\n");

for(int i=0;i<=N;i++) prv[i] = PI;

for(int i=0;i<C;i++)
{
int s = S[i];
double a = A[i] / 180.0 *PI;

change(s,a-prv[s], 1, 1, N);
prv[s] = a;
printf("%.2f %.2f\n", vx[1], vy[1]);

}
}
return 0;
}

线段树区间更新
A Simple Problem with Integers

给N个数$A_1, A_2, … , A_n$. 你需要处理两种操作,一种操作是在一个区间上每个数都增加一个数,别一种操作是查询一个区间所有数的和

以目前仅有的单节点更新很难高效滴实现对一个区间同时加一个值,所以我们改进线段树,对每个节点我们维护一下两个数据:

  • a. 给这个节点对应的区间内的所有元素共同加上的值
  • b. 在这个节点对应的区间中除去a(a就是上面那个a)之外所有其他值的和
    如果对于父节点同时加上了一个值,那么这个值就不会在儿子节点被重复考虑,而是在递归计算和的时候再把这一部分的值加到结果中。这样无论是同时加一个值还是查询一段的和复杂度都是$O(\log n)$。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#include <iostream>
using namespace std;
const int maxn=100000+5;
typedef long long LL;

LL sum[maxn<<2], add[maxn<<2];
int a[maxn];

void PushUp(int root)
{
sum[root] = sum[root<<1] + sum[root<<1|1];
}

void Build(int l, int r, int root)
{
add[root] = 0;
if(l == r)
{
scanf("%I64d",&sum[root]);
return;
}
int m=(l+r)>>1;
Build(l,m,root<<1);
Build(m+1,r,root<<1|1);
PushUp(root);
}


void PushDown(int root,int m)
{
if(add[root])
{
add[root<<1]+=add[root];
add[root<<1|1]+=add[root];
sum[root<<1]+=(m-(m>>1))*add[root];
sum[root<<1|1]+=(m>>1)*add[root];
add[root]=0;
}

}

void Update(int L,int R,int c,int l,int r, int root)
{
if(L<=l&&R>=r)
{
add[root]+=c;
sum[root]+=(LL)c*(r-l+1);
return;
}
PushDown(root,r-l+1);
int m=(l+r)>>1;
if(L<=m)
Update(L,R,c,l,m,root<<1);
if(R>m)
Update(L,R,c,m+1,r,root<<1|1);
PushUp(root);
}

LL Query(int L,int R,int l, int r,int root)
{
if(L<=l && R>=r)
return sum[root];
PushDown(root,r-l+1); //在查询和的时候在调用PushDown,把保存在add数组中的值加到sum数组的结果之中。
int m=(l+r)>>1;
LL res = 0;
if(L<=m) res += Query(L,R,l,m,root<<1);
if(R>m) res += Query(L,R,m+1,r,root<<1|1);
return res;

}
int main()
{
int m,n;
scanf("%d%d",&n,&m);
Build(1,n,1);
while(m--)
{
char s[5];
int a,b,c;
scanf("%s",s);
if(s[0]=='Q')
{
scanf("%d%d",&a,&b);
printf("%I64d\n",Query(a,b,1,n,1));
}
else
{
scanf("%d%d%d",&a,&b,&c);
Update(a,b,c,1,n,1);
}
}

return 0;
}

3.3.3 分桶法和平方分割

分桶法是把一排物品或者平面分成桶,每个桶分别维护自己内部的信息,以达到高效计算的目的。

其中平方分割是把排成一排的n个元素每个$\sqrt{n}$个分在一个桶内进行维护的方法的统称。这样的分割方法可以使区间的操作的复杂度降至$O(\sqrt{n})$。

水平分割法相比于线段树实现上要简单,单多数情况下线段树会比水平分割快。

K-th Number

给出一个长度为n的序列a,给出m次查询,每次查询区间[l,r]中第k大的数是什么