Java算法之哈希算法
哈希表
哈希表(Hash Table),也称为散列表,是一种根据关键码值(Key Value)直接进行访问的数据结构。它通过哈希函数(Hash Function)将关键码值映射到哈希表中的一个位置,以实现数据的快速插入、删除和查询操作。
哈希表主要由一个数组构成,数组的每个元素被称为哈希桶(Bucket)或槽(Slot),其中存放着键-值对。哈希函数是哈希表的核心部分,它将任意长度的输入(Key)映射为固定长度的输出(Hash值)。通过这个映射,哈希表能够快速定位到存储特定键值对的位置,从而实现高效的查找操作。
哈希表具有非常高的空间效率和时间效率。在空间效率方面,哈希表不需要为每个键都保存一个位置,而是通过哈希函数将键映射为一个哈希值,然后将键放置在对应的位置上,因此其空间利用率通常很高。在时间效率方面,哈希表的插入、查找和删除操作的时间复杂度均为O(1),即与元素数量多少无关,因此能够实现非常快速的查找。
然而,哈希表也存在一些潜在的问题,如哈希冲突。哈希冲突是指不同的键经过哈希函数后映射到了同一个哈希值的情况。为了处理这种情况,哈希表通常使用一些冲突解决策略,如链地址法或开放地址法等。
总的来说,哈希表是一种高效、灵活的数据结构,广泛应用于各种需要快速查找的场景,如数据库索引、缓存系统等。
接下来,就介绍一下哈希表的实现。
定义一个哈希表
static class Entry{
int hash;//哈希码
Object key;//键
Object value;//值
Entry next;
public Entry(int hash, Object key, Object value) {
this.hash = hash;
this.key = key;
this.value = value;
}
}
- 类定义:
static class Entry {
这里定义了一个静态内部类Entry
。静态内部类意味着这个类不需要外部类的实例就可以被实例化。在HashMap
的实现中,这样的设计有助于减少内存占用,因为每个Entry
对象不需要持有对外部HashMap
对象的引用。
- 成员变量:
int hash;//存储键的哈希码。当向哈希表中插入一个键值对时,会先计算键的哈希码,然后根据这个哈希码确定键应该放在哈希表的哪个位置。
Object key;// 存储键。键用于唯一标识一个键值对。
Object value;//存储值。与键相关联的值。
Entry next; //指向下一个`Entry`的引用。这是链地址法解决哈希冲突的一种常见方式。当两个或多个键的哈希码相同(即产生了哈希冲突) //时,这些键对应的`Entry`对象会形成一个链表,通过`next`字段链接在一起。
- 构造函数:
public Entry(int hash, Object key, Object value) {
this.hash = hash;
this.key = key;
this.value = value;
}
这是Entry
类的构造函数,它接受一个哈希码、一个键和一个值作为参数,并将它们分别赋值给类的成员变量。
在HashMap
的实际实现中,这个Entry
类(或其类似的形式)会被用来在内部存储键值对。当向HashMap
中插入一个键值对时,会先计算键的哈希码,然后根据这个哈希码确定键应该放在哈希表的哪个位置(通常是一个数组的索引)。如果那个位置已经有一个或多个Entry
(即发生了哈希冲突),新的Entry
会被添加到链表的末尾。
当从HashMap
中查找一个键时,会再次计算该键的哈希码,然后定位到哈希表中的相应位置。然后,会遍历该位置上的链表,直到找到匹配的键或遍历完整个链表(即键不存在)。
总的来说,这个Entry
类是实现哈希表数据结构的关键部分,它负责存储键值对并处理哈希冲突。
哈希表的get方法
Object get(int hash,Object key){
int index = hash&(table.length-1);
if(table[index]==null){
return null;
}
Entry p = table[index];
while(p!=null){
if(p.key.equals(key)){
return p.value;
}
p = p.next;
}
return null;
}
方法定义
Object get(int hash, Object key) {
这个方法接受两个参数:hash
是键的哈希码,key
是你要查找的键。方法的返回类型是Object
,这意味着它可以返回任何类型的值。
计算索引
int index = hash & (table.length - 1);
这行代码计算了键在哈希表中的索引位置。这里使用了位与操作(&
)和哈希表长度减一(table.length - 1
)来确保索引在哈希表的有效范围内。这种计算方式通常用于实现哈希表的开放寻址法中的线性探测或二次探测,但在这里它更像是用于数组形式的哈希表,其中数组的每个位置可能包含一个链表来处理哈希冲突。
检查索引位置的链表头
if (table[index] == null) {
return null;
}
这里首先检查哈希表中指定索引位置的链表头是否为null
。如果是null
,说明该位置没有链表,因此键肯定不在哈希表中,方法返回null
。
遍历链表
Entry p = table[index];
while (p != null) {
if (p.key.equals(key)) {
return p.value;
}
p = p.next;
}
如果链表头不为null
,代码会遍历链表以查找键。遍历过程中,使用equals
方法比较当前节点的键与要查找的键是否相等。如果找到匹配的键,则返回对应的值。如果遍历完整个链表都没有找到匹配的键,方法会返回null
。
返回结果
return null;
如果在哈希表中没有找到与给定键匹配的项,方法最终会返回null
。
总结
这个get
方法实现了在哈希表中根据键查找值的基本逻辑。它首先计算键的哈希码对应的索引位置,然后检查该位置是否有链表,并遍历链表以查找匹配的键。如果找到,返回对应的值;否则,返回null
。这种方法是哈希表实现中常见的查找逻辑,特别是在处理哈希冲突时使用链表作为解决方案时。
哈希表的put方法
void put(int hash,Object key,Object value){
int index = hash & (table.length-1);
if(table[index] == null) {
//如果此处有空位直接新增
table[index] = new Entry(hash, key, value);
}else {
//如果没有空位,遍历下面的元素
Entry p = table[index];
while (true) {
//如果此元素下的key与新增的key相同,则更新元素
if (p.key.equals(key)) {
p.value = value;
return;
}
//如果p的下一个元素为空,则需要在下一个空位上新增,此时跳出循环,执行新增操作
if(p.next==null){
break;
}
p=p.next;
}
//进行新增操作
p.next = new Entry(hash,key,value);
}
//数组大小变大
size++;
}
方法定义
void put(int hash, Object key, Object value) {
这个方法接受三个参数:hash
是键的哈希码,key
是要插入的键,value
是与键相关联的值。
计算索引
int index = hash & (table.length - 1);
这行代码与get
方法中的计算索引的方式相同,用于确定键应该插入到哈希表的哪个位置。
检查索引位置的链表头
if (table[index] == null) {
// 如果此处有空位直接新增
table[index] = new Entry(hash, key, value);
} else {
// 如果没有空位,遍历下面的元素
Entry p = table[index];
...
}
首先检查哈希表中指定索引位置的链表头是否为null
。如果是null
,说明该位置还没有链表,直接创建一个新的Entry
对象并将其放在该位置。
遍历链表
如果链表头不为null
,则开始遍历链表:
while (true) {
// 如果此元素下的key与新增的key相同,则更新元素
if (p.key.equals(key)) {
p.value = value;
return;
}
// 如果p的下一个元素为空,则需要在下一个空位上新增,此时跳出循环,执行新增操作
if (p.next == null) {
break;
}
p = p.next;
}
遍历链表时,会检查每个Entry
的键是否与要插入的键相等。如果找到相等的键,则更新该Entry
的值并返回。如果遍历到链表的末尾(即p.next
为null
),则跳出循环,准备在链表末尾插入新的Entry
。
在链表末尾插入新的Entry
// 进行新增操作
p.next = new Entry(hash, key, value);
在链表的末尾插入一个新的Entry
对象,将其next
字段设置为null
。
更新哈希表大小
// 数组大小变大
size++;
总结
这个put
方法实现了向哈希表中插入键值对的基本逻辑。它首先计算键的哈希码对应的索引位置,然后检查该位置是否有链表。如果没有链表,则直接在该位置创建一个新的Entry
。如果有链表,则遍历链表以查找是否有相同键的Entry
。如果找到,则更新其值;如果没找到,则在链表末尾插入新的Entry
。最后,更新哈希表中键值对的数量。
哈希表的remove方法
Object remove(int hash,Object key){
//根据hash码的值找出索引
int index = hash & (table.length-1);
//如果当前索引为空,那么直接返回空
if(table[index]==null){
return null;
}
//定义两个指针,一个为前驱一个为后继
Entry p = table[index];
//前驱初始化为null
Entry prve = null;
//当p不为null时,执行循环
while(p!=null) {
//如果找到了key的值相同,那么执行删除操作
if (p.key.equals(key)) {
//当前驱为null时,说明只有一个结点,直接让索引处的值指向p.next
if (prve==null){
table[index] = p.next;
}//如果前驱不为null,那就让前驱的next指向p的next
else {
prve.next = p.next;
}
//执行完删除操作,size减一
size--;
//返回删除的值
return p.value;
}
//更新prve和p的值
prve = p;
p=p.next;
}
//如果没有找到相对应的值,返回空
return null;
}
方法定义
java复制代码
Object remove(int hash, Object key) {
该方法接受两个参数:hash
是键的哈希码,key
是要删除的键。方法返回被删除的值,如果键不存在则返回null
。
计算索引
java复制代码
int index = hash & (table.length - 1);
通过哈希码和哈希表长度计算键在哈希表中的索引位置。
检查索引位置的链表头
if (table[index] == null) {
return null;
}
如果索引位置的链表头为null
,说明没有链表存在,直接返回null
。
初始化前驱和后继指针
Entry p = table[index];
Entry prve = null;
p
用于遍历链表,prve
用于记录p
的前一个节点,初始化为null
。
遍历链表
while (p != null) {
if (p.key.equals(key)) {
...
}
prve = p;
p = p.next;
}
使用while
循环遍历链表。如果找到键与要删除的键相同的Entry
,则执行删除操作;否则,更新前驱和后继指针,继续遍历。
执行删除操作
if (prve == null) {
table[index] = p.next;
} else {
prve.next = p.next;
}
size--;
return p.value;
当找到要删除的Entry
时,根据前驱是否为null
来判断是删除链表头还是链表中间的节点。如果前驱为null
,说明p
是链表头,直接让索引处的链表头指向p
的下一个节点;否则,让前驱的next
指向p
的下一个节点。无论哪种情况,都需要将size
减一,并返回被删除的值。
返回结果
return null;
如果遍历完整个链表都没有找到要删除的键,则返回null
。
总结
这个remove
方法实现了从哈希表中删除指定键及其对应的值的功能。它首先根据哈希码计算键的索引位置,然后遍历该位置的链表来查找要删除的键。找到后,根据前驱节点是否为null
来执行不同的删除操作,并更新哈希表的大小。如果遍历完链表都没有找到要删除的键,则返回null
。
哈希表的resize方法
在哈希表中,当键值对数量达到某个阈值时,会进行数组的扩容(即创建一个新的更大的数组,并重新计算所有键值对的哈希码和索引位置),但在上面的代码中,还暂时没有实现这个方法,接下来就是实现这个方法,在put方法中,如果达到了这个阈值,就直接调用resize方法进行扩容。
private void resize(){
Entry[] newtable = new Entry[table.length<<1];
for(int i = 0;i< table.length;i++){
//拿到每个链表头
Entry p = table[i];
if(p != null){
/*
* 拆分链表,移动到新数组,拆分规律
* 一个链表最多拆分成两个
* hash & table.length == 0的一组
* hash & table.length != 0的一组
* 例如对于长度为8的table,现在有哈希值为0,8,16,24
* 32,40,48的七个数据
* 那么拆分后,0,16,32,48就为一组,剩下的为另一组
* */
Entry a = null;
Entry b = null;
Entry ahead = null;
Entry bhead = null;
while(p != null){
if((p.hash&table.length)==0){
if(a!=null){
a.next = p;
}else{
ahead = p;
}
//分配到a
a = p;
}else{
if(b!=null){
b.next=p;
}else{
bhead = p;
}
b = p;
}
p = p.next;
}
if(a!=null){
a.next = null;
newtable[i] = ahead;
}
if(b!=null){
b.next = null;
newtable[i+table.length] = bhead;
}
}
}
}
几个问题
/*为什么计算索引位置用式子:hash & (数组长度-1)
为什么旧链表会拆分成两条,一条hash & 旧数组长度==0,另一条hash & 旧数组长度!=0
为什么拆分后的两条链表,一个原索引不变,另一个是原索引+旧数组长度
它们都有个共同的前提:数组长度是2的n次方
1.hash % 数组长度等价于hash & (数组长度-1)
在十进制中,对于求10,100,1000这些数作为除数的余数,只需要看最后几位就可以了
因为前面的都被整除掉了。这是十进制求余的一个小规律,二进制也有类似的鬼绿
30 % 2 = 0,0011110 % 0000010 = 0000000
30 % 4 = 2, 0011110 % 0000100 = 0000010
30 % 8 = 2, 0011110 % 0001000 = 0000110
30 % 16 = 2, 0011110 % 0010000 = 0001110
30 % 32 = 2, 0011110 % 0100000 = 0011110
对于二进制的数,2用二进制表示为10,4为100,8为1000,以此类推通过上面式子可知,对于二进制来说
求2,4,8这种是2的n次方的数的余数,也可以只看后几位,比如2,只需要看被除数二进制的最后一位
那么4就要看被除数的后两位。
那么求余数,我们只需要保留后几位就可以了,对于2,保留后一位,4,保留后两位。根据按位与运算的规律
与0进行按位与运算,结果还是0,与1进行按位与运算,结果仍是原数,那保留后三位,只需要与0000111进行按位与运算即可
而111就是十进制中的7,所以计算索引位置时,我们可以用式子hash & (数组长度-1),因为数组长度是2的n次方是前提
2.一条hash & 旧数组长度==0,另一条hash & 旧数组长度 != 0
进行与运算其实就是检查更高位上的数字是否为1,如果为1,那么就应该是新索引
3.理解了第二个问题,那么第三个问题就迎刃而解了*/
Object.hashCode()方法
Object.hashCode()
是 Java 中所有对象的基类 Object
类的一个方法。该方法用于返回对象的哈希码值,这个哈希码通常用于数据结构,如哈希表(如 HashMap
和 HashSet
)。因为上面的方法中,哈希值是由人为输入的,但是人为输入哈希值,很容易出现冲突的情况,所以可以在此使用hashCode方法进行哈希值的获取。
基本概念
- 哈希码(Hash Code):哈希码是一个整数,它是通过对象的内部信息(通常是对象的字段)计算出来的。如果两个对象根据
equals(Object)
方法是相等的,那么调用这两个对象的hashCode
方法必须产生相同的整数结果。 - 哈希表:哈希表是一种数据结构,它允许我们以平均常数时间复杂度进行插入、删除和查找操作。哈希表通过计算键(key)的哈希码来确定元素在表中的位置。
使用注意事项
- 一致性:如果两个对象根据
equals(java.lang.Object)
方法是相等的,那么调用这两个对象的hashCode
方法必须产生相同的整数结果。 - 性能:哈希码的计算应该尽可能快,因为哈希表在插入、删除和查找元素时都会使用哈希码。
- 分布:理想情况下,哈希码应该均匀地分布在整个整数范围内,以减少哈希冲突并提高哈希表的性能。
String.hashCode()方法
在Java中,String
类的 hashCode()
方法返回该字符串的哈希码值,该值是根据字符串内容计算得出的。哈希码通常用于在哈希表中快速查找键,或者在其他数据结构(如哈希集合)中确定元素的唯一性。
String
类的 hashCode()
方法的设计保证了:
- 对于两个不同的字符串,只要它们的内容不同,它们的哈希码也很可能不同。这有助于在哈希表中区分不同的键。
- 对于相同的字符串(即内容完全相同的字符串),无论它们是在何时创建的,或者在程序中的哪个位置,它们的哈希码都是相同的。这保证了哈希表能够正确地识别相同的键。
注意,虽然哈希码冲突(即不同的字符串具有相同的哈希码)在理论上是可能的,但在实际使用中,String
类的 hashCode()
方法设计得非常出色,使得哈希码冲突的概率非常低。
Object的hashCode方法只能生成数字,而String类的hashCode方法可以生成字符串等,使用起来更为广泛。
力扣算法题
两数之和
首先是力扣题库的第一题
)
两数之和,第一次做的时候我是直接使用暴力循环来做的,居然没有超时,时间复杂度为O(n^2)
class Solution {
public int[] twoSum(int[] nums, int target) {
for(int i = 0;i<nums.length;i++){
for(int j = i+1;j<nums.length;j++){
if(nums[i]+nums[j]==target){
return new int[] {i,j};
}
}
}
return new int [0];
}
}
那么既然现在学习了哈希表,就可以使用哈希表来进行时间复杂度为O(1)的算法
使用hash表进行的算法大概思路就是,遍历数组,然后确定与他相匹配的数字,在map中查找这个数字,如果能查到,返回这两个数字的索引,如果不能查到,那就把这个数字插入到map中。
class Solution {
public int[] twoSum(int[] nums, int target) {
HashMap<Integer,Integer> h = new HashMap<>();
for(int i = 0;i< nums.length;i++){
int x = target-nums[i];
if(h.containsKey(x)){
return new int[]{h.get(x),i};
}else{
h.put(nums[i],i);
}
}
return null;
}
}
执行用时击败了99.35%的用户,比使用暴力循环快了很多。
无重复字符的最长子串
这一题也可以用哈希表来进行求解
class Solution {
public int lengthOfLongestSubstring(String s) {
HashMap<Character, Integer> map = new HashMap<>();
int maxlength = 0;
int start = 0;
int length = s.length();
for (int i = 0; i < length; i++) {
char c = s.charAt(i);
if (map.containsKey(c)) {
//start = map.get(c)+1;
start = Math.max(start, map.get(c) + 1);
}
map.put(c, i);
maxlength = Math.max(maxlength, i - start + 1);
}
return maxlength;
}
}
基本思想是先定义两个指针,一个起始指针,一个末尾指针,对字符串进行遍历,检查字符串中是否存在这个字符,如果存在,就让起始指针向后移,在这里有一个点要注意,按照正常思维来说,应该是把起始指针向重复的那一位向后再移动一位,但是当中间有重复的时,有可能会出现start指针回退的情况,所以代码应该修改为
start = Math.max(start, map.get(c) + 1);
字母异位词分组
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
HashMap<String,List<String>> map = new HashMap<>();
for (String str:strs){
//把字符串转化为数组,方便排序
char[] c = str.toCharArray();
//排序
Arrays.sort(c);
//再把数组转化为字符串
String s = new String(c);
//确定集合的位置,因为每一个映射中都含有一个集合
List<String> list = map.get(s);
//如果集合为空,就新建一个集合,并插入到哈希表中
if(list == null){
list = new ArrayList<>();
map.put(s,list);
}
//在集合中插入数据
list.add(str);
}
return new ArrayList<>(map.values());
}
}
基本思路
- 遍历字符串数组,每个字符串中的字符重新排序后作为key值
- 所谓分组就是准备一个集合,把这些单词加入到key相同的集合中
- 返回分组结果