DAY13:二叉树(一):二叉树理论基础

文章目录

    • 一、二叉树的种类
      • 1.满二叉树
      • 2.完全二叉树
      • 3.二叉搜索树
        • 为什么上图中9>6,还满足左子树上所有结点的值均小于其根节点值?
        • 二叉搜索树的搜索方式补充
        • 总结
      • 4.平衡二叉搜索树
        • 补充:红黑树
    • 二、二叉树的存储方式
      • 链式存储
      • 顺序存储(线式存储)
        • 数组来存储二叉树的遍历方式
        • 刷题的时候注意:
    • 三、二叉树的遍历方式
      • 深度优先搜索和广度优先搜索
        • (DFS)深度优先搜索
          • 概念
          • 搜索过程
          • 搜索示例
          • 图的邻接表表示法
          • 无向图
          • DFS的性能
        • (BFS):广度优先搜索
      • 二叉树的遍历
        • 深度优先遍历
        • 广度优先遍历
        • 前中后序的遍历怎么实现?
    • 四、二叉树的定义
    • 补充:运算符>>的问题

一、二叉树的种类

1.满二叉树

满二叉树的节点数量是2^k-1,k是深度,下图的二叉树深度为3.
DAY13:二叉树(一):二叉树理论基础_第1张图片

2.完全二叉树

1.完全二叉树是除了底层之外,其他层都是满的

2.并且,底层是从左到右连续的

满二叉树一定是完全二叉树。堆其实就是一个完全二叉树,同时保证父亲节点和子节点之间的顺序。

图上这种情况,就不是完全二叉树,因为底层并不连续。但是,如果去掉底层最右侧的节点,这就是完全二叉树。
DAY13:二叉树(一):二叉树理论基础_第2张图片

3.二叉搜索树

二叉搜索树是节点便于搜索的树,为了便于搜索,节点是有顺序的。搜索一个节点的时间复杂度是logn

二叉搜索树中,左子树的所有节点都小于中间节点,右子树的所有节点都大于中间节点。同时,左右子树本身也满足这个规律。下面这两棵树都是搜索树:
DAY13:二叉树(一):二叉树理论基础_第3张图片
比如在这棵树里面搜索元素3,直接就能找到元素3的路径,时间复杂度是logn

二叉搜索树是一个有序树

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值

  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值

  • 它的左、右子树也分别为二叉排序树

为什么上图中9>6,还满足左子树上所有结点的值均小于其根节点值?

    10
   /  
  6   
 / \  
3   9

实际上,这棵树满足二叉搜索树的定义。我们可以检查每个节点,看它是否满足二叉搜索树的属性:

  • 对于根节点 10,它的左子树只有一个节点,6,它小于 10,所以满足条件。
  • 对于节点 6,**它的左子节点 3 小于 6,右子节点 9 大于 6,**也满足条件。

我们可能对于节点 9 小于根节点 10 有疑问,为什么它可以出现在左子树中。

这是因为二叉搜索树的定义中,只要求一个节点的所有左子节点都要小于这个节点,所有右子节点都要大于这个节点,而并没有要求一个节点的所有子节点都要小于根节点或所有右子节点都要大于根节点。所以,节点 9 虽然大于节点 6,但是它小于节点 10,所以它可以作为节点 6 的右子节点出现在根节点 10 的左子树中。

二叉搜索树的搜索方式补充

二叉搜索树中:

  • 对于任意一个节点,它的左子树中所有节点的值都应该小于这个节点的值
  • 同样地,对于任意一个节点,它的右子树中所有节点的值都应该大于这个节点的值

注意,这是对每个节点的规定,不仅仅是根节点。这就意味着,在左子树中的右节点,虽然它大于左子树的根节点,但是它依然会小于整个二叉搜索树的根节点(假设它在根节点的左子树里)。同样,在右子树中的左节点,虽然它小于右子树的根节点,但是它依然会大于整个二叉搜索树的根节点(假设它在根节点的右子树里)。

这样的规则使得二叉搜索树可以方便地进行搜索操作:从根节点开始,如果我们要搜索的值小于当前节点,我们就去左子树搜索;如果我们要搜索的值大于当前节点,我们就去右子树搜索。这样可以有效地将搜索范围减半,提高搜索效率

总结

二叉搜索树对节点的结构没有要求,但是对节点上的元素顺序有要求

4.平衡二叉搜索树

平衡二叉搜索树:又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树

平衡二叉搜索树中,左子树和右子树的高度绝对值不能超过1

如图:
DAY13:二叉树(一):二叉树理论基础_第4张图片
最后一棵 不是平衡二叉树,因为它的左右两个子树的高度差的绝对值超过了1。

C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树(红黑树),所以map、set、multimap、multiset的增删/查找操作时间时间复杂度是**logn,并且这些容器内key是有序的**。因为这四种容器底层实现就是平衡二叉搜索树,平衡二叉搜索树就是按照元素值来排序的。map中的key和set中的元素都是有序的。

另外注意unordered_mapunordered_set底层实现是哈希表,不是红黑树。

补充:红黑树

红黑树是一种自平衡二叉搜索树。在C++的标准模板库(STL)中,map、set、multimap和multiset等关联容器的底层实现通常使用红黑树。红黑树的插入、删除和查找操作的时间复杂度都是O(log n),其中n是树中节点的数量。这个时间复杂度的基础在于,红黑树是一种自平衡的二叉查找树。

红黑树通过一些操作(颜色更改和节点旋转)来确保树在插入和删除节点后依然保持大致的平衡,从而保证查找、插入和删除操作的高效率。尽管在某些操作后红黑树可能不是完全平衡的,但是由于红黑树的特性,任何一条从根到叶子的最长路径的长度都不会超过任何一条从根到叶子的最短路径长度的两倍,所以红黑树的高度仍然是O(log n),因此查找、插入和删除操作的时间复杂度也是O(log n)

红黑树的主要特性如下:

  1. 每个节点要么是红色,要么是黑色。
  2. 根节点是黑色。
  3. 所有的叶节点(通常是NULL节点或者空节点)是黑色的。
  4. 每个红色节点的两个子节点都是黑色的(也就是说,没有两个连续的红色节点)。
  5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

通过这些规则,红黑树能保证任何一条从根到叶子的最长路径的长度不会超过任何一条从根到叶子的最短路径的两倍。因此,红黑树是近似平衡的,这个特性使得红黑树在插入、删除和查找操作中都能保证较高的效率,即在最坏的情况下,都是对数级别的时间复杂度。

补充注意:

一定要知道常用的容器底层都是如何实现的,最基本的就是map、set等等,否则自己写的代码,对其性能分析都分析不清楚

二、二叉树的存储方式

二叉树可以链式存储,也可以顺序存储。

链式存储方式就用指针, 顺序存储的方式就是用数组

顾名思义就是顺序存储的元素在内存是连续分布的,而链式存储则是通过指针把分布在各个地址的节点串联一起

链式存储

DAY13:二叉树(一):二叉树理论基础_第5张图片

顺序存储(线式存储)

顺序存储其实就是用数组来存储二叉树,顺序存储的方式如图:
DAY13:二叉树(一):二叉树理论基础_第6张图片
DAY13:二叉树(一):二叉树理论基础_第7张图片

数组来存储二叉树的遍历方式

如果想找到下标i节点的左右孩子:

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

但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树

要了解,用数组依然可以表示二叉树。

刷题的时候注意:

力扣上已经封装好了二叉树的节点和要输入的数据,直接用就行,但是面试的时候是需要自己写的,比如自己传入一个二叉树

二叉树用的一般都是链式存储,二叉树可以理解为一种链表唯一的区别是一个结点里有两个指针,一个指向左孩子一个指向右孩子。因此可以选择构造一个节点,让其左右指针分别指向新构造的下一个节点然后把头节点传入到功能函数里,就已经实现构造二叉树传入函数了。

二叉树就是一种特殊的链表

三、二叉树的遍历方式

二叉树主要有两种遍历方式:

  1. 深度优先遍历:先往深走,遇到叶子节点再往回走。
  2. 广度优先遍历:一层一层的去遍历。

这两种遍历是图论中最基本的两种遍历方式

深度优先搜索和广度优先搜索

深度优先搜索是用递归的方式来实现的。前序遍历、中序遍历、后续遍历,都是通过递归实现的深度优先搜索

(DFS)深度优先搜索

概念

DFS 全称是 Depth First Search,中文名是深度优先搜索,是一种用于遍历或搜索树或图的算法。所谓**深度优先,就是说每次都尝试向更深的节点走。**沿着一个方向一直去搜,一直搜到终点再回退,换下一个方向,再到终点再回退。

该算法常常与 BFS 并列,但两者除了都能遍历图的连通块以外,用途完全不同,很少有能混用两种算法的情况。

DFS 常常用来指代用递归函数实现的搜索,但实际上两者并不一样。有关该类搜索思想请参阅 DFS(搜索).

深度优先搜索的实现通常采用递归,在树的遍历中,前序遍历、中序遍历、后序遍历都是深度优先搜索的特例

搜索过程

DFS 最显著的特征在于其 递归调用自身。同时与 BFS 类似,DFS 会对其访问过的点打上访问标记,在遍历图时跳过已打过标记的点,以确保 每个点仅访问一次。符合以上两条规则的函数,便是广义上的 DFS。

具体地说,DFS 大致结构如下:

DFS(v) // v 可以是图中的一个顶点,也可以是抽象的概念,如 dp 状态等。
  //在 v 上打访问标记
  for u in v 的相邻节点
    if u 没有打过访问标记 then
      DFS(u)
    end
  end
end
搜索示例

按照上述伪代码,我们举一个DFS的例子:

  • “DFS(v)” 对应 "void dfs(int node)"
  • “在 v 上打访问标记” 对应 "visited[node] = true"
  • “for u in v 的相邻节点” 对应 "for (i = adj[node].begin(); i != adj[node].end(); ++i)"
  • “if u 没有打过访问标记 then DFS(u)” 对应 "if (!visited[*i]) dfs(*i)".
#include
using namespace std;

vector<bool> visited; // 用于跟踪节点是否被访问过的向量
vector<vector<int>> adj; // 图的邻接表表示法

void dfs(int node) {
    visited[node] = true;
    cout << node << ' ';
  
    vector<int>::iterator i;
    for (i = adj[node].begin(); i != adj[node].end(); ++i)
        if (!visited[*i])
            dfs(*i);
}

int main() {
    int nodes, edges, u, v;
    cin >> nodes; // 输入节点数量
    cin >> edges; // 输入边的数量
  
    visited.assign(nodes, false);
    adj.assign(nodes, vector<int>());

    for (int i = 0; i < edges; i++) {
        cin >> u >> v; // 输入边的起始和终止节点
        adj[u].push_back(v); // 无向图
        adj[v].push_back(u);
    }

    dfs(0); // 从节点0开始深度优先搜索
  
    return 0;
}

这种递归策略就是深度优先搜索的核心思想,它会一直遍历直到达到某个节点的所有相邻节点都被访问过,然后再回溯。

图的邻接表表示法
  • 图的邻接表表示法:vector> adj;是图的邻接表(Adjacency List)表示法

  • 邻接表是图的一种常见表示法,特别适合表示稀疏图即边的数量远小于节点数的平方的图)。在邻接表中,图中的每个节点都维护一个列表,该列表包含了与该节点直接相连的所有其他节点。

    在这句代码中,adj 是一个二维向量。adj[i] 代表与节点 i 直接相连的所有节点的列表,因此 adj[i][j] 就是与节点 i 直接相连的第 j 个节点

    例如,如果 adj[2]{0, 1, 3},那就意味着节点2与节点0、1和3相连。这就是邻接表的基本表示方式。

无向图

无向图是一种图形,其中的边没有方向,也就是说,图中的每条边都可以沿两个方向遍历。这与有向图相对,有向图中的每条边都有一个明确的起点和终点。

在这段DFS中,无向图是通过以下两行代码表示的:

adj[u].push_back(v); 
adj[v].push_back(u);

对于每条输入的边(u, v),在邻接表中都添加两个条目:一个是在u的邻接列表中添加v,另一个是在v的邻接列表中添加u。这样就表明了u到v和v到u都存在一条路径,从而形成无向图。

为什么使用无向图会根据问题的具体需求而变化。在一些问题中,如社交网络中的好友关系,无向图可能是最自然的表示,因为好友关系是相互的。在其他问题中,例如表示网页链接或道路网络,有向图可能是更好的选择。

DFS的性能

该算法通常的时间复杂度为O(n+m),空间复杂度为O(n),其中n表示点数,m表示边数。注意空间复杂度包含了栈空间,栈空间的空间复杂度是O(n)的。在平均O(1)遍历一条边的条件下才能达到此时间复杂度,例如用前向星或邻接表存储图;如果用邻接矩阵则不一定能达到此复杂度。

(BFS):广度优先搜索

BFS 全称是 Breadth First Search,中文名是宽度优先搜索,也叫广度优先搜索。广度优先搜索就是一层一层遍历,或者一圈一圈遍历。二叉树里是一层一层遍历,图论里通常是一圈一圈遍历。

是图上最基础、最重要的搜索算法之一。

所谓宽度优先。就是每次都尝试访问同一层的节点。 如果同一层都访问完了,再访问下一层

这样做的结果是,BFS 算法找到的路径是从起点开始的 最短 合法路径。换言之,这条路径所包含的边数最小

在 BFS 结束时,每个节点都是通过从起点到该点的最短路径访问的。

二叉树的遍历

回到二叉树的遍历,从深度优先遍历和广度优先遍历进一步拓展,才有如下遍历方式:

  • 深度优先遍历
    • 前序遍历(递归法,迭代法)
    • 中序遍历(递归法,迭代法)
    • 后序遍历(递归法,迭代法)
  • 广度优先遍历
    • 层次遍历(迭代法)

深度优先遍历

做二叉树相关题目,经常会使用递归的方式来实现深度优先遍历DFS,也就是实现前中后序遍历,使用递归是比较方便的。

之前讲到栈与队列的时候,就说过栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用非递归的方式来实现的。

而广度优先遍历BFS的实现一般使用队列来实现,这也是队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树。

(这里其实我们又了解了栈与队列的一个应用场景了。)

理论上说,二叉树中每一种用递归遍历解决的题目,用相应的迭代法(也就是非递归方法)都可以解决,只不过迭代法很多时候比较麻烦

(面试中也有可能会考到,面试官考察一个较简单的二叉树,然后问你能不能用非递归方式实现。)

广度优先遍历

层序遍历就是使用一个队列,来实现对二叉树一层一层的搜索。图论里的BFS也是使用队列,因为队列先进先出的特性符合一层一层遍历的需求

前中后序的遍历怎么实现?

  • 前序遍历的遍历方式是中左右。先遍历中间节点,再遍历左子树(不是左节点)。在左子树里继续中左右。左子树处理结束后,再处理右子树。
  • 中序遍历是左中右。先访问左子树。在左子树里面继续左中右。
  • 后序遍历是左右中。先访问左子树,在左子树里左右中。

前中后可以理解为描述的就是中的位置。例如下面的示例:
DAY13:二叉树(一):二叉树理论基础_第8张图片
前序:5 4 1 2 6 7 8

中序:1 4 2 5 7 6 8

后序:1 2 4 7 8 6 5

四、二叉树的定义

力扣的核心代码模式是把数据结构都定义好了,但是面试还是会面临二叉树节点定义的问题。基本数据结构的定义一定要可以手写出来,并且不出错!

二叉树的节点其实就是链表节点,构造一个结构体:

//定义二叉树的节点
struct TreeNode{
    int val;
    TreeNode* left;
    TreeNode* right;
    //写构造函数,new一个节点的时候方便对其初始化
    TreeNode(int t):val(t),left(NULL),right(NULL){
        
    }
};

二叉树的定义和链表是差不多的,相对于链表 ,二叉树的节点里多了一个指针, 有两个指针,指向左右孩子。

补充:运算符>>的问题

(a + b) >> 1 在数学意义上等同于 (a + b) / 2。两者的目的都是ab 的平均值(当 ab 都是整数时,结果是向下取整的)

然而,在计算机的二进制运算中,位移运算符 >>(右移)的效率更高,因为它只需要移动二进制位,而不需要进行除法运算。因此,在性能敏感的情况下,程序员经常使用 (a + b) >> 1 代替 (a + b) / 2

但是,对于大多数现代编译器,它们可以自动优化除以2的操作为右移操作,所以在大多数情况下,两者的性能差异并不明显。

另外,需要注意的是,直接使用 (a + b) >> 1 可能会引发整型溢出的问题。如果 ab 都是 int 类型的最大值,那么 a + b 的结果会超出 int 的表示范围,造成整型溢出。因此,更安全的方式是使用 **a + ((b - a) >> 1) **,这两种方式都可以避免整型溢出的问题。

补充2((a + b) >> 1) & INT_MAX

这个表达式的目的是去掉 a + b 结果中可能产生的符号位,以保证结果是一个非负数。INT_MAXint 类型可以表示的最大非负整数,其二进制表示中,除了最高位(符号位)以外,其他所有位都是1。因此,(a + b) & INT_MAX 可以去掉 a + b 结果中的符号位。

然而,这并不能防止 a + b 溢出,如果 a + b 的结果超出了 int 的范围,那么溢出仍然会发生。在C++中,有符号整数溢出是未定义行为,这意味着如果溢出发生,程序的行为是不可预测的

你可能感兴趣的:(数据结构,算法,c++)