在树上动态规划

在树上动态规划

LeetCode 上有两个问题,挺有意思:

337. 打家劫舍 III

在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。
这个地区只有一个入口,我们称之为“根”。 除了“根”之外,每栋房子有且只有一个“父“房子与之相连。
一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。
如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。

计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。

示例 1:

输入: [3,2,3,null,3,null,1]

     3
    / \
   2   3
    \   \
     3   1

输出: 7
解释: 小偷一晚能够盗取的最高金额 = 3 + 3 + 1 = 7.

示例 2:

输入: [3,4,5,1,3,null,1]

     3
    / \
   4   5
  / \   \
 1   3   1

输出: 9
解释: 小偷一晚能够盗取的最高金额 = 4 + 5 = 9.

968. 监控二叉树

给定一个二叉树,我们在树的节点上安装摄像头。
节点上的每个摄影头都可以监视其父对象、自身及其直接子对象。
计算监控树的所有节点所需的最小摄像头数量。

示例 1:

     0
    / 
   📸   
  / \  
 0   0   

输入:[0,0,null,0,0]
输出:1
解释:如图所示,一台摄像头足以监控所有节点。

示例 2:

         0
        / 
       📸   
      / 
     0
    /
   📸
     \
      0

输入:[0,0,null,0,null,0,null,null,0]
输出:2
解释:需要至少两个摄像头来监视树的所有节点。 上图显示了摄像头放置的有效位置之一。

提示:
给定树的节点数的范围是 [1, 1000]。
每个节点的值都是 0。

分析

树上打劫

让我们从简单一点的第一个问题开始

从根节点开始,递归地做动态规划——这是我第一次抛开数组做动态规划~ 对于当前节点,有两个情况:选或不选(偷或不偷),当然当前节点做了选择后会影响临近节点。 因为是从上到下,可以认为当前节点的选择只影响其孩子节点:

如果选择当前节点,那么它的孩子节点就不能选 
如果不选择当前节点,那么选不选它的孩子节点都行 

第一版代码如下:

func rob(root *TreeNode) int {
	var dfs func(node *TreeNode, selected bool) int
	dfs = func(node *TreeNode, selected bool) int {
		if node == nil {
			return 0
		}
		leftNotTake := dfs(node.Left, false)
		rightNotTake := dfs(node.Right, false)
		if selected {
			return node.Val + leftNotTake + rightNotTake
		}
		leftTake := dfs(node.Left, true)
		rightTake := dfs(node.Right, true)
		return max(leftTake, leftNotTake) + max(rightTake, rightNotTake)
	}
	return max(dfs(root, true), dfs(root, false))
}

这就是在树上边做了动态规划。 不过当前的写法会在 LeetCode 最后一个用例超时。 有一个轻巧的改进,将 dfs 函数多个参数改成多个返回值:

func rob(root *TreeNode) int {
	return max(dfs(root))
}

func dfs(node *TreeNode) (int, int) {
	if node == nil {
		return 0, 0
	}
	leftTake, leftNotTake := dfs(node.Left)
	rightTake, rightNotTake := dfs(node.Right)
	take := node.Val + leftNotTake + rightNotTake
	notTake := max(leftTake, leftNotTake) + max(rightTake, rightNotTake)
	return take, notTake
}

这样就通过了所有用例。 细想想,一开始的写法有比较多的重复计算,对于同一个节点,会调用两次 dfs 函数。 但是后边的写法没有重复计算,对同一个节点只调用了一次 dfs 函数。 (也因为这样, 无需加备忘录优化)。

不但性能提升了,代码也更精简了。

时间复杂度 O(n), 空间复杂度 O(h), n 是节点总数,所有节点都遍历了; h 是 树的高度,递归栈的大小

树上监控

这个问题稍微复杂一点。 对于一个节点,仅用是否安装了相机这一个状态没法得到结果,还需加一个状态:是否被监控。 这两个状态会有重合,安装了相机意味着同时被监控了;没装相机,也有可能被监控。

同样有第一版代码:

func minCameraCover0(root *TreeNode) int {
	var help func(*TreeNode, bool, bool) int
	// placeCam,是否在 node 处安装相机;
	// watched,node 是否被父节点或自身监控(递归过程是自上而下,对于当前节点,只知道父节点或自身是否监控自己,并不知道子节点的情况)
	help = func(node *TreeNode, placeCam, watched bool) int {
		if node == nil {
			if placeCam {
				return math.MaxInt32
			}
			return 0
		}

		leftPlaceWatch := help(node.Left, true, true)
		rightPlaceWatch := help(node.Right, true, true)

		if placeCam {
			leftNotPlaceWatch := help(node.Left, false, true)
			rightNotPlaceWatch := help(node.Right, false, true)
			return 1 + min(
				leftNotPlaceWatch+rightNotPlaceWatch, // 两个子节点都不安装相机
				leftPlaceWatch+rightNotPlaceWatch,    // 仅左子节点安装相机
				leftNotPlaceWatch+rightPlaceWatch) // 仅右子节点安装相机
			// 两个子节点都装相机的情况不用考虑
		}
		leftNotPlaceNotWatch := help(node.Left, false, false)
		rightNotPlaceNotWatch := help(node.Right, false, false)
		res := min(
			leftPlaceWatch+rightPlaceWatch,       // 两个子节点都安装相机
			leftPlaceWatch+rightNotPlaceNotWatch, // 左装右不装
			leftNotPlaceNotWatch+rightPlaceWatch) // 右装左不装
		if watched {
			res = min(res, leftNotPlaceNotWatch+rightNotPlaceNotWatch) // 左右都不装,当前节点是被其父节点监控的
		}
		return res

	}
	return min(help(root, true, true), help(root, false, false))
}

果然,这个写法的战绩是:160 / 170 个通过测试用例,后边超时了。

同样修改多个函数入参为多个返回值, 对于当前节点,可以返回下边三种情况下的结果:

hasCam:                 有相机
noCamWatchedByParent:   没相机,被父节点监控
noCamWatchedBySons:     没相机,被子节点监控
func minCameraCover(root *TreeNode) int {
	var dfs func(node *TreeNode) (int, int, int)
	dfs = func(root *TreeNode) (int, int, int) {
		if root == nil {
			return math.MaxInt32, 0, 0
		}
		lHasCam, lNoCamWatchedByParent, lNoCamWatchedBySons := dfs(root.Left)
		rHasCam, rNoCamWatchedByParent, rNoCamWatchedBySons := dfs(root.Right)

		hasCam := 1 + min(lHasCam, lNoCamWatchedByParent, lNoCamWatchedBySons) +
			min(rHasCam, rNoCamWatchedByParent, rNoCamWatchedBySons)

		noCamWatchedByParent := min(lHasCam, lNoCamWatchedBySons) +
			min(rHasCam, rNoCamWatchedBySons)

		noCamWatchedBySons := min(lHasCam+rNoCamWatchedBySons, lHasCam+rHasCam, lNoCamWatchedBySons+rHasCam)

		return hasCam, noCamWatchedByParent, noCamWatchedBySons
	}
	hasCam, _, noCamWatchedBySons := dfs(root)
	return min(hasCam, noCamWatchedBySons)
}

func min(s ...int) int {
	res := s[0]
	for _, v := range s[1:] {
		if res > v {
			res = v
		}
	}
	return res
}

AC了,时空复杂度都同树上打劫的那个问题。

另一个思路:从叶子结点向上,贪心地设置相机。

type state = int

const (
	hasCamera state = iota
	covered
	notCovered
)

func minCameraCover(root *TreeNode) int {
	res := 0
	var dfs func(*TreeNode) state
	dfs = func(root *TreeNode) state {
		if root == nil {
			return covered // 贪心,为了使的叶子结点尽量不放置相机
		}
		left, right := dfs(root.Left), dfs(root.Right)
		if left == notCovered || right == notCovered {
			res++
			return hasCamera
		}
		if left == covered && right == covered {
			return notCovered
		}
		return covered
	}

	if dfs(root) == notCovered {
		res++
	}
	return res
}

小结

整体还是动态规划的思想,只是具体实现是在树上。
多状态的递归,将状态写到返回值,优于写到入参里,无论从可读性还是从性能。

延伸