一、树基础知识
1. 树概念
树是算法面试经常遇到的数据结构之一,在实际工作中也有可能经常用到。在一棵非空的树中有一个根节点,这个节点下面可能有若干子节点,每个子节点下面还有其他的子节点。如果一个节点没有子节点,那么它就是一个叶节点。
二叉树是经典的树结构之一,在二叉树中每个节点最多只有两个子节点,可以分别把它们称为左子节点和右子节点。二叉树的根节点没有父节点,一棵非空二叉树只有一个父节点,二叉树的叶节点没有子节点。
二叉树是一种典型的具有递归性质的数据结构。二叉树的根节点可能有子节点,子节点又是对应子树的根节点,它可能也有自己的子节点。由于二叉树本身就是递归的数据结构,因此很多与二叉树相关的问题用递归的代码解决会更加的直观。
2. 二叉树类样例
/**
* 二叉树节点
* @author nepakina
*/
public class TreeNode {
public int val = 0;
public TreeNode left = null;
public TreeNode right = null;
public TreeNode(int val) {
this.val = val;
}
}
二、知识扩展 & 练习
1. 二叉树的深度优先搜索
知识扩展
二叉树的深度优先搜索又可以细分为中序遍历、前序遍历和后序遍历。
一般的,我们可以通过递归的方式实现不同方式的遍历。但递归无法很好的解决多层数的二叉树导致的调用栈溢出问题。二叉树的遍历往往可以借助栈来实现,通过在不同的时机将元素放入栈或者出栈,可以很好的模拟前、中、后序遍历。
Ⅰ. 中序遍历
根节点在中间的遍历方式:先遍历二叉树的左子树,然后遍历二叉树的根节点,最后遍历二叉树的右子树。
递归的思路这里不做过多解释。此时的栈的解决思路其实非常简单,因为是中序遍历,左节点要优先遍历,所以先依次遍历左节点,并将其翻入栈,直到不再有子节点时,将节点放入序列,再从栈中获取父节点,处理父节点的右节点。右节点依旧是优先处理左节点,并依次入栈。如此循环,直到最后栈中不再有元素。
/**
* 中序遍历:递归
* @param root 根节点
* @return 结果
*/
public List<Integer> inorderTraversalByRecursion(TreeNode root) {
List<Integer> nodes = new ArrayList<>();
dfs(root, nodes);
return nodes;
}
/**
* 递归中序遍历
* @param node 根节点
* @param nodes 结果
*/
public void dfs(TreeNode node, List<Integer> nodes) {
if (node != null) {
// 先处递归处理左节点,再处理自身,最后处理右节点
dfs(node.left, nodes);
nodes.add(node.val);
dfs(node.right, nodes);
}
}
/**
* 中序遍历:栈
* @param root 根节点
* @return 结果
*/
public List<Integer> inorderTraversalByStack(TreeNode root) {
List<Integer> nodes = new ArrayList<>();
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
while (cur != null || !stack.isEmpty()) {
// 顺着节点的左节点一直遍历
while (cur != null) {
stack.push(cur);
cur = cur.left;
}
// 取出节点,当作根节点,随后遍历右节点
cur = stack.pop();
nodes.add(cur.val);
cur = cur.right;
}
return nodes;
}
Ⅱ. 前序遍历
根节点在前面的遍历方式:先遍历二叉树的根节点,再遍历 二叉树的左子树,最后遍历二叉树的右子树。
递归的代码中,只需要更改一下代码顺序即可。而栈也只需要将添加元素放入到遍历左节点的代码块内即可。
public List<Integer> preorderTraversalByRecursion(TreeNode root) {
// ...
}
public void dfs(TreeNode node, List<Integer> nodes) {
if (node != null) {
nodes.add(node.val);
dfs(node.left, nodes);
dfs(node.right, nodes);
}
}
public List<Integer> preorderTraversalByStack(TreeNode root) {
// ...
while (cur != null || !stack.isEmpty()) {
while (cur != null) {
nodes.add(cur.val);
stack.push(cur);
cur = cur.left;
}
cur = stack.pop();
cur = cur.right;
}
// ...
}
Ⅲ. 后序遍历
根节点在后面的遍历方式:先遍历左子树,再遍历右子树,最后遍历根节点。
同理,递归只需要略微更改一下代码顺序即可。而栈需要对逻辑进行调整,遍历过程中,对元素的左右节点遍历,并记录上一个遍历的节点,如果当前节点的右节点是上一个遍历的节点,说明以当前节点为根节点的字节点已经遍历过了,按照左右中的顺序,下一个遍历的应该是当前节点。否则应该对右节点进行入栈遍历,如此往复,最终即可得到遍历序列。
public List<Integer> postorderTraversalByRecursion(TreeNode root) {
// ...
}
public void dfs(TreeNode node, List<Integer> nodes) {
if (node != null) {
dfs(node.left, nodes);
dfs(node.right, nodes);
nodes.add(node.val);
}
}
/**
* 后序遍历:栈
* @param root 根节点
* @return 结果
*/
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> nodes = new ArrayList<>();
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
// 记录上一个加入返回值集合的节点,pre 用于判断是否已经完成右节点的遍历,
// 如果完成,pre 会等于其右节点,此时下一个遍历的应该是当前节点
TreeNode pre = null;
while (cur != null || !stack.isEmpty()) {
while (cur != null) {
stack.push(cur);
cur = cur.left;
}
cur = stack.peek();
// 如果右边节点为空,则左右中变为左中,或者右边节点为上一个节点(左右已经遍历)
// 此时处理根节点
if (cur.right == null || cur.right == pre) {
stack.pop();
nodes.add(cur.val);
pre = cur;
cur = null;
} else {
cur = cur.right;
}
}
return nodes;
}
题目练习
Ⅰ:二叉树剪枝
题目描述
一棵二叉树的所有节点的值要么是 0 要么是 1,请剪除该二叉树中所有节点的值全都是 0 的子树。例如,在剪除图 7.1(a) 中二叉树中所有节点值都为 0 的子树之后的结果如图 7.1(b) 所示。
题目分析
难度: ⭐🐇🐇🐇🐇
思路: 上层节点保留与否需要满足两个条件之一,自身值非 0 或者子节点存在非 0 值节点。子节点是否满足可以通过递归传递该信息给父节点,父节点再通过这个来判断即可。
参考代码
public TreeNode cutZeroNode(TreeNode node) {
boolean flag = checkChildNode(node);
return flag ? node : null;
}
/**
* 检查是否保留节点
* @param node 当前节点
* @return true/false
*/
public boolean checkChildNode(TreeNode node) {
boolean flag = false;
// 检查左子节点是否存在非 0 值节点
if (node.left != null) {
if (checkChildNode(node.left)) {
flag = true;
} else {
node.left = null;
}
}
// 检查右子节点是否存在非 0 值节点
if (node.right != null) {
if (checkChildNode(node.right)) {
flag = true;
} else {
node.right = null;
}
}
// 当前节点非 0 或者子节点存在非 0 节点,均保留
return node.val != 0 || flag;
}
/**
* 代码优化
* @param root 根节点
* @return 结果
*/
public TreeNode pruneTree(TreeNode root) {
if (root == null) {
return null;
}
root.left = pruneTree(root.left);
root.right = pruneTree(root.right);
if (root.left == null && root.right == null && root.val == 0) {
return null;
}
return root;
}
Tips:2025-05-25
Ⅱ:序列化和反序列化二叉树
题目描述
请设计一个算法将二叉树序列化成一个字符串,并能将该字符串反序列化出原来二叉树的算法。
题目分析
难度: ⭐⭐🐇🐇🐇
思路:
序列化和反序列化,可以通过前中后序遍历来实现,在遍历过程中借助队列辅助处理比较合适,这里选择前序遍历进行处理。
序列化时,将根节点放入队列,之后将队列首元素取出,遍历左右节点,依次放入队列尾,对于空节点,定义一个无意义节点表示。对于无意义节点处理时跳过,每个元素在拼接时,以指定的字符隔开。
反序列化时,同理借助一个队列辅助处理,先将字符串按指定字符分割为元素数组,将数组第一个元素放入队列作为初始值。随后从下标 1 开始遍历数组,队列取队首元素,数组取当前下标以及下一个下标元素,其值分别作为左节点和右节点。
当然也可以使用递归解决。
参考代码
/**
* 队列前序遍历
* @param root 根节点
* @return 结果
*/
public String serialize(TreeNode root) {
if (root == null) {
return "";
}
TreeNode emptyNode = new TreeNode(0x3f3f3f3f);
Deque<TreeNode> temp = new ArrayDeque<>();
temp.addLast(root);
StringBuilder sb = new StringBuilder();
while (!temp.isEmpty()) {
// 双端队列中很多方法效果是一样的,为了便于理解使用有First和Last结尾的方法处理
TreeNode node = temp.pollFirst();
sb.append(node.val).append(",");
if (!emptyNode.equals(node)) {
// null的节点,默认使用定义的空节点代替
temp.addLast(node.left == null ? emptyNode : node.left);
temp.addLast(node.right == null ? emptyNode : node.right);
}
}
return sb.toString();
}
public TreeNode deserialize(String str) {
if ("".equals(str)) {
return null;
}
int INF = 0x3f3f3f3f;
// 将字符串按指定字符分割
String[] split = str.split(",");
Deque<TreeNode> temp = new ArrayDeque<>();
// 将root节点加入队列
TreeNode root = new TreeNode(Integer.parseInt(split[0]));
temp.addLast(root);
// 循环时跳过root节点和末尾无效节点
for (int i = 1; i < split.length - 1; i += 2) {
TreeNode poll = temp.pollFirst();
TreeNode a = new TreeNode(Integer.parseInt(split[i]));
TreeNode b = new TreeNode(Integer.parseInt(split[i + 1]));
if (INF != a.val) {
poll.left = a;
temp.add(poll.left);
}
if (INF != b.val) {
poll.right = b;
temp.add(poll.right);
}
}
return root;
}
/**
* 递归前序遍历
* @param root 根节点
* @return 结果
*/
public String serialize(TreeNode root) {
if (root == null) {
return "#";
}
// 前序遍历
String left = serialize(root.left);
String right = serialize(root.right);
return root.val + "," + left + "," + right;
}
public TreeNode deserialize(String data) {
String[] split = data.split(",");
int[] i = {0};
return dfs(split, i);
}
public TreeNode dfs(String[] strArr, int[] i) {
String str = strArr[i[0]];
// 通过定义一个长度为 1 的数组使得取值的下标能够进行传递;
// 类似于定义一个全局变量
i[0]++;
if ("#".equals(str)) {
return null;
}
TreeNode node = new TreeNode(Integer.parseInt(str));
node.left = dfs(strArr, i);
node.right = dfs(strArr, i);
return node;
}
Tips:2025-05-25
Ⅲ:从根节点到叶节点的路径数字之和
题目描述
在一棵二叉树中所有节点都在 0~9 的范围之内,从根节点到叶节点的路径表示一个数字。求二叉树中所有路径表示的数字之和。例如,图的二叉树有 3 条从根节点到叶节点的路径,它们分别表示数字 395、391 和 302,这 3 个数字之和是 1088。
题目分析
难度: ⭐⭐🐇🐇🐇
思路: 下一层如果有效,下一层的数值应该是当前层数值的 10 倍,然后再加上下一层的数。下一层无效时,直接返回当前层的计算结果。过程符合递归思想,只不过需要用一个值用来存储当前层的计算结果。
参考代码
public int sumNodePath(TreeNode root) {
return dfs(root, 0);
}
public int dfs(TreeNode node, int path) {
if (node == null) {
return 0;
}
// 计算当成值
path = path * 10 + node.val;
if (node.left == null && node.right == null) {
return path;
}
// 如果有下层,那么下层值的初始值为当层值
return dfs(node.left, path) + dfs(node.right, path);
}
Tips:2025-05-25
Ⅳ:向下的路径节点值之和
题目描述
给定一棵二叉树和一个值 sum,求二叉树中节点值之和等于 sum 的路径的数目。路径的定义为二叉树中顺着指向子节点的指针向下移动所经过的节点,但不一定从根节点开始,也不一定到叶节点结束。例如,在如图所示中的二叉树中有两条路径的节点值之和等于 8,其中,第 1 条路径从节点 5 开始经过节点 2 到达节点 1,第 2 条路径从节点 2 开始到节点 6。
题目分析
难度: ⭐⭐⭐🐇🐇
思路:
首先可以暴力,以每个节点为首届点,向下寻找路径和为所求的情况,找到则计数,直到没有子节点。由于暴力复杂度较高,此处借助 Map 来优化处理。
暴力遍历的逻辑中,不难看出存在大量的路径和的重复计算。如果我们将节点的所有前置(父)节点的路径和和出现次数记录到 Map 中,是否可以快速的找到路径数呢?当遍历到某一个节点时,此时路径和如果为 pathTotal,那么以当前节点为结束点且和为 sum 的分支其首节点的路径和应该等于 pathTotal - sum,那么如何才能获取前置节点中路径和为 pathTotal - sum 的节点数呢?前面提到的 Map 就是一个不错的选择,用节点和作为 key,节点和出现次数作为 value,用于快速查询前置节点中节点和为某一值所出现得次数。这样统计和为某一个值的分支数就变得十分好处理了,不过这里要注意一点,Map 中的记录要即时的移除,不能影响其他分支。
参考代码
public int findPath(TreeNode root, int sum) {
// 记录路径值和出现的次数
Map<Integer, Integer> map = new HashMap<>();
// 初始路径值为 0,次数为 1 次
map.put(0, 1);
return dfs(root, sum, 0, map);
}
/**
* 计算路径数量
*
* @param node 节点
* @param sum 目标值
* @param pathTotal 路径和
* @param map 记录累计路径和和条数
* @return 结果
*/
public int dfs(TreeNode node, int sum, int pathTotal, Map<Integer, Integer> map) {
if (node == null) {
return 0;
}
int res = 0;
// 累加路径和
pathTotal += node.val;
// 当前节点达到 sum 值需要的值
int newTarget = pathTotal - sum;
// map 中有所求值的条数即为当前节点所求
res += map.getOrDefault(newTarget, 0);
// 将当前节点的值记录到 map 中,为子节点提供依据
map.put(pathTotal, map.getOrDefault(pathTotal, 0) + 1);
// 处理子节点
res += dfs(node.left, sum, pathTotal, map);
res += dfs(node.right, sum, pathTotal, map);
// 当前节点的值记录从 map 中移除,以免影响其他分支节点结果
map.put(pathTotal, map.get(pathTotal) - 1);
return res;
}
Tips:2025-05-25
Ⅴ:节点值之和最大的路径
题目描述
在二叉树中将路径定义为顺着节点之间的连接从任意一个节点开始到达任意一个节点所经过的所有节点。路径中至少包含一个节点,不一定经过二叉树的根节点,也不一定经过叶节点。给定非空的一棵二叉树,请求出二叉树所有路径上节点值之和的最大值。例如,在如图所示的二叉树中,从节点 15 开始经过节点 20 到达节点 7 的路径的节点值之和为 42,是节点值之和最大的路径。
题目分析
难度: ⭐⭐⭐🐇🐇
思路:
当路径经过某一个节点(非第一个节点),此时如果左子节点也在路径上,那么右子节点将不能在路径上;反之右子节点在路径上,左子节点则不能在路径上;如果左右子节点都在路径上,那么节点的父节点一定不在路径上。所以最大和要么仅包含左子节点,要么仅包含右子节点,要么包含左右子节点。仅包含左或右子节点时,值需要向父节点传递。
根据上诉逻辑,计算经过当前节点的最大路径和,我们可以将情况分为三类,第一类,当前节点的最大和在左子节点中;第二类,在右子节点中;第三类,当前节点值同时加上左和右子节点的最大和。先遍历左子节点,再遍历右子节点,最后遍历当前节点,这与后序遍历十分类似。
参考代码
public int maxPathSum(TreeNode root) {
int[] maxSum = {Integer.MIN_VALUE};
dfs(root, maxSum);
return maxSum[0];
}
public int dfs(TreeNode node, int[] maxSum) {
if (node == null) {
return 0;
}
// 遍历左,再遍历右;因节点值可能为负,所以,最小值设为 int 最小值
int[] maxLeftSum = {Integer.MIN_VALUE};
// 如果子节点最大值小于 0,那么不经过子节点才是最好的决定,不经过时为 0
int left = Math.max(0, dfs(node.left, maxLeftSum));
int[] maxRightSum = {Integer.MIN_VALUE};
int right = Math.max(0, dfs(node.right, maxRightSum));
// 左右节点的最大值中取最大的
maxSum[0] = Math.max(left, right);
// 经过当前节点时的最大值也进行比较
maxSum[0] = Math.max(maxSum[0], node.val + left + right);
// 向上传递子节点最右路径节点和
return node.val + Math.max(left, right);
}
Tips:2025-05-25
2. 二叉搜索树
知识扩展
二叉搜索树是一类特殊的二叉树,它的左子节点总是小于或等于根节点,而右子节点总是大于或等于根节点。如下图就是一颗标准的二叉搜索树。
二叉树的 3 种不同的深度优先搜索算法都适用于二叉搜索树,但中序遍历是解决大多数问题的最常用思路,这是因为中序遍历的结果集正好是递增的。
题目练习
Ⅰ:展平二叉搜索树
题目描述
给定一棵二叉搜索树,请调整节点的指针使每个节点都没有左子节点。调整之后的树看起来像一个链表,但仍然是二叉搜索树。例如,把图 7.5(a) 中的二叉搜索树按照这个规则展平之后的结果如图 7.5(b) 所示。
题目分析
难度: ⭐⭐🐇🐇🐇
思路: 根据图示转化不难看出,这是一个中序遍历的过程。当然也可以是左子节点中序遍历,右子节点前序遍历,存在歧义,不过这里以中序遍历为准,向进行组合遍历也是可以的。
参考代码
public TreeNode spreadTree(TreeNode root) {
if (root == null) {
return null;
}
TreeNode newRoot = null;
TreeNode pre = new TreeNode(-1);
Stack<TreeNode> stack = new Stack<>();
while (root != null || !stack.isEmpty()) {
while (root != null) {
stack.push(root);
root = root.left;
}
TreeNode pop = stack.pop();
if (newRoot == null) {
newRoot = pop;
}
pre.right = pop;
pre.left = null;
pre = pop;
root = pop.right;
}
return newRoot;
}
Tips:2025-05-26
Ⅱ:二叉搜索树的下一个节点
题目描述
给定一棵二叉搜索树和它的一个节点 p,请找出按中序遍历的顺序该节点 p 的下一个节点。假设二叉搜索树中节点的值都是唯一的。例如,在图 7.6 的二叉搜索树中,节点 8 的下一个节点是节点 9,节点 11 的下一个节点是 null。
题目分析
难度: ⭐⭐🐇🐇🐇
思路:
中序遍历的过程中,记录下上一个元素 pre,如果 pre 和 所指定元素 p 相同,那么当前遍历的元素就是下一个节点。也可以用 boolean 类型变量来作为遍历中止的表示。
扩展方法:由于是二叉搜索树,所以也可以按值进行搜索。从根节点开始,每到达一个节点就比较根节点的值和节点 p 的值。如果当前节点的值小于或等于节点 p 的值,那么节点 p 的下一个节点应该在它的右子树。如果当前节点的值大于节点 p 的值,那么当前节点有可能是它的下一个节点。此时当前节点的值比节点 p 的值大,但节点 p 的下一个节点是所有比它大的节点中值最小的一个,因此接下来前往当前节点的左子树,确定是否能找到值更小但仍然大于节点 p 的值的节点。重复这样的比较,直至找到最后一个大于节点 p 的值的节点,就是节点 p 的下一个节点。
参考代码
public TreeNode nextNode(TreeNode root, TreeNode p) {
if (root == null) {
return null;
}
Stack<TreeNode> stack = new Stack<>();
TreeNode pre = new TreeNode(-1);
while (root != null || !stack.isEmpty()) {
while (root != null) {
stack.push(root);
root = root.left;
}
TreeNode pop = stack.pop();
if (pre == p) {
return pop;
}
pre = pop;
root = pop.right;
}
return null;
}
Tips:2025-05-26
Ⅲ:所有大于或等于节点的值之和
题目描述
给定一棵二叉搜索树,请将它的每个节点的值替换成树中大于或等于该节点值的所有节点值之和。假设二叉搜索树中节点的值唯一。例如,输入如图 7.7(a) 所示的二叉搜索树,由于有两个节点的值大于或等于 6(即节点 6 和节点 7),因此值为 6 节点的值替换成 13,其他节点的值的替换过程与此类似,所有节点的值替换之后的结果如图 7.7(b) 所示。
题目分析
难度: ⭐⭐🐇🐇🐇
思路:
根据题目的要求,可以简单分析一下,二叉搜索树右节点的值是大于当前节点值的,左节点是小于当前值的。所以我们以最小二叉树为原型进行分析,node 为当前节点,left 为左子节点,right 为右子节点。
此时 right 的新值是其本身,node 的新值应该是 node + right,left 的新值是 node + right + left,即 node 的新值加上 left 旧值。
这个计算过程不难看出,是一种倒序的中序遍历,先遍历右子节点,再遍历当前节点,再遍历左子节点,遍历过程中对值进行累加。所以,只需要对中序遍历的代码略微调整即可。
当然直接中序遍历也可以,不过由于取值是相反的,所以需要先遍历一次求出总和 total,最终赋值时用 total - sum 取代 sum 即可(sum 是比当前节点值小的值总和)。
参考代码
public TreeNode resetNodeVal(TreeNode root) {
if (root == null) {
return null;
}
int sum = 0;
TreeNode cur = root;
Stack<TreeNode> stack = new Stack<>();
while (cur != null || !stack.isEmpty()) {
while (cur != null) {
stack.push(cur);
// 只需要将中序遍历的 left 更改为 right 即可
cur = cur.right;
}
cur = stack.pop();
sum += cur.val;
cur.val = sum;
// 中序遍历的 right 更改为 left
cur = cur.left;
}
return root;
}
Tips:-
Ⅳ:二叉搜索树迭代器
题目描述
请实现二叉搜索树的迭代器 BSTIterator,它主要有如下 3 个函数。
构造函数:输入二叉搜索树的根节点初始化该迭代器。
函数 next:返回二叉搜索树中下一个最小的节点的值。
函数 hasNext:返回二叉搜索树是否还有下一个节点。
题目分析
难度: ⭐⭐🐇🐇🐇
思路:
二叉搜索树的中序遍历结果结合是有序的且顺序为从小到大,所以可以借助中序遍历,得到有序集合,再通过集合输出数据。由于中序遍历写入集合和集合输出元素符合先进先出的规则,可以使用队列来处理。
参考代码
import nep.akina.algorithm.base.TreeNode;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;
/**
* @author nepakina
*/
public class BSTIterator {
public static final Queue<TreeNode> QUEUE = new LinkedList<>();
public BSTIterator(TreeNode root) {
// 中序遍历处理节点
Stack<TreeNode> stack = new Stack<>();
while (root != null || !stack.isEmpty()) {
while (root != null) {
stack.push(root);
root = root.left;
}
root = stack.pop();
QUEUE.offer(root);
root = root.right;
}
}
public Integer next() {
// poll() 如果 QUEUE 为空返回 null
TreeNode poll = QUEUE.poll();
return poll == null ? null : poll.val;
}
public boolean hasNext() {
return !QUEUE.isEmpty();
}
}
这样在初始化的时候时间花费的比较多,而在每次往队列中添加元素时,其实就是在输出 next 元素。如果将当前元素记录下来,每次获取 next 时进行中序遍历,当输出一个元素后,便不再继续中序遍历,而下一次调用 next 时继续中序遍历,即使用 next 来控制中序遍历的进度。
import nep.akina.algorithm.base.TreeNode;
import java.util.Stack;
/**
* @author nepakina
*/
public class BSTIterator {
private TreeNode cur;
private final Stack<TreeNode> stack;
public BSTIterator(TreeNode root) {
this.cur = root;
this.stack = new Stack<>();
}
public Integer next() {
if (!hasNext()) {
return null;
}
while (cur != null) {
stack.push(cur);
cur = cur.left;
}
cur = stack.pop();
int res = cur.val;
cur = cur.right;
return res;
}
public boolean hasNext() {
return cur != null || !stack.isEmpty();
}
}
Tips:2025-05-27
Ⅴ:二叉搜索树中两个节点的值之和
题目描述
给定一棵二叉搜索树和一个值 k,请判断该二叉搜索树中是否存在值之和等于 k 的两个节点。假设二叉搜索树中节点的值均唯一。例如,在如图 7.8 所示的二叉搜索树中,存在值之和等于 12 的两个节点(节点 5 和节点 7),但不存在值之和为 22 的两个节点。
题目分析
难度: ⭐⭐🐇🐇🐇
思路:
将二叉搜索树中序遍历展开后,就是一个有序数组,这有点像在有序数组中寻找两个值和为 sum 的情况是否存在。而数组的解决思路其实就是利用 Set 存储值,或者双指针。
虽然将树展开后再按数组寻找的方式寻找可以解决问题,但如果在中序遍历的过程中结合上述两种方式之一便可以减少不必要的循环。以 Set 为例,在遍历过程中判断 Set 中是否存在与当前元素值和为sum的元素,即 key = sum - val;如果存在直接返回 true。
参考代码
public boolean findSumValue(TreeNode root, int sum) {
Set<Integer> set = new HashSet<>();
Stack<TreeNode> stack = new Stack<>();
while (root != null || !stack.isEmpty()) {
while (root != null) {
stack.push(root);
root = root.left;
}
root = stack.pop();
if (set.contains(sum - root.val)) {
return true;
}
set.add(root.val);
root = root.right;
}
return false;
}
而使用双指针的话,左指针是按左中右的顺序遍历(中序遍历),而右指针是右中左的顺序遍历(倒序中序遍历),且每次都是只移动一步,所以需要对中序遍历和倒序中序遍历进行拆分,题目 Ⅳ 中,next 函数正好符合要求,仿造其再写出倒序的中序遍历 prev 函数(改造题目 Ⅲ 的倒序中序遍历),最后再结合双指针即可。
public boolean findSumValue(TreeNode root, int sum) {
if (root == null) {
return false;
}
BSTIterator iterNext = new BSTIterator(root);
BSTIteratorReverse iterPrev = new BSTIteratorReverse(root);
int next = iterNext.next();
int prev = iterPrev.prev();
while (next != prev) {
int valSum = prev + next;
if (valSum == sum) {
return true;
}
if (valSum < sum) {
// 左遍历元素 +1
next = iterNext.next();
} else {
// 右遍历元素 +1
prev = iterPrev.prev();
}
}
return false;
}
Tips:2025-05-27
3. TreeSet 和 TreeMap
扩展知识
二叉搜索树是一种很有用的数据结构。如果二叉搜索树有 n 个节点,深度为 h ,那么查找、添加和删除操作的时间复杂度都是 O(h)。如果二叉搜索树是平衡的,那么深度 h 近似等于 logn。但在极端情况下 (如每个节点只有一个子节点),树的深度 h 等于 n - 1,此时二叉搜索树的查找、添加和删除操作的时间复杂度都退化成 O(n)。二叉搜索树是否平衡对二叉搜索树的时间效率至关重要。
Java 根据红黑树这种平衡的二叉搜索树实现 TreeSet 和 TreeMap 两种数据结构,在必要的时候我们可以使用这两种平衡二叉搜索树高效的解决问题。
TreeSet 的常用函数
函数 | 作用 |
---|---|
ceiling | 返回大于或等于给定值的最小键;没有返回 null |
floor | 返回小于或等于给定值的最大键;没有返回 null |
highter | 返回大于给定值的最小键;没有返回 null |
lower | 返回小于给定值的最大键;没有返回 null |
TreeMap 的常用函数
函数 | 作用 |
---|---|
ceilingEntry / ceilingKey | 返回大于或等于给定值的最小映射/键;没有返回 null |
floorEntry / floorKey | 返回小于或等于给定值的最大映射/键;没有返回 null |
highterEntry / highterKey | 返回大于给定值的最小映射/键;没有返回 null |
lowerEntry / lowerKey | 返回小于给定值的最大映射/键;没有返回 null |
对比之前的数据结构而言,哈希表(HashSet 或 HashMap)中查找、添加和删除操作的时间复杂度都是 O(1),是非常高效的。但它有一个缺点,哈希表只能根据键进行查找,只能判断该键是否存在。如果需要根据数值的大小查找,如查找数据集合中比某个值大的所有数字中的最小的一个,哈希表就无能为力。如果在一个排序的动态数组(如 Java 的 ArrayList)中根据数值的大小进行查找,则可以应用二分查找算法实现时间效率为 O(logn) 的查找。但排序的动态数组的添加和删除操作的时间复杂度是 O(n)。由于 TreeSet 或 TreeMap 能够保证其内部的二叉搜索树是平衡的,因此它们的查找、添加和删除操作的时间复杂度都是 O(logn),综合来看它们比动态排序数组更加高效。
题目练习
Ⅰ:值和下标之差都在给定的范围内
题目描述
给定一个整数数组 nums 和两个正数 k、t,请判断是否存在两个不同的下标 i 和 j 满足 i 和 j 之差的绝对值不大于给定的 k,并且两个数值 nums[i] 和 nums[j] 的差的绝对值不大于给定的 t。
题目分析
难度: ⭐⭐⭐🐇🐇
思路:
一般的,我们可以对每个元素做一下处理,找到前 k 个元素,其中如果存在和当前元素值差绝对值小于等于 k 的元素时,说明能找到,否则当前元素没有对应的值,继续遍历下一个元素。
方案一:如果当前元素为 nums[i],目标则是在 nums[i - k] 到 nums[i - 1] 的范围内找到是否有绝对差值小于 t 的元素。绝对差值小于 t,可以表示为 nums[j] - nums[i] <= t 或者 nums[i] - nums[j] <= t,也就是说其实是在值小于自己的区间和值大于自己的区间找差值小于等于 t 的情况。如果两个区间中离自己最近的值和自己的差值都超过了 t,那边其他值就不需要考虑了,此时一定找不到满足条件的值了,反之,这就是其中一个符合条件的值。
那么基于这个理解,题意转化为 nums[i - k] 到 nums[i - 1] 区间中寻找小于等于 num[i] 的最大值(左区间),以及大于等于 nums[i] 的最小值(右区间),然后判断这两个值是否满足和 nums[i] 的绝对差值是否小于等于 t。
方案二:由于这个题目关心的是绝对差值小于或等于 t 的数字,因此可以将数字放入若干大小为 t + 1 的桶中。例如,将从 0 到 t 的数字放入编号为 0 的桶中,从 t + 1 到 2t + 1 的数字放入编号为 1 的桶中。其他数字以此类推。这样做的好处是如果两个数字被放入同一个桶中,那么它们的绝对差值一定小于或等于 t。
逐一扫描数组中的数字。如果当前扫描到数字 num,那么它将放入编号为 i 的桶中。如果这个桶中之前已经有数字,那么就找到两个绝对差值小于或等于 t 的数字。如果桶中之前没有数字,则再判断编号为 i - 1 和 i - 2 的这两个相邻的桶中是否存在与 num 的绝对差值小于或等于 t 的数字。除此之外其他桶中的数字与 num 的绝对差值一定大于 t,所以不需要判断其他的桶中是否有符合条件的数字。
参考代码
// 方案一
public boolean findTarget(int[] nums, int k, int t) {
TreeSet<Integer> set = new TreeSet<>();
for (int i = 0; i < nums.length; i++) {
// 大于或等于自己的最小值
Integer ceiling = set.ceiling(nums[i]);
if (ceiling != null && ceiling - nums[i] <= t) {
return true;
}
// 小于或等于自己的最大值
Integer floor = set.floor(nums[i]);
if (floor != null && nums[i] - floor <= t) {
return true;
}
// 或者 i >= k 是一样的
if (set.size() >= k) {
// 移除最开始的元素
set.remove(nums[i - k]);
}
set.add(nums[i]);
}
return false;
}
Tips:2025-05-27
Ⅱ:日程表
题目描述
请实现一个类型 MyCalendar 用来记录自己的日程安排,该类型用方法 book(int start,int end) 在日程表中添加一个时间区域为 [start,end) 的事项(这是一个半开半闭区间)。如 [start,end) 中之前没有安排其他事项,则成功添加该事项并返回 true;否则,不能添加该事项,并返回 false。
题目分析
难度: ⭐⭐🐇🐇🐇
思路:
有了上题的经验,这题的分析便会简单很多。在已有时间区间中寻找是否有一个区间和新的区间有公共部分,如果找到那么说明不能添加,否则可以添加。
如何判断最少的区间呢,对于起点小于自己的区间,取一个起点最大的,如果这个区间和新区间都没有交际,那么起点小于自己的其他区间也不会有交集。起点大于自己的区间同理,取一个大于新区见起点的最小起点区间,如果这个区间和新区见都没有交集,那么其他起点大于自己的区间也没有交集。
这和 TreeSet 设计不谋而合,但是由于区间是两个值不是一个值,所以 TreeSet 不能很好的支持,而 TreeMap 的 value 刚好可以存放。所以只需要取出两 key-value 进行比较即可。
参考代码
import java.util.Map;
import java.util.TreeMap;
/**
* @author nepakina
*/
public class MyCalendar {
private final TreeMap<Integer, Integer> map;
public MyCalendar() {
this.map = new TreeMap<>();
}
public boolean book(int start, int end) {
Map.Entry<Integer, Integer> floorEntry = map.floorEntry(start);
if (floorEntry != null && start < floorEntry.getValue()) {
return true;
}
Map.Entry<Integer, Integer> ceilingEntry = map.ceilingEntry(start);
if (ceilingEntry != null && end > ceilingEntry.getKey()) {
return false;
}
map.put(start, end);
return true;
}
}
Tips:2025-05-27
三、章节小结
前中后序遍历这 3 种遍历的是解决树类型的问题非常常用的遍历方式,建议对 3 中遍历方式的递归和循环的代码尽可能的熟悉。
而二叉搜索树这种特殊的二叉树,在二叉搜索树中进行搜索、添加和删除操作的平均时间复杂度都是 O(logn)。如果按照中序遍历的顺序遍历一棵二叉搜索树,遍历的结果是一个递增的有序集合。由于这个特性,可以解决很多与二叉搜索树相关的问题。
Java 中提供的 TreeSet 和 TreeMap 这两种数据结构实现了平衡二叉搜索树。如果需要动态地在一个排序的数据集合中添加元素,或者需 要根据数据的大小查找,这种情况就比较适合使用 TreeSet 或 TreeMap 解决。