1.搜索树
1.1 概念
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
int[] array ={5,3,4,1,7,8,2,6,0,9};
二叉搜索树与普通的二叉树有所不同,如果我们按照中序遍历进行访问结点的值的时候我们可以发现它是一个升序的序列,中序遍历的结果:0,1,2,3,4,5,6,7,8,9
1.2 操作-查找
/**
* 在搜索树中查找 key,如果找到,返回 key 所在的结点,否则返回 null
* @param key
* @return
*/
public Node search(int key) {
Node cur = root;
while (cur != null) {
if(cur.key < key) {
cur = cur.right;
} else if (key < cur.key) {
cur = cur.left;
}else{
return cur;
}
}
return null;
}
1.3 操作-插入
-
如果树为空树,即根 == null,直接插入
-
如果树不是空树,按照查找逻辑确定插入位置,插入新结点
/**
* 插入
* @param key
* @return true 表示插入成功, false 表示插入失败
*/
public boolean insert(int key) {
//如果是一颗空树,那么就将新节点置为根结点
if(root == null) {
root = new Node(key);
return true;
}
//在二叉搜索树中找到要插入的位置
Node cur = root;
Node parent = null;
while (cur != null) {
if(cur.key < key) {
parent = cur;
cur = cur.right;
} else if (key < cur.key) {
parent = cur;
cur = cur.left;
}else{
//二叉搜索树的值是唯一的,如果该值已经存在,就返回false
return false;
}
}
//将新节点插入二叉搜索树中
Node tmp = new Node(key);
if(tmp.key < parent.key) {
parent.left = tmp;
}else{
parent.right = tmp;
}
return true;
}
1.4 操作-删除(难点)
设待删除结点为 cur, 待删除结点的双亲结点为 parent
1.cur.left == null
-
cur 是 root,则 root = cur.right
-
cur 不是 root,cur 是 parent.left,则 parent.left = cur.right
- cur 不是 root,cur 是 parent.right,则 parent.right = cur.right
2.cur.right == null
- cur 是 root,则 root = cur.left
-
cur 不是 root,cur 是 parent.left,则 parent.left = cur.left
-
cur 不是 root,cur 是 parent.right,则 parent.right = cur.left
3.cur.left != null && cur.right != null
需要使用替换法进行删除,即在它的右子树中寻找中序下的第一个结点(关键码最小),用它的值填补到被删除节点中,再来处理该结点的删除问题
删除结点时分为两种情况:
(1)
(2)
/**
* 删除成功返回 true,失败返回 false
* @param key
* @return
*/
private void removeChild(Node parent, Node cur) {
if (cur.left == null) {
if (cur == root) {
root = cur.right;
} else if (parent.left == cur) {
parent.left = cur.right;
} else { //parent.right = cur
parent.right = cur.right;
}
} else if (cur.right == null) {
if (cur == root) {
root = cur.left;
} else if (parent.left == cur) {
parent.left = cur.left;
} else { //parent.right = cur
parent.right = cur.left;
}
} else { //cur的左节点和右节点都不为空
Node tmpParent = cur;
Node target = cur.right;
//找到距离key的临近值
while (target.left != null) {
tmpParent = target;
target = target.left;
}
//将临近值覆盖到要删除的值
cur.key = target.key;
//删除这个临近值
if (tmpParent.left == target) {
tmpParent.left = target.right;
} else {
tmpParent.right = target.right;
}
}
}
1.6 性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
最优情况下,二叉搜索树为完全二叉树,其平均比较次数为: log 2 N \log_2N log2N
最差情况下,二叉搜索树退化为单支树,其平均比较次数为: N / 2 \ N/2 N/2
1.7 和 java 类集的关系
TreeMap 和 TreeSet 即 java 中利用搜索树实现的 Map 和 Set;实际上用的是红黑树,而红黑树是一棵近似平衡的二叉搜索树,即在二叉搜索树的基础之上 + 颜色以及红黑树性质验证,关于红黑树的内容后序再进行讲解。
2. 搜索
2.1 概念及场景
Map和set是一种专门用来进行搜索的容器或者数据结构,其搜索的效率与其具体的实例化子类有关。以前常见的搜索方式有:
- 直接遍历,时间复杂度为 O ( N ) \ O(N) O(N),元素如果比较多效率会非常慢
- 二分查找,时间复杂度为 O ( l o g 2 N ) \ O(log_2N) O(log2N) ,但搜索前必须要求序列是有序的
上述排序比较适合静态类型的查找,即一般不会对区间进行插入和删除操作了,而现实中的查找比如: 根据姓名查询考试成绩、 通讯录,即根据姓名查询联系方式、不重复集合,即需要先搜索关键字是否已经在集合中
可能在查找时进行一些插入和删除的操作,即动态查找,那上述两种方式就不太适合了,本节介绍的Map和Set是一种适合动态查找的集合容器。
2.2 模型
一般把搜索的数据称为关键字(Key),和关键字对应的称为值(Value),将其称之为Key-value的键值对,所以模型会有两种:
1. 纯 key 模型,比如: 有一个英文词典,快速查找一个单词是否在词典中 快速查找某个名字在不在通讯录中
2. Key-Value 模型,比如: 统计文件中每个单词出现的次数,统计结果是每个单词都有与其对应的次数:<单词,单词出现的次数> ,而Map中存储的就是key-value的键值对,Set中只存储了Key。
3. Map 的使用
【官方文档】:Map
3.1 关于Map的说明
Map是一个接口类,该类没有继承自Collection,该类中存储的是<K,V>结构的键值对,并且K一定是唯一的,不能重复。
3.2 关于Map.Entry<K, V>的说明
Map.Entry<K, V> 是Map内部实现的用来存放<key, value>键值对映射关系的内部类,该内部类中主要提供了<key, value>的获取,value的设置以及Key的比较方式。
方法 | 解释 |
---|---|
K getKey() | 返回 entry 中的 key |
V getValue() | 返回 entry 中的 value |
V setValue(V value) | 将键值对中的value替换为指定value |
注意:Map.Entry<K,V>并没有提供设置Key的方法
Map.Entry是Java中的一个接口,用于表示Map中的键值对。它定义了访问和操作Map中键值对的方法。每个Map中的条目都由一个Map.Entry对象表示。通过Map.Entry接口,可以获取键和值,并且可以对键值对进行操作,如修改值、移除条目等。Map.Entry接口通常与Map的迭代器一起使用,用于遍历Map中的所有键值对。
3.3 Map 的常用方法说明
方法 | 解释 |
---|---|
V get(Object key) | 返回 key 对应的 value |
V getOrDefault(Object key, V defaultValue) | 返回 key 对应的 value,key 不存在,返回默认值 |
V put(K key, V 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 |
注意:
1. Map是一个接口,不能直接实例化对象,如果要实例化对象只能实例化其实现类TreeMap或者HashMap
2. Map中存放键值对的Key是唯一的,value是可以重复的
3. 在TreeMap中插入键值对时,key不能为空,否则就会抛NullPointerException异常,value可以为空。但是HashMap的key和value都可以为空。
(1)在TreeMap中插入键值对时,键(key)不能为空,否则就会抛NullPointerException异常,因为TreeMap使用红黑树来维护键的顺序,需要进行比较操作。
(2)值(value)可以为空,因为在TreeMap中,键值对的顺序是基于键的。
(3)在HashMap中,键(key)和值(value)都可以为空,因为HashMap使用哈希码来确定存储位置,不依赖于键的比较操作
4. Map中的Key可以全部分离出来,存储到Set中来进行访问(因为Key不能重复)。
5. Map中的value可以全部分离出来,存储在Collection的任何一个子集合中(value可能有重复)。
6. Map中键值对的Key不能直接修改,value可以修改,如果要修改key,只能先将该key删除掉,然后再来进行 重新插入。
7. TreeMap和HashMap的区别
(1) TreeMap是基于红黑树(一种自平衡的二叉搜索树)实现的有序Map,它根据键的自然顺序或自定义的Comparator对键进行排序。
(2) HashMap是基于散列表(哈希表)实现的无序Map,它使用键的哈希码来确定存储位置,没有固定的顺序。
(3)TreeMap适用于需要按键排序的场景,而HashMap适用于无序的键值对存储和检索。
(4)由于红黑树的特性,TreeMap的查找、插入和删除操作的时间复杂度为O(log n),而HashMap的时间复杂度通常为O(1)。
Map底层结构 | TreeMap | HashMap |
---|---|---|
底层结构 | 红黑树 | 哈希桶 |
插入/删除/查找时间复杂度 | O ( l o g 2 N ) \ O(log_2N) O(log2N) | O(1) |
是否有序 关于Key | 有序 | 无序 |
线程安全 | 不安全 | 不安全 |
插入/删除/查找区别 | 需要进行元素比较 | 通过哈希函数计算哈希地址 |
比较与覆写 | key必须能够比较,否则会抛出ClassCastException异常 | 自定义类型需要覆写equals和hashCode方法 |
应用场景 | 需要Key有序场景下 | Key是否有序不关心,需要更高的时间性能 |
public static void testMap() {
Map<String, String> m = new TreeMap<>();
//put(key, value):插入key-value的键值对
//如果key不存在,会将key-value的键值对插入到map中,返回null
m.put("林冲", "豹子头");
m.put("鲁智深", "花和尚");
m.put("武松", "行者");
m.put("宋江", "及时雨");
String str = m.put("李逵", "黑旋风");
System.out.println(m.size());
System.out.println(m);
//put(key, value):注意key不能为空,但是value可以为空
//key如果为空,会抛出空指针异常
// m.put(null, "花名");
str = m.put("无名", null);
System.out.println(m.size());
// put(key, value):
// 如果key存在,会使用value替换原来key所对应的value,
// 返回旧value
str = m.put("李逵", "铁牛");
// get(key):返回key所对应的value
// 如果key存在,返回key所对应的value
// 如果key不存在,返回null
System.out.println(m.get("鲁智深"));
System.out.println(m.get("史进"));
//GetOrDefault():如果key存在,返回与key所对应的value,如果key不存在,返回一个默认值
System.out.println(m.getOrDefault("李逵", "铁牛"));
System.out.println(m.getOrDefault("史进", "九纹龙"));
System.out.println(m.size());
//containKey(key):检测key是否包含在Map中,时间复杂度:O(logN)
//按照红黑树的性质来进行查找,找到返回true,否则返回false
System.out.println(m.containsKey("林冲"));
System.out.println(m.containsKey("史进"));
//containValue(value):检测value是否包含在Map中,时间复杂度:O(N)
//找到返回true,否则返回false
System.out.println(m.containsValue("豹子头"));
System.out.println(m.containsValue("九纹龙"));
//打印所有的key
//keySet是将map中的key防止在Set中返回的
for (String s : m.keySet()) {
System.out.print(s + " ");
}
System.out.println();
// 打印所有的value
// values()是将map中的value放在collect的一个集合中返回的
for (String s : m.values()) {
System.out.print(s + " ");
}
System.out.println();
// 打印所有的键值对
// entrySet(): 将Map中的键值对放在Set中返回了
for (Map.Entry<String, String> entry : m.entrySet()) {
System.out.println(entry.getKey() + "--->" + entry.getValue());
}
System.out.println();
}
代码运行结果:
4. Set 的说明
Set 的官方文档:Set
Set与Map主要的不同有两点:Set是继承自Collection的接口类,Set中只存储了Key。
4.1 常见方法说明
方法 | 解释 |
---|---|
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中,可以达到去重的效果 |
注意:
1. Set是继承自Collection的一个接口类:Set接口是Java集合框架中的一部分,它继承自Collection接口,定义了一组操作用于管理一组不重复的元素。
2. Set中只存储了key,并且要求key一定要唯一
(1)Set集合中只存储了键(key),没有对应的值(value)。
(2)Set要求集合中的元素(key)是唯一的,每个元素只能出现一次(是因为它的底层是Map)。
(3)这意味着无论是Set的实现类还是Set接口本身,都会自动去除重复的元素,确保集合中没有重复的键。
3. TreeSet的底层是使用Map来实现的,其使用key与Object的一个默认对象作为键值对插入到Map中的。(不管你实例化一个什么类型的treemap,value都是一个Object对象)
(1)TreeSet是Set接口的一个实现类,它是基于红黑树(一种自平衡的二叉搜索树)实现的有序集合。
(2)TreeSet内部使用TreeMap(基于红黑树的有序映射)来实现,它将元素作为键存储在TreeMap的键中,值则使用一个默认对象(Object)作为统一的值。
(3)通过TreeMap的有序性,TreeSet能够实现有序集合的特性。
4. Set最大的功能就是对集合中的元素进行去重
5. 实现Set接口的常用类有TreeSet和HashSet,还有一个LinkedHashSet,LinkedHashSet是在HashSet的基础上维护了一个双向链表来记录元素的插入次序。
(1) TreeSet是基于红黑树实现的有序集合,它可以按照元素的自然顺序或自定义比较器进行排序。
(2)HashSet是基于散列表实现的无序集合,它使用哈希码来确定存储位置,没有固定的顺序。
(3) LinkedHashSet是HashSet的子类,它在HashSet的基础上维护了一个双向链表来记录元素的插入次序,保持了元素插入的顺序
6. Set中的Key不能修改,如果要修改,先将原来的删除掉,然后再重新插入
7. TreeSet中不能插入null的key,HashSet可以。
(1)TreeSet是基于红黑树实现的有序集合,在插入元素时会进行排序,你需要进行比较。因此不允许插入null的键。
(2)HashSet是基于散列表实现的无序集合,可以插入null作为键。
8. TreeSet和HashSet的区别
(1) TreeSet是有序集合,它根据元素的自然顺序或自定义比较器进行排序,内部使用红黑树实现。因此,它适合需要有序性的场景。
(2)HashSet是无序集合,它使用散列表(哈希表)来存储元素,没有固定的顺序。由于散列表的特性,HashSet在插入和查找操作上具有较快的性能。它适用于不需要有序性的场景。
(3) 另外,TreeSet和HashSet的迭代顺序也是不同的,TreeSet的迭代顺序是根据元素的排序顺序,而HashSet的迭代顺序是不确定的,可能会受到散列表的扩容和哈希冲突等因素的影响。
Set底层结构 | TreeSet | HashSet |
---|---|---|
底层结构 | 红黑树 | 哈希桶 |
插入/删除/查找时间复杂度 | O ( l o g 2 N ) \ O(log_2N) O(log2N) | O(1) |
是否有序 | 关于Key有序 | 不一定有序 |
线程安全 | 不安全 | 不安全 |
插入/删除/查找区别 | 按照红黑树的特性来进行插入和删除 | 1. 先计算key哈希地址 2. 然后进行插入和删除 |
比较与覆写 | key必须能够比较,否则会抛出ClassCastException异常 | 自定义类型需要覆写equals和hashCode方法 |
应用场景 | 需要Key有序场景下 | Key是否有序不关心,需要更高的时间性能 |
public static void testSet() {
Set<String> s = new TreeSet<>();
// add(key): 如果key不存在,则插入,返回ture
// 如果key存在,返回false
boolean isIn = s.add("apple");
s.add("orange");
s.add("peach");
s.add("banana");
System.out.println(s.size());
System.out.println(s);
isIn = s.add("apple");
// add(key): key如果是空,抛出空指针异常
//s.add(null);
// contains(key): 如果key存在,返回true,否则返回false
System.out.println(s.contains("apple"));
System.out.println(s.contains("watermelen"));
// remove(key): key存在,删除成功返回true
// key不存在,删除失败返回false
// key为空,抛出空指针异常
s.remove("apple");
System.out.println(s);
s.remove("watermelen");
System.out.println(s);
Iterator<String> it = s.iterator();
while (it.hasNext()) {
System.out.print(it.next() + " ");
}
System.out.println();
}
代码运行结果:
Set与Map的主要不同点有两个:
存储内容:Set只存储了Key,而不存储Key-Value键值对。它只关注集合中的元素本身,而不需要与其他值进行关联。Set中的元素通常用于表示一组独立的、不重复的对象。
元素的重复性:Set不允许存储重复的元素,每个元素在Set中只能出现一次。这与Map不同,Map存储的是Key-Value键值对,其中Key是用来索引和唯一标识Value的,不同的Key可以对应相同的Value。
5.哈希表
5.1 概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O ( l o g 2 N ) \ O(log_2N) O(log2N) ,搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放 - 搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
5.2 冲突-概念
问题:按照上述哈希方式,向集合中插入元素14,会出现什么问题?哈希函数计算14的结果为4,而下标4的位置已经存储了元素,此时出现了冲突。
对于两个数据元素的关键字 k i \ k_i ki和 k j \ k_j kj (i != j),有 k i \ k_i ki != k j \ k_j kj,但有:Hash( k i \ k_i ki) == Hash( k j \ k_j kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
5.3 冲突-避免
首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的,但我们能做的应该是尽量的降低冲突率。
5.4 冲突-避免-哈希函数设计
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数
- 直接定制法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况
计数排序:key - minValue,其中A=1,B= -minValue。
- 除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址 - 平方取中法–(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况 - 折叠法—(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况 - 随机数法–(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。
通常应用于关键字长度不等时采用此法 - 数学分析法–(了解)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:
假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
5.5 冲突-避免-负载因子调节(重点掌握)
负载因子和冲突率的关系粗略演示
所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。
已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。
5.6 冲突-解决
解决哈希冲突两种常见的方法是:闭散列和开散列
5.7 冲突-解决-闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
1. 线性探测
比如上面的场景,现在需要插入元素14,先通过哈希函数计算哈希地址,下标为4,因此14理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
- 插入
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
- 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,14查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
【线性探测法的特点】
-
简单直观:线性探测法是一种简单直观的探测方法,易于理解和实现。
-
冲突解决:当发生哈希冲突时,线性探测法会顺序地查找下一个可用的槽位,直到找到空槽或者遍历整个哈希表。这种方法可以有效解决冲突问题。
-
空间利用率高:线性探测法会尽可能地利用哈希表中的空槽,减少空间浪费。
-
内存访问效率高:线性探测法的内存访问效率较高,因为它可以通过简单的计算索引值来访问哈希表中的元素。
-
可能存在聚集现象:线性探测法容易出现聚集现象,即相邻的元素会聚集在一起,导致查找效率降低。
2. 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:
H
i
=
(
H
0
+
i
2
)
\ H_i = ( H_0+i^2 )% m
Hi=(H0+i2), 或者:
H
i
=
(
H
0
−
i
2
)
\ H_i = ( H_0-i^2 )% m
Hi=(H0−i2)。其中:i = 1,2,3…, 是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。 对于2.1中如果要插入14,产生冲突,使用解决后的情况为:
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
5.8 冲突-解决-开散列/哈希桶(重点掌握)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
出现冲突时,将冲突元素挂在链表上,在JDK1.7及之前,采用头插法,从1.8开支之后,采用尾插法。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。
5.9 冲突严重时的解决办法
刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
- 每个桶的背后是另一个哈希表
- 每个桶的背后是一棵搜索树
5.10 哈希桶的实现
插入数据:
1.计算键的哈希值,并确定要插入的哈希槽的索引
2.如果键已经存在,则更新对应的值
3.如果键不存在,则在链表中插入该数据(此次使用头插法)
4.插入数据之后,还应该判断负载因子,如果大于默认值还需要对数组进行扩容,扩容之后链表的长度改变了,所以还需要把所有的元素都要进行重新的哈希。
public class HashBucket {
private static class Node {
private int key;
private int value;
Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
private Node[] array;
private int size; // 当前的数据个数
private static final double LOAD_FACTOR = 0.75;
public HashBucket() {
array = new Node[8];
size = 0;
}
public int put(int key, int value) {
int index = key % array.length;
// 在链表中查找 key 所在的结点
// 如果找到了,更新
// 所有结点都不是 key,插入一个新的结点
for (Node cur = array[index]; cur != null; cur = cur.next) {
if (key == cur.key) {
int oldValue = cur.value;
cur.value = value;
return oldValue;
}
}
//所有结点都不是key,插入一个新节点
Node node = new Node(key, value);
node.next = array[index];
array[index] = node;
size++;
//还需要判断负载因子,如果大于负载因子就需要扩容
if (loadFactor() >= LOAD_FACTOR) {
resize();
}
return -1;
}
private void resize() {
Node[] newArray = new Node[array.length * 2];
for (int i = 0; i < array.length; i++) {
Node next;
for (Node cur = array[i]; cur != null; cur = next) {
//存储下一个节点
next = cur.next;
//将该结点挂在新数组上
int index = cur.key % newArray.length;
cur.next = newArray[index];
newArray[index] = cur;
}
}
array = newArray;
}
private double loadFactor() {
return size * 1.0 / array.length;
}
public int get(int key) {
int index = key % array.length;
Node head = array[index];
for (Node cur = head; cur != null; cur = cur.next) {
if (key == cur.key) {
return cur.value;
}
}
return -1;
}
}
这个时候有一个问题:刚刚写的代码只适用于 key 等于整数的情况,如果是一个自定义类型,你还能用“==”来比较吗?并且,如果我们的 key 是一个字符串这种引用类型,那么计算下标的时候还可以直接用长度来计算吗?
这样,我们就不得不重写我们的 equals()方法,而且还会涉及到 hashCode() 方法:hashCode() 是 Java 中定义在 Object 类中的一个方法,用于计算对象的哈希码(hash code)。哈希码是一个整数值,用于在哈希表等数据结构中快速定位对象。
在 Java 中,哈希表是一种常见的数据结构,用于存储键-值对,比如 HashMap 和 HashSet。在这些数据结构中,当你插入一个对象作为键或值时,Java 会调用该对象的 hashCode() 方法来计算哈希码,并将对象存储在相应的位置上。当你要查找一个对象时,Java 也会先计算其哈希码,然后定位到相应的位置来查找。
hashCode() 方法的默认实现位于 Object 类中,其实现如下:
public class Object {
public native int hashCode();
// ...
}
在这里,你会发现你是看不到它的具体实现方法的,里面的 native 关键字表示该方法的实现是由本地代码(Native Code)提供的,是用C++代码写的,而不是用 Java代码实现的。这是因为每个对象的哈希码通常是由对象的内部表示和地址等信息计算得出的,而这些信息对于 Java 代码并不直接可见,需要通过本地代码来获取。
当我们自定义类时,通常会重写 hashCode() 方法,以便根据对象的内容来计算哈希码,而不仅仅是依赖于默认的 Object 类实现。这是因为,当两个对象的内容相同时,它们应该具有相同的哈希码。这样,当我们将自定义类的对象用作键或值存储在哈希表中时,能够正确地找到和检索对象。而默认的 Object 类中实现的 hashCode() 方法可能无法得到正确的结论。
在重写 hashCode() 方法时,通常需要遵循以下几个原则:
- 一致性:相同对象多次调用 hashCode() 方法应该返回相同的哈希码。
- 相等性:如果两个对象相等(根据 equals() 方法判断),则它们的哈希码也应该相等。
- 效率:计算哈希码的过程应该尽可能高效,避免耗费大量计算资源。
为了满足以上原则,通常在重写 hashCode() 方法时会使用对象内部的一些属性来计算哈希码,例如:
class Student {
public String id;
public Student(String id) {
this.id = id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return Objects.equals(id, student.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
这个Student类表示一个学生对象,其中包含一个id属性。在这个类中,我们重写了equals()和hashCode()方法。
-
equals()方法用于比较两个Student对象是否相等。我们首先检查两个对象是否是同一个对象,如果是则直接返回true。然后检查传入的对象是否为null或者是否属于Student类,如果不是则返回false。接着将传入的对象转换为Student类型,并比较它们的id属性是否相等,如果相等则返回true,否则返回false。
-
hashCode()方法用于返回对象的哈希码值,这个哈希码值可以用于在哈希表等数据结构中进行快速查找。在这里,我们使用Objects类的hash()方法计算对象的哈希码值,其中我们只考虑id属性的值来计算哈希码。
通过重写equals()和hashCode()方法,我们可以确保在比较和存储Student对象时能够正确地处理相等性和哈希码
总结:hashCode() 方法是 Java中用于计算对象哈希码的方法,它对于存储对象在哈希表等数据结构中起着关键作用。在自定义类中,我们应该根据对象的内容来重写 hashCode()方法,确保相等的对象具有相同的哈希码,并且尽可能避免哈希冲突,以提高数据结构的性能。
模拟实现K-V模型的哈希桶
public class HashBucketPlus<K, V> {
private static class Node<K, V> {
private K key;
private V value;
Node<K, V> next;
public Node(K key, V value) {
this.key = key;
this.value = value;
}
}
private Node<K, V>[] array;
private int size; // 当前的数据个数
public HashBucketPlus() {
array = (Node<K,V>[])new Node[8];
size = 0;
}
public void put(K key, V value) {
int hash = key.hashCode();
int index = hash % array.length;
Node<K, V> cur = array[index];
while (cur != null) {
if (cur.key.equals(key)) {
cur.value = value;
return;
}
cur = cur.next;
}
Node<K, V> node = new Node<>(key, value);
node.next = array[index];
array[index] = node;
size++;
}
public V get(K key) {
int hash = key.hashCode();
int index = hash % array.length;
Node<K, V> cur = array[index];
while (cur != null) {
if (cur.key.equals(key)) {
return cur.value;
}
cur = cur.next;
}
return null;
}
}
5.11 性能分析
哈希表是一种用于存储键值对的数据结构,其性能取决于哈希函数的设计和哈希表的大小。以下是哈希表的性能分析:
-
插入和查找操作的时间复杂度为O(1):虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是O(1) 。在理想情况下,哈希函数能够将键均匀地映射到不同的索引位置,避免冲突。
-
哈希冲突会影响性能:当两个不同的键映射到了同一个索引位置,就会发生哈希冲突。解决哈希冲突的方法有拉链法和开放寻址法等。哈希冲突的发生会降低哈希表的性能,使得插入和查找操作的时间复杂度变为O(n)。
-
哈希表的负载因子影响性能:负载因子是指哈希表中键值对的数量与哈希表大小的比值。当负载因子超过一定阈值时,哈希表需要进行扩容操作,重新计算哈希函数,这会导致性能下降。
-
哈希表的大小对性能有影响:哈希表的大小会影响哈希函数的设计和哈希冲突的发生。通常情况下,哈希表的大小应该选择一个质数,以减少哈希冲突的发生。
总的来说,哈希表在插入和查找操作上具有较高的性能,但需要注意哈希冲突和负载因子对性能的影响。合理设计哈希函数和选择适当的哈希表大小可以提高哈希表的性能。
5.12 java 类集的关系
1.HashMap 和 HashSet:
- HashMap 是 Java 中实现的基于哈希表的 Map 接口的实现类,它允许将键值对存储在哈希表中,并能快速地根据键查找值。键和值都可以是任意类型的对象。
- HashSet 是 Java 中实现的基于哈希表的 Set 接口的实现类,它使用哈希表来存储唯一的元素,不允许重复的元素存在。HashSet 内部实际上是使用 HashMap 来实现的,HashSet 的元素被存储在 HashMap 的键的位置,而值则是一个固定的常量对象。
2.哈希桶方式解决冲突:
在 Java 中,哈希表使用哈希桶方式来解决哈希冲突。每个桶(bucket)是一个存储元素的容器,哈希冲突会导致多个元素映射到同一个桶中。当发生哈希冲突时,新的元素会被添加到对应的桶中,并通过链表或红黑树等数据结构来管理相同哈希值的元素。
3.转变为搜索树(红黑树):
当哈希表中某个桶中链表的长度达到一定阈值(默认为 8)时,Java 8 之后的版本会将这个链表转变为一棵搜索树(红黑树),以提高在大量冲突元素时的查找性能。这样,查找、插入和删除的时间复杂度将从 O(n) 降低为 O(log n),其中 n 是链表或搜索树中的元素数量。
4.自定义类作为 HashMap 的 key 或 HashSet 的值,需要覆写该类的 hashCode() 和 equals() 方法, java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法:
- 如果要将自定义类作为 HashMap 的键(key)或 HashSet 的值(value),必须覆写该类的 hashCode() 和 equals() 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。这是因为哈希表在插入和查找元素时依赖于对象的 hashCode() 方法确定存储位置(计算哈希值),进行 key 的相等性比较是调用 key 的 equals 方法。
- hashCode() 方法的覆写应该保证:当两个对象通过 equals() 方法比较返回 true 时,它们的 hashCode() 方法返回的值也必须相同。equals() 方法的覆写应该定义对象相等的条件,即对于两个不同的对象,通过 equals() 方法比较应该返回 false;对于两个相等的对象,equals() 方法应该返回 true。
5.13 HashMap源码解析
当使用默认构造方法时,没有给数组分配内存,那么是如何存储数据的呢?
在求数组下标时,我们使用i = (n - 1) & hash,使用按位与运算,这个方法的前提是数组的长度必须为二次幂。
当我们自定义数组大小时,不就不是二次幂了吗?那么该代码为何还会成立?
如果构造方法的初始容量我们给一个15,容量就会是15吗?
解树化:
6 OJ练习
6.1 只出现一次的数字
【题目链接】:只出现一次的数字
【题目解析】:
方法一(异或法):
首先,要在非空整数数组 nums 中找出只出现一次的元素,并且满足线性时间复杂度(O(n))和常数额外空间的要求,可以使用位运算中的异或操作(XOR)。
而异或操作的特性是:对于两个相同的数异或操作结果为 0,对于任意数与 0
进行异或操作结果还是其本身。因此,如果我们将数组中所有的元素进行异或操作,最终得到的结果就是只出现一次的元素。
public class Solution {
public int singleNumber(int[] nums) {
int result = 0;
for (int num : nums) {
result ^= num;
}
return result;
}
}
代码逻辑:
- 我们首先将 result 初始化为 0,因为任何数与 0 异或操作都是其本身。
- 然后我们遍历整个数组 nums,对每个元素进行异或操作,将结果保存在 result 中。
- 最终,result 中的值就是只出现一次的元素。
方法二(集合法):
- 我们创建一个HashSet来存储数组中的元素。
- 然后遍历整个数组 nums,对于每个元素:
- 如果该元素不在HashSet中,将其添加到HashSet中。
- 如果该元素已经在HashSet中,说明它是出现两次的元素,将其从HashSet中移除(保证HashSet中只保存出现一次的元素)。
- 最终,HashSet中剩下的元素就是只出现一次的元素,由于题目中已经说明只有一个元素出现一次,因此直接使用 set.iterator().next() 获取这个元素并返回。
1.
public int singleNumber(int[] nums) {
Set<Integer> set = new HashSet<>();
for (int num : nums) {
if(!set.contains(num)){
set.add(num);
}else {
set.remove(num);
}
}
//集合当中只剩一个元素了
for (int num : nums) {
if(set.contains(num)){
return num;
}
}
return -1;
}
2.
public int singleNumber(int[] nums) {
Set<Integer> set = new HashSet<>();
for (int num : nums) {
if (!set.contains(num)) {
set.add(num);
} else {
set.remove(num);
}
}
//集合当中只剩一个元素了
return set.iterator().next();
}
}
请注意,虽然这种方法也可以找出只出现一次的元素,但它的时间复杂度为O(n),其中n是数组的长度,因为需要遍历整个数组。而且它使用了额外的HashSet来辅助计算,因此不满足题目要求的常数额外空间的条件。使用位运算的方法仍然是更优的解决方案。
6.2 宝石与石头
【题目链接】:宝石与石头
【题目解析】:
方法一:
直接计算:
- 创建一个变量 count,用于记录宝石的数量,初始化为 0。
- 遍历 stones 字符串中的每个字符:如果该字符在 jewels 字符串中出现,则它是宝石,将 count 增加 1。
- 完成遍历后,count 就是你拥有的石头中宝石的数量。
public class Solution {
public int numJewelsInStones(String jewels, String stones) {
int count = 0;
for (char stone : stones.toCharArray()) {
//使用indexOf()方法查找宝石字符串中是否包含当前字符,如果返回值不等于-1,说明该字符是宝石,将count加1
if (jewels.indexOf(stone) != -1) {
count++;
}
}
return count;
}
}
如果要用集合来解决的话:
- 创建一个变量 count,用于记录宝石的数量,初始化为 0。
- 创建一个 HashSet 集合 set 用于存储珠宝字符串 jewels 中的字符。遍历珠宝字符串 jewels,将每个字符添加到集合 set 中.
- 遍历字符串 stones,如果集合 set 中包含 stones 中的字符,则计数器 count 自增
- 完成遍历后,count 就是你拥有的石头中宝石的数量。
public int numJewelsInStones(String jewels, String stones) {
int count = 0;
Set<Character> set = new HashSet<>();
for (char ch : jewels.toCharArray()) {
set.add(ch);
}
for (char s : stones.toCharArray()) {
if (set.contains(s)) {
count++;
}
}
return count;
}
6.3 坏键盘打字
【题目链接】:坏键盘打字
【题目解析】:注意:此题目描述中,不区分字母的大小写
算法步骤:
- 创建一个set用于存储实际可以被输出(没有坏掉)的键。由于不区分字母的大小写,在添加字符时将其转换为大写形式
- 逐个比较预期输出的字符串中的字符(在比较字符时将其转换为大写形式),如果set中不包含该字符且不在ret集合中(即没有被输出过),说明这个键坏了,输出此键,并将其加入新创建的一个ret中(用于存储坏掉的键)。
- 在比较预期输出的字符过程中借用ret实现去重(用于存储坏掉的键),如果ret中已经包含了的就是已经被打印过的,不需要再次输出。
- 最终实现了找出实际输出字符串中不在预期输出字符串中出现的字符,并输出这些字符的功能。
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
// 注意 hasNext 和 hasNextLine 的区别
while (in.hasNextLine()) { // 注意 while 处理多个 case
String a = in.nextLine();
String b = in.nextLine();
fun(a, b);
}
}
public static void fun(String a, String b) {
Set<Character> set = new HashSet<>();
for (char ch : b.toUpperCase().toCharArray()) {
set.add(ch);
}
Set<Character> ret = new HashSet<>();
for (char ch : a.toUpperCase().toCharArray()) {
//输出没有被实际输出的单词,并在输出的过程中借用HashSet实现去重,如果ret中已经包含了的就是已经被打印过的,不需要再次输出
if (!set.contains(ch) && !ret.contains(ch)) {
System.out.print(ch);
ret.add(ch);
}
}
}
6.4 随机链表的复制
【题目链接】:随机链表的复制
【题目解析】:
next指向下一个节点,random指向随机节点
要实现深拷贝一个包含随机指针的链表,我们需要遍历原链表并逐一创建新节点,同时保持原链表节点和新链表节点的对应关系。为了实现这一点,我们可以使用HashMap来存储原链表节点和对应的新链表节点的映射关系,步骤如下:
-
创建一个HashMap,用于存储原链表节点和对应的新链表节点的映射关系。
-
第一次遍历原链表,创建新节点,并将原链表节点和新链表节点的映射关系存储到HashMap中。
-
第二次遍历原链表,更新新链表节点的 next 和 random 指针,根据HashMap中存储的映射关系找到对应的新链表节点,并将其赋给新链表节点的 next 和 random 指针。
-
返回新链表的头节点。
public Node copyRandomList(Node head) {
Node cur = head;
Map<Node,Node> map = new HashMap<>();
while(cur != null) {
Node node = new Node(cur.val);
map.put(cur,node);
cur = cur.next;
}
cur = head;
while(cur!= null) {
map.get(cur).next = map.get(cur.next);
map.get(cur).random = map.get(cur.random);
cur = cur.next;
}
return map.get(head);
}
可不可以用TreeSet?
放节点的时候肯定会比较大小,又涉及到自定义类型,所以还得给比较器啥的。而且树的效率肯定比哈希表的效率低。当然,TreeSet的底层是TreeMap,HashSet的底层是HashMap。
6.5 前k个高频单词
思考两个问题:
1.有10W个数据,如何让实现去除重复的数据,重复的数据只保留一份
可以通过使用集合(Set)数据结构来实现去除重复数据的操作。将所有数据放入集合中,集合会自动去除重复的数据,最终只保留一份。
public static void main(String[] args) {
Set<Integer> set = new HashSet<>();
// 假设data是包含10W个数据的数组
int[] data = {1, 2, 3, 1, 2, 4, 5, 3, 6, 7, ...}; // 数据示例
for (int num : data) {
set.add(num);
}
System.out.println(set);
}
2.有10W个数据,统计每个数据出现的次数
可以通过使用Map数据结构来实现统计数据次数的操作。遍历所有数据,如果Map中不包含该数据,那么存储该数据将该数据的value域置为1,如果存在获取该数据的value域,并加一,再存回Map中。
1.
public static void main(String[] args) {
Map<Integer, Integer> countMap = new HashMap<>();
// 假设data是包含10W个数据的数组
int[] data = {1, 2, 3, 1, 2, 4, 5, 3, 6, 7, ...}; // 数据示例
for (int num : data) {
if(countMap.get(num) == null){
//第一次存放
countMap.put(num,1);
}else{
//将该单词对应的值设置为该单词在HashMap中已有的值基础上加1
int count = countMap.get(num);
countMap.put(num,count+1);
}
System.out.println(countMap);
}
2.
public static void main(String[] args) {
Map<Integer, Integer> countMap = new HashMap<>();
// 假设data是包含10W个数据的数组
int[] data = {1, 2, 3, 1, 2, 4, 5, 3, 6, 7, ...}; // 数据示例
for (int num : data) {
countMap.put(num, countMap.getOrDefault(num, 0) + 1);
}
System.out.println(countMap);
}
【题目链接】:前k个高频单词
【题目解析】:
- 首先,创建一个哈希表用于记录每个单词出现的次数。
- 遍历单词列表 words,统计每个单词的出现次数,存储在刚刚创建的哈希表中。
- 然后,我们创建一个优先队列,小根堆 minHeap,用于按照单词出现次数的升序进行排序。在优先队列中,我们可以自定义比较器来实现按照出现次数降序排列,如果出现次数相同,则按字典顺序排列。
- 将哈希表中的单词和对应的出现次数加入 minHeap 中,因为我们使用了自定义比较器,所以在优先队列中,单词会按照出现次数升序排列,如果出现次数相同,则按字典顺序排列。
- 逆置一遍集合,再取出前 k 个元素,即为出现次数最多的前 k 个单词。
public List<String> topKFrequent(String[] words, int k) {
//统计words中每个单词出现的次数
Map<String, Integer> minHeap = new HashMap<>();
for (String word : words) {
//将该单词对应的值设置为该单词在HashMap中已有的值加1。如果该单词在HashMap中不存在,则将其值设置为1
minHeap.put(word, (minHeap.getOrDefault(word, 0)) + 1);
}
//根据次数建立小根堆
PriorityQueue<Map.Entry<String, Integer>> priorityQueue = new PriorityQueue<>(new Comparator<Map.Entry<String, Integer>>() {
@Override
public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
//按照字母顺序建立大根堆
if (o1.getValue().compareTo(o2.getValue()) == 0) {
return o2.getKey().compareTo(o1.getKey());
}
return o1.getValue().compareTo(o2.getValue());
}
});
//遍历map 调整优先级队列
for (Map.Entry<String, Integer> entry : minHeap.entrySet()) {
if (priorityQueue.size() < k) {
priorityQueue.offer(entry);
} else {
Map.Entry<String, Integer> top = priorityQueue.peek();
//如果次数大于小根堆的堆顶,则入队
if (top.getValue().compareTo(entry.getValue()) < 0) {
priorityQueue.poll();
priorityQueue.offer(entry);
//如果当前频率相同,则按照字母降序进入小根堆
} else if (top.getValue().compareTo(entry.getValue()) == 0) {
//如果字母小于堆顶元素,则入队
if (top.getKey().compareTo(entry.getKey()) > 0) {
priorityQueue.poll();
priorityQueue.offer(entry);
}
}
}
}
//遍历小根堆的元素,存储在list中,此时是逆序进入list的
List<String> ret = new ArrayList<>();
for (int i = 0; i < k; i++) {
Map.Entry<String, Integer> tmp = priorityQueue.poll();
ret.add(tmp.getKey());
}
//逆置list之后,里面的数据是顺序的
Collections.reverse(ret);
return ret;
}