RMQ、树上差分和LCA问题
倍增
首先,回忆一下倍增的思想。
倍增的意思就是成倍地增长。由于“任意一个整数可以表示为若干个2的次幂项的和”,所以可以将问题的状态空间划分为2的次幂项个,从而降低时间复杂度为O(logN)。
经典的快速幂算法就是将问题的状态空间进行了划分。
int qpow(int a, int b, int p){
// (a ^ b) mod p
int ans = 1 % p;
while(b){
if(b & 1){
ans = (long long)ans * a % p;
a = (long long)a * a % p;
}
b >>= 1;
}
return ans;
}
试想这样一个问题:
给定一个长度为n的数列a,有m次询问,每次给出一个t,求满足a[1]+a[2]+...+a[k]<=t的k值(满足t>=0)
朴素的暴力做法每次询问的时间复杂度在O(N),或者,先预处理前缀和数组s,再对其进行二分查找,但是这种做法在最坏的情况下时间复杂度也比较高。
考虑运用倍增的思想,让每一次查找的步长为2的次幂。即设计步长为2^p。
依旧预处理前缀和数组s,并且设置一个sum值,作为已累计的部分的求和。对于下一步要累加s[k + 2^p]-s[ k ],如果sum+s[ k+2^p ]-s[ k ]大于t值,那么就缩短查找步长为一半,令p–;反之,则增长查找步长为原来的一倍,令p++。
(在实际计算的过程中,令p=2^i,每次对p值进行*2或/2操作)
RMQ问题: 区间最值问题
考虑这样的问题:
对于一个给定长度为n的数列a,在线回答“数列a中下标在l~r之间的数的最大值是多少”问题。
朴素的做法是对于每次询问以O(n)的复杂度求解,但是对于m次询问,复杂度会上升为O(n*m)。我们更希望以更低的复杂度求解。
ST算法
ST算法是一种以O(nlogn)的复杂度进行预处理后,能够在O(1)的时间回答询问的离线方法。
核心思想如下:
固定区间[l, r]的开始位置l,将r的长度划分为2的次幂项个。通过预处理,求出其区间内的最大值。
设计F[i, j]数组,表示a[ i ]到a[ i+ 2^j-1 ]的区间内的最大值。递推求解,边界是F[i, 0] = a[ i ]。
void ST_prework(){
for(int i = 1; i <= n; i++)
F[i][0] = a[i];
int t = (int)log(n) / log(2) + 1;
for(int j = 1; i < t; j++)
for(int i = 1; i <= n - (1<<j) + 1; i++)
f[i][j] = max(f[i][j-1], f[i + (1 << (j-1))][j-1]);
}
因为F[i][j]的区间长度为2^j,可以将其划分为两个长度为2 ^ (j-1)的区间进行求解。F[i][j]的区间最值就是两个子区间最值中较大的一个。
在询问区间[l, r]内最值时,找到一个k值,令2^k <= r-l+1 <= 2 ^ (k+1),即找到一个最大的不大于区间长度的2的次幂项。同时从区间的开始位置l和结束位置r判断。
这时左边区间为F[i][k],右边区间为F[r-2^k+1][k]。
int ST_query(int l, int r){
int k = log(r - l + 1) / log(2);
return max(f[l][k], f[r - (1<<k) + 1][k]);
}
最近公共祖先LCA问题
求解最近公共祖先的问题可以想象成为一个在树上的倍增问题。
最朴素的做法的时间复杂度是O(N)。
树上倍增
和ST算法类似,设置F[x][k]为x节点向上走2 ^ k步到达的祖先节点。特别的,F[x][0]就是x节点的父节点。同时对于小于树的深度logn的k,有F[x][k] = [F[x, k-1]][k-1]。
树上倍增法的预处理时间复杂度是O(logn),每次询问的时间复杂度也是O(logn)。
看一下模板代码:
const int maxn = 5e5 + 20;
int n, m, t;
int head[maxn], nex[2 * maxn], ver[2 * maxn], edge[2 * maxn];
int tot;
int F[maxn][50];
int d[maxn], dist[maxn];//深度,距根节点距离
void init() {
t = int(log(n) / log(2)) + 1;//计算log值
tot = 0;
for (int i = 1; i <= n; i++) {
head[i] = 0;
d[i] = 0;
}
}
void add(int u, int v, int k) {
ver[++tot] = v;
nex[tot] = head[u];
head[u] = tot;
edge[tot] = k;
}
void bfs() {
queue<int> q;
q.push(1);
d[1] = 1;//根节点深度为1
while (q.size()) {
int x = q.front();
q.pop();
for (int i = head[x]; i; i = nex[i]) {
int y = ver[i];
if (d[y])//已经遍历过
continue;
d[y] = d[x] + 1;//深度
dist[y] = dist[x] + edge[i];//距离
F[y][0] = x;//父节点
for (int j = 1; j <= t; j++) {
F[y][j] = F[F[y][j - 1]][j - 1];
}
q.push(y);
}
}
}
int lca(int x, int y) {
if (d[x] <= d[y]) swap(x, y);//x的深度更大
for (int i = t; i >= 0; i--)
if (d[F[x][i]] >= d[y]) {
x = F[x][i];
}
if (x == y)
return y;
for (int i = t; i >= 0; i--)
if (F[x][i] != F[y][i]) {
x = F[x][i], y = F[y][i];
}
return F[y][0];
}
int main() {
int T;
scanf("%d", &T);
while (T--) {
scanf("%d%d", &n, &m);
init();//初始化
for (int i = 1; i <= n - 1; i++) {
int u, v, k;
scanf("%d%d%d", &u, &v, &k);
add(u, v, k);
add(v, u, k);
}
bfs();
while (m--) {
int x, y;
scanf("%d%d", &x, &y);
int z = lca(x, y);
printf("%d\n", dist[x] + dist[y] - 2 * dist[z]);
}
}
return 0;
}
树上差分
再回忆一下前缀和数组与差分数组。前缀和数组可以在O(1)的时间求出区间的和,差分数组可以在O(1)时间给区间同时增减一个数值。并且“差分数组的前缀和就是原序列”,求差分和求前缀和数组的操作其实是一对互逆操作。
我们类比求前缀和数组的思想:将区间类比为树上路径,将前缀和对应为字数的节点和。
看一个例题: