思想

二叉树,和前面讲的回溯法或者二分查找相比,不可同日而语。二叉树不是一个方法,而是一个场景,回溯法和二分查找都是方法,因此有一套模板,而关于二叉树,或许只能概括在这个舞台上,有哪些好戏会上演,难以找到一个 silver bullet。

题目

94. Binary Tree Inorder Traversal

这道题是基础的二叉树中序遍历题目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
private:
vector<int> result;
public:
vector<int> inorderTraversal(TreeNode* root) {
if (!root) return {};

inorderTraversal(root->left);
result.push_back(root->val);
inorderTraversal(root->right);

return result;
}
};

递归的解法是平凡的,没什么好说的。记住中序遍历是左-中-右(即根结点在间)就好。题目要求我们用循环的方式来解决,那需要模拟递归。

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:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
TreeNode* curr = root;

while (curr || !st.empty()) {
while (curr) {
st.push(curr);
curr = curr->left;
}

// mid
curr = st.top(); st.pop();
result.push_back(curr->val);

// right
curr = curr->right;
}

return result;
}
};

思路是这样的:在进行中序遍历的时候,我们一开始会贪心地遍历到整棵树的最左侧,直到撞上南墙。这时候 currnullptr。随后,从栈中取出栈顶,这就是当前该访问的结点,记录它,并设置下一步访问它的右孩子。

何时循环终止?curr 是空,且整个栈也空了,那就说明遍历完毕整棵树。

98. Validate Binary Search Tree

这道题让我们验证一棵树是不是二叉搜索树。一棵合法的二叉搜索树,左子树的所有结点值严格小于根的,且右子树的所有值严格大于根的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
private:
long long prev = LLONG_MIN;
public:
bool isValidBST(TreeNode* root) {
if(!root) return true;

if (!isValidBST(root->left)) return false;

if (root->val <= prev) return false;
prev = root->val;

return isValidBST(root->right);
}
};

和 BST 相关的一个好用的结论是:如果中序遍历一棵合法的二叉搜索树,得到的是严格递增的数列。上面的解法就是在使用这个性质,先处理左孩子,再处理根,最后处理右孩子。

需要注意的有两点:

  1. 需要判断根是否为空,否则访问左右孩子没有意义;
  2. prev 的初值需要开 long long,否则用例会过不去。

而另一种解法则比较直观:既然是二叉搜索树,根据定义,我应该检查是不是这棵树的左子树和右子树都满足上下界:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
private:
bool validRange(TreeNode* root, long long left, long long right) {
if (!root) return true;

if (!(left < root->val && root->val < right)) return false;

return validRange(root->left, left, root->val) && validRange(root->right, root->val, right);
}
public:
bool isValidBST(TreeNode* root) {
return validRange(root, LLONG_MIN, LLONG_MAX);
}
};

或许这题给我们的启示是:记得开 long long

101. Symmetric Tree

这棵树要求我们判断一棵树是不是对称的。

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:
bool isSymmetric(TreeNode* root) {
if (!root) return true;

queue<TreeNode*> q;
q.push(root->left);
q.push(root->right);

while (!q.empty()) {
TreeNode* l = q.front(); q.pop();
TreeNode* r = q.front(); q.pop();

if (!l && !r) continue;
if (!l || !r) return false;
if (l->val != r->val) return false;

// check pass and push child
q.push(l->left);
q.push(r->right);
q.push(l->right);
q.push(r->left);
}

return true;
}
};

核心思想是需要设置两个指针,分别指向树的两侧需要检查的位置。这两个指针可能有几种情形:

  1. 全为空 -> 考虑下一对;
  2. 有一个为空 -> 不对称;
  3. 全不为空但值不同 -> 不对称;
  4. 值相同 -> 考虑孩子。

加入孩子进入队列时,也需要按照镜面的方式来考虑。左侧指针的左孩子要和右侧指针的右孩子进行比较;另一种情况同理。这样才符合镜面的定义。

这道题是个 Easy,但我认为如果之前没有见过,想不到双指针的情形,或者不知道该如何处理对称,其实没那么容易。

102. Binary Tree Level Order Traversal

这道题要求我们进行二叉树的层次遍历。

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
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
queue<TreeNode*> q;
vector<vector<int>> result;

if (!root) return {};

q.push(root);

while (!q.empty()) {
int sz = q.size();
vector<int> line;

for (int i = 0; i < sz; i++) {
TreeNode* curr = q.front(); q.pop();

line.push_back(curr->val);

if (curr->left) q.push(curr->left);
if (curr->right) q.push(curr->right);
}

result.push_back(line);
}

return result;
}
};

如果说中序遍历用栈模拟,那层次遍历就用队列来模拟。我们需要记录队列,以及这一层的结点数量。这样,随着遍历的进行,队列中的结点增增减减,但我们始终不忘初心,记得队列在这层遍历开始前结点的数量。

当然老样子,也是记得判断 root 不是 nullptr

104. Maximum Depth of Binary Tree

这道题要求我们求出二叉树的最大深度。

1
2
3
4
5
6
7
8
class Solution {
public:
int maxDepth(TreeNode* root) {
if (!root) return 0;

return max(maxDepth(root->left), maxDepth(root->right)) + 1;
}
};

这题更是可以一发入魂。到底二叉树的树深定义是什么?对于这个结点来说,应该是左子树的深度与右子树深度的最大值,再加一。如果当前结点为空,则树深为 0。递归代码忠实地反映了上述定义。

105. Construct Binary Tree from Preorder and Inorder Traversal

这道题给了我们二叉树的先序遍历和中序遍历,要求我们重新构建出这棵树。

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 {
private:
unordered_map<int, int> index;

TreeNode* build(vector<int>& preorder, int preL, int preR, vector<int>& inorder, int inL, int inR) {
if (preL > preR) return nullptr;

int curVal = preorder[preL];
TreeNode* curr = new TreeNode(curVal);

int k = index[curVal];
int leftSize = k - inL;

curr->left = build(preorder, preL + 1, preL + leftSize, inorder, inL, k - 1);
curr->right = build(preorder, preL + leftSize + 1, preR, inorder, k + 1, inR);

return curr;
}
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
for (int i = 0; i < inorder.size(); i++) {
index[inorder[i]] = i;
}

return build(preorder, 0, preorder.size() - 1, inorder, 0, inorder.size() - 1);
}
};

如果是数据结构与算法的期末考试题,让我手写出来,是非常简单的,然而如果让我用算法来准确表述,说句实话,有些困难。这两者之间的 gap 表明对于树的构造算法并没有很熟悉。

树的构造是个递归的过程,对于每个结点,需要考虑如下几件事情:

  1. 它的值是什么;
  2. 它的左子树、右子树范围是什么;
  3. 对应的子树范围如何反映到 pre/inorder 向量的下标范围中,从而完成孩子的递归构造。

我们就分析上面代码的 build 递归的部分。什么时候构造结束呢?左侧边界比右侧大,表示结束。有人说,一定要 preL > preR 吗?其实不然,inL > inR 也是可以的。

那么,对于当前这个结点以及输入的向量和范围,哪个值是当前结点的值?

对于这个问题,我觉得首先要做一个思维上的转变,不要认为输入是向量 + 左右边界,而就是一个向量。这个向量是 原始向量[left, right] 闭区间取得的。这样的话,对于边界的含义也更加清晰,更符合递归的思路。

好,那么现在我有一个结点,以及对应的 preorderinorder,它的值是什么?显然——是 preorder 的第一个元素!好,问题 1 解决了。

下面要确定左右子树的划分了,这一步,需要 inorder 向量发挥作用。定位到当前元素在 inorder 向量的位置,其左侧是左子树,右侧是右子树(不含当前结点)。这就是为什么我们需要初始化一个 index,也是这种子树构造必须提供中序遍历的原因。

这样,中序遍历的子树范围是最好确定的,那,先序的怎么办呢?很好理解,先序遍历是什么顺序来着?中-左-右!那,可以理解为,先遍历根结点,再遍历整个左子树(不管内部顺序)这堆结点,再遍历右子树这堆。

大功告成!先序遍历中,左子树的范围应该是从当前结点开始,到 leftSize 偏移量;右子树的范围则是左子树的右边界 + 1,到当前边界的末尾。

Follow up:如果提供给你后序遍历和中序遍历的向量,你能还原一棵树吗?

这个后续问题和本题的区别是,后序遍历是左-右-中的顺序。那么还是刚刚的三个问题。

给了两个向量,当前值是什么?得从后序遍历入手了,当前值应该是后续向量的最后一个。那,左右子树的范围呢?中序还是老样子没变化,而后序遍历中,左子树的范围是 [postL, postL + leftSize - 1];右子树是左子树的右边界 + 1postR - 1

108. Convert Sorted Array to Binary Search Tree

这题给了我们一个有序数组,让我们转化为平衡的二叉搜索树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
private:
TreeNode* build(vector<int>& nums, int left, int right) {
if (left > right) return nullptr;

int mid = left + (right - left) / 2;

TreeNode* node = new TreeNode(nums[mid]);
node->left = build(nums, left, mid - 1);
node->right = build(nums, mid + 1, right);

return node;
}
public:
TreeNode* sortedArrayToBST(vector<int>& nums) {
return build(nums, 0, nums.size() - 1);
}
};

有序数组和二叉搜索树有什么关系?是树被压平了的体现。这样的话,在构建树的时候,取中点作为根,随后递归构造左子树和右子树,看上去就没问题了。

114. Flatten Binary Tree to Linked List

这题要求我们,把一棵二叉树展平为一个链表,或者说右深树。要求这颗右深树相当于先序遍历这棵树的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
private:
TreeNode* prev = nullptr;
public:
void flatten(TreeNode* root) {
if (!root) return;

// right
flatten(root->right);

// left
flatten(root->left);

// mid
root->left = nullptr;
root->right = prev;
prev = root;
}
};

这题算是个找规律的题目。现在它希望我展平原来的数组,使得顺序和先序(中左右)一样。方式是按照右左中的顺序来遍历并修改指针。

而如果想让顺序和中序(左中右)一样,则按照右中左的顺序来遍历;后序则是中右左。后两种情况,就需要注意保存中的孩子,否则修改指针后就丢掉了。

算是玩弄二叉树规律的题目。

124. Binary Tree Maximum Path Sum

这道题要求我们求出二叉树中最大的路径和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
private:
int ans = INT_MIN;

int dfs(TreeNode* node) {
if (!node) return 0;

int left = max(0, dfs(node->left));
int right = max(0, dfs(node->right));

ans = max(ans, left + right + node->val);

return max(left, right) + node->val;
}
public:
int maxPathSum(TreeNode* root) {
dfs(root);
return ans;
}
};

这题是二分搜索里被错误分类的那一道,一个 Hard,放到这里来。这道题,我觉得有几个地方可以总结:

  1. 路径和 437 中的不一样,后者是前缀和的思想,这里是递归,需要品一品;
  2. 思想和 543 类似,都是在递归的过程中维护一个全局 ans 最后返回;
  3. 计算路径和,考虑左边值和右边值,必须加上当前结点值,这样才能连通起来;在返回值的时候,只能选择左右较大的一边加上该结点的值来返回,因为路径不能分叉。

199. Binary Tree Right Side View

这题要求我们自上而下地返回一棵二叉树从右侧观看时,能看到的结点。

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 {
public:
vector<int> rightSideView(TreeNode* root) {
if (!root) return {};

vector<int> result;
queue<TreeNode*> q;
q.push(root);

while (!q.empty()) {
int sz = q.size();

for (int i = 0; i < sz; i++) {
TreeNode* now = q.front(); q.pop();

if (now->left) q.push(now->left);
if (now->right) q.push(now->right);

if (i == sz - 1) result.push_back(now->val);
}
}

return result;
}
};

本质上是层次遍历,只不过保留层次遍历每层的最后一个元素。稍微改一改,还可以返回左侧视角。

226. Invert Binary Tree

这题要求我们翻转二叉树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if (!root) return nullptr;

invertTree(root->left);
invertTree(root->right);

TreeNode* tmp = root->left;
root->left = root->right;
root->right = tmp;

return root;
}
};

简单的递归,没什么好说的。

230. Kth Smallest Element in a BST

要求我们找出一棵二叉搜索树中第 k 个最小的元素。

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
class Solution {
private:
int num;
int _k;
int inorder(TreeNode* node) {
if (!node) return -1;

// left
int res = inorder(node->left);

if (res >= 0) return res;

// mid
num++;
if (num == _k) {
return node->val;
}

// right
res = inorder(node->right);

if (res >= 0) return res;

return -1;
}
public:
int kthSmallest(TreeNode* root, int k) {
num = 0;
_k = k;

return inorder(root);
}
};

方法是利用 BST 的性质:中序遍历得到递增序列。至于说怎么中序遍历,可以用递归的方法,也可以用循环来控制。看喜欢哪种方式了。这样来看,循环中序遍历确实是基础中的基础。

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 {
public:
int kthSmallest(TreeNode* root, int k) {
stack<TreeNode*> st;
TreeNode* curr = root;

int num = 0;

while (curr || !st.empty()) {
while (curr) {
st.push(curr);
curr = curr->left;
}

curr = st.top(); st.pop();
num++;

if (num == k) return curr->val;

curr = curr->right;
}

return -1;
}
};

236. Lowest Common Ancestor of a Binary Tree

这题让我们找出树中的两个结点的最低共同祖先。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root || root == p || root == q) return root;

TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);

if (left && right) return root;

return left ? left : right;
}
};

说起来这道题,我记得学算法的时候,学校的 OJ 也有这道题,我当时完全是暴力的做法,好像花了一个上午才完成一道题目,给我累的不行。

其实这道题的解法很巧妙:对于一个结点,分别在它的左右子树试图寻找目标,如果在两边都找到了,那它就是最小祖先;如果只在一边找到了,那这一边就是最小的共同祖先。

一个自然的疑问是,这个递归是怎样跨越层次来找到祖先的?

如果在两侧都能找到,毫无疑问,它肯定是我们要找的这个祖先了,但关键是,怎么把这个信息层层上报?

这个递归函数的返回值的语义是:距离两个目标最近的祖先。这样就清楚了。从根开始,问问左结点,问问右结点。左结点如果找到,会返回其中一个目标的最小祖先;右结点同理,可能会返回另一个目标的最小祖先。

如果这两个祖先都找到了,那说明,当前结点是分叉点,是共同的最小祖先。如果只在一侧找到,说明那一侧返回的最小祖先是共同最小祖先。层层递归,信息就上去了。

还是要再悟。

我想起来,当时之所以 OJ 写这么久,可能因为要手动处理输入输出,诶,麻烦死。

437. Path Sum III

这题要求我们找出等于目标的树上路径。路径必须从上到下走。

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 {
private:
unordered_map<long long, int> cnt;
int ans;
int target;

void dfs(TreeNode* node, long long currSum) {
if (!node) return;

currSum += node->val;

if (cnt.count(currSum - target)) {
ans += cnt[currSum - target];
}

cnt[currSum]++;
dfs(node->left, currSum);
dfs(node->right, currSum);
cnt[currSum]--;
}
public:
int pathSum(TreeNode* root, int targetSum) {
ans = 0;
cnt[0] = 1;
target = targetSum;

dfs(root, 0);

return ans;
}
};

这题是个前缀和的题目,有些难度在的。关键的思想是,所谓的每一条路径,实际上是两条前缀和路径的差值。

因此,我们要维护一个字典,记录每个前缀和出现的次数。同时,在进行 dfs 时,需要记录当前前缀和的值是多少。这样,有了当前信息和历史信息,就能得出满足目标值的路径个数。

当然,和回溯法的思想类似,在递归前,修改状态,递归后要还原状态。以及,前缀和为 0 的路径初始化有 1 条,即空路径。最后,记得开 long long

543. Diameter of Binary Tree

这题要求我们找到一棵二叉树的直径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
private:
int ans;

int depth(TreeNode* root) {
if (!root) return 0;

int left = depth(root->left);
int right = depth(root->right);

ans = max(ans, left + right);

return max(left, right) + 1;
}
public:
int diameterOfBinaryTree(TreeNode* root) {
ans = 0;
depth(root);
return ans;
}
};

是个简单的递归,但是具体的写法怎样才好看,还是要琢磨一下。边统计深度,边计算答案,还不错。那,可以认为是树深那道题的变体了。