Fork me on GitHub

数据结构-06 |树 | 二叉树| 二叉搜索树

 

1. 树、链表和图的联系

树、链表和图它们之间都有相应的联系;

单链表 Linked List  

 单链表:Value & Next ,每个Next都指向它的后续节点,HEAD头结点、TAIL尾节点。它最大的问题就是查询时太慢(如果要访问中间的或者倒数某个节点,必须从头节点一个个看)。

               ===> 加索引--跳表 (升维) 

    ===> 二维的数据结构-树和图(多个next,next1、 next2、 next3等节点指向多个节点就变成一颗树)

 Linked List链表就是特殊化的树Tree,Tree树就是特殊化的图Graph。(Graph是可以随便链,链回到它自己节点就可以,而树永远是单向链)

Tree(左节点和右节点或者认为一个树的节点有两个Next的指针)

2. 树 Tree概念

 树结构的出现,

 树,这个是二叉树(每个节点只有两个Next指针,或者只有一个左儿子和右儿子)。 每个元素叫作“节点”;用来连线相邻节点之间的关系叫作“父子关系”。

   A 头Root节点(根节点,没有父节点),B或者C叫左儿子、 右儿子,左边任何一枝树叫左子数(比如DHI),右边任何一枝树叫右子树(比如CFG)。

  树中有父节点,两边有左儿子和右儿子; 儿子之间叫兄弟节点(比如F和G,F叫左节点, G叫右节点,因为它们有同一个父节点)

       没有子节点的节点叫作叶子节点或者叶节点,比如H、 I、 J、F、G 

  层级: 从level0 - 1 - 2 - 3 

  

 

题目:分层来打印一个二叉树;

分类:

常用的为二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点右子。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。

以此类推,四叉树、八叉树等。

   

 上图都是二叉树,其中,编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作满二叉树

                                     编号 3 的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作完全二叉树

这里“最后一层的叶子节点都靠左排列”不是最后一层的子节点是左节点,而是指最后一层的子节点,从左数到右是连续,中间没有断开,缺少节点(如图例H、I、J是连续的)。(

  1. 当这一层的节点未满时是不允许存在下一层节点的
  2. 每层节点填充的方式是从左到右。

为什么偏偏把最后一层的叶子节点靠左排列的叫完全二 叉树?如果靠右排列就不能叫完全二叉树吗  (它的由来查看二叉树的存储,为了省内存。)

   

3. 二叉树 Binary  Tree

树结构多种多样,最常用是二叉树,即每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点右子。二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。以此类推,可以想象一下四叉树、八叉树的样子。

 这张图是典型的二叉树,一个节点只有两个孩子(左孩子和右孩子),每个节点都有两个孩子而没有单个的情况,叫完全二叉树;除了Next节点,Next节点再指回去就是图graph。

 树和图最关键的差别就是看它有没有环。如果节点只连了儿子节点,永远都不会走回去,比如第一个图中F节点指向了E或者C或者A就会形成一个环,就是图。

二叉树的存储

 如何表示(或者存储)一棵二叉树:

  • 一种是基于指针或者引用的二叉链式存储法
  • 一种是基于数组的顺序存储法

链式存储法

  每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式我们比较常用大部分二叉树代码都是通过这种结构来实现的

      

 

顺序存储法

顺序存储比较适合完全二叉树。

  把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 * i = 2 的位置,右子节点存储在 2 * i + 1 = 3 的位置。以此类推,B 节点的左子节点存储在 2 * i = 2 * 2 = 4 的位置,右子节点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。

    

 

如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。反过来,下标为 i/2 的位置存储就是它的父节点。通过这种方式,我们只要知道

根节点存储的位置(一般情况下,为了方便计算子节点, 根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。

上例是一棵完全二叉树,所以仅仅“浪费”了一个下标为 0 的存储位置。如果是非完全二叉树,其实会浪费比较多的数组存储空间。

   

所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针

这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。
 堆和堆排序,堆其实就是一种完全二叉树,最常用的存储方式就是 数组。

4.  二叉树遍历 Pre-order/ In-order/ Post-order

前中后序遍历,在实际中使用的很少,真正的遍历使用的是深度优先、广度优先以及搜树

复制代码
public class TreeNode { 
  public int val;   
  public TreeNode left, right;   
  public TreeNode(int val) {   
         this.val = val;    
         this.left = null;    
        this.right = null; 
  }
}
复制代码

  1.前序(Pre-order):根-左-右 (对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。)

  2.中序(In-order):左-根-右

  3.后序(Post-order):左-右-根

 二叉树的前、中、后序遍历就是一个递归的过程。

       

前序Pre-order

根-左-右, 先根结点,后左子树,再右子树;

    

中序 In-order

左-根-右  

    

后序 Post-order

左-右-根 

  

复制代码
前序遍历的递推公式:
preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)

中序遍历的递推公式:
inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)

后序遍历的递推公式:
postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r
复制代码

 层次遍历

 层次遍历为:A --> B --> C --> D --> E --> F --> G

 

5. 二叉搜索树 Binary Search Tree的遍历

二叉搜索树,也称二叉搜索树、有序二叉树(Ordered Binary Tree)、 排序二叉树(Sorted Binary Tree),是指一棵空树或者具有下列性质的 二叉树

  1. 左子树上所有结点的值均小于它的根结点的值;

  2. 右子树上所有结点的值均大于它的根结点的值;

  3. 以此类推:左、右子树也分别为二叉查找树。 (这就是 重复性!)

它的遍历是 中序遍历:升序排列 

如果只是个普通的二叉树,前中后遍历只是知道如何遍历即可,但如果是二叉搜索树,中序遍历左-根-右,它遍历出来的是一个有序的数组。

二叉搜索树常见操作

   https://visualgo.net/zh/bst

1. 查询   遍历(前| 中| 后遍历),它的时间复杂度为 O(n) 的; 二分搜索,每次减半,所以二叉搜索树它的查询时间复杂度为 log2n  

我们先取根节点,如果它等于我们要查找的数 据,那就返回。 如果要查找的数据比根节点的值小,那就在左子树中递归查找;

                             如果要查找的数 据比根节点的值大,那就在右子树中递归查找。

2. 插入新结点(创建)

   类似查找操作。新插入的数据一般都是在叶子节点上,所以只需从根节点开始,依次比较要插入的数据和节点的大小关系。

如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位 置;

                如果不为空,就再递归遍历右子树,查找插入位置。

同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;

                  如果不为空,就再递归遍历左 子树,查找插入位置。

3. 删除

( ①在叶子上,直接删除,树的形状没有发生任何变化。 ②关键性的节点--如根结点或某个子树的根结点 )  时间复杂度 O(long(n))

  针对要删除节点的子节点个数的不同,分三种情况:

  • 一,如果要删除的节点没有子节点,只需要直接将父节点中指向要删除节点的指针置为 null。
  • 二,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),只需更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。
  • 三,如果要删除的节点有两个子节点,这就比较复杂了。需要找到这个节点的右子树中的小节点,把它替换到要删除的节点上。然后再删除掉这个小节点,因为小节点肯定没有左子节点(如果有左子结点,那就不是小节点了),所以,我们可以应用上面两条规则 来删除这个小节点。

实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点 标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内 存中,比较浪费内存

空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加 插入、查找操作代码实现的难度。

  

 

 

 

 

4. 二叉查找树的其他操作 

除了插入、删除、查找操作之外,二叉查找树中还可以支持快速地查找大节点和小节点、前驱节点和后继节点。

二叉查找树除了支持上面几个操作之外,还有一个重要的特性,就是中序遍历二叉查找树,可以 输出有序的数据序列,时间复杂度是 O(n),非常高效。因此,二叉查找树也叫作二叉排序树。

 

一种特殊情况是:

   这个树退化成了一根根子,只有右结点,即变成了一个单链表。时间复杂度退化为 O(n)的。  加速的办法变成平衡的二叉树。

  

  Demo:

   https://visualgo.net/zh/bst

支持重复数据的二叉查找树

默认树中节点存储的都是数字。很多时候,在实际的软件开发 中,在二叉查找树中存储的,是一个包含很多字段的对象。利用对象的某个字段作为键 值(key)来构建二叉查找树。

我们把对象中的其他字段叫作卫星数据

前面二叉查找树的操作,针对的都是不存在键值相同的情况。那如果存储的两个对象键 值相同,这种情况该怎么处理呢?两种解决方法:

  • 第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
  • 第二种方法比较不好理解,不过更加优雅。每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。

二叉查找树的复杂度分析

二叉查找树的插入、删除、查找操作的时间复杂度。
实际上,二叉查找树的形态各式各样。比如这个图中,对于同一组数据,我们构造了三种二叉查找树。它们的查找、插入、删除操作的执行效率都是不一样的。

第一种二叉查找树,根节点 的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了 O(n)。

    

理想的情况,二叉查找树是一棵完 全二叉树(或满二叉树)。这时,插入、删除、查找的时间复杂度是多少呢?

从前面例子不管操作是插入、删除还是查找,时间复杂度其实都跟树的高度成正比,也就是 O(height)。现在问题就转变成另外一个了,即如何求一棵包含n 个节点的完全二叉树的高度?

树的高度就等于大层数减一,为了方便计算,转换成层来表示。从图中可以看出,包含n个节点的完全二叉树中,第一层包含1 个节点,第二层包含2 个节点,第三层包含4 个节点,依次类推,下面一层节点个数是上一层的2 倍,第K 层包含的节点个数就是 2(K-1)

不过,对于完全二叉树来说,后一层的节点个数有点儿不遵守上面的规律了。它包含的节点个数在1个到 2(L-1) 个之间(假设大层数是 L)。把每一层的节点个数加起来就是总的节点个数n。也即如果节点的个数是n,那么n 满足这样一个关系:

n >= 1+2+4+8+...+2(L-2)+1 
n <= 1+2+4+8+...+2(L-2)+2(L-1)

借助等比数列的求和公式,可计算出,L 的范围是 [log(n+1), log n +1]。
完全二叉树 的层数小于等于 log n +1,也就是说,完全二叉树的高度小于等于 log n。

对于极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。

需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,

一种特殊的二叉查找树,平衡二叉查找树。平衡二叉查找树的高度接近logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是 O(logn)。

 

 

 

  它的查询和插入不再是O(n), 而是log n, 就相当于加速了。(二维结构,排好序)

 树,本质上是到了这个节点有两个Next指针,指向左子树或指向右子树,如果左右子树没有任何关系的话这种结构是很低效的。

  左子树和右子树会有一个先后顺序的关系,即二叉搜索树。

一棵没有任何特点的二叉树很少有实际高效实用的办法,用的最高的是二叉搜索树、在普通二叉树上发展出来的平衡二叉搜索树、红黑树

二叉搜索树:Search指的是在树中这些节点之间有一个序列关系,搜索的时候更便捷,也称为有序二叉树(排序二叉树)。

特征:如果是空树也是二叉排序树;如果不是空树则:

1. 左子树上所有结点的值均小于它的根结点的值;
2. 右子树上所有结点的值均大于它的根结点的值; (这里是左右子树去做)
3. Recursively, 左、右子树也分别为二叉查找树。

 

二叉搜索树,
左子树上的所有节点都要 < 根节点;
右子树上的所有节点都要 > 根节点
比如上图中, 左子树14|10|19都要 < 27, 右子树35|31|42都要 > 27;
同理, 14 > 10, 14 < 19; 

   搜索查找效率很高,LogN

 

 https://www.bigocheatsheet.com/

 

1. 给定一组数据,比如 1,3,5,6,9,10。你来算算,可以构建出多少种不同的二叉树?

  如果是完全二叉树,则问题可简化为数组内的元素有多少种组合方式,n! 种方法。 

C[n,2n] / (n+1)种形状,c是组合数,节点的不同又是一个全排列,一共就是n!*C[n,2n] / (n+1)个二叉树。   https://en.wikipedia.org/wiki/Catalan_number

散列表与二叉树的对比

散列表的插入、删除、查找操作的时间复杂度可以做到常量级的 O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是 O(logn),

相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢?

原因如下:

第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查 找树来说,只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。

第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。

第三,笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存 在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数耗时,也不一定就比平衡二叉查找树的效率高。

第四,散列表的构造比二叉查找树要复杂,考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方 案比较成熟、固定。

最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的 散列表,不然会浪费一定的存储空间。

综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我 们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。

 

二叉树高度如何通过编程,求出一棵给定二叉树的确切高度呢?

确定二叉树高度有两种思路:

第一种是深度优先思想的递归,分别求左右子树的高度。当前节点的高度就是左右子树中较大的那个+1;(根节点高度 = max(左子树高度,右子树高度)+1 )

第二种可以采用层次遍历的方式,每一层记录都记录下当前队列的长度,这个是队尾,每一层队头从0开始。然后每遍历一个元素,队头下标 +1。直到队头下标等于队尾下标。这个时候表示当前层遍历完成。每

一层刚开始遍历的时候,树的高度+1。后队列为空,就能得到树的高度。

 

图Graph

 

 最短的时间内用最低的费用到达相应的目标。

posted @   kris12  阅读(108)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
历史上的今天:
2018-06-15 11.Django|中间件
levels of contents
点击右上角即可分享
微信分享提示