关注Java领域相关技术 记录有趣的事情

LeetCode-834. 树中距离之和

US-B.Ralph
US-B.Ralph
2020-10-06

问题地址

LeetCode每日一题/2020-10-06

LeetCode834. 树中距离之和


问题描述

规则

给定一个无向、连通的树。树中有 N 个标记为 0…N-1 的节点以及 N-1 条边 。

第 i 条边连接节点 edges[i][0] 和 edges[i][1] 。

返回一个表示节点 i 与其他所有节点距离之和的列表 ans。

示例

  • 示例1
输入: N = 6, edges = [[0,1],[0,2],[2,3],[2,4],[2,5]]
输出: [8,12,6,10,10,10]
解释: 
如下为给定的树的示意图:
  0
 / \
1   2
   /|\
  3 4 5
我们可以计算出 dist(0,1) + dist(0,2) + dist(0,3) + dist(0,4) + dist(0,5) 
也就是 1 + 1 + 2 + 2 + 2 = 8。 因此,answer[0] = 8,以此类推。

说明

  • 1 <= N <= 10000

解析

解题思路

数据操作分析

  • 见思路分析

复杂度分析

  1. 时间复杂度
  2. 空间复杂度

编码实现


官方解法

方法一:树形动态规划

思路与算法:

  • 首先我们来考虑一个节点的情况,即每次题目指定一棵树,以 \textit{root} 为根,询问节点 \textit{root} 与其他所有节点的距离之和。
  • 很容易想到一个树形动态规划:定义 \textit{dp}[u] 表示以 u 为根的子树,它的所有子节点到它的距离之和,同时定义 \textit{sz}[u] 表示以 u 为根的子树的节点数量,不难得出如下的转移方程:

\textit{dp}[u]=\sum_{v\in \textit{son}[u]}\textit{dp}[v] + \textit{sz}[v]

  • 方程说明
    • 其中 \textit{son}[u] 表示 u 的所有后代节点集合。转移方程表示的含义就是考虑每个后代节点 v,已知 v 的所有子节点到它的距离之和为 \textit{dp}[v],那么这些节点到 u 的距离之和还要考虑 u\rightarrow v 这条边的贡献。考虑这条边长度为 1,一共有 sz[v] 个节点到节点 u 的距离会包含这条边,因此贡献即为 1\times \textit{sz}[v]=\textit{sz}[v]。我们遍历整棵树,从叶子节点开始自底向上递推到根节点 \textit{root} 即能得出最后的答案为 \textit{dp}[\textit{root}]
  • 回到本题中,题目要求的其实是上题的扩展,即要求我们求出每个节点为根节点的时候,它与其他所有节点的距离之和。暴力的角度我们可以考虑对每个节点都做一次如上的树形动态规划,这样时间复杂度即为 O(N^2),那么有没有更优雅的方法呢?
    • 经过一次树形动态规划后其实我们获得了在 u 为根的树中,每个节点为根的子树的答案 \textit{dp},我们可以利用这些已有信息来优化时间复杂度。
    • 假设 u 的某个后代节点为 v,如果要算 v 的答案,本来我们要以 v 为根来进行一次树形动态规划。但是利用已有的信息,我们可以考虑树的形态做一次改变,让 v 换到根的位置,u 变为其孩子节点,同时维护原有的 \textit{dp} 信息。在这一次的转变中,我们观察到除了 uv\textit{dp} 值,其他节点的 \textit{dp} 值都不会改变,因此只要更新 \textit{dp}[u]\textit{dp}[v] 的值即可。
    • 那么我们来看 v 换到根的位置的时候怎么利用已有信息求出 \textit{dp}[u]\textit{dp}[v] 的值。重新回顾第一次树形动态规划的转移方程,我们可以知道当 u 变为 v 的孩子的时候 v 不在 u 的后代集合 \textit{son}[u] 中了,因此此时 \textit{dp}[u] 需要减去 v 的贡献,即

\textit{dp}[u]=\textit{dp}[u]-(\textit{dp}[v]+\textit{sz}[v])

  • 接上:
    • 同时 \textit{sz}[u] 也要相应减去 \textit{sz}[v]
    • v 的后代节点集合中多出了 u,因此 \textit{dp}[v] 的值要由 u 更新上来, \textit{sz}[v] 也要相应加上 \textit{sz}[u],即

\textit{dp}[v]=\textit{dp}[v]+(\textit{dp}[u]+\textit{sz}[u])

  • 至此我们完成了一次「换根」操作,在 O(1) 的时间内维护了 \textit{dp} 的信息,且此时的树结构以 v 为根。那么接下来我们不断地进行换根的操作,即能在 O(N) 的时间内求出每个节点为根的答案,实现了时间复杂度的优化。

复杂度分析:

  1. 时间复杂度:O(N),其中 N 是树中的节点个数。我们只需要遍历整棵树两次即可得到答案,其中每个节点被访问两次,因此时间复杂度为 O(2N)=O(N)
  2. 空间复杂度:O(N)。我们需要线性的空间存图,N 个节点的树包含 N-1 条边,数组 \textit{dp}\textit{sz} 的长度均为 N

编码实现

class Solution {
    int[] ans;
    int[] sz;
    int[] dp;
    List<List<Integer>> graph;

    public int[] sumOfDistancesInTree(int N, int[][] edges) {
        ans = new int[N];
        sz = new int[N];
        dp = new int[N];
        graph = new ArrayList<List<Integer>>();
        for (int i = 0; i < N; ++i) {
            graph.add(new ArrayList<Integer>());
        }
        for (int[] edge: edges) {
            int u = edge[0], v = edge[1];
            graph.get(u).add(v);
            graph.get(v).add(u);
        }
        dfs(0, -1);
        dfs2(0, -1);
        return ans;
    }

    public void dfs(int u, int f) {
        sz[u] = 1;
        dp[u] = 0;
        for (int v: graph.get(u)) {
            if (v == f) {
                continue;
            }
            dfs(v, u);
            dp[u] += dp[v] + sz[v];
            sz[u] += sz[v];
        }
    }

    public void dfs2(int u, int f) {
        ans[u] = dp[u];
        for (int v: graph.get(u)) {
            if (v == f) {
                continue;
            }
            int pu = dp[u], pv = dp[v];
            int su = sz[u], sv = sz[v];

            dp[u] -= dp[v] + sz[v];
            sz[u] -= sz[v];
            dp[v] += dp[u] + sz[u];
            sz[v] += sz[u];

            dfs2(v, u);

            dp[u] = pu;
            dp[v] = pv;
            sz[u] = su;
            sz[v] = sv;
        }
    }
}

精彩评论

跳转地址1:「手画图解」树中距离之和 | 树形DP | 思路详解

思路:


– 如图,我们看节点 2,其他节点和它之间的距离和,可以分为两部分:
– 节点 2 所在的子树中的节点,与它的距离和。
– 子树之外的节点与它的距离和。
– 前者是一个子树内部的问题,我们寻找一下递归关系。
– 定义distSum[i]表示:节点i所在的子树中的节点到节点i的距离和。

  • distSum[2]仅仅是它的子节点的distSum累加吗?不是的,还要算上走红色分支的次数。
    • 节点 2 到子树 3 中的节点,要走两次红色路径,因为子树 3 有两个节点。
    • 节点 2 到子树 4 中的节点,要走一次红色路径,因为子树 4 有一个节点。
    • 节点 2 到子树 5 中的节点,要走一次红色路径,因为子树 5 有一个节点。
  • 因此还需要计算每个子树的节点个数nodeNum[i],递归公式如下:

$$
nodeNum[root]=sum(nodeNum[child])+1 \\
distSum[root] = sum(nodeNum[child] + distSum[child])
$$

  • 从上往下递归,到达底部就遇到「结果已知的 base case」,随着递归的出栈,结果不断向上返回,最后求出当前子树的 distSum。
  • 什么时候结束递归呢,即,什么时候才算遇到 base case 呢?
    • 显然,我们需要遍历当前 root 节点的所有邻居,如果它的邻居只有它的父亲,说明它除了父亲,没有子节点,它就是 base case:nodeNum 为 1,distSum 为 0。
  • 因为我们要遍历所有的邻居,所以需要构建邻接关系表:
const graph = new Array(N);
for (let i = 0; i < graph.length; i++) {
    graph[i] = [];
}
for (const edge of edges) {
    const [from, to] = edge;
    graph[from].push(to);
    graph[to].push(from);
}
  • from bottom to top,由小的子树的结果,计算出大的子树的结果,即,在递归完当前节点的所有子树之后,再计算当前节点。在递归出栈时解决问题。因此,采用后序遍历:
const postOrder = (root, parent) => {
    const neighbors = graph[root]; // 与它相连的节点们
    for (const neighbor of neighbors) {
        if (neighbor == parent) {  // 如果邻居是自己父亲,跳过。
            continue;              // 如果邻居只有自己父亲,则for循环结束,当前递归结束  
        }
        postOrder(neighbor, root);
        nodeNum[root] += nodeNum[neighbor];
        distSum[root] += nodeNum[neighbor] + distSum[neighbor];
    }
};
  • 此时的distSum数组,存放 root 到所在子树中的节点的距离和,显然,还要加上子树外的节点到 root 的距离和,才是最后的结果。
  • 后者怎么求?不好求。需要求吗?其实不需要。
    • 我们看到,对整个树的根节点来说,它的distSum已经是正确的,因为,它的子树就是整棵树。
    • 我们这样:根据已知的对的distSum,自顶而下更新它下面的子节点的distSum。怎么利用已知的distSum[root]呢?
  • 图,节点2所在的子树的节点个数为nodeNum[2],从计算distSum[0]变成计算distSum[2]:从节点 0 到这些节点,变成从节点 2 到这些节点,这nodeNum[2]个节点,每个节点都少走了一步,一共少走了nodeNum[2]步。
  • 子树以外的节点呢,有N – nodeNum[2]个,从计算distSum[0]变成计算distSum[2]:从节点 0 到这些节点,变成从节点 2 到这些节点,每个节点都多走了一步,一共多走了N-nodeNum[2]步。
  • 因此,我们找到distSum[i]与distSum[root]之间的递推关系:

distSum[i] = distSum[root] – nodeNum[i] + (N – nodeNum[i])

  • 这是从上往下计算,不是等到遇到底部的 base case 向上返回再计算。而是,在压栈前解决问题:递归当前节点的子树之前,就更新出当前节点的distSum,使之正确,在子递归中,它作为正确的父节点 distSum,递推出子节点的 distSum。因此采用前序遍历:
const preOrder = (root, parent) => {
    const neighbors = graph[root];
    for (const neighbor of neighbors) {
      if (neighbor == parent) {
        continue;
      }
      distSum[neighbor] = distSum[root] - nodeNum[neighbor] + (N - nodeNum[neighbor]);
      preOrder(neighbor, root);
    }
  };
  • 可以看到,我们用了两次 DFS 搜索,前者是计算出每个子树的 nodeNum 和 distSum,后者是计算出真正的 distSum,即每个节点与「其他所有节点」的距离和,注意二者的区别。
const sumOfDistancesInTree = (N, edges) => {
  // 建立映射表,graph[i]:存放与节点i相连的所有节点
  const graph = new Array(N);
  for (let i = 0; i < graph.length; i++) {
    graph[i] = [];
  }
  for (const edge of edges) {
    const [from, to] = edge;
    graph[from].push(to);
    graph[to].push(from);
  }

  // distSum[i]:节点i到它所在子树的节点的距离和,后面更新为:节点i到其他所有节点的距离和
  const distSum = new Array(N).fill(0);
  // nodeNum[i]:节点i所在子树的节点个数,保底为1
  const nodeNum = new Array(N).fill(1);

  const postOrder = (root, parent) => {
    const neighbors = graph[root]; // 与它相连的节点们
    for (const neighbor of neighbors) {
      if (neighbor == parent) {    // 如果邻居是自己父亲,跳过。
        continue;                  // 如果邻居只有自己父亲,则for循环结束,当前递归结束  
      }
      postOrder(neighbor, root);
      nodeNum[root] += nodeNum[neighbor];
      distSum[root] += nodeNum[neighbor] + distSum[neighbor];
    }
  };

  const preOrder = (root, parent) => {
    const neighbors = graph[root];
    for (const neighbor of neighbors) {
      if (neighbor == parent) {
        continue;
      }
      distSum[neighbor] = distSum[root] - nodeNum[neighbor] + (N - nodeNum[neighbor]);
      preOrder(neighbor, root);
    }
  };

  postOrder(0, -1); // dfs的入口。因为N>=1,节点0肯定存在,就从节点0开始搜
  preOrder(0, -1);
  return distSum;
};
复盘总结
  • 我们重复利用了distSum数组这个空间。它先是代表:节点到它所在子树的节点的距离和。后面就更新为:节点到其他所有节点的距离和。
  • 当然,也可以开辟一个新的容器,毕竟它们两个是不同的定义。不开辟就节省了一点空间。
  • 本题的突破口,依然是:总是关注子树,关注子树,递归解决,子树是一种重复的结构,适合用递归求解,这道题巧妙地将目标拆分为两部分:
    • 子树内的节点与 root 的距离和
    • 子树外的节点与 root 的距离和
  • 用递归求解前者,这是主旋律,后者不用特地求,因为我们有一个已知的,正确的 distSum,利用递推公式,往下递推出每个节点的正确的 distSum,就像沿着一棵树在填表,这就是树形DP。
US-B.Ralph
LeetCode数据结构与算法算法

Leave a Comment

邮箱地址不会被公开。 必填项已用*标注

15 − 4 =