1. 问题背景与需求分析
在企业管理系统中,我们经常需要统计组织架构中每个管理者的直接和间接下属数量。这道题目将这一实际业务需求抽象为一个经典的树形结构问题:给定公司的层级关系,计算每位员工的下属总人数(包含直接下属和间接下属)。
1.1 问题形式化描述
输入:
- 第一行:整数n(1≤n≤2×10^5),表示员工总数
- 后续n-1行:第i个数字表示员工i+1的直接上司编号(员工1为总经理,没有上司)
输出:
- n个整数,第i个数字表示员工i的下属总人数
示例:
输入:
5
1 1 2 3
输出:
4 1 1 0 0
1.2 数据结构选择考量
面对2×10^5的数据规模,我们需要特别关注算法的时间复杂度和空间复杂度:
- 邻接矩阵:空间复杂度O(n^2),对于n=2×10^5需要约160GB内存,显然不可行
- 标准邻接表:使用vector<vector
>存储,虽然空间复杂度O(n),但存在动态扩容开销 - 链式前向星:用数组模拟链表,空间利用率最高,访问效率稳定,是本题最优选择
提示:在算法竞赛中,当n≥10^5时,链式前向星通常是处理稀疏图的首选数据结构
2. 核心算法设计与实现
2.1 树形DFS算法原理
这个问题本质上是计算树中每个节点的子树大小(包含自身),然后减1得到下属人数。我们采用后序遍历的DFS策略:
- 从根节点(总经理)开始递归
- 对于每个节点,先递归处理所有子节点
- 将子节点的子树大小累加到当前节点
- 当前节点的子树大小即为所有子节点子树大小之和加1(自身)
2.2 链式前向星实现细节
链式前向星是一种用数组模拟邻接表的高效存图方式,包含三个核心数组:
cpp复制int h[N]; // h[u]:节点u的第一条边的索引
int vtex[M]; // 边的终点
int nxt[M]; // 下一条兄弟边的索引
int idx = 0; // 当前可用的边索引
void addedge(int u, int v) {
vtex[idx] = v;
nxt[idx] = h[u];
h[u] = idx++;
}
初始化时需要特别注意:
cpp复制memset(h, -1, sizeof(h)); // -1表示空指针
2.3 完整算法实现
cpp复制#include <iostream>
#include <cstring>
using namespace std;
const int N = 200010;
int h[N], vtex[N], nxt[N], idx;
int sz[N]; // sz[u]表示以u为根的子树节点数
void dfs(int u) {
sz[u] = 1; // 包含自己
for(int i = h[u]; ~i; i = nxt[i]) { // ~i等价于i!=-1
int v = vtex[i];
dfs(v);
sz[u] += sz[v];
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
memset(h, -1, sizeof(h));
for(int i = 2; i <= n; ++i) {
int p;
cin >> p;
addedge(p, i);
}
dfs(1);
for(int i = 1; i <= n; ++i) {
cout << sz[i] - 1 << " ";
}
return 0;
}
3. 性能优化与工程实践
3.1 输入输出加速
对于大规模数据(n=2×10^5),IO成为性能瓶颈。我们采用两种优化手段:
- 解除C++流同步:
cpp复制ios::sync_with_stdio(false);
cin.tie(0);
- 避免使用endl,改用'\n':
cpp复制cout << sz[i]-1 << " \n"[i==n]; // 最后换行
3.2 内存访问优化
链式前向星的内存局部性较差,可以通过以下方式优化:
- 预分配足够大的连续内存
- 将边的遍历改为顺序访问(虽然链式结构本身限制较大)
- 考虑使用vector+reserve(牺牲一点空间换取更好缓存命中率)
3.3 递归深度问题
虽然题目保证是树结构,但极端情况下(链状树)递归深度可能达到2×10^5,可能引发栈溢出。解决方案:
- 增加编译栈大小(竞赛中通常已配置)
- 改用非递归DFS实现:
cpp复制void dfs(int root) {
stack<pair<int, bool>> stk;
stk.push({root, false});
while(!stk.empty()) {
auto [u, visited] = stk.top();
stk.pop();
if(visited) {
for(int i = h[u]; ~i; i = nxt[i]) {
sz[u] += sz[vtex[i]];
}
sz[u] += 1;
} else {
stk.push({u, true});
for(int i = h[u]; ~i; i = nxt[i]) {
stk.push({vtex[i], false});
}
}
}
}
4. 常见问题与调试技巧
4.1 典型错误案例
- 无限递归:忘记初始化h数组为-1,导致遍历时无法终止
cpp复制// 错误示例
int h[N]; // 未初始化,可能不为-1
- 输出格式错误:末尾多空格或缺少换行
cpp复制// 正确做法
for(int i = 1; i <= n; ++i) {
cout << sz[i]-1 << " \n"[i==n];
}
- 数组越界:没有考虑节点编号从1开始,数组大小应为N+1
cpp复制const int N = 200010;
int sz[N]; // 错误,应该是N+1
4.2 调试技巧
- 小数据测试:先用n=5的样例验证基本逻辑
- 边界测试:测试n=1(只有总经理)和n=2(总经理和1个下属)的情况
- 打印中间结果:在dfs中加入调试输出
cpp复制void dfs(int u) {
cout << "Entering " << u << endl;
// ...
cout << "Leaving " << u << " with sz=" << sz[u] << endl;
}
- 内存检查:使用valgrind检测内存访问错误
bash复制valgrind ./a.out < input.txt
5. 算法扩展与变种思考
5.1 支持动态查询
如果需要支持动态组织结构变更和实时查询,可以考虑:
- 欧拉序+线段树:将树转为线性结构,支持子树查询和单点修改
- 树状数组:结合DFS序,实现O(logn)的子树和查询
5.2 多维度统计
如果需要统计不同层级的下属数量(如直接下属、二级下属等),可以:
- 分层记录:维护一个dep数组记录每个节点的层级
- BFS遍历:按层级顺序处理节点
5.3 并行计算优化
对于超大规模数据(n>10^6),可以考虑:
- 多线程DFS:将子树分配给不同线程处理
- MapReduce框架:适合分布式计算环境
在实际工程实践中,这类组织架构计算通常会结合数据库实现。例如使用递归CTE查询:
sql复制WITH RECURSIVE emp_tree AS (
SELECT id, 0 AS level FROM employee WHERE manager_id IS NULL
UNION ALL
SELECT e.id, et.level + 1
FROM employee e JOIN emp_tree et ON e.manager_id = et.id
)
SELECT id, COUNT(*) - 1 AS subordinates
FROM emp_tree
GROUP BY id;
6. 实际应用场景延伸
这个问题虽然以公司组织架构为背景,但其解决方案可应用于多种树形结构场景:
- 社交网络分析:计算用户的直接和间接关注者
- 文件系统统计:计算每个目录下的文件总数
- 电商分类系统:统计每个商品类目下的商品数量
- 评论系统:计算每条评论的回复总数
在实现这类系统时,除了考虑算法效率,还需要注意:
- 数据一致性:当树结构变更时如何高效更新统计结果
- 缓存策略:对频繁访问的节点进行缓存
- 增量计算:只重新计算发生变化的部分子树
我在实际开发中遇到过类似需求,当时选择的是维护一个专门的count字段,并通过触发器在关系变更时自动更新。这种方案虽然写入时开销稍大,但读取效率极高,适合读多写少的场景。