目录
本节目标
1. 掌握二叉搜索树的原理和实现
一. 二叉搜索树
1.1 搜索树的概念
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树 :
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
1.2 搜索树的实现
1.2.1 查找
二叉搜索树-- 顾名思义,它的产生就是为了便于查找.
基于二叉搜索树的性质,我们的查找策略如下:
- 根节点的val比要查找的元素key小-->查找根节点的右子树
- 根节点的val比要查找的元素key大-->查找根节点的左子树
- 根节点的val==要查找的元素key-->返回当前的根节点
思路其实还是很简单滴 ,不管用递归还是迭代都可以解决
public class SearchTree {
TreeNode root=null;
class TreeNode{
int val;
TreeNode left;
TreeNode right;
public TreeNode(int val){
this.val=val;
}
}
TreeNode search(int key){//使用迭代法
TreeNode cur=this.root;//不能直接使用root遍历,会让root丢失原本的只
while(cur!=null){
if(cur.val>key){
cur=cur.left;
}else if(cur.val<key){
cur=cur.right;
}else {
return cur;
}
}
return null;//没有找到,返回null
}
TreeNode non_search(TreeNode root,int key){//使用递归
if(root==null) return null;
if(root.val==key) return root;
if(root.val<key) return non_search(root.right,key);
return non_search(root.left,key);
}
}
1.2.2 插入
搜索树的插入比较简单--每个新生成的结点都可以插入到叶子结点上
和搜索树的查找策略是一样的
- 根节点的val比要插入的元素key小-->插入到根节点的右子树
- 根节点的val比要查找的元素key大-->插入到根节点的左子树
- 根节点的val==要查找的元素key-->不能插入该元素,返回false
同样给出递归和迭代两种代码:
boolean insert(int key){//迭代法插入
TreeNode parent=null;
TreeNode cur=this.root;
while(cur!=null){
parent=cur;//记录当前结点的父节点,方便插入
if(cur.val<key){
cur=cur.right;
}else if(cur.val>key){
cur=cur.left;
}else {
return false;
}
}
TreeNode newNode=new TreeNode(key);
if(this.root==null) this.root=newNode;
else if(parent.val<key){
parent.right=newNode;
}else {
parent.left=newNode;
}
return true;
}
TreeNode non_insert(TreeNode root,int key){//递归插入
if(root==null) {
TreeNode newNode=new TreeNode(key);
if(this.root==null){
this.root=newNode;//如果当前搜索树的根节点是null,需要更新root的值
}
return newNode;
}
if(root.val<key){
root.right=non_insert(root.right,key);
}else if(root.val>key){
root.left=non_insert(root.left,key);
}else {
return null;
}
return root;
}
1.2.3 删除
在删除某个元素前同样需要先找到该元素,同时要记录该元素的父节点,方便删除
boolean delete(int key){
TreeNode cur=root;
TreeNode parent=null;
while(cur!=null){
if(cur.val<key){
parent=cur;
cur=cur.right;//当前根节点比要删除的元素小,往当前结点的右子树找
}else if(cur.val>key){
parent=cur;
cur=cur.left;//当前根节点比要删除的元素大,往当前结点的左子树找
}else {
break;//找到该节点了
}
}
//......
}
下面我们来看删除的逻辑:
1.要删除的结点没有左子树或者没有右子树(叶子结点也包括在内)
比如:
要删除6(没有右子树),就让6的左节点代替
要删除25(没有左子树),就让25的右节点代替
2.要删除的结点既有右子树又有左子树
比如:
要删除结点9-->肯定是不能直接删除的,因为会株连九族~~
这时候我们就需要找一个替罪羊-->让左子树最右边的结点/右子树最左边的结点来代替9,然后删除该节点
来分析一下这个替代法删除的可行性:
用左子树最右边的结点--6覆盖9,然后在左子树中删除6这个结点,因为是左子树的最右节点,没有左子树,所以可以按照第一个情况删除
同时我们可以发现6的左子树都比6小,6的右子树都比6大
使用右子树的最左边结点10来代替9也可以,自己论证吧~
下面我们继续写删除部分的代码
boolean delete(int key){
TreeNode cur=root;
TreeNode parent=null;
while(cur!=null){
if(cur.val<key){
parent=cur;
cur=cur.right;//当前根节点比要删除的元素小,往当前结点的右子树找
}else if(cur.val>key){
parent=cur;
cur=cur.left;//当前根节点比要删除的元素大,往当前结点的左子树找
}else {
break;//找到该节点了
}
}
if(cur==null) return false;//没有找到要删除的结点,返回false
if(cur.left==null){//要删除的结点没有左子树
if(parent!=null){//要删除的结点如果是this.root,parent仍等于null
if(cur==parent.right){
parent.right=cur.right;
}else {
parent.left=cur.right;
}
}
}else if(cur.right==null){//要删除的结点没有右子树
if(parent!=null){
if(cur==parent.left){
parent.left=cur.left;
}else {
parent.right=cur.left;
}
}
}else {
TreeNode sub=cur.right;//找右子树最左边的结点
TreeNode subP=cur;//记录要代替的结点的父节点,方便删除该节点
while(sub.left!=null){
subP=sub;
sub=sub.left;
}
cur.val=sub.val;
if(sub==subP.left){
subP.left=null;
}else {
subP.right=null;
}
}
return true;
}
1.3 性能分析
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:![]()
最优情况下,二叉搜索树为完全二叉树,平均比较次数为log2N
最坏情况下,二叉搜索树退化为单支树,平均比较次数为N
可以看到,当二叉搜索树是单支树时效率最低,能否避免这种情况?
可以使用AVL树和红黑树,有关这两种树的内容以后会更新~~
二. Map和Set
2.1 搜索模型
- 有一个英文词典,快速查找一个单词是否在词典中
- 快速查找某个名字在不在通讯录中
- 查找某个英文单词的中文释义
- 梁山好汉的江湖绰号:每个好汉都有自己的江湖绰号
2.2 Map的使用
Map是一个接口,要用TreeMap和HashMap来实例化,TreeMap的底层是用红黑树实现的,HashMap底层是用哈希表实现的
Map内存储的是<k,v>键值对,并且要求k是唯一的
2.2.1 Map的常用方法
Map中设置了一个内部类Entry用来存储K-V键值对
这个内部类提供了方法,用来得到key和value及更改value的值
方法 说明 K getKey () 返回 entry 中的 key V getValue () 返回 entry 中的 value V setValue(V value)将键值对中的value替换为指定value
方法 | 说明 |
V
get
(Object key)
|
返回
key
对应的
value
|
V
getOrDefault
(Object key, V defaultValue)
|
返回
key
对应的
value
,
key
不存在,返回默认值
|
V
put
(K key, V value)
|
设置
key
对应的
value,如果key已经存在,会更新Value的值
|
V
remove
(Object key)
|
删除
key
对应的映射关系
|
Set<K>
keySet
()
|
返回所有
key
的不重复集合
|
Collection<V>
values
()
|
返回所有
value
的可重复集合
|
Set<Map.Entry<K, V>>
entrySet
()
|
返回所有的
key-value
映射关系
|
boolean
containsKey
(Object key)
|
判断是否包含
key
|
boolean containsValue(Object value) | 判断是否包含value |
注意:
- TreeMap底层是红黑树,使用TreeMap实现Map接口时,Key类对象必须可比较(实现Comparable接口或者有比较类对象),并且Key不能为null
//使用Comparable接口
class Person implements Comparable<Person>{
int age;
public Person(int age){
this.age=age;
}
@Override
public int compareTo(Person o) {
return this.age-o.age;
}
}
TreeMap<Person,Integer> map=new TreeMap<>();
//使用Comparator接口
class Person {
int age;
public Person(int age){
this.age=age;
}
}
//创建Person的比较类
class AgeComp implements Comparator<Person>{
@Override
public int compare(Person o1, Person o2) {
return o1.age-o2.age;
}
}
TreeMap<Person,Integer> map=new TreeMap<>(new AgeComp());//使用Comparator对象初始化TreeMap对象
- Map中的Key是唯一的,Value是可以重复的
- Map中的Key不可以修改,只能先删除然后重新插入修改Value使用Entry提供的setValue方法
2.3 Set的使用
Set和Map的不同点是Set是纯Key搜索模型
Set和Map一样,都是接口,需要TreeSet或HashSet实例化
2.3.1 Set的常用方法
方法 | 说明 |
boolean
add
(E e)
|
添加元素,但重复元素不会被添加成功
|
void
clear
()
|
清空集合
|
boolean
contains
(Object o)
|
判断
o
是否在集合中
|
Iterator<E>
iterator
()
|
返回迭代器
|
boolean
remove
(Object o)
|
删除集合中的
o
|
int size()
|
返回
set
中元素的个数
|
boolean isEmpty()
|
检测
set
是否为空,空返回
true
,否则返回
false
|
Object[] toArray()
|
将
set
中的元素转换为数组返回
|
boolean containsAll(Collection<?> c)
|
集合
c
中的元素是否在
set
中全部存在,是返回
true
,否则返回 false
|
boolean addAll(Collection<? extends E> c)
| 将集合c中的元素添加到set中,可以达到去重的效果 |
注意:
-
Set 中只存储了 key ,并且要求 key 一定要唯一
-
Set 的底层是使用 Map 来实现的,其使用 key 与 Object 的一个默认对象作为键值对插入到 Map 中的

-
Set最大的功能就是对集合中的元素进行去重
-
实现 Set 接口的常用类有 TreeSet 和 HashSet ,还有一个 LinkedHashSet , LinkedHashSet 是在 HashSet的基础上维护了一个双向链表来记录元素的插入次序
-
Set 中的 Key 不能修改,如果要修改,先将原来的删除掉,然后再重新插入
-
TreeSet 中不能插入 null 的 key , HashSet 可以
三. 哈希表
TreeMap和TreeSet的底层是红黑树--比搜索树效率更高的树型结构
HashMap和HashSet的底层是哈希表,下面一起来了解一下吧
3.1 哈希表的概念
顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素时,必须要经过关键 码的多次比较 。 顺序查找时间复杂度为 O(N) ,平衡树中为树的高度,即 O( logN) ,搜索的效率取决于搜索过程中 元素的比较次数。理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素 。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。当向该结构中:插入元素根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放搜索元素对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功举例:有一组数据集合{1,2,4,7,10}, 哈希函数设置为: hash(key) = key % capacity ; capacity 为存储元素底层空间总的大小![]()
3.2 哈希冲突

3.2.1 哈希冲突的预防
哈希冲突是无法避免的,我们要做的就是降低冲突率
引起哈希冲突的一个原因是哈希函数,我们要尽可能设计出一个合理的哈希函数,使数据均匀分布
常见的哈希函数有以下几种
//解题思路:先遍历字符串s,让每个字符和它的次数建立映射关系;再遍历一遍字符串s,如果当前遍历的字符出现次数为1,返回该字符
class Solution {
public int firstUniqChar(String s) {
int[] arr=new int[26];
for(int i=0;i<s.length();i++){
int index=s.charAt(i)-'a';
arr[index]++;
}
for(int i=0;i<s.length();i++){
int index=s.charAt(i)-'a';
if(arr[index]==1) {
return i;
}
}
return -1;
}
}
引起哈希冲突的另一个重要原因就是负载因子:
HashMap/Set中默认设置的负载因子是0.75
HashSet实例在初始化时也可以传入负载因子
3.2.2 哈希冲突的解决-----开散列和闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 把 key 存放到冲突位置中的 “ 下一个 ” 空位置中去。 那如何寻找下一个空位置呢?
1.线性探测
步骤:
- 先用哈希函数计算出要插入的位置
- 2.如果当前位置为空,直接插入;如果不为空,从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止

但是这个方法有个缺点,如果还想插入21,31,......,就要占据后面的空位置,这时候如果想插入元素3,5,..就不得不往后面一直找空位置,当插入的元素发生冲突时,容易"扎堆"出现,并且这只是一种缓兵之计--后面再插入元素时拥堵
2.二次探测
步骤:
- 先用哈希函数计算出要插入的位置index0
- 如果当前位置为空,直接插入;如果不为空,找下一个空位置的方法为:indexi=(index0+i^2 )% capacity(i=1,2...)
再拿11来举例
先计算出index0=1,发现位置有冲突,此时i为初始值1
index1=1+i^2=2,看下标为2的位置有没有冲突,发现有元素
i++,index2=1+i^2=5,下标为5的位置没有冲突,放入11
可以发现,闭散列法的空间利用率比较低
于是又引入了开散列~
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子 集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
那么元素11就可以这样插入~
3.3 哈希表的实现
现在我们来实现一个Key-Value类型的哈希表
public class HashBuck<K,V> {
//定义内部类,相当于Map里的Entry类
class Node{
public K key;
public V value;
Node next;
public Node(K key, V value) {
this.key = key;
this.value = value;
}
}
//哈希表的底层是一个数组
private Object[] elems=new Object[10];
private int usedSize=0;
private final float factor=0.75f;
//插入元素
public V put(K key,V value){
Node newNode=new Node(key,value);
int hash=key.hashCode();//key类要重写hashCode方法或者默认使用Object父类方法
int index=hash% elems.length;
Node cur=(Node)elems[index];
if(cur==null) {
elems[index]=newNode;//key要重写equal方法或者默认使用Object父类方法
usedSize++;
if(calFac()>factor) {
resize();
}
return value;
}
while(cur.next!=null){
if(cur.key.equals(key)){//这里的开散列使用尾插法
cur.value=value;
return value;
}
cur=cur.next;
}
cur.next=newNode;
usedSize++;
if(calFac()>factor) {
resize();
}
return value;
}
private void resize(){
Object[] newElems=new Object[elems.length*2];
for (int i = 0; i < elems.length; i++) {
Node cur=(Node)elems[i];
while(cur!=null){
Node next=cur.next;
cur.next=(Node)newElems[cur.key.hashCode()% newElems.length];
newElems[cur.key.hashCode()% newElems.length]=cur;
cur=next;
}
}
elems=newElems;
}
private float calFac(){
return (float) (usedSize*1.0/ elems.length);
}
}
从上面哈希表的简单实现中我们可以看到,自定义类型最好重写HashCode方法和equal方法
四. Map和Set续
4.1 部分源码剖析
下面来分析一下有关HashMap和HashSet内部的细节
先来看插入,因为在Java中,Set复用了Map的很多方法,下面我们主要研究Map
先来看看Map主要的的成员变量:
再来看看Map的初始化:
可以发现,构造方法并没有对table变量初始化,这时候还没有给哈希表分配内存
一起来看看插入第一个元素时Map是怎么做的吧:
可以看到,Map是在第一次插入元素时进行了哈希表的初始化,默认容量是16,之后每次都按二倍扩容
仔细看put方法,发现新元素插入的位置是(n-1)&hash,这是采用了除留余数法,相当于hash%capacity,但前提是n必须是2的k次幂
这里肯定有同学会问:Map的构造方法中不是可以传入容量吗,如果我传入的容量不是2的k次幂咋整?
其实源码悄悄来了个狸猫换太子~
上面的英文解释大意就是把容量变为2的k次幂
HashMap中除了有Node类,还有TreeNode类...
这是因为当哈希表的一个元素上挂的结点太多时,也不便于查找,这个结点就会自动变成红黑树,这个临界点就是8
4.2 Hash和Tree的对比
我们以Map为例
TreeMap | HashMap | |
底层结构 | 红黑树 | 哈希表 |
线程安全 | 不安全 | 不安全 |
性能 | O(logN) | O(1) |
是否有序 | 关于Key有序 | 无序 |
插入/查找/删除逻辑 | 按照红黑树进行增删查改 | 1.根据哈希函数计算插入地址 2.进行增删查改 |
Key能否为null | 不能为null,会抛NullPointerException | 可以为null |
对Key的要求 | 必须实现Comparable接口,或者传入Comparator对象 | 需要重写hashCode和equals方法 |
引用场景 | 适用于key有序的场景下 | 适用于对空间要求不高,对性能要求高的场景 |
还有一个遗留的问题,既然HashMap的某个链表在元素超过8 时会变成红黑树,红黑树的key必须可以比较,那么是根据什么来比较的呢?
不卖关子了~是根据key的哈希值比较的
end~~