数据结构与算法(九) 树

news2024/11/25 13:00:17

大家好,我是半虹,这篇文章来讲树


树是一种常见的数据结构,其定义为:由有限个节点组成的具有层次关系的集合

解决树问题的关键是递归,递归的关键是分解子问题

对于树来说,递归函数只要考虑对单个节点如何处理,然后通过递归调用拓展到整棵树即可

下面具体介绍一些常见的树结构及相关算法,以深入地理解如何应用递归解决树相关的问题


1  二叉树

二叉树是特殊的树结构,每个节点最多有两个子节点,分别称为左孩子和右孩子

二叉树很多的相关问题,基本上都可以用递归来解决,下面具体来看一下怎么用


1.1 初识递归

对于一个数据结构来说,最基本的操作就是遍历元素,二叉树的遍历同样是基于递归的

而其中最主要的问题就是怎么设计递归函数,为此可将二叉树抽象成下面的模式

模式

为什么是这样的模式呢,这其实就是二叉树的子结构,在递归中可理解成子问题

递归函数要做的事情就是解决好这个子问题,然后再通过递归调用当左右孩子是新的父节点拓展到整棵树


这个是解决二叉树问题的一种常用思考模式,是从上往下的理解,而写递归逻辑时也可以从下往上去理解

设递归函数能分别处理以左右孩子为根节点的树对应的题目要求,也即可以通过递归处理左子树和右子树

则能否处理以父节点为根节点的树所对应的题目要求,如果是能,具体如何操作,写出来就是递归的逻辑

这样表达还是有些抽象,下面就以遍历为例具体讲讲


现题目要求遍历二叉树,我们假设能通过递归调用分别遍历以左右孩子为根节点的树

那么要遍历以父节点为根节点的树很简单,只需访问父节点,然后通过递归调用遍历左子树和右子树即可

将这些操作都写出来就是递归函数的逻辑,对应的代码如下:

// 递归函数:只需考虑如何去处理子结构
// 功能定义:遍历以父节点为根节点的树
void traverse(TreeNode* root) {
    // 边界情况
    // 这里对应的是当父节点是空指针的情况
    // 此时无需进行任何操作,直接返回就行
    if (root == null) {
        return;
    }
    // 基本情况
    // 这里对应的是当父节点是树节点的情况
    cout  << root -> val << endl; // 访问父节点
    traverse(root -> left);       // 遍历左子树(递归调用)
    traverse(root -> right);      // 遍历右子树(递归调用)
}

看递归函数千万不要跟着深入,只需要定义好递归函数的功能,知道调用函数能完成什么事就可以

上述的函数所做的事情很简单:打印传入节点,然后将该节点的左孩子和右孩子作为参数递归调用

递归就是这样的,把完整过程想清楚很难,把子问题处理好很简单,关键是怎么定义和处理子问题


在上述代码中,对访问父节点的位置也有讲究,不同位置对应着二叉树的三种遍历方式,具体如下:

  • 前序遍历:先访问父节点,再访问左孩子,后访问右孩子(前序指父节点首先访问)
  • 中序遍历:先访问左孩子,再访问父节点,后访问右孩子(中序指父节点中间访问)
  • 后序遍历:先访问左孩子,再访问右孩子,后访问父节点(后序指父节点最后访问)

对应例子图示如下:

遍历

对应代码框架如下:

void traverse(TreeNode* root) {
    if (root == null) {
        return;
    }
    // 在这里访问父节点,就是前序遍历
    traverse(root -> left);     // 遍历左子树(递归调用)
    // 在这里访问父节点,就是中序遍历
    traverse(root -> right);    // 遍历右子树(递归调用)
    // 在这里访问父节点,就是后序遍历
}

大家不要小看遍历,这是解决二叉树问题的一种基础模式,一个典型的例题就是序列化与反序列化

所谓序列化就是将二叉树变成序列,其实就是将二叉树的遍历结果存下来就可以

而反序列化则是将序列变回二叉树,这个就是根据序列用递归的方式还原二叉树

用三种遍历方式来做序列化都可以,下面我们就用前序遍历举例给出代码,leetcode297

class Codec {
public:
    // 序列化
    // 递归函数:序列化以传入节点为根节点的子树
    string serialize(TreeNode* root) {
        // 边界情况
        // 对应的是父节点是空指针的情况
        // 此时要将父节点转字符串,但是不用处理左右子树
        if (!root) {
            return "#";
        }
        // 基本情况
        // 对应的是父节点是树节点的情况
        // 此时要将父节点转字符串,同时通过递归调用来将左右子树序列化
        return to_string(root -> val) + "," + 
               serialize(root -> left) + "," +
               serialize(root -> right);
    }

    // 反序列化
    TreeNode* deserialize(string data) {
        // 先转列表
        string temp;
        list<string> dataList;
        for (char& ch: data) {
            if (ch == ',') {
                dataList.push_back(temp);
                temp.clear();
            } else {
                temp += ch;
            }
        }
        if (!temp.empty()) {
            dataList.push_back(temp);
            temp.clear();
        }
        // 进入递归
        return my_deserialize(dataList);
    }

    // 递归函数:构造以传入列表第一个元素为根节点的子树
    // 这里注意,参数列表是动态变化的
    TreeNode*  my_deserialize(list<string>& dataList) {
        // 边界情况
        // 若当元素为空指针
        // 则直接返回空指针,且不用处理左右子树
        if (dataList.front() == "#") {
            dataList.erase(dataList.begin());
            return nullptr;
        }
        // 基本情况
        // 若当元素为树节点
        // 则将其作为父节点,并通过递归调用设置左右子树
        TreeNode* root = new TreeNode(stoi(dataList.front()));
        dataList.erase(dataList.begin());
        root -> left = my_deserialize(dataList);
        root -> right = my_deserialize(dataList);
        return root;
    }
};

序列化时需要把空指针也记录下来,这样后面才能去还原,没有空指针标记是无法分辨树结构的

没有空指针标记,但已知中序遍历和前序遍历,或已知中序遍历和后序遍历,也可以还原二叉树

但是这里要注意,若已知前序遍历和后序遍历,并不能唯一确定一棵二叉树

下面假设已知中序遍历和前序遍历,给出还原二叉树的代码如下,leetcode105

// 事实
// 前序遍历结果:[父节点, [左子树前序遍历结果], [右子树前序遍历结果]]
// 中序遍历结果:[[左子树中序遍历结果], 父节点, [右子树中序遍历结果]]
// 
// 思路
// 1. 从前序遍历结果可知父节点,很显然第一个值就是
// 2. 从中序遍历结果搜索父节点,父节点左边的值就是左子树,父节点右边的值就是右子树,然后再统计左右子树元素数量
// 3. 然后根据左右子树元素数量,从前序遍历结果分出左子树和右子树
// 4. 最后再对左右子树执行上述的步骤(递归)

class Solution {
public:
    unordered_map<int, int> val2idx;

    TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
        // 构造哈希映射
        // 可加速在中序遍历列表找到根节点
        for (int i = 0; i < inorder.size(); i++) {
            val2idx[inorder[i]] = i;
        }
        // 进入递归
        return myBuildTree(
            preorder,
            inorder,
            0,
            preorder.size() - 1,
            0,
            inorder.size() - 1
        );
    }

    // 递归函数
    // 构造子树
    TreeNode* myBuildTree(
        vector<int>& preorder,
        vector<int>& inorder,
        // 不修改原序列
        // 通过下标表示要处理的子序列
        int preorder_left,
        int preorder_right,
        int inorder_left,
        int inorder_right
    ) {
        // 边界情况
        if (preorder_left > preorder_right) {
            return nullptr;
        }

        // 基本情况
        int root_value = preorder[preorder_left];
        TreeNode* root = new TreeNode(root_value); // 父节点

        int inorder_root = val2idx[root_value];
        int left_subtree = inorder_root - inorder_left;

        root -> left = myBuildTree( // 为父节点构造左子树
            preorder,
            inorder,
            preorder_left + 1,
            preorder_left + left_subtree,
            inorder_left,
            inorder_root - 1
        );
        root -> right = myBuildTree( // 为父节点构造右子树
            preorder,
            inorder,
            preorder_left + 1 + left_subtree,
            preorder_right,
            inorder_root + 1,
            inorder_right
        );

        return root;
    }
};

1.2 深入递归

上面我们主要讲了三种遍历方式和怎么根据遍历结果还原二叉树,解决这些问题都要用到递归的思想

下面我们再来看些更复杂的递归,并探讨如何在递归中传递数据,概括地说会有两种传递数据的方式

一是通过递归函数的返回值传递,二是通过递归函数的参数或者是外部变量辅助传递

但无论是哪种方式,解递归问题的关键依然是定义和处理子问题,下面通过例题介绍


(1)二叉树的最大深度 | leetcode104

给定二叉树,计算其最大深度,二叉树的深度定义为根节点到最远叶子节点的路径上的节点数

从下往上的角度来思考问题,设递归函数可以分别求出以左右孩子为根节点的树的最大深度

则求出以父节点为根节点的树的最大深度也很简单,只要用左右子树最大深度中的较大值加上自己就可以

在这里,最大深度的信息是从下往上传的,具体来说是通过递归函数的返回值来传的

所以要先计算子树的最大深度后才能计算原树的最大深度,也就是说对原树的计算要放在后序位置去执行

class Solution {
public:
    int maxDepth(TreeNode* root) {
        // 边界情况
        if (!root) {
            return 0;
        }
        // 基本情况
        int left = maxDepth(root -> left); // 左子树最大深度
        int right = maxDepth(root -> right); // 右子树最大深度
        int ans = max(left, right) + 1; // 后序位置
        return ans;
    }
};

从上往下的角度来思考问题,先定义一个递归函数,接收一个树节点作为参数

其功能定义为计算传入树节点的深度,然后递归调用该函数计算左孩子和右孩子的深度

而左右孩子的深度其实就等于父节点的深度加一

这样当我们初始传入根节点时,该函数就能得到所有树节点的深度,然后取最大值就是树的最大深度

这里,最大深度的信息是从上往下传的,具体来说是通过递归函数的参数来传的

所以要先有父节点的深度才能计算左右孩子的深度,也就是说对父节点的计算要在前序位置执行

另外,在从上往下传当前深度信息时,同时用外部变量保存最大深度信息

class Solution {
public:
    int ansDepth = 0;

    int maxDepth(TreeNode* root) {
        traverse(root, 1);
        return ansDepth;
    }

    void traverse(TreeNode* root, int curDepth) {
        // 边界情况
        if (!root) {
            return;
        }
        // 基础情况
        // 由于在传入参数时就同时传入了节点的深度
        // 其实也就是在前序位置时就处理好了父节点
        ansDepth = max(ansDepth , curDepth);
        traverse(root -> left, curDepth + 1); // 遍历左子树
        traverse(root -> right, curDepth + 1); // 遍历右子树
    }
};

(2)二叉树的最大路径和 | leetcode124

给定二叉树,计算其最大路径和,路径和也就是路径中各个节点值的总和

路径定义为从树中任意节点出发达到任意节点的序列,要求同一个节点在一条路径中至多出现一次

例如给定二叉树如下:

     -10
     /  \
    9   20
       /  \
      15   7

那么最大路径和就是:42,对应的路径就是:15 -> 20 -> 7

上面对路径的定义很难用于解题,我们首先试一下能不能转化为二叉树的基本模式,也就是父左右

每一条路径可看作是有一个顶点,也即父节点,其既可与左路径或者与右路径连接,也可单独存在

第一种情况:只有顶点

               n1 (顶点)

第二种情况:顶点只与左路径连接

               n1 (顶点)
              /
             n2
             |
            ...

第三种情况:顶点只与右路径连接

               n1 (顶点)
                 \
                 n3
                  |
                 ...

第四种情况:顶点既与左路径连接、又与右路径连接

               n1 (顶点)
              /  \
             n2  n3
             |    |
            ...  ...

按照先前的经验,用从下往上的角度思考问题,设递归函数能分别求出以左右孩子为顶点的最大路径和

那么能否求出以父节点为顶点的最大路径和呢?好像不太行,主要问题出现在第四种情况

若左右孩子所已知的最大路径和所对应的路径是第四种情况
那么父节点无论是连接左孩子还是右孩子都是不合法的路径

例如左孩子所已知的最大路径和所对应的路径如下所示:n4 -> n2 -> n5

               n2
              /  \
             n4  n5

那么父节点连接左孩子的路径是不合法的,因为没办法用不重复的序列穿起四个节点

                 n1
                /
               n2
              /  \
             n4  n5

那要怎么处理呢?别急,我们先来看一个简化版题目,那就是二叉树的单边最大路径和

在这里,路径定义简化为从父节点到子节点的单路径,也就是先忽略上面的第四种情况

处理这种情况就很简单,先设递归函数能分别求出以左右孩子为顶点的单边最大路径和

则求出以父节点为顶点的单边最大路径和只要考虑以下情况的最大值:父、父加左路径、父加右路径

class Solution {
public:
    // 单边最大路径和
    int maxSideSum(TreeNode* root) {
        // 边界情况
        if (!root) {
            return 0;
        }
        // 基本情况
        int left = maxSideSum(root -> left); // 以左孩子为顶点的单边最大路径和
        int right = maxSideSum(root -> right); // 以右孩子为顶点的单边最大路径和
        int val = root -> val;
        int cur = max({val, val + left, val + right});
        return cur;
    }
};

现在我们顺着这个思路再深入一下

假设我们知道以左右孩子为顶点的单边最大路径和,能不能求出以父节点为顶点的最大路径和呢

好像也是可以的,只要考虑以下情况的最大值:父、父加左路径、父加右路径、父加左路径加右路径

为此我们可以基于上面的递归框架,多加一步来计算最大路径和就可以

class Solution {
public:
    // 答案
    int ans = INT_MIN;

    // 最大路径和
    int maxPathSum(TreeNode* root) {
        maxSideSum(root);
        return ans;
    }

    // 单边最大路径和
    int maxSideSum(TreeNode* root) {
        if (!root) {
            return 0;
        }
        int left = maxSideSum(root -> left);
        int right = maxSideSum(root -> right);
        int val = root -> val;
        int cur = max({val, val + left, val + right});
        ans = max({ans, cur, val + left + right}); // 新增
        return cur;
    }
};

这里需要注意,计算最大路径和这一步是附加在单边最大路径和的递归逻辑上的,并不影响原有的递归逻辑

只是刚好在解决单边最大路径和时,可以顺便计算出最大路径和,这给我们一个解题的启发

那就是在原有的设定下无法递归时,可以转为递归简化版的问题,然后从中计算原问题的解


(3)二叉树的最近公共祖先 | leetcode236

给定二叉树和两个树节点,找出这两个节点的最近公共祖先

最近公共祖先要求为同时是两个节点的祖先且深度尽可能大

例如给定二叉树如下:

         3
       /   \
     5       1
    / \     / \
   6   2   0   8
      / \
     7   4

节点 5 和节点 1 的最近公共祖先是节点 3
节点 5 和节点 4 的最近公共祖先是节点 5

解决这个问题有一个很直观的方法,那就是遍历一次二叉树,记录每个节点的深度和父节点

然后对于给定的两个树节点,每次将深度较大的节点指向其父节点,直至二者汇合就是最近公共祖先

class Solution {
public:
    unordered_map<TreeNode*, int> depth;
    unordered_map<TreeNode*, TreeNode*> parent;

    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        depth[root] = 1;
        parent[root] = nullptr;
        traverse(root, 1);

        while  (true) {
            if (p == q) {
                return p;
            }
            if (depth[p] == depth[q]) {
                p = parent[p];
                q = parent[q];
            }
            else if (depth[p] > depth[q]) {
                p = parent[p];
            }
            else if (depth[p] < depth[q]) {
                q = parent[q];
            }
        }
    }

    void traverse(TreeNode* root, int curDepth) {
        if (root -> left != nullptr) {
            depth[root -> left] = curDepth + 1;
            parent[root -> left] = root;
            traverse(root -> left, curDepth + 1);
        }
        if (root -> right != nullptr) {
            depth[root -> right] = curDepth + 1;
            parent[root -> right] = root;
            traverse(root -> right, curDepth + 1);
        }
    }
};

然而这个问题就不能用递归的方法来做吗?当然可以,虽然咋一看好像很难去定义递归函数

我们不妨思考下究竟什么是最近公共祖先,一个节点是最近公共祖先必须满足以下条件之一

  1. 该节点的左右子树分别包含给定的两个节点
  2. 该节点的左右子树包含给定的两个节点之一,且该节点本身就是另一个给定的节点

为此我们可以定义递归函数功能为判断以父节点为根节点的树是否包含两个给定的节点中的至少一个

验证一下这个定义是否可求,设递归函数能判断以左右孩子为根节点的树是否包含给定节点

那么判断以父节点为根节点的树是否包含给定节点非常简单

当父节点本身就是给定节点、或其左、右子树包含给定节点时为真,其余情况为假

class Solution {
public:
    // 判断以 root 为根节点的树是否包含 p 或 q 节点
    bool haveNode(TreeNode* root, TreeNode* p, TreeNode* q) {
        // 边界情况
        if (!root) {
            return false;
        }
        // 基础情况
        bool left = haveNode(root -> left, p, q); // 左子树是否包含给定节点
        bool right = haveNode(root -> right, p, q); // 右子树是否包含给定节点
        return left || right || root -> val == p -> val || root -> val == q -> val;
    }
};

那么能否在此基础上求出最近公共祖先呢?假设已知左右子树是否包含给定节点

在判断父节点是否为最近公共祖先时,只需按照最近公共祖先的两条件判断即可

class Solution {
public:
    TreeNode* ans;

    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        haveNode(root, p, q);
        return ans;
    }

    // 判断以 root 为根节点的树是否包含 p 或 q 节点
    bool haveNode(TreeNode* root, TreeNode* p, TreeNode* q) {
        if (!root) {
            return false;
        }
        bool left = haveNode(root -> left, p, q);
        bool right = haveNode(root -> right, p, q);
        if ( // 新增
            (left && right) || // 满足条件 1
            ((root -> val == p -> val || root -> val == q -> val) && (left || right)) // 满足条件 2
        ) {
            ans = root;
        }
        return left || right || root -> val == p -> val || root -> val == q -> val;
    }
};

这就跟上一道题目是一样的,都是因为按原设定无法递归,所以选择递归其变种,并从中求出原问题的答案

又或者我们从另一个角度来看一下上述的解法,递归函数在后序位置返回值实际上就是从下往上传递的数据

这整个传递过程从叶子节点开始到根节点结束,而根节点的返回值就是整个递归函数的最终返回值

以上述递归函数来举例,该函数返回的是布尔,其表示以当前节点为根节点的树是否包含给定节点

而在此基础上,该函数在传递值到每个节点时,会根据当前的信息判断该节点是否为最近公共祖先

判断条件设置很巧妙,所有节点有且仅有一个符合要求,然后通过外部变量保存该节点,就是答案

例如给定二叉树如下,其中的节点 6 和 7 是给定节点,现在要求找出这两个节点的最近公共祖先

             3(T)             /|\
           /      \            |
      {5}(T)       1(F)        |
      /    \      /   \        |
   [6](T)  2(T) 0(F)  8(F)     |
          /   \                |
      [7](T)  4(F)             |

这其中 :
小括号 () 表示递归函数传递的数据,从下往上传递,汇合到根节点
中括号 [] 表示两个给定节点
大括号 {} 表示最近公共祖先

按照这个思路,其实我们可以直接设计出一个递归函数,将树节点作为返回值,使其最终返回最近公共祖先

返回规则如下:

  1. 当父节点为空的指针时,返回空的指针(边界情况)
  2. 当父节点为给定节点时,返回给定节点
  3. 当父节点为其余节点时,如果其左子树和右子树都包含给定节点,返回父节点,就是最近公共祖先
  4. 当父节点为其余节点时,如果其左子树或右子树有包含给定节点,返回该给定节点
  5. 当父节点为其余节点时,如果其左子树和右子树没包含给定节点,返回空指针

直观图示如下:

例如给定二叉树如下,其中的节点 6 和 7 是给定节点,现在要求找出这两个节点的最近公共祖先

             3(5)             /|\
           /      \            |
      {5}(5)       1(n)        |
      /    \      /   \        |
   [6](6)  2(7) 0(n)  8(n)     |
          /   \                |
      [7](7)  4(n)             |

这其中 :
小括号 () 表示函数的返回值,也就是传递的数据,数字 表示树节点指针,n 表示空指针
中括号 [] 表示两个给定节点
大括号 {} 表示最近公共祖先

符合返回规则 1 的节点有:/
符合返回规则 2 的节点有:6、7
符合返回规则 3 的节点有:5
符合返回规则 4 的节点有:2、3
符合返回规则 5 的节点有:4、0、8、1

对应代码如下:

class Solution {
public:
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        // 返回规则 1
        if (root == nullptr) {
            return  nullptr;
        }
        // 返回规则 2
        if (root == p) {
            return  p;
        }
        if (root == q) {
            return  q;
        }
        // 左右子树 返回值
        TreeNode* left = lowestCommonAncestor(root -> left, p, q);
        TreeNode* right = lowestCommonAncestor(root -> right, p, q);
        // 返回规则 3
        if (left && right) {
            return root;
        }
        // 返回规则 4
        else if (left || !right) {
            return left;
        }
        else if (!left || right) {
            return right;
        }
        // 返回规则 5
        else {
            return nullptr;
        }
    }
};

1.3 小结

与二叉树相关的问题,基本上都可以用递归解决,而递归的关键就是分解子问题

对二叉树而言,存在着一种很典型的子问题模式,也就是上面提到的父左右模式

以后遇到二叉树问题,首先可以往这种模式靠拢


假如确定二叉树问题能符合上述的模式,则可以参考下面提供的思路来解决问题

首先要将父左右模式放在最普遍的场景下去思考

普遍

然后再定义递归函数,思考怎么去处理以当前父节点为根节点的树,使其能满足题目要求

若当前父节点要获取其父节点的数据,则通过递归函数的参数传递下来

且当前父节点在调用递归函数处理其子节点时需要传入与接收参数定义一致的数据

若当前父节点要获取其子节点的数据,则通过递归函数返回值传递上来

且当前父节点的处理逻辑需要在函数的最后去返回与所接收的内容定义一致的数据


至此,已定义好递归函数的所有内容,最后再提醒大家注意下三个细节

  1. 在已定义好递归函数后,调用递归函数时需要相信就是能做到你所定义的事情,别跟递归调用层层深入
  2. 在写父节点处理逻辑时,分类讨论节点的不同情况,例如当节点是空指针或是树指针时的处理是不同的
  3. 在对父节点传递数据时,定义递归函数的参数和递归函数返回值所代表的含义,这样会使逻辑更加清晰

下面是用伪代码所写的框架,后面会有好几道例题,帮助大家进行回顾

BottomUpData function(root, TopDownData) {
    if (root == nullptr) {
        // ...
    }
    else if (root == TreeNode) {
        // ...
        TopDownDataToLeft = process1(root, TopDownData);
        TopDownDataToRight = process1(root, TopDownData);
        // ...
        BottomUpDataFromLeft = function(root -> left, TopDownDataToLeft);
        BottomUpDataFromRight = function(root -> right, TopDownDataToRight);
        // ...
        return process2(root, BottomUpDataFromLeft, BottomUpDataFromRight);
    }
    // else if (...) {
    //     ...
    // }
}

注意,上述所说的所有内容,都是针对父左右的递归模式来说的

但是,并非所有二叉树问题,都会符合这种模式,而此时可试着分析二叉树中的其他子问题

另外,更非所有二叉树问题,都能使用递归去做,若发现不能用递归解决,则要另外找方法

总之,解决问题应是灵活的,千万不要被框架困住了思维


好了,该说的都已经说完了,下面来几道题目检查下学习成果吧

(1)二叉树展开为链表 | leetcode114

给定二叉树将其展开为链表,要求展开后的链表与二叉树的前序遍历一致

链表的元素是树节点,节点的左孩子指向空指针,右孩子指向下一个元素


解题思路如下:

假设给定二叉树如下:

         6
       /   \
     2       8
    / \     / \
   1   4   7   9
      / \
     3   5

前序遍历顺序为:[6, [左子树前序遍历结果], [右子树前序遍历结果]]

显然,
左子树前序遍历的倒数第一个元素是:5(左子树最下最右的节点)
右子树前序遍历的正数第一个元素是:8(右子树的根节点)

所以,
对于每个根节点我们可以做如下处理:

1. 将右子树前序遍历正数第一个的元素接到左子树前序遍历倒数第一个的元素的右孩子

         6
       /
     2
    / \
   1   4
      / \
     3   5
          \
           8
          / \
         7   9

2. 将左子树接到父节点的右孩子

         6
           \
            2
           / \
          1   4
             / \
            3   5
                 \
                  8
                 / \
                7   9

3. 至此,已完成父节点处理逻辑,之后再递归调用处理右孩子即可
   此时,树的前序遍历结果不变

对应代码如下:

class Solution {
public:
    void flatten(TreeNode* root) {
        if (root == nullptr) {
            // 如果根节点为空,则直接返回
            return;
        }
        else if (root -> left == nullptr && root -> right == nullptr) {
            // 如果左孩子为空且右孩子为空,则直接返回
            return;
        }
        else if (root -> left == nullptr && root -> right != nullptr) {
            // 如果左孩子为空但右孩子不空,则递归调用处理右孩子
            flatten(root -> right);
        }
        else if (root -> left != nullptr && root -> right == nullptr) {
            // 如果左孩子不空但右孩子为空,则将左孩子移到右孩子,再递归调用处理右孩子
            root -> right = root -> left;
            root -> left = nullptr;
            flatten(root -> right);
        }
        else if (root -> left != nullptr && root -> right != nullptr) {
            // 如果左孩子不空且右孩子不空,则将左子树接到右子树,再将左孩子移到右孩子,再递归调用处理右孩子
            TreeNode* last_in_left = root -> left;
            while (last_in_left -> right != nullptr) {
                last_in_left = last_in_left -> right;
            }
            last_in_left -> right = root -> right;
            root -> right = root -> left;
            root -> left = nullptr;
            flatten(root -> right);
        }
    }
};


(2)不同的二叉搜索树 | leetcode96

给定整数 n,求恰由 n 个节点组成且节点值从 1n 的不同二叉搜索树数量


二叉搜索树有一个性质,每个节点的值大于等于其左子树所有节点的值且小于等于其右子树所有节点的值

如果将数组的某个元素作为父节点,那么只能用该元素左边值构造左子树,并用该元素右边值构造右子树

而对于左右子树,也可以用同样的方法去处理,这就是子问题,看个例子:

假设给定整数 9 ,那么对应二叉树节点为 [1, 2, 3, 4, 5, 6, 7, 8, 9]

然后我们可以选择任意一个节点作为根节点 构造二叉搜索树

例如选择元素 7 作为根节点,那么只能用 [1, 2, 3, 4, 5, 6] 作为左子树,用 [8, 9] 作为右子树

接下来只需要递归调用处理左右子树就可以

边界条件是:当子树只有零个或一个节点时 其构造方法只有一种

---

另外注意到,在上述求解时存在重叠子问题

接上面举例,继续处理左子树 [1, 2, 3, 4, 5, 6]

如果选择元素 3 作为根节点,那么,其左子树就是 [1, 2],其右子树就是 [4, 5, 6]

而用 [1, 2] 构造子树,和用 [8, 9] 构造子树,结果其实是一样的,这就是重叠子问题

我们可以用备忘录来优化

代码如下:

class Solution {
public:
    unordered_map<int, int> mem; // 备忘录

    int numTrees(int n) {
        if (n == 0) {
            return 1;
        }
        if (n == 1) {
            return 1;
        }
        if (mem.find(n) != mem.end()) {
            return mem[n];
        }
        int ans = 0;
        for (int i = 0; i <= n - 1; i++) {
            int l = i;
            int r = n - 1 - i;
            mem[l] = numTrees(l);
            mem[r] = numTrees(r);
            ans += mem[l] * mem[r];
        }
        return ans;
    }
};

2  二叉搜索树

如果二叉树任意节点的值都大于等于左子树所有节点的值且小于等于右子树所有节点的值

则该二叉树称为二叉搜索树 ( Binary Search Tree ,  BST \text{Binary Search Tree},\ \text{BST} Binary Search Tree, BST ), 接下来介绍其常见操作


2.1 判断合法性

给定一棵二叉树,判断是否为二叉搜索树 | leetcode98

还是能用递归解,关键是如何定义子问题,这里提供三种思考的角度

第一是自上而下,即通过递归函数的参数来传递约束,要在前序位置处理父节点

class Solution {
public:
    bool isValidBST(TreeNode* root) {
        return preOrder(root, LONG_MIN, LONG_MAX);
    }

    bool preOrder(TreeNode* root, long minVal, long maxVal) {
        // 边界情况
        if (!root) {
            return true;
        }
        // 基础情况
        // 先检查父节点是否满足约束
        if (
            root -> val <= minVal ||
            root -> val >= maxVal
        ) {
            return false;
        }
        // 再检查两子树是否满足约束
        return (
            preOrder(root -> left , minVal, root -> val) &&
            preOrder(root -> right, root -> val, maxVal)
        );
    }
};

第二是自下而上,即通过递归函数返回值来传递约束,要在后序位置处理父节点

class Solution {
public:
    bool isValidBST(TreeNode* root) {
        pair<long, long> result = postOrder(root);
        return result.first != LONG_MIN && result.second != LONG_MAX;
    }

    // 返回以当前节点为根节点的树中的最小最大值
    pair<long, long> postOrder(TreeNode* root) {
        // 边界情况
        if (!root) {
            return {LONG_MAX, LONG_MIN};
        }
        // 基础情况
        // 先从两子树获取约束
        pair<long, long> left = postOrder(root -> left);
        pair<long, long> right = postOrder(root -> right);
        // 再对父节点检查约束
        long val = root -> val;
        if (
            val <= left.second ||
            val >= right.first
        ) {
            return {LONG_MIN, LONG_MAX};
        }
        // 最后向上层返回当前约束
        return {
            min(val, left.first),
            max(val, right.second)
        };
    }
};

第三是中序遍历,二叉搜索树有个很重要的特性,就是二叉搜索树的中序遍历是所有元素的升序排序

那么我们就可以用数组将二叉搜索树的中序遍历的结果存起来,然后检查数组是否为升序的数组即可

class Solution {
public:
    // 中序遍历结果
    vector<int> elements;

    bool isValidBST(TreeNode* root) {
        inOrder(root);
        // 检查中序遍历结果是否为升序的
        for (int i = 1; i < elements.size(); i++) {
            if (elements[i - 1] >= elements[i]) {
                return false;
            }
        }
        return true;
    }

    // 中序遍历
    void inOrder(TreeNode* root) {
        if (!root) {
            return;
        }
        inOrder(root -> left);
        elements.push_back(root -> val);
        inOrder(root -> right);
    }
};

2.2 查找

在二叉搜索树中查找给定的元素 | leetcode700

这其实就是从上往下遍历,只是根据二叉搜索树的特性能定向遍历,就是每次左右子树只选其一深入

class Solution {
public:
    TreeNode* searchBST(TreeNode* root, int val) {
        // 边界情况
		if (!root) {
            return nullptr;
        }
        // 基础情况
        if (val == root -> val) { // 如果目标值等于当前节点值,则返回结果
            return root;
        }
        else if (val < root -> val) { // 如果目标值小于当前节点值,则去左子树继续找
            return searchBST(root -> left, val);
        }
        else if (val > root -> val) { // 如果目标值大于当前节点值,则去右子树继续找
            return searchBST(root -> right, val);
        }
        return nullptr;
    }
};

2.3 插入

在二叉搜索树中插入给定的元素 | leetcode701

在查找到合适的位置之后做插入,需要注意的是,插入的节点与其父节点要有正确的指向

class Solution {
public:
    TreeNode* insertIntoBST(TreeNode* root, int val) {
        // 边界情况
		if (!root) {
            return new TreeNode(val); // 找到插入位置进行插入,返回插入的节点
        }
        // 基础情况
        // if (val == root -> val) {
        //     不会插入已存在于树中的值
        // }
        if (val < root -> val) { // 如果目标值小于当前节点值,则去左子树找插入位置
            root -> left = insertIntoBST(root -> left, val);
        }
        if (val > root -> val) { // 如果目标值大于当前节点值,则去右子树找插入位置
            root -> right = insertIntoBST(root -> right, val);
        }
        return root;
    }
};

2.4 删除

在二叉搜索树中删除给定的元素 | leetcode450

待删除节点会有三种不同的情况,下面分别说明对三种情况的处理方式

第一,待删除节点的左右孩子都为空,此时直接去删除该节点就好

第二,待删除节点的左右孩子有一个为空,此时让非空的孩子节点接替自己的位置就好

第三,待删除节点的左右孩子都不空,此时情况就复杂了

为了去保持二叉搜索树的性质,需要让左子树中的最大节点或右子树中的最小节点接替自己

找左子树中的最大节点需要从左子树的根节点开始一直往右孩子走直到叶子节点就是目标值

找右子树中的最小节点需要从右子树的根节点开始一直往左孩子走直到叶子节点就是目标值

class Solution {
public:
    TreeNode* deleteNode(TreeNode* root, int key) {
        // 边界情况
		if (!root) {
            return nullptr;
        }
        // 基础情况
        if (key == root -> val) { // 如果目标值等于于当前节点值,表示已找到待删除元素
            // 处理情况 1、2
            if (root -> left == nullptr) {
                return root -> right;
            }
            if (root -> right == nullptr) {
                return root -> left;
            }
            // 处理情况 3
            // 找到右子树最小节点
            TreeNode* minNode = root -> right;
            while (minNode -> left != nullptr) {
                minNode = minNode -> left;
            }
            // 删除右子树最小节点
            root -> right = deleteNode(root -> right, minNode -> val);
            // 使用右子树最小节点替换当前节点
            minNode -> left = root -> left;
            minNode -> right = root -> right;
            // 返回被删除位置上的新的节点
            root = minNode;
        }
        else if (key < root -> val) { // 如果目标值小于当前节点值,则去左子树删除元素
            root -> left = deleteNode(root -> left, key);
        }
        else if (key > root -> val) { // 如果目标值大于当前节点值,则去右子树删除元素
            root -> right = deleteNode(root -> right, key);
        }
        return root;
    }
};

3  其他

除了二叉树和二叉搜索树之外,还有很多有良好特性的树,这里稍微列举一下,例如

完全二叉树、平衡二叉搜索树、线段树、伸展树、红黑树、前缀树、B 树、B+ 树…

但由于篇幅的原因,这里并不能涵盖所有这些树结构,实在是可惜,以后有机会一定会补上

本文的主要目的还是希望将解决树问题的一般思路整理出来,希望能对大家有所启发


好啦,本文到此结束,感谢您的阅读!

如果你觉得这篇文章有需要修改完善的地方,欢迎在评论区留下你宝贵的意见或者建议

如果你觉得这篇文章还不错的话,欢迎点赞、收藏、关注,你的支持是对我最大的鼓励 (/ω\)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/459351.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

波奇学Linux:Linux的认识和云服务器使用

在讲Linux前&#xff0c;我们先来理解计算机&#xff1a; 计算机&#xff1a;输入->算法->输出 举个栗子&#xff1a; pritnf :输出到屏幕&#xff08;硬件&#xff09;上 我们在计算机所有的行为都会转为硬件行为。 再进一步理解,我们打开visual studio后&#xff0c;敲…

BPF技术学习与整理

目录 eBPF是什么&#xff1f; eBPF是做什么的&#xff1f;可以解决什么问题&#xff1f; eBPF可以带来的解决方案是什么&#xff1f; eBPF的技术点 eBPF hookeBPF MapeBPF Helper FunctioneBPF有什么限制吗&#xff1f; 前言 21年因为项目需求而要开发一个工具&#xff0c;可以…

如何通过docker启动一个本地springboot的jar包

一、构建本地jar包 进入到项目目录下执行如下命令 mvn -e clean package -Dmaven.test.skiptrue或者直接在idea中打包 得到target文件夹 进入到target文件夹得到jar包 二、创建Dockerfile文件 新建Dockerfile文件&#xff0c;内容如下 FROM openjdk:8-jre MAINTAINER ja…

硬盘坏掉之后

文章目录 背景解决方案数据丢失软件安装 总结 背景 前一段时间&#xff0c;我的电脑突然就开不了机了&#xff0c;进入安全模式之后&#xff0c;发现硬盘无法读取&#xff0c;大概率是硬盘坏掉了。电脑是 MacBook&#xff0c;自己不太好换。于是跑到华强北&#xff0c;找了一家…

电脑无论是连接热点,还是公共网络,qq、微信都能用,就是不能上网,现分享解决办法如下。

这里写自定义目录标题 问题引入&#xff1a;解决办法1、打开电脑的控制面板2、点击 “网络和internet”3、点击 “internet 选项”4、点击 “连接”5、点击 “局域网设置”6、按照下面操作 问题引入&#xff1a; 在魔法使用网站之后&#xff0c;忘记关闭 clash 按钮就关闭电脑&…

Spring-学习修改尚硅谷最新教程笔记

二、Spring 1、Spring简介 1.1、Spring概述 官网地址&#xff1a;https://spring.io/ Spring 是最受欢迎的企业级 Java 应用程序开发框架&#xff0c;数以百万的来自世界各地的开发人员使用 Spring 框架来创建性能好、易于测试、可重用的代码。 Spring 框架是一个开源的 Jav…

数据湖Iceberg-SparkSQL集成(4)

文章目录 数据湖Iceberg-SparkSQL集成一、环境准备安装Spark 二、Spark配置Catalog2.1在配置文件中添加HiveCatalog与HadoopCatalog配置&#xff08;一劳永逸&#xff09;2.2使用spark-sql连接Hive Catalog2.3使用spark-sql连接Hadoop Catalog 三、SQL操作3.1 创建表创建分区表…

一个 24 通道 100Msps 逻辑分析仪

这是一个创建非常便宜的逻辑分析仪的项目&#xff0c;但其功能可与昂贵的商业分析仪相媲美。该分析仪可以以每秒 1 亿个样本的最高速度对多达 24 个通道进行采样&#xff0c;并且可以通过单个通道中的极性变化或多达 16 个通道形成的模式来触发。 该项目不仅包含硬件&#xff0…

去银行还是干嵌入式?

晚上要睡觉的时候&#xff0c;一个读者给我发消息 说是最近拿到了4个offer&#xff0c;现在犹豫不决 听说&#xff0c;最近嵌入式突然就火起来了。 不过&#xff0c;嵌入式很多人的薪资还是低了&#xff0c;而且&#xff0c;工作很多年后&#xff0c;嵌入式的工作&#xff0c;那…

蛋白冠™蛋白质组学技术实现快速深入精确地解析血浆蛋白质图谱

文章标题&#xff1a;Rapid, deep and precise profiling of the plasma proteome with multi-nanoparticle protein corona 发表期刊&#xff1a;Nature Communications 影响因子&#xff1a;17.694 作者单位&#xff1a;哈佛医学院&#xff1b;Seer&#xff0c;美国&#…

opencv (六十四)监督学习聚类(k近邻原理、支持向量机原理、k近邻(KNN)手写字识别、支持向量机数据分类)

文章目录 1 k近邻原理介绍2 支持向量机原理3 K近邻(KNN)手写字识别训练模型4 手写字识别5 支持向量机 进行数据分类6 源代码及数据文件下载1 k近邻原理介绍 k最近邻(k-Nearest Neighbor)算法是比较简单的机器学习算法。它采用测量不同特征值之间的距离方法进行分类。它的思想…

算法刷题知识点总结

因为数组的在内存空间的地址是连续的&#xff0c;所以我们在删除或者增添元素的时候&#xff0c;就难免要移动其他元素的地址。 二分法&#xff1a;采用两个指针&#xff0c;注意他们的区间划分&#xff1b; 双指针法&#xff0c;用于查找排序&#xff1a;双指针将一个两层循…

BUUCTF warmup_csaw_2016

小白垃圾做题笔记而已&#xff0c;不建议阅读。 唉&#xff0c;本来以为是让写shellcode的打了半天没打通&#xff0c;后来发现疏忽了sub_40060D函数。 前两行(6,7)没啥就是把那个字符串写到屏幕上。 然后是第八行&#xff0c; sprintf(): 这个函数用于将格式化的字符串写入…

Linux安装MongoDB数据库,实现外网远程连接访问

文章目录 1. 配置Mongodb源2. 安装MongoDB3. 局域网连接测试4. 安装cpolar内网穿透5. 配置公网访问地址6. 公网远程连接7. 固定连接公网地址8. 使用固定地址连接 简单几步实现Linux安装mongoDB数据库并且结合cpolar内网穿透实现在公网环境下的远程连接。 1. 配置Mongodb源 进…

聊聊开源类ChatGPT工作——MOSS

自从ChatGPT发布以来&#xff0c;它的“三步走方案”就好比《九阴真经》流落到AI江湖中&#xff0c;各大门派练法不一&#xff0c;有人像郭靖一样正着练&#xff0c;循序渐进&#xff1b;有人像欧阳锋一样反着练&#xff0c;守正出奇&#xff1b;也有像梅超风一样仅练就半部《九…

5、认真学习枚举类型

1、数字枚举 // 这里你的TSLint可能会报一个&#xff1a;枚举声明只能与命名空间或其他枚举声明合并。这样的错误&#xff0c;这个不影响编译 enum Status {Uploading,Success,Failed } console.log(Status.Uploading); // 0 console.log(Status["Success"]); // 1 …

智能骨传导蓝牙耳机该如何选,分享几款不错的骨传导蓝牙耳机

骨传导耳机是一种通过骨骼传递声音的耳机。相比于传统的耳塞和头戴式耳机&#xff0c;它有许多优点&#xff0c;例如&#xff1a; 1.安全。因为无需通过耳膜进行声音传递&#xff0c;所以对听力影响较小。 2.对耳朵没有伤害。 3.舒适。 4.节省时间。由于无需通过耳膜传递声音&a…

Codefi基于区块链的开发框架

&#x1f497;wei_shuo的个人主页 &#x1f4ab;wei_shuo的学习社区 &#x1f310;Hello World &#xff01; Codefi基于区块链的开发框架 Codefi技术是一种基于区块链的开发框架&#xff0c;它提供了一系列工具和服务&#xff0c;帮助开发者轻松构建和管理去中心化应用程序。C…

【Access】win 10 / win 11:Access 下载、安装、使用教程(「管理信息系统」实践专用软件)

目录 一、前言 二、卸载 Office 三、下载 Office Tool Plus 四、安装 Office&#xff08;内含 Access&#xff09; &#xff08;1&#xff09;启动 Office Tool Plus &#xff08;2&#xff09;部署 &#xff08;3&#xff09;安装 Office&#xff08;内含 Access&#…

C++STL详解(10) -- 使用哈希表封装unordered_set和unordered_map

文章目录 哈希表模板参数改造针对模板参数V改造增加仿函数获取具体数据类型. 哈希表的正向迭代器正向迭代器中的内置成员:正向迭代器的成员函数 哈希表插入函数的修改(适用于unordered_map)一个类型K去做set和unordered_set他的模板参数的必备条件.unordered_set的模拟实现(完整…