P2605 [ZJOI2010] 基站选址 解题报告

P2605 [ZJOI2010] 基站选址 解题报告

1. 题目大意

我们有一条直线上的 \(N\) 个村庄,需要在其中选择不超过 \(K\) 个位置建立基站。

  • 建站成本:在村庄 \(i\) 建基站的费用是 \(C_i\)
  • 覆盖规则:在村舍 \(j\) 建一个基站,可以覆盖所有与它距离不超过 \(S_p\) 的村庄 \(p\)
  • 赔偿费用:如果村庄 \(i\) 没有被任何基站覆盖,我们需要支付 \(W_i\) 的赔偿金。

我们的目标是找到一个建站方案(选择哪些村庄建站,总数不超过\(K\)个),使得 总建站费用 + 总赔偿费用 最小。


2. 思路分析

这是一道典型的“在 \(N\) 个物品中选择 \(K\) 个,求最优解”的问题,非常适合使用动态规划(DP)来解决。

第一步:朴素的动态规划

首先,我们尝试定义一个DP状态。一个自然的想法是:

  • dp[i][j] 表示:在前 \(i\) 个村庄中,总共建立了 \(j\) 个基站,并且\(j\) 个基站正好建在村庄 \(i\) 时,所需要的最小费用。

为了计算 dp[i][j],我们需要考虑第 \(j-1\) 个基站建在哪里。假设它建在村庄 \(k\)(其中 \(k < i\))。那么从 dp[k][j-1] 这个状态转移过来,我们需要付出的额外成本是:

  1. 在村庄 \(i\) 建第 \(j\) 个基站的费用:\(C_i\)
  2. 对于所有在 \(k\)\(i\) 之间的村庄(即村庄 \(k+1, k+2, \dots, i-1\)),如果它们既不能被 \(k\) 的基站覆盖,也不能被 \(i\) 的基站覆盖,就需要支付赔偿金。我们把这部分赔偿金总和记为 Penalty(k, i)

所以,状态转移方程就是:
dp[i][j] = min(dp[k][j-1] + Penalty(k, i)) + C[i],其中 \(j-1 \le k < i\)

复杂度分析

  • \(j\) (基站数量) 从 1到 \(K\)
  • \(i\) (当前基站位置) 从 1到 \(N\)
  • \(k\) (上一个基站位置) 从 1到 \(i-1\)
  • 计算 Penalty(k, i) 需要遍历 \(k\)\(i\) 之间的所有村庄,这需要 \(O(N)\) 的时间。
    总复杂度大约是 \(O(K \cdot N \cdot N \cdot N) = O(K \cdot N^3)\),这对于 \(N=20000\) 的数据规模来说是完全无法接受的。
第二步:优化DP结构

我们可以发现,每次计算第 \(j\) 个基站的费用时,我们只依赖于第 \(j-1\) 个基站的费用。这提示我们可以把DP的循环结构进行优化。

我们可以把基站数量作为最外层循环。

// 用 dp_prev 数组存储建 j-1 个基站的最小费用
for j from 1 to K:
  // 用 dp_curr 数组计算建 j 个基站的最小费用
  for i from 1 to N:
    dp_curr[i] = C[i] + min_{k < i} (dp_prev[k] + Penalty(k, i))
  // 计算完后,dp_prev = dp_curr,准备下一轮

这样,我们把问题转化成了:对于固定的 j,如何快速计算 min_{k < i} (dp_prev[k] + Penalty(k, i))
此时,计算 Penalty(k, i) 仍然需要 \(O(N)\),总复杂度降为 \(O(K \cdot N^2)\),对于 \(N=500\) 的数据可以通过,但对于 \(N=20000\) 仍然太慢。瓶颈在于快速求出 Penalty(k, i) 并找到最小值。

第三步:聚焦于代价计算与线段树优化

我们需要一个更高效的方法来处理 Penalty。让我们换个角度思考赔偿金是怎么产生的。

对于任何一个村庄 \(p\),它需要赔偿,当且仅当所有已建的基站都无法覆盖它。在我们的DP模型中,当我们从上一个基站 \(k\) 转移到当前基站 \(i\) 时,一个中间的村庄 \(p\) (\(k < p < i\)) 需要赔偿,当且仅当:

  • 基站 \(k\) 无法覆盖 \(p\)
  • 基站 \(i\) 无法覆盖 \(p\)

这个条件还是和 \(k, i\) 都相关,不方便优化。让我们再次转换思路。

关键洞察:当我们从计算 f[k](第 \(j\) 个基站建在 \(k\))推进到计算 f[i](第 \(j\) 个基站建在 \(i\))时,我们需要的 min(dp_prev[k] + Penalty(k, i)) 这一整块的值是如何变化的?

让我们先做一些预处理。对于每个村庄 \(p\),我们可以通过二分查找,计算出能覆盖它的最左边的基站位置 st[p]最右边的基站位置 ed[p]

  • st[p]:最小的村庄编号 \(x\),使得在 \(x\) 建站能覆盖 \(p\)
  • ed[p]:最大的村庄编号 \(y\),使得在 \(y\) 建站能覆盖 \(p\)

现在,我们再来看 min_{k < i} (dp_prev[k] + Penalty(k, i))。这个式子可以看作是在 \(i\) 固定的情况下,求关于 \(k\) 的一个函数的最小值。
当我们的 \(i\) 从 1 增加到 \(N\) 时,这个函数本身也在变化。

考虑当我们把当前基站位置从 \(i-1\) 移动到 \(i\) 时,赔偿金会发生什么变化。
对于一个村庄 \(p\),如果它的最右覆盖点 ed[p] 恰好是 i-1,这意味着:

  1. 如果当前基站建在 \(i\) 或更右边的地方,那么村庄 \(p\) 肯定不会被这个基站覆盖。
  2. 此时,村庄 \(p\) 是否需要赔偿,就完全取决于上一个基站(建在 \(k\))能否覆盖它。
  3. 基站 \(k\) 无法覆盖 \(p\) 的条件是 \(k < st[p]\)

这就引出了我们的优化策略:
我们用一个数据结构(线段树)来维护 dp_prev[k] 的值。当我们计算第 \(j\) 层DP,并且正在计算 f[i] 时:

  1. 线段树里存储的是 dp_prev[k] 加上一些累计的赔偿金。
  2. 当我们从 \(i-1\) 推进到 \(i\) 时,我们检查所有满足 ed[p] = i-1 的村庄 \(p\)
  3. 对于每个这样的 \(p\),它现在确定不会被当前基站(位置 \(\ge i\))覆盖了。因此,如果前一个基站 \(k\) 的位置小于 st[p],就必须赔偿 \(W_p\)
  4. 所以,我们在线段树上,对区间 [1, st[p]-1] 内的所有 \(k\),都加上 \(W_p\)。这相当于更新了从这些 \(k\) 转移过来的总成本。
  5. 完成更新后,f[i] 的值就是 C[i] + 线段树中区间 [1, i-1] 的最小值。

算法流程总结 (计算第 j 个基站,j>1)

  1. dp_prevj-1 个基站的最小费用数组。
  2. 根据 dp_prev 数组,建立一个线段树,支持区间加区间查询最小值
  3. 预处理一个邻接表 adjadj[x] 存储所有 ed[p]=x 的村庄 \(p\)
  4. 循环 \(i\) 从 1 到 \(N\)
    a. 更新:遍历 adj[i-1] 中的所有村庄 \(p\)。对于每个 \(p\),在线段树上对区间 [1, st[p]-1] 增加 \(W_p\)
    b. 查询:查询线段树中区间 [1, i-1] 的最小值 min_val
    c. 计算f[i] = C[i] + min_val
  5. 循环结束后,f 数组就是新的 dp_prev,用于计算下一轮(j+1 个基站)。

复杂度分析

  • 外层循环 \(K\) 次。
  • 内层循环 \(N\) 次。
  • 每次循环内部,有若干次线段树的区间更新和一次区间查询,单次操作复杂度为 \(O(\log N)\)
    总复杂度为 \(O(K \cdot N \log N)\),可以通过本题。

3. 代码细节解析

a. 增加虚拟终点 (Dummy Node)

代码中有一行 ++n; d[n] = w[n] = Maxn;。这是为什么?
我们的DP状态 dp[i][j] 表示最后一个基站建在 \(i\),它没有考虑 \(i\) 之后村庄的赔偿情况。
为了把所有村庄的赔偿都计算在内,我们虚构一个在无穷远处的村庄 \(N+1\)。我们设置它的建设费用 \(C_{N+1}=0\),但未被覆盖的赔偿金 \(W_{N+1}\) 巨大。我们总共可以建 \(K+1\) 个基站,并强制第 \(K+1\) 个基站必须建在这个虚拟村庄上。
当我们计算到这个虚拟终点时,它前面的所有真实村庄(1到N)都已经是“中间村庄”了,它们的赔偿金要么由前面的基站覆盖,要么已经被计入了成本。最终 f[N+1] 的值就是建 \(K\) 个基站(因为第 \(K+1\) 个是免费的)覆盖前 \(N\) 个村庄的最小总费用。这是一个非常巧妙的处理边界的技巧。

b. 预处理 sted
st[i] = lower_bound(d + 1, d + n + 1, d[i] - s[i]) - d;
ed[i] = lower_bound(d + 1, d + n + 1, d[i] + s[i]) - d;
if (d[ed[i]] > d[i] + s[i]) ed[i]--;

lower_bound 查找第一个大于等于目标值的元素。

  • st[i]:能覆盖 \(i\) 的基站位置 \(x\) 需满足 \(D_x \ge D_i - S_i\)lower_bound 找的就是第一个满足这个条件的 \(x\),正确。
  • ed[i]:能覆盖 \(i\) 的基站位置 \(y\) 需满足 \(D_y \le D_i + S_i\)lower_bound 找的是第一个大于等于 d[i]+s[i] 的位置。如果这个位置的距离严格大于 d[i]+s[i],说明它和它后面的都不能覆盖 \(i\),所以真正的右边界是它的前一个位置,故 ed[i]--
c. 主体DP逻辑
  • i = 1 (代码中的循环变量,代表基站数):这是DP的边界,即只建1个基站。

    • f[j] = res + c[j]:如果第1个基站建在 \(j\),费用是建站费 \(C_j\) 加上之前所有村庄(1到 \(j-1\))的赔偿金 res
    • for (point *e = lst[j]; ...)res 在这里累加。当 \(j\) 向右移动时,那些 ed[p]=j 的村庄 \(p\) 就被覆盖了,赔偿金可以不用付。但这里代码的实现是反过来的:遍历到 \(j\) 时,把 ed[p]<j 的村庄 \(p\) 的赔偿金加入 res。两种逻辑是等价的。
  • i > 1 (建多个基站)

    • Build(1, 1, n):用上一轮计算出的 f 数组(即 dp_prev)初始化线段树。
    • for (int j = 1; j <= n; ++j)j 在这里是位置):
      • f[j] = Query(...) + c[j]:查询 [i-1, j-1] 的最小值。k 必须 \(\ge i-1\) 是因为要建 \(i\) 个基站,第 \(i-1\) 个基站的位置至少在 \(i-1\)
      • for (point *e = lst[j]; ...):这对应我们分析的更新步骤。当计算完 f[j] 后,我们处理所有 ed[p]=j 的村庄 \(p\)。对于这些 \(p\),如果上一个基站 \(k\)[1, st[p]-1] 范围内,那么 \(p\) 肯定没被覆盖,所以把赔偿金 \(W_p\) 加到这些 \(k\) 的转移成本上。
    • CkMin(Ans, f[n]):每一轮DP(建 i 个基站)结束后,用 f[n]n是虚拟终点)更新最终答案。

这份报告应该能帮你更好地理解这个精妙的DP优化过程。

posted @ 2025-07-09 17:13  surprise_ying  阅读(21)  评论(0)    收藏  举报