24/100
发布文章
加粗
斜体
标题
删除线
无序
有序
待办
引用
代码块
图片
视频
表格
超链接
投票
导入
导出
保存
撤销
重做
模版
使用富文本编辑器
目录
发文助手
语法说明
## 树
树是由结点和边组成的,不存在环的一种数据结构。
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711234953664.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQ0OTY5NjQz,size_16,color_FFFFFF,t_70)
树满足递归定义的特性。也就是说,如果一个数据结构是树结构,那么剔除掉根结点后,得到的若干个子结构也是树,通常称作子树。
1)A 结点是 B 结点和 C 结点的上级,则 A 就是 B 和 C 的父结点,B 和 C 是 A 的子结点。
2)B 和 C 同时是 A 的“孩子”,则可以称 B 和 C 互为兄弟结点。
3)A 没有父结点,则可以称 A 为根结点。
4)G、H、I、F 结点都没有子结点,则称 G、H、I、F 为叶子结点。
## 二叉树
1)满二叉树,定义为除了叶子结点外,所有结点都有 2 个子结点。
2)完全二叉树,定义为除了最后一层以外,其他层的结点个数都达到最大,并且最后一层的叶子结点都靠左排列。
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235029375.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQ0OTY5NjQz,size_16,color_FFFFFF,t_70)
存储二叉树有两种办法,一种是基于指针的链式存储法,另一种是基于数组的顺序存储法。
1)链式存储法,也就是像链表一样,每个结点有三个字段,一个存储数据,另外两个分别存放指向左右子结点的指针,如下图所示:
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235058221.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQ0OTY5NjQz,size_16,color_FFFFFF,t_70)
2)顺序存储法,就是按照规律把结点存放在数组里,如下图所示,为了方便计算,我们会约定把根结点放在下标为 1 的位置。随后,B 结点存放在下标为 2 的位置,C 结点存放在下标为 3 的位置,依次类推。
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235126628.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQ0OTY5NjQz,size_16,color_FFFFFF,t_70)
根据这种存储方法,我们可以发现如果结点 X 的下标为 i,那么 X 的左子结点总是存放在 2 * i 的位置,X 的右子结点总是存放在 2 * i + 1 的位置。
之所以称为完全二叉树,是从存储空间利用效率的视角来看的。对于一棵完全二叉树而言,仅仅浪费了下标为 0 的存储位置。而如果是一棵非完全二叉树,则会浪费大量的存储空间。
**树的基本操作**
1)前序遍历,对树中的任意结点来说,先打印这个结点,然后前序遍历它的左子树,最后前序遍历它的右子树。
2)中序遍历,对树中的任意结点来说,先中序遍历它的左子树,然后打印这个结点,最后中序遍历它的右子树。
3)后序遍历,对树中的任意结点来说,先后序遍历它的左子树,然后后序遍历它的右子树,最后打印它本身。
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235152135.png)
```java
// 先序遍历
publicstaticvoidpreOrderTraverse(Node node){
if(node ==null)
return;
System.out.print(node.data +" ");
preOrderTraverse(node.left);
preOrderTraverse(node.right);
}
// 中序遍历
publicstaticvoidinOrderTraverse(Node node){
if(node ==null)
return;
inOrderTraverse(node.left);
System.out.print(node.data +" ");
inOrderTraverse(node.right);
}
// 后序遍历
publicstaticvoidpostOrderTraverse(Node node){
if(node ==null)
return;
postOrderTraverse(node.left);
postOrderTraverse(node.right);
System.out.print(node.data +" ");
}
```
二叉树遍历过程中,每个结点都被访问了一次,其时间复杂度是 O(n)。
执行增加和删除数据的操作时,我们只需要通过指针建立连接关系就可以了。
对于没有任何特殊性质的二叉树而言,抛开遍历的时间复杂度以外,真正执行增加和删除操作的时间复杂度是 O(1)。树数据的查找操作和链表一样,都需要遍历每一个数据去判断,所以时间复杂度是 O(n)。
**二叉查找树的特性**
二叉查找树(也称作二叉搜索树)具备以下几个的特性:
1)在二叉查找树中的任意一个结点,其左子树中的每个结点的值,都要小于这个结点的值。
2)在二叉查找树中的任意一个结点,其右子树中每个结点的值,都要大于这个结点的值。
3)在二叉查找树中,会尽可能规避两个结点数值相等的情况。
**二叉查找树的查找操作**
在利用二叉查找树执行查找操作时,我们可以进行以下判断:
1)首先判断根结点是否等于要查找的数据,如果是就返回。
2)如果根结点大于要查找的数据,就在左子树中递归执行查找动作,直到叶子结点。
3)如果根结点小于要查找的数据,就在右子树中递归执行查找动作,直到叶子结点。
这样的“二分查找”所消耗的时间复杂度就可以降低为 O(logn)。
**二叉查找树的插入操作**
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235308637.gif)
二叉查找树插入数据的时间复杂度是 O(logn)。但这并不意味着它比普通二叉树要复杂。原因在于这里的时间复杂度更多是消耗在了遍历数据去找到查找位置上,真正执行插入动作的时间复杂度仍然是 O(1)。
**二叉查找树的删除操作**
情况一,如果要删除的结点是某个叶子结点,则直接删除,将其父结点指针指向 null 即可。
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235358544.gif)
情况二,如果要删除的结点只有一个子结点,只需要将其父结点指向的子结点的指针换成其子结点的指针即可。
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235427928.gif)
情况三,如果要删除的结点有两个子结点,则有两种可行的操作方式。
第一种,找到这个结点的左子树中最大的结点,替换要删除的结点。
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235454561.gif)
第二种,找到这个结点的右子树中最小的结点,替换要删除的结点。
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235519729.gif)
**树的案例**
例1:
输入一个字符串,判断它在已有的字符串集合中是否出现过?(假设集合中没有某个字符串与另一个字符串拥有共同前缀且完全包含的特殊情况,例如 deep 和 dee。)如,已有字符串集合包含 6 个字符串分别为,cat, car, city, dog,door, deep。输入 cat,输出 true;输入 home,输出 false。
我们假设采用最暴力的办法,估算一下时间复杂度。假设字符串集合包含了 n 个字符串,其中的字符串平均长度为 m。那么新来的一个字符串,需要与每个字符串的每个字符进行匹配。则时间复杂度为 O(nm)。
但在 nm 的复杂度中,显然存在很多的无效匹配。例如,输入 home 时,6 个字符串都没有 h 开头的,则不需要进行后续的匹配。因此,如果可以通过对字符前缀进行处理,就可以最大限度地减少无谓的字符串比较,从而提高查询效率。这就是“用空间换时间”的思想,再利用共同前缀来提高查询效率。
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235620251.gif)
例2:
给定一棵树,按照层次顺序遍历并打印这棵树。例如:
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235716674.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQ0OTY5NjQz,size_16,color_FFFFFF,t_70)
```java
publicstaticvoidlevelTraverse(Node root){
if(root ==null){
return;
}
LinkedList<Node> queue =newLinkedList<Node>();
Node current =null;
queue.offer(root);// 根节点入队
while(!queue.isEmpty()){// 只要队列中有元素,就可以一直执行,非常巧妙地利用了队列的特性
current = queue.poll();// 出队队头元素
System.out.print("-->"+ current.data);
// 左子树不为空,入队
if(current.leftChild !=null)
queue.offer(current.leftChild);
// 右子树不为空,入队
if(current.rightChild !=null)
queue.offer(current.rightChild);
}
}
```
## 哈希表
哈希表名字源于 Hash,也可以叫作散列表。哈希表是一种特殊的数据结构,它与数组、链表以及树等我们之前学过的数据结构相比,有很明显的区别。
1)线性表中的栈和队列对增删有严格要求,它们会更关注数据的顺序。
2)数组和字符串需要保持数据类型的统一,并且在基于索引的查找上会更有优势。
3)树的优势则体现在数据的层次结构上。
但它们普遍都存在这样的缺陷,那就是数据数值条件的查找,都需要对全部数据或者部分数据进行遍历。
hash表的增删改查时间复杂度都是o(1)
**哈希表的核心思想**
哈希表的设计采用了函数映射的思想,将记录的存储位置与记录的关键字关联起来。这样的设计方式,能够快速定位到想要查找的记录,而且不需要与表中存在的记录的关键字比较后再来进行查找。
如果有一种方法,可以实现“地址 = f (关键字)”的映射关系,那么就可以快速完成基于数据的数值的查找了。这就是哈希表的核心思想。
**如何设计哈希函数**
第一,直接定制法
哈希函数为关键字到地址的线性函数。如,H (key) = a*key + b。 这里,a 和 b 是设置好的常数。
第二,数字分析法
假设关键字集合中的每个关键字 key 都是由 s 位数字组成(k1,k2,…,Ks),并从中提取分布均匀的若干位组成哈希地址。上面张一、张二、张三、张四的手机号信息存储,就是使用的这种方法。
第三,平方取中法
如果关键字的每一位都有某些数字重复出现,并且频率很高,我们就可以先求关键字的平方值,通过平方扩大差异,然后取中间几位作为最终存储地址。
第四,折叠法
如果关键字的位数很多,可以将关键字分割为几个等长的部分,取它们的叠加和的值(舍去进位)作为哈希地址。
第五,除留余数法
预先设置一个数 p,然后对关键字进行取余运算。即地址为 key mod p。
**如何解决哈希冲突**
第一,开放定址法
即当一个关键字和另一个关键字发生冲突时,使用某种探测技术在哈希表中形成一个探测序列,然后沿着这个探测序列依次查找下去。当碰到一个空的单元时,则插入其中。
常用的探测方法是线性探测法:
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235843430.gif)
第二,链地址法
将哈希地址相同的记录存储在一张线性链表中。
例如,有一组关键字 {12,13,25,23,38,84,6,91,34},采用的哈希函数为 key mod 11。如下图所示:
![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200711235913596.gif)
**哈希表的基本操作**
在很多高级语言中,哈希函数、哈希冲突都已经在底层完成了黑盒化处理,是不需要开发者自己设计的。也就是说,哈希表完成了关键字到地址的映射,可以在常数级时间复杂度内通过关键字查找到数据。
实现细节,比如用了哪个哈希函数,用了什么冲突处理,甚至某个数据记录的哈希地址是多少,都是不需要开发者关注的。
**哈希表的案例**
假设有一个在线系统,可以实时接收用户提交的字符串型关键字,并实时返回给用户累积至今这个关键字被提交的次数。
例如,用户输入"abc",系统返回 1。用户再输入"jk",系统返回 1。用户再输入"xyz",系统返回 1。用户再输入"abc",系统返回 2。用户再输入"abc",系统返回 3。
**一种解决方法是**,用一个数组保存用户提交过的所有关键字。当接收到一个新的关键字后,插入到数组中,并且统计这个关键字出现的次数。
根据数组的知识可以计算出,插入到最后的动作,时间复杂度是 O(1)。但统计出现次数必须要全部数据遍历一遍,时间复杂度是 O(n)。随着数据越来越多,这个在线系统的处理时间将会越来越长。显然,这不是一个好的方法。
**如果采用哈希表**,则可以利用哈希表新增、查找的常数级时间复杂度,在 O(1) 时间复杂度内完成响应。预先定义好哈希表后(可以采用 Map < String, Integer > d = new HashMap <> (); )对于关键字(用变量 key_str 保存),判断 d 中是否存在 key_str 的记录。
如果存在,则把它对应的value(用来记录出现的频次)加 1;
如果不存在,则把它添加到 d 中,对应的 value 赋值为 1。最后,打印处 key_str 对应的 value,即累积出现的频次。
```java
if(d.containsKey(key_str){
d.put(key_str, d.get(key_str)+1);
}
else{
d.put(key_str,1);
}
System.out.println(d.get(key_str));
```
树
树是由结点和边组成的,不存在环的一种数据结构。
树满足递归定义的特性。也就是说,如果一个数据结构是树结构,那么剔除掉根结点后,得到的若干个子结构也是树,通常称作子树。
1)A 结点是 B 结点和 C 结点的上级,则 A 就是 B 和 C 的父结点,B 和 C 是 A 的子结点。
2)B 和 C 同时是 A 的“孩子”,则可以称 B 和 C 互为兄弟结点。
3)A 没有父结点,则可以称 A 为根结点。
4)G、H、I、F 结点都没有子结点,则称 G、H、I、F 为叶子结点。
二叉树
1)满二叉树,定义为除了叶子结点外,所有结点都有 2 个子结点。
2)完全二叉树,定义为除了最后一层以外,其他层的结点个数都达到最大,并且最后一层的叶子结点都靠左排列。
存储二叉树有两种办法,一种是基于指针的链式存储法,另一种是基于数组的顺序存储法。
1)链式存储法,也就是像链表一样,每个结点有三个字段,一个存储数据,另外两个分别存放指向左右子结点的指针,如下图所示:
2)顺序存储法,就是按照规律把结点存放在数组里,如下图所示,为了方便计算,我们会约定把根结点放在下标为 1 的位置。随后,B 结点存放在下标为 2 的位置,C 结点存放在下标为 3 的位置,依次类推。
根据这种存储方法,我们可以发现如果结点 X 的下标为 i,那么 X 的左子结点总是存放在 2 * i 的位置,X 的右子结点总是存放在 2 * i + 1 的位置。
之所以称为完全二叉树,是从存储空间利用效率的视角来看的。对于一棵完全二叉树而言,仅仅浪费了下标为 0 的存储位置。而如果是一棵非完全二叉树,则会浪费大量的存储空间。
树的基本操作
1)前序遍历,对树中的任意结点来说,先打印这个结点,然后前序遍历它的左子树,最后前序遍历它的右子树。
2)中序遍历,对树中的任意结点来说,先中序遍历它的左子树,然后打印这个结点,最后中序遍历它的右子树。
3)后序遍历,对树中的任意结点来说,先后序遍历它的左子树,然后后序遍历它的右子树,最后打印它本身。
// 先序遍历 public static void preOrderTraverse(Node node) { if (node == null) return; System.out.print(node.data + " "); preOrderTraverse(node.left); preOrderTraverse(node.right); } // 中序遍历 public static void inOrderTraverse(Node node) { if (node == null) return; inOrderTraverse(node.left); System.out.print(node.data + " "); inOrderTraverse(node.right); } // 后序遍历 public static void postOrderTraverse(Node node) { if (node == null) return; postOrderTraverse(node.left); postOrderTraverse(node.right); System.out.print(node.data + " "); }
二叉树遍历过程中,每个结点都被访问了一次,其时间复杂度是 O(n)。
执行增加和删除数据的操作时,我们只需要通过指针建立连接关系就可以了。
对于没有任何特殊性质的二叉树而言,抛开遍历的时间复杂度以外,真正执行增加和删除操作的时间复杂度是 O(1)。树数据的查找操作和链表一样,都需要遍历每一个数据去判断,所以时间复杂度是 O(n)。
二叉查找树的特性
二叉查找树(也称作二叉搜索树)具备以下几个的特性:
1)在二叉查找树中的任意一个结点,其左子树中的每个结点的值,都要小于这个结点的值。
2)在二叉查找树中的任意一个结点,其右子树中每个结点的值,都要大于这个结点的值。
3)在二叉查找树中,会尽可能规避两个结点数值相等的情况。
二叉查找树的查找操作
在利用二叉查找树执行查找操作时,我们可以进行以下判断:
1)首先判断根结点是否等于要查找的数据,如果是就返回。
2)如果根结点大于要查找的数据,就在左子树中递归执行查找动作,直到叶子结点。
3)如果根结点小于要查找的数据,就在右子树中递归执行查找动作,直到叶子结点。
这样的“二分查找”所消耗的时间复杂度就可以降低为 O(logn)。
二叉查找树的插入操作
二叉查找树插入数据的时间复杂度是 O(logn)。但这并不意味着它比普通二叉树要复杂。原因在于这里的时间复杂度更多是消耗在了遍历数据去找到查找位置上,真正执行插入动作的时间复杂度仍然是 O(1)。
二叉查找树的删除操作
情况一,如果要删除的结点是某个叶子结点,则直接删除,将其父结点指针指向 null 即可。
情况二,如果要删除的结点只有一个子结点,只需要将其父结点指向的子结点的指针换成其子结点的指针即可。
情况三,如果要删除的结点有两个子结点,则有两种可行的操作方式。
第一种,找到这个结点的左子树中最大的结点,替换要删除的结点。
第二种,找到这个结点的右子树中最小的结点,替换要删除的结点。
树的案例
例1:
输入一个字符串,判断它在已有的字符串集合中是否出现过?(假设集合中没有某个字符串与另一个字符串拥有共同前缀且完全包含的特殊情况,例如 deep 和 dee。)如,已有字符串集合包含 6 个字符串分别为,cat, car, city, dog,door, deep。输入 cat,输出 true;输入 home,输出 false。
我们假设采用最暴力的办法,估算一下时间复杂度。假设字符串集合包含了 n 个字符串,其中的字符串平均长度为 m。那么新来的一个字符串,需要与每个字符串的每个字符进行匹配。则时间复杂度为 O(nm)。
但在 nm 的复杂度中,显然存在很多的无效匹配。例如,输入 home 时,6 个字符串都没有 h 开头的,则不需要进行后续的匹配。因此,如果可以通过对字符前缀进行处理,就可以最大限度地减少无谓的字符串比较,从而提高查询效率。这就是“用空间换时间”的思想,再利用共同前缀来提高查询效率。
例2:
给定一棵树,按照层次顺序遍历并打印这棵树。例如:
public static void levelTraverse(Node root) { if (root == null) { return; } LinkedList<Node> queue = new LinkedList<Node>(); Node current = null; queue.offer(root); // 根节点入队 while (!queue.isEmpty()) { // 只要队列中有元素,就可以一直执行,非常巧妙地利用了队列的特性 current = queue.poll(); // 出队队头元素 System.out.print("-->" + current.data); // 左子树不为空,入队 if (current.leftChild != null) queue.offer(current.leftChild); // 右子树不为空,入队 if (current.rightChild != null) queue.offer(current.rightChild); } }
哈希表
哈希表名字源于 Hash,也可以叫作散列表。哈希表是一种特殊的数据结构,它与数组、链表以及树等我们之前学过的数据结构相比,有很明显的区别。
1)线性表中的栈和队列对增删有严格要求,它们会更关注数据的顺序。
2)数组和字符串需要保持数据类型的统一,并且在基于索引的查找上会更有优势。
3)树的优势则体现在数据的层次结构上。
但它们普遍都存在这样的缺陷,那就是数据数值条件的查找,都需要对全部数据或者部分数据进行遍历。
hash表的增删改查时间复杂度都是o(1)
哈希表的核心思想
哈希表的设计采用了函数映射的思想,将记录的存储位置与记录的关键字关联起来。这样的设计方式,能够快速定位到想要查找的记录,而且不需要与表中存在的记录的关键字比较后再来进行查找。
如果有一种方法,可以实现“地址 = f (关键字)”的映射关系,那么就可以快速完成基于数据的数值的查找了。这就是哈希表的核心思想。
如何设计哈希函数
第一,直接定制法
哈希函数为关键字到地址的线性函数。如,H (key) = a*key + b。 这里,a 和 b 是设置好的常数。
第二,数字分析法
假设关键字集合中的每个关键字 key 都是由 s 位数字组成(k1,k2,…,Ks),并从中提取分布均匀的若干位组成哈希地址。上面张一、张二、张三、张四的手机号信息存储,就是使用的这种方法。
第三,平方取中法
如果关键字的每一位都有某些数字重复出现,并且频率很高,我们就可以先求关键字的平方值,通过平方扩大差异,然后取中间几位作为最终存储地址。
第四,折叠法
如果关键字的位数很多,可以将关键字分割为几个等长的部分,取它们的叠加和的值(舍去进位)作为哈希地址。
第五,除留余数法
预先设置一个数 p,然后对关键字进行取余运算。即地址为 key mod p。
如何解决哈希冲突
第一,开放定址法
即当一个关键字和另一个关键字发生冲突时,使用某种探测技术在哈希表中形成一个探测序列,然后沿着这个探测序列依次查找下去。当碰到一个空的单元时,则插入其中。
常用的探测方法是线性探测法:
第二,链地址法
将哈希地址相同的记录存储在一张线性链表中。
例如,有一组关键字 {12,13,25,23,38,84,6,91,34},采用的哈希函数为 key mod 11。如下图所示:
哈希表的基本操作
在很多高级语言中,哈希函数、哈希冲突都已经在底层完成了黑盒化处理,是不需要开发者自己设计的。也就是说,哈希表完成了关键字到地址的映射,可以在常数级时间复杂度内通过关键字查找到数据。
实现细节,比如用了哪个哈希函数,用了什么冲突处理,甚至某个数据记录的哈希地址是多少,都是不需要开发者关注的。
哈希表的案例
假设有一个在线系统,可以实时接收用户提交的字符串型关键字,并实时返回给用户累积至今这个关键字被提交的次数。
例如,用户输入"abc",系统返回 1。用户再输入"jk",系统返回 1。用户再输入"xyz",系统返回 1。用户再输入"abc",系统返回 2。用户再输入"abc",系统返回 3。
一种解决方法是,用一个数组保存用户提交过的所有关键字。当接收到一个新的关键字后,插入到数组中,并且统计这个关键字出现的次数。
根据数组的知识可以计算出,插入到最后的动作,时间复杂度是 O(1)。但统计出现次数必须要全部数据遍历一遍,时间复杂度是 O(n)。随着数据越来越多,这个在线系统的处理时间将会越来越长。显然,这不是一个好的方法。
如果采用哈希表,则可以利用哈希表新增、查找的常数级时间复杂度,在 O(1) 时间复杂度内完成响应。预先定义好哈希表后(可以采用 Map < String, Integer > d = new HashMap <> (); )对于关键字(用变量 key_str 保存),判断 d 中是否存在 key_str 的记录。
如果存在,则把它对应的value(用来记录出现的频次)加 1;
如果不存在,则把它添加到 d 中,对应的 value 赋值为 1。最后,打印处 key_str 对应的 value,即累积出现的频次。
if (d.containsKey(key_str) { d.put(key_str, d.get(key_str) + 1); } else{ d.put(key_str, 1); } System.out.println(d.get(key_str));