【OD机试题多种解法笔记】城市聚集度/找城市

题目

一张地图上有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. 后序遍历树结构,确保在处理某个节点时,其左右子树已经被处理过

  2. 对于每个节点,计算其子树大小(包括自身)= 左子树大小 + 右子树大小 + 1

  3. 在遍历过程中,利用已经计算出的子树大小信息来优化后续计算

这种优化方法将时间复杂度从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)nO(n²)
遍历边执行 unionO(n α(n))nO(n² α(n))
统计连通块O(n α(n))nO(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));
});

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值