java小白翻身-手写HashMap

System.out.println(customer.getName());

}

}

这是java自带的HashMap,我们就需要实现类似的功能。

HashCode和数组

==============================================================================

先考虑第一个问题,既然HashMap底层是用数组,可是key不一定是数字,那么就得想办法把key转化为一个数字。

HashCode就是一种hash码值,任何一个字符串,对象都有自己的Hash码,计算方式如下:

s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1]

使用 int 算法,这里 s[i] 是字符串的第 i 个字符,n 是字符串的长度,^ 表示求幂。空字符串的哈希值为 0。比如字符串是“abc”, 套用公式计算如下:

String a = “abc”;

// 97 * 31^(3-1) + 98 * 31^(3-2) + 99 ^ (3-3)

//= 93217 + 3038 + 99 = 96354

System.out.println(a.hashCode());

答案正是:

image

我们99%的情况,HashMap的key就是String,所以都可以用这个公式去计算。HashCode算法的牛逼之处在于,任何字符串都可以对应唯一的一个数字。

相信你肯定有一个疑惑,就是为啥Hash算法用的是31的幂等运算?

在名著 《Effective Java》第 42 页就有对 hashCode 为什么采用 31 做了说明:

之所以使用 31, 是因为他是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算(低位补0)。使用素数的好处并不很明显,但是习惯上使用素数来计算散列结果。 31 有个很好的性能,即用移位和减法来代替乘法,可以得到更好的性能: 31 * i == (i << 5) - i, 现代的 VM 可以自动完成这种优化。这个公式可以很简单的推导出来。

反正大概意思就是一些数学家发现,用31计算hash值的话,性能是最好的。毕竟你也不希望算个HashCode花太多时间吧。

一开始,我们不需要做的太完美,刚开始的时候,完成永远优于完美。

public class TuziHashMap {

private Object arr[];

private int capacity = 20; //初始化容量

public TuziHashMap(){

arr = new Object[capacity];

}

}

TuziHashMap内部维护了一个数组,初识容量为20。接下来,实现put方法,put方法就是给这个Map添加新的元素。

public Object put(String key,Object value){

//1. 算出HashCode

int hashCode = key.hashCode();

//2. 直接取模,得到余数,这个余数就是数组的下标

int index = hashCode % capacity;

//3. 将对应的数据放入数组的对应格子

this.arr[index] = value;

return value;

}

然后是get方法,接收对应的key值,返回对应的元素。

public Object get(String key){

//1. 算出HashCode

int hashCode = key.hashCode();

//2. 直接取模,得到余数,这个余数就是数组的下标

int index = hashCode % capacity;

//3. 将对应的数据放入数组的对应格子

return this.arr[index];

}

代码非常相似,先别管优不优化,实现功能再说,这就是踏出的第一步。

TuziHashMap map = new TuziHashMap();

map.put(“王大锤”,new Customer(“王大锤”));

System.out.println(map.get(“王大锤”));

效果:

image

Hash碰撞

=========================================================================

Hash碰撞也叫做Hash冲突,就是当两个key算出来HashCode相同的情况下,就会产生冲突。

目前我们的Map类中,底层的数组长度默认值20(真正的HashMap默认值是16),当存入的数据足够多并且不进行扩容的话,Hash碰撞是必然的。所谓Hash碰撞,就是比如说两个key明明是不同的,但是经过hash算法后,hash值竟然是相同的。那么另一个key的value就会覆盖之前的,从而引起错误。

image

添加专门的hash方法,然后先写死hashcode为10,那就一定会发生hash碰撞!

private int hash(String key){

//return key.hashCode();

return 10;

}

get方法也要改过来,用hash方法:

public Object get(String key){

//1. 算出HashCode

int hashCode = hash(key);

//2. 直接取模,得到余数,这个余数就是数组的下标

int index = hashCode % capacity;

//3. 将对应的数据放入数组的对应格子

return this.arr[index];

}

TuziHashMap map = new TuziHashMap();

map.put(“王大锤”,new Customer(“王大锤”));

map.put(“王尼玛”,new Customer(“王尼玛”));

System.out.println(map.get(“王大锤”));

结果:

Customer{name=‘王尼玛’, sex=‘null’, birthDate=‘null’, phoneNumber=‘null’, status=0}

原因:产生了Hash碰撞,后面的王尼玛直接把王大锤给覆盖了。

怎么解决Hash碰撞呢?因为Hash碰撞在实际应用中肯定会出现,所以,我们就不能在数组的每一个格子中直接存Object对象,而应该弄一个链表。

假如出现Hash碰撞,就直接在链表中加一个节点。然后,下次取元素的时候,如果遇到Hash碰撞的情况就去循环那个链表,从而解决了Hash冲突的问题。

有了基本的思路,我们就得去修改put方法。

put方法接收一个key,一个value。如果发生Hash冲突,就得把新的key和value加到链表的末尾。

初步代码如下:

image

但是,这样有个问题,你没法取。为什么没法取呢?因为,链表上的每一个节点,我们只保存了value,没有key。那么相同的key的情况,怎么去获取对应的元素呢?比如两个key,分别是“王大锤”和“王尼玛”,假如他们的HashCode是相同的,因为链表上没有保存“王大锤”和“王尼玛”两个key,我们就没法区分了。

image

没有办法,只好修改一下之前的Node。为什么之前没有加key呢?因为之前的Node类是为LinkedList服务的,LinkedList不需要key这个东西。

在linkedList中,原来的add方法是不需要key的,所以也需要做一个修改:

/**

  • 原来的add方法保留,调用重载的add方法

  • @param object

  • @return

*/

public boolean add(Object object){

return add(null,object);

}

/**

  • 新增的add方法,增加参数key

*/

public boolean add(Object key,Object object){

//将数据用节点类包装好,这样才能实现下一个数据的指向

Node data = new Node(object);

if(key != null){

data.key = key;

}

//先判断是否是第一个节点

if(this.firstNode == null){

this.firstNode = data;

this.currentNode = data;

}else{

//不是第一个节点,就指向当前节点的下一个节点,即currentNode.next

this.currentNode.next = data;

//因为已经指向下一个了,所以当前节点也要移动过来

this.currentNode = data;

}

size++;

return true;

}

如果你读过jdk里面的源码,就一定会知道,在很多Java基础类中,都有一大堆的构造方法,一大堆方法重载。而且,很多方法里面都会调用同名的方法,只不过参数传的不一样罢了。

我之前也一直不理解,为什么要整的这么麻烦,后来当自己尝试写一些数据结构的时候,才明白,不这样搞真的不行。方法重载的意义不是去秀肌肉的,而是减少代码的工作量。

比如,因为LinkedList需要增加key的保存,原来的add方法是没有的。我们不太好直接修改原来的add方法,因为万一这个类被很多调用了,那么很多地方都会受到不同程度的影响。所以,类的设计思路有一条很重要,那就是:

做增量好过做修改。

还是原来的测试代码:

TuziHashMap map = new TuziHashMap();

map.put(“王大锤”,new Customer(“王大锤”));

map.put(“王尼玛”,new Customer(“王尼玛”));

System.out.println(map.get(“王大锤”));

因为我们修改了hash方法,强行导致Hash碰撞,所以目前是肯定冲突的。

运行:

Customer{name=‘王大锤’, sex=‘null’, birthDate=‘null’, phoneNumber=‘null’, status=0}

成功了,王尼玛没有覆盖掉王大锤。

toString方法

=============================================================================

为了更好的调试,我们给TuziHashMap添加toString方法

public String toString(){

StringBuffer sb = new StringBuffer(“[\n”);

for (int i = 0; i < arr.length; i++) {

LinkedList list = arr[i];

if(list != null){

while(list.hasNext()){

Node node = list.nextNode();

sb.append(String.format(“\t{%s=%s}”,node.key,node.data));

if(list.hasNext())

sb.append(“,\n”);

else

sb.append(“\n”);

}

}

}

sb.append(“]”);

return sb.toString();

}

运行之前的测试代码,就是这样的:

image

百万级数据压测

==========================================================================

做一点性能测试,又有新的问题了。。。

###步骤 1 来100w条数据,看看要花多久?

long startTime = System.currentTimeMillis(); //获取开始时间

TuziHashMap map = new TuziHashMap();

for (int i = 0; i < 100000; i++) {

map.put(“key” + i, “value–” + i);

}

System.out.println(map);

long overTime = System.currentTimeMillis(); //获取结束时间

System.out.println(“程序运行时间为:”+(overTime-startTime)+“毫秒”);

上面的代码就是循环10w次,然后用一个toString全部打印出来,看看需要多久吧?

image

大概是800毫秒左右。

可如果是100w呢?

image

直接报错了,原因是JVM内存溢出。这是为什么呢?

那是因为,我们的Map初识容量是20,100w条数据插进去,想也知道链表是扛不住了。

步骤 2 设计思路

1.初始化数组

2.每次put的时候,就计算是不是快溢出来了,如果是,数组翻倍。

3.由于数组容量翻倍了,原来的数据需要重新计算hash值,散列到新的数组中去。(不这样做的话,数组利用率会不够,很多数据全部挤在前半段)

步骤 3 添加一个size

现在的数组长度是20,最理想的情况,添加20个元素,一次Hash碰撞都没有,均匀分布在20个格子里面。当添加第21个的时候,一定会发生Hash碰撞,这个时候我们就需要去扩容数组了。

image

image

步骤 4 先设计,后实现

因为代码写到这里,已经开始慢慢变得复杂了。我们可以参考之前接口的章节,先设计,再谈如何实现。只要设计是合理了,就别担心能不能实现的问题。如果你一开始就陷入到各种细节里面,那你就很难更进一步。

image

image

利用DIEA的自动提示功能,生成扩容方法。

步骤 5 扩容方法

private void enlarge() {

capacity *= 2; // 等同于 capacity = capacity * 2 ,这么写只是因为我想装个逼。

LinkedList[] newArr = new LinkedList[capacity];

System.out.println(“数组扩容到” + capacity);

int len = arr.length; //把arr的长度写在外面,这样能减少一丢丢的计算

for (int i = 0; i < len; i++) {

LinkedList linkedList = arr[i];

if(arr[i] != null){

while(linkedList.hasNext()){

Node node = linkedList.nextNode();

Object key = node.key;

Object value = node.data;

//将原有的数据一个个塞到新的数组

reHash(newArr,key,value);

}

}

}

//新数组正式上位

this.arr = newArr;

}

每个数组元素都是一个链表,这个链表里面每一个数据都应该要遍历到,需要再写一个reHash方法,重新计算Hash值。

步骤 6 reHash方法

private void reHash(LinkedList[] newArr, Object key, Object value) {

//1. 算出HashCode

int hashCode = hash(key.toString());

//2. 直接取模,得到余数,这个余数就是数组的下标

int index = hashCode % capacity ;

//3. 将对应的数据放入数组的对应格子

if(newArr[index] == null){

newArr[index] = new LinkedList();

}

newArr[index].add(key,value);

}

和put方法差不多,只是多了一个参数,如果是JDK的套路,肯定又得做函数重载了吧。不过现在赶进度,就不做优化了。

步骤 7 新的问题出现

做个测试,我们来个26条数据,肯定会触发扩容的。

long startTime = System.currentTimeMillis(); //获取开始时间

TuziHashMap map = new TuziHashMap();

for (int i = 0; i < 260; i++) {

map.put(“key” + i, “value–” + i);

}

System.out.println(map);

long overTime = System.currentTimeMillis(); //获取结束时间

System.out.println(“程序运行时间为:”+(overTime-startTime)+“毫秒”);

image

天啊,竟然报错了!

从错误信息看,index是-142,也就是说hashCode是负数。

hashCode怎么会是负数呢?

答案是:hashCode肯定有可能是负数。因为HashCode是int类型,大家都知道,int型的值取值范围为Integer.MIN_VALUE(-2147483648)~Integer.MAX_VALUE(2147483647),那怎么修改呢?可以想到取绝对值,其实还有一个更酷的方法,就是用与逻辑。

为了防止漏改,我们把取模的运算抽出来做成方法。

image

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,不论你是刚入门Java开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
8)~Integer.MAX_VALUE(2147483647),那怎么修改呢?可以想到取绝对值,其实还有一个更酷的方法,就是用与逻辑。

为了防止漏改,我们把取模的运算抽出来做成方法。

image

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-FC6z5SvK-1715734100714)]

[外链图片转存中…(img-kThjBKim-1715734100714)]

[外链图片转存中…(img-4aTdeuAV-1715734100715)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,不论你是刚入门Java开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

  • 22
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值