1. 项目背景与问题定义
最近在刷题社区看到一个挺有意思的二叉树问题——LintCode 3893题"二叉树中得到结果所需的最少翻转次数"。这个题目看似简单,但实际考察了对二叉树遍历和动态规划的综合运用。作为经常在LeetCode和LintCode上刷题的老手,我发现这类"最小操作次数"问题在实际面试中出现的频率相当高,特别是在大厂的算法轮。
题目要求:给定一棵二叉树,每个节点值为0或1。每次操作可以选择任意节点,将其值从0翻转为1,或从1翻转为0。求让整棵树满足"每个节点的值等于其左右子节点值的逻辑或"(即node.val = node.left.val | node.right.val)所需的最少翻转次数。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法思路解析
2.1 问题转化与关键观察
首先我们需要明确题目要求的树性质:对于每个节点,如果它有子节点,那么它的值必须是子节点值的"或"结果。这意味着:
- 如果一个节点值为1,那么至少有一个子节点必须为1
- 如果一个节点值为0,那么所有子节点都必须为0
这个性质给了我们重要的递归处理依据。我们可以自底向上地处理每个节点,考虑当前节点在不同取值情况下需要的最小翻转次数。
2.2 动态规划状态定义
对于树形DP问题,通常需要在节点处定义状态。这里我们定义:
- dp[node][0]:当node值为0时,以node为根的子树满足条件所需的最小翻转次数
- dp[node][1]:当node值为1时,以node为根的子树满足条件所需的最小翻转次数
最终答案就是min(dp[root][0], dp[root][1])。
2.3 状态转移方程
对于每个节点,我们需要考虑两种情况:
- 当前节点保持原值(不需要翻转)
- 当前节点翻转值(需要1次翻转)
然后根据子节点的状态来计算当前节点的状态值。具体转移方程如下:
java复制// 当前节点取0的情况
int cost0 = (node.val == 0) ? 0 : 1; // 是否需要翻转
dp[node][0] = cost0 + dp[left][0] + dp[right][0];
// 当前节点取1的情况
int cost1 = (node.val == 1) ? 0 : 1; // 是否需要翻转
int leftMin = Math.min(dp[left][0], dp[left][1]);
int rightMin = Math.min(dp[right][0], dp[right][1]);
dp[node][1] = cost1 + leftMin + rightMin;
3. Java实现详解
3.1 树节点定义
首先定义标准的二叉树节点类:
java复制class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
3.2 递归解法实现
采用后序遍历的方式递归计算每个节点的状态:
java复制public int minFlip(TreeNode root) {
int[] res = dfs(root);
return Math.min(res[0], res[1]);
}
private int[] dfs(TreeNode node) {
if (node == null) {
return new int[]{0, 0};
}
int[] left = dfs(node.left);
int[] right = dfs(node.right);
int[] dp = new int[2];
// 当前节点为0的情况
dp[0] = (node.val == 0 ? 0 : 1) + left[0] + right[0];
// 当前节点为1的情况
int leftMin = Math.min(left[0], left[1]);
int rightMin = Math.min(right[0], right[1]);
dp[1] = (node.val == 1 ? 0 : 1) + leftMin + rightMin;
return dp;
}
3.3 复杂度分析
- 时间复杂度:O(N),每个节点只被访问一次
- 空间复杂度:O(H),递归栈空间,H为树的高度
4. 边界条件与测试用例
4.1 特殊情况的处理
- 空树:返回0
- 单节点树:返回0(无需任何操作就满足条件)
- 只有左子树或右子树:将缺失的子节点视为0
4.2 测试用例设计
java复制// 测试用例1:简单二叉树
// 0
// / \
// 0 1
TreeNode root1 = new TreeNode(0);
root1.left = new TreeNode(0);
root1.right = new TreeNode(1);
System.out.println(minFlip(root1)); // 应输出0
// 测试用例2:需要翻转
// 1
// / \
// 0 0
TreeNode root2 = new TreeNode(1);
root2.left = new TreeNode(0);
root2.right = new TreeNode(0);
System.out.println(minFlip(root2)); // 应输出1
// 测试用例3:多层复杂情况
// 1
// / \
// 0 1
// / \
// 0 1
TreeNode root3 = new TreeNode(1);
root3.left = new TreeNode(0);
root3.right = new TreeNode(1);
root3.left.left = new TreeNode(0);
root3.left.right = new TreeNode(1);
System.out.println(minFlip(root3)); // 应输出1
5. 算法优化与变种思考
5.1 空间优化
上述实现使用了递归,可能会因为树的高度导致栈溢出。可以改为迭代式的后序遍历,使用栈来模拟递归过程:
java复制public int minFlipIterative(TreeNode root) {
if (root == null) return 0;
Deque<TreeNode> stack = new ArrayDeque<>();
Map<TreeNode, int[]> dpMap = new HashMap<>();
stack.push(root);
TreeNode prev = null;
while (!stack.isEmpty()) {
TreeNode curr = stack.peek();
if (prev == null || prev.left == curr || prev.right == curr) {
if (curr.left != null) stack.push(curr.left);
else if (curr.right != null) stack.push(curr.right);
} else if (curr.left == prev) {
if (curr.right != null) stack.push(curr.right);
} else {
curr = stack.pop();
int[] dp = new int[2];
int[] left = dpMap.getOrDefault(curr.left, new int[]{0, 0});
int[] right = dpMap.getOrDefault(curr.right, new int[]{0, 0});
dp[0] = (curr.val == 0 ? 0 : 1) + left[0] + right[0];
int leftMin = Math.min(left[0], left[1]);
int rightMin = Math.min(right[0], right[1]);
dp[1] = (curr.val == 1 ? 0 : 1) + leftMin + rightMin;
dpMap.put(curr, dp);
}
prev = curr;
}
int[] rootDp = dpMap.get(root);
return Math.min(rootDp[0], rootDp[1]);
}
5.2 问题变种思考
- 如果操作改为"翻转一个节点会同时翻转其所有子节点",该如何解决?
- 如果条件改为"与"操作而非"或"操作,算法需要如何调整?
- 如果每个翻转操作有不同的代价,如何计算最小总代价?
6. 实际应用场景
这类树形动态规划问题在实际中有广泛的应用:
- 游戏AI决策树优化:确定最少的操作步骤达到目标状态
- 网络路由优化:计算最小代价的配置变更
- 自动化测试:生成最小操作序列达到特定状态
- 编译器优化:确定最少的指令重排达到优化目标
7. 常见错误与调试技巧
在实现这类算法时,容易犯以下错误:
- 忘记处理空节点的情况,导致NullPointerException
- 混淆节点当前值和目标值的关系
- 状态转移方程写错,特别是"或"条件的实现
- 没有正确初始化叶子节点的状态
调试技巧:
- 打印每个节点的状态值,验证是否正确
- 使用小规模的测试用例手动验证
- 检查边界条件(空树、单节点树等)
8. 个人实现心得
在实际编码实现这个算法时,有几个关键点需要注意:
- 后序遍历的顺序至关重要,必须确保子节点的状态先于父节点计算
- 对于空子节点,其状态应视为[0,0],因为不存在的节点不影响父节点的值
- 在计算node.val=1的情况时,子节点可以取0或1中的较小值,这是"或"操作的性质决定的
- 翻转次数的计算要考虑当前节点是否需要翻转,这是cost0和cost1的作用
这个题目很好地展示了如何将树形结构和动态规划结合起来解决问题。在实际面试中,解释清楚状态定义和转移方程的逻辑比写出完美代码更重要。建议在练习时多画图,手动推导几个简单例子的状态值,这样能更深入地理解算法原理。
