0504区间DP和树形DP

05.04 区间DP和树形DP

区间动态规划:线性 DP 的一种,简称为「区间 DP」。以「区间长度」划分阶段,以两个坐标(区间的左、右端点)作为状态的维度。一个状态通常由被它包含且比它更小的区间状态转移而来。

区间 DP 的主要思想就是:先在小区间内得到最优解,再利用小区间的最优解合并,从而得到大区间的最优解,最终得到整个区间的最优解。

根据小区间向大区间转移情况的不同,常见的区间 DP 问题可以分为两种:

  • 单个区间从中间向两侧更大区间转移的区间 DP 问题:$dp[i][j] = max \lbrace dp[i + 1][j - 1], \quad dp[i + 1][j], \quad dp[i][j - 1] \rbrace + cost[i][j], \quad i \le j$
    1. 枚举区间的起点;
    2. 枚举区间的终点;
    3. 根据状态转移方程计算从小区间转移到更大区间后的最优值。
  • 多个(大于等于2个)小区间转移到大区间的区间 DP 问题:$dp[i][j] = max / min \lbrace dp[i][k] + dp[k + 1][j] + cost[i][j] \rbrace, \quad i < k \le j$
    1. 枚举区间长度;
    2. 枚举区间的起点,根据区间起点和区间长度得出区间终点;
    3. 枚举区间的分割点,根据状态转移方程计算合并区间后的最优值。

最长回文子序列

分析

  1. 划分阶段:按照区间长度进行阶段划分。
  2. 定义状态:dp[i][j]表示s在[i,j]范围内的最长回文子序列长度。
  3. 状态转移方程:$dp[i][j] = \begin{cases} max \lbrace dp[i + 1][j - 1] + 2 \rbrace & s[i] = s[j] \cr max \lbrace dp[i][j - 1], dp[i - 1][j] \rbrace & s[i] \ne s[j] \end{cases}$
  4. 初始条件:单个字符的最长回文序列是1,dp[i][i] = 1
  5. 返回结果:dp[i][j]依赖于dp[i+1][j-1]dp[i+1][j]dp[i][j-1],所以i应该从n-1到0枚举,而j应该从i+1到n-1枚举。所以最终应该返回dp[0][n-1]
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
class Solution {
public:
int longestPalindromeSubseq(string s) {
int n = s.length();
// 定义状态
vector<vector<int>> dp(n, vector<int>(n));
// 初始化单个字符串的最长回文子序列长度
for(int i = 0; i < n; i++){
dp[i][i] = 1;
}
// 枚举起点
for(int i = n-1; i >= 0; i--){
// 枚举终点
for(int j = i+1; j < n; j++){
if(s[i] == s[j]){
dp[i][j] = dp[i+1][j-1] + 2;
}
else{
dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
}
}
}
return dp[0][n-1];
}
};
  • 时间复杂度:$O(n^2)$
  • 空间复杂度:$O(n^2)$

戳气球

分析

在首尾加上两个气球,金币数为1,方便计算。

  1. 划分阶段:按照区间长度进行阶段划分。
  2. 定义状态:dp[i][j]表示戳破所有(i,j)之间的气球所获得的最大金币数。
  3. 状态转移方程:假设气球i与气球j之间最后一个被戳破的气球编号为dp[i][j] = dp[i][k] + dp[k][j] + cost(k)cost(k) = max(nums[i] × nums[k] × nums[j]), i < k < j
  4. 初始条件:dp[i][j]表示的是开区间,则i < j-1,当i >= j-1, dp[i][j] = 0
  5. 返回结果:dp[0][n+1]。
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
class Solution {
public:
int maxCoins(vector<int>& nums) {
int n = nums.size();
vector<int> arr(n + 2);
arr[0] = 1;
arr[n+1] = 1;
for(int i = 1; i <= n; i++){
arr[i] = nums[i-1];
}
// 定义状态
vector<vector<int>> dp(n+2, vector<int>(n+2));
// 枚举区间长度
for(int len = 3; len <= n+2; len++){
// 枚举起点
for(int i = 0; i < n+2; i++){
// 计算终点
int j = i + len - 1;
// 终点超过边界
if(j >= n + 2){
break;
}
// 枚举中间值k
for(int k = i + 1; k <= j-1; k++){
dp[i][j] = max(dp[i][j], dp[i][k] + dp[k][j] + arr[i] * arr[k] * arr[j]);
}
}
}

return dp[0][n+1];
}
};
  • 时间复杂度:$O(n^3)$
  • 空间复杂度:$O(n^2)$

切棍子的最小成本

分析

在数组中添加0和n并进行排序,形成区间。

  1. 划分阶段:按照区间长度进行阶段划分。
  2. 定义状态:dp[i][j]表示切割[i,j]上的小木棍的最小成本。
  3. 状态转移方程:$dp[i][j] = min \lbrace dp[i][k] + dp[k][j] + cuts[j] - cuts[i] \rbrace, \quad i < k < j$
  4. 初始条件:相邻位置之间没有切割点,不需要切割,最小成本为0,即dp[i-1][i]=0。其余位置默认为最小成本为一个极大值。
  5. 返回结果:dp[0][n-1]。
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
class Solution {
public:
int minCost(int n, vector<int>& cuts) {
// 处理成区间
cuts.push_back(0);
cuts.push_back(n);
sort(cuts.begin(), cuts.end());
int size = cuts.size();
// 定义状态
vector<vector<int>> dp(size, vector<int>(size));
// 初始化相邻位置之间的成本为0
for(int i = 1; i < size; i++){
dp[i-1][i] = 0;
}
// 枚举区间长度
for(int len = 3; len <= size; len++){
// 枚举起点
for(int i = 0; i < size; i++){
// 计算终点
int j = i + len - 1;
if(j >= size){
break;
}
// 枚举中间值K
dp[i][j] = INT_MAX;
for(int k = i+1; k < j; k++){
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + cuts[j] - cuts[i]);
}
}
}
return dp[0][size-1];
}
};
  • 时间复杂度:$O(n^3)$
  • 空间复杂度:$O(n^2)$

最长回文子串

分析

之前都是用双指针的方法做的,根据上面学习的内容,再来用动态规划做一遍。

  1. 划分阶段:按照区间长度进行阶段划分。
  2. 定义状态:dp[i][j]表示s[i:j]是否是回文的。
  3. 状态转移方程:$s[i] = s[j], dp[i][j] = \begin{cases} true & j-i \leq 2 \cr dp[i+1][j - 1] & else \end{cases}$
  4. 初始条件:dp[i][j]=false.
  5. 返回结果:s[maxStart:maxStart+maxLen]
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
class Solution {
public:
string longestPalindrome(string s) {
// 定义状态
int n = s.length();
if(n == 1){
return s;
}
vector<vector<bool>> dp(n, vector<bool>(n));
// 记录
int maxStart = 0;
int maxLen = 1;
// 枚举结束位置
for(int j = 1; j < n; j++){
// 枚举起始位置
for(int i = 0; i < j; i++){
if(s[i] == s[j]){
if(j - i <= 2){
dp[i][j] = true;
}
else{
dp[i][j] = dp[i+1][j-1];
}
}
// 更新最大长度
if(dp[i][j] && j - i + 1 > maxLen){
maxLen = j - i + 1;
maxStart = i;
}
}
}
return s.substr(maxStart, maxLen);
}
};
  • 时间复杂度:$O(n^2)$
  • 空间复杂度:$O(n^2)$

预测赢家

分析

  1. 划分阶段:按照区间长度进行阶段划分。
  2. 定义状态:dp[i][j]表示玩家1和玩家2在nums[i]…nums[j]之间选取,玩家1比玩家2多的最大分数。
  3. 状态转移方程:$dp[i][j] = \begin{cases}
    nums[i] & i == j \cr \max \lbrace nums[i]-dp[i+1][j], nums[j]-dp[i][j-1] \rbrace & i \neq j
    \end{cases}$
  4. 初始条件:如果i == jdp[i][j] = nums[i]
  5. 返回结果:dp[0][n-1]
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
class Solution {
public:
bool predictTheWinner(vector<int>& nums) {
// 定义状态
int n = nums.size();
vector<vector<int>> dp(n, vector<int>(n));
// 枚举区间长度
for(int len = 1; len <= n; len++){
// 枚举起点
for(int i = 0; i < n; i++){
// 计算终点
int j = i + len - 1;
// 超出边界
if(j >= n){
break;
}
// 如果i==j
if(len == 1){
dp[i][j] = nums[i];
}
else{
dp[i][j] = max(nums[i]-dp[i+1][j], nums[j]-dp[i][j-1]);
}
}
}
return dp[0][n-1] >= 0;
}
};
  • 时间复杂度:$O(n^2)$
  • 空间复杂度:$O(n^2)$

奇怪的打印机

分析

  1. 划分阶段:按照区间长度进行阶段划分。
  2. 定义状态:dp[i][j]表示打印i…j之间字符所需要的最少操作次数。
  3. 状态转移方程:$dp[i][j] = \begin{cases}
    1 & i == k \cr dp[i][j-1] & s[i] == s[j] \cr \min \lbrace dp[i][k] + dp[k+1][j] \rbrace & s[i] \neq s[j], i < k < j
    \end{cases}$
  4. 初始条件:如果i == j,则dp[i][j] = 1,否则初始化为INT_MAX.
  5. 返回结果:dp[0][n-1]。
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
class Solution {
public:
int strangePrinter(string s) {
// 定义状态
int n = s.length();
vector<vector<int>> dp(n, vector<int>(n));
// 初始化
for(int i = 0; i < n; i++){
dp[i][i] = 1;
}
// 枚举区间长度
for(int len = 2; len <= n; len++){
// 枚举起点
for(int i = 0; i < n; i++){
// 计算终点
int j = i + len - 1;
// 超出边界
if(j >= n){
break;
}
// 初始化
dp[i][j] = 100;
// 如果相等
if(s[i] == s[j]){
dp[i][j] = dp[i][j-1];
continue;
}
// 否则枚举
for(int k = i; k < j; k++){
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j]);
}
}
}
return dp[0][n-1];
}
};
  • 时间复杂度:$O(n^3)$
  • 空间复杂度:$O(n^2)$

合并石头的最低成本

动态规划+前缀和

先将无法将所有的石头合并成一堆的情况排除出去:每次将k堆合并成1堆,堆数会减少k-1堆。当$(n-1) \bmod (k-1) == 0$,说明可以将所有的石头合并成一堆,否则不能。

使用前缀和的方法计算出前i堆石头成本。

  1. 划分阶段:按照区间长度划分。
  2. 定义状态:dp[i][j][m]表示将区间[i,j]的石堆合并成m堆需要的最少成本,m取值为[1,k]。
  3. 状态转移方程:
    • 先合并成m堆石头,$dp[i][j][m] = min_{i \le n < j} \lbrace dp[i][n][1] + dp[n + 1][j][m - 1] \rbrace$。
    • 再合并成一堆:$dp[i][j][1] = dp[i][j][k] + \sum_{t = i}^{t = j} stones[t]$
  4. 初始条件:dp[i][i][1] = 0
  5. 返回结果:dp[0][size-1][1]
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
class Solution {
public:
int mergeStones(vector<int>& stones, int k) {
// 排除情况
int size = stones.size();
if((size-1) % (k-1) != 0){
return -1;
}
// 计算前缀和
vector<int> prefix(size + 1);
for(int i = 1; i <= size; i++){
prefix[i] = prefix[i-1] + stones[i-1];
}
// 定义状态
vector<vector<vector<int>>> dp(size, vector<vector<int>>(size, vector<int>(k+1, 10000000)));
// 初始化区间为1的情况
for(int i = 0; i < size; i++){
dp[i][i][1] = 0;
}
// 枚举区间
for(int len = 2; len <= size; len++){
// 枚举起点
for(int i = 0; i < size; i++){
// 计算终点
int j = i + len - 1;
// 超出边界
if(j >= size){
break;
}
// 将石头合并成m堆
for(int m = 2; m <= k; m++){
// 以k步枚举分割点n
for(int n = i; n < j; n += k-1){
dp[i][j][m] = min(dp[i][j][m], dp[i][n][1] + dp[n+1][j][m-1]);
}
}
// 合成一堆
dp[i][j][1] = dp[i][j][k] + prefix[j+1] - prefix[i];
}
}
return dp[0][size-1][1];
}
};
  • 时间复杂度:$O(n^3 \times k)$
  • 空间复杂度:$O(n^2 \times k)$

动态规划 + 状态优化

上一种思路中,dp[i][j][m]表示将[i,j]区间的石头合并成m堆,m的取值为[1,k]。初始时,[i,j]内的堆数为j-i+1堆,每次合并都会减少k-1堆,合并到无法合并时的堆数固定为$(j-i) \bmod (k-1) + 1$,因此定义dp[i][j]为合并到无法合并时的最低成本。

  1. 划分阶段:按照区间长度进行阶段划分。
  2. 定义状态:dp[i][j]为合并到无法合并时的最低成本。
  3. 状态转移方程:枚举n,将区间[i,j]拆分成[i,n]和[n+1,j]两个区间,$dp[i][j] = min_{i \le n < j} \lBrace dp[i][n] + dp[n+1][j] \rBrace$,如果$(j-i) \bmod (k-1) == 0$,说明区间可以合并成一堆,dp[i][j] += prefix[j+1] - prefix[i]
  4. 初始条件:长度为1的区间合并到无法合并时的最低成本为0,dp[i][i] = 0
  5. 返回结果:dp[0][size-1]
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
class Solution {
public:
int mergeStones(vector<int>& stones, int k) {
// 排除情况
int size = stones.size();
if((size-1) % (k-1) != 0){
return -1;
}
// 计算前缀和
vector<int> prefix(size + 1);
for(int i = 1; i <= size; i++){
prefix[i] = prefix[i-1] + stones[i-1];
}
// 定义状态
vector<vector<int>> dp(size, vector<int>(size, 10000000));
// 初始化区间为1的情况
for(int i = 0; i < size; i++){
dp[i][i] = 0;
}
// 枚举区间长度
for(int len = 2; len <= size; len++){
// 枚举起点
for(int i = 0; i < size; i++){
// 计算终点
int j = i + len -1;
// 超出边界
if(j >= size){
break;
}
// 枚举分割点
for(int n = i; n < j; n+=k-1){
dp[i][j] = min(dp[i][j], dp[i][n] + dp[n+1][j]);
}
// 区间可以合并成一堆
if((j-i) % (k-1) == 0){
dp[i][j] += prefix[j+1] - prefix[i];
}
}
}
return dp[0][size-1];
}
};
  • 时间复杂度:$O(n^3)$
  • 空间复杂度:$O(n^2)$

区间DP剩余题目

树形DP

固定根的树形 DP 问题,如果是二叉树,树通常是以根节点的形式给出。我们可以直接从指定根节点出发进行深度优先搜索。如果是多叉树,树是以一张n个节点、n - 1条边的无向图形式给出的,并且事先给出指定根节点的编号。这种情况下,我们要先用邻接表存储下这n个点和n-1条边,然后从指定根节点出发进行深度优先搜索,并注意标记节点是否已经被访问过,以避免在遍历中沿着反向边回到父节点。

二叉树中的最大路径和

分析

该二叉树的最大路径和 = max(左子树中最大贡献值 + 右子树中最大贡献值 + 当前节点值,所有子树中最大路径和)。这道题在学递归算法的时候做过,再做一遍加深印象。

相邻字符不同的最长路径

分析

最长路径长度 = max(某子树中的最长路径长度 + 另一子树中的最长路径长度 + 1,某个子树中的最长路径长度)。

  1. 先计算出从相邻节点v出发的最长路径长度vLen。
  2. 更新维护全局最长路径长度为res = max(res, uLen + vLen + 1)
  3. 更新维护当前节点最长路径长度uLen = max(uLen, vLen + 1)

因为题目限定了「相邻节点字符不同」,所以在更新全局最长路径长度和当前节点u的最长路径长度时,我们需要判断一下节点
u与相邻节点v的字符是否相同,只有在字符不同的条件下,才能够更新维护。

题目要求的是树的直径(树的最长路径长度)中的节点个数,节点个数=路径长度+1.

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
class Solution {
public:
int res = 0;
int longestPath(vector<int>& parent, string s) {
// 根据 parent 数组,建立有向图
int n = parent.size();
vector<vector<int>> graph(n);
for(int i = 1; i < n; i++){
graph[parent[i]].push_back(i);
}
// 递归
dfs(0, graph, s);
return res + 1;
}
int dfs(int u, vector<vector<int>>& graph, string& s){
int uLen = 0; //u 节点的最大路径长度
// 遍历 u 节点的相邻节点
for(int v : graph[u]){
int vLen = dfs(v, graph, s); // 相邻节点的最大路径长度
// 如果相邻字符不同,更新结果和当前节点最大路径长度
if(s[u] != s[v]){
res = max(res, uLen + vLen + 1);
uLen = max(uLen, vLen + 1);
}
}
return uLen;
}
};
  • 时间复杂度:$O(n)$
  • 空间复杂度:$O(n)$

[!NOTE]
在函数参数中传值和传引用有很大的区别,在这道题中,如果dfs函数按值传递,即dfs(string s)会超出内存限制,这是因为每次递归调用时都要复制字符串。而传引用,即dfs(string& s)可以节省大量内存,特别是当字符串比较长时。

「两次扫描与换根法」:

  1. 第一次扫描时,任选一个节点为根,在「有固定根的树」上执行一次树形 DP,预处理树的一些相关信息。
  2. 第二次扫描时,从刚才的根节点出发,对整棵树再执行一次深度优先搜索,同时携带根节点的一些信息提供给子节点进行推导,计算出「换根」之后的解。

最小高度树

分析

  1. 图的构建:用邻接表graph存储每个节点的邻居节点。
  2. 自底向上DFS,计算每个节点向下的最长路径:
    • dfs函数实现了一次深度优先搜索(DFS),用于从叶子节点开始向上收集信息,即从子节点往父节点传递最长路径信息。
    • 定义两个数组down1和down2,分别记录从每个节点出发向下走的最长路径和次长路径。
    • 通过比较每个子节点的高度(即down1[v] + 1),更新当前节点的down1和down2,即节点向下走的最长路径和次长路径。同时,用p[u]记录下这个最长路径是由哪个子节点传上来的。
  3. 自顶向下DFS,计算每个节点向上的最长路径:
    • 完成自底向上的DFS后,接着要计算每个节点向上的最长路径,即如果该节点作为根节点,其向父节点方向可以走多远。定义一个数组up来记录这一信息。
    • reroot函数实现自顶向下的动态规划。对于每个节点u的每个子节点v:
      • 如果v是当前节点u的最长路径的子节点(即p[u] == v),则up[v]从u向上走的最长路径要结合down2[u](次长路径)来计算。
      • 如果v不是最长路径的子节点,up[v]从u向上走的路径可以结合down1[u](最长路径)来计算。
  4. 计算每个节点的最大高度:
  • 对于每个节点,树的高度就是它的最大高度,也就是它向下的最长路径down1[i]和向上的最长路径up[i]的最大值。
  • 找到所有节点中具有最小高度的那些节点。
  1. 最后,将所有具有最小高度的节点放入结果数组中并返回。
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
class Solution {
public:
vector<int> findMinHeightTrees(int n, vector<vector<int>>& edges) {
// 邻接表构建图
vector<vector<int>> graph(n);
for(auto& edge : edges){
int u = edge[0];
int v = edge[1];
graph[u].push_back(v);
graph[v].push_back(u);
}
// down1 用于记录向下走的最长路径
vector<int> down1(n);
// down2 用于记录向下走的次长路径
vector<int> down2(n);
// p[u]记录下这个最长路径是由哪个子节点传上来的
vector<int> p(n);

// 自底向上记录最长路径、次长路径
function<void(int, int)> dfs = [&](int u, int fa){
// 遍历所有邻接节点
for(int v : graph[u]){
if(v == fa){
continue;
}
// 自底向上统计信息
dfs(v, u);
int height = down1[v] + 1; // 当前节点经过v的最长路径
// 更新
if(height > down1[u]){
down2[u] = down1[u];
down1[u] = height;
p[u] = v;
}
else if(height > down2[u]){
down2[u] = height;
}
}
};

// 进行换根动态规划,自顶向下统计向上走的最长路径
vector<int> up(n);
function<void(int,int)> reroot = [&](int u, int fa){
// 遍历u的所有邻接节点
for(int v : graph[u]){
if(v == fa){
continue;
}
// 如果u的向下最长路径经过v
if(p[u] == v){
up[v] = max(up[u], down2[u]) + 1;
}
else{
up[v] = max(up[u], down1[u]) + 1;
}
// 自顶向下统计信息
reroot(v, u);
}
};
// 自底向上统计信息
dfs(0, -1);
// 自顶向下统计信息
reroot(0, -1);
// 找到所有树中的最小高度
int minHeight = INT_MAX;
for(int i = 0; i < n; i++){
minHeight = min(minHeight, max(down1[i], up[i]));
}
// 将所有最小高度根加入数组
vector<int> res;
for(int i = 0; i < n; i++){
int h = max(down1[i], up[i]);
if(h == minHeight){
res.push_back(i);
}
}
return res;
}
};
  • 时间复杂度:$O(n)$
  • 空间复杂度:$O(n)$

[!WARNING]
这道题按照给的步骤思路写出来了,但是不是特别懂,以后再看看。

练习题目

最长同值路径

分析

二叉树的直径 = max(左子树高度+右子树高度,所有子树最大直径长度)。

在递归遍历左子树时,如果当前节点与左子树的值相同,则:$\text{包含当前节点向左的最长同值路径长度} = \text{左子树最长同值路径长度} + 1$,否则为 0。

在递归遍历左子树时,如果当前节点与左子树的值相同,则:$\text{包含当前节点向右的最长同值路径长度} = \text{右子树最长同值路径长度} + 1$,否则为0。

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
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
int res = 0;
int longestUnivaluePath(TreeNode* root) {
dfs(root);
return res;
}
int dfs(TreeNode* root){
// 如果root为空
if(!root){
return 0;
}
// 递归计算左右子树
int leftLen = dfs(root->left);
int rightLen = dfs(root->right);
// 判断和当前节点值是否相等
if(root->left && root->left->val == root->val){
leftLen++;
}
else{
leftLen = 0;
}
if(root->right && root->right->val == root->val){
rightLen++;
}
else{
rightLen = 0;
}
// 更新结果
res = max(res, leftLen + rightLen);
return max(leftLen, rightLen);
}
};
  • 时间复杂度:$O(n)$
  • 空间复杂度:$O(n)$

统计子树中城市之间最大距离

分析

  1. 通过二进制枚举的方式,得到所有子树。
  2. 对于当前子树,通过树形 DP + 深度优先搜索的方式,计算出当前子树的直径。
  3. 统计所有子树直径中经过的不同边数个数,将其放入答案数组中。
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
class Solution {
public:
vector<int> countSubgraphsForEachDiameter(int n, vector<vector<int>>& edges) {
// 构建图
vector<vector<int>> graph(n);
for(auto& edge : edges){
int u = edge[0] - 1;
int v = edge[1] - 1;
graph[u].push_back(v);
graph[v].push_back(u);
}

int visited = 0, diameter = 0; // visited 位掩码和当前子图的直径
vector<int> ans(n - 1, 0); // 用于存储结果

// 深度优先搜索函数
function<int(int, int)> dfs = [&](int mask, int u) -> int{
visited |= (1 << u); // 标记 u 访问过
int uLen = 0; // u 节点的最大路径长度
// 遍历 u 的相邻节点
for(int v : graph[u]){
// 如果 v 未被访问且在子集中
if(((visited >> v) & 1) == 0 && ((mask >> v) & 1)){
// 递归计算 v 节点的最大路径长度
int vLen = dfs(mask, v);
// 更新
diameter = max(diameter, uLen + vLen + 1);
uLen = max(uLen, vLen + 1);
}
}
return uLen;
};
// 二进制枚举子集
for(int mask = 3; mask < (1 << n); mask++){
// 排除只有一个节点被选择的情况
if(__builtin_popcount(mask) <= 1){
continue;
}
visited = 0;
diameter = 0;
// 找到子集中最高位的节点
int u = 31 - __builtin_clz(mask);
// 计算子集的直径
dfs(mask, u);
// 如果子集内所有节点都被访问,更新对应直径的计数
if (visited == mask){
ans[diameter-1]++;
}
}
return ans;
}
};
  • 时间复杂度:$O(n \times 2^n)$
  • 空间复杂度:$O(n)$

树中距离之和

分析

第一次遍历:从编号为0的根节点开始,自底向上地计算出节点0到其他的距离之和,记录在 res[0]中。并且统计出以子节点为根节点的子树节点个数size[v]。

第二次遍历:从编号为0的根节点开始,自顶向下地枚举每个点,计算出将每个点作为新的根节点时,其他节点到根节点的距离之和。如果当前节点为v,其父节点为u,则自顶向下计算出res[u]后,将根节点从u换为节点v,子树上的点到新根节点的距离比原来都小了1,非子树上剩下所有点到新根节点的距离比原来都大了1。则可以据此计算出节点v与其他节点的距离和为:$res[v] = res[u] + n - 2 \times sizes[u]$。


0504区间DP和树形DP
http://example.com/2024/10/05/posts/0504区间DP和树形DP/
作者
Xuan Yang
发布于
2024年10月5日
许可协议