18.树(7) | 二叉搜索树的最小绝对差、二叉搜索树中的众数、二叉树的最近公共祖先(h)

        今天的三道题用一次遍历就解决的方法都有难度。重点是二叉搜索树的查找,二叉搜索树一定能转化为有序数组,要将查找到的目标正确转化为二叉搜索树的查找逻辑。这其中有查找多个最多值/最大值的技巧。第3题(236. 二叉树的最近公共祖先)一次遍历的方法实现虽容易,但背后的原理和思路很难,需要反复理解。


        第1题(530.二叉搜索树的最小绝对差)递归法自己的起初的思路是绝对值最小值一定存在于中间节点与左节点的差,和中间节点与右节点的差,这两者之间。但实现后正确率只有一半多,才意识到不是这样的,而是应该存在于中间节点与左子树的最右节点之差,和中间节点与右子树的最左节点之差,这两者之间,于是实现如下:

class Solution {
public:
    int absMin;
    void traversal(TreeNode *cur) {
        if (cur->left == nullptr && cur->right == nullptr) {
            return;
        }
        if (cur->left) {
            TreeNode *leftRight = cur->left;
            while (leftRight->right != nullptr) {
                leftRight = leftRight->right;
            }
            absMin = min(absMin, abs(cur->val - leftRight->val));
            traversal(cur->left);
        }
        if (cur->right) {
            TreeNode *rightLeft = cur->right;
            while (rightLeft->left != nullptr) {
                rightLeft = rightLeft->left;
            }
            absMin = min(absMin, abs(cur->val - rightLeft->val));
            traversal(cur->right);
        }
        return;
    }
    int getMinimumDifference(TreeNode* root) {
        absMin = INT_MAX;
        traversal(root);
        return absMin;
    }
};

虽然AC,但这样的方法太过麻烦,效率不高,这也是因为我再次忘记二叉搜索树的中序遍历是有序的,在二叉搜索树中的各种查找最值问题都可转化为在有序数组中查找。于是,同98.验证二叉搜索树一样,可以在中序遍历时生成有序数组,再遍历数组找答案,也可以像下面代码一样在中序遍历途中就进行比较:

class Solution {
public:
    int absMin;
    TreeNode *pre;
    void traversal(TreeNode *cur) {
        if (cur == nullptr) {
            return;
        }
        traversal(cur->left);
        if (pre != nullptr && cur->val - pre->val < absMin) {
            absMin = cur->val - pre->val;
        }
        pre = cur;
        traversal(cur->right);
        return;
    }
    int getMinimumDifference(TreeNode* root) {
        absMin = INT_MAX;
        pre = nullptr;
        traversal(root);
        return absMin;
    }
};

代码中需要pre节点来保存上一个节点,因为中序遍历只有在中间部分(左节点递归后,右节点递归前)才是中间节点的处理部分,也是每个节点的处理部分,所以在这里更新pre。关于pre还需要注意2点:

  • pre要设置为全局变量或引用类型的递归函数参数,不能设置为值传递类型的递归函数参数。因为如果设置为值传递的参数的话,pre每次的更新仅在当前层的递归函数内有效,return之后在上一层递归函数中pre并未更新;
  • 递归出口要设置为当前节点为空。如果设置为当前节点为叶子节点,那么需要在出口处也对当前叶子节点进行处理,即进行差值的比较,并更新pre。

另外从代码中也学到,因为中序遍历的二叉搜索树是有序的,cur->val总是大于pre->val的,所以在计算绝对值时不需要abs(),直接用cur->val减去pre->val就可以。

        这里也因上面的第2点发现一个严重问题。二叉树遍历的递归解法在题解中是以“当前节点为空”作为递归出口,但我自己经常将“当前节点为叶子节点”作为递归出口,这样做会使遍历掠过所有叶子节点,如果一定要这样写,就要在递归出口处也加上对当前叶子节点的处理。以中序遍历为例:

class Solution {
public:
    void traversal(TreeNode *cur, vector<int>& res) {
        if (cur->left == nullptr && cur->right == nullptr) {
            res.push_back(cur->val);
            return;
        }
        if (cur->left) {
            traversal(cur->left, res);
        }
        res.push_back(cur->val);
        if (cur->right) {
            traversal(cur->right, res);
        }
    }
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> res;
        if (root == nullptr) {
            return res;
        }
        traversal(root, res);
        return res;
    }
};

        迭代法也与98.验证二叉搜索树一样,利用中序遍历中pre和cur的比较来得到最后结果。

class Solution {
public:
    int getMinimumDifference(TreeNode* root) {
        int absMin = INT_MAX;
        stack<TreeNode*> st;
        TreeNode *cur = root, *pre = nullptr;
        while (cur != nullptr || !st.empty()) {
            if (cur != nullptr) {
                st.push(cur);
                cur = cur->left;
            }
            else {
                cur = st.top();
                st.pop();
                if (pre != nullptr && cur->val - pre->val < absMin) {
                    absMin = cur->val - pre->val;
                }
                pre = cur;
                cur = cur->right;
            }
        }
        return absMin;
    }
};

        二刷:忘记设置pre为nullptr的方法。


        第2题(501.二叉搜索树中的.众数)虽然是二叉搜索树问题,但可扩展为二叉树问题。面对普通的二叉树,解法分为以下几个步骤:

  1. 建立map,用于保存每个数字和对应的出现次数;
  2. 遍历二叉树,遍历时用map统计出现次数;
  3. 将map转为数据类型为pair<int, int>的vector;
  4. 对vector按照数字出现次数由大到小排序;
  5. 排序后vector中第0个元素的次数即为众数,基于此遍历vector,将次数与众数相等的数字都存入结果并返回。

第2步依然可以使用递归或迭代,具体方法与上一题(530.二叉搜索树的最小绝对差)一致。采用递归方法遍历的题解:

class Solution {
public:
    void traversal(TreeNode *cur, unordered_map<int, int>& map) {
        if (cur == nullptr) {
            return;
        }
        traversal(cur->left, map);
        map[cur->val]++;
        traversal(cur->right, map);
        return;
    }
    static bool cmp(pair<int, int> a, pair<int, int> b) {
        return a.second > b.second;
    }
    vector<int> findMode(TreeNode* root) {
        unordered_map<int, int> map;
        traversal(root, map);
        vector<pair<int, int>> times(map.begin(), map.end());
        sort(times.begin(), times.end(), cmp);
        vector<int> res;
        res.push_back(times[0].first);
        int timesMax = times[0].second;
        for (int i = 1; i < times.size(); ++i) {
            if (times[i].second == timesMax) {
                res.push_back(times[i].first);
            }
            else {
                break;
            }
        }
        return res;
    }
};

        而如果是二叉搜索树,因为中序遍历过程中val是有序的,所以通过一次遍历就可以得到最大出现次数cntMax。具体方法是跟前几道题一样使用记录前一个节点的pre,通过比较cur与pre的val是否相等而决定将临时计数器cnt加1还是重新置为1,然后再将其与cntMax比较并更新cntMax,就能得到最终的cntMax了。然而,还需要记录所有众数,似乎还需要一次遍历,但实际上总共只需要一次,在计算cntMax的遍历中就可以记录所有众数。方法是在每次cntMax更新时(更新意味着更“众”的数出现了),清空保存结果的vector,重新添加当前val进vector。然后当cnt与cntMax相等时(意味着当前元素的次数也达到了最大次数),也将当前val添加进vector。

class Solution {
public:
    TreeNode *pre;
    int cntMax, cnt;
    vector<int> res;
    void traversal(TreeNode *cur) {
        if (cur == nullptr) {
            return;
        }
        traversal(cur->left);
        if (pre == nullptr || cur->val != pre->val) {
            cnt = 1;
        }
        else {
            cnt++;
        }
        if (cnt > cntMax) {
            res.clear();
            res.push_back(cur->val);
            cntMax = cnt;
        }
        else if (cnt == cntMax) {
            res.push_back(cur->val);
        }
        pre = cur;
        traversal(cur->right);
        return;
    }
    vector<int> findMode(TreeNode* root) {
        pre = nullptr;
        cntMax = 0;
        cnt = 0;
        res.clear();
        traversal(root);
        return res;
    }
};

        迭代法只需要把中序遍历从递归换为迭代,处理当前节点的逻辑与递归法一致。

class Solution {
public:
    vector<int> findMode(TreeNode* root) {
        int cntMax = 0, cnt = 0;
        vector<int> res;
        stack<TreeNode*> st;
        TreeNode *pre = nullptr, *cur = root;
        while (cur != nullptr || !st.empty()) {
            if (cur != nullptr) {
                st.push(cur);
                cur = cur->left;
            }
            else {
                cur = st.top();
                st.pop();
                if (pre == nullptr || cur->val != pre->val) {
                    cnt = 1;
                }
                else {
                    cnt++;
                }
                if (cnt > cntMax) {
                    res.clear();
                    res.push_back(cur->val);
                    cntMax = cnt;
                }
                else if (cnt == cntMax) {
                    res.push_back(cur->val);
                }
                pre = cur;
                cur = cur->right;
            }
        }
        return res;
    }
};

        二刷:忘记map的方法,以及对应与pair<>的转换( vector<pair<int, int>> v(map.begin(), map.end()) )、和pair<>的排序方法(cmp函数前要加static)。


        第3题(236. 二叉树的最近公共祖先)递归法自己AC的方法是判断root是否与p或q之一相等,相等则返回root;之后开始通过root的左、右子树分别查找p、q,如果p、q都在左/右子树中,则递归从左/右子树中寻找最近公共祖先,否则说明最近公共祖先就是root,返回root。查找也用递归实现。

class Solution {
public:
    bool find(TreeNode* root, TreeNode* x) {
        if (root == x) {
            return true;
        }
        if (root == nullptr) {
            return false;
        }
        return find(root->left, x) || find(root->right, x);
    }
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        if (root == nullptr) {
            return nullptr;
        }
        if (root == p || root == q) {
            return root;
        }
        if (find(root->left, p) && find(root->left, q)) {
            return lowestCommonAncestor(root->left, p, q);
        }
        if (find(root->right, p) && find(root->right, q)) {
            return lowestCommonAncestor(root->right, p, q);
        }
        return root;
    }
};

        然而这种方法因为每次查找都要从当前节点遍历当前子树,效率不高。题解的思路如下:

  1. 已知两节点,求最小公共祖先直觉上最高效的方法是自下向上查找,而后序遍历就是这样一种向上回溯的过程;
  2. 利用后序遍历查找p和q,一旦查找到,就返回p或q。如果没找到就返回nullptr,以此来区分找到与否;
  3. 后序遍历时用2个变量接收左、右子树的查找结果。(1)如果都成功找到了,说明当前的中间节点是最小公共祖先。这是因为采用了后序遍历,是由下至上回溯的,所以首次找到的一定就是最小公共祖先,而非更大的公共祖先;(2)如果在一边找到了,在另一边没找到,说明找到一边p和q都在找到的一边,而这一边的返回值就是最小公共祖先。(3)如果都没找到,说明p和q都不在当前子树,返回nullptr。
  4. 2中还有一种情况,当找到p(q)时,q(p)可能是p(q)的子孙节点(虽然是后序遍历,但因为2这一步放在函数开头,所以这一步仍相当于前序遍历,所以p和q如果是子孙/祖先关系,那么两者当中首先被查找到的一定是祖先节点。这种情况下被首先查找到的祖先节点也就是两者的最小公共祖先,所以2中返回这一节点是正确的,3的(2)中,查找成功的一边的返回值是最小公共祖先也就是正确的。

另外,这一题就是day 18中关于递归返回值总结部分,需要返回值的第2种情况。虽然目标是找到p和q,但由于是回溯,左、右子节点的结果都需返回接收,需要根据返回值再做处理,所以仍需遍历整个树。

class Solution {
public:
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        if (root == nullptr) {
            return nullptr;
        }
        if (root == p || root == q) {
            return root;
        }
        TreeNode *left = lowestCommonAncestor(root->left, p, q);
        TreeNode *right = lowestCommonAncestor(root->right, p, q);
        if (left == nullptr && right == nullptr) {
            return nullptr;
        }
        if (left != nullptr && right == nullptr) {
            return left;
        }
        if (left == nullptr && right != nullptr) {
            return right;
        }
        return root;
    }
};

从题解中也学习到二叉树搜索某一条边和搜索整个树的不同。搜索某一条边的写法通常是:

if (递归函数(root->left)) return xxx;
if (递归函数(root->right)) return xxx;

不需要对两者返回值做进一步处理。而搜索整棵树的写法一般是:

left = 递归函数(root->left);
right = 递归函数(root->right);
根据left与right的值进一步处理;

隐含了回溯的过程。

        题解中提到迭代法不适合模拟回溯的过程,所以这道题就不用迭代法了(倒是也不会)。

        二刷:忘记题解解法。题解解法从另一个角度看,是后序遍历找p,q的过程,因为后序遍历是自底向上,所以当找到其中某一个时,返回值就是找到的那个目标节点。直到向上返回过程中左右都找到时,返回当前节点。之后的向上返回过程中,该节点又继续作为返回值被一直返回到主函数。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值