有好一段时间没更新这个系列了,原因是博主去学了C++和STL(用C语言刷题真的太累了,什么轮子都得自己造),而且也快临近期末了,各种大作业和ddl,因此刷题的时间也变少了。回到正题,二叉树相关的题目让我领略到了递归的魅力,我会从二叉树的基操开始,到二叉树思想的应用来逐渐展开。
该系列博客旨在记录我的刷题心得和一些解题技巧,题目全部来源于力扣,一些技巧和方法参考过力扣上的题解和labuladong大佬的文章。虽然说这些内容主要是写给我自己看的,但也欢迎大家发表自己新颖的解法和不一样的观点。
目录
一、基操(递归+迭代)
一、四种遍历
1.前、中、后序的递归实现
void traverse(TreeNode* root){
if(root==nullptr)
return;
//前序位置
traverse(root->left);
//中序位置
traverse(root->right);
//后序位置
}
2.前、中序的迭代实现
void Traversal(TreeNode* root) {
stack<TreeNode*> s;
while(!s.empty() || root){
while(root!=nullptr){
s.push(root);
//前序位置
root=root->left;
}
root=s.top();
s.pop(); //访问完成
//中序位置
root=root->right;
}
}
3.后序的迭代实现
void postorderTraversal(TreeNode* root) {
TreeNode* prev=nullptr;
stack<TreeNode*> s;
while(root != nullptr || !s.empty()){
while(root!=nullptr){
s.push(root);
root=root->left;
}
root=s.top();
//当没有右子树或前一个遍历的节点是右子树的跟节点时,说明右子树已遍历完
if(root->right==nullptr || root->right==prev){
//后序位置
prev=root;
root=nullptr; //准备返回父节点
s.pop(); //访问完成
}else{
root=root->right; //准备进入右节点,此时未访问该节点,因此不能pop
}
}
}
4.层序遍历
void levelOrder(TreeNode* root) {
if(root==nullptr)
return;
queue<TreeNode*> q;
q.push(root);
while(!q.empty()){ //while循环控制从上往下遍历
int sz=q.size();
for(int i=0;i<sz;i++){ //for循环控制从左往右遍历
TreeNode* cur=q.front();
q.pop();
//层序位置
if(cur->left)
q.push(cur->left);
if(cur->right)
q.push(cur->right);
}
}
}
二、BST插入
TreeNode* insertIntoBST(TreeNode* root, int val) {
if(root==nullptr)
return new TreeNode(val);
if(val>root->val)
root->right=insertIntoBST(root->right, val);
else
root->left=insertIntoBST(root->left, val);
return root;
}
三、BST删除
TreeNode* deleteNode(TreeNode* root, int key) {
if(root==nullptr)
return nullptr;
if(root->val==key){
if(root->left==nullptr)
return root->right;
else if(root->right==nullptr)
return root->left;
else{
TreeNode* tmp=findMax(root->left);
tmp->left=deleteNode(root->left, tmp->val);
tmp->right=root->right;
root=tmp;
}
}else if(root->val>key)
root->left=deleteNode(root->left, key);
else
root->right=deleteNode(root->right, key);
return root;
}
TreeNode* findMax(TreeNode* root){
TreeNode* p=root;
while(p->right!=nullptr)
p=p->right;
return p;
}
二、二叉树问题的两大解法
二叉树是递归实现的,这意味着我们解决问题时,只需考虑在单独一个节点上要做什么,在什么位置(前序/中序/后序/层序)做。其中单独一个节点有四种情况:空节点、左子树为空、右子树为空、左右子树均不空。中序经常在BST相关的题中用到,后序经常在需要知道左右子树的结构时用到,而前序则不需要知道左右子树的结构。
1、遍历
核心思想:通过遍历一遍二叉树,并配合外部变量解决问题,辅助函数通常无返回值
2、分解
核心思想:把问题分解为两个子问题,辅助函数通常有返回值
3、例题分析
例一 965.单值二叉树
先用遍历的思想考虑,开始用num记录跟节点的值,再遍历二叉树,把每个节点的值依次与num比较
class Solution {
int num;
bool flag=true;
public:
bool isUnivalTree(TreeNode* root) {
if(root==nullptr)
return true;
num=root->val;
traverse(root);
return flag;
}
void traverse(TreeNode* root){
if(root==nullptr)
return;
if(flag==false)
return;
if(root->val!=num){
flag=false;
return;
}
traverse(root->left);
traverse(root->right);
}
};
如果用分解问题的思想,可以先判断左右子树是否为单值二叉树,若均是,再判断跟节点的值和左右节点的值是否相等
class Solution {
int num;
public:
bool isUnivalTree(TreeNode* root) {
if(root==nullptr)
return true;
if(isUnivalTree(root->left)&&isUnivalTree(root->right)){
if(root->left==nullptr && root->right==nullptr)
return true;
else if(root->left==nullptr)
return root->val==root->right->val;
else if(root->right==nullptr)
return root->val==root->left->val;
else
return root->val==root->right->val&&root->val==root->left->val;
}else{
return false;
}
}
};
例二 654.最大二叉树
构造类问题采用分解的思想,用一个build的辅助函数,其中left,right限定了查找最大值的范围,这样就不用额外构造两个子数组,再拷贝元素了。
class Solution {
public:
TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
return build(nums,0,nums.size()-1);
}
TreeNode* build(vector<int>& nums, int left,int right){
if(left==right)
return new TreeNode(nums[left]);
if(left>right)
return nullptr;
int maxIndex=left;
for(int i=left+1;i<=right;i++){
if(nums[i]>nums[maxIndex])
maxIndex=i;
}
TreeNode* root=new TreeNode(nums[maxIndex]);
root->left=build(nums, left, maxIndex-1);
root->right=build(nums,maxIndex+1, right);
return root;
}
};
不难发现,nums数组中的元素其实会被重复遍历,实际上,若maxIndex每次出现在right位置,那么总时间复杂度会达到O(),那能不能一次遍历解决问题呢?能。在前面树的遍历中,递归实现总能改写为用栈的迭代实现(递归在内存里就是用栈实现的),因此树的问题往栈的方向想想总会有所启发。这里我们用单调栈来解决。
class Solution {
public:
TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
int n=nums.size();
stack<TreeNode*> s;
for(auto &x:nums){
TreeNode* node=new TreeNode(x);
while(!s.empty() && x>s.top()->val){
node->left=s.top();
s.pop();
}
if(!s.empty())
s.top()->right=node;
s.push(node);
}
while(s.size()>1){
s.pop();
}
return s.top();
}
};
例三 297.二叉树的序列化与反序列化
序列化问题就是遍历问题,把遍历结果转化为字符串就行。这里用前序遍历,并使用istringstream类将含空格的字符串隐式转化为字符串数组。每次执行ss>>data语句相当于访问数组的下一个元素。
class Codec {
public:
// Encodes a tree to a single string.
string serialize(TreeNode* root) {
string s;
traverse(s,root);
return s;
}
// Decodes your encoded data to tree.
TreeNode* deserialize(string data) {
istringstream ss(data);
return build(ss);
}
void traverse(string& s,TreeNode* root){
if(root==NULL){
s+="# ";
return;
}
s+=to_string(root->val)+" ";
traverse(s,root->left);
traverse(s,root->right);
}
TreeNode* build(istringstream& ss){
string data;
ss>>data;
if(data=="#"){
return nullptr;
}
TreeNode* root=new TreeNode(stoi(data));
root->left=build(ss);
root->right=build(ss);
return root;
}
};
例四 543.二叉树的直径
用遍历的思想,对每个节点,计算过该节点的最长路径(记为length),length等于左子树的最大深度加上右子树的最大深度。用maxLength寻找所有length中的最大值,即为最终答案。
class Solution {
int maxLength=0;
public:
int diameterOfBinaryTree(TreeNode* root) {
traverse(root);
return maxLength;
}
void traverse(TreeNode* root){
if(root==nullptr)
return;
int length=maxDepth(root->left)+maxDepth(root->right);
maxLength=max(maxLength,length);
traverse(root->left);
traverse(root->right);
}
int maxDepth(TreeNode* root){
if(root==nullptr)
return 0;
return max(maxDepth(root->left),maxDepth(root->right))+1;
}
};
以上解法使用了两重递归,时间复杂度为,其实可以只用一重递归,我们采用后序遍历,让traverse函数返回当前节点的最大深度。这样我们就不用再去调maxDepth函数了。
class Solution {
int maxLength=0;
public:
int diameterOfBinaryTree(TreeNode* root) {
traverse(root);
return maxLength;
}
int traverse(TreeNode* root){
if(root==nullptr)
return 0;
int leftDepth=traverse(root->left);
int rightDepth=traverse(root->right);
int depth=max(leftDepth,rightDepth)+1;
maxLength=max(maxLength,leftDepth+rightDepth);
return depth;
}
};
例五 236.二叉树的最近公共祖先
本题采用分解问题的思想,先明确lowestCommonAncestor函数的定义,
给该函数输⼊三个参数 root,p,q,它会返回⼀个节点:情况 1,如果 p 和 q 都在以 root 为根的树中,函数返回 p 和 q 的最近公共祖先节点。情况 2,如果 p 和 q 都不在以 root 为根的树中,函数返回 null 。情况 3,那如果 p 和 q 只有⼀个存在于 root 为根的树中呢?函数就会返回那个节点。根据这个定义,对左右节点递归调用函数,并将结果存储在left和right分情况讨论:情况 1,如果 left 为 null ,说明左子树中无p, q节点,最近公共祖先节点为 root 或 right。情况 2,如果 right 为 null ,说明右子树中无p, q节点,最近公共祖先节点为 root 或 left。情况 3,如果均不为 null,说明p, q在跟节点两侧,那么最近公共祖先节点只能为 root。
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if(root==nullptr){
return nullptr;
}
TreeNode* left=lowestCommonAncestor(root->left, p, q);
TreeNode* right=lowestCommonAncestor(root->right, p, q);
if(left==nullptr){
if(root==p || root==q){
return root;
}else{
return right;
}
}else if(right==nullptr){
if(root==p || root==q){
return root;
}else{
return left;
}
}else{
return root;
}
}
};
例六 114.二叉树展开为链表
依旧可以用分解问题的思想,先把左右子树展开为链表,再把右子树换为左子树,最后把原来的左子树接到右子树上即可。
class Solution {
public:
void flatten(TreeNode* root) {
if(root==nullptr)
return;
flatten(root->left);
flatten(root->right);
TreeNode* left=root->left;
TreeNode* right=root->right;
root->left=nullptr;
root->right=left;
TreeNode* p=root;
while(p->right){
p=p->right;
}
p->right=right;
}
};
例七 222.完全二叉树的节点个数
本题的常规方法很简单,遍历一遍二叉树即可。但这种解法没有用到完全二叉树的性质,因此不是最优解。我们用 h 表示层数,并规定跟节点在第0层,则每层的节点个数在 范围内,根据完全二叉树的特性可知,完全二叉树的最左边的节点一定位于最底层,因此从根节点出发,每次访问左子节点,同时递增 h, 直到遇到叶子节点。由等比数列求和可得总节点个数在和之间,然后用二分查找,判断第k个节点是否在二叉树内。
如何判断第 k 个节点是否存在呢?如果第 k 个节点位于第 h 层,则 k 的二进制表示包含 h+1 位,其中最高位是 1,其余各位从高到低表示从根节点到第 k 个节点的路径,0 表示移动到左子节点,1 表示移动到右子节点。通过位运算得到第 k 个节点对应的路径,判断该路径对应的节点是否存在,即可判断第 k 个节点是否存在。
class Solution {
public:
int countNodes(TreeNode* root) {
if(root==nullptr)
return 0;
int h=0;
TreeNode* pt=root;
while(pt->left){
pt=pt->left;
h++;
}
int left=1<<h,right=(1<<(h+1))-1;
while(left<=right){
int mid=left+(right-left)/2;
if(exist(root,mid,h+1)==1){
left=mid+1;
}else{
right=mid-1;
}
}
return left-1;
}
bool exist(TreeNode* root ,int x,int w){ //w为x转化为二进制后的位数
if(w==1)
return true;
TreeNode* p=root;
int bits=1<<(w-2);
while(bits){
if(x & bits)
p=p->right;
else
p=p->left;
bits>>=1;
}
return p!=nullptr;
}
};
例八 230.二叉搜索树中第K小的元素
不难想到简单的解决办法是利用BST中序遍历的有序性,遍历到第K个节点输出即可
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
traverse(root,k);
return res;
}
int cnt=0;
int res;
void traverse(TreeNode* root, int k){
if(root==NULL)
return;
traverse(root->left,k);
if(++cnt==k){
res=root->val;
return;
}
traverse(root->right,k);
}
};
BST树性质的还有一种应用方法是若左子树有k个节点,那么根节点即为第k小的元素。因此我们可以先预处理,用哈希表存储每颗子树的节点个数。再用二分查找的思想寻找答案。若左子树有K-1个节点,则当前节点即为答案;若大于K-1个,说明答案在左子树中,到左子树中去找;若小于K-1个,说明答案在右子树中,到右子树中去找,同时更新K的值(在右子树中就不是找第K小的元素了)。
class Solution {
unordered_map<TreeNode*,int> nodeNums;
public:
int kthSmallest(TreeNode* root, int k) {
countNodeNums(root);
int num=getNum(root->left);
while(num!=k-1){
if(num<k-1){
root=root->right;
k=k-num-1;
}else{
root=root->left;
}
num=getNum(root->left);
}
return root->val;
}
int countNodeNums(TreeNode* root){
if(root==nullptr)
return 0;
nodeNums[root] = 1+countNodeNums(root->left)+countNodeNums(root->right);
return nodeNums[root];
}
int getNum(TreeNode* root){
if(root != nullptr){
return nodeNums[root];
}else{
return 0;
}
}
};
例九 1373.二叉搜索子树的最大键值和
用遍历的思想,对每个节点,我们需要知道以该节点为根节点的二叉树是否为BST树,以及该二叉树的键值和。而判断是否为BST树,需要知道左子树的最大值和右子树的最小值。如果对判断BST和计算键值和分别写两个单独的函数,免不了多次遍历。那可不可以就一次呢?可以。利用后序遍历,可以知道左右子树的信息,因此可以让traverse函数返回一个四元组 {是否为BST,以当前节点为根的二叉树中的最小值,当前节点为根的二叉树中的最大值,以当前节点为根的二叉树的键值和} ,这样就实现了一次遍历解决问题。
class Solution {
int maxSum=0;
public:
int maxSumBST(TreeNode* root) {
traverse(root);
return maxSum;
}
vector<int> traverse(TreeNode* root){
if(root==NULL)
return {1,INT_MAX,INT_MIN,0};
vector<int> left=traverse(root->left);
vector<int> right=traverse(root->right);
if(left[0]&&right[0]&&root->val>left[2]&&root->val<right[1]){
int sum=left[3]+right[3]+root->val;
maxSum=max(maxSum, sum);
//必须要有max,min函数,不能返回{1,left[1],right[2],sum}, 否则永远为{1,INT_MAX,INT_MIN,sum}
return {1,min(left[1], root->val),max(right[2],root->val),sum};
}else{
return {0};
}
}
};
例十 783.二叉搜索树节点最小距离
利用BST中序遍历的有序性,把当前访问节点与前一次访问的节点做差,寻找所有差的最小值即可(因为是递增的,所以不需要绝对值)
class Solution {
int Min=INT_MAX;
public:
int minDiffInBST(TreeNode* root) {
traverse(root);
return Min;
}
//用prev记录前一次访问的节点
TreeNode* prev=nullptr;
void traverse(TreeNode* root){
if(root==nullptr)
return;
traverse(root->left);
if(prev!=nullptr){
Min=min(Min,root->val - prev->val);
}
prev=root;
traverse(root->right);
}
};
例十一 501.二叉搜索树中的众数
可以顺序扫描中序遍历序列,用prev记录重复的数,用 curCount 记录当前数字重复的次数,用 maxCount 来维护已经扫描过的数当中出现最多的那个数字的出现次数,用 ans 数组记录出现的众数。
class Solution {
vector<int> ans;
int curCount=0, maxCount=0;
int prev=INT_MAX;
public:
vector<int> findMode(TreeNode* root) {
traverse(root);
return ans;
}
void traverse(TreeNode* root){
if(root==nullptr)
return;
traverse(root->left);
if(root->val != prev){
curCount=1;
prev=root->val;
}else{
curCount++;
}
if(curCount>maxCount){
ans.clear();
ans.emplace_back(root->val);
maxCount=curCount;
}else if(curCount==maxCount){
ans.emplace_back(root->val);
}
traverse(root->right);
}
};
例十二 96.不同的二叉搜索树
可以把numTrees的含义理解为返回由n个不同的数所能构成的不同BST树的个数,因为本题其实并不需要关心节点值的具体大小,我们对n个不同的数从小到大编号,编号i表示第i大的数。假设编号为i的数是根节点,那么左子树有i-1个比它小的数,右子树有n-i个比它大的数,然后递归求解即可。
由于递归过程中会多次遇到相同的子问题,可以用一个哈希表备忘录构建n到BST数的个数的映射来减少递归次数。
class Solution {
unordered_map<int, int> memo;
public:
int numTrees(int n) {
if(n==0||n==1)
return 1;
if(memo[n]!=0)
return memo[n];
int sum=0;
for(int i=1;i<=n;i++){
int left=numTrees(i-1);
int right=numTrees(n-i);
sum+=left*right;
}
memo[n]=sum;
return sum;
}
};
例十三 95.不同的二叉搜索树 II
本题想法与上一题相似。先穷举 root 的所有可能,再递归构造出左右子树的所有合法BST,最后结合 root 节点穷举左右子树的所有组合。
class Solution {
map<pair<int,int>,vector<TreeNode*>> memo; //unordered_map不能用pari来作key值
public:
vector<TreeNode*> generateTrees(int n) {
return generateTrees(1, n);
}
vector<TreeNode*> generateTrees(int low, int up){
if(low>up){
return {nullptr};
}
if(memo.find(make_pair(low,up))!=memo.end())
return memo[pair<int,int>(low,up)];
vector<TreeNode*> allTrees;
for(int i=low;i<=up;i++){
vector<TreeNode*> leftTrees=generateTrees(low, i-1);
vector<TreeNode*> rightTrees=generateTrees(i+1, up);
for(auto& left: leftTrees){
for(auto& right: rightTrees){
TreeNode* root=new TreeNode(i);
root->left=left;
root->right=right;
allTrees.emplace_back(root);
}
}
}
memo[pair<int,int>(low,up)]=allTrees;
return allTrees;
}
};
三、二叉树思想的应用:归并排序
1.核心思想
归并排序其实是二叉树的后序遍历,可以是把原先无序的数组拆分成两个有序的数组从而使问题得到化简
2.例题分析
例一 315.计算右侧小于当前元素的个数
我们在使用 merge 函数合并两个有序数组的时候,其实是可以知道一个元素 nums[i] 后边有多少个元素比 nums[i] 小的。
在对 nums[lo..hi] 合并的过程中,每当i右移时,就可以确定 temp[i] 这个元素后面比它小的元素个数为 j - mid - 1。在合并时,每个元素的相对位置都在变,因此可以引入一个新数组记录原始下标。
class Solution {
vector<pair<int,int>> v; //同时记录数据和原始下标
vector<int> ans;
public:
vector<int> countSmaller(vector<int>& nums) {
ans.resize(nums.size());
for(int i=0;i<nums.size();i++){
v.push_back(make_pair(nums[i], i));
}
mergeSort(v,0,v.size()-1);
return ans;
}
void mergeSort(vector<pair<int,int>>& v, int left, int right){
if(left==right)
return;
int mid=left + ((right - left)>>1);
mergeSort(v, left, mid);
mergeSort(v, mid+1, right);
merge(v,left, mid, right);
}
vector<pair<int, int>> tmp;
void merge(vector<pair<int,int>>& v, int left, int mid, int right){
int pl=left;
int pr=mid+1;
while(pl<=mid || pr<=right){
if(pl>mid){
tmp.push_back(v[pr++]);
}else if(pr>right){
tmp.push_back(v[pl]);
ans[v[pl++].second] += pr-(mid+1);
}else if(v[pl].first > v[pr].first){
tmp.push_back(v[pr++]);
}else{
tmp.push_back(v[pl]);
ans[v[pl++].second] += pr-(mid+1);
}
}
for(int i=0;i<tmp.size();i++){
v[i+left] = tmp[i];
}
tmp.clear();
}
};
例二 493.翻转对
本题思路和 315. 计算右侧小于当前元素的个数 大致一致。只要对于nums[left,mid]中的每个元素,在nums[mid+1,right]中找符合条件的即可。
比较暴力的找法是用两个for循环:
for(int i=left;i<=mid;i++){
for(int j=mid+1;j<=right;j++){
if((long) nums[i]>(long) nums[j]*2)
cnt++;
}
}
这种解法的nums[mid+1,right]中很多元素是重复遍历的,可以用变量end记录,维护nums[mid+1,end)是符合条件的区间即可,由于end是单增的,所以时间复杂度为O(N)
for(int i=left;i<=mid;i++){
while(end<=right && (long) nums[i] > (long) 2*nums[end]){
++end;
}
cnt += end-mid-1;
}
完整代码:
class Solution {
int cnt=0;
public:
int reversePairs(vector<int>& nums) {
sortMerge(nums,0,nums.size()-1);
return cnt;
}
void sortMerge(vector<int>& nums, int left, int right){
if(left==right)
return;
int mid = left + ((right - left)>>1);
sortMerge(nums, left, mid);
sortMerge(nums, mid+1, right);
merge(nums, left, mid, right);
}
vector<int> tmp;
void merge(vector<int>& nums, int left, int mid, int right){
int pl=left, pr=mid+1, pt=0;
int end=mid+1;
for(int i=left;i<=mid;i++){
while(end<=right && (long) nums[i] > (long) 2*nums[end]){
++end;
}
cnt += end-mid-1;
}
tmp.resize(right-left+1);
while(pl<=mid || pr<=right){
if(pl>mid){
tmp[pt++]=nums[pr++];
}else if(pr>right){
//while(end<=right && (long) nums[pl] > (long) 2*nums[end])
// ++end;
//cnt += end-mid-1; 也可以边排序边计算
tmp[pt++]=nums[pl++];
}else if(nums[pl]>=nums[pr]){
tmp[pt++]=nums[pr++];
}else{
//while(end<=right && (long) nums[pl] > (long) 2*nums[end])
// ++end;
//cnt += end-mid-1;
tmp[pt++]=nums[pl++];
}
}
for(int i=0;i<pt;i++){
nums[i+left]=tmp[i];
}
}
};
例三 327.区间和的个数
首先,解决这道题需要快速计算子数组的和,所以需要创建一个前缀和数组 preSum 来辅助我们迅速计算区间和。在 presum 数组中,对任意的两个下标 i, j(i < j), presum[j]-presum[i] 都对应 nums 的一个区间和。所以要找到这样的 (i, j) , 使得lower<=presum[j]-presum[i]<=upper。还是与“右边”这个概念相关,可用归并排序
class Solution {
public:
int cnt=0;
int low;
int up;
int countRangeSum(vector<int>& nums, int lower, int upper) {
low=lower;
up=upper;
long presum[nums.size()+1];
presum[0]=0;
for(int i=1;i<nums.size()+1;i++){
presum[i]=presum[i-1]+nums[i-1];
}
mergeSort(presum,0,nums.size());
return cnt;
}
void mergeSort(long* nums, int left, int right){
if(left==right)
return;
int mid=left+((right-left)>>1);
mergeSort(nums, left, mid);
mergeSort(nums, mid+1, right);
merge(nums,left,mid,right);
}
vector<long> tmp;
void merge(long* nums, int left, int mid, int right){
int pl=left, pr=mid+1, pt=0;
tmp.resize(right-left+1);
int start=mid+1, end=mid+1; //维护[start,end)区间为满足条件的区间
for(int i=left;i<=mid;i++){
while(end<=right && nums[end]-nums[i]<=up){ //end左边均<=up
++end;
}
while(start<=right && nums[start]-nums[i]<low){ //start右边均>=low
++start;
}
cnt += end-start;
}
while(pl<=mid || pr<=right){
if(pl>mid){
tmp[pt++]=nums[pr++];
}else if(pr>right){
tmp[pt++]=nums[pl++];
}else if(nums[pl]>=nums[pr]){
tmp[pt++]=nums[pr++];
}else{
tmp[pt++]=nums[pl++];
}
}
for(int i=0;i<pt;i++){
nums[i+left]=tmp[i];
}
}
};