小星学DSA丨一文学完二叉树(基础篇)

这是小星学DSA系列的第二篇,我会记录我学习的过程与理解,希望能够帮到你。

本篇文章的思维导图如下,在文章的末尾,我会给出更加详细的思维导图。

上篇我们一上来就介绍了红黑树,原因是红黑树在sde岗位面试中的重要分量。

当然,基础也要打好,因此这篇文章,我们来学习一下二叉树的基础。

二叉树的定义与性质

定义与表示

二叉树是一种特殊类型的通用树,它的每个节点最多可以有两个子节点,两个子节点是有序的,分为左子节点与右子节点。

链式表示(常用)

链式表示是通过指针把分布在散落在各个地址的节点串联一起。

二叉树通常由指向根节点的指针表示,二叉树的每个节点都包括以下三个部分:

  1. 节点的值
  2. 指向左子节点的指针
  3. 指向右子节点的指针
struct node {
    int data;
    node* left;
    node* right;
};

顺序表示

顺序表示的元素在内存是连续分布的。

如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。

二叉树的基本概念

以下列出了二叉树中的一些基本概念

概念释义
根节点二叉树的顶端节点,没有父节点
父节点/子节点有子节点的节点被称为父节点;连接在父节点下面的节点被称为子节点
叶子节点没有子节点的节点被称为叶子节点
兄弟节点具有相同父节点的节点之间互为兄弟节点
深度从根节点到当前节点的路径长度被称为深度
高度从当前节点到叶子节点的最长路径长度被称为高度

二叉树的分类

满二叉树

若高度为h的二叉树恰好有 2 h − 1 2^h -1 2h1个元素,则称其为满二叉树(顾名思义,满了的二叉树)

完全二叉树

如果所有叶子节点都位于相同的高度,则称为完全二叉树(只有最后一层没满的二叉树)

二叉搜索树

所有节点的值唯一,且左子树的值都小于该节点的值,右子树的值都大于该节点的值。

平衡二叉树(AVL树)

左子树和右子树高度差至多为1的二叉搜索树

二叉树的性质

  1. 含n个元素的二叉树有n-1条边
  2. 若二叉树的高度为h,它最少有h个元素,最多有 2 h − 1 2^h -1 2h1个元素
  3. 若二叉树有n个元素,它的最大高度为n,最下高度为 l o g 2 ( n + 1 ) log_2(n+1) log2(n+1)
  4. 对于完全二叉树的一个元素i,其父节点为[i/2],左孩子为2i(或者不存在),右孩子为2i+1(或不存在)

这些性质证明起来都不难,初中数学难度,这里不再赘述。

二叉树的操作

二叉树的遍历

二叉树的遍历是老生常谈了,我在实习的面试里遇到过无数次(不过出这个一般也和放水差不多了)

二叉树的遍历有四种方式:前序、中序、后序、层序

💫小星说丨前中后序的区分方法——父节点在什么时候被访问(前序:先访问父节点,再访问左子树和右子树;中序:先访问左子树,再访问父节点,再访问右子树;后序:先访问左子树,再访问右子树,最后访问父节点)

前序遍历

二叉树前序遍历的步骤如下:

  1. 访问根节点
  2. 以前序遍历左子树
  3. 以前序遍历右子树

具体到代码实现,我们通常可以使用递归方法(常用)或者迭代方法实现。

  1. 递归方法非常简单粗暴,除了一个非空判定之外,与上面的步骤是一样的
void preOrderTravesal(NodePtr node)
    {
        if (node == nullptr)
        {
            return;
        }
        cout << node->data << " ";
        preOrderTravesal(node->left);
        preOrderTravesal(node->right);
    }
  1. 迭代法则需要用到栈,每次访问栈顶元素,并将它的右节点和左节点依次放入栈中,从而实现前序遍历的逻辑。
void preOrderIteration(NodePtr node)
    {
        if (node == nullptr)
        {
            return;
        }

        stack<NodePtr> nodeStack;
        nodeStack.push(node);

        while (!nodeStack.empty())
        {
            NodePtr currnode = nodeStack.top();
            nodeStack.pop();

            cout << currnode->data << " ";
            // 先进后出,因此要先放右节点,再放子节点
            if (currnode->right != nullptr)
            {
                nodeStack.push(currnode->right);
            }
            if (currnode->left != nullptr)
            {
                nodeStack.push(currnode->left);
            }
        }
    }

中序遍历

知道了前序遍历,中序遍历的思路也就很清楚了

二叉树中序遍历的步骤:

  • 按顺序遍历左子树
  • 访问根节点
  • 按顺序遍历右子树

中序遍历同样可以使用递归法和迭代法实现

  1. 递归法也没什么好说的,简单粗暴
void inorderRecursion(NodePtr node) {
        if (node == nullptr) {
            return;
        }

        inorderRecursion(node->left);
        cout << node->data << " ";
        inorderRecursion(node->right);
    }
  1. 中序遍历的迭代法和前序遍历不同的是,这里的栈保存的是所有的根节点,一路向左一路保存根节点,当到达最左处时,可以从栈中取出一个根节点来访问,之后再遍历右子树,也是用同样的方法。
void inorderIteration(NodePtr node)
    {
        if (node == nullptr)
        {
            return;
        }

        stack<NodePtr> nodeStack;
        NodePtr currNode = node;

        while (currNode != nullptr || !nodeStack.empty())
        {
            // Traverse left subtree
            while (currNode != nullptr)
            {
                nodeStack.push(currNode);
                currNode = currNode->left;
            }

            // Process current node
            currNode = nodeStack.top();
            nodeStack.pop();
            cout << currNode->data << " ";

            // Traverse right subtree
            currNode = currNode->right;
        }
    }

后序遍历

二叉树后序遍历的步骤如下:

  • 按后序遍历左子树
  • 按后序遍历右子树
  • 访问根节点

同样可以使用递归法和迭代法来实现

  1. 递归法还是一样
void postorderRecursion(NodePtr node) {
        if (node == nullptr) {
            return;
        }

        postorderRecursion(node->left);
        postorderRecursion(node->right);
        cout << node->data << " ";
    }
  1. 迭代法中,我们仍使用一个栈保存待遍历的根节点,而当我们从栈中拿出一个待访问的根节点时,有两种情况:1. 我们刚从左边上来,要遍历右子树2. 我们已经遍历了右子树,要访问当前根节点,因此需要一个指针保存上一次访问的节点,以确定右子树是否已被访问
void postorderIteration(NodePtr node)
    {
        if (node == nullptr)
        {
            return;
        }

        stack<NodePtr> nodeStack;
        NodePtr currNode = node;
        NodePtr lastVisitedNode = nullptr;

        while (currNode != nullptr || !nodeStack.empty()){
            // 一路向左
            while (currNode != nullptr){
                nodeStack.push(currNode);
                currNode = currNode->left;
            }

            NodePtr peekNode = nodeStack.top();
            // 判断栈顶节点的右节点是否被访问过
            if (peekNode->right != nullptr && peekNode->right != lastVisitedNode){
                // 遍历右子树
                currNode = currNode->right;
            }
            else{
                // 访问当前节点
                cout << peekNode->data << " ";
                lastVisitedNode = nodeStack.top();
                nodeStack.pop();
            }

        }

    }
  1. 我们注意到后序遍历(左右中)其实可以由变种的前序遍历(中右左)反转得到,因此可以使用反转的方法, cr《代码随想录》
vector<int> postorderTraversal(NodePtr node)
    {
        stack<NodePtr> stack;
        if (root == NULL)
        {
            return;
        }
        stack.push(node);
        vector<int> result;
        while (!stack.empty())
        {
            NodePtr currnode = stack.top();
            stack.pop();
            result.push_back(node->data);
            if (currnode->left)
            {
                stack.push(node->left);
            } // 相对于前序遍历,这更改一下入栈顺序
            if (currnode->right)
            {
                stack.push(node->right);
            }
        }
        reverse(result.begin(), result.end()); // 将结果反转之后就是左右中的顺序了
        return result;
    }

层序遍历

层序遍历理解上更简单了,就是一层一层的遍历。

层序遍历同样可以使用迭代法和递归法,只不过这里迭代法可能更常用一些

  1. 迭代法,使用一个队列存储待访问的节点,每次访问将左节点和右节点放入队列内,即可实现一层一层的遍历
void levelOrderIteration(NodePtr node) {
        if (node == nullptr) {
            return;
        }

        queue<NodePtr> nodeQueue;
        nodeQueue.push(node);

        while (!nodeQueue.empty()) {
            NodePtr currNode = nodeQueue.front();
            nodeQueue.pop();
            cout << currNode->data << " ";

            if (currNode->left != nullptr) {
                nodeQueue.push(currNode->left);
            }
            if (currNode->right != nullptr) {
                nodeQueue.push(currNode->right);
            }
        }
    }
  1. 递归法:层序遍历的递归法,需要记录每层的高度,通过高度储存每一层的节点
void levelOrderRecursion(NodePtr cur, vector<vector<int>> &result, int depth)
    {
        if (cur == nullptr)
        {
            return;
        }
        if (result.size() == depth)
            result.push_back(vector<int>());
        result[depth].push_back(cur->data);
        levelOrderRecursion(cur->left, result, depth + 1);
        levelOrderRecursion(cur->right, result, depth + 1);
    }

二叉树的搜索

二叉树的搜索,二叉树的最值,二叉树的高度,二叉树的元素数目等操作均建立在二叉树的遍历基础上,选一种合适的遍历方法即可实现,这里不再赘述。

二叉树的重构

二叉树的重构,即从遍历的结果里重新构造一棵二叉树。

二叉树的重构需要两种遍历结果的综合,但是通常不能从前序和后序结果里重构二叉树,因为不能确定根节点的左子树和右子树信息。

从前序与中序遍历结果中重构二叉树

从前序与中序遍历结果中重构二叉树分为以下几步:

  1. 从前序遍历结果中获得根节点值(前序遍历的第一个元素)
  2. 找到该值在中序遍历结果中的索引rootIdx
  3. 借助rootIdx,将前序结果和中序结果都切分成左子树的结果和右子树的结果
  4. 递归实现重构

NodePtr buildTreeFromPreIn(vector<int> &preorder, vector<int> &inorder)
    {
        if (preorder.empty() || inorder.empty())
        {
            return nullptr;
        }

        // 找到根节点在中序遍历中的索引
        int rootVal = preorder[0];
        int rootIdx = 0;
        while (inorder[rootIdx] != rootVal)
        {
            rootIdx++;
        }
        NodePtr root = new node;
        root->data = rootVal;

        // 切割成左子树和右子树
        vector<int> leftInorder(inorder.begin(), inorder.begin() + rootIdx);
        vector<int> rightInorder(inorder.begin() + rootIdx + 1, inorder.end());
        vector<int> leftPreorder(preorder.begin() + 1, preorder.begin() + 1 + rootIdx);
        vector<int> rightPreorder(preorder.begin() + 1 + rootIdx, preorder.end());

        root->left = buildTreeFromPreIn(leftPreorder, leftInorder);
        root->right = buildTreeFromPreIn(rightPreorder, rightInorder);

        return root;
    }

从中序和后序结果中重构二叉树

中序和后序重构二叉树的方法类似:

  1. 从后序遍历结果中获得根节点值(后序遍历的第一个元素)
  2. 找到该值在中序遍历结果中的索引rootIdx
  3. 借助rootIdx,将前序结果和中序结果都切分成左子树的结果和右子树的结果
  4. 递归实现重构

NodePtr buildTreeFromInPost(vector<int> &inorder, vector<int> &postorder)
    {
        if (inorder.empty() || postorder.empty())
        {
            return nullptr;
        }

        // 找到根节点在中序遍历中的索引
        int rootVal = postorder.back();
        int rootIdx = 0;
        while (inorder[rootIdx] != rootVal)
        {
            rootIdx++;
        }

        NodePtr root = new node;
        root->data = rootVal;

        // 切割成左子树和右子树
        vector<int> leftInorder(inorder.begin(), inorder.begin() + rootIdx);
        vector<int> rightInorder(inorder.begin() + rootIdx + 1, inorder.end());
        vector<int> leftPostorder(postorder.begin(), postorder.begin() + rootIdx);
        vector<int> rightPostorder(postorder.begin() + rootIdx, postorder.end() - 1);

        root->left = buildTreeFromInPost(leftInorder, leftPostorder);
        root->right = buildTreeFromInPost(rightInorder, rightPostorder);

        return root;
    }

二叉树操作的时空复杂度

最后总结一下二叉树基本操作的时空复杂度

操作时间复杂度空间复杂度
遍历(递归)O(n)O(h),递归栈的最大深度为树的高度
遍历(迭代)O(n)前中后层次具体分析
搜索O(n)O(1)
插入/删除O(n)O(1)
重构O(n)O(h),递归栈的最大深度为树的高度

这里都是将二叉树视作退化的链表来计算的,如果二叉树是搜索树或平衡树,则时间复杂度进一步降低。

总结

最后我们再给出本篇文章的详细内容导图

源代码

本文代码已在github上开源,包含c++,python(待补充), golang(待补充)的二叉树及其操作代码,如果你觉得这篇文章对你有帮助的话,还请帮忙在github上点个Star,谢谢!

Github地址

同时,欢迎访问我的个人网站小星code,我会分享更多关于CS学习的心得与经验。

参考资料

  1. 清华大学计算机系邓俊辉教授DSA课件[05.二叉树],下载地址
  2. 萨尼 S, 王立柱, 刘志红 数据结构、算法与应用 C++语言描述[M]. 北京: 机械工业出版社, 2015.
  3. 代码随想录
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值