Apong's Blog

当你快坚持不住的时候,困难也快坚持不住了

0%

leetcode top100详解

98. 验证二叉搜索树

image-20240727163719314

解法一:递归

验证搜索树可以转换为每一颗子树的 小 < 本身 < 大 的思想。
根节点由于没有限制,只要满足 -inf - +inf 即可以,
然后随着左右子树的递归遍历,分别改变上界和下界。
如左子树必须满足 (-inf, root.val),右子树:(root.val, +inf)。

1
2
3
4
5
6
7
8
9
10
class Solution {
public boolean isValidBST(TreeNode root) {
return isValidBST(root, Long.MIN_VALUE, Long.MAX_VALUE);
}
public boolean isValidBST(TreeNode root, long left, long right){
if(root == null) return true;
if(root.val >= right || root.val <= left) return false;
return isValidBST(root.left, left, root.val) && isValidBST(root.right, root.val, right);
}
}

解法二:中序遍历

由于二叉搜索树的中序遍历得到的一定是有序的数组。

只要判断中序遍历过程中,是否有序即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public boolean isValidBST(TreeNode root) {
// 最小值为初始比较值
long min = Long.MIN_VALUE;
// 构建栈,模仿递归
Deque<TreeNode> stack = new ArrayDeque<>();
// 以根节点开始
while(!stack.isEmpty() || root != null) {
// 开始递归
while(root != null){
stack.push(root);
root = root.left;
}
// 中序遍历:取出当前“根节点”
root = stack.pop();
if(root.val <= min) return false;
// 迭代最小值
min = root.val;
// 访问右子树
root = root.right;
}
return true;
}
}

模仿递归的一个关键因素是保证状态始终在 root 上变化。

230. 二叉搜索树中第K小的元素

最简单的思路就是:中序遍历,然后返回第 k-1 个元素。

迭代实现,不需要访问所有元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public int kthSmallest(TreeNode root, int k) {
Deque<TreeNode> stack = new ArrayDeque<TreeNode>();
while (root != null || !stack.isEmpty()) {
while (root != null) {
stack.push(root);
root = root.left;
}
root = stack.pop();
--k;
if (k == 0) {
break;
}
root = root.right;
}
return root.val;
}
}

记录子树的结点数

之所以需要中序遍历前 k 个元素,是因为不知道第 k 个元素是在树的什么位置。

也就是不知道子树的结点数量,需要一层一层遍历,然后计数。

所以可以先记录每颗子树的结点数。

那么第 k 小元素的位置一定满足如下条件:

以 node 作为根结点,开始搜索:

如果 node 的左子树的结点数 left < k−1**,则第 k 小的元素一定在 node 的**右子树**中,令 node 等于其的右子结点,将左子树的结点数算在内,然后令 **k 等于 k−left−1**,并继续搜索;
如果 node 的左子树的结点数 **left == k−1**,则**第 k 小的元素即为 node** ,结束搜索并返回 node 即可;
如果 node 的左子树的结点数 **left > k−1
,则第 k 小的元素一定在 node 的左子树中,令 node 等于其左子结点,并继续搜索。
为了提高效率,选择将每个子树的结点数存在哈希表中。

递归思想:一颗子树的左右结点数,分别是左右两颗子树的总结点数,只要不断往下遍历就能找到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution {
List<Integer> list = new ArrayList<>();
public int kthSmallest(TreeNode root, int k) {
MyBst myBst = new MyBst(root);
return myBst.kthSmallest(k);
}
}
class MyBst {
TreeNode root;
Map<TreeNode, Integer> nodeNum;
public MyBst(TreeNode root){
this.root = root;
this.nodeNum = new HashMap<>();
countNodeNum(root);
}
// 根据子树的结点数分支遍历返回第 k 个结点
public int kthSmallest(int k) {
TreeNode node = root;
while(node != null){
// 当左右子树为空时,需返回0
int left = nodeNum.getOrDefault(node.left, 0);
if(left < k-1) {
// 不够数,减去左边和根看右边
node = node.right;
k -= left+1;
} else if (left == k-1){
// 刚好,退出循环
break;
} else {
// 超了,在左边
node = node.left;
}
}
return node != null ? node.val : -1;
}
// 递归统计所有结点作为子树的结点数
private int countNodeNum(TreeNode node) {
if(node == null) return 0;
nodeNum.put(node, 1+countNodeNum(node.left)+countNodeNum(node.right));
return nodeNum.get(node);
}
}

199. 二叉树的右视图

image-20240729151611620

右视图就是从每一层的右边看过去的第一个节点。

由于不知道树的具体结构,所以可以全部遍历然后取【每一层的】右边第一个。

可以用深度优先遍历,每次优先访问最右边的,然后用一个哈希表记录每一层访问到的第一个元素,其它元素直接舍弃。

DPS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public List<Integer> rightSideView(TreeNode root) {
if(root == null) return Collections.emptyList();
// 总是向右走,每层遇到的第一个就是右视图
Map<Integer, Integer> map = new HashMap<>();
int max_depth = -1;
Deque<TreeNode> nodeStack = new LinkedList<>();
Deque<Integer> depthStack = new LinkedList<>();
// dps遍历
nodeStack.push(root);
depthStack.push(0);
while(!nodeStack.isEmpty()) {
TreeNode node = nodeStack.pop();
int depth = depthStack.pop();
// 维护二叉树的最大深度
max_depth = Math.max(max_depth, depth);
// 只插入此深度的第一个节点
if(!map.containsKey(max_depth)) map.put(max_depth, node.val);
// 放入可访问的其它节点
if(node.left != null) {
nodeStack.push(node.left);
depthStack.push(depth+1);
}
if(node.right != null) {
nodeStack.push(node.right);
depthStack.push(depth+1);
}
}
ArrayList<Integer> ans = new ArrayList<>();
// 按深度取出所有节点
for(int i=0; i<=max_depth; i++)
ans.add(map.get(i));
return ans;
}
}

或者用广度优先搜索,每一层的最后一个就是右边第一个。

BFS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public List<Integer> rightSideView(TreeNode root) {
if(root == null) return Collections.emptyList();
ArrayList<Integer> ans = new ArrayList<>();
// 每层遇到的右边第一个就是右视图
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
while(!q.isEmpty()){
// 根据每一层的个数,隔离每一层
int layerNum = q.size();
for(int i=0; i<layerNum; i++){
TreeNode node = q.poll();
// 添加各层最后一个
if(i == layerNum-1) ans.add(node.val);
if(node.left != null) q.offer(node.left);
if(node.right != null) q.offer(node.right);
}
}
return ans;
}
}

114. 二叉树展开为链表

image-20240729160936745

常规思路可以先按照先序遍历存储一条链表,然后从 root 开始逐一复制节点。

或者直接“拼接”。

只要按照先序遍历的顺序,找到左子树最后一个节点,然后拼接上右子树,

再把左子树整个移到右边去,如此循环处理每一个右节点,最后就变成直直的一条链表了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public void flatten(TreeNode root) {
while(root != null){
// 根据左边是否有节点,决定是否需要拼接
if(root.left != null){
// 先找到左子树最右边的节点
TreeNode last_right = root.left;
while(last_right.right != null) {
last_right = last_right.right;
}
// 把当前右子树插到左子树最右边
last_right.right = root.right;
// 把左子树整个移到当前节点右边
root.right = root.left;
root.left = null;
// 拼接下一个节点的左右子树
root = root.right;
} else{
// 左边是空的,直接拼接下一个节点
root = root.right;
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    1
/ \
2 5
/ \ \
3 4 6

//将 1 的左子树插入到右子树的地方
1
\
2 5
/ \ \
3 4 6
//将原来的右子树接到左子树的最右边节点
1
\
2
/ \
3 4
\
5
\
6

//将 2 的左子树插入到右子树的地方
1
\
2
\
3 4
\
5
\
6

//将原来的右子树接到左子树的最右边节点
1
\
2
\
3
\
4
\
5
\
6

......

300. 最长递增子序列

image-20240730132945810

动态规划

定义状态:dp[i] 的值就代表以 nums[i] 结尾的最长子序列长度

转移方程:遍历之前的 dp[j],当 nums[j] < nums[i] 时表示可以接在后面,保留拼接后的最大值 dp[i] = max(dp[i], dp[j]+1)

初始状态:每个数元素本身至少就是一个长度为 1 的序列。

返回值:记录 dp[i] 的最大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length;
if(n == 0) return 0;
int[] dp = new int[n];
// 每个数字本身都是一个序列
Arrays.fill(dp, 1);
int res = 0;
for(int i=0; i<n; i++){
// 找到能继续连起来的最大的子序列
for(int j=0; j<i; j++){
if(nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j]+1);
}
res = Math.max(res, dp[i]);
}
return res;
}
}

动态规划 + 二分法

普通的动态规划需要找到能接到上的最长的子序列,所以是 dp[i] = max(dp[i], dp[j]+1)。

那如果维护一个 tails 数组,不断取出子序列 nums[1,k] 尾部元素更新 tails 数组,只保留更小的尾部,那么 tails 数组的长度就是最终答案。

优化的精髓在于:本身是一个有序序列,能够通过二分法更新 nums[1,k] 的尾部最小值,保证每次迭代都是基于之前子序列的最大可能有序序列。

这个更新尾部最小值,很好的“拿出了”一长串递增序列中的无关元素。

如:

1
2
3
4
5
6
7
nums: 1 5 3
取出 1
tails: 1
取出 5(能接上)
tails: 1,5
取出 3(替掉5,重新接上)
tails: 1,3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length;
// 始终维护每个子序列的尾部最小,并按升序排列
int[] tails = new int[n];
int res = 0;
for(int num: nums){
int i=0, j=res;
// 二分法替换掉第一个尾部更大的(按顺序接上)
while(i < j){
int m = (i+j)/2;
if(tails[m] < num) i = m+1;
else j = m;
}
tails[i] = num;
// 接在了最长的后面
if(res == j) res++;
}
return res;
}
}

105. 从前序和中序遍历序列构造二叉树

通过后序和中序、前序和中序都能构造二叉树。

因为可以确定根的位置,然后从中序遍历中,将根的两边分为左右子树。

再不断递归划分左右子树,然后回溯就构建成功了。

前序遍历结构:

1
[ 根节点, [左子树的前序遍历结果], [右子树的前序遍历结果] ]

中序遍历结构:

1
[ [左子树的中序遍历结果], 根节点, [右子树的中序遍历结果] ]

递归:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
// 减少索引结点位置时间
HashMap<Integer, Integer> map = new HashMap<>();
public TreeNode buildTree(int[] preorder, int[] inorder) {
int n = inorder.length;
for(int i=0; i<inorder.length; i++){
map.put(inorder[i], i);
}
return splitBuildTree(preorder, inorder, 0, n-1, 0, n-1);
}
public TreeNode splitBuildTree(int[] pre, int[] in, int lp, int rp, int li, int ri){
if(lp > rp) return null;
// 只要从先序遍历中知道根节点,就可以在中序中分割出左右子树
// 如此递归,直到建成最小的子树,然后返回根节点
int pre_root = lp;
int in_root = map.get(pre[lp]);
TreeNode root = new TreeNode(pre[lp]);
int leftNum = in_root-li;
// 从 左边界+1 开始的 leftNum 个,就是左子树
root.left = splitBuildTree(pre, in, lp+1, lp+leftNum, li, in_root-1);
// 从 左子树
root.right = splitBuildTree(pre, in, lp+1+leftNum, rp, in_root+1, ri);
return root;
}
}

437. 路径总和 Ⅲ

image-20240801171805324

最简单的思路就是穷举法,以每一个结点为根,遍历所有可能的路径,如果满足就计数+1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {

public int pathSum(TreeNode root, long targetSum) {
if (root == null)
return 0;
int ans = rootSum(root, targetSum);
// 以每个结点为起点,穷举所有路径
ans += pathSum(root.left, targetSum);
ans += pathSum(root.right, targetSum);
return ans;
}

// 穷举法
public int rootSum(TreeNode root, long targetSum) {
if (root == null)
return 0;
int ret = 0;
// 判断当前节点是否满足
if (root.val == targetSum) {
++ret;
}
// 继续累减下去,看是否有满足的结点数(相比于累加,可以少维护一个变量)
ret += rootSum(root.left, targetSum - root.val);
ret += rootSum(root.right, targetSum - root.val);
return ret;
}
}

前缀和

通过 map 记录从根节点开始到每个结点的和,只要在前面结点中能够找到一个结点 pre[i] 到当前结点 pre[j] 的路径和为 targetSum,问题就解决了。

省去了第二层遍历,因为所有的路径都被“记住”了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
// 存储所有路径总和的次数
Map<Long, Integer> prefix = new HashMap<>();
public int pathSum(TreeNode root, long targetSum) {
// 用于计算相等时的路径
prefix.put(0L, 1);
return dfs(root, 0L, targetSum);
}
public int dfs(TreeNode root, long cur, long targetSum){
if(root == null) return 0;
int ret = 0;
cur += root.val;
// 是否存在一段路径和为 targetSum = cur - (cur-targetSum)
// 相当于:既然无法直接索引某个点去求差得到 targetSum,就直接去查这个点
ret = prefix.getOrDefault(cur-targetSum, 0);
// 加入这条路径
prefix.put(cur, prefix.getOrDefault(cur, 0)+1);
// 继续访问其他路径
ret += dfs(root.left, cur, targetSum);
ret += dfs(root.right, cur, targetSum);
// 回溯,删除这条路径
prefix.put(cur, prefix.getOrDefault(cur, 0)-1);
return ret;
}
}

236. 二叉树的最近公共祖先

image-20240801183056893

哈希回溯定位

可以先遍历整个树,通过哈希表存储所有结点的父节点,这样就可以得到 p,q 的“继承”路线。

然后先回溯 p 的路线,并记录所有已访问的结点。

再回溯 q 的路线,同时匹配是否已访问,因为是自底向上,第一个存在的就是最近公共祖先。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution {
HashMap<TreeNode, TreeNode> parent = new HashMap<>();
HashSet<TreeNode> visited = new HashSet<>();
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
// 自底向上回溯一遍
dfs(root);
while(p != null){
visited.add(p);
p = parent.get(p);
}
// 当 q 回溯时,遇到第一个已访问的结点就是最近公共祖先
while(q != null){
if(visited.contains(q)){
return q;
}
q = parent.get(q);
}
return null;
}
public void dfs(TreeNode root){
// 排除空结点
if(root.left != null){
parent.put(root.left, root);
dfs(root.left);
}
if(root.right != null){
parent.put(root.right, root);
dfs(root.right);
}
}
}

标记回溯

最近公共祖先存在两种类型

1
2
3
4
5
6
7
8
分散在两边
0
/ \
p q
一方作为另一方的父结点
p
/ \
q

第一种情况,可以用两个标记变量表示

left: 表示左边存在一个 p / q

right: 表示右边存在一个 q / p

p 在左或在右都一样,第一种情况另一个一定在另一边。

所以当满足 left && right 此根节点就是 p,q 的祖先。

第二种情况类似,只需要额外判断自身是不是 p / q。

满足条件:(x == p || x == q) && (left || right) 即本身存在一个和左右存在一个。

再配合上递归回溯的特性,第一个满足的就是最近的祖先。

是否会存在这个 true 使得祖先不满足最近的特点?

不会,因为如果已经在一个子树确定了结果,另一个子树是不可能为 true 的,也就是祖先不会上升。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
TreeNode ans;
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
dfs(root, p, q);
return ans;
}
public boolean dfs(TreeNode root, TreeNode p, TreeNode q){
if(root == null){
return false;
}
int val = root.val;
boolean left = dfs(root.left, p, q);
boolean right = dfs(root.right, p, q);
if((left && right) || ((root == p || root == q) && (left || right))){
ans = root;
}
// 只要一方满足就行
return left || right || (root == p || root == q);
}
}