1. 项目概述:从“医院设置”问题看图的中心性度量
最近在辅导学生准备信息学奥赛时,又翻出了这道经典题目——“医院设置”。它同时出现在《信息学奥赛一本通》的例题(1338:【例3-3】)和洛谷(P1364)上,足以说明其作为图论与树形结构入门题目的重要地位。表面上看,这是一个关于在社区中选址建立医院,使得居民看病总距离最短的问题。但它的内核,实际上是一个关于如何在树结构上高效计算“带权重心”或“最小化带权距离和”的经典模型。这个问题不仅考察了对树这种数据结构的理解,更是在引导我们思考如何将现实世界的优化问题,抽象为可计算的图论模型。
简单来说,题目给你一棵树,每个树节点代表一个居民点,节点上有一个权值(居民数量)。树上的边代表道路,每条边的长度通常视为1(单位距离)。你需要在这棵树的某个节点上(可以是居民点,也可以是道路上的任意点,具体看题目变体)设立一所医院,目标是让所有居民去看病的总路程(每个居民点的权值乘以该点到医院的最短距离)最小。这个“总路程”就是我们要求解的最小代价。
为什么这道题值得深究?因为在算法竞赛中,它像一把钥匙,打开了“树形DP”(动态规划)和“换根DP”这两扇大门。许多更复杂的问题,比如树的最长路径、树的直径、特定节点的最优选择等,其解题思路都与此一脉相承。对于初学者,搞懂这道题,就意味着掌握了在树上进行系统性计算和状态转移的基本功。今天,我就结合自己多年的刷题和教学经验,把这个问题的几种主流解法掰开揉碎讲清楚,并分享一些在实现时容易踩的“坑”。
2. 核心思路解析:暴力、树形DP与换根思想的演进
面对这个问题,最直观的想法可能就是暴力枚举。既然医院可以建在任何节点上,那我就把每个节点都假设为医院选址,然后分别计算以该节点为医院时,所有居民的总路程,最后取最小值。这个思路完全正确,也是我们验证其他算法正确性的基础。对于一个有n个节点的树,计算从一个源点到所有其他点的距离和,使用一次深度优先搜索(DFS)或广度优先搜索(BFS)需要 O(n) 的时间复杂度。那么枚举 n 个节点,总时间复杂度就是 O(n²)。当 n 在 10^3 到 10^4 量级时,这个复杂度尚可接受,这也是题目常见的数-据范围。暴力枚举法虽然朴素,但它是理解问题本质的起点,在竞赛中,确保正确性的朴素算法往往比写错的“高级”算法得分更高。
然而,当树节点数量达到 10^5 甚至更高时,O(n²) 的暴力法就力不从心了。这就需要我们寻找更优的解法。这里就引出了两种更高效的方法:基于一次DFS的预处理后快速计算各点代价的“换根DP”法,以及严格符合“医院可建在边上”要求的二次扫描法。它们的核心都在于利用树的无环结构,通过一次或两次遍历,推导出所有节点作为根时的信息,避免重复计算。
为什么“换根”思想如此强大?想象一下,我们已经花费 O(n) 时间计算出了以节点u为根(医院)时的总路程f[u]。现在,医院选址挪到与u相邻的节点v上。那么,对于整棵树来说,只有连接u和v的这条边两侧的节点受到了影响。所有原本在v子树里的节点,去医院的距离都减少了1(因为医院从u移到了更近的v);而所有不在v子树里的节点(包括u和其他部分),去医院的距离都增加了1。如果我们能提前知道每个节点的子树权重之和,那么从f[u]推导f[v]就是一个 O(1) 的公式计算。这样,我们只需要一次DFS求出以某个节点为根时的f[root]和各子树权重,然后再一次DFS(换根)就能推出所有节点的f[i],总复杂度 O(n)。
这个“换根”过程,就是动态规划在树形结构上的典型应用。状态f[u]表示以u为医院的总距离和。状态转移就是当根从父节点u移动到子节点v时,如何利用已知信息快速更新f[v]。理解了这个模型,你就掌握了解决一大类“树形结构上对每个节点求某个全局属性”问题的钥匙。
3. 关键算法细节与实现要点
接下来,我们深入到两种主流解法的实现细节中。我会先用“换根DP”解决医院必须建在节点上的标准版,再讨论医院可以建在边上的扩展版。
3.1 解法一:换根DP(医院建于节点)
这是解决洛谷 P1364 和《一本通》例题最标准、最高效的方法。我们定义几个关键数组:
w[i]: 节点i的居民数量(权值)。size[i]: 以i为根的子树中,所有节点的权值之和。这是一个非常重要的中间量。f[i]: 以节点i为医院时,所有居民的总距离和(即我们要最小化的目标值)。
算法分为两个清晰的阶段:
第一阶段:第一次DFS(后序遍历),计算size和初始的f[root]。我们任选一个节点作为根(比如1号节点)。进行一次深度优先搜索。
- 对于当前节点
u,递归计算其所有子节点v的size[v]和f[v](这里f[v]是以v为根的子树内部,如果医院设在v上,子树内居民的距离和,这是一个局部值,注意区分)。 - 回溯到
u时,size[u] = w[u] + sum(size[v]),即自身权值加上所有子树权值和。 - 同时,我们可以计算以
u为根时,仅考虑其子树内居民的总距离。但这个距离并不是我们最终定义的f[u]。更常用的方法是,在第一次DFS中,我们直接计算以我们选定的根节点(比如1)为医院时的总距离f[1]。这个计算可以在DFS过程中累加:对于每条边(u, v),节点v子树中的所有居民(共size[v]人)去看病,都需要经过这条边,因此对总距离的贡献是size[v]。所以f[1] = sum(size[v]),其中v是所有非根节点。实际上,f[root]就等于所有节点的(权值 * 该节点到根节点的深度)之和。我们可以在DFS求深度的同时累加得到。
关键细节:第一次DFS的主要目的,除了求出
f[root],更重要的是求出每个节点的size[i](子树权值和)。这是后续换根推导的基石。
第二阶段:第二次DFS(先序遍历,即“换根”),推导所有f[i]。现在我们知道了f[1],以及每个节点的size[i]。我们再进行一次DFS,这次的任务是从父节点u的状态f[u],推导出子节点v的状态f[v]。 假设当前我们在节点u,已知f[u]。现在考虑将医院从u移到其子节点v。
- 对于
v子树内的所有节点(共size[v]个权值单位),它们去医院的距离减少了1,所以总距离减少size[v]。 - 对于不在
v子树内的所有节点(总权值为total_weight - size[v]),它们去医院的距离增加了1,所以总距离增加(total_weight - size[v])。 因此,状态转移方程为:f[v] = f[u] - size[v] + (total_weight - size[v]) = f[u] + total_weight - 2 * size[v]
其中total_weight是所有节点权值之和,这是一个常量。
通过这次DFS,我们可以从根节点开始,递推地计算出所有节点的f[i]。最后,答案就是min(f[1], f[2], ..., f[n])。
实现注意事项:
- 树的存储:使用邻接表(
vector<int> graph[N])来存储这棵无向树。注意输入可能是父子节点编号,也可能给出的是每个节点的左右孩子(类似二叉树)。对于更一般的树,用邻接表处理更通用。 - 避免重复访问:在DFS过程中,必须传递一个
parent参数,防止走回头路。 - 初始化:
total_weight需要在读入权值时提前计算好。 - 数据类型:总距离和可能很大,
f[i]和中间变量应使用long long类型。
3.2 解法二:二次扫描与医院建于边上的情况
《信息学奥赛一本通》的例题描述中,明确提到“医院可以建在居民点上,也可以建在道路上”。这比洛谷 P1364(默认建在点上)的要求更宽泛。当医院可以建在边上时,最优解有可能出现在某条边的中间某个点,而不仅仅是端点。如何解决呢?
思路需要进一步延伸。对于一条连接u和v的边,长度为L(通常为1)。假设我们在这条边上距离u为x的位置建医院。那么,总距离函数F(x)是关于x的一个分段线性函数。可以证明,在这个一维线段上,使总距离最小的点x一定位于某个“权重平衡点”附近,或者说,最小值点会使得医院两侧的“权重压力”尽可能平衡。
一个经典的二次扫描解法如下:
- 第一次扫描(DFS):任选根,计算每个节点
i的size[i](子树权值和),同时计算以该节点为根的子树中,所有居民到该节点的距离之和down[i]。down[i]的计算类似于树形DP:down[u] = sum(down[v] + size[v]),因为子节点v子树内的居民到u的距离,等于他们到v的距离(down[v])再加上经过边(u,v)的一步(共size[v]步)。 - 第二次扫描(DFS,换根):计算每个节点
i的up[i],表示不在i子树内的所有居民,到节点i的距离之和。以及最终我们要求的f[i],即所有居民到i的总距离,f[i] = down[i] + up[i]。- 计算
up[v](v是u的子节点):up[v] = up[u] + down[u] - (down[v] + size[v]) + (total_weight - size[v])。这个公式需要仔细理解:up[u] + down[u]是除了v子树外其他点到u的距离和,减去(down[v] + size[v])是为了去掉v子树对down[u]的贡献,然后加上(total_weight - size[v])是因为其他所有点到v比到u多了一步。
- 计算
- 处理边上的点:对于边
(u, v),设其长度为1。我们已经有了f[u]和f[v],以及size[u]和size[v](注意这里的size需要根据根的选择来定义,通常我们定义size[v]为以v为根的子树权值和)。假设医院建在靠近u的x位置(0 <= x <= 1)。- 那么,
u一侧(包含u及其部分子树,具体取决于根的选择)的居民到医院的距离变化是线性的。 - 实际上,可以证明,总距离函数在这条边上是凸的,最小值点要么在端点(
u或v),要么在满足某种平衡条件的内部点。一个实用的方法是:考虑将边上的点想象成将这条边细分出一个虚拟节点。最优解往往出现在“权重平衡”的位置。一个简化且正确的做法是:对于每条边,答案的最小值候选点就是这条边的两个端点。因为如果最优解在边内部,那么将其移动到离权重更大的一侧更近的端点,不会使总距离增加(可以推导)。因此,在实际编程中,我们只需要比较所有节点作为医院选址的f[i],取最小值即可。这是因为在单位边权的树上,位于边内部的点一定不如某个端点优。这是一个非常重要的结论,可以简化代码,直接使用解法一的换根DP结果即可。
- 那么,
实操心得:很多同学在遇到“边上可建”的条件时,会想复杂去求边上的精确位置。实际上,对于本题的树结构和单位边权,只需考虑节点。这是一个常见的思维陷阱。务必先进行数学分析或举简单例子验证,避免实现复杂化。
4. 代码实现与逐行解析
下面,我给出基于“换根DP”思路,解决医院建于节点情况的C++代码实现,并附上详细注释。这个版本可以直接通过洛谷 P1364。
#include <iostream> #include <vector> #include <algorithm> using namespace std; const int N = 105; // 根据题目范围调整,本题一般n<=100 typedef long long ll; vector<int> graph[N]; // 邻接表存树 ll w[N]; // 节点权值(居民数) ll size[N]; // 子树权值和 ll f[N]; // f[i]: 以i为医院的总距离和 ll total_weight = 0; // 总权值 int n; // 第一次DFS:以u为当前节点,fa为父节点,计算size[u]和以u为根的“子树贡献” // 同时,这里我们采用另一种方式直接计算f[root]:在递归过程中累加深度*权值 ll dfs1(int u, int fa, int depth) { size[u] = w[u]; // 初始化,包含自身权值 ll sum_dist = w[u] * depth; // 当前节点对根节点总距离的贡献 for (int v : graph[u]) { if (v == fa) continue; // 避免回环 sum_dist += dfs1(v, u, depth + 1); // 累加子树的贡献 size[u] += size[v]; // 回溯时更新子树权值和 } return sum_dist; // 返回以u为根的子树中,所有节点到初始根节点的距离*权值之和 } // 第二次DFS:换根,从u推导其子节点v的f[v] void dfs2(int u, int fa) { for (int v : graph[u]) { if (v == fa) continue; // 核心换根公式 f[v] = f[u] + total_weight - 2 * size[v]; dfs2(v, u); } } int main() { cin >> n; for (int i = 1; i <= n; ++i) { cin >> w[i]; total_weight += w[i]; // 计算总权值 int left, right; cin >> left >> right; // 构建无向树图 if (left) { graph[i].push_back(left); graph[left].push_back(i); } if (right) { graph[i].push_back(right); graph[right].push_back(i); } } // 任选1号节点为根,进行第一次DFS // dfs1的返回值,就是以1为根时,所有居民到节点1的总距离 f[1] = dfs1(1, 0, 0); // 进行第二次DFS(换根),推导所有f[i] dfs2(1, 0); // 找出最小的总距离 ll ans = f[1]; for (int i = 2; i <= n; ++i) { if (f[i] < ans) ans = f[i]; } cout << ans << endl; return 0; }代码关键点解析:
dfs1函数:参数depth记录了当前节点u到初始根节点(1号)的距离。sum_dist累加了当前子树中所有节点(权值*深度)的和,这个值在根节点1的调用返回后,就是f[1]。同时,我们顺利求出了每个节点的size[i]。dfs2函数:这是换根的核心。当我们知道f[u]后,利用公式f[v] = f[u] + total_weight - 2 * size[v]来推导子节点v的f[v]。注意,这个公式成立的前提是,size[v]的定义必须是在第一次DFS中,以1为根时,v的子树权值和。我们的dfs1正是这样计算的。- 图的构建:题目输入有时以“左孩子、右孩子”形式给出,我们将其转化为无向图邻接表。使用
vector<int> graph[N]存储,graph[u]包含所有与u相邻的节点。 - 数据类型:总距离可能超过
int范围,因此使用long long。
5. 常见错误与调试技巧
即便理解了算法,实现时也常常会遇到各种问题。这里我总结几个最常见的“坑”:
将树误当作二叉树处理:题目虽然样例输入像二叉树,但描述是一棵普通的树。必须用邻接表来存储,并用
fa参数防止DFS走回头路。如果只用左右孩子指针,遇到非二叉树结构就会出错。- 排查方法:用一组简单的非二叉树的测试数据验证,例如三个节点成一条线
1-2-3。
- 排查方法:用一组简单的非二叉树的测试数据验证,例如三个节点成一条线
size数组定义混淆:在换根公式f[v] = f[u] + total_weight - 2 * size[v]中,size[v]必须是在第一次DFS确定的根(如节点1)下,以v为根的子树权值和。如果在换根过程中size[v]发生了变化,公式就不成立。我们的实现中,size数组只在dfs1中计算一次,之后是只读的,这保证了正确性。忽略权值和的距离溢出:这是最隐蔽的错误。节点权值和总距离的乘积很容易超出 32 位整数 (
int) 的范围。例如,100个节点,每个节点权值10000,距离最大为100,总距离可能达到 10^8 量级,还在int范围内。但如果节点数和权值更大,就危险了。安全起见,所有与权值、距离、总和相关的变量,统一使用long long。换根公式推导错误:公式
f[v] = f[u] + total_weight - 2 * size[v]是核心。自己推导一遍是加深理解的最好方式。可以画一棵简单的树,手工计算f[u]和f[v],验证公式。初始化与边界条件:确保
total_weight正确累加。对于孤立的节点(权值为0),算法也应正确处理。
调试技巧实录:
- 小数据手工模拟:当程序输出错误时,不要急于看代码。取一个 n=3 或 4 的小例子,在纸上画出树,标上权值,手工计算出每个节点作为医院的总距离(暴力枚举)。然后单步调试你的程序,对比每一步计算出的
size[i]和f[i]是否与手工结果一致。这是定位逻辑错误最有效的方法。 - 打印中间变量:在
dfs1和dfs2的关键步骤后,打印出size[u],f[u]等变量。观察它们的值是否符合预期。特别是换根前后f值的变化,是否满足推导公式。 - 测试边界数据:
- 所有节点权值相同。
- 权值集中在某一个叶子节点。
- 链状树(退化的树)。
- n=1 的情况。
6. 算法扩展与思维提升
掌握了“医院设置”的基础解法,我们可以看看它如何延伸到更复杂的问题,这有助于构建知识网络。
扩展到带边权:如果道路长度不是1,而是不同的正整数
c。公式需要调整。此时,size[v]的定义不变,但换根公式中的1需要替换为边权c。推导时,v子树内居民距离减少c,子树外居民距离增加c。公式变为:f[v] = f[u] - size[v] * c + (total_weight - size[v]) * c = f[u] + c * (total_weight - 2 * size[v])。实现时,需要在DFS中传递边权信息。与“树的中心”和“树的质心”关联:
- 树的中心:树上到一个点最远距离最小的点。求法通常是求树的直径,然后找直径中点。这与“医院设置”追求总和最小不同,中心是追求最大值最小。
- 树的质心:树中删除该点后,产生的最大子树节点数最小的点。求质心是树分治算法的第一步。有趣的是,在所有权值为1的树上,“医院设置”问题(距离和最小)的解往往就是树的质心,或者在其附近。这是一个很好的性质,将不同概念联系起来。
实际问题建模:这道题的本质是“设施选址问题”在树形网络上的特例。类似的现实问题很多,比如在居民区设立快递柜、在电网中设立变电站、在公司分布式架构中部署中心缓存服务器等,只要网络结构可以抽象为树,优化目标是最小化加权距离和,都可以套用这个模型。
理解一道题,不仅仅是AC。更重要的是理清它的算法脉络,看清它背后的模型,并知道它如何变化、如何与其他知识点连接。当你再遇到“树形结构上对所有节点求某个全局值”的问题时,不妨想想:能不能用一次DFS预处理出一些子树信息?能不能用换根DP在O(n)时间内求出所有答案?“医院设置”这道题,正是训练这种思维能力的绝佳起点。