二叉树解体两种思路
- 是否可以通过遍历一遍二叉树得到答案?
- 用一个traverse函数配合外部变量
- 实现遍历的思维模式
- 是否可以定义一个递归函数,通过子树的答案推导出原问题的答案? 递归三部曲:
- 函数定义,参数,返回值,充分利用返回值
- 终止条件?
- 单次递归条件?
快速排序:
- 对于nums[lo…hi]
- 找到分界点p
- 通过交换元素使得nums[lo…p-1]都小于nums[p]
- nums[p+1…hi]都大于nums[p]
- 然后递归处理nums[lo…p-1]和nums[p+1…hi]
- 最后整个数组就被排序了
void sort(int nums[], int st, int ed) {if (st >= ed) {return;}// ****** 前序位置 ******// 对 nums[lo..hi] 进行切分,将 nums[p] 排好序// 使得 nums[lo..p-1] <= nums[p] < nums[p+1..hi]int p = partition(nums, st, ed);// 左右子数组进行拆分sort(nums, st, p-1);sort(nums, p+1, ed);
先构造分界点,然后去左右子数组递归构造分界点,这就是二叉树的前序遍历!
归并排序:
- 若要对nums[st…ed]排序
- 先对nums[st…mid]排序
- 再对nums[mid+1…ed]排序
- 最后连接两个有序子数组
void sort(int[] nums, int st, int ed) {if (st == ed) {return;}int mid = (st + ed) / 2;sort(nums, st, mid);sort(nums, mid+1, ed);// ****** 后序位置 ******// 此时两部分子数组已经被排好序// 合并两个有序数组,使 nums[lo..hi] 有序merge(nums, st, mid, ed);
}
先对左右子数组进行排序,然后合并,这就是二叉树后续遍历框架!并且也是传说中的分治算法。
二叉树遍历方式
- 递归遍历
- 层序遍历
二叉树递归遍历(DFS)
递归遍历二叉树代码模板:
// 定义二叉树节点
class TreeNode {
public:int val;TreeNode* left;TreeNode* right;TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};// 二叉树递归遍历框架
void traverse(TreeNode* root) {if (root == nullptr) {return;}traverse(root->left);traverse(root->right);
}
-
traverse函数的遍历就是一直往左子节点走,直到遇到空指针走不了了,才尝试往右子节点走,如此循环,直到左右子树都走完了才返回上一层父节点。
-
递归遍历节点的顺序仅取决于左右子节点的递归调用顺序,与其他代码无关。
理解前中后序遍历
递归遍历的顺序,即函数traverse访问节点的顺序是固定的,root指针在树上移动的顺序是固定的。
但是,我们在traverse函数不同位置写入代码处理逻辑,产生的效果是不同的,这就是前中后序遍历的结果不同,原因在于我们把代码写在了不同位置,产生了不同效果。
// 二叉树遍历框架
void traverse(TreeNode* root) {if (root == nullptr) {return;};// 前序位置traverse(root->left);// 中序位置traverse(root->right);// 后序遍历位置
}
强调:
- 三种代码写入位置的关键区别在于执行时机不同
- 真正的算法体不会叫我们简单计算前中后序遍历结果,而是要我们把正确的代码写入到正确的位置上
- 所以必须准确理解三个位置的代码产生的不同效果
补充:
- 二叉搜索树(BST)的中序遍历结果是有序的
层序遍历
写法一:
- 简单写法,每次把对头元素拿出来,然后左右子节点加入队列
- 缺点:无法知道节点在第几层,这是常见需求
- 这个写法用得不多
void levelOrderTraverse(TreeNode* root) {if (root == nullptr) {return;}std::queue<TreeNode*> q;q.push(root);while (!q.empty()) {TreeNode* cur = q.front();q.pop();// 访问cur节点// 左右子树加入队列if (cur->left != nullptr) {q.push(cur->left);}if (cur->right != nullptr) {q.push(cur->right);}}
}
写法二:
void levelOrderTraverse(TreeNode* root) {if (root == nullptr) {return;}std::queue<TreeNode*> q;q.push(root);// 记录当前遍历到的层数(根节点视为第1层)int depth = 1;while (!q.empty()) {// 记录当前层的节点数量int sz = q.size();// 处理当前层的所有节点, sz 个// for (int i = 0; i < sz; i++) {while (sz-- > 0) {TreeNode* cur = q.front();q.pop();// 访问或者处理cur节点...cout << "depth = " << depth << ", val = " << cur->val << endl;// 左右子树加入队列if (cur->left != nullptr) {q.push(cur->left);}if (cur->right != nullptr) {q.push(cur->right);} }depth++;}
}
- 注意
写法三:
假设树枝有不同权重!
class State {
public:TreeNode* node;int depth;State(TreeNode* node, int depth) : node(node), depth(depth) {}
};void levelOrderTraverse(TreeNode* root) {if (root == nullptr) {return;}queue<State> q;// 根节点的路径权重和是 1q.push(State(root, 1));while (!q.empty()) {State cur = q.front();q.pop();// 访问 cur 节点,同时知道它的路径权重和cout << "depth = " << cur.depth << ", val = " << cur.node->val << endl;// 把 cur 的左右子节点加入队列if (cur.node->left != nullptr) {q.push(State(cur.node->left, cur.depth + 1));}if (cur.node->right != nullptr) {q.push(State(cur.node->right, cur.depth + 1));}}
}
深入理解前中后序遍历
- 前中后序遍历都是什么?
- 后序遍历有何特殊之处?
- 为什么多叉树没有中序遍历?
回顾二叉树递归遍历框架:
// 二叉树的遍历框架
void traverse(TreeNode* root) {if (root == nullptr) {return;}// 前序位置traverse(root->left);// 中序位置traverse(root->right);// 后序位置
}
- 暂且不管前中后序的东西,单单看traverse函数
- 实质上是个能够遍历二叉树节点的函数
- 本质上和遍历数组,链表完全一样
// 迭代遍历数组
void traverse(vector<int>& arr) {for (int i = 0; i < arr.size(); i++) {}
}// 递归遍历数组
void traverse(vector<int>& arr, int i) {if (i == arr.size()) {return;}// 前序位置traverse(arr, i + 1);// 后序位置
}// 迭代遍历单链表
void traverse(ListNode* head) {for (ListNode* p = head; p != nullptr; p = p->next) {}
}// 递归遍历单链表
void traverse(ListNode* head) {if (head == nullptr) {return;}// 前序位置traverse(head->next);// 后序位置
}
- 可见单链表和数组一般遍历方式是迭代,即for循环或者while循环的方式显示的迭代
- 但是也是可以递归遍历的
- 最关键的:只要是递归遍历,都有前序位置和后序位置,分别在递归之前和递归之后两个 位置
- 前序位置:刚进入一个节点时
- 后序位置:即将离开一个节点时
比如倒序打印一条单链表:
void traverse(ListNode* head) {if (head == nullptr) {return;}traverse(head->next);// 在后序位置 执行打印,就可以实现 先递归,每次递归过去想打印时都要先执行递归下一个节点// 直到最后一个节点,递归不了,就开始从最后一个节点开始向前打印cout << head->val << endl;
}
-
前中后序绝不仅仅是三个顺序不同的list,而是递归遍历二叉树过程中,处理每个节点的三个特殊时间点。
-
前序位置的代码:在刚刚进入一个二叉树节点时执行
-
后序位置的代码:在即将离开一个二叉树节点时执行
-
中序位置的代码:在一个二叉树节点的左子树都遍历完,即将开始遍历右子树的时候开始执行
-
本文一直视“前中后序”为“位置”,代码执行位置,执行时机
-
而不是我们常说的“前中后序”为“遍历”
两种解题思路:
- 遍历一遍二叉树得出答案——>回溯算法思想
- 分解问题,递归得到答案——>动态规划思想
个人函数命名习惯:
- 遍历二叉树解题时:
- 函数签名一般使用void traverse(…),没有返回值
- 靠外部变量来计算结果
- 与此对应:回溯算法的函数签名一般也不需要返回值
- 分解问题思路解题:
- 视具体函数,一般会有返回值
- 返回值是子问题的计算结果
- 与此对应:动态规划给出的函数签名是带有返回值的dp函数
二叉树深度
遍历的思路
- 遍历一遍二叉树 + 外部变量记录每个节点所在深度
- 取得最大值就是最大深度
class Solution {// 外部变量记录当前深度和最大深度int depth = 0;int res = 0;public:// 本题函数签名, 求取当前树root的最大深度int maxDepth(TreeNode* root) {return res;}// 遍历二叉树求最大深度, void类型签名, 不返回值void traverse(TreeNode* root) {if (root == nullptr) {return;}// 由于根节点算作深度为1, 所以在前序位置depth++depth++;// 在前序位置, 进入一个新节点时候, 若为叶子节点, 记录答案if (root->left == nullptr && root->right == nullptr) {res = std::max(res, deqth);}traverse(root->left);traverse(root->right);// 后序位置 是 离开当前节点的时机// 减少深度, 类似回溯, 需要把深度还回去depth--; }
};
- 为什么需要前序位置depth++, 后序位置depth–?
- 把traverse视作一个在二叉树上游走的指针
- 前序位置:刚进入一个节点时
- 后序位置:即将离开一个节点
- 对于res的更新?
- 其实放在前中后序位置都可以
- 只要保证进入节点之后,离开节点之前
分解+递归的思路
class Solution {
public:// 函数签名:输入一个根节点root, 返回二叉树最大深度int maxDepth(TreeNode* root) {// 仅一个节点的深度视为1, 空节点就0if (root == nullptr) return 0;// 当前root的深度等于左右子树深度较大的一个+1// 分解问题:root的结果依赖 左右子树的结果int leftMax = maxDepth(root->left);int righMax = maxDepth(root->right);return 1 + std::max(leftMax, rightMax);}
}
- 这道题主要代码思路放在了前中后哪个位置?
- 后序位置!
- 后序位置执行深度加的操作!先遍历了左右子树,得到结果后,后序位置执行深度加的操作!
二叉树前序遍历
遍历思路
- 前序位置:执行加vector操作,前序遍历
- void签名函数,无返回值
- 配合外部变量res
// 遍历思路计算前序遍历结果
class Solution {
public:// 外部变量存放前序遍历结果vector<int> res;// 前序遍历输出结果函数签名vector<int> preorderTraversal(TreeNode* root) {traverse(root);return res;}// 二叉树遍历函数, 前序位置执行关键操作void traverse(TreeNode* root) {if (root == nullptr) return;// 前序位置res.push_back(root->val);traverse(root->left);traverse(root->right);}
};
- 这种遍历思路+外部变量的思路很好想
- 考虑改成分解问题+递归的思路?来得到前序遍历的结果!
分解思路
- 前序遍历的特点:根节点排在首位,接着是左子树遍历的结果,最后是右子树的遍历结果。
- 这就实现了分解问题
- 一个二叉树前序遍历结果 = 根节点 + 左子树前序遍历结果 + 右子树前序遍历结果
- 一个二叉树前序遍历结果 = 根节点 + 左子树前序遍历结果 + 右子树前序遍历结果
class Solution {
public:vector<int> preorderTraversal(TreeNode* root) {vector<int> res;// 空节点返回if (root == nullptr) return res;// 前序遍历的结果 = [root-val , 左子树前序遍历结果 , 右子树前序遍历结果] res.push_back(root->val);// vector<int> left = preorderTraversal(root->left);res.insert(res.end(), left.begin(), left.end());vector<int> right = preorderTraversal(root->right);res.insert(res.end(), left.begin(), left.end()); return res;}
};
遇到一道二叉树题目:
- 是否可以通过遍历一遍二叉树得到答案?
- 用一个遍历函数traverse(),注意前中后序位置的利用
- 配合外部变量
- 是否可以通过定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?
- 写出递归函数定义
- 充分利用函数返回值
- 无论哪种思路,最重要的是明白二叉树中每个节点做什么,在什么时机(前中后序)做?
后序位置的特殊之处
- 前序位置本身没啥特殊之处,一般是习惯把前中后序位置不敏感的代码放在前序位置了。
- 中序位置主要用在BST场景,完全可以把BST的中序遍历认为是遍历有序数组。
仔细观察前中后位置的代码:
- 前序位置的代码只能从函数参数中获取父节点传递来的数据。
- 中序位置的代码不仅有参数数据,还有左子树通过函数返回值传递回来的数据。
- 后序位置的代码最强大,不仅获取参数数据,还可以同时获得左右子树通过函数返回值传递回来的数据。
所以,某些情况下把代码移动到后序位置效率最高;
有时,只有后序位置的代码能做。
举个例子感受它们能力差别
- 如果根节点视作第一层,打印每个节点所在层数
- 打印每个节点左右子树各有多少节点
// 二叉树遍历函数
void traverse(TreeNode* root, int level) {if (root == nullptr) return;// 前序位置printf("节点 %s 在第 %d 层", root.val, level);traverse(root->left, level + 1);traverse(root->right, level + 1);
}
// 这样调用
traverse(root, 1);在这里插入代码片
// 定义:输入一棵二叉树,返回这棵二叉树的节点总数
int count(TreeNode root) {if (root == null) {return 0;}int leftCount = count(root.left);int rightCount = count(root.right);// 后序位置printf("节点 %s 的左子树有 %d 个节点,右子树有 %d 个节点",root, leftCount, rightCount);return leftCount + rightCount + 1;
}
- 一个节点在第几层,我们从根节点遍历过来的过程就能顺带记录,且递归函数的参数就能传递下去
- 而以一个节点为根的整颗子树有多少节点,必须遍历完子树之后才能得到
- 只有后序位置才能通过返回值获取子树的信息!
- 一旦发现题目与子树有关,大概率要给函数设置合理的定义和返回值,在后序位置写关键代码,利用子树的信息了!
二叉树直径
- 关键理解:直径长度 = 每个节点左右子树最大深度之和
- 直径可以不经过根节点
- 要求最长直径长度,那就是遍历整棵树每个节点,然后通过每个节点的左右子树的最大深度,求出每个节点的直径,最后把直径取最大值即可
思路一:
class Solution {
public:// 全局变量记录最大直径长度int maxDiameter = 0; // 解题主签名int diameterOfBinaryTree(TreeNode* root) {// 对每个节点计算直径,求最大直径traverse(root);return maxDiameter;}private:// 遍历二叉树void traverse(TreeNode* root) {if (root == nullptr) return;// 前序位置// 直径 = 左右子树各自最大深度之和,所以这里先算左右子树最大深度int leftMax = maxDepth(root->left);int rightMax = maxDepth(root->right);int myDiameter = leftMax + rightMax;// 更新全局最大直径 maxDiameter = max(maxDiameter, myDiameter);traverse(root->left);traverse(root->right);}// 计算二叉树的最大深度int maxDepth(TreeNode* root) {if (root == nullptr) return 0;// 分解问题+递归思路int leftMax = maxDepth(root->left);int rightMax = maxDepth(root->right);return 1 + max(leftMax, rightMax);}
};
- 缺陷很明显的解法,最坏时间复杂度是 O(N^2)
- 因为traverse遍历每个节点(计算直径)时,还会调用递归函数maxDepth,maxDepth同样遍历所有子树的所有节点
- 究其原因在于,前序位置无法获取子树的信息,只能让每个节点调用maxDepth函数去计算子树的深度,属于想起来容易,实现起来容易,但是时间复杂度过高的解法!
- 如何优化呢?maxDepth的后序位置是已经知道左右子树的最大深度的,所以将计算直径的逻辑同样放在后序位置,放在已知左右子树最大深度的位置就可以优化时间复杂度。
class Solution {
public:// 全局变量记录最大直径长度int maxDiameter = 0; // 解题主签名int diameterOfBinaryTree(TreeNode* root) {// 对每个节点计算直径,求最大直径maxDepth(root);return maxDiameter;}private:// 这样就不用traverse()单独计算直径了!// 去掉traverse()// 计算二叉树的最大深度int maxDepth(TreeNode* root) {if (root == nullptr) return 0;// 分解问题+递归思路int leftMax = maxDepth(root->left);int rightMax = maxDepth(root->right);// maxDepth的后序位置在此,此处已知左右子树各自的最大深度,这里更新直径就可以了!int myDiameter = leftMax + rightMax;maxDiameter = max(maxDiameter, myDiameter)return 1 + max(leftMax, rightMax);}
};
- 时间复杂度只有 maxDepth 函数的 O(N) 了。
- 遇到子树问题,首先想到的是给函数设置返回值,然后在后序位置做文章!
思考题:运用后序位置的题目使用的是遍历思路还是分解问题的思路?
我个人答案:是分解问题的思路,利用了子树返回的答案,作为当前节点的答案的一部分
看了答案:利用后序位置的题目,一般都使用分解问题的思路,因为当前 节点接收并且利用了子树返回返回的信息,意味着把原问题分解成了当前节点+左右子树的子问题。
所以,如果一开始写出递归套递归的解法,大概率要反思,重写成后序遍历优化!
以树的视角看动规/回溯/DFS算法的区别和联系
DFS和回溯算法非常相似,细节上有所区别:
- DFS 做选择和撤销选择是在for循环外部
- 回溯 做选择和撤销选择是在for循环内部
结合二叉树,动归/DFS/回溯可以看作二叉树问题扩展,只是关注点不同:
- 动态规划算法属于分解问题、分治的思路,关注点在于整颗子树,处理子树的方式和利用子树返回的结果。
- 回溯算法属于遍历的思路,关注点在于节点之间的树枝。
- DFS算法属于遍历的思路,关注点在于单个节点处理。
例子一:分解问题的思想体现,动归
给一个二叉树,用分解问题的思路写一个count函数,计算共有多少节点
- 后序位置,可以利用上左右子树的返回的结果!
- 所以后序位置天然与分解的思路适配!
// 函数签名:输入一颗二叉树,返回二叉树节点总数
int count(TreeNode* root) {if (root == nullptr) return 0;// 分解思路:当前节点下的树的总节点数 == 左 + 右 + 1int leftCount = count(root->left);int rightCount = count(root->right);// 后序位置:已经得到了左右子树的结果!return leftCount + rightCount + 1;
}
- 动态规划分解问题的思路
- 着眼点永远是结构解法相同的整个子问题!
- 类比到二叉树上就是递归处理子树
- 注意后序位置才可以充分利用处理好的子树结果!
类比一个经典动归:
斐波那契
// f(n) 计算第 n 个斐波那契数
int fib(int n) {// base case if (n == 0 || n == 1) return n;// fib() 调用 本身是分解问题return fib(n - 1) + fib(n - 2);
}
例子二:回溯算法思想
(这里是java, 请豆包帮我换成cpp的)
void traverse(TreeNode root) {if (root == null) 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.right, root);
}
- 总之对于回溯来说,
- 在对某个子路径调用递归函数traverse()遍历之前,就是即将进入时刻,属于该路径的前序
- 在对某个子路径调用递归函数traverse()遍历之后,就是退出这个节点,回溯时刻!属于该路径后序么?(不确定这句话对不对,豆包帮我改!)
把二叉树进化成多叉树:(java 改成cpp!)
// 多叉树节点
class Node {int val;Node[] children;
}void traverse(Node root) {if (root == null) return;for (Node child : root.children) {printf("从节点 %s 进入节点 %s", root, child);traverse(child);printf("从节点 %s 回到节点 %s", child, root);}
}
从这个多叉树的遍历框架可以延伸出回溯算法框架套路:
void backtrack(...) {// base caseif (...) return;for (int i = 0; i < ...; i++) {// 做选择...// 进入下一层决策树backtrack(...);// 撤销刚才的决策...}
}
回溯算法关注点是树的一条条树枝!
(改cpp)
// 回溯算法核心部分代码
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;}
}
例子三:DFS的思想体现,着眼于节点处理
一棵二叉树,请你写一个 traverse 函数,把这棵二叉树上的每个节点的值都加一。,代码如下:
void traverse(TreeNode* root) {if (root == nullptr) return;// 遍历过的每个节点的值加一root->val++;traverse(root->left);traverse(root->right);
}
看具体的 DFS 算法问题,比如
一文秒杀所有岛屿题目 中讲的前几道题,我们的关注点是 grid 数组的每个格子(节点),我们要对遍历过的格子进行一些处理,所以我说是用 DFS 算法解决这几道题的:
// DFS 算法核心逻辑
void dfs(int[][] grid, int i, int j) {int m = grid.length, n = grid[0].length;if (i < 0 || j < 0 || i >= m || j >= n) {return;}if (grid[i][j] == 0) {return;}// 遍历过的每个格子标记为 0grid[i][j] = 0;dfs(grid, i + 1, j);dfs(grid, i, j + 1);dfs(grid, i - 1, j);dfs(grid, i, j - 1);
}
总的来说:
- 动态规划关注整颗子树
- 回溯关注节点之间的树枝
- DFS关注单个节点
理解为什么回溯算法和 DFS 算法代码中「做选择」和「撤销选择」的位置不同了,看下面两段代码:
// DFS 算法把「做选择」「撤销选择」的逻辑放在 for 循环外面
void dfs(Node* root) {if (!root) return;// 做选择printf("enter node %s\n", root->val.c_str());for (Node* child : root->children) {dfs(child);}// 撤销选择printf("leave node %s\n", root->val.c_str());
}// 回溯算法把「做选择」「撤销选择」的逻辑放在 for 循环里面
void backtrack(Node* root) {if (!root) return;for (Node* child : root->children) {// 做选择printf("I'm on the branch from %s to %s\n", root->val.c_str(), child->val.c_str());backtrack(child);// 撤销选择printf("I'll leave the branch from %s to %s\n", child->val.c_str(), root->val.c_str());}
}
回溯算法必须把「做选择」和「撤销选择」的逻辑放在 for 循环里面,否则怎么拿到「树枝」的两个端点?
- 那dfs的重点呢?在于处理节点本身?不关注其他?(豆包回答我)
层序遍历:
// 输入一棵二叉树的根节点,层序遍历这棵二叉树
int levelTraverse(TreeNode* root) {if (root == nullptr) return;queue<TreeNode*> q;q.push(root);int depth = 0;// 从上到下遍历二叉树的每一层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);}}depth++;}return depth;
}
- while循环和for循环分管从上到下和从左到右的遍历!合力完成了层序遍历!