题目
一张地图上有n个城市,城市和城市之间有且只有一条道路相连:要么直接相连,要么通过其它城市中转相连(可中转一次或多次)。城市与城市之间的道路都不会成环。
当切断通往某个城市 i 的所有道路后,地图上将分为多个连通的城市群,设该城市i的聚集度为DPi(Degree of Polymerization),DPi = max(城市群1的城市个数,城市群2的城市个数,…城市群m 的城市个数)。
请找出地图上DP值最小的城市(即找到城市j,使得DPj = min(DP1,DP2 … DPn))
提示:如果有多个城市都满足条件,这些城市都要找出来(可能存在多个解)
提示:DPi的计算,可以理解为已知一棵树,删除某个节点后;生成的多个子树,求解多个子数节点数的问题。
输入描述
每个样例:第一行有一个整数N,表示有N个节点。1 <= N <= 1000。
接下来的N-1行每行有两个整数x,y,表示城市x与城市y连接。1 <= x, y <= N
输出描述
输出城市的编号。如果有多个,按照编号升序输出。
用例
输入 | 输出 | 说明 |
---|---|---|
5 1 2 2 3 3 4 4 5 | 3 |
输入地图示意图:①-②-③-④-⑤ 对于城市3,切断通往3的所有道路后,形成2个城市群[(1,2),(4,5)],其聚集度分别都是2。DP3 = 2。对于城市4,切断通往城市4的所有道路后,形成2个城市群[(1,2,3),(5)],DP4 = max(3,1)= 3。依次类推,切断其它城市的所有道路后,得到的DP都会大于2,因为城市3就是满足条件的城市,输出是3。 |
6 1 2 2 3 2 4 3 5 3 6 | 2 3 | 将通往2或者3的所有路径切断,最大城市群数量是3,其他任意城市切断后,最大城市群数量都比3大,所以输出2 3 |
思考一 (暴力求解)
根据题目提示,问题可以简化为删除树上一个节点,求解子树节点数目最大值,这个最大值就是删除节点的DP,目标是找到DP最小的节点,可能有多个。暴力解法是把每个节点都删一次统计各自的DP,找到最下的DP。由于是树(连通的无环图),可以用BFS遍历统计节点数目。
算法过程
1、根据输入数据用邻接表构建树,树是连通的无环图,把每个节点单独存一份到数组中用于后续检索;
2、遍历节点数组,把当前节点作为removedNode参数传给countDP函数,定义minDP记录最小的DP值,list集合存储DP值对应的节点,循环内用countDP返回的节点dp值更新minDP;
3、countDP函数实现,还是遍历nodes节点数组,对于参数removedNode跳过,用其它节点作为起始节点进行内层的广度优先遍历,遇到removedNode判为不可达,不加入临时队列,最终完成一次BFS统计当前节点的DP值,整个外层循环执行完成得到最大的DP值返回;
4、完成节点数组的遍历,得到最小的DP和对应的节点集合,时间复杂度:O (n³)。
参考代码
function solution(line) {
const arr = line.split('\n');
const n = parseInt(arr[0].trim());
const data = arr.slice(1).map(item => item.trim().split(' ').map(e => parseInt(e)));
const nodes = [];
const tree = {};
for (let [u, v] of data) {
if (!nodes.includes(u)) {
nodes.push(u);
}
if (!nodes.includes(v)) {
nodes.push(v);
}
if (!tree[u]) {
tree[u] = [];
}
tree[u].push(v);
if (!tree[v]) {
tree[v] = [];
}
tree[v].push(u);
}
const visited = new Array(nodes.length);
visited.fill(false);
const countDP = function(removedNode) {
const queue = [];
let maxDP = 1;
for (let node of nodes) {
if (node !== removedNode) {
queue.push(node);
let dp = 1;
visited.fill(false);
while (queue.length) {
let aNode = queue.shift();
if (aNode === removedNode) {
aNode = queue.shift();
}
if (aNode === undefined) continue;
visited[aNode] = true;
let children = tree[aNode].filter(e => e!== removedNode);
for (let ch of children) {
if (!visited[ch]) {
queue.push(ch);
dp++;
}
}
}
maxDP = Math.max(maxDP, dp);
}
}
return maxDP;
}
let minDP = Infinity, list = [];
for (let node of nodes) {
let dp = countDP(node);
if (minDP === dp) {
list.push(node);
} else if (minDP > dp){
minDP = dp;
list = [node];
}
}
return list.sort().join(' ');
}
let lines = [
`5
1 2
2 3
3 4
4 5`,
`6
1 2
2 3
2 4
3 5
3 6`,
];
lines.forEach(line => {
console.log(solution(line));
});
思考二(树形DP)
暴力解法虽然直观,但其时间复杂度高达O(n³),在较大规模的树结构应用中性能较差。通过深入研究树形结构的特性,我们可以在暴力算法的基础上进行优化改进。
核心优化思路是利用后序遍历来计算子树大小。后序遍历(左子树-右子树-根节点)的特点非常适合自底向上地计算子树信息。具体实现步骤如下:
-
后序遍历树结构,确保在处理某个节点时,其左右子树已经被处理过
-
对于每个节点,计算其子树大小(包括自身)= 左子树大小 + 右子树大小 + 1
-
在遍历过程中,利用已经计算出的子树大小信息来优化后续计算
这种优化方法将时间复杂度从O(n³)降低到O(n),显著提高了算法效率。实际应用场景包括:
-
社交网络中的好友关系树分析
-
组织结构图的层级统计
-
文件系统的目录大小计算
例如,在处理一个包含1000个节点的组织架构图时,暴力解法可能需要数秒完成,而优化后的算法能在毫秒级完成计算。这种优化在需要频繁查询或实时计算子树信息的场景中尤为重要。
算法过程
1、根据输入数据构建邻接表,定义parent、size、children分别存储父节点、子树大小、子节点;
2、DFS构建父节点和子节点关系,这里DFS实现采用栈+迭代实现,可以避免递归实现因节点层级过深导致栈溢出;
3、第二次DFS得到树的后序遍历结果,实现采用栈+迭代实现,对子节点压栈前需要先反转,这样下次出栈顺序就能保持子节点原本的顺序,栈特性是后进先出;
4、根据后序遍历序列和size、children数组计算子树大小。
以树 1-2-3-4-5
为例:
-
节点
5
是叶子节点,子树大小为1
。 -
节点
4
的子树包含4
和5
,大小为2
。 -
节点
3
的子树包含3、4、5
,大小为3
。 -
依此类推,根节点
1
的子树大小为5
(整个树的节点数)
5、 计算删除每个节点后的最大连通块大小,以树1-2-3-4-5为例:
各节点的子树大小:
-
size[5] = 1
-
size[4] = 1 + size[5] = 2
-
size[3] = 1 + size[4] = 3
-
size[2] = 1 + size[3] = 4
-
size[1] = 1 + size[2] = 5
删除节点 3
时:
-
子树大小:
size[4] = 2
(子节点4
的子树) -
剩余部分大小:
n - size[3] = 5 - 3 = 2
(删除节点3后的剩余的连通块为节点 1、2) -
最大连通块大小:
max(2, 2) = 2
,这是该树的最优解(树的重心)。
查阅资料得时间复杂度O(n),适合处理大规模树结构(如 n ≤ 10⁵)。
参考代码
function solution(line) {
const arr = line.split('\n');
const n = parseInt(arr[0].trim());
const data = arr.slice(1).map(item => item.trim().split(' ').map(e => parseInt(e)));
// 构建邻接表
const tree = {};
for (let [u, v] of data) {
if (!tree[u]) tree[u] = [];
if (!tree[v]) tree[v] = [];
tree[u].push(v);
tree[v].push(u);
}
const nodes = Object.keys(tree).map(Number);
const root = nodes[0]; // 任意选择根节点
// 后序遍历计算每个节点的子树大小
const parent = new Array(n + 1).fill(-1); // 存储父节点
const size = new Array(n + 1).fill(0); // 存储子树大小
const children = new Array(n + 1).fill().map(() => []); // 存储子节点
// 第一次DFS:构建父节点和子节点关系
const stack = [root];
const visited = new Array(n + 1).fill(false);
while (stack.length > 0) {
const node = stack.pop();
visited[node] = true;
for (const neighbor of tree[node]) {
if (!visited[neighbor]) {
parent[neighbor] = node;
children[node].push(neighbor);
stack.push(neighbor);
}
}
}
// 第二次DFS:后序遍历计算子树大小
const postOrder = [];
stack.push(root);
visited.fill(false);
while (stack.length > 0) {
const node = stack[stack.length - 1];
if (visited[node]) {
stack.pop();
postOrder.push(node);
continue;
}
visited[node] = true;
for (const child of children[node].reverse()) {
stack.push(child);
}
}
// 计算子树大小
for (const node of postOrder) {
size[node] = 1; // 包含自身
for (const child of children[node]) {
size[node] += size[child];
}
}
// 计算删除每个节点后的最大连通块大小
let minDP = Infinity;
const list = [];
for (const node of nodes) {
let maxSize = 0;
// 子树部分
for (const child of children[node]) {
maxSize = Math.max(maxSize, size[child]);
}
// 父树部分
const parentSize = n - size[node];
maxSize = Math.max(maxSize, parentSize);
if (maxSize < minDP) {
minDP = maxSize;
list.length = 0;
list.push(node);
} else if (maxSize === minDP) {
list.push(node);
}
}
return list.sort((a, b) => a - b).join(' ');
}
let lines = [
`5
1 2
2 3
3 4
4 5`,
`6
1 2
2 3
2 4
3 5
3 6`,
];
lines.forEach(line => {
console.log(solution(line));
});
思考三(并查集)
并查集是一种高效处理不相交集合合并与查询问题的数据结构。使用并查集解法思路是动态维护连通分量,每次删除一个节点后,重新合并剩余节点形成的连通块,统计每个连通块的节点数量。
算法过程
1、编写一个简单的并查集类,只要提供查找和简单的合并(不需要按深度合并)就行了。查找函数find(x)根据父节点映射列表递归查询当前节点的根节点,如果根节点是本身就直接返回,连通子图的父节点的父节点都设为自身。合并函数union(x,y)根据两个节点各自的父节点进行合并;
2、遍历树节点,根据输入用例的节点连通关系调用并查集的union方法生成集合同时忽略掉当前节点(即移除当前节点),遍历parent数组统计每个连通集合的节点数目,取最大值作为当前移除节点的DP值,同时更新全局最小dp值和城市列表;
3、完成树节点遍历后,返回城市列表。查阅资料知时间复杂度 O(n² * α(n)),其中 α(n) 是阿克曼函数的反函数(在实际应用中可视为常数)。
操作 | 单次时间复杂度 | 总次数 | 总贡献 |
---|---|---|---|
并查集初始化 | O(n) | n | O(n²) |
遍历边执行 union | O(n α(n)) | n | O(n² α(n)) |
统计连通块 | O(n α(n)) | n | O(n² α(n)) |
整体 | O(n² α(n)) |
该算法在节点数 n
较大时(如 n > 10⁴)会显著变慢,而树形DP算法(O(n))能高效处理更大规模数据。
参考代码
class UnionFindSet {
constructor(n) {
this.parent = new Array(n);
for (let i = 0; i < n; i++) {
this.parent[i] = i;
}
}
find(x) {
if (this.parent[x] !== x) {
this.parent[x] = this.find(this.parent[x]);
}
return this.parent[x];
}
union(x, y) {
const parentX = this.find(x);
const parentY = this.find(y);
if (parentX !== parentY) {
this.parent[parentY] = parentX;
}
}
}
function solution(line) {
const arr = line.split('\n');
const n = parseInt(arr[0].trim());
const edges = arr.slice(1).map(item => item.trim().split(' ').map(Number));
let minDP = Infinity;
let city = [];
for (let i = 1; i <= n; i++) {
const ufs = new UnionFindSet(n + 1);
for (const [x, y] of edges) {
if (x === i || y === i) continue;
ufs.union(x, y);
}
const count = new Map();
for (let p of ufs.parent) {
p = ufs.find(p);
count.set(p, (count.get(p) || 0) + 1);
}
let dp = 0;
for (const c of count.values()) {
dp = Math.max(dp, c);
}
if (dp < minDP) {
minDP = dp;
city = [i];
} else if (dp === minDP){
city.push(i);
}
}
return city.join(' ');
}
let lines = [
`5
1 2
2 3
3 4
4 5`,
`6
1 2
2 3
2 4
3 5
3 6`,
];
lines.forEach(line => {
console.log(solution(line));
});