DAY17:二叉树(六)完全二叉树的节点个数,注意深度初值和时间复杂度优化

222.完全二叉树的节点个数

给你一棵 完全二叉树 的根节点 root ,求出该树的节点个数。

完全二叉树 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1~ 2h 个节点。
在这里插入图片描述
输入:root = [1,2,3,4,5,6]
输出:6
示例 2:
输入:root = []
输出:0

完全二叉树、完美二叉树和满二叉树

  1. 完美二叉树(Perfect Binary Tree):每一层都是完全填满的,即每一层的所有节点都存在,没有空缺。也就是说,如果树的高度为h,则有2^h - 1个节点。
  2. 满二叉树(Full Binary Tree):每个节点或者有2个子节点(左子节点和右子节点),或者没有子节点(叶子节点)。也就是说,没有只有一个子节点的节点。
  3. 完全二叉树(Complete Binary Tree):除了最底层外,其他各层的节点数都要达到最大,且最底层必须从左到右填入。也就是说,如果在某一层有空缺,那么该空缺必须在该层的最右侧。

(注意满二叉树和完美二叉树并不是一个概念)

普通二叉树的写法

思路

求节点的数量和求高度类似,递归写法依旧是用后序遍历,统计左右子树的节点数量并返回上一层,进行累加。

完整版
  • 每个节点都遍历了一遍,时间复杂度是O(N)
class Solution {
public:
    int countNodes(TreeNode* root) {
     //终止条件
    if(root==NULL){
        return 0;
    }
    
    //后序遍历
    int leftNum = countNodes(root->left);
    int rightNum = countNodes(root->right);
    int Num = 1+leftNum+rightNum;
    
    return Num;


    }
};

完全二叉树的写法

思路

完全二叉树是除了底层节点之外全满,并且底层节点从左到右没有断开的二叉树。

当我们计算满二叉树的时候,节点数量可以直接用公式,假设满二叉树层数为n,那么节点数量就是2^n-1。例如下图中中间的完全二叉树,前三层的节点数量就是2^3-1=7
在这里插入图片描述
因此可以通过完全二叉树除了底层都是满的这一特性来计算节点数。

遍历一个二叉树的子树,其子树如果是满二叉树,可以先得到子树的深度,然后直接2^n-1。

下面问题就是如何判断子树是否是满二叉树,并计算其深度。如果是满二叉树,向左遍历的深度和向右遍历的深度一定是相等的,并且,本题目规定了是一个完全二叉树,因此不存在左右深度相同但是中间存在NULL的情况。如下图所示。
在这里插入图片描述
但是图中的完全二叉树,其左子树是满二叉树,因此可以计算。右子树继续向下遍历,遇到了单个节点,单个节点也可以算满二叉树,因此也可以计算满二叉树节点数量并返回上层。也就是说,一直向下递归,一定可以遇到符合满二叉树条件的节点,不会进入死循环,因为最后的叶子节点一定是满二叉树
在这里插入图片描述
因此,我们可以先往左递归,计算左侧深度,再一直往右递归,计算右侧深度,如果两侧深度相同,说明子树是满二叉树,直接通过2^n-1计算其节点数目,返回给上一层。

这种写法的好处是,只需要遍历外侧节点,不需要遍历内侧,在二叉树足够大的情况下,中间侧的节点都不遍历,只遍历左右侧节点,能节省较多的时间开销

伪代码
  • 终止条件的计算里并没有用递归,终止条件里计算深度,单层逻辑依然是后序遍历的递归逻辑,和104.求二叉树最大深度的前序遍历写法并不一样
int getNum(TreeNode* root){
    
    //这种解法终止条件比较复杂,除了遇到空的情况,还要判断遇到满二叉树的情况
    //遇到空
    if(root==NULL){
        return 0;
    }
    //遇到满二叉树,返回公式计算的子树节点数,也是终止条件
    TreeNode* left = root->left;
    TreeNode* right = root->right;
    int leftDepth = 0;
    int rightDepth = 0;
    
    //一直向左计算深度
    //这里注意,当left=left->left==NULL的时候,leftDepth就不会++了,也就是说leftDepth统计的就是真实的深度
    while(left){
        left = left->left;
        leftDepth++;   
    }
    //一直向右计算深度
 
    while(right){
        right = right->right;
        rightDepth++;
    }
    if(leftDepth==rightDepth){
        //指数计算方法:位运算
        // 此处注意(2<<1) 相当于2^2,所以leftDepth初始为0
        return (2<<leftDepth)-1;
    }
    
    //单层递归:后序遍历
    int leftNum = getNum(root->left);
    int rightNum = getNum(root->right);
    int Num = 1+leftNum+righNum;
    return Num;
    
    
    
}
位运算注意

c++中,2<<1相当于2的2次方,也就是说2<<1 = 4。2<<0是2的1次方,相当于没有移动。因此leftDepth是从0开始的。但是计算的时候,相当于从2<<0也就是2的1次方开始计算。

leftDepth初值必须取0的原因

在最后计算节点数量的时候,根节点那一层的数量应该是2<<0-1,所以深度的初值必须从0开始取

二叉树深度/高度初始值的问题

对于二叉树的深度或高度的定义,有两种常见的约定:

  1. 一种是将根节点的深度(或高度)视为0,那么只有一个节点的树的深度(或高度)为0,两个节点的树的深度(或高度)为1,以此类推。
  2. 另一种是将只有一个节点的树(只有根节点)的深度(或高度)视为1,那么两个节点的树的深度(或高度)为2,以此类推。

这两种约定都是常见的,选择哪一种主要取决于具体的应用场景和需求。例如,在111.二叉树最小深度题目中,显然采用了第二种约定,将只有一个节点的树(只有根节点)的深度视为1。在这种约定下,计算最小深度时,对于每个节点,我们都需要计算其左右子树的最小深度,然后加1(因为需要算上该节点自身)

但是在本题222.完全二叉树的节点个数中,当计算满二叉树的节点数量时,采用了将根节点深度视为0的约定,因为根据公式,满二叉树的节点数为 2^d - 1,其中d是树的深度,根节点深度为0,满二叉树节点数量就为 2<<0 - 1 = 2 - 1 = 1,这与实际情况(只有一个节点)相符。这也是一个合理的约定,因为在这种情况下,我们关心的是整个树的结构,而不是单个节点的深度。

总的来说,对于树的深度(或高度)的定义没有固定的规定,取决于具体的应用场景和需求。只要在实际应用中保持一致,就不会出现问题。

什么时候使用位运算

在C++中,如果想计算 ab 次方,通常使用 <cmath> 库中的 pow(a, b) 函数。

但是,如果 b 是2的幂,也就是说我们要计算的是2的多少次方使用位运算会更快,因为位运算通常比浮点数运算更快。这是为什么本题中使用了位运算。然而,如果 b 不是2的幂,那么位运算就不能用来计算 ab 次方了

正常情况下,不是2的多少次方的运算,都是通过库的pow(a,b)来解决。示例:

#include <iostream>
#include <cmath> // 引入cmath库以使用pow函数

int main() {
    double a = 2.0;
    double b = 3.0;
    double result = std::pow(a, b); // 计算a的b次方
    std::cout << "The result of " << a << " to the power of " << b << " is: " << result << std::endl;
    return 0;
}

pow(a, b)函数对于所有的浮点数ab都有定义,这意味着它可以用来计算诸如pow(2.0, 0.5)(即2的平方根)这样的表达式。但如果你只需要计算整数的整数次幂,也可以使用标准库<cmath>中的pow函数,如std::pow(2, 3)

但要注意的是,使用浮点数的运算可能会存在一定的精度问题,尤其是在处理大数或者需要高精度计算的情况下,这点在实际编程中需要特别注意。

完整版

class Solution {
public:
    int countNodes(TreeNode* root) {
       //使用完全二叉树的特性完成题目
        
        //终止条件
        if(root==NULL){
            return 0;
        }
        TreeNode* left = root->left;
        TreeNode* right = root->right;
        int leftDepth = 0;
        int rightDepth = 0;
        while(left){
            left = left->left;
            leftDepth++;
        }
        while(right){
            right = right->right;
            rightDepth++;
        }
        if(leftDepth==rightDepth){
            return (2<<leftDepth)-1;
        }
        
        //单层递归:后序遍历
        int leftNum = countNodes(root->left);
        int rightNum = countNodes(root->right);
        int Num = 1+leftNum+rightNum;
        return Num;

    }
};
时间复杂度

这种做法的时间复杂度是O(logn×logn)。原因是它结合了二分查找和递归的思想。

在每一层递归中,代码首先通过两个while循环计算左子树和右子树的深度(高度),这个操作的时间复杂度是O(log n),因为在完全二叉树中,树的深度(高度)是log n(这里的n是树的节点总数)。

然后,如果左子树和右子树的深度(高度)相同,说明左子树是一棵满二叉树,直接通过公式 (2<<leftDepth)-1 计算节点数。如果左子树和右子树的深度(高度)不同,代码会递归地在左子树和右子树中调用 countNodes 函数。

注意,由于每一层递归中,代码都在二叉树中的一半节点中进行查找,这与二分查找的思想类似。所以,这个代码的总时间复杂度是O(log n × log n)。

为什么是O(log n × log n)而不是O(log n)呢?这是因为在每一层递归(深度)中,代码都要进行O(log n)的操作(两个while循环计算深度),并且树的深度也是O(log n),所以总的时间复杂度就是O(log n × log n)。

与后序遍历递归O(N)的对比

对于完全二叉树,O(log n × log n)的时间复杂度通常优于O(N)。

首先注意:O(log n × log n)=O((log n)^2),虽然是平方级别的复杂度,但仍然是对数级别的复杂度。对数级别的复杂度在大多数情况下优于线性复杂度。O(log n × log n)通常被用来描述那些每一层复杂度为O(log n),并且总共有log n层的算法。这样描述有助于理解算法的工作机制,但是实质上O(log n) × O(log n) = O((log n)^2)。

在第二种做法中,代码利用了完全二叉树的特性,只需要在一半的节点上进行操作。并且,每一层递归都会将问题规模减半,因此它的时间复杂度是对数级别的

相比之下,第一种做法中的后序遍历算法需要访问树中的每一个节点一次,因此它的时间复杂度是线性的

但需要注意的是,这只是理论上的比较。在实际情况中,由于各种因素(例如计算机的缓存效果、函数调用的开销等),实际性能可能会有所不同。实际上,如果树的大小不是特别大,那么简单的后序遍历算法可能会更快一些,因为它的实现更简单,没有额外的函数调用开销。

在理论上,O(log n × log n)的时间复杂度优于O(N),但在实际应用中,哪一种算法更好可能取决于具体的情境。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值