1. 题目背景与问题分析
洛谷P1364"医院设置"是一道经典的树形结构算法题,考察对树的基本操作和简单动态规划的应用。题目描述了一个二叉树结构的社区,每个节点代表一个居民区,包含一定数量的人口。我们需要找到一个最优的位置建立医院,使得所有居民前往医院的总路程最小。
这个问题的实际应用场景非常广泛,比如:
- 城市规划中公共设施的选址
- 物流中心的位置优化
- 网络服务器的部署决策
题目给出的示例是一个具有5个节点的二叉树,每个节点的人口数分别为13、4、12、20和40。相邻节点之间的距离定义为1,我们需要计算在每个节点建立医院时的总路程,然后找出最小值。
2. 解题思路与算法选择
2.1 暴力解法分析
最直观的解法是枚举每个节点作为医院的位置,然后计算所有居民到该节点的距离之和。对于树结构,节点之间的距离可以通过深度优先搜索(DFS)或广度优先搜索(BFS)来计算。
这种方法的优点是实现简单,容易理解。对于n个节点的树,需要进行n次DFS/BFS遍历,每次遍历的时间复杂度是O(n),因此总时间复杂度是O(n²)。在题目给定的数据范围(N≤100)内完全可行。
2.2 树的重心概念
这个问题实际上与图论中的"树的重心"概念密切相关。树的重心是指树中满足删除该节点后,剩余各个连通块节点数的最大值最小的节点。在本题中,我们可以将人口数视为权重,寻找加权意义下的树重心。
虽然本题可以直接使用重心的性质来优化,但考虑到题目规模不大,使用暴力枚举法已经足够高效,且更易于理解和实现。
2.3 动态规划优化思路
对于更大的数据规模(n>1e5),我们可以考虑使用动态规划来优化:
- 第一次DFS预处理每个子树的权重和
- 第二次DFS计算换根时的总距离变化
- 通过O(1)的转移方程快速计算每个节点作为根时的总距离
这种方法可以将时间复杂度降低到O(n),但实现起来相对复杂。对于初学者来说,掌握暴力解法已经足够应对大多数情况。
3. 代码实现详解
3.1 数据结构设计
我们使用邻接表来存储树结构,这是处理树和图问题的常用方法:
cpp复制const int N = 105, M = N * 2; // 最大节点数和边数
int h[N], e[M], ne[M], idx; // 邻接表
int w[N]; // 节点权重(人口)
int dep[N]; // 节点深度(距离)
邻接表相比邻接矩阵更节省空间,特别适合稀疏图(如树结构)。对于n个节点的树,恰好有n-1条边。
3.2 深度优先搜索实现
DFS函数负责计算以当前节点u为根时,其他节点的深度(即到医院的距离):
cpp复制void dfs(int u, int fa) {
if (fa) dep[u] = dep[fa] + 1; // 计算当前节点深度
for (int i = h[u]; i != -1; i = ne[i]) {
int j = e[i];
if (j == fa) continue; // 避免回父节点
dfs(j, u); // 递归处理子节点
}
}
关键点:
fa参数记录父节点,避免重复访问形成环路- 节点深度通过父节点深度+1递推得到
- 递归处理所有子节点
3.3 主逻辑流程
主函数处理输入输出和核心计算逻辑:
cpp复制int main() {
memset(h, -1, sizeof h); // 初始化邻接表
cin >> n;
// 构建树结构
for (int i = 1; i <= n; i++) {
cin >> w[i] >> u >> v;
if (u) add(i, u), add(u, i); // 无向边
if (v) add(i, v), add(v, i);
}
// 枚举每个节点作为医院位置
for (int i = 1; i <= n; i++) {
memset(dep, 0, sizeof dep);
dfs(i, 0); // 以i为根计算深度
int sum = 0;
for (int j = 1; j <= n; j++)
sum += w[j] * dep[j]; // 计算总距离
ans = min(ans, sum); // 更新最小值
}
cout << ans << endl;
return 0;
}
4. 算法优化与变种
4.1 记忆化搜索优化
对于大规模数据,我们可以通过记忆化技术避免重复计算。记录已经计算过的子树信息,减少递归开销。
4.2 多叉树扩展
题目给出的是二叉树,但实际代码已经支持多叉树的情况。邻接表存储方式天然支持任意度的树结构。
4.3 带权边的情况
如果题目中边带有不同的长度(不只是1),我们可以修改DFS函数,累加实际边长而非简单+1。
5. 常见错误与调试技巧
5.1 邻接表未初始化
cpp复制memset(h, -1, sizeof h); // 这行绝对不能漏!
忘记初始化邻接表头指针数组会导致遍历时无法正确终止,产生无限循环或内存错误。
5.2 未处理无子节点情况
输入数据中用0表示没有子节点,必须进行判断:
cpp复制if (u) add(i, u), add(u, i); // 只有非零才添加边
5.3 深度数组未重置
每次枚举新的根节点时,必须重置深度数组:
cpp复制memset(dep, 0, sizeof dep); // 清除之前的计算结果
5.4 树的构建错误
特别注意树的边是无向的,需要添加双向边:
cpp复制add(i, u), add(u, i); // 不能只加单向边
6. 复杂度分析与适用场景
6.1 时间复杂度
- 建树:O(n)
- 枚举每个节点作为根:O(n)
- 每次DFS:O(n)
- 总复杂度:O(n²)
对于n≤1e3的数据规模完全足够,n≤1e5时需要更优的算法。
6.2 空间复杂度
- 邻接表存储:O(n)
- 深度数组:O(n)
- 总空间:O(n)
6.3 适用场景
这种解法适用于:
- 树结构数据的处理
- 需要计算节点间距离的问题
- 设施选址类优化问题
- 算法竞赛中的树形DP基础题
7. 实际应用案例
假设我们要在一个小区内设置快递柜,每个楼栋有不同的住户数量,楼栋之间的路径形成树状结构。我们可以使用同样的方法找到使所有住户取快递总距离最小的最优位置。
另一个例子是公司内部设置打印机的位置,考虑不同部门的使用频率和位置关系,优化整体效率。
8. 进一步学习建议
- 学习树的重心相关算法
- 掌握更高效的换根DP方法
- 尝试解决边权不为一的扩展问题
- 练习类似的题目如:
- 洛谷P1395 会议
- POJ 3107 Godfather
- Codeforces 685B Kay and Snowflake
对于算法竞赛选手,这类树形问题是非常基础且重要的题型。建议通过大量练习来熟悉树结构的各种操作和常见算法。在实际编程中,要注意树的构建、遍历的正确性,特别是边界条件的处理。