leetcode 周赛 481 - 树形DP

作者: | 更新日期:

树形DP出现的频率好高

本文首发于公众号:天空的代码世界,微信号:tiankonguse

零、背景

这次比赛最后一题是经典的树形DP,整体难度不算高,本篇主要记录四道题的解题思路。

A: 简单计算
B: 枚举统计
C: 贪心+优先队列
D: 树形DP

下面按题号依次简单记录一下解题思路。

最终排名:124
代码仓库https://github.com/tiankonguse/leetcode-solutions

一、整数的镜像距离

题目:给一个数字,求 abs(n - reverse(n))

思路:按题意翻转与求差值的绝对值即可。

二、使所有字符相等的最小删除代价

题意:字符串的每个字符有一个删除代价,问如何使用最小代价删除字符,使得所有字符相等。

思路:枚举统计

枚举剩余的字符,计算删除代价,取最小值。

剩余的字符都相等,显然,与最后保留的字符相等的字符一个都不能删除。
换言之,与最后保留字符不相等的字符都需要删除。

计算方法是使用总代价减去剩余字符的代价,就是删除的代价。

vector<ll> stat(26, 0);
ll sum = 0;
for (int i = 0; i < n; i++) {
  stat[s[i] - 'a'] += cost[i];
  sum += cost[i];
}
ll ans = sum;
for (int i = 0; i < 26; i++) {
  ans = min(ans, sum - stat[i]);
}
return ans;

三、避免禁用值的最小交换次数

题意:给 n 个二元组,每次可以选择两个二元组交换第一个数字,问最少需要几次交换,使得所有二元组的两个数字都不相等。

思路:贪心+优先队列

首先,需要将二元组相等的数字进行统计,得到每个数字对应的二元组有多少个。

unordered_map<int, int> H1;
for (int i = 0; i < n; i++) {
  int a = A[i], b = B[i];
  if (a == b) {
    H1[a]++;
  }
}
if (H1.empty()) return 0;

贪心方法:优先选择二元组数量最多的两个数字进行交换。

max_queue<pair<int, int>> que;
for (auto [val, cnt] : H1) {
  que.push({cnt, val});
}

while (que.size() >= 2) {
  auto [cnt1, val1] = que.top();
  que.pop();
  auto [cnt2, val2] = que.top();
  que.pop();
  cnt1--;
  cnt2--;
  if (cnt1 > 0) que.push({cnt1, val1});
  if (cnt2 > 0) que.push({cnt2, val2});
  ans++;
}
if (que.empty()) return ans;

最后如果还有二元组 lastCnt, lastVal,再从不相等的二元组里面去尝试交换。
这时候需要满足二元组的值都不等于 lastVal

for (int i = 0; i < n; i++) {
  int a = A[i], b = B[i];
  if (a == b || a == lastVal || b == lastVal) continue;
  lastCnt--;
  ans++;
  if (lastCnt == 0) return ans;
}

return -1;

时间复杂度:O(n log n)

优化:不同的数字两两抵消,如果最后还有剩余,肯定是原先次数最多的那个数字。
所以不需要使用优先队列来模拟,直接根据计数判断是否可以全部抵消即可。

什么时候可以全部抵消?
当其他数字的个数大于等于次数最多数字的个数时,就可以全部抵消。

// 计算总个数与次数最多的数字的个数
int sameSum = 0;
int maxVal = 0;
int maxCnt = 0;
for (auto [val, cnt] : H1) {
  sameSum += cnt;
  if (cnt > maxCnt) {
    maxCnt = cnt;
    maxVal = val;
  }
}

// 可以两两消除
if (maxCnt <= sameSum - maxCnt) { 
  return (sameSum + 1) / 2;
}


ans += (sameSum - maxCnt);
maxCnt -= (sameSum - maxCnt);

时间复杂度:O(n)

四、树上分组的交互代价总和

题意:给一棵无根树,每个节点属于一个分组。
要求所有分组内,两两节点之间路径长度(边数)的总和。

思路:树形 DP。

如果枚举同一分组内的两两节点,二元组数量是 O(n^2),再结合 LCA 计算距离,时间复杂度是 O(n^2 * log n),显然无法通过。
因此不能直接两两枚举,而是要把同一分组内的节点一起统计。

先考虑固定一棵根树,求“根节点到所有同组节点的边数之和”。
可以发现,这个量是递归可拆分的。
即先自底向上计算每个儿子子树内,各分组的节点个数和到这些节点的边数之和;设除根节点外,同一分组的节点个数为 cnt,则儿子到根节点的那一条边会被走 cnt 次,需要把它累加进去。

vector<vector<pair<int, ll>>> gg;  // <cnt, sum>
void DfsInit(int u, int p) {
  gg[group[u]][u] = {1, 0};
  for (int v : g[u]) {
    if (v == p) continue;
    DfsInit(v, u);
    for (int i = 0; i < 20; i++) {  // u 为根的答案
      const auto [vCnt, vSum] = gg[i][v];
      auto& [uCnt, uSum] = gg[i][u];
      uCnt += vCnt;
      uSum += vSum + vCnt;
    }
  }
}

再考虑把根转移到其中一个儿子上。
此时,需要把对应的儿子从根节点的答案中减去,然后把根节点当做儿子,来递归计算。

// pg 父节点作为子树的答案
void DfsDp(int u, int p, const vector<pair<int, ll>>& pg) {
  // 更新答案
  AddChild(gg[u], pg);
  ans += gg[u][group[u]].second;  // 计算 u 为根的答案
  for (int v : g[u]) {
    if (v == p) continue;
    RemoveChild(gg[u], gg[v]);
    DfsDp(v, u, gg[u]);
    AddChild(gg[u], gg[v]);
  }
  RemoveChild(gg[u], pg);
}

这里先把添加一个儿子和删除一个儿子的操作封装好,代码就简洁很多。

void AddChild(vector<pair<int, ll>>& ug, const vector<pair<int, ll>>& vg) {
  for (int i = 0; i < 20; i++) {  // u 为根,排除包括 v 的答案
    const auto [vCnt, vSum] = vg[i];
    auto& [ugCnt, ugSum] = ug[i];
    ugCnt = ugCnt + vCnt;
    ugSum = ugSum + vSum + vCnt;
  }
}
void RemoveChild(vector<pair<int, ll>>& ug, const vector<pair<int, ll>>& vg) {
  for (int i = 0; i < 20; i++) {  // u 为根,排除包括 v 的答案
    const auto [vCnt, vSum] = vg[i];
    auto& [ugCnt, ugSum] = ug[i];
    ugCnt = ugCnt - vCnt;
    ugSum = ugSum - vSum - vCnt;
  }
}

复杂度:O(20 n)

五、最后

这次比赛整体都不算难,第四题是树形 DP,能过的人比我预期多。
感觉现在树形 DP 已经有点烂大街了,大家好像都能写出来。
总之,这场周赛对树形 DP 的掌握还是一次不错的巩固。

《完》

-EOF-

本文公众号:天空的代码世界
个人微信号: tiankonguse
公众号 ID: tiankonguse-code

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越