一、入门篇
三、动归/DFS/回溯算法
动归/DFS/回溯算法都可以看做二叉树问题的扩展,只是它们的关注点不同:
- 动态规划算法专注于把问题拆分成更小的子问题
- 回溯算法专注于节点之间的移动
- DFS 算法专注于一个点要做的事情
例子1:分解问题的思路:
int count(TreeNode* root){
if(root == nullptr){
return 0;
}
int left = count(root->left);
int right = count(root->right);
return left + right + 1;
}
这就是动态规划分解问题的思路,它的着眼点永远是结构相同的整个子问题,类比到二叉树上就是「子树」。
例子2:写一个打印出遍历这棵二叉树过程的函数
void traverse(TreeNode* root){
if(root == nullptr){
return;
}
printf("从节点%s进入到节点%s", root, root->left);
traverse(root->left);
printf("从节点%s回到到节点%s", root->left, root);
printf("从节点%s进入到节点%s", root, root->right);
traverse(root->right);
printf("从节点%s进入到节点%s", root->left, root);
}
上述代码进阶到多叉树:
void traverse(Node* root) {
if (root == nullptr) return;
for (Node* child : root->children) {
printf("从节点 %s 进入节点 %s", root, child);
traverse(child);
printf("从节点 %s 回到节点 %s", child, root);
}
}
再回来看回溯算法框架
// 回溯算法核心部分代码
void backtrack(int[] nums) {
// 回溯算法框架
for (int i = 0; i < nums.length; i++) {
// 做选择
used[i] = true;
track.addLast(nums[i]);
// 进入下一层回溯树
backtrack(nums);
// 取消选择
track.removeLast();
used[i] = false;
}
}
这就是回溯算法遍历的思路,它的着眼点永远是在节点之间移动的过程,做选择和撤销选择,类比到二叉树上就是「树枝」。
例子3:把这棵二叉树上的每个节点的值都加一
void traverse(TreeNode* root) {
if (root == nullptr) return;
// 遍历过的每个节点的值加一
root->val++;
traverse(root->left);
traverse(root->right);
}
这就是 DFS 算法遍历的思路,它的着眼点永远是在单一的节点上,类比到二叉树上就是处理每个「节点」。
DFS 算法和回溯算法非常类似,只是在细节上有所区别。
这个细节上的差别是什么呢?其实就是「做选择」和「撤销选择」到底在 for 循环外面还是里面的区别,DFS 算法在外面,回溯算法在里面。
// DFS 算法把「做选择」「撤销选择」的逻辑放在 for 循环外面
void dfs(Node* root) {
if (root == NULL) return;
// 做选择
printf("我已经进入节点 %p 啦\n", root);
for (Node* child : root->children) {
dfs(child);
}
// 撤销选择
printf("我将要离开节点 %p 啦\n", root);
}
// 回溯算法把「做选择」「撤销选择」的逻辑放在 for 循环里面
void backtrack(Node* root) {
if (root == NULL) return;
for (Node* child : root->children) {
// 做选择
printf("我站在节点 %p 到节点 %p 的树枝上\n", root, child);
backtrack(child);
// 撤销选择
printf("我将要离开节点 %p 到节点 %p 的树枝上\n", child, root);
}
}
关于两者详细可见:
四、层序遍历
二叉树题型主要是用来培养递归思维的,而层序遍历属于迭代遍历
// 输入一棵二叉树的根节点,层序遍历这棵二叉树
void levelTraverse(TreeNode* root) {
if (root == nullptr) return;
queue<TreeNode*> q;
q.push(root);
// 从上到下遍历二叉树的每一层
while (!q.empty()) {
int sz = q.size();
// 从左到右遍历每一层的每个节点
for (int i = 0; i < sz; i++) {
TreeNode* cur = q.front();
q.pop();
// 将下一层节点放入队列
if (cur->left != nullptr) {
q.push(cur->left);
}
if (cur->right != nullptr) {
q.push(cur->right);
}
}
}
}
这里面 while 循环和 for 循环分管从上到下和从左到右的遍历:
五、例题讲解
例题1: 二叉树的最大深度
思路一:遍历一遍
为什么前序,因为每次进入二叉树节点就可以算出深度了,而不需要左右子树的信息
class Solution {
public:
int deepest, present;
void traverse(TreeNode* root){
if(root == nullptr){
return;
}
present++;
if(root->left == nullptr && root->right == nullptr){
deepest = max(present, deepest);
}
traverse(root->left);
traverse(root->right);
present--;
return;
}
int maxDepth(TreeNode* root) {
traverse(root);
return deepest;
}
};
思路2:分解问题
因为需要用到子树的信息,所以业务代码在递归后面
class Solution {
public:
int maxDepth(TreeNode* root) {
if(root == nullptr){
return 0;
}
int ret = max(maxDepth(root->left), maxDepth(root->right));
return ret+1;
}
};
例题2:二叉树的前序遍历
前序遍历的顺序:根节点,左子树,右子树,对应ans向量
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> ans;
if(root == nullptr){
return ans;
}
ans.push_back(root->val);
vector<int> left = preorderTraversal(root->left);
ans.insert(ans.end(), left.begin(), left.end());
vector<int> right = preorderTraversal(root->right);
ans.insert(ans.end(), right.begin(), right.end());
return ans;
}
};
例题3: 二叉树的直径
设置一个外部变量,然后遍历二叉树,遍历过程中更新结果即可。
class Solution {
public:
int best;
int depth(TreeNode* root){
if(root == nullptr){
return 0;
}
int left = depth(root->left);
int right = depth(root->right);
best = max(best, left + right);
return max(left ,right)+1;
}
int diameterOfBinaryTree(TreeNode* root) {
depth(root);
return best;
}
};
二、思维篇
1. 介绍
二叉树解题的思维模式分两类:
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse
函数配合外部变量来实现,这叫「遍历」的思维模式。
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值,这叫「分解问题」的思维模式。
无论使用哪种思维模式,你都需要思考:
如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?其他的节点不用你操心,递归函数会帮你在所有节点上执行相同的操作。
拿到一道题,需要思考:如果单独考虑二叉树节点,在什么位置(前中后)做?不用考虑其他节点,递归函数会在所有节点上执行相同的操作。
2. 例题讲解
例题1:翻转二叉树
分析:遍历的思路:只需要每个节点把它的左右节点互换,然后递归处理左右节点即可。
代码:
class Solution {
public:
TreeNode* tmp;
void traverse(TreeNode* root){
if(root == nullptr){
return;
}
tmp = root->right;
root->right = root->left;
root->left = tmp;
traverse(root->left);
traverse(root->right);
}
TreeNode* invertTree(TreeNode* root) {
traverse(root);
return root;
}
};
能不能用分解的思路解决呢?
class Solution {
public:
TreeNode* Inverse(TreeNode* root){
if(root == nullptr){
return root;
}
TreeNode* left = Inverse(root->left);
TreeNode* right = Inverse(root->right);
root->right = left;
root->left = right;
return root;
}
TreeNode* invertTree(TreeNode* root) {
return Inverse(root);
}
};
这种「分解问题」的思路,核心在于你要给递归函数一个合适的定义,然后用函数的定义来解释你的代码;如果你的逻辑成功自恰,那么说明你这个算法是正确的。
例题2:填充每个节点的下一个右侧节点指针
代码:
思路1:while循环
class Solution {
public:
Node* connect(Node* root) {
if(root == nullptr){
return root;
}
Node* left,* head;
head = root;
while(root->left){
left = root->left;
while(root != nullptr){
root->left->next = root->right;
if(root->next){
root->right->next = root->next->left;
}
root = root->next;
}
root = left;
}
return head;
}
};
思路2:递归遍历思路
class Solution {
public:
Node* connect(Node* root) {
if(!root){
return nullptr;
}
if(root->left){
root->left->next = root->right;
if(root->next){
root->right->next = root->next->left;
}
}
connect(root->left);
connect(root->right);
return root;
}
};
上面两个方法是等价的,大部分递归都可以用while循环写出来
例题3:二叉树展开为链表
代码:
分解问题,先把子树排好,然后排序子树
class Solution {
public:
TreeNode* tmp;
TreeNode* traverse(TreeNode* root){
if(!root){
return nullptr;
}
TreeNode* left = traverse(root->left);
TreeNode* right = traverse(root->right);
root->left = nullptr;
root->right = left;
tmp = root;
while(tmp->right != nullptr){
tmp = tmp->right;
}
tmp->right = right;
return root;
}
void flatten(TreeNode* root) {
traverse(root);
}
};
三、构造篇
例题1:最大二叉树
分析:每个二叉树节点都可以认为是一棵子树的根节点,对于根节点,首先要做的当然是把想办法把自己先构造出来,然后想办法构造自己的左右子树。
代码:
class Solution {
public:
TreeNode* traverse(vector<int>& nums, int a, int b){
if(a >= b){
return nullptr;
}
TreeNode* root = new TreeNode();
auto maxn = max_element(nums.begin()+a, nums.begin()+b) - nums.begin();
root->val = nums[maxn];
root->left = traverse(nums, a, maxn);
root->right = traverse(nums, maxn+1, b);
return root;
}
TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
TreeNode* root = traverse(nums,0, nums.size());
return root;
}
};
例题2*:从前序与中序遍历序列构造二叉树
分析:
代码:
四、后序篇
五、序列化篇
六、二叉搜索树篇
前中后序的区别
i)前序位置的代码在刚刚进入一个二叉树节点的时候执行;
ii)中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。(很多多叉树没有中序位置,因为一个节点没有唯一的中序遍历位置。)
iii)后序位置的代码在将要离开一个二叉树节点的时候执行;后序和前序的区别在于,后序会用到子树的信息,一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了。
七、总结
i)C++没有截取vector的函数,只能够传起始位置进去。