Skip to content

Commit

Permalink
feat: 添加二叉树的笔记
Browse files Browse the repository at this point in the history
  • Loading branch information
yangjin committed Oct 27, 2019
1 parent aa3ff73 commit c686b44
Show file tree
Hide file tree
Showing 11 changed files with 293 additions and 1 deletion.
13 changes: 12 additions & 1 deletion docs/.vuepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ module.exports = {
sidebarDepth: 2,
children: [
['排序/排序', '排序算法介绍'],
'排序/冒泡、插入和选择排序'
'排序/冒泡、插入和选择排序',
'排序/归并排序和快速排序',
'排序/桶排序、计数排序和基数排序',
'排序/排序优化'
]
}
],
Expand Down Expand Up @@ -96,6 +99,14 @@ module.exports = {
'队列/练习'
]
},
{
title: '跳表',
collapsable: false,
sidebarDepth: 2,
children: [
'跳表/跳表'
]
},
],
'/javaScript/': [
{
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
113 changes: 113 additions & 0 deletions docs/dataStructure/二叉树/二叉查找树.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 二叉查找树

再看一种特殊的二叉树,二叉查找数。最大的特点是支持动态数据集合的快速插入、删除、查找操作。散列表也支持这些操作,而且散列表的这些操作更加高效,时间复杂度是 O(1)。既然有了高效的散列表,是不是可以用散列表替换来替换二叉树呢?有哪些地方散列表做不了,必须要用二叉树来做?

## 什么是二叉查找数

二叉查找树是二叉树中常见的一种类型,也叫二叉搜素树,是为了快速查找而产生的。

**二叉查找树就是:在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都要大于这个节点的值。**

![binarySearchTree](../../.vuepress/public/images/dataStructure-tree-binarySearchTree.png)

## 二叉查找数的查找、插入和删除

二叉树支持快速的查找、插入和删除,看这 3 个操作时如何实现的。

### 查找

在二叉查找树中查找一个节点,先取根节点,如果等于要查找节点的值,那就返回;如果要查找节点的值小于根节点的值,那就在左子树中递归查找,如果要查找节点的值大于根节点的值,那就在右子树中递归。

### 插入

插入类似于查找,**新插入的节点一般在叶子节点上**

在二叉树中插入节点,所以也是从根节点开始,依次比较要插入节点和节点的大小关系。如果要插入节点的值比节点的值大,并且节点的右子树为空,那就将节点要插入节点直接插入到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入的位置。同理,如果要插入节点的值比节点的值小,并且节点的左子树为空,就将节点要插入节点插入到左子节点的位置;如果不为空,那就再遍历左子树,查找插入位置。

### 删除

二叉树查找和插入都比较简单,但是它的删除复杂一些。**针对删除节点的子节点个数不同**,需要分 3 中情况来处理:

1. 要删除的节点没有子节点:只需要将父节点中,指向要删除结点的指针值为 null。
2. 要删除的节点只有一个子节点(只有左子节点或者右子节点):只需要更新父节点中指向要删除节点的指针,让它指向要删除节点的子节点就可以了。
3. 要删除的节点有两个子节点:要找到这个节点的右子树中最小的节点,把它替换到要删除的节点上,然后删除掉这个最小节点。替换之后,就变成删除这个最小节点了。值得注意的是最小节点肯定没有左子节点(如果有左子节点,那就不是最小节点了)。这个右子树中最小节点和要删除节点有着相同的特点,用它替换,才可以满足二叉查找数的定义。

### 其他操作

除了插入、删除、查找操作之外,二叉查找树还支持**快速查找最大节点和最小节点、前驱节点和后继节点**。还有一个重要的特性,**中序遍历二叉查找树可以输出有序的数据序列,时间复杂度是 O(n),非常高效**。因此,二叉查找树也叫二叉排序树。

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

前面的操作中,都是假设树中的节点存储的是数字。但是实际开发中,在二叉查找树中存储的是一个包含很多字段的对象,**利用其中的某个字段作为键值(key)来构建二叉查找树**。其它字段叫作卫星数据。

那如果存储的数据中,有两个对象的键值相同,那怎么处理?

这里有两种处理办法:

1. 把值相同的数据都存储在同一个节点上

每个节点不仅会存储一个数据,因此可以通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。

2. 存储在不同节点

插入的时候,如果碰到一个节点的值与要插入的数据值相同,就将这个要插入的数据放到这个节点的右子树中,**也就是把新插入的数据当作当于这个节点的值来处理**

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

删除的时候,也需要先先找每个要删除的节点,然后再按照前面讲的删除操作的方法,依次删除。

## 二叉树的时间复杂度分析

不同结构的二叉查找树,它们的查找、插入、删除操作的执行效率都是不一样的。

![binarySearchTree](../../.vuepress/public/images/dataStructure-tree-binarySearchTree2.png)

如图第一种,最糟糕的情况下二叉查找树已经退化为链表,此时查找的复杂度为 **O(n)**;那么最好的情况复杂度又是呢?比如二叉查找树正好是完全二叉树(或者满二叉树)。

通过代码、图来看,插入、删除和查找,**时间复杂度其实都是跟树的高度成正比,也就是 O(height)**。所以问题就变成怎么求一棵包含 n 个节点的完全二叉树的高度?

树的高度等于树的层数减 1,为了方便计算,用层来求解:

对于完全二叉树,第一层有 1 个节点,第二层有 2 个节点,...,第 k 层有 2^(k-1) 个节点。

不过,完全二叉树的最后一层就不满足上面的规律了,假设 L 为最大层,那么它包含的节点个数在 1~2^(L-1) 之间。

所有节点相加起来,得到总节点个数 n,它满足这一关系:

```
1 + 2 + 4 + ... + 2^(L-2) + 1 <= n <= 1 + 2 + 4 + ... + 2^(L-2) + 2^(L-1)
```

根据等比数列求和,得到:

```
2^(L-1) <= n <= 2^L-1
```

**所以 L 的范围是**
```
log2(n + 1) <= L <= log2n + 1
```

即完全二叉树的层数小于等于 log2n + 1,高度小于等于 **log2n**

显然,**极度不平衡的二叉查找树,查找性能是不能满足要求的**。需要构建一种不管怎么插入、删除数据都能保持任意节点的左右子树都比较平衡的二叉查找树——**平衡二叉查找数**,它的高度接近 logn,所以插入、删除和查找的时间复杂度都比较稳定,为 O(logn)。

## 总结

现在再看看开头的问题,有了散列表,为什么还要使用二叉查找树?

主要有这几方面的原因:

1. 散列表的数据是**无序的**,要想输出有序的数据,需要先进行排列。而对于二叉查找树来说,通过中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。
2. 散列表**扩容耗时比较多,而且遇到散列冲突时,性能不稳定**。尽管二叉查找树的性能也不稳定,但是工程中,最常用的**平衡二叉查找树**性能非常稳定,时间复杂度稳定在 O(logn)。
3. 笼统的说,尽管散列表的查找等操作都是常量级的,但是因为哈希冲突,**这个常量不一定比 logn 小**。所以实际的查找速度可能不一定比 O(logn) 快,加上哈希函数的耗时,也不一定比**平衡二叉查找树**效率高。
4. 散列表的构造比二叉查找树要复杂,考虑的东西很多。比如散列函数设计、冲突解决、扩容、缩容等。而平衡二叉树查找树只考虑平衡性这一个问题,而且这个问题的方案比较成熟、固定。
5. 散列表为例避免过多的散列冲突,散列表转载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。

综合比较来说,平衡二叉查找数在某些方面的性能优于散列表。所以,这两者的存在并不冲突,实际开发,要根据需求来选择使用哪种。

树,二叉树、满二叉树、完全二叉树都是树的结构概念。

二叉查找树是最常用的一种二叉树,它要求任意一个节点,其左子树中的每个节点的值都小于这个节点的值,而右子树中每个节点的值都大于这个节点的值,那这种二叉树就是二叉查找数。**也就是它本身是二叉树,但是节点数据要满足前面的特点**

100 changes: 100 additions & 0 deletions docs/dataStructure/二叉树/二叉树.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# 二叉树

二叉树是一种非线性表结构,二叉树有哪几种存储方式?什么样的二叉树适合用数组来存储?

## 树(Tree)

这里的树和生活中的“树”很像,先看看几棵树:

![tree](../../.vuepress/public/images/dataStructure-tree.png)

树的常用概念有:

1. 节点:树中的每个元素称为节点;
2. 父子关系:相连的相邻节点之间的关系叫作父子关系;
3. 父节点:A 节点就是 B 节点的父节点;
4. 子节点:B 节点就是 A 节点的子节点;
5. 根节点:没有父节点的节点,也就是图中的 A;
6. 叶子节点(或者叶节点):没有子节点的节点,也就是图中的 G、H、I、J、K;
7. 节点的高度:**节点到叶子节点**的最长路径(边数);
8. 节点的深度:**根节点到这个节点**经历的边的个数;
9. 节点的层数:节点的深度 **+ 1**
10. 树的高度:根节点的高度。

![tree-example](../../.vuepress/public/images/dataStructure-tree-example.png)

高度就类似楼层一样,从下往上,起点记为 0;而深度类似水的深度,从上往下,起点也记为 0;层数跟深度类似,不过起点记为 1。

## 二叉树

树的结构中常用的还是二叉树。

1. 二叉树

二叉树的每个节点**最多**有两个子节点,分别是左子节点和右子节点。注意这里是最多也就是并不要求都有两个子节点。

2. 满二叉树

二叉树中,叶子节点都在底层,并且除了叶子节点之外,每个节点**都有**左右两个子节点,这种二叉树就叫作满二叉树。

3. 完全二叉树

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

![binaryTree](../../.vuepress/public/images/dataStructure-tree-binaryTree.png)

<nx-tip text="完全二叉树的特征并不是很明显,为什么要特意说明呢?为什么要求它最后一层的叶子节点靠左排列?它的定义目的在哪?"/>

要理解二叉树的定义,从二叉树的存储说起。

## 二叉树的存储

存储二叉树有两种方法:链式存储法和顺序存储法。

1. 链式存储法

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

2. 顺序存储法

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

这个时候,如果是完全二叉树,它仅仅浪费一个下标为 0 的存储位置,而非完全二叉树,其实就会浪费比较多的数组存储空间。

**所以,如果一棵二叉树是完全二叉树,那么使用数组存储是最节省内存的一种方式。**

## 二叉树的遍历

如何将所有节点都遍历打印出来?经典的方法有 3 种:**前序遍历****中序遍历****后序遍历**

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

二叉树的前、中、后序遍历就是一个递归的过程。为了写递归代码,需要先写出递推公式。而写递推公式,关键就是把要解决的问题 a,分解成子问题 b 和 c,再看如何利用 b,c 来解决 A。

```
// 前序遍历的递推公式
preOrder(r) = print r -> preOrder(r->left) -> preOrder(r->right)
// 前序遍历的递推公式
preOrder(r) = preOrder(r->left) -> print r -> preOrder(r->right)
// 前序遍历的递推公式
preOrder(r) = preOrder(r->left) -> preOrder(r->right) -> print r
```

根据递推公式,用代码实现为:

```js
```

### 时间复杂度分析

从前、中、后序遍历的顺序图中,每个节点最多被访问两次,所以遍历操作的时间复杂度跟节点的个数 n 成正比,也就是说时间复杂度为 O(n)。

## 总结

回答开头的问题,二叉树有两种存储方法:用链表存储和用数组顺序存储。数组顺序存储的方式比较适合完全二叉树,其他类型的二叉树用数组会比较浪费存储空间。

前、中、后序遍历都属于按深度遍历,另外还有一种按层遍历的方式。
68 changes: 68 additions & 0 deletions docs/dataStructure/二叉树/红黑树.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 红黑树

二叉查找树是最常用的一种二叉树,但是在频繁的动态更新过程中,可能出现树的高度远大于 log2n,从而导致性能下降。极端情况下,二叉树退化成链表,此时时间复杂度退化为 O(n)。

**要解决这个问题,就需要设计一种平衡二叉查找树**。为什么实际开发中都喜欢用红黑树,而不是其他平衡二叉查找树?

## 什么是平衡二叉树查找树

1. 平衡二叉树

平衡二叉树的严格定义是:二叉树中任意一个节点的左右子树的高度相差不能大于 1。完全二叉树、满二叉树都属于平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。

2. 平衡二叉查找树

不仅要满足平衡二叉树的定义,还要满足二叉查找树的定义。最先被发明的平衡二叉查找树是 AVL 树,它严格满足平衡二叉查找树的定义,是一种高度平衡的二叉查找树。

而实际上,很多平衡二叉查找树其实**没有严格符合**上面的定义:二叉树中任意一个节点的左右子树的高度相差不能大于 1,比如下面的红黑树。

**平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来“比较对称”、“比较平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度想读低一些,相应的插入、查找、查询等操作的效率高一些**。它是为了解决二叉查找树频繁插入、删除之后,时间复杂度的退化问题。

设计一棵平衡二叉查找树,只要树的高度不比 log2n 大太多(依然是对数量级),尽管不满足严格的平衡二叉查找树的定义,但仍然可以说是一棵合格的平衡二叉查找树。

## 红黑树

提到平衡二叉查找树,听到的基本是红黑树,甚至会默认平衡二叉查找树就是红黑树。

### 定义

红黑树(Red-Black Tree,R-B Tree)是一种不严格的平衡二叉查找树。顾名思义,红黑树中的节点,一类被标记为黑色,一类被标记位红色。除此之外,一棵红黑树还要满足一下要求:

1. 根节点是黑色的;
2. 每个叶子节点都是黑色的空节点(NIL)。也就是说,叶子节点不存储数据;
3. 任何相邻的节点都不能同时为**红色**。也就是说,红色节点是被黑色节点隔开的;
4. 每个节点,从该节点到其可到达的叶子节点的所有路径,都包含相同数目的黑色节点。

第二点稍微有些奇怪,它主要是为了简化红黑树的代码实现设置的。暂不考虑这点,忽略掉之后红黑树的图例为:

![Red-Black Tree](../../.vuepress/public/images/dataStructure-tree-RebBlackTree.png)

### 为什么说红黑树近似平衡

平衡二叉查找树是为了解决二叉查找树频繁插入、删除之后,时间复杂度的退化问题,**“平衡”等价为性能不退化,“近似平衡”就等价为性能不会退化太严重**

二叉查找数的操作性能和高度成正比。一棵及其平衡的二叉树(满二叉树或完全二叉树)的高度约为 log2n,所以要证明红黑树近似平衡,只要分析说明红黑树的高度近似为 log2n 就好。

1. 去掉红色节点之后,只包含单纯黑色节点的红黑树的高度不超过 log2n。

把红黑树中的红色节点去掉后,红黑树就变成三叉树或者四叉树。红黑树的定义要求每个节点,从该节点到其可到达的叶子节点的所有路径,都包含**相同数目**的黑色节点。所以这个时候,从三叉树或者四叉树中取出某些节点放到叶子节点的位置,**它就变成完全二叉树**

而前面说到,完全二叉树的高度近似为 log2n,而三叉树或四叉树的高度要小于完全二叉树,所以去掉红色节点之后,只包含单纯黑色节点的红黑树的高度不超过 log2n。

2. 加回红色节点之后,高度近似为 2log2n。

在红黑树中,红色节点不能相邻,也就是说,有一个红色节点就要**至少有一个**黑色节点,将它和其他红色节点隔开。

从上面知道,红黑树中包含黑色节点最多的路径不超过 log2n,所以加入红色节点之后,最长的路径不会超过 2log2n,也就说**红黑树高度近似为 2log2n**

## 总结

为什么实际开发中都喜欢用红黑树,而不是其他平衡二叉查找树?

AVL 树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL 树**为了维持这种高度的平衡,就要付出更多的代价**。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高了。

红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。

红黑树的插入、删除、查找各种操作的性能都比较平衡,对于实际工程应用来说,为了支撑这种工业级的应用,更倾向于这种性能稳定的平衡二叉查找树。

不过最后要说的是,红黑树的代码实现难度有些高,自己实现的话,更倾向于用跳表代替。

0 comments on commit c686b44

Please sign in to comment.