洛谷 P3596 [POI2015] MOD 題解

XuYueming發表於2024-03-14

題意簡述

給定一棵樹,求斷掉一條邊再連上一條邊所得的新樹直徑最小值和最大值,以及相應方案(你可以不進行任何操作,即斷掉並連上同一條邊)。

題目分析

假設我們列舉斷掉某一條邊,得到了兩棵樹,並且知道它們的直徑分別為 \(d_0, d_1\),那麼如何連線一條邊讓新樹的直徑最大 / 最小呢?

  1. 最大:顯然,將兩棵樹的直徑首尾相接,得到的直徑是最大的,新樹的直徑長度是 \(d=d_0+d_1+1\)。別忘了新加的這條邊的貢獻 \(1\)
  2. 最小:和 HXY 造公園 裡的思想一樣,我們將兩個樹的直徑的中點相連(或者沒有中點時取直徑中心相鄰的那兩點任一),得到的新直徑長度是 \(d = \max \lbrace d_0, d_1, \left \lceil \cfrac{d_0}{2} \right \rceil + \left \lceil \cfrac{d_1}{2} \right \rceil + 1 \rbrace\)。別忘了新加的這條邊的貢獻 \(1\)

可是,我們這樣只能知道答案直徑的長度,以及斷掉哪條邊,那怎麼知道斷邊之後連線哪兩個點呢?如果在 \(\Theta(n)\) 列舉斷邊的同時把兩棵樹的直徑求出來時間複雜度是 恐怖的 \(\Theta(n^2)\),顯然超時。如何最佳化呢?事實上,我們完全不用每得到一個可能的答案就算出其具體方案,而是留到最後再處理,處理方法隨便一個 \(\Theta(n)\) 求直徑的方法都行。這樣,總體的時間複雜度就是 \(\Theta(n)\) 的。於是,問題變成給出斷開的邊,如何求兩顆樹的直徑長度。在這裡提供了兩種方法 \(\Theta(n)\) 地求解此題。

1. 樹形 DP

欽定原樹以 \(1\) 為根結點。列舉斷邊可以使用深搜,那麼我們就需要在搜尋的時候快速求得以 \(u\) 為根的子樹的直徑長度以及 \(fa[u]\) 這個方向上的直徑長度。於是我們想到了使用樹形 DP 求解。前者是樹形 DP 求直徑的模板,可以用一遍深搜預處理出來。考慮如何換根求得後者。在根從 \(fa[u]\) 變成 \(u\) 的時候,發現 \(fa[u]\) 這個方向上的樹多出了 \(u\) 的兄弟子樹,那麼可能構成直徑的分為以下幾個部分。

  • 原先 \(fa[fa[u]]\) 方向上的直徑。
  • \(u\) 兄弟子樹中的直徑。
  • \(u\) 的兩個兄弟(如果存在)\(x\)\(y\),以及分別在以 \(x\) 為根的子樹中和以 \(y\) 為根的子樹中取出一條鏈 \(x \rightarrow x'\)\(y \rightarrow y'\),組成的新鏈 \(x' \rightarrow x \rightarrow fa[u] \rightarrow y \rightarrow y'\)
  • \(fa[fa[u]]\) 方向連向 \(fa[u]\) 的一條鏈 \(p \rightarrow fa[u]\)。選取 \(u\) 的兄弟 \(x\),以及 \(x\) 子樹中一條鏈 \(x \rightarrow x'\)。兩條鏈拼接組成的新鏈 \(p \rightarrow fa[u] \rightarrow x \rightarrow x'\)

顯然,以上分析囊括了不越過 \(fa[u]\) 和越過 \(fa[u]\) 的所有可能情況,不存在漏解。為了幫助理解,可以參考下圖。

對於第二點,想到記 \(w_i\) 表示 \(i\) 所有子樹中最長的直徑,那麼第二點直徑長度就是 \(w_{fa[u]}\),但是請注意,我們要的是 \(u\) 的兄弟子樹而不包括 \(u\) 這棵子樹,萬一 \(w_{fa[u]}\) 正好是 \(u\) 這棵子樹中的直徑就出現了問題。所以,套路化地,我們給 \(w\) 多加一維,變為 \(w_{i,0/1}\) 表示以 \(i\) 的所有子樹中最長的直徑 / 次長的直徑。這樣,對於上文提到的情況,就使用 \(w_{fa[u], 1}\) 來轉移就沒有問題。

對於第三種情況,想到記 \(d_{i, 0/1}\) 表示 \(i\) 所有子樹中,根節點連出的最長鏈和次長鏈的長度。那麼對於一般情況,合併後的直徑長度就是 \(d_{fa[u],0}\) + \(d_{fa[u],1}\)。套路化地,發現當 \(u\) 這棵子樹貢獻了最長鏈或者次長鏈會產生問題,所以需要再開一維,記 \(d_{i,0/1/2}\) 表示 \(i\) 所有子樹中,根節點連出的最長鏈、次長鏈和次次長鏈的長度。轉移的時候注意不要使用到 \(u\) 這棵子樹產生的資訊就可以了。

對於第四種情況,我們需要知道 \(fa[u]\)\(fa[fa[u]]\) 方向上最長鏈的長度,這個假設已經求得,為 \(chain_{fa[u]}\)。和 \(u\) 兄弟子樹中根節點連出的最長鏈的長度,發現就是上文求的 \(d_{fa[u],0}\),當 \(u\) 這棵子樹存在最長鏈的時候是 \(d_{fa[u],1}\)。那麼合併後的直徑長度就是 \(chain_{fa[u]} + d_{fa[u],0/1}\)。考慮如何使用資訊更新 \(chain_u\)。首先,可能新的鏈是 \(chain_{fa[u]}\) 的基礎上連上了 \(fa[u] \rightarrow u\) 這條邊,長度是 \(chain_{fa[u]} + 1\)。其次可能是 \(u\) 兄弟子樹連過來的一條邊,長度是 \(d_{fa[u],0/1} + 1\),這個要根據 \(u\) 是否是最長鏈分類討論。兩者合併,得到 \(chain_u = \max\{chain_{fa[u]}+1,d_{fa[u],0/1}+1\}\)

分析結束,具體使用程式碼實現就是兩遍 DFS,第一遍預處理出 \(u\) 子樹中直徑長度 \(f_u\)\(d_{u,0/1/2}\)\(w_{u,0/1}\)。第二遍使用資訊更新 \(fa[u]\) 方向上的直徑 \(g_u\)\(chain_u\),同時更新答案即可。具體實現和細節見程式碼。

2. 在原直徑上 DP

假設在想到斷掉一條邊後,我們沒有往樹形 DP 的方向思考,而是想到了如下結論:

結論一:如果要獲得直徑的最小值,把原直徑斷開一定不劣。

證明:
設原樹直徑為 \(d\)。如果沒有斷開原直徑,那麼答案 \(D=\max \lbrace d,l,\left \lceil \cfrac{d}{2} \right \rceil + \left \lceil \cfrac{l}{2} \right \rceil + 1 \rbrace\) 一定有 \(D \geq d\),而我們斷開連線同一條邊獲得的答案就是原樹直徑 \(d\) 顯然不劣。所以為了得到更優的答案,就必須要把原樹直徑斷開。

結論二:如果要獲得直徑的最大值,只可能是斷開直徑或者斷開和直徑連線的邊

證明:
方案分為兩種,即斷開直徑或者不斷開直徑。如果不斷開直徑,我們就需要和直徑分離的那棵樹直徑最長,所以此時有刪除和直徑連線的這條邊不劣。這是因為考慮直徑上一個點 \(u\) 和與其相連的不在直徑上的兒子 \(v\),如果斷開的邊在 \(v\) 這棵子樹裡,獲得了一條直徑,那麼這條直徑同樣在斷開連線 \(u\)\(v\) 這條邊後 \(v\) 的子樹裡,故刪除和直徑連線的這條邊不劣

有了如上兩個結論,實現方法呼之欲出。考慮先將原樹直徑“拉下來”,樹的其他部分“掛”在這條直徑上(詳見下圖),發現樹上的問題變成了一個類似序列上的問題,簡單了許多。從右向左遍歷直徑上相鄰的點對 \((u, v)\),刪除他們之間的邊,快速求得 \(u\) 這邊和 \(v\) 這邊樹的直徑,然後統計答案。對於斷開和直徑相連的邊,暴力列舉時間複雜度不超過 \(\Theta(n)\),問題就得到解決。

接下來考慮從右向左列舉斷邊從 \((v,y)\) 變為 \((u,v)\) 的過程,兩樹直徑變化。首先對於左樹的直徑我們可以預處理出來,那麼只需要考慮多出的 \(y\) 以及它的不在直徑上的子樹對右半部分直徑產生的貢獻,和前文樹形 DP 討論方法類似,分為不經過 \(y\) 和經過 \(y\) 的直徑,具體如下:

  • 原來 \(y\) 右邊的直徑。
  • \(y\) 為根的不經過原樹直徑的直徑。
  • 對於 \(y\) 一個兒子 \(yzh\) 和它子樹裡以 \(yzh\) 為一個端點的鏈 \(yzh \rightarrow yzh'\),以及 \(y\) 右邊延伸過來的一條鏈 \(p \rightarrow y\) 組成的直徑 \(yzh' \rightarrow yzh \rightarrow y \rightarrow p\)

可以藉助下圖進行形象地理解。

對於第二點,發現可以和求左樹直徑一樣用同一個 DFS 預處理出來。

對於第三點,我們只用記 \(y\) 連出的不經過原直徑最長鏈的長度 \(f_y\),和右邊伸過來的鏈 \(R_{len}\) 和並得 \(f_y+R_{len}\)(你看看次長、次次長都不見了)。那麼,我們怎麼算得對於 \(v\)\(R_{len}'\) 呢?發現可以是目前鏈再向左延伸或者是 \(y\) 中一條長鏈連過來,故 \(R_{len}'=\max \lbrace R_{len}+1,f_y+1 \rbrace\)

分析結束,完成了對本題的求解。具體使用一遍深搜把原樹直徑“拉下來”再反著列舉斷邊,同時更新 \(R_len\)。隨後列舉斷於直徑相連的邊。最後分別求出答案要求的連線哪些邊。具體實現和細節見程式碼。

程式碼及具體實現(已略去快讀快寫,碼風清新,註釋詳盡)

1. 樹形 DP 目前最優解 rank3

//#pragma GCC optimize(3)
//#pragma GCC optimize("Ofast", "inline", "-ffast-math")
//#pragma GCC target("avx", "sse2", "sse3", "sse4", "mmx")
#include <iostream>
#include <cstdio>
#define debug(a) cerr << "Line: " << __LINE__ << " " << #a << endl
#define print(a) cerr << #a << "=" << (a) << endl
#define file(a) freopen(#a".in","r",stdin), freopen(#a".out","w",stdout)
#define main Main(); signed main(){ return ios::sync_with_stdio(0), cin.tie(0), Main(); } signed Main
using namespace std;

struct node{
	int to, nxt;
} edge[500010 << 1];
int eid, head[500010];
void add(int u, int v){
	edge[++eid] = node({v, head[u]});
	head[u] = eid;
}

int n;

int kmin =  0x3f3f3f3f, x1min, y1min, x2min, y2min;
int kmax = -0x3f3f3f3f, x1max, y1max, x2max, y2max;

// f[i]        表示以 i 為子樹的直徑長度
// d[i][0/1/2] 表示表示 i 向其子樹連出的最長鏈、次長鏈、次次長鏈的長度
// w[i][0/1]   表示 i 所有子樹中的最長直徑(也就是不跨過 i 的最長直徑)
// chain[i]    表示 fa[i] 方向連過來的最長鏈的長度
int f[500010], d[500010][3], w[500010][2], chain[500010];

void Dfs(int now, int fa){
	for (int i = head[now]; i; i = edge[i].nxt){
		int to = edge[i].to;
		if (to == fa) continue;
		Dfs(to, now);
		
		f[now] = max<int, int, int>(f[now], f[to], d[now][0] + d[to][0] + 1);
		// 樹形 DP 求直徑
		
		if (d[to][0] + 1 > d[now][0])      d[now][2] = d[now][1], d[now][1] = d[now][0], d[now][0] = d[to][0] + 1;
		else if (d[to][0] + 1 > d[now][1]) d[now][2] = d[now][1], d[now][1] = d[to][0] + 1;
		else if (d[to][0] + 1 > d[now][2]) d[now][2] = d[to][0] + 1;
		// d[to][0] + 1 就是 now 向 to 連出的最長鏈的長度,用其更新 now 的最長鏈、次長鏈、次次長鏈的長度
		// 如果有兩條相同的最長鏈,我們把一個看做次長鏈,就避免了冗長的分類討論
		
		if (f[to] > w[now][0])      w[now][1] = w[now][0], w[now][0] = f[to];
		else if (f[to] > w[now][1]) w[now][1] = f[to];
		// 更新 i 所有子樹中的最長直徑
	}
}

int g[500010];

void redfs(int now, int fa){
	if (fa != 0){
		// 不是根節點就嘗試斷開 now 和 fa 之間的邊
		
		if (kmax < g[now] + f[now] + 1) kmax = g[now] + f[now] + 1, x1max = fa, y1max = now;
		// 求最長直徑
		
		int len = max<int, int, int>(f[now], g[now], (f[now] + 1) / 2 + (g[now] + 1) / 2 + 1);
		if (kmin > len) kmin = len, x1min = fa, y1min = now;
		// 求最短直徑
	}
	for (int i = head[now]; i; i = edge[i].nxt){
		int to = edge[i].to;
		if (to == fa) continue;
		
		chain[to] = chain[now] + 1;  // 新的鏈是 fa[now] -> now 的基礎上連上了 now -> to
		g[to] = g[now];  // 對應第一種情況
		
		if (d[to][0] + 1 == d[now][0]){
			g[to] = max<int, int, int>(g[to], chain[now] + d[now][1], d[now][1] + d[now][2]);
			chain[to] = max(chain[to], d[now][1] + 1);
		} else if (d[to][0] + 1 == d[now][1]){
			g[to] = max<int, int, int>(g[to], chain[now] + d[now][0], d[now][0] + d[now][2]);
			chain[to] = max(chain[to], d[now][0] + 1);
		} else {
			g[to] = max<int, int, int>(g[to], chain[now] + d[now][0], d[now][0] + d[now][1]);
			chain[to] = max(chain[to], d[now][0] + 1);
		}
		// 判斷鏈長是不是最長鏈,次長鏈、次次長鏈,可以畫圖輔助理解
		
		if (f[to] == w[now][0]) g[to] = max(g[to], w[now][1]);
		else g[to] = max(g[to], w[now][0]);
		// 對應第二種情況
		
		redfs(to, now);
	}
}

int pre[500010], dis[500010], mxpos;
void dfs(int now, int fa, int skip = -1){
	if (dis[now] > dis[mxpos]) mxpos = now;
	for (int i = head[now]; i; i = edge[i].nxt){
		int to = edge[i].to;
		if (to != fa && to != skip){
			dis[to] = dis[now] + 1, pre[to] = now;
			dfs(to, now, skip);
		}
	}
}

int Diameter[500010], Dlen;
bool InDiameter[500010];
void GetDiameter(int u = 1, int v = -1){
	int p = -1, now = -1;
	mxpos = u, dis[u] = 0, pre[u] = -1, dfs(u, 0, v), p = mxpos;
	mxpos = p, dis[p] = 0, pre[p] = -1, dfs(p, 0, v), now = mxpos;
	for (int i = 1; i <= n; ++i) InDiameter[i] = false;
	for (Dlen = 0; ~now; InDiameter[now] = true, Diameter[++Dlen] = now, now = pre[now]);
}
// 搜直徑並把直徑“拉下來”

int GetNodeOfDiameter(int u = 1, int v = -1){
	return mxpos = u, dis[u] = 0, pre[u] = -1, dfs(u, 0, v), mxpos;
}
// 獲取直徑的一端

signed main(){
	read(n);
	for (int i = 1, u, v; i <= n - 1; ++i) read(u, v), add(u, v), add(v, u);
	
	Dfs(1, 0), redfs(1, 0);
	
	GetDiameter(x1min, y1min), x2min = Diameter[(Dlen + 1) / 2];
	GetDiameter(y1min, x1min), y2min = Diameter[(Dlen + 1) / 2];
	
	x2max = GetNodeOfDiameter(x1max, y1max);
	y2max = GetNodeOfDiameter(y1max, x1max);
	
	write(kmin, ' ', x1min, ' ', y1min, ' ', x2min, ' ', y2min, '\n');
	write(kmax, ' ', x1max, ' ', y1max, ' ', x2max, ' ', y2max, '\n');
	return 0;
}

2. 在原直徑上 DP 目前最優解 rank1

//#pragma GCC optimize(3)
//#pragma GCC optimize("Ofast", "inline", "-ffast-math")
//#pragma GCC target("avx", "sse2", "sse3", "sse4", "mmx")
#include <iostream>
#include <cstdio>
#define debug(a) cerr << "Line: " << __LINE__ << " " << #a << endl
#define print(a) cerr << #a << "=" << (a) << endl
#define file(a) freopen(#a".in","r",stdin), freopen(#a".out","w",stdout)
#define main Main(); signed main(){ return ios::sync_with_stdio(0), cin.tie(0), Main(); } signed Main
using namespace std;

struct node{
	int to, nxt;
} edge[500010 << 1];
int eid, head[500010];
void add(int u, int v){
	edge[++eid] = node({v, head[u]});
	head[u] = eid;
}

int n;

int kmin =  0x3f3f3f3f, x1min, y1min, x2min, y2min;
int kmax = -0x3f3f3f3f, x1max, y1max, x2max, y2max;

int pre[500010], dis[500010], mxpos;
void dfs(int now, int fa, int skip = -1){
	if (dis[now] > dis[mxpos]) mxpos = now;
	for (int i = head[now]; i; i = edge[i].nxt){
		int to = edge[i].to;
		if (to != fa && to != skip){
			dis[to] = dis[now] + 1, pre[to] = now;
			dfs(to, now, skip);
		}
	}
}

int Diameter[500010], Dlen;
bool InDiameter[500010];
void GetDiameter(int u = 1, int v = -1){
	int p = -1, now = -1;
	mxpos = u, dis[u] = 0, pre[u] = -1, dfs(u, 0, v), p = mxpos;
	mxpos = p, dis[p] = 0, pre[p] = -1, dfs(p, 0, v), now = mxpos;
	for (int i = 1; i <= n; ++i) InDiameter[i] = false;
	for (Dlen = 0; ~now; InDiameter[now] = true, Diameter[++Dlen] = now, now = pre[now]);
}
// 搜直徑並把直徑“拉下來”

int GetNodeOfDiameter(int u = 1, int v = -1){
	return mxpos = u, dis[u] = 0, pre[u] = -1, dfs(u, 0, v), mxpos;
}
// 獲取直徑的一端

// f[i] 表示 i 向非直徑連出的最長鏈長度
// g[i] 表示 i 子樹的直徑
int f[500010], g[500010];
void TreeDP(int now, int fa){
	for (int i = head[now]; i; i = edge[i].nxt){
		int to = edge[i].to;
		if (to != fa){
			TreeDP(to, now);
			if (InDiameter[to]) continue;  // 這句話很巧妙地做到了分別以直徑上的每個結點往直徑外搜尋
			g[now] = max<int, int, int>(g[now], g[to], f[to] + 1 + f[now]);
			f[now] = max(f[now], f[to] + 1);
			// 說明 to 不是直徑上的結點,更新最長鏈和直徑
		}
	}
}

int p[500010];

signed main(){
	read(n);
	for (int i = 1, u, v; i <= n - 1; ++i) read(u, v), add(u, v), add(v, u);
	
	GetDiameter(), TreeDP(Diameter[1], 0);
	// 先把直徑拉下來
	
	for (int i = 1, now = 0; i <= Dlen; ++i){
		// 這裡 i 表示把直徑拉下來後第 i 個直徑結點
		// 正著掃,p[i] 表示字首直徑
		// 考慮新增部分的貢獻,可能直徑完整在 i 的的子樹裡,即 g[Diameter[i]]
		// 也可能是之前連向 i 的最長鏈和 i 向子樹連出的最長鏈
		p[i] = max<int, int, int>(p[i - 1], g[Diameter[i]], now + f[Diameter[i]]);
		// 這裡的 now 就是維護連到 i 的最長鏈的長度
		// 可能是之前那條鏈再向右延伸,或者是從 i 的子樹裡連過來
		now = max(now + 1, f[Diameter[i]] + 1);
	}
	
	// 接下來倒著掃一遍,嘗試刪除直徑上 i - 1 號點和第 i 號點之間的邊
	// 同樣用 Rlen 記錄右半部分的直徑長度
	// 用 now 記錄從右邊連向 i 的最長鏈的長度
	for (int i = Dlen, Rlen = 0, now = 0; i - 1 >= 1; --i){
		Rlen = max<int, int, int>(Rlen, g[Diameter[i]], now + f[Diameter[i]]);
		now = max(now + 1, f[Diameter[i]] + 1);
		// 同前面的維護
		
		int len = max<int, int, int>(p[i - 1], Rlen, (Rlen + 1) / 2 + (p[i - 1] + 1) / 2 + 1);
		if (len < kmin) kmin = len, x1min = Diameter[i], y1min = Diameter[i - 1];
		// 維護最小直徑
		
		if (Rlen + 1 + p[i - 1] > kmax) kmax = Rlen + 1 + p[i - 1], x1max = Diameter[i], y1max = Diameter[i - 1];
		// 維護最長直徑
	}
	
	// 為了獲得最長直徑,我們還要把和直徑相連的邊都嘗試斷一遍
	// 這裡直接列舉直徑上的點,在列舉它連出的邊
	for (int i = 1; i <= Dlen; ++i) for (int j = head[Diameter[i]]; j; j = edge[j].nxt){
		int to = edge[j].to;
		if (!InDiameter[to]){
			// 更新最長直徑
			if (Dlen + g[to] > kmax) kmax = Dlen + g[to], x1max = Diameter[i], y1max = to;
		}
	}
	
	// 最後求得具體方案
	GetDiameter(x1min, y1min), x2min = Diameter[(Dlen + 1) / 2];
	GetDiameter(y1min, x1min), y2min = Diameter[(Dlen + 1) / 2];
	
	x2max = GetNodeOfDiameter(x1max, y1max);
	y2max = GetNodeOfDiameter(y1max, x1max);
	
	write(kmin, ' ', x1min, ' ', y1min, ' ', x2min, ' ', y2min, '\n');
	write(kmax, ' ', x1max, ' ', y1max, ' ', x2max, ' ', y2max, '\n');
	return 0;
}

相關文章