【LeetCode Cookbook(C++ 描述)】一刷二叉树综合(下)

目录

本系列文章仅是 GitHub 大神 @halfrost 的刷题笔记 《LeetCode Cookbook》的提纲以及示例、题集的 C++转化。原书请自行下载学习。

本篇文章涉及新手应该优先刷的几道经典二叉树综合算法题。

LeetCode #257:Binary Tree Paths 二叉树的所有路径

给定一个二叉树的根节点 root ,按任意顺序 ,返回所有从根节点到叶子节点的路径。

深度优先搜索

类似于二叉树的最大深度问题,从根节点递归到叶子节点,记录路径上的节点,并更新维护路径。每次都是先从根节点开始,先递归左子树, 再递归右子树,自顶向下,考虑采用前序遍历的方式遍历二叉树。

对于这一问题,我们采用二维字符串数组来存储每一条路径,如果当前节点不是叶子节点,则在当前的路径末尾添加该节点 ,并继续递归遍历该节点的每一个子节点;如果当前节点是叶子节点,则在当前路径末尾添加该节点,就得到了一条从根节点到叶子节点的路径,将该路径加入到答案即可。

我们需要一个辅助函数 construct_paths() 来反复操作字符串数组 paths 以正确地遍历左右子树:

cpp 复制代码
class Solution {
public:
    vector<string> binaryTreePaths(TreeNode* root) {
        vector<string> paths;
        construct_paths(root, "", paths);
        return paths;
    }

private:
    void construct_paths(TreeNode* root, string path, vector<string>& paths) {
       if (root != nullptr) {
           path += to_string(root->val);
           if (root->left == nullptr && root->right == nullptr) paths.push_back(path);   //当前节点是叶子节点
           else {
               path += "->";    //当前节点不是叶子节点,继续递归遍历
               construct_paths(root->left, path, paths);
               construct_paths(root->right, path, paths);
           }
       }
    }
};

在这一算法中,每个节点会被访问一次且只会被访问一次,每一次会对 path 变量进行拷贝构造,时间代价为 O ( n ) \ O(n) O(n),总体的时间复杂度为 O ( n 2 ) \ O(n^2) O(n2) 。

在最坏情况下,当二叉树中每个节点只有一个孩子节点时,即整棵二叉树呈一个链状 ,此时递归的层数为 n n n ,此时每一层的 path 变量的空间代价的总和为 O ( ∑ i = 1 n i ) = O ( n 2 ) O\left(\sum^{n}_{i=1}i\right) = O(n^2) O(∑i=1ni)=O(n2),即该算法的空间复杂度。

广度优先搜索

我们维护两个队列,分别存储节点以及根到该节点的路径,在每步迭代中,我们取出队首节点,如果它是叶子节点,则将它对应的路径加入到答案中;如果它不是叶子节点,则将它的所有子节点加入到队列的末尾,直到队列为空。

cpp 复制代码
class Solution {
public:
    vector<string> binaryTreePaths(TreeNode* root) {
        vector<string> paths;
        if (root == nullptr) return paths;

        queue<TreeNode*> node_queue;   //存储待处理的节点
        queue<string> path_queue;      //存储到达当前节点的路径

        node_queue.push(root);   //将根节点加入节点队列
        path_queue.push(to_string(root->val));   //将根节点的值转换为字符串,‌并加入路径队列

        while (!node_queue.empty()) {
            TreeNode* node = node_queue.front(); 
            string path = path_queue.front();
            node_queue.pop(); 
            path_queue.pop();

            if (node->left == nullptr && node->right == nullptr) paths.push_back(path);
            else {
                //如果左子节点非叶子节点,‌则将左子节点加入节点队列,‌并更新路径
                if (node->left != nullptr) {
                    node_queue.push(node->left);
                    path_queue.push(path + "->" + to_string(node->left->val));
                }
                //如果右子节点非叶子节点,‌则将右子节点加入节点队列,‌并更新路径
                if (node->right != nullptr) {
                    node_queue.push(node->right);
                    path_queue.push(path + "->" + to_string(node->right->val));
                }
            }
        }
        return paths;
    }
};

LeetCode #404:Sum of Left Leaves 左叶子之和

给定二叉树的根节点 root ,返回所有左叶子之和。

非常简单,遍历整棵树:

  • 如果遍历的当前节点的左孩子是一个叶子节点,则左孩子的值累加入结果。
  • 如果遍历的当前节点的左孩子不是叶子节点,则继续遍历。

深度优先搜索

cpp 复制代码
class Solution {
public:
    int sumOfLeftLeaves(TreeNode* root) {
        if (root == nullptr) return 0;
        //如果当前节点的左孩子存在,‌并且左孩子为叶子节点‌,‌则将左孩子的值加入res
        if (root->left != nullptr && root->left->left == nullptr && root->left->right == nullptr) res += root->left->val;
        //递归遍历左子树和右子树
        sumOfLeftLeaves(root->left);
        sumOfLeftLeaves(root->right);
        return res;
    }
    
private:
	int res = 0;
};

该算法的时间复杂度和空间复杂度均为 O ( n ) O(n) O(n)。

广度优先搜索

cpp 复制代码
class Solution {
public:
    int sumOfLeftLeaves(TreeNode* root) {
        if (!root) return 0;

        queue<TreeNode*> q;
        q.push(root);
        int ans = 0;
        
        while (!q.empty()) {
            TreeNode* node = q.front();
            q.pop();
            
            if (node->left)
                if (isLeafNode(node->left)) ans += node->left->val;
                else q.push(node->left);

            if (node->right)
                if (!isLeafNode(node->right)) q.push(node->right);
        }
        return ans;
    }

private:
	 bool isLeafNode(TreeNode* node) {
        return !node->left && !node->right;
     }
};

LeetCode #199:Binary Tree Right Side View 二叉树的右视图

给定一个二叉树的根节点 root ,想象自己站在它的右侧,按照从顶部到底部的顺序 ,返回从右侧所能看到的节点值。

所谓右视图,其实就是每一层最右边的节点 ,涉及到"层"的概念,直接利用层次遍历 也就是广度优先搜索算法来解决。对应到具体操作上,对于每一层的节点从左到右遍历 ,保存每层最后一个遍历的节点即可。

广度优先搜索

使用队列 保存每一层的所有节点,把队列里的所有节点弹出队列,如果当前节点是当前层

的最后一个节点,入 res

cpp 复制代码
class Solution {
public:
    vector<int> rightSideView(TreeNode* root) {
        vector<int> res;
        if (root == nullptr) return res;
        
        queue<TreeNode*> q;
        q.push(root);
        
        while (!q.empty()) {
            int size = q.size();
            for (int i = 0; i < size; i++) {
                TreeNode* node = q.front();
                q.pop();
                //如果当前节点是该层的最后一个节点,‌则将其值添加到结果中
                if (i == size - 1) res.push_back(node->val);
                //将当前节点的左右子节点‌添加到队列中
                if (node->left != nullptr) q.push(node->left);
                if (node->right != nullptr) q.push(node->right);
            }
        }
        
        return res;
    }
};

该算法的时间复杂度与空间复杂度均为 O ( n ) O(n) O(n)。

深度优先搜索

层次遍历对于每一层,均为从左到右遍历,而我们可以换个方向,从右向左遍历 , 这样对于每一层来说,遍历的第一个节点就是所谓右视图的子节点。 因此,我们可以先遍历右子树,再遍历左子树

cpp 复制代码
class Solution {
public:
    vector<int> rightSideView(TreeNode* root) {
        vector<int> res;
        if (root == nullptr) return res;

        level(root, 1, res);
        return res;
    }

private:
    void level(TreeNode* root, int depth, vector<int>& res) {
        //如果当前节点的深度还未在 res 中出现(‌每层仅有一个节点)‌
        //这意味着当前节点是该层第一个被访问的,‌将当前节点的值添加到 res 中
        if (res.size() < depth) res.push_back(root->val);
        //遍历右子树
        if (root->right != nullptr) level(root->right, depth + 1, res);
        //遍历左子树
        if (root->left != nullptr) level(root->left, depth + 1, res);
    }
};

LeetCode #513:Find Bottom Left Tree Value 找树左下角值

给定一个二叉树的根节点 root ,请找出该二叉树最底层最左边节点的值。

所谓"最底层",便是二叉树最大深度,最大深度那一层必然是最后一层,其次则是"最左边"。

深度优先搜索

我们可以自顶向下,从根节点递归到叶子节点,对于每一个节点,先判断是否为叶子节点,再按前序遍历左右子树。

我们需要维护两个深度,一个是当前的遍历到的最大深度 maxDepth ,另一个则是当前节点所处的深度 leftDepth 。如果当前节点是叶子节点,且 leftDepth > maxDepth 的时候,证明当前遍历的节点是新的一层最先被遍历的节点^1^,更新 maxDepth 和当前的结果 res

cpp 复制代码
class Solution {
private:
    int maxDepth = -1;
    int res = -1;

    void leftValue(TreeNode* root, int leftDepth) {
        if (root == nullptr) return;
        //如果当前节点是叶子节点
        if (root->left == nullptr && root->right == nullptr) {
            //当前叶子节点的深度大于之前保存的最大深度
            //此时更新最大深度,‌更新结果值
            if (leftDepth > maxDepth) {
                maxDepth = leftDepth;
                res = root->val;
            }
        }
        //递归左子树
        leftValue(root->left, leftDepth + 1);
        //递归右子树
        leftValue(root->right, leftDepth + 1);
    }

public:
    int findBottomLeftValue(TreeNode* root) {
        leftValue(root, 0);
        return res;
    }
};

该算法的时间复杂度与空间复杂度均为 O ( n ) O(n) O(n)。

广度优先搜索

我们也可以自左向右,根节点开始,一层一层地遍历二叉树,那么最后一层的第一个节点便是我们希望得到的结果,即所谓层序遍历 。我们依然采用经典的队列进行遍历,使用队列保存每一层的节点,第 1 个出队列的节点值保存(即该层最左边的值),把队列里的所有节点出队列,然后把这些出去节点各自的子节点入队列 。用 depth 维护每一层。

cpp 复制代码
class Solution {
public:
    int findBottomLeftValue(TreeNode* root) {
        queue<TreeNode*> q;
        q.push(root);
        int res = 0;
        
        while (!q.empty()) {
            int n = q.size();
            for (int i = 0; i < n; i++) {
                TreeNode* node = q.front();
                q.pop();
                //存储每一层的第一个元素
                if (i == 0) res = node->val;
                if (node->left) q.push(node->left);
                if (node->right) q.push(node->right);
            }
        }
        return res;
    }
};

LeetCode #112:Path Sum 路径总和

给定二叉树的根节点 root 和一个表示目标的整数 targetSum

判断该树中是否存在根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum 。存在返回 true ,否则返回 false

深度优先搜索

依然采用自顶向下的思路,以前序遍历的方式,每次先判断当前的节点,再递归左子树,最后是右子树。具体的解法如下:

  • 判断当前节点是否为叶子节点,如果是,则判断当前叶子节点的值是否为 targetSum 减去之前路径上节点值
  • 递归左子树。
  • 递归右子树。
cpp 复制代码
class Solution {
public:
    bool hasPathSum(TreeNode* root, int targetSum) {
        if (root == nullptr) return false;
        //如果当前节点为叶子节点,‌且叶子节点的值等于减去该路径之前节点的值,‌返回 true
        if (root->left == nullptr && root->right == nullptr && root->val == targetSum) return true;
        //递归左子树
        bool leftPath = hasPathSum(root->left, targetSum - root->val);
        //递归右子树
        bool rightPath = hasPathSum(root->right, targetSum - root->val);
        //返回左子树或右子树的结果
        return leftPath || rightPath;
    }
};

该算法的时间复杂度和空间复杂度均为 O ( n ) O(n) O(n)。

广度优先搜索

我们使用两个队列,分别存储将要遍历的节点,以及根节点到这些节点的路径和,以防止重复计算。

cpp 复制代码
class Solution {
public:
    bool hasPathSum(TreeNode *root, int targetSum) {
        if (root == nullptr) return false;
        //定义两个队列,‌一个用来存储节点,‌一个用来存储从根节点到当前节点的路径和
        queue<TreeNode *> que_node;
        queue<int> que_val;
        //将根节点和根节点的值入队
        que_node.push(root);
        que_val.push(root->val);
   
        while (!que_node.empty()) {
            //取出当前节点和当前路径和
            TreeNode *node = que_node.front();
            int currSum = que_val.front();
            que_node.pop();
            que_val.pop();
            //如果当前节点是叶子节点
            if (node->left == nullptr && node->right == nullptr) {
                //如果当前路径和等于给定的和,‌则返回ntrue
                if (currSum == targetSum) return true;
                continue;
            }
            //如果左子节点不为空,‌则将左子节点与左子节点路径和入队
            if (node->left != nullptr) {
                que_node.push(node->left);
                que_val.push(node->left->val + currSum);
            }
            //如果右子节点不为空,‌则将右子节点与右子节点路径和入队
            if (node->right != nullptr) {
                que_node.push(node->right);
                que_val.push(node->right->val + currSum);
            }
        }
        //如果遍历完所有路径都没有找到符合条件的路径,‌则返回 false
        return false;
    }
};

LeetCode #617:Merge Two Binary Trees 合并二叉树

给定两棵二叉树 root1root2 ,合并成一棵新的二叉树,合并规则为:

如果两个节点重叠,将两个节点的值相加作为合并后节点的新值,否则,不为 null 的节点将直接作为新二叉树的节点。

大致可以分为两种情况讨论:

  • 如果两棵树对应位置上都有节点,则新节点的值为两个节点的值相加。
  • 如果两棵树对应位置上只有一个节点有值,则新节点的值就为该节点的值。

只需要遍历二叉树,保证遵守如上两条规则即可。

深度优先搜索

对于每一层来说,重新创建一个节点 root 存储 root1->val + root2->val ,那么,之后 root 的左子树就是合root1 左子树和 root2 左子树之后 的左子树,root 的右子树就是合并 root1 右子树和 root2 右子树之后的右子树,依然是经典的前序遍历。

cpp 复制代码
class Solution {
public:
    TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
        // base case
        if (root1 == nullptr) return root2;
        if (root2 == nullptr) return root1;

        //如果均存在节点,‌创建一个新的节点存储合并后的值
        TreeNode* root = new TreeNode(root1->val + root2->val);
        //递归合并左子树
        root->left = mergeTrees(root1->left, root2->left);
        //递归合并右子树
        root->right = mergeTrees(root1->right, root2->right);
        
        return root;
    }
};

root1 的节点数为 n n n,root2 的节点数为 m m m,该算法的时间复杂度为 O ( min ⁡ ( n , m ) ) O(\min (n,m)) O(min(n,m)),空间复杂度也为 O ( min ⁡ ( n , m ) ) O(\min (n,m)) O(min(n,m))。

广度优先搜索

我们维护两个队列 ,队列 qMerge 存储合并后的树节点,q 则是 root1root2 的节点。

先创建一个新的根节点 root ,其值为 root1->val + root2->val ,同时初始化两个队列,将 rootqMerge 队列,将 root1root2 入队列 q 。此时队列不为空,qMerge 队首元素出队列 ,同时q 两个队首元素出队列

若两棵树的当前节点都存在左孩子,则直接合并二者之和,为新的节点的值,同时将新节点的左孩子入 qMerge 队列,将两棵树节点的左孩子入 q 队列。以此类推,对于两棵树当前节点的右孩子同理并继续弹出队列,反复遍历,直到队列为空。

cpp 复制代码
class Solution {
public:
    TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
    	// base case
        if (root1 == nullptr) return root2;
        if (root2 == nullptr) return root1;
		//如果都存在节点,创建一个新的节点存储合并后的值
        TreeNode* root = new TreeNode(root1->val + root2->val);
        //初始化队列
        queue<TreeNode*> qMerge;
        queue<TreeNode*> q;
        qMerge.push(root);
        q.push(root1);
        q.push(root2);
        
        while (!q.empty()) {
        	//从队列中取出当前节点
            TreeNode* node = qMerge.front();
            qMerge.pop();
            TreeNode* node1 = q.front();
            q.pop();
            TreeNode* node2 = q.front();
            q.pop();
            //若两棵树的左孩子都存在
            if (node1->left != nullptr || node2->left != nullptr) {
                if (node1->left != nullptr && node2->left != nullptr) {
                    TreeNode* leftMerge = new TreeNode(node1->left->val + node2->left->val);
                    node->left = leftMerge;
                    qMerge.push(leftMerge);
                    q.push(node1->left);
                    q.push(node2->left);
                } 
                else if (node1->left != nullptr) node->left = node1->left;    //若只有一棵树存在左孩子,直接赋值
                else if (node2->left != nullptr) node->left = node2->left;
            }
            //若两棵树的右孩子都在
            if (node1->right != nullptr || node2->right != nullptr) {
                if (node1->right != nullptr && node2->right != nullptr) {
                    TreeNode* rightMerge = new TreeNode(node1->right->val + node2->right->val);
                    node->right = rightMerge;
                    qMerge.push(rightMerge);
                    q.push(node1->right);
                    q.push(node2->right);
                } 
                else if (node1->right != nullptr) node->right = node1->right;    //若只有一棵树存在右孩子,直接赋值
                else if (node2->right != nullptr) node->right = node2->right;
            }
        }
        return root;
    }
};

LeetCode #236:Lowest Common Ancestor of a Binary Tree 二叉树的最近公共祖先

给定一个二叉树,找到该树中两个指定节点的最近公共祖先。

最近公共祖先:对于有根树 T 的两个节点 pq ,最近公共祖先表示为一个节点 x ,满足 xp q 的最先且 x 的深度尽可能地大(一个节点也可以是它自己的祖先)。

所谓祖先,其实就是从根节点到当前节点所经过的所有节点。我们用 Git 来引出「最近公共祖先」(LCA)这一经典问题。

git pull 默认使用 merge 方式将远端修改拉取到本地,如果加上参数而使用 git pull -r 这一命令,就会使用 rebase 方式拉取。这两者最重要的区别是,merge 方式合并的分支会看到很多「分叉 」,而 rebase 方式合并的分支就是一条直线。但无论哪种方式,如果存在冲突,Git 都会检测出来并让用户手动解决冲突。

rebase 命令为例,如图,在 dev 分支上执行 git rebase masterdev 就会接到 master 分支之上:

这个过程中,Git 先找到这两条分支的最近公共祖先 LCA ,然后master 节点开始,重演 LCAdevcommit 的修改 。如果这些修改和 LCAmastercommit 有冲突,就会提示用户手动解决冲突,最后的结果就是把 dev的分支完全接到 master 上面。因此,关键在于寻找 LCA 算法的实现

我们先实现一个简单的算法:给出一棵没有重复元素 的二叉树根节点 root 和一个目标值 val ,写一个函数以寻找树中值为 val 的节点。

cpp 复制代码
TreeNode* find(TreeNode* root, int val) {
    // base case
    if (root == nullptr) return nullptr;
    //检查 root->val 是否为所求节点
    if (root->val == val) return root;
    //若 root 不是目标节点,则递归左子树
    TreeNode* left = find(root->left, val);
    if (left != nullptr) return left;
    //左子树未找到,递归右子树
    TreeNode* right = find(root->right, val);
    if (right != nullptr) return right;
    
    return nullptr;
}

我们对这个函数进行改动。先修改一下 return 的位置

cpp 复制代码
TreeNode* find(TreeNode* root, int val) {
    if (root == nullptr) return null;
    //前序位置
    if (root->val == val) return root;
    // root 不是目标节点,去左右子树寻找
    TreeNode* left = find(root->left, val);
    TreeNode* right = find(root->right, val);
    //确定左右子树对应目标节点的位置
    return left != nullptr ? left : right;
}

可以实现目的,但是这段代码即使能够在左子树找到目标节点,它还是会去右子树找一圈,实际运行的效率会降低。

更进一步地,对 root->val 的判断从前序位置移动到后序位置

cpp 复制代码
TreeNode* find(TreeNode* root, int val) {
    if (root == null) return null;
    //先去左右子树寻找
    TreeNode* left = find(root->left, val);
    TreeNode* right = find(root->right, val);
    //后序位置,检查 root 是否为目标节点
    if (root->val == val) return root;
    // root 非目标节点,再去查看哪边的子树找到了
    return left != nullptr ? left : right;
}

这段代码相当于先去左右子树找,最后才检查 root ,依然可以实现目的,但是这种写法必然会遍历二叉树的每一个节点,效率会进一步降低。

对于之前的算法,在前序位置就检查 root ,如果输入的二叉树根节点的值恰好就是目标值 val ,那么函数直接返回了,其他的节点根本不用搜索;但如果在后序位置判断 ,那么就算根节点就是目标节点,也要去左右子树遍历完所有节点才能判断出来

此时,如果我们不再寻找值为 val 的单一节点,而是两个值为 val1val2 的节点,仿照这一写法,代码如下:

cpp 复制代码
TreeNode* find(TreeNode* root, int val1, int val2) {
    // base case
    if (root == nullptr) return nullptr;
    //前序位置,检查 root 是否为目标值
    if (root->val == val1 || root->val == val2) return root;
    //寻找左右子树
    TreeNode* left = find(root->left, val1, val2);
    TreeNode* right = find(root->right, val1, val2);
    //后序位置,已经知道左右子树是否存在目标值
    return left != nullptr ? left : right;
}

这一写法有些奇怪,而且也存在其他解法。但是我们可以利用 find() 方法来解决 LCA 问题。

对于本题,如果节点 nodepq 的最近公共祖先,那么会有 3 种情况:

  1. pq 分别在节点 node 的左右子树中。
  2. node 即为节点 pq 在节点 p 的左子树或右子树中。
  3. node 即为节点 qp 在节点 q 的左子树或者右子树中。

两个节点的最近公共祖先其实就是这两个节点向根节点的「延长线」的交汇点 ,那么对于任意一个节点,如果一个节点能够在它的左右子树中分别找到 pq ,则该节点为 LCA 节点

这就要用到之前实现的 find() 方法了,只需在后序位置添加一个判断逻辑,即可解决本题:

cpp 复制代码
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
    return find(root, p->val, q->val);
}
 
//在二叉树中寻找 val1 和 val2 的最近公共祖先节点
TreeNode* find(TreeNode* root, int val1, int val2) {
    if (root == nullptr) return nullptr;
    //前序位置
    if (root->val == val1 || root->val == val2) return root;    //如果遇到目标值,直接返回
        
    TreeNode* left = find(root->left, val1, val2);
    TreeNode* right = find(root->right, val1, val2);
    //后序位置,已经知道左右子树是否存在目标值
    if (left != nullptr && right != nullptr) return root;    //当前节点是 LCA 节点
 
    return left != nullptr ? left : right;
}

find() 方法的后序位置,如果发现 leftright 都非空,就说明当前节点是 LCA 节点,即解决了第一种情况;在 find() 方法的前序位置,如果找到一个值为 val1val2 的节点则直接返回,恰好解决了第二、三种情况。

基于 pq 一定存在于二叉树中 这一重要推断,即便遇到 q 就直接返回,根本没遍历到 p ,也依然可以断定 pq 底下,q 就是 LCA 节点。

该算法的时间复杂度为 O ( n ) O(n) O(n),空间复杂度也为 O ( n ) O(n) O(n)。


  1. 我们优先进行的是左子树的遍历,所以它肯定是当前层最左边的节点。 ↩︎
相关推荐
Charles Ray6 分钟前
C++学习笔记 —— 内存分配 new
c++·笔记·学习
重生之我在20年代敲代码6 分钟前
strncpy函数的使用和模拟实现
c语言·开发语言·c++·经验分享·笔记
limingade2 小时前
手机实时提取SIM卡打电话的信令和声音-新的篇章(一、可行的方案探讨)
物联网·算法·智能手机·数据分析·信息与通信
jiao000015 小时前
数据结构——队列
c语言·数据结构·算法
迷迭所归处6 小时前
C++ —— 关于vector
开发语言·c++·算法
leon6256 小时前
优化算法(一)—遗传算法(Genetic Algorithm)附MATLAB程序
开发语言·算法·matlab
CV工程师小林6 小时前
【算法】BFS 系列之边权为 1 的最短路问题
数据结构·c++·算法·leetcode·宽度优先
Navigator_Z7 小时前
数据结构C //线性表(链表)ADT结构及相关函数
c语言·数据结构·算法·链表
Aic山鱼7 小时前
【如何高效学习数据结构:构建编程的坚实基石】
数据结构·学习·算法
white__ice7 小时前
2024.9.19
c++