给定一个二叉树,我们在树的节点上安装摄像头。
节点上的每个摄影头都可以监视其父对象、自身及其直接子对象。
计算监控树的所有节点所需的最小摄像头数量。
示例 1:
输入:[0,0,null,0,0]
输出:1
解释:如图所示,一台摄像头足以监控所有节点。
示例 2:
输入:[0,0,null,0,null,0,null,null,0]
输出:2
解释:需要至少两个摄像头来监视树的所有节点。 上图显示了摄像头放置的有效位置之一。
动态规划
本题以二叉树为背景,不难想到用递归的方式求解。本题的难度在于如何从左、右子树的状态,推导出父节点的状态。
对于本题,我们假定一个二维数组dp,分别代表所选节点与其父节点的四个状态。
dp[0][0] = 第一位代表父节点没有监控,第二位代表该节点没有监控;
dp[0][1] = 第一位代表父节点没有监控,第二位代表该节点有监控;
dp[1][0] = 第一位代表父节点有监控,第二位代表该节点没有监控;
dp[1][1] = 第一位代表父节点有监控,第二位代表该节点有监控;
我们对左右子树,分别建立一个二维数组。
let l = new Array(2).fill().map(item => new Array(2));
let r = new Array(2).fill().map(item => new Array(2));
对左右子树进行递归。
计算dp数组每一个位置的最小值。
// 由于当前节点没有监控,且当前节点的父节点没有监控,所以若要监控到该元素,那么该
// 节点的左右子树中,至少要有一个节点需要有监控,所以遍历所有组合取最小值。
dp[0][0] = Math.min(l[0][1] + r[0][0], l[0][0] + r[0][1], l[0][1] + r[0][1]);
// 当该节点的父节点有监控时,则该节点的左右子节点不必须有监控,也能监控到该节点
dp[1][0] = Math.min(dp[0][0], l[0][0] + r[0][0])
// 当该节点有监控时,其父节点与其左右子节点有无监控都可以,遍历其所有情况取最小值
// 并+1,代表加上该节点的监控
dp[0][1] = Math.min(l[1][0] + r[1][0], l[1][1] + r[1][1], l[1][0] + r[1][1], l[1][1] + r[1][0]) + 1
dp[1][1] = dp[0][1]
边界情况
当遍历到空节点时
// 当遍历到空节点,需要初始化dp
if (!root) {
// 假定该节点没有监控,将其节点的监控数设为0;
dp[0][0] = 0;
dp[1][0] = 0;
// 假定当前节点有监控,因为当前节点并不存在,不可能有监控,所以设为无穷大
dp[0][1] = Infinity;
dp[1][1] = Infinity;
return dp
}
当遍历到叶子结点,也就是该节点并不存在子结点时
// 当该节点的左右子节点都不存在时
if (!root.left && !root.right) {
// 当该节点没有监控,且其父节点也没有监控时,由于其没有子结点,所以该节点必不能被
// 监控到,所以该情况不成立,将其设为无穷大,表示其不存在
dp[0][0] = Infinity;
// 当该节点没有监控其父节点有监控时,该节点可以被监控到,且自身没有监控,没有子结
// 点,所以该节点的监控数为0
dp[1][0] = 0;
// 当该节点有监控时,无论其父节点是否有监控,其都能被监控到,并且当前节点的监控数
// 为1
dp[1][1] = 1;
dp[0][1] = 1;
return dp
}
当所有结点都被遍历完成之后,回到根节点,dp二维数组里面的数就代表了根节点的监控数的所有情况。而我们只需要判断两种情况下的大小比较即可,这两种情况就是
// 由于根节点并不存在父节点,所以其必在dp[0][1], dp[0][0]中寻找最小值。
return Math.min(dp[0][1], dp[0][0])
最终的代码成果为:
var minCameraCover = function (root) {
let dp = new Array(2).fill().map(item => new Array(2));
dp = getDp(root, dp);
return Math.min(dp[0][1], dp[0][0])
}
function getDp(root, dp) {
if (!root) {
dp[0][0] = 0;
dp[0][1] = Infinity;
dp[1][0] = 0;
dp[1][1] = Infinity;
return dp
}
if (!root.left && !root.right) {
dp[0][0] = Infinity;
dp[1][0] = 0;
dp[1][1] = 1;
dp[0][1] = 1;
return dp
}
let l = new Array(2).fill().map(item => new Array(2));
let r = new Array(2).fill().map(item => new Array(2));
l = getDp(root.left, l);
r = getDp(root.right, r);
dp[0][0] = Math.min(l[0][1] + r[0][0], l[0][0] + r[0][1], l[0][1] + r[0][1]);
dp[1][0] = Math.min(dp[0][0], l[0][0] + r[0][0])
dp[0][1] = Math.min(l[1][0] + r[1][0], l[1][1] + r[1][1], l[1][0] + r[1][1], l[1][1] + r[1][0]) + 1
dp[1][1] = dp[0][1]
return dp
};
动态规划方法二
假设当前节点为 root,其左右孩子为 left, right。如果要覆盖以 root 为根的树,有两种情况:
- 若在
root处安放摄像头,则孩子left,right一定也会被监控到。此时,只需要保证left的两棵子树被覆盖,同时保证right的两棵子树也被覆盖即可。 - 否则, 如果
root处不安放摄像头,则除了覆盖root的两棵子树之外,孩子left,right之一必须要安装摄像头,从而保证root会被监控到。
根据上面的讨论,能够分析出,对于每个节点 root ,需要维护三种类型的状态:
- 状态 a:
root必须放置摄像头的情况下,覆盖整棵树需要的摄像头数目。 - 状态 b:覆盖整棵树需要的摄像头数目,无论
root是否放置摄像头。 - 状态 c:覆盖两棵子树需要的摄像头数目,无论节点
root本身是否被监控到。
根据它们的定义,一定有 。
对于节点 root 而言,设其左右孩子 left, right 对应的状态变量分别为 以及 。根据一开始的讨论,我们已经得到了求解 的过程:
对于 c 而言,要保证两棵子树被完全覆盖,要么 root 处放置一个摄像头,需要的摄像头数目为 a;要么 root 处不放置摄像头,此时两棵子树分别保证自己被覆盖,需要的摄像头数目为 。
需要额外注意的是,对于 root 而言,如果其某个孩子为空,则不能通过在该孩子处放置摄像头的方式,监控到当前节点。因此,该孩子对应的变量 a 应当返回一个大整数,用于标识不可能的情形。
最终,根节点的状态变量 b 即为要求出的答案。
var minCameraCover = function(root) {
const dfs = (root) => {
if (!root) {
return [Math.floor(Infinity / 2), 0, 0];
}
const [la, lb, lc] = dfs(root.left);
const [ra, rb, rc] = dfs(root.right);
const a = lc + rc + 1;
const b = Math.min(a, la + rb, ra + lb);
const c = Math.min(a, lb + rb);
return [a, b, c];
}
return dfs(root)[1];
};
动态规划方法三
这道题目难在两点:
- 需要确定遍历方式
- 需要状态转移的方程
本题并不是动态规划,其本质是贪心,但我们要确定状态转移方式,而且要在树上进行推导,所以难度就上来了,一些同学知道这道题目难,但其实说不上难点究竟在哪。
-
需要确定遍历方式
在安排选择摄像头的位置的时候,我们要从底向上进行推导,因为尽量让叶子节点的父节点安装摄像头,这样摄像头的数量才是最少的 ,这也是本道贪心的原理所在!
就是后序遍历也就是左右中的顺序,这样就可以从下到上进行推导了。
function traversal(root,res) { // 空节点,该节点有覆盖 if (!root) return ; let left = traversal(root.left); // 左 let right = traversal(root.right); // 右 res.push(root.val) // 中 return; }注意在以上代码中我们取了左孩子的返回值,右孩子的返回值,即 left 和 right, 以后推导中间节点的状态
-
需要状态转移的方程
确定了遍历顺序,再看看这个状态应该如何转移,先来看看每个节点可能有几种状态:
可以说有如下三种:
- 该节点无覆盖
- 本节点有摄像头
- 本节点有覆盖
我们分别有三个数字来表示:
- 0:该节点无覆盖
- 1:本节点有摄像头
- 2:本节点有覆盖
大家应该找不出第四个节点的状态了。
一些同学可能会想有没有第四种状态:本节点无摄像头,其实无摄像头就是 无覆盖 或者 有覆盖的状态,所以一共还是三个状态。
那么问题来了,空节点究竟是哪一种状态呢? 空节点表示无覆盖? 表示有摄像头?还是有覆盖呢?
回归本质,为了让摄像头数量最少,我们要尽量让叶子节点的父节点安装摄像头,这样才能摄像头的数量最少。
那么空节点不能是无覆盖的状态,这样叶子节点就可以放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。
所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了
接下来就是递推关系。
那么递归的终止条件应该是遇到了空节点,此时应该返回 2(有覆盖),原因上面已经解释过了。
代码如下:
// 空节点,该节点有覆盖
if (!root) return 2;
递归的函数,以及终止条件已经确定了,再来看单层逻辑处理。
主要有如下四类情况:
-
情况1:左右节点都有覆盖
左孩子有覆盖,右孩子有覆盖,那么此时中间节点应该就是无覆盖的状态了。
代码如下:
// 左右节点都有覆盖 if (left == 2 && right == 2) return 0; -
情况2:左右节点至少有一个无覆盖的情况
如果是以下情况,则中间节点(父节点)应该放摄像头:
left == 0 && right == 0左右节点无覆盖left == 1 && right == 0左节点有摄像头,右节点无覆盖left == 0 && right == 1左节点有无覆盖,右节点摄像头left == 0 && right == 2左节点无覆盖,右节点覆盖left == 2 && right == 0左节点覆盖,右节点无覆盖这个不难理解,毕竟有一个孩子没有覆盖,父节点就应该放摄像头。
此时摄像头的数量要加一,并且
return 1,代表中间节点放摄像头。代码如下:
if (left == 0 || right == 0) { result++; return 1; } -
情况3:左右节点至少有一个有摄像头
如果是以下情况,其实就是
如果是以下情况,其实就是 左右孩子节点有一个有摄像头了,那么其父节点就应该是2(覆盖的状态)
left == 1 && right == 2左节点有摄像头,右节点有覆盖left == 2 && right == 1左节点有覆盖,右节点有摄像头left == 1 && right == 1左右节点都有摄像头代码如下:
if (left == 1 || right == 1) return 2;从这个代码中,可以看出,如果
left == 1,right == 0怎么办?其实这种条件在情况 2 中已经判断过了,如图:这种情况也是大多数同学容易迷惑的情况。
-
情况4:头结点没有覆盖
以上都处理完了,递归结束之后,可能头结点 还有一个无覆盖的情况,如图:
所以递归结束之后,还要判断根节点,如果没有覆盖,result++,代码如下:
function minCameraCover(root) { result = 0; if (traversal(root) == 0) { // root 无覆盖 result++; } return result; }以上四种情况我们分析完了,代码也差不多了,整体代码如下:
var minCameraCover = function(root) { const traversal = (cur) =>{ // 空节点,该节点有覆盖 if(cur == null) return 2; let left = traversal(cur.left); //左 let right = traversal(cur.right); //右 // 情况1 // 左右节点都有覆盖 if(left == 2 && right == 2) return 0; // 情况2 // left == 0 && right == 0 左右节点无覆盖 // left == 1 && right == 0 左节点有摄像头,右节点无覆盖 // left == 0 && right == 1 左节点有无覆盖,右节点摄像头 // left == 0 && right == 2 左节点无覆盖,右节点覆盖 // left == 2 && right == 0 左节点覆盖,右节点无覆盖 if(left == 0 || right == 0){ res++; return 1; } // 情况3 // left == 1 && right == 2 左节点有摄像头,右节点有覆盖 // left == 2 && right == 1 左节点有覆盖,右节点有摄像头 // left == 1 && right == 1 左右节点都有摄像头 // 其他情况前段代码均已覆盖 if (left == 1 || right == 1) return 2; // 以上代码我没有使用else,主要是为了把各个分支条件展现出来,这样代码有助于读者理解 // 这个 return -1 逻辑不会走到这里。 return -1; } let res = 0; // 情况4 if (traversal(root) == 0) { // root 无覆盖 res++; } return res; };