数据结构与算法之美学习笔记(二叉树基础)

目录:

  • 树的基础知识
  • 二叉树、满二叉树、完全二叉树
  • 如何遍历一棵树
  • 什么是二叉查找树
  • 二叉查找树的增删改的实现
  • 二叉查找树中的重复数据如何处理
  • 散列表已经这么强了,为什么还要有二叉查找树(二叉查找树相对于散列表的优势)

笔记参考:

https://time.geekbang.org/column/article/67856?utm_source=pinpaizhuanqu&utm_medium=geektime&utm_campaign=guanwang&utm_term=guanwang&utm_content=0511

https://time.geekbang.org/column/article/68334?utm_source=pinpaizhuanqu&utm_medium=geektime&utm_campaign=guanwang&utm_term=guanwang&utm_content=0511

树的基础知识

1、什么是树

2、树的基本概念

节点、父子关系:

  • 节点:图中的圆点就是树的元素,我们把它叫做节点。
  • 父子关系:用来连接节点之间关系,叫做“父子关系”。

树的根节点、父节点、子节点、叶子节点、兄弟节点:

  • 父节点:A节点是B、C、D的父节点。
  • 子节点:同理B、C、D是A节点的子节点。
  • 叶子节点:没有子节点的叫做叶子节点,也叫叶节点,如G、H、I、J、K、L。
  • 兄弟节点:拥有同一个父节点的节点们叫做兄弟节点,如B、C、D是父节点,K、L是父节点等。
  • 树的根节点:没有父节点的节点叫做,如图中E就是树的根节点。

节点高度、节点深度、节点的层数、树的高度:还是用上图来理解。

  • 节点高度:该节点到叶子节点的最长路径(边数,从0开始计算),如F到叶子节点的最长路径也就是到G、H、I、J,所以F节点的高度为2。
  • 节点深度:根节点到该节点的所经历的边数(从0开始计算),如C就是2。
  • 节点的层数:节点深度加1。
  • 树的高度:根节点的高度。

再来看个简单的例子:

二叉树、满二叉树、完全二叉树

满足树的条件,且一个节点最多只能有两个子节点的树就叫做二叉树,分别是左子树和右子树,如下图。

这个图里面,有两个比较特殊的二叉树,分别是编号2和编号3这两个。

编号2是满二叉树,编号3是完全二叉树。


1、满二叉树:

  • 所有叶子节点都在最后一层,并且节点总数为2 ^ n - 1,其中n为树层数。
  • 除叶子节点之外,每个节点都有左右两个子节点。

2、完全二叉树:

  • 叶子节点都在最底下两层。
  • 最后一层的叶子节点都靠左排列,并且除了最后一层,其它层的节点个数都要达到最大。

从长相上来看完全二叉树也不具有特殊性啊,为什么要单独把这种树拎出来呢?

首先要了解到,表示一棵树可以用数组,也可以用链表(链表更直观些),就长下面那样。

基于数组的顺序存储法,我们把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 * i = 2 的位置,右子节点存储在 2 * i + 1 = 3 的位置。以此类推,B 节点的左子节点存储在 2 * i = 2 * 2 = 4 的位置,右子节点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。

也就是说,我们舍弃下标为0的空间,如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。反过来,下标为 i/2 的位置存储就是它的父节点。通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。


综上,完全二叉树只需要浪费下标为0的空间就可以很好的把树串联起来了,如果不是完全二叉树的话则会浪费调很多空间。

所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。

如何遍历一棵树

经典的方法有三种,前序遍历、中序遍历和后序遍历。

  • 前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
  • 中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
  • 后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。

 1 public class BinaryTree {
 2     
 3     /**
 4      * 前序遍历
 5      */
 6     public void preOrder(Node treeNode) {
 7         if (treeNode == null) {
 8             return;
 9         }
10         System.out.print(treeNode.value + " ");
11         preOrder(treeNode.left);
12         preOrder(treeNode.right);
13     }
14 
15     /**
16      * 中序遍历
17      */
18     public void inOrder(Node treeNode) {
19         if (treeNode == null) {
20             return;
21         }
22         inOrder(treeNode.left);
23         System.out.print(treeNode.value + " ");
24         inOrder(treeNode.right);
25     }
26 
27     /**
28      * 后序遍历
29      */
30     public void postOrder(Node treeNode) {
31         if (treeNode == null) {
32             return;
33         }
34         postOrder(treeNode.left);
35         postOrder(treeNode.right);
36         System.out.print(treeNode.value + " ");
37     }
38 
39     /**
40      * 树节点
41      */
42     public static class Node {
43         int value;
44         Node left;
45         Node right;
46 
47         public Node(int value) {
48             this.value = value;
49         }
50 
51         public Node(int value, Node left, Node right) {
52             this.value = value;
53             this.left = left;
54             this.right = right;
55         }
56     }
57 
58 }

什么是二叉查找树

二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的。不过,它不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。它是怎么做到这些的呢?

二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值(中序遍历二叉查找树可以输出有序的数据序列)。

二叉查找树的增删查的实现

1、查询数据:

  • 先取根节点,如果满足则返回根节点。
  • 如果小于根节点则在左子树递归查找,大于则在右子树递归查找。

2、添加数据:

  • 若根节点无数据则将数据放入根节点。
  • 插入数据比节点大且右子树为空,则直接插入到右子节点;如果不为空,则递归遍历右子树。
  • 同理,插入数据比节点小且左子树为空,则直接插入到左子节点;如果不为空,则递归遍历左子树。

3、删除数据:

  • 删除的是叶子节点,只需要将父节点中指向要删除节点的指针置为null。
  • 删除的节点只有一个子节点(左子节点或右子节点),我们只需要将其父节点的指向改为其子节点即可(需要根据大小判断放在左还是右)。
  • 删除的节点有两个子节点,找到该节点右子树中最小的节点,把它替换到删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了)。

二叉查找树的查找、插入操作都比较简单易懂,但是它的删除操作就比较复杂了 。针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。

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

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

这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。

  1 public class BinaryTree {
  2 
  3     /**
  4      * 根节点
  5      */
  6     private Node root;
  7 
  8     /**
  9      * 1、先取根节点,如果满足则返回根节点。
 10      * 2、如果小于根节点则在左子树递归查找,大于则在右子树递归查找。
 11      *
 12      * @param value 查询的值
 13      */
 14     public Node find(int value) {
 15         Node node = root;
 16         while (node != null) {
 17             if (value > node.value) {
 18                 node = node.right;
 19             }
 20             else if (value < node.value) {
 21                 node = node.left;
 22             }
 23             else {
 24                 // value == node.value
 25                 return node;
 26             }
 27         }
 28         return null;
 29     }
 30 
 31     /**
 32      * 1、若根节点无数据则将数据放入根节点。
 33      * 2、插入数据比节点大且右子树为空,则直接插入到右子节点;如果不为空,则递归遍历右子树。
 34      * 3、同理,插入数据比节点小且左子树为空,则直接插入到左子节点;如果不为空,则递归遍历左子树。
 35      *
 36      * @param value 插入的值
 37      */
 38     public void insert(int value) {
 39         if (root == null) {
 40             root = new Node(value);
 41             return;
 42         }
 43         Node node = root;
 44         while (node != null) {
 45             if (value > node.value) {
 46                 if (node.right == null) {
 47                     node.right = new Node(value);
 48                     return;
 49                 }
 50                 node = node.right;
 51             }
 52             else {
 53                 // value <= node.value
 54                 if (node.left == null) {
 55                     node.left = new Node(value);
 56                     return;
 57                 }
 58                 node = node.left;
 59             }
 60         }
 61     }
 62 
 63     /**
 64      * 1、删除的是叶子节点,只需要将父节点中指向要删除节点的指针置为null。
 65      * 2、删除的节点只有一个子节点(左子节点或右子节点),我们只需要将其父节点的指向改为其子节点即可(需要根据大小判断放在左还是右)。
 66      * 3、删除的节点有两个子节点,找到该节点右子树中最小的节点,把它替换到删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了)。
 67      *
 68      * @param value 删除的值
 69      */
 70     public void delete(int value) {
 71         // p指向要删除的节点,初始化指向根节点
 72         Node p = root;
 73         // pp记录的是p的父节点
 74         Node pp = null;
 75         // 找到要删除的节点
 76         while (p != null && p.value != value) {
 77             pp = p;
 78             if (value > p.value) {
 79                 p = p.right;
 80             }
 81             else {
 82                 p = p.left;
 83             }
 84         }
 85         if (p == null) {
 86             // 没有找到要删除的节点
 87             return;
 88         }
 89 
 90         // 要删除的节点有两个子节点,找到右子树的最小节点
 91         if (p.left != null && p.right != null) {
 92             Node minp = p.right;
 93             Node minpp = p;
 94             while (minp.left != null) {
 95                 minpp = minp;
 96                 minp = minp.left;
 97             }
 98             // 将minp的数据替换到p中
 99             p.value = minp.value;
100             p = minp;
101             pp = minpp;
102         }
103 
104         // 删除节点是叶子节点或仅有一个子节点
105         Node child;
106         if (p.left != null) {
107             child = p.left;
108         }
109         else if (p.right != null) {
110             child = p.right;
111         }
112         else {
113             child = null;
114         }
115 
116         if (pp == null) {
117             // 删除的是根节点
118             root = child;
119         }
120         else if (pp.left == p) {
121             pp.left = child;
122         }
123         else {
124             pp.right = child;
125         }
126     }
127 
128     /**
129      * 前序遍历
130      */
131     public void preOrder(Node treeNode) {
132         if (treeNode == null) {
133             return;
134         }
135         System.out.print(treeNode.value + " ");
136         preOrder(treeNode.left);
137         preOrder(treeNode.right);
138     }
139 
140     /**
141      * 中序遍历
142      */
143     public void inOrder(Node treeNode) {
144         if (treeNode == null) {
145             return;
146         }
147         inOrder(treeNode.left);
148         System.out.print(treeNode.value + " ");
149         inOrder(treeNode.right);
150     }
151 
152     /**
153      * 后序遍历
154      */
155     public void postOrder(Node treeNode) {
156         if (treeNode == null) {
157             return;
158         }
159         postOrder(treeNode.left);
160         postOrder(treeNode.right);
161         System.out.print(treeNode.value + " ");
162     }
163 
164     /**
165      * 树节点
166      */
167     public static class Node {
168         int value;
169         Node left;
170         Node right;
171 
172         public Node(int value) {
173             this.value = value;
174         }
175 
176         public Node(int value, Node left, Node right) {
177             this.value = value;
178             this.left = left;
179             this.right = right;
180         }
181     }
182 
183 }
 1 public class BinaryTreeTest {
 2 
 3     public static void main(String[] args) {
 4         //    4
 5         //  2   6
 6         // 1 3 5 7
 7         BinaryTree binaryTree = new BinaryTree();
 8         binaryTree.insert(4);
 9         binaryTree.insert(2);
10         binaryTree.insert(6);
11         binaryTree.insert(1);
12         binaryTree.insert(3);
13         binaryTree.insert(5);
14         binaryTree.insert(7);
15 
16         BinaryTree.Node node = binaryTree.find(4);
17         System.out.println("----- 二叉树前序遍历 -----");
18         binaryTree.preOrder(node);
19         System.out.println("\r\n----- 二叉树中序遍历 -----");
20         binaryTree.inOrder(node);
21         System.out.println("\r\n----- 二叉树后序遍历 -----");
22         binaryTree.postOrder(node);
23 
24         binaryTree.delete(4);
25     }
26 
27 }

二叉查找树中的重复数据如何处理

1、节点扩展:比较容易。

二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。

2、当成新节点处理:不好理解,不过更加优雅。

每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理

 

 

当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。

 

 

对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。

散列表已经这么强了,为什么还要有二叉查找树(二叉查找树相对于散列表的优势)

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

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

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

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

5、最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。

posted @ 2021-03-15 11:32  被猪附身的人  阅读(77)  评论(0编辑  收藏  举报