RMQ、树上差分和LCA问题

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)时间给区间同时增减一个数值。并且“差分数组的前缀和就是原序列”,求差分和求前缀和数组的操作其实是一对互逆操作。

我们类比求前缀和数组的思想:将区间类比为树上路径,将前缀和对应为字数的节点和。

看一个例题:

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/xukeke12138/article/details/116191502