leetcode 周赛 479 - 换根DP

作者: | 更新日期:

学习换根DP的经典题目

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

零、背景

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

A: 排序
B: 打素数表
C: 带删除的优先队列
D: 换根DP

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

一、二进制反射排序

题目:给一个数组,按每个数二进制反转后的值升序排序;如果两个数反转后的值相等,则按它们在原数组中的顺序排序。

思路:排序

按题意预处理出每个数的二进制反转值,组成二元组,然后排序即可。

翻转代码如下:

ll v2 = 0;
ll V = v;
while (V) {
  v2 = (v2 << 1) | (V & 1);
  V >>= 1;
}

二、可表示为连续质数和的最大质数

题意:给一个整数 n,求不大于 n 的最大特殊质数。
特殊质数定义:可以表示为从 2 开始的连续质数之和。

思路:打表

先预处理出数据范围内的素数表。
然后预处理出所有特殊质数表。
最后二分查找即可。

三、探索地牢的得分

题意:有一个闯关游戏,初始血量为 Hp,每一关会消耗 d 点血量,消耗后若当前血量大于等于该关的阈值 r,则得一分。
闯关不会结束,直到最后一关。
现在问分别从某一关开始闯关,合起来能得多少分。

思路:

暴力模拟,复杂度 O(n^2)
所以需要思考,怎么将这一批分数快速计算出来。

一种思路是分别计算每个起始位置的分数,然后求和。
另一种思路是分别求每一关的分数,然后求和。

算法1:枚举起始位置。

对于每个起始位置,需要判断每一关的剩余血量是否大于当前关的得分血量。
不同关的得分血量不同,比较的基线不一样,复杂度 O(n)

如果对齐基线,剩余血量减去当前关得分血量,然后判断是否大于等于 0,就可以维护一个队列来快速计数了。

vector<ll> leftHp(n + 1); // 第一关剩余的血量
map<ll, ll> mp;
leftHp[0] = hp;
for (int i = 1; i <= n; i++) {
  ll d = damage[i - 1];
  ll r = requirement[i - 1];
  leftHp[i] = leftHp[i - 1] - d;
  mp[leftHp[i] - r]++;
}

观察相邻起始位置,发现每一关剩余血量会上升相同的值。
这个相当于队列中的血量整体向上平移,大于 0 的血量会更多。
整体平移可以使用延迟标记来降低复杂度。

另外,起始位置移动后,上一个关卡需要从队列中删除,所以需要支持带删除的优先队列。
map 就是一个标准的带删除的优先队列。

复杂度分析:每个元素至多从 map 中删除 1 次,所以复杂度 O(n log(n))

ll ans = 0;
ll okNum = 0;  // 当前剩余关卡得分的个数
ll add = 0;
for (int i = 1; i <= n; i++) {
  const ll d = damage[i - 1];
  const ll r = requirement[i - 1];
  // 第一步:计算从第 i 关开始的得分
  while (!mp.empty()) {
    auto it = mp.end();
    it--;
    if (it->first + add < 0) {
      break;
    }
    okNum += it->second;  // 大于关卡分数的个数
    mp.erase(it);
  }
  ans += okNum;
  
  // 第二步:删除第 i 关
  if (leftHp[i] - r + add >= 0) {
    okNum--;  // 这个关卡已经得分了,不在队里中
  } else {
    mp[leftHp[i] - r]--;  // 一定在队列中,减少计数,统计得分时再出队
  }
  // 第三步:延迟标记,后续的血量都上升 d
  add += d;
}

算法2:枚举每一关

换一个思路,看每个起始位置在每一关有什么特征。
例如起始位置从 1 到 n,最后一关的剩余血量是递增的,我们要找大于最后一关得分血量的个数。
直接二分查找即可。

vector<ll> leftHp(n + 1);
leftHp[n] = hp; // 增加桩,方便计算
for (int i = n - 1; i >= 0; i--) {
  ll d = damage[i];
  leftHp[i] = leftHp[i + 1] - d;
}
leftHp.pop_back(); // 删除桩

最后一关转移到倒数第二关,有什么关系呢?

第一,最后一关起始位置的剩余血量需要从数组中删除,这个恰好是最大值。
第二,剩余血量都加上最后一关的伤害值。

对于第一个,直接数组大小减一即可。
对于第二个,都加上一个值 d,相当于关卡的得分血量降低了 d。
从而可以O(1) 转移,使用二分查找找到答案。

ll ans = 0;
ll sum = 0;
for (int i = n - 1; i >= 0; i--) {
  ll d = damage[i];
  ll v = requirement[i] - sum;
  // 查询大于等于 v 的个数
  ll cnt = leftHp.end() - lower_bound(leftHp.begin(), leftHp.end(), v);
  ans += cnt;
  sum += d;
  leftHp.pop_back();
}

复杂度:O(n log(n))
由于这个只有二分查找,所以常数会小很多。

四、子图的最大得分

题意:给一个树,有些节点权值是 1,有些是 -1。
求每个节点当做根的最大子图权值和。

思路:换根DP

先预处理,坏节点权值修改成 -1。

for (auto& v : good) {
  if (v == 0) v = -1;
}

有根树天然存在最优子结构。
即如果一个儿子子树中存在权值和大于 0 的子图,就可以给父节点贡献这部分权值,否则贡献为 0。
对于一个有根树,父节点的最优解,就是所有儿子的贡献之和加上自身的权值。

vector<int> childsScore;
int Dfs(const int u, const int p) {
  childsScore[u] = good[u];
  for (const auto v : g[u]) {
    if (v == p) continue;
    int ret = Dfs(v, u);
    if (ret > 0) {
      childsScore[u] += ret;
    }
  }
  return childsScore[u];
}

如果把某个儿子节点当作新的根,它的子树最优结构不变,只是原来的父节点要作为它的一个儿子重新参与贡献。
所以换根的时候,需要把父节点给儿子的贡献计算出来。

怎么计算呢?
父节点的最优解减去儿子的贡献,就是父节点对儿子的贡献。

void DfsDP(const int u, const int p, const int preScore) {
  ans[u] = preScore + childsScore[u];
  for (const auto v : g[u]) {
    if (v == p) continue;
    if (childsScore[v] > 0) {
      DfsDP(v, u, max(ans[u] - childsScore[v], 0));
    } else {
      DfsDP(v, u, max(ans[u], 0));
    }
  }
}

复杂度:O(n)

五、最后

这次比赛第四题是标准的换根DP,比较简单。
第三题则比较有意思,既可以枚举每一个起始位置的得分,也可以枚举每一关本身能带来的得分。
不管枚举哪一类,都用到了“整个区间同时加上同一个值,相当于都不加”的技巧,用一个标记位来统一记录即可。

《完》

-EOF-

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

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

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

tiankonguse +
穿越