算法套路十九——树形DP
树形 DP,即在树上进行的 DP。由于树固有的递归性质,这里的DP是指是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法,故虽然带有DP,但一般都是通过递归来进行。
算法示例一:LeetCode543. 二叉树的直径
给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。
易知二叉树的直径就是左、右子树的深度之和,故我们定义dfs求树的深度,再定义全局变量ans记录最长直径,在dfs中根据每个结点的左、右深度来更新ans直径,最后调用dfs,即可返回ans。
func diameterOfBinaryTree(root *TreeNode) int {
var dfs func(root *TreeNode) int
//ans记录直径
ans := 0
//dfs是返回的子树最大深度,但在dfs中通过左右深度来修改当前遍历到的最长直径
dfs = func(root *TreeNode) int {
// 如果当前节点为空,则返回-1
if root == nil {
return -1
}
// 递归计算左右子树的深度,加上当前结点则深度+1
left := dfs(root.Left) + 1
right := dfs(root.Right) + 1
// 如果左右子树深度之和大于当前最大直径,则更新最大直径
ans = max(ans, left+right)
// 返回当前子树的最大深度
return max(left, right)
}
dfs(root)
// 返回遍历过程中找到的最大直径
return ans
}
func max(a,b int)int{if a>b{return a};return b}
算法示例二:LeetCode2246. 相邻字符不同的最长路径
给你一棵 树(即一个连通、无向、无环图),根节点是节点 0 ,这棵树由编号从 0 到 n - 1 的 n 个节点组成。用下标从 0 开始、长度为 n 的数组 parent 来表示这棵树,其中 parent[i] 是节点 i 的父节点,由于节点 0 是根节点,所以 parent[0] == -1 。
另给你一个字符串 s ,长度也是 n ,其中 s[i] 表示分配给节点 i 的字符。
请你找出路径上任意一对相邻节点都没有分配到相同字符的 最长路径 ,并返回该路径的长度。
因为本题不一定是二叉树,我们首先要利用parent数组转换为孩子数组children ,children[i]记录所有节点i的孩子数组。
之后循环枚举neighbors,并定义maxLen 表示遍历过的父、子间的最长路径,len 表示当前这一条路径长度,若i节点与邻居节点字符不同,则判断是否先用maxLen 更新 ans,再更新 maxLen 巧妙得到 第一大和第二大的值,将最大值与次大值相加则可。
func longestPath(parent []int, s string) int {
n := len(parent)
children := make([][]int, n) // 孩子列表,表示每个节点的孩子节点
for i := 1; i < n; i++ { // 依据父亲节点填充孩子列表
children[parent[i]] = append(children[parent[i]], i)
}
ans := 0 // 最长路径长度
// 定义dfs函数,查找树从i节点向下的最长路径长度
var dfs func(int) int
dfs = func(i int) int {
maxLen := 0 // 记录从i向下最长的异色路径的长度
for _, child := range children[i] {
len := dfs(child) + 1 // 递归查找以孩子节点为起点,向下的路径长度,并累加上当前节点i
if s[child] != s[i] { // 如果孩子节点颜色与当前节点不同
ans = max(ans, maxLen+len) // 更新最长异色路径长度,将i到孩子节点+最长异色路径设置为新的最长路径长度
maxLen = max(maxLen, len) // 记录最长异色路径的长度
}
}
return maxLen // 返回从i向下最长的异色路径的长度
}
dfs(0) // 从根节点开始递归查找树中最长的异色路径长度
return ans + 1 // 返回最长路径长度 +1,也就是路径上节点的数量
}
func max(a,b int)int{if a>b{return a};return b}
算法示例三:LeetCode337. 打家劫舍 III
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root 。 除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。
如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。
给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。
每个节点可选择偷或者不偷两种状态,根据题目意思,相连节点不能一起偷
- 当前节点选择偷时,那么两个孩子节点就不能选择偷了
- 当前节点选择不偷时,两个孩子节点只需要拿最多的钱出来就行(两个孩子节点偷不偷没关系)
故直接定义dfs,返回两个值,第一个为选当前结点的最高金额,第二个为不选当前结点的最高金额
class Solution:
def rob(self, root: Optional[TreeNode]) -> int:
#返回两个值,第一个为选当前结点的最高金额,第二个为不选当前结点的最高金额
def dfs(node :TreeNode)->(int,int):
if node is None:
return 0,0
l_rob,l_not_rob=dfs(node.left)
r_rob,r_not_rob=dfs(node.right)
rob = l_not_rob + r_not_rob + node.val # 选node结点
not_rob = max(l_rob, l_not_rob) + max(r_rob, r_not_rob) # 不选node结点
return rob, not_rob
return max(dfs(root))
算法练习一:LeetCode124. 二叉树中的最大路径和
二叉树中的 路径 被定义为一条节点序列,序列中每对相邻节点之间都存在一条边。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。
路径和 是路径中各节点值的总和。
给你一个二叉树的根节点 root ,返回其 最大路径和 。
此题与上题基本一致,只是将所求值有直径改为路径上的结点和,故定义dfs返回当前结点左右子树的最长路径和,不过需要注意结点值可以为负数,则在dfs返回时需要判断路径和是否为负数,若是负数则返回0,表示该树不选择加入路径。
func maxPathSum(root *TreeNode) int {
var dfs func(root *TreeNode) int
// 将最大路径和初始化为负无穷
ans := -math.MaxInt
dfs = func(root *TreeNode) int {
if root == nil {
return 0
}
left := dfs(root.Left)
right := dfs(root.Right)
// 如果左右子树和当前节点的值之和大于当前最大路径和,则更新路径和ans
ans = max(ans, left+right+root.Val)
// 返回当前子树的最大路径和,若为负数则不能加入最大路径和,返回0
return max(0, max(left, right)+root.Val)
}
dfs(root)
// 返回遍历过程中找到的最大路径和
return ans
}
func max(a,b int)int{if a>b{return a};return b}
算法练习二:LeetCode687. 最长同值路径
给定一个二叉树的 root ,返回 最长的路径的长度 ,这个路径中的 每个节点具有相同值 。 这条路径可以经过也可以不经过根节点。
两个节点之间的路径长度 由它们之间的边数表示。
func longestUnivaluePath(root *TreeNode) (ans int) {
var dfs func(*TreeNode) int
dfs = func(node *TreeNode) int {
if node == nil {
return -1 // 下面 +1 后,对于叶子节点就刚好是 0
}
lLen := dfs(node.Left) + 1 // 左子树最大链长+1,在dfs内部可更新ans
rLen := dfs(node.Right) + 1 // 右子树最大链长+1,在dfs内部可更新ans
if node.Left != nil && node.Left.Val != node.Val {
lLen = 0 // 链长视作 0
}
if node.Right != nil && node.Right.Val != node.Val {
rLen = 0 // 链长视作 0
}
ans = max(ans, lLen+rLen) // 两条链拼成路径
return max(lLen, rLen) // 当前子树最大链长
}
dfs(root)
return
}
func max(a, b int) int { if a < b { return b }; return a }
算法进阶一:LeetCode1617. 统计子树中城市之间最大距离
给你 n 个城市,编号为从 1 到 n 。同时给你一个大小为 n-1 的数组 edges ,其中 edges[i] = [ui, vi] 表示城市 ui 和 vi 之间有一条双向边。题目保证任意城市之间只有唯一的一条路径。换句话说,所有城市形成了一棵 树 。
一棵 子树 是城市的一个子集,且子集中任意城市之间可以通过子集中的其他城市和边到达。两个子树被认为不一样的条件是至少有一个城市在其中一棵子树中存在,但在另一棵子树中不存在。
对于 d 从 1 到 n-1 ,请你找到城市间 最大距离 恰好为 d 的所有子树数目。
请你返回一个大小为 n-1 的数组,其中第 d 个元素(下标从 1 开始)是城市间 最大距离 恰好等于 d 的子树数目。
请注意,两个城市间距离定义为它们之间需要经过的边的数目。2 <= n <= 15
本题有较大难度,需要用到多种技巧,如下所示:
- 根据算法示例二LeetCode2246. 相邻字符不同的最长路径进行扩展,要根据给定的双向边edges数组得出neighbors邻居数组,即既要考虑子结点也要考虑父亲节点, neighbors[i]记录i节点的所有邻居节点,此时该所有的邻居数组组合起来,就能得到一颗树
- 根据示例一LeetCode543. 二叉树的直径继续扩展,本题给定选择的城市数组inSet,返回该数组组成的树的最长直径,并用visit记录所有遍历的节点
- 利用LeetCode78. 子集,采用回溯法选或不选的思路,从所有城市中选择出所有的城市子集
- 对比遍历城市节点visit与所选城市节点inSet,若两者长度不一样,则说明所选的城市并没有遍历完全,即inSet数组并不是一个连通树,因此不能加入ans中;若两者长度一样,则说明是连通树,才能加入ans中
func countSubgraphsForEachDiameter(n int, edges [][]int) []int {
// neighbors[i]记录i节点的所有邻居节点
neighbors := make([][]int, n)
for _, e := range edges {
x, y := e[0]-1, e[1]-1 // 编号改为从 0 开始
neighbors[x] = append(neighbors[x], y)
neighbors[y] = append(neighbors[y], x)
}
// dfs函数求以x为初始节点并深度优先遍历求树的直径,inSet数组记录目前选择的城市,vis数组记录节点是否被访问
var inSet, vis [15]bool
var diameter int
var dfs func(int) int
dfs = func(x int) (maxLen int) {
vis[x] = true
for _, neighbor := range neighbors[x] {
if !vis[neighbor] && inSet[neighbor] {
//深度优先遍历
ml := dfs(neighbor) + 1
diameter = max(diameter, maxLen+ml)
maxLen = max(maxLen, ml)
}
}
return
}
ans := make([]int, n-1)
var f func(int)
//递归函数f,采用回溯法,用ans记录从城市i到n间最大距离恰好为d的所有子树数目
f = func(i int) {
if i == n {
for v, b := range inSet {
if b {
//先初始化vis访问数组与选择的inSet数组组成的树的直径diameter
vis, diameter = [15]bool{}, 0
//以v为初始节点开始遍历求树的直径diameter,并直接break退出循环
dfs(v)
break
}
}
//如果直径大于0,且所有选择的节点都可以被遍历到即是一个连通树,则将距离加入ans中
if diameter > 0 && vis == inSet {
//ans[0]表示长度为1的子树数目,故diameter-1
ans[diameter-1]++
}
return
}
// 不选城市 i
f(i + 1)
// 选城市 i
inSet[i] = true
f(i + 1)
inSet[i] = false // 恢复现场
}
f(0)
return ans
}
func max(a, b int) int { if a < b { return b }; return a }
算法进阶二:LeetCode2538. 最大价值和与最小价值和的差值
给你一个 n 个节点的无向无根图,节点编号为 0 到 n - 1 。给你一个整数 n 和一个长度为 n - 1 的二维整数数组 edges ,其中 edges[i] = [ai, bi] 表示树中节点 ai 和 bi 之间有一条边。
每个节点都有一个价值。给你一个整数数组 price ,其中 price[i] 是第 i 个节点的价值。
一条路径的 价值和 是这条路径上所有节点的价值之和。
你可以选择树中任意一个节点作为根节点 root 。选择 root 为根的 开销 是以 root 为起点的所有路径中,价值和 最大的一条路径与最小的一条路径的差值。
请你返回所有节点作为根节点的选择中,最大 的 开销 为多少。
法一:
与进阶一类似,用neighbors[i] 记录第i个节点的邻居数组,dfs求以i为根节点的最大路径和,由于价值都是正数,因此价值和最小的一条路径一定只有一个点,「价值和最大的一条路径与最小的一条路径的差值」等价于「去掉路径的一个端点即叶子结点」,故ans直接用最大路径和dfs(i)-price[i],返回最大的ans即最大开销
func maxOutput(n int, edges [][]int, price []int) int64 {
//建立邻居数组
neighbors := make([][]int, n)
for _, e := range edges {
x, y := e[0], e[1]
neighbors[x] = append(neighbors[x], y)
neighbors[y] = append(neighbors[y], x)
}
ans:=0
vis:=[]int{}
for i:=0;i<n;i++{
vis=append(vis,0)
}
//dfs返回以i为根节点的最大路径和
var dfs func(int)int
dfs=func(i int)int{
maxCost:=price[i]
vis[i]=1
for _,nxt:= range neighbors[i]{
if vis[nxt]==0{
vis[nxt]=1
cost:=price[i]+dfs(nxt)
maxCost=max(maxCost,cost)
vis[nxt]=0
}
}
vis[i]=0
return maxCost
}
for i:=0;i<n;i++{
//如果i为叶子结点,则遍历i为根
if len(neighbors[i])==1{
ans=max(ans,dfs(i)-price[i])
}
}
return int64(ans)
}
func max(a, b int) int { if a < b { return b }; return a }
法二:
上一种方法会提示超时,主要原因在于求ans时需要循环遍历以每个叶子结点为根的情况,但其实我们可以类比树的直径与算法示例三LeetCode337. 打家劫舍 III,对于每个结点,有两种情况为包括叶子结点与不包括叶子结点,而最终结果只能有一个包括叶子结点的情况,因此我们直接通过dfs返回 返回带叶子的最大路径和,不带叶子的最大路径和来一次求出ans,且上题中我们使用vis判断是否遍历结点,但其实我们只需要记录pre即前一个遍历的结点,遍历neighbors[i]时只需要不等于pre即可
func maxOutput(n int, edges [][]int, price []int) int64 {
ans := 0
neighbor := make([][]int, n)
for _, e := range edges {
x, y := e[0], e[1]
neighbor[x] = append(neighbor[x], y)
neighbor[y] = append(neighbor[y], x) // 建树
}
// 返回带叶子的最大路径和,不带叶子的最大路径和
var dfs func(int, int) (int, int)
//x表示当前结点,pre表示前一个遍历的结点
dfs = func(x, pre int) (int, int) {
p := price[x]
//maxS1记录带叶子的最大路径和,maxS2记录不带叶子的最大路径和
maxS1, maxS2 := p, 0
for _, nxt := range neighbor[x] {
//遍历x的邻居数组,如果nxt!=pre则更新maxS1, maxS2
if nxt != pre {
//nxt作为当前结点,x作为pre前一个遍历结点
s1, s2 := dfs(nxt, x)
// 前面最大带叶子的路径和 + 当前不带叶子的路径和
// 前面最大不带叶子的路径和 + 当前带叶子的路径和
ans = max(ans, max(maxS1+s2, maxS2+s1))
maxS1 = max(maxS1, s1+p)
maxS2 = max(maxS2, s2+p) // 这里加上p是因为neighbor[x]包括pre外的元素,说明x 必不是叶子
}
}
return maxS1, maxS2
}
//-1表示没有前一个遍历结点
dfs(0, -1)
return int64(ans)
}
func max(a, b int) int { if b > a { return b }; return a }
算法进阶三:LeetCode1377. T 秒后青蛙的位置
给你一棵由 n 个顶点组成的无向树,顶点编号从 1 到 n。青蛙从 顶点 1 开始起跳。规则如下:
在一秒内,青蛙从它所在的当前顶点跳到另一个 未访问 过的顶点(如果它们直接相连)。
青蛙无法跳回已经访问过的顶点。
如果青蛙可以跳到多个不同顶点,那么它跳到其中任意一个顶点上的机率都相同。
如果青蛙不能跳到任何未访问过的顶点上,那么它每次跳跃都会停留在原地。
无向树的边用数组 edges 描述,其中 edges[i] = [ai, bi] 意味着存在一条直接连通 ai 和 bi 两个顶点的边。
返回青蛙在 t 秒后位于目标顶点 target 上的概率。与实际答案相差不超过 10-5 的结果将被视为正确答案。
本题与算法进阶一类似,不过更加简单,关键也在于利用edges建立邻居节点数组neighbors
func frogPosition(n int, edges [][]int, t int, target int) float64 {
//邻居节点
neighbors:=make([][]int,n+1)
for _,e:=range edges{
x,y:=e[0],e[1]
neighbors[x]=append(neighbors[x],y)
neighbors[y]=append(neighbors[y],x)
}
var dfs func(int,int,int)float64
//dfs返回第j秒,从第i个节点到target的概率,pre则记录前一个遍历的结点
dfs=func(i,j,pre int)float64{
//判断边界条件
if j==t&&i==target{
return 1.0
}else if j==t{
return 0.0
}
//res记录返回的概率
res:=float64(0)
//cnt记录未被遍历的邻居点个数
cnt:=0
//循环遍历每个邻居节点
for _,nxt:=range neighbors[i]{
//判断是否被遍历过
if nxt!=pre{
cnt++
res+=dfs(nxt,j+1,i)
}
}
if cnt==0{
return dfs(i,j+1,pre)
}
//除以可以跳到的点,由于青蛙从1开始跳,不能简单的除以len(neighbors[i])-1
return res/float64(cnt)
}
return dfs(1,0,1)
}
算法进阶四:LeetCode2646. 最小化旅行的价格总和
现有一棵无向、无根的树,树中有 n 个节点,按从 0 到 n - 1 编号。给你一个整数 n 和一个长度为 n - 1 的二维整数数组
edges ,其中 edges[i] = [ai, bi] 表示树中节点 ai 和 bi 之间存在一条边。
每个节点都关联一个价格。给你一个整数数组 price ,其中 price[i] 是第 i 个节点的价格。给定路径的 价格总和
是该路径上所有节点的价格之和。 另给你一个二维整数数组 trips ,其中 trips[i] = [starti, endi] 表示您从节点
starti 开始第 i 次旅行,并通过任何你喜欢的路径前往节点 endi 。在执行第一次旅行之前,你可以选择一些 非相邻节点
并将价格减半。 返回执行所有旅行的最小价格总和。
利用cnt记录结点遍历次数,然后dfs返回每个结点减半与不减半两种情况的结果
func minimumTotalPrice(n int, edges [][]int, price []int, trips [][]int) int {
neighbors := make([][]int, n)
for _, e := range edges {
x, y := e[0], e[1]
neighbors[x] = append(neighbors[x], y)
neighbors[y] = append(neighbors[y], x)
}
cnt:=make([]int,n)
for _,trip:=range trips{
end:=trip[1]
//for循环内部的dfs函数,用来记录路径进过结点的次数,且树只有唯一简单路径
var dfs func(int,int)bool
//i表示当前遍历结点,pre记录前一个遍历的结点
dfs=func(i,pre int)bool{
if i==end{
cnt[i]++
return true
}
for _,nxt:=range(neighbors[i]){
if nxt!=pre&&dfs(nxt,i){
cnt[i]++
return true
}
}
return false
}
dfs(trip[0],-1)
}
//dfs返回两个值,分别是当前结点折半与不折半的值
var dfs func(int,int)(int,int)
dfs=func(i,pre int)(int,int){
mins1,mins2:=price[i]*cnt[i],price[i]*cnt[i]/2
for _,nxt:=range(neighbors[i]){
if nxt!=pre{
s1,s2:=dfs(nxt,i)
mins1+=min(s1,s2)// i未减半,那么nxt可以不减半,可以减半,取这两种情况的最小值
mins2+=s1 // i减半,那么nxt只能不减半
}
}
return mins1,mins2
}
ans1, ans2 := dfs(0, -1)
return min(ans1, ans2)
}
func min(a,b int)int{if a>b{return b};return a}