京东太猛,手写hashmap又一次重现江湖

Posted 疯狂创客圈

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了京东太猛,手写hashmap又一次重现江湖相关的知识,希望对你有一定的参考价值。

文章很长,且持续更新,建议收藏起来,慢慢读!疯狂创客圈总目录 博客园版 为您奉上珍贵的学习资源 :

免费赠送 :《尼恩Java面试宝典》 持续更新+ 史上最全 + 面试必备 2000页+ 面试必备 + 大厂必备 +涨薪必备
免费赠送 :《尼恩技术圣经+高并发系列PDF》 ,帮你 实现技术自由,完成职业升级, 薪酬猛涨!加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷1)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷2)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷3)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领

免费赠送 资源宝库: Java 必备 百度网盘资源大合集 价值>10000元 加尼恩领取


京东太猛,手写hashmap又一次重现江湖

说在前面

在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如京东、极兔、有赞、希音、百度、网易的面试资格,遇到一个很重要的面试题:

手写一个hashmap?

尼恩读者反馈说,之前总是听人说,大厂喜欢手写hashmap、手写线程池,这次终于碰到了。

和线程池的知识一样,hashmap既是面试的核心知识,又是开发的核心知识。

手写线程池,之前已经通过博客、公众号的形式已经发布:

网易一面:如何设计线程池?请手写一个简单线程池?

在这里,老架构尼恩再接再厉,和架构师唐欢一块,给大家做一下手写hashmap系统化、体系化的线程池梳理,使得大家可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”

也一并把这个题目以及参考答案,收入咱们的 《尼恩Java面试宝典》V68版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。

注:本文以 PDF 持续更新,最新尼恩 架构笔记、面试题 的PDF文件,请关注本公众号 【技术自由圈】获取,暗号:领电子书

手写极简版本的HashMap

如果对HashMap理解不深,可以手写一个极简版本的HashMap,不至于颗粒无收

尼恩给大家展示,两个极简版本的首先HashMap

  • 一个GoLang手写HashMap极简版本
  • 一个Java手写HashMap极简版本

一个GoLang手写HashMap极简版本

设计不能少,首先,尼恩给大家做点简单的设计:

如果确实不知道怎么写, 可以使用 Wrapper 装饰器模式,把Java或者Golang内置的 HashMap包装一下,然后可以交差了。

如果是使用Go语言实现的话,具体实现方式是通过Go语言内置的map来实现,其中key和value都是int类型。

以下是一个Go语言版本 简单的手写HashMap示例:

package main

import "fmt"

type HashMap struct 
    data map[int]int


func NewHashMap() *HashMap 
    return &HashMap
        data: make(map[int]int),
    


func (h *HashMap) Put(key, value int) 
    h.data[key] = value


func (h *HashMap) Get(key int) int 
    if val, ok := h.data[key]; ok 
        return val
     else 
        return -1
    


func main() 
    m := NewHashMap()
    m.Put(1, 10)
    m.Put(2, 20)
    fmt.Println(m.Get(1)) // Output: 10
    fmt.Println(m.Get(3)) // Output: -1

这个HashMap实现了Put方法将key-value对存储在map中,Get方法从map中获取指定key的value值。

为啥要先说go语言版本, Go性能高、上手快,未来几年的Java开发,理论上应该是 Java、Go 并存模式, 所以,首先来一个go语言的版本。

当然,以上的版本,太low了。

这样偷工减料,一定会被嫌弃。 只是在面试的时候,可以和面试官提一嘴, 咱们对设计模式还是很娴熟滴。

既然是手写 手写HashMap ,那么就是要从0开始,自造轮子。接下来,来一个简单版本的Java手写HashMap示例。

一个Java手写HashMap极简版本

设计不能少,首先,尼恩给大家做点简单的设计:

  • 数据模型设计:

设计一个Entry数组来存储每个key-value对,其中每个Entry又是一个链表结构,用于解决hash冲突问题。

  • 访问方法设计:

设计Put方法将key-value对存储在map中,Get方法从map中获取指定key的value值。

以下是一个简单的Java手写HashMap示例:

public class MyHashMap<K, V> 
    private Entry<K, V>[] buckets;
    private static final int INITIAL_CAPACITY = 16;

    public MyHashMap() 
        this(INITIAL_CAPACITY);
    

    @SuppressWarnings("unchecked")
    public MyHashMap(int capacity) 
        buckets = new Entry[capacity];
    

    public void put(K key, V value) 
        Entry<K, V> entry = new Entry<>(key, value);
        int bucketIndex = getBucketIndex(key);
        Entry<K, V> existingEntry = buckets[bucketIndex];

        if (existingEntry == null) 
            buckets[bucketIndex] = entry;
         else 
            while (existingEntry.next != null) 
                if (existingEntry.key.equals(key)) 
                    existingEntry.value = value;
                    return;
                
                existingEntry = existingEntry.next;
            
            if (existingEntry.key.equals(key)) 
                existingEntry.value = value;
             else 
                existingEntry.next = entry;
            
        
    

    public V get(K key) 
        int bucketIndex = getBucketIndex(key);
        Entry<K, V> existingEntry = buckets[bucketIndex];

        while (existingEntry != null) 
            if (existingEntry.key.equals(key)) 
                return existingEntry.value;
            
            existingEntry = existingEntry.next;
        

        return null;
    

    private int getBucketIndex(K key) 
        int hashCode = key.hashCode();
        return Math.abs(hashCode) % buckets.length;
    

    static class Entry<K, V> 
        K key;
        V value;
        Entry<K, V> next;

        public Entry(K key, V value) 
            this.key = key;
            this.value = value;
            this.next = null;
        
    

咱们这个即为简单的版本,有两个特色:

  • 解决hash碰撞,使用了 链地址法
  • 将键转化为数组的索引的时候,使用 了 优化版本的 除留余数法

如果对这些基础知识不熟悉,可以看一下 尼恩给大家展示的基本原理。

哈希映射(哈希表)基本原理

为了一次存储便能得到所查记录,在记录的存储位置和它的关键字之间建立一个确定的对应关系H,已H(key)作为关键字为key的记录在表中的位置,这个对应关系H为哈希(Hash)函数, 按这个思路建立的表为哈希表。

哈希表也叫散列表。

从根本上来说,一个哈希表包含一个数组,通过特殊的关键码(也就是key)来访问数组中的元素。

哈希表的主要思想:

(1)存放Value的时候,通过一个哈希函数,通过关键码(key)进行哈希运算得到哈希值,然后得到映射的位置, 去寻找存放值的地方 ,

(2)读取Value的时候,也是通过同一个哈希函数,通过关键码(key)进行哈希运算得到哈希值,然后得到 映射的位置,从那个位置去读取。

哈希函数

哈希表的组成取决于哈希算法,也就是哈希函数的构成。

哈希函数计算过程会将键转化为数组的索引。

一个好的哈希函数至少具有两个特征:

(1)计算要足够快;

(2)最小化碰撞,即输出的哈希值尽可能不会重复。

那接下来我们就来看下几个常见的哈希函数:

直接定址法

  • 取关键字或关键字的某个线性函数值为散列地址。
  • 即 f(key) = key 或 f(key) = a*key + b,其中a和b为常数。

除留余数法

将整数散列最常用方法是除留余数法。除留余数法的算法实用得最多。

我们选择大小为m的数组,对于任意正整数k,计算k除以m的余数,即f(key)=k%m,f(key)<m。这个函数的计算非常容易(在Java中为k% M)并能够有效地将键散布在0到M-1的范围内。

数字分析法

  • 当关键字的位数大于地址的位数,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。
  • 仅适用于所有关键字都已知的情况下,根据实际应用确定要选取的部分,尽量避免发生冲突。

平方取中法

  • 先计算出关键字值的平方,然后取平方值中间几位作为散列地址。
  • 随机分布的关键字,得到的散列地址也是随机分布的。

随机数法

  • 选择一个随机函数,把关键字的随机函数值作为它的哈希值。
  • 通常当关键字的长度不等时用这种方法。

每种数据类型都需要相应的散列函数.

例如,Interge的哈希函数就是直接获取它的值:

public static int hashCode(int value) 
    return value;

对于字符串类型则是使用了s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1]的算法:

public int hashCode() 
    int h = hash;
    if (h == 0 && value.length > 0) 
        hash = h = isLatin1() ? StringLatin1.hashCode(value)
                              : StringUTF16.hashCode(value);
    
    return h;


public static int hashCode(byte[] value) 
    int h = 0;
    for (byte v : value) 
        h = 31 * h + (v & 0xff);
    
    return h;

public static int hashCode(byte[] value) 
    int h = 0;
    int length = value.length >> 1;
    for (int i = 0; i < length; i++) 
        h = 31 * h + getChar(value, i);
    
    return h;

double类型则是使用位运算的方式进行哈希计算:

public int hashCode() 
    long bits = doubleToLongBits(value);
    return (int)(bits ^ (bits >>> 32));

public static long doubleToLongBits(double value) 
    long result = doubleToRawLongBits(value);
    if ( ((result & DoubleConsts.EXP_BIT_MASK) == DoubleConsts.EXP_BIT_MASK)
    &&
   (result & DoubleConsts.SIGNIF_BIT_MASK) != 0L)
        result = 0x7ff8000000000000L;
    return result;

于是Java让所有数据类型都继承了超类Object类,并实现hashCode()方法。接下来我们看下Object.hashcode方法。Object类中的hashcode方法是一个native方法。

 public native int hashCode();

hashCode 方法的实现依赖于jvm,不同的jvm有不同的实现,我们看下主流的hotspot虚拟机的实现。

hotspot 定hashCode方法在src/share/vm/prims/jvm.cpp中,源码如下:

JVM_ENTRY(jint, JVM_IHashCode(JNIEnv* env, jobject handle))
  JVMWrapper("JVM_IHashCode");
  return handle == NULL ? 0 : ObjectSynchronizer::FastHashCode (THREAD, JNIHandles::resolve_non_null(handle)) ;
JVM_END

接下来我们看下ObjectSynchronizer::FastHashCode 方法是如何返回hashcode的,ObjectSynchronizer::FastHashCode 在synchronized.hpp文件中,

intptr_t ObjectSynchronizer::identity_hash_value_for(Handle obj) 
  return FastHashCode (Thread::current(), obj()) ;


intptr_t ObjectSynchronizer::FastHashCode (Thread * Self, oop obj) 
  if (UseBiasedLocking) 
   
    if (obj->mark()->has_bias_pattern()) 
      // Box and unbox the raw reference just in case we cause a STW safepoint.
      Handle hobj (Self, obj) ;
      // Relaxing assertion for bug 6320749.
      assert (Universe::verify_in_progress() ||
              !SafepointSynchronize::is_at_safepoint(),
             "biases should not be seen by VM thread here");
      BiasedLocking::revoke_and_rebias(hobj, false, JavaThread::current());
      obj = hobj() ;
      assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
    
  


  ObjectMonitor* monitor = NULL;
  markOop temp, test;
  intptr_t hash;
  // 获取调用hashCode() 方法的对象的对象头中的mark word 
  markOop mark = ReadStableMark (obj);

  // object should remain ineligible for biased locking
  assert (!mark->has_bias_pattern(), "invariant") ;

  if (mark->is_neutral())   //普通对象
    hash = mark->hash();              // this is a normal header
    //如果mark word 中已经保存哈希值,那么就直接返回该哈希值
    if (hash)                        // if it has hash, just return it
      return hash;
    
    // 如果mark word 中还不存在哈希值,那就调用get_next_hash(Self, obj)方法计算该对象的哈希值
    hash = get_next_hash(Self, obj);  // allocate a new hash code
   // 将计算的哈希值CAS保存到对象头的mark word中对应的bit位,成功则返回,失败的话可能有几下几种情形:
   //(1)、其他线程也在install the hash并且先于当前线程成功,进入下一轮while获取哈希即可
   //(2)、有可能当前对象作为监视器升级成了轻量级锁或重量级锁,进入下一轮while走其他case;
    temp = mark->copy_set_hash(hash); // merge the hash code into header
    // use (machine word version) atomic operation to install the hash
    test = (markOop) Atomic::cmpxchg_ptr(temp, obj->mark_addr(), mark);
    if (test == mark) 
      return hash;
    
    // If atomic operation failed, we must inflate the header
    // into heavy weight monitor. We could add more code here
    // for fast path, but it does not worth the complexity.
   else if (mark->has_monitor())  //重量级锁
  // 果对象是一个重量级锁monitor,那对象头中的mark word保存的是指向ObjectMonitor的指针,
  //此时对象非加锁状态下的mark word保存在ObjectMonitor中,到ObjectMonitor中去拿对象的默认哈希值:
    monitor = mark->monitor();
    temp = monitor->header();
    assert (temp->is_neutral(), "invariant") ;
    hash = temp->hash();
  //(1)如果已经有默认哈希值,则直接返回;
    if (hash) 
      return hash;
    
    // Skip to the following code to reduce code size
   else if (Self->is_lock_owned((address)mark->locker()))   //轻量级锁锁
  //如果对象是轻量级锁状态并且当前线程持有锁,那就从当前线程栈中取出mark word:
 
    temp = mark->displaced_mark_helper(); // this is a lightweight monitor owned
    assert (temp->is_neutral(), "invariant") ;
    hash = temp->hash();              // by current thread, check if the displaced
     //(1)如果已经有默认哈希值,则直接返回;
    if (hash)                        // header contains hash code
      return hash;
    
   
  

  // Inflate the monitor to set hash code
  monitor = ObjectSynchronizer::inflate(Self, obj);
  // Load displaced header and check it has hash code
  mark = monitor->header();
  assert (mark->is_neutral(), "invariant") ;
  hash = mark->hash();
  //计算默认哈希值并保存到mark word中后再返回
  if (hash == 0) 
    hash = get_next_hash(Self, obj);
    temp = mark->copy_set_hash(hash); // merge hash code into header
    assert (temp->is_neutral(), "invariant") ;
    test = (markOop) Atomic::cmpxchg_ptr(temp, monitor, mark);
    if (test != mark) 
     
      hash = test->hash();
      assert (test->is_neutral(), "invariant") ;
      assert (hash != 0, "Trivial unexpected object/monitor header usage.");
    
  
  // We finally get the hash
  return hash;

关于对象头、java内置锁的内容请阅读《Java 高并发核心编程 卷2 加强版》。

ObjectSynchronizer :: FastHashCode()也是通过调用identity_hash_value_for方法返回值的,调用了get_next_hash()方法生成hash值,源码如下:

static inline intptr_t get_next_hash(Thread * Self, oop obj) 
  intptr_t value = 0 ;
  if (hashCode == 0)  //随机数 openjdk6、openjdk7 采用的是这种方式
     // This form uses an unguarded global Park-Miller RNG,
     // so it\'s possible for two threads to race and generate the same RNG.
     // On MP system we\'ll have lots of RW access to a global, so the
     // mechanism induces lots of coherency traffic.
     value = os::random() ;
   else
  if (hashCode == 1)  //基于对象内存地址的函数
     // This variation has the property of being stable (idempotent)
     // between STW operations.  This can be useful in some of the 1-0
     // synchronization schemes.
     intptr_t addrBits = cast_from_oop<intptr_t>(obj) >> 3 ;
     value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom ;
   else
  if (hashCode == 2)  //恒等于1(用于敏感性测试)
     value = 1 ;            // for sensitivity testing
   else
  if (hashCode == 3)  //自增序列
     value = ++GVars.hcSequence ;
   else
  if (hashCode == 4)   //将对象的内存地址强转为int
     value = cast_from_oop<intptr_t>(obj) ;
   else  
    //生成hash值的方式六: Marsaglia\'s xor-shift scheme with thread-specific state
  //(基于线程具体状态的Marsaglias的异或移位方案) openjdk8之后采用的就是这种方式
     // Marsaglia\'s xor-shift scheme with thread-specific state
     // This is probably the best overall implementation -- we\'ll
     // likely make this the default in future releases.
     unsigned t = Self->_hashStateX ;
     t ^= (t << 11) ;
     Self->_hashStateX = Self->_hashStateY ;
     Self->_hashStateY = Self->_hashStateZ ;
     Self->_hashStateZ = Self->_hashStateW ;
     unsigned v = Self->_hashStateW ;
     v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ;
     Self->_hashStateW = v ;
     value = v ;
  

  value &= markOopDesc::hash_mask;
  if (value == 0) value = 0xBAD ;
  assert (value != markOopDesc::no_hash, "invariant") ;
  TEVENT (hashCode: GENERATE) ;
  return value;

到底用的哪一种计算方式,和参数hashCode有关系,在src/share/vm/runtime/globals.hpp中配置了默认:

openjdk6:

  product(intx, hashCode, 0,                                       \\
  "(Unstable) select hashCode generation algorithm")                \\   

openkjdk8:

  product(intx, hashCode, 5,                                       \\
  "(Unstable) select hashCode generation algorithm")                \\

也可以通过虚拟机启动参数-XX:hashCode=n来做修改。

到这里你知道hash值是如何生成的了吧。

哈希表因为其本身结构使得查找对应的值变得方便快捷,但是也带来了一些问题,问题就是无论使用哪种方式生成hash值,总有产生相同值的时候。接下来我们就来看下如何解决hash值相同的问题。

hash 碰撞(哈希冲突)

对于两个不同的数据元素通过相同哈希函数计算出来相同的哈希地址(即两不同元素通过哈希函数取模得到了同样的模值),这种现象称为哈希冲突或哈希碰撞。

一般来说,哈希冲突是无法避免的。如果要完全避免的话,那么就只能一个字典对应一个值的地址,这样一来, 空间就会增大,甚至内存溢出。减少哈希冲突的原因是Hash碰撞的概率就越小,map的存取效率就会越高。 常见的哈希冲突的解决方法有开放地址法和链地址法:

开放地址法

开放地址法又叫开放寻址法、开放定址法,当冲突发生时,使用某种探测算法在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。开放地址法需要的表长度要大于等于所需要存放的元素。

按照探测序列的方法,可以细分为线性探查法、平法探查法、双哈希函数探查法等。

这里为了更好的展示三种方法的效果,我们用例子来看看:设关键词序列为47,7,29,11,9,84,54,20,30,哈希表长度为13,装载因子=9/13=0.69,哈希函数为f(key)=key%p=key%11

关键词(key) 47 7 29 11 9 84 54 20 30
散列地址k(key) 3 7 7 0 9 7 10 9 8

(1)线性探测法

当我们的所需要存放值的位置被占了,我们就往后面一直加1并对m取模直到存在一个空余的地址供我们存放值,取模是为了保证找到的位置在0~m-1的有效空间之中。

公式:fi=(f(key)+i) % m ,0 ≤ i ≤ m-1i会逐渐递增加1)

具体做法: 探查时从地址d开始,首先探查T[d],然后依次探查T[d+1]....直到T[m-1],然后又循环到T[0]、T[1],...直到探查到有空余的地址或者直到T[d-1]为止。

用线性探测法处理冲突得到的哈希表如下

缺点:需要不断处理冲突,无论是存入还是査找效率都会大大降低。

(2)平方探查法

当我们的所需要存放值的位置被占了,会前后寻找而不是单独方向的寻找。

公式:fi=(f(key)+di) % m,0 ≤ i ≤ m-1

具体操作:探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+di],di 为增量序列12、-12,22、-22, ……,q2、-q2 且q≤1/2 (m-1) ,直到探查到 有空余地址或者到 T[d-1]为止。

用平方探查法处理冲突得到的哈希表如下

(3)双哈希函数探查法

公式:fi=(f(key)+i*g(key)) % m (i=1,2,……,m-1)
其中f(key) 和g(key) 是两个不同的哈希函数,m为哈希表的长度。

具体步骤:

双哈希函数探测法,先用第一个函数f(key)对关键码计算哈希地址,一旦产生地址冲突,再用第二个函数 g(key)确定移动的步长因子,最后通过步长因子序列由探测函数寻找空的哈希地址。

开发地址法,通过持续的探测,最终找到空的位置。为了解决这个问题,引入了链地址法。

链地址法

在哈希表每一个单元中设置链表,某个数据项对的关键字还是像通常一样映射到哈希表的单元中,而数据项本身插入到单元的链表中.

链地址法简单理解如下:

来一个相同的数据,就将它插入到单元对应的链表中,在来一个相同的,继续给链表中插入。

链地址法解决哈希冲突的例子如下:

(1)采用除留余数法构造哈希函数,而 冲突解决的方法为 链地址法。

(2)具体的关键字列表为(19,14,23,01,68,20,84,27,55,11,10,79),则哈希函数为f(key)=key MOD 13。则采用除留余数法和链地址法后得到的预想结果应该为:

哈希造表完成后,进行查找时,首先是根据哈希函数找到关键字的位置链,然后在该链中进行搜索,如果存在和关键字值相同的值,则查找成功,否则若到链表尾部仍未找到,则该关键字不存在。

哈希表作为一个非常常用的查找数据结构,它能够在O(1)的时间复杂度下进行数据查找,时间主要花在计算hash值上。在Java中,典型的Hash数据结构的类是HashMap。

然而也有一些极端的情况,最坏的就是hash值全都映射在同一个地址上,这样哈希表就会退化成链表,例如下面的图片:

当hash表变成图2的情况时,查找元素的时间复杂度会变为O(n),效率瞬间低下,

所以,设计一个好的哈希表尤其重要,如HashMap在jdk1.8后引入的红黑树结构就很好的解决了这种情况。

手写一个相对复杂的HashMap

要拿高分,写个极简的版本,是不够的。

接下来模拟JDK的HashMap,我们就自己来手写hashMap。

复杂的HashMap的数据模型设计+接口设计

设计不能少,首先,尼恩给大家做点简单的设计:

  • 数据模型设计:

宏观上来说:数组+ 链表

设计一个Table 数组来存储每个key-value对,一个key-value对封装为一个Node,其中每个Node可以增加指针,指向后继节点,可以形成一个链表结构,用于解决hash冲突问题。

  • 访问方法设计:

设计一组方法,进行原始的 put、get。

既然接下来模拟JDK的HashMap,知己方能知彼,首先来看看,一个JDK 1.8版本ConcurrentHashMap实例的内部结构示例如图7-16所示。

图7-16 一个JDK 1.8 版本ConcurrentHashMap实例的内部结构

以上的内容,来自 尼恩的 《Java 高并发核心编程 卷2 加强版》,尼恩的高并发三部曲,很多小伙伴反馈说:相见恨晚,爱不释手。

接下来,开始定义顶层的访问接口。

定义顶层的访问接口

首先,我们需要的是确定HashMap结构,那么咱们就定义一个Map接口和一个Map实现类HashMap,其结构如下:

Map接口的实现

在Map接口中定义了以下几个方法

public interface Map<K,V> 
    int size();
    boolean isEmpty();
    void clear();
    V put(K key,V value);
    V remove(K key);
    V get(K key);
    boolean containsKey(K key);
    boolean containsValue(V value);
    boolean equals(Object o);
    int hashCode();

    interface Entry<K, V> 
        K getKey();
        V getValue();
        V setValue(V value);
        int hashCode();
        boolean equals(Object o);
    

手写实现类HashMap

定义好Map接口后,那么接下来我们就需要实现Map接口,定义实现类为HashMap。

HashMap类如下:

public class HashMap<K, V> implements Map<K, V> 
    //数组默认初始容4
    private int DEFAULT_CAPACITY = 1 << 2;
    // 加载因子 
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
     //数组扩容的阈值= loadFactorx 容量(capacity)
     int threshold;
   
    public HashMap() 
        threshold = (int) (DEFAULT_CAPACITY * DEFAULT_LOAD_FACTOR);
    
    ......

HashMap类的构造函数中,仅对数组扩容阈值做了默认设置, 默认的数组扩容阈值等于数组默认容量*负载因子(0.75)

Node<K,V>节点的实现

在hashMap中定义数组集合节点Node<K,V>,Node<K,V>节点实现了Map中的Entry接口类,

在Node节点中定义了hash值,Key值,Value值和指针,

其核心代码如下:

/**
* 链表结点
*
* @param <K>
* @param <V>
*/
static class Node<K, V> implements Map.Entry<K, V> 
    int hash;
    K key;
    V value;
    Node<K, V> next;


public Node(int hash, K key, V value, Node<K, V> next) 
    this.hash = hash;
    this.key = key;
    this.value = value;
    this.next = next;

......

定义好Node<K,V>节点后,我们需要定义一个数组,来存储Key-Value键值对;

数组定义如下:

// 数组
Node<K, V>[] table;

hash()函数实现

使用table中存储key-value键值对的前提是获取到table的下标值,在这我们采用最常用的hash函数-除留余数法f(key) =key%m 获取散列地址作为数组table的下标值,hash()函数实现如下:

private int hash(Object key) 
    if (key == null) return 0;
    int hash = (Integer) key % 4;
    return hash;

开放地址法解决hash碰撞

通过hash()函数计算出散列地址作为数组下标后,那么我们就可以实现Key-Value键值对的存储。

HashMap的构造函数中仅设置了数组扩容的阈值,但是并没对数组进行初始化,那么就需要在第一次保存Key-Value值时进行数组table的初始化。

hash表最常见的问题就是hash碰撞,hash碰撞的解决方法有两种,开放地址法和链地址法,

我们先用最简单的开放地址法来解决hash冲突,那么保存Key-Value键值对的具体实现如下:

/**
 * 插入节点
 *
 * @param key   key值
 * @param value value值
 * @return
 */
@Override
public V put(K key, V value) 
    //通过key计算hash值
    int hash = hash(key);

    //数组
    Node<K, V>[] tab;
    // 数组长度
    int n;

    // 数组的位置,即hash槽位
    int i;

    //根据数组长度和哈子自来寻址
    Node<K, V> parent;

    if ((tab = table) == null || (n = tab.length) == 0) 
        //第一次put的时候,调用ensureCapacity初始化数组table
        tab = ensureCapacity();
        n = tab.length;
    

    // 开始时插入元素
    if ((parent = tab[i = hash]) == null)  //无hash碰撞,在当前下标位置直接插入
        System.out.println("下标:" + i + ",数组插入的key:" + key + ",value:" + value);
        //如果没有hash碰撞,就直接插入数组中
        tab[i] = new Node<>(hash, key, value, null);
        ++size;
     else   // 有hash碰撞的时候,就采用线性探查法解决hash碰撞:fi=(f(key)+i)%4

        if (i == (n - 1)) 
            //若已是下标最大值,就从头开始查找空位置插入
            for (int j = 0; j < i; j++) 
                if (tab[j] == null) 
                    System.out.println("已最后一个下标,从0下标开始找,下标为:" + j + ",数组插入的key:" + key + ",value:" + value);
                    tab[j] = new Node<>(hash, key, value, null);
                    ++size;
                    break;
                
            
         else  // 若不是下标最大值,那就从当前下标往后查找空位置插入

            for (int index = 1; index < n - i - 1; index++) 
                //先往后查找,若往后查找有空位,就直接插入,
                if (tab[i + index] == null) 
                    System.out.println("从当前下标往后找,下标为:" + (i + index) + ",数组插入的key:" + key + ",value:" + value);
                    tab[i + index] = new Node<>(hash, key, value, null);
                    ++size;
                    break;
                
            
        
    

    // 判断当前数组是否需要扩容
    if (size > threshold )
        //扩容操作
        ensureCapacity();
    
    return value;

在第一次调用put()方法保存Key-Value键值对的时候,调用ensureCapacity()方法初始化数组。

在保存Key-Value键值对后需要判断是否需要扩容,扩容的条件是当前数组中元素个数超过阈值就需要扩容。

调用ensureCapacity()方法进行扩容操作,每次新容量=1.5 * 数组原容量;

具体代码实现如下:

/**
 * 数组扩容
 */
private Node<K, V>[] ensureCapacity() 
    int oldCapacity = 0;
    //数组未初始化,对数组进行初始化
    if (table == null || table.length == 0) 
        table = new Node[DEFAULT_CAPACITY];
        return table;
    

    // 数组已初始化,旧容量
    oldCapacity = table.length;
    // 扩容后新的数组容量
    int newCapacity = 0;
    // 如果数组的长度 == 容量
    if (size > threshold) 
        // 新容量为旧容量的1.5倍
        newCapacity = oldCapacity + (oldCapacity >> 1);

        //数组扩容阈值= 新容量*负载因子(0.75)
        threshold = (int) (newCapacity * DEFAULT_LOAD_FACTOR);
        //创建一个新数组
        Node<K, V>[] newTable = new Node[newCapacity];
        // 把原来数组中的元素放到新数组中
        for (int i = 0; i < size; i++) 
            newTable[i] = table[i];
        
        table = newTable;
        System.out.println(oldCapacity + "扩容为" + newCapacity);
    
    return table;

从上述代码可看到,在原数组容量超过阈值的时候,就会进行扩容操作,扩容成功后还需要做以下几件事:

  • 重新设置数组扩容的阈值, 这个时候扩容阈值= 数组新容量* 负载因子;
  • 把旧数组的元素赋值到新数组中, 新数组的元素存放位置按照旧数组的位置进行存储,这一个步骤是最影响性能的。
  • 返回新创建的数组。

HashMap存储Key-Value键值对到此就完成了,我们来写一个测试单元来看下执行效果,测试单元代码如下:

@Test
public void hashMapTest() 
    HashMap<Integer, Integer> hashMap = new HashMap<>();
    hashMap.put(4, 104); 
    hashMap.put(6, 108); 
    hashMap.put(7, 112); 
    hashMap.put(11, 111);
    hashMap.put(15, 115);
    hashMap.put(19, 119);
    hashMap.put(1, 100); 
    hashMap.put(5, 105); 
    hashMap.put(9, 109);
    hashMap.put(29, 129);
    hashMap.put(13, 113);
    hashMap.put(17, 117);
    hashMap.put(21, 121);
    hashMap.put(25, 125);

    hashMap.put(33, 133);
    hashMap.put(37, 137);
    hashMap.put(41, 141);
    hashMap.put(45, 145);
    hashMap.put(49, 149);

执行结果如下:

存储结构如下图所示:

Key-Value 键值对已经保存到数组中了,那接下来我们就来探索下在HashMap中如何通过Key值某个Value值。

主要是通过for循环遍历查找,如果hash值相同或者Key值相同就说明找到Key-Value键值对,然后返回对应的value值,具体实现如下:

@Override
public V get(Object key) 
    Node<K, V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;

/**
 * 通过key值在数组中查找value值
 *
 * @param hash
 * @param key
 * @return
 */
private Node<K, V> getNode(int hash, Object key) 
    K k;

    //如果不是就for循环查找
    for (int i = 0; i < table.length; i++) 
        if (table[i].hash == hash && ((k = table[i].key) == key || (key != null && key.equals(k)))) 
            return table[i];
        
    
    return null;

用测试单元来查看Key = 19 看返回的值是否正确,测试单元如下:

@Test
public void hashMapTest01() 
    HashMap<Integer, Integer> hashMap = new HashMap<>();
    hashMap.put(4, 104);
    hashMap.put(6, 108);
    hashMap.put(7, 112);
    hashMap.put(11, 111);
    hashMap.put(15, 115);
    hashMap.put(19, 119);

    System.out.println("hashMap get() value:" + hashMap.get(19));    

执行结果如下:

从上述的HashMap 的put()方法采用的开发地址法持续探测最终找到空的位置保存Key-Value键值对,在get()方法中也是通过循环不断的探测hash值或Key值。这种方式在记录总数可以预知的情况下,可以创建完美的hash表,这种情况下存储效率是很高的。

但是在实际应用中,往往记录的数据量是不确定的,那么存储的数组元素超过阈值的时候就需要进行扩容操作,扩容操作的时间成本是很高的,频繁的扩容操作同样也会程序的性能。 采用开放地址法是通过不断的探测寻找空地址,探测的过程的时间成本也是很高的,而且在查找key-value键值对时,就不能单纯的使用数组下标的方式获取,而是通过循环的方式进行查找,这个过程也是十分消耗时间的。

针对hash表的开放地址法存在的问题,我们引入链地址法来解决, jdk1.7以及之前的HashMap就是采用的数组+链表的方式进行解决的。

链地址法解决hash碰撞

首先,我们对存储Key-Value键值对的put方法进行优化,优化的内容就是把有hash碰撞的Key-Value键值对用链表的形式进行存储,采用尾插入的方式往链表中插入有hash碰撞的Key-Value键值对,具体实现如下:

/**
 * 插入节点
 *
 * @param key   key值
 * @param value value值
 * @return
 */
@Override
public V put(K key, V value) 
   ...
    // 开始时插入元素
    if ((parent = tab[i = hash]) == null) 
        System.out.println("下标为:"+i+"数组插入的key:" + key + ",value:" + value);
        //如果没有hash碰撞,就直接插入数组中
        tab[i] = new Node<>(hash, key, value, null);
        ++size;
     else  //有哈希碰撞时,采用链表存储
        // 下一个子结点
        Node<K, V> next;
        K k;
        System.out.println("下标为:"+i+"有哈希碰撞的key:" + key + ",value:" + value);
        if (parent.hash == hash
                && ((k = parent.key) == key || (key != null && key.equals(k)))) 
            // 哈希碰撞,且节点已存在,直接替换数组元素
            next = parent;
         else 
            System.out.println("下标为:"+i+"链表插入的key:" + key + ",value:" + value);
            // 哈希碰撞, 链表插入
            for (int linkSize = 0; ; ++linkSize) 
                // System.out.println("linkSize="+linkSize+",node:"+parent);
                //如果当前结点的下一个结点为null,就直接插入
                if ((next = parent.next) == null) 
                    System.out.println("new链表长度为:" + linkSize);
                    parent.next = new Node<>(hash, key, value, null);
                    break;
                
                if (next.hash == hash
                        && ((k = next.key) == key || (key != null && key.equals(k)))) 
                    //如果节点已经存在,直接跳出for循环
                    break;
                
                parent = next;
            
            printLinked(hash);
        
    
    ...

执行测试单元结果如下:

存储的结构如下图所示:

保存有hash碰撞的Key-Value键值对时采用了链表形式,那么在调用get()方法查找的时候,

首先通过hash()函数计算出数组的下标索引值,然后通过下标索引值查找数组对应的Node<K,V>节点,通过key值和hash值判断第一个结点是否是查找的Key-Value键值对,

若不是第一个结点不是要查找的Key-Value键值对,就从头开始变量链表进行Key-Value键值查找,查找到了就返回Key-Value键值对,没有查找到就返回null,

使用链地址法优化后的get()方法实现代码如下:

@Override
public V get(Object key) 
    Node<K, V> e;

    return (e = getNode(hash(key), key)) == null ? null : e.value;

/**
     * 通过key值在数组/链表/红黑树中查找value值
     *
     * @param hash
     * @param key
     * @return
     */
private Node<K, V> getNode(int hash, Object key) 
    //数组
    Node<K, V>[] tab;

    //数组长度
    int n;
    // (n-1)$hash 获取该key对应的数据节点的hash槽位,即链表的根结点
    Node<K, V> parent;

    //root的子节点
    Node<K, V> next;

    K k;

    //如果数组为空,并且长度为空, hash槽位对应的节点为空,就返回null
    if ((tab = table) != null && (n = table.length) > 0
        && (parent = tab[ hash]) != null) 
        // 如果计算出来的hash槽位所对应的结点hash值等于hash值,结点的key=查找key值,
        // 返回hash槽位对应的结点,即数组
        if (parent.hash == hash && ((k = parent.key) == key || (key != null && key.equals(k)))) 
            return parent;
        
        //如果不在根结点,在子结点
        if ((next = parent.next) != null) 

            //在链表中查找,需要通过循环一个个往下查找
            while (next != null) 
                if (next.hash == hash && ((k = next.key) == key || (key != null && key.equals(k)))) 
                    return next;
                
                next = next.next;
            

        

    
    return null;

采用链地址法解决hash碰撞问题相比开放地址法来说,处理冲突简单且无堆积现象, 发生hash碰撞后不用探测空位置保存元素,数组table也不需要频繁的进行扩容操作。

而且链表地址法中链表采用的时候尾插入方式增加节点,不会出现环问题,而且链表的节点插入效率比较高;链表上的节点空间是动态申请的,它更适合需要保存的Key-Value键值对个数不确定的情况,节省了空间也提高了插入效率。

但是链表不支持随机访问,查找元素效率比较低,需要遍历结点,所以当链表长度过长的时候,查找元素效率就会比较低,那么在链表长度超过一定阈值的时候,我们可以把链表转换成红黑树来提升查询的效率。

红黑树提升查询效率

采用红黑树来提升查询效率,首先需要定义红黑树的节点,该节点继承了Node节点,同时新增了左右结点和父节点。代码如下:

/**
 * 红黑树结点
 *
 * @param <K>
 * @param <V>
 */
static final class RBTreeNode<K, V> extends Node<K, V> 
    boolean color = RED;
    // 左节点
    RBTreeNode<K, V> left;
    // 右节点
    RBTreeNode<K, V> right;
    // 父节点
    RBTreeNode<K, V> parent;


public RBTreeNode(int hash, K key, V value, Node<K, V> next) 
    super(hash, key, value, next);



public boolean hasTwoChildren() 
    return left != null && right != null;


/**
 * 是否为左结点
 *
 * @return
 */
public boolean isLeftChild() 
    return parent != null && this == parent.left;



/**
 * 判断是否为右子树
 *
 * @return
 */
public boolean isRightChild() 
    return parent != null && this == parent.right;


/**
 * 获取兄弟结点
 *
 * @return
 */
public RBTreeNode<K, V> sibling() 
    if (isLeftChild()) 
        return parent.right;
    

    if (isRightChild()) 
        return parent.left;
    

    return null;


....

接下来我们来优化一下Key-Value键值存储的put()方法,优化的点主要是Hash碰撞后的处理,具体如下:

  • 通过hash()函数获得table下标索引值后,若该结点已是红黑树节点,就把需保存的Key-Value()节点插入到红黑树中,并判断是否平衡,若不平衡则进行自平衡操作;
  • 通过hash()函数获得table下标索引值的节点是链表节点,则采用尾插入的方式插入链表结点,插入完成后判断链表长度是否超过链表长度阈值,若超过阈值就把链表转换成红黑树。

首先,我们先定义一个链表转红黑树的阈值,

//链表长度到达8时转成红黑树
private static final int TREEIFY_THRESHOLD = 8;

接下来我们看下put()方法的执行流程:

  1. 首先判断table是否有足够的容量,若没有足够容量,就进行扩容操作;
  2. 判断是否有hash冲突, 若无hash冲突,就把新增的key-value插入数组中对应的位置;
  3. 若有hash冲突的时候,判断是否该数组下标的结点是树节点还是链表节点,若是树节点就添加到树上; 若是链表节点就采用尾节点插入。
  4. 链表插入成功后需要判断一下链表的长度,若链表长度超过8时,就需要把链表转换成红黑树。

执行流程如下图所示:

put()方法优化后的代码如下:

/**
 * 插入节点
 *
 * @param key   key值
 * @param value value值
 * @return
 */
@Override
public V put(K key, V value) 
    //通过key计算hash值
    int hash = hash(key);

    //数组
    Node<K, V>[] tab;
    // 数组长度
    int n;

    // 数组的位置,即hash槽位
    int i;

    //根据数组长度和哈子自来寻址
    Node<K, V> parent;

    if ((tab = table) == null || (n = tab.length) == 0) 
        //第一次put的时候,调用ensureCapacity创建数组
        tab = ensureCapacity();
        n = tab.length;
    

    // 开始时插入元素
    if ((parent = tab[i = (n - 1) & hash]) == null) 
        System.out.println("数组插入的key:" + key + ",value:" + value);
        //如果没有hash碰撞,就直接插入数组中
        tab[i] = new Node<>(hash, key, value, null);

     else  //有哈希碰撞时,需要判断是红黑树还是链表
        // 下一个子结点
        Node<K, V> next;
        K k;
        System.out.println("有哈希碰撞的key:" + key + ",value:" + value);
        if (parent.hash == hash
                && ((k = parent.key) == key || (key != null && key.equals(k)))) 
            // 哈希碰撞,且节点已存在,直接替换数组元素
            next = parent;
         else if (parent instanceof RBTreeNode) 
            // 如果是红黑树节点,就插入红黑树节点
            System.out.println("往红黑树中插入的key:" + key + ",value:" + value);
            //先找到root根节点
            int index = (tab.length - 1) & hash;
            //取出红黑树的根结点
            RBTreeNode<K, V> root = (RBTreeNode<K, V>) tab[index];


            putRBTreeVal(root, hash, key, value);
         else 
            System.out.println("链表插入的key:" + key + ",value:" + value);
            printLinked(hash);
            // 哈希碰撞, 链表插入
            for (int linkSize = 0; ; ++linkSize) 
                // System.out.println("linkSize="+linkSize+",node:"+parent);
                //如果当前结点的下一个结点为null,就直接插入
                if ((next = parent.next) == null) 
                    System.out.println("new链表长度为:" + linkSize);
                    parent.next = new Node<>(hash, key, value, null);
                    // 链表长度 >8时,链表的第九个元素开始转换为红黑树
                    if (linkSize >= TREEIFY_THRESHOLD - 1) 
                        Node<K, V> testNode = tab[i];
                        System.out.println("转换成红黑树插入的key:" + key + ",value:" + value);
                       /* for (int linkSize1 = 0; linkSize1 <linkSize+1; ++linkSize1)
                            System.out.println("linkSize1="+linkSize+",testNode:"+testNode);
                            testNode = testNode.next;
                        */
                        //System.out.println("node:"+parent.next);

                        System.out.println("链表长度为:" + linkSize);

                        linkToRBTree(tab, hash, ++linkSize);
                    
                    break;
                
                if (next.hash == hash
                        && ((k = next.key) == key || (key != null && key.equals(k)))) 
                    //如果节点已经存在,直接跳出for循环
                    break;
                
                parent = next;
            
        
    
    if (++size > DEFAULT_CAPACITY * DEFAULT_LOAD_FACTOR) 
        ensureCapacity();
    
    return value;

首先我们来看下当链表的长度大于8时,是如何把链表转换成红黑树的, 这里采用的是遍历链表,然后把链表中的节点一个个转换成功红黑树节点后,插入到红黑树中,最后做自平衡操作。

我们来看下把链表转换成红黑树的实现代码如下

/**
 * 把链表转换成红黑树
 *
 * @param tab
 * @param hash
 */
    private void linkToRBTree(Node<K, V>[] tab, int hash, int linkSize) 
        // 通过hash计算出当前table数组的位置
        int index = (tab.length - 1) & hash;
        Node<K, V> node = tab[index];
        int n = 0;
        
        //遍历链表中的每个节点,将链表转换为红黑树
        do 
            //把链表结点转换成红黑树结点
            RBTreeNode<K, V> next = replacementTreeNode(node, null);
            putRBTreeVal(next, hash, next.key, next.value);
            System.out.println("转换成红黑树数组的循环次数:" + n);
            ++n;
            node = node.next;
         while (node != null);
        System.out.println("n:" + n);
        print(hash);
    


/**
 * 把链表结点转换成红黑树结点
 *
 * @param p
 * @param next
 * @return
 */
RBTreeNode<K, V> replacementTreeNode(Node<K, V> p, Node<K, V> next) 
    return new RBTreeNode<K, V>(p.hash, p.key, p.value, next);

链表转红黑树的时候,调用了节点插入的 putRBTreeVal()方法, 由于红黑树是二叉树的其中一种,根据二叉树的特性,左子树的值都比根结点值小,右子树的值都比根结点值大。

由于同一颗红黑树的hash值都是相同的,在插入新节点之前,那我们就需要比较Key值的大小,大的往右子树放,小的就往左子树放,那么putRBTreeVal()方法的实现如下:

RBTreeNode<K, V> putRBTreeVal(RBTreeNode<K, V> tabnode, int hash, K key, V value) 
    if ((table[hash]) instanceof RBTreeNode) 

        RBTreeNode<K, V> root = (RBTreeNode<K, V>) table[hash];
        RBTreeNode<K, V> parent = root;
        RBTreeNode<K, V> node = root;

        int cmp = 0;

        // 先找到父节点
        do 
            parent = node;
            K k1 = node.key;
            //比较key值
            cmp = compare(key, k1);
            if (cmp > 0) 
                node = node.right;
             else if (cmp < 0) 
                node = node.left;
             else 
                V oldValue = node.value;
                node.key = key;
                node.value = value;
                node.hash = hash;
                return node;
            

         while (node != null);


        //插入新节点
        RBTreeNode<K, V> newNode = new RBTreeNode<>(hash, key, value, parent);
        if (cmp > 0) 
            parent.right = newNode;
         else if (cmp < 0) 
            parent.left = newNode;
        
        newNode.parent = parent;
        //插入成功后自平衡操作
        fixAfterPut(newNode, hash);
     else 
        table[hash] = tabnode;
        fixAfterPut(tabnode, hash);

    

    return null;

虽然说红黑树不是严格的平衡二叉查找树,但是红黑树插入/移除节点后仍然需要根据红黑树的五个特性进行自平衡操作。

由于红色破坏原则的可能性最小,插入的新节点颜色默认是红色。

若红黑树还没有根结点,新插入的红黑树节点就会被设置为根结点,然后根据特性2(根节点一定是黑色)把根节点设置为黑色后返回。

若父节点是黑色的,插入节点是红色的,不会影响红黑树的平衡,所以直接插入无需做自平衡。

若插入节点的父节点为红色的,那么该父节点不可能成为根结点,就需要找到祖父节点和叔父节点,那这个时候就会出现两种状态:(1)父亲和叔叔为红色;(2)父亲为红色,叔叔为黑色。

出现这两种状态的时候就需要做自平衡操作,

如果父节点和叔父节点都是红色的话,根据红黑树的特性4(红色节点不能相连)可以推断出祖父节点肯定为黑色。那这个时候只需进行变色操作即可,把祖父节点变成红色,父节点和叔父节点变成黑色操作

若叔父节点为黑色, 父节点为红色,若新插入的红色节点在父节点的左侧,此处就出现了LL型失衡,自平衡操作就需要先进行变色,然后父节点进行右旋操作;若新插入的红色节点在父节点的右侧,此处就出现了LR型失衡,自平衡操作就需要先父节点进行左旋,将父节点设置为当前节点,然后再按LL型失衡操作进行自平衡操作即可。

若叔叔为黑节点,父亲为红色,并且父亲节点是祖父节点的右子节点,如果新插入的节点为其父节点的右子节点,此时就出现了RR型失衡操作, 自平衡处理操作是先进行变色处理,把父节点设置成黑色,把祖父节点设置为红色,然后祖父节点进行左旋操作;若新插入节点,为其父节点的左子节点,此时就出现了RL型失衡,自平衡操作是对父节点进行右旋,并将父节点设置为当前节点,接着按RR型失衡进行自平衡操作。

自平衡操作的实现代码如下:

/**
 * 添加后平衡二叉树并设置结点颜色
 *
 * @param node 新添结点
 * @param hash hash值
 */
private void fixAfterPut(RBTreeNode<K, V> node, int hash) 
    RBTreeNode<K, V> parent = node.parent;

    // 添加的是根节点 或者 上溢到达了根节点
    if (parent == null) 
        black(node);
        return;
    

    // 如果父节点是黑色,直接返回
    if (isBlack(parent)) 
        return;
    

    // 叔父节点
    RBTreeNode<K, V> uncle = parent.sibling();
    // 祖父节点
    RBTreeNode<K, V> grand = red(parent.parent);
    if (isRed(uncle))  // 叔父节点是红色【B树节点上溢】
        black(parent);
        black(uncle);
        // 把祖父节点当做是新添加的节点
        fixAfterPut(grand, hash);
        return;
    

    // 叔父节点不是红色
    if (parent.isLeftChild())  // L
        if (node.isLeftChild())  // LL
            black(parent);
         else  // LR
            black(node);
            rotateLeft(parent, hash);
        
        rotateRight(grand, hash);
     else  // R
        if (node.isLeftChild())  // RL
            black(node);
            rotateRight(parent, hash);
         else  // RR
            black(parent);
        
        rotateLeft(grand, hash);
    

/**
 * 左旋
 *
 * @param grand
 */
private void rotateLeft(RBTreeNode<K, V> grand, int hash) 
    RBTreeNode<K, V> parent = grand.right;
    RBTreeNode<K, V> child = parent.left;
    grand.right = child;
    parent.left = grand;
    afterRotate(grand, parent, child, hash);


/**
 * 右旋
 *
 * @param grand
 */
void rotateRight(RBTreeNode<K, V> grand, int hash) 
    RBTreeNode<K, V> parent = grand.left;
    RBTreeNode<K, V> child = parent.right;
    grand.left = child;
    parent.right = grand;
    afterRotate(grand, parent, child, hash);


void afterRotate(RBTreeNode<K, V> grand, RBTreeNode<K, V> parent, RBTreeNode<K, V> child, int hash) 
    // 让parent称为子树的根节点
    parent.parent = grand.parent;
    if (grand.isLeftChild()) 
        grand.parent.left = parent;
     else if (grand.isRightChild()) 
        grand.parent.right = parent;
     else  // grand是root节点
        int index = table.length - 1 & hash;
        table[index] = parent;
    

    // 更新child的parent
    if (child != null) 
        child.parent = grand;
    

    // 更新grand的parent
    grand.parent = parent;
    print(hash);

使用单元测试看下红黑树的结果:

存储结构如下:

同样,查找Key-Value键值对的get()方法也同样需要做优化, 主要优化的内容就是在红黑树中查找Key-Value键值对;

实现步骤如下:

(1)通过hash值找到数组table的下标,

(2)通过数组table下标判断是否是红黑树节点,若是红黑树节点就在红黑树中查找;

(3)通过数组table下标判断是否是链表节点,若是链表节点就在链表中查找;

(4)若结点都不在红黑树和链表中,就在数组table中查找;

实现代码如下:

 @Override
public V get(Object key) 
    Node<K, V> e;

    return (e = getNode(hash(key), key)) == null ? null : e.value;


/**
 * 通过key值在数组/链表/红黑树中查找value值
 *
 * @param hash
 * @param key
 * @return
 */
private Node<K, V> getNode(int hash, Object key) 
    //数组
    Node<K, V>[] tab;

    //数组长度
    int n;
    // (n-1)$hash 获取该key对应的数据节点的hash槽位,即链表的根结点
    Node<K, V> parent;

    //root的子节点
    Node<K, V> next;

    K k;

    //如果数组为空,并且长度为空, hash槽位对应的节点为空,就返回null
    if ((tab = table) != null && (n = table.length) > 0
            && (parent = tab[(n - 1) & hash]) != null) 
        // 如果计算出来的hash槽位所对应的结点hash值等于hash值,结点的key=查找key值,
        // 返回hash槽位对应的结点,即数组
        if (parent.hash == hash && ((k = parent.key) == key || (key != null && key.equals(k)))) 
            return parent;
        
        //如果不在根结点,在子结点
        if ((next = parent.next) != null) 
            //有子结点的时候,需要判断是链表还是红黑树

            //在链表中查找,需要通过循环一个个往下查找
            while (next != null) 
                if (next.hash == hash && ((k = next.key) == key || (key != null && key.equals(k)))) 
                    return next;
                
                next = next.next;
            

        

        if (parent instanceof RBTreeNode) 
            //在红黑树中查找
            return getRBTreeNode((RBTreeNode<K, V>) parent, hash, key);
        
    
    return null;



 /**
 * 在红黑树中查找结点
 *
 * @param node 根结点
 * @param hash hash(key) 计算出的哈希值
 * @param key  需要寻找的key值
 * @return
 */
public Node<K, V> getRBTreeNode(RBTreeNode<K, V> node, int hash, Object key) 

    // 存储查找结果
    Node<K, V> result = null;
    K k;
    int cmp = 0;
    while (node != null) 
        //左节点
        RBTreeNode<K, V> nl = node.left;
        // 右节点
        RBTreeNode<K, V> nr = node.right;
        K k2 = node.key;
        int hash1 = node.hash;
        //比较hash值,判断是在左子树还是右子树
        if (hash > hash1) 
            //查找结点在右子树
            node = nr;
         else if (hash < hash1) 
            //查找结点在左子树
            node = nl;
         else if ((k = node.key) == key || (key != null && key.equals(k))) 
            //如果key 相等,就返回node
            return node;
         else if (nl == null) 
            node = nr;
         else if (nr == null) 
            node = nl;
         else if (key != null & k2 != null
                && key.getClass() == k2.getClass()
                && key instanceof Comparable
                && (cmp = compare(key, k2)) != 0
        ) 
            node = cmp > 0 ? node.right : node.left;
         else if (node.right != null && (result = getRBTreeNode(node.right, hash, key)) != null) 
            return result;
         else 
            node = node.left;
        
    
    return null;

如果HashMap需要通过key值移除Key-Value键值对,首先通过key值查找到节点,然后进行移除;

若需移除的节点在红黑树中,首先需要判断移除节点的度是多少,若度为2的话,就需要先找到后继节点后才可以移除,若度为1或0的话,可以直接进行移除操作,红黑树移

又一 OCR 神器面世!网友:这也太猛了...

本文转载自程序员不高兴

相信大家在工作生活中经常会遇到表格识别的问题,比如导师说,把下面 PDF 文件里面的表格取出来整理成 Excel 表。

也可能会遇到,公司领导或者客户发来一张截图,需要里面的表格取出来转成 Excel 表。

这种情况下你会怎么做呢,新建一个 Excel 一个一个数据敲么,辛辛苦苦半天赶出来,领导还会来一句,怎么这么慢,简直郁闷死……

别着急,只要稍微会一点 Python 代码,这个开源项目神器拯救你!

效果展示

版面分析 + 表格识别

如图所示,针对一张完整的 PDF 图片,这个开源项目可以对文档图片中的文本、表格、图片、标题与列表区域进行分类。同时还可以利用表格识别技术完整地提取表格结构信息,使得表格图片变为可编辑的 Excel 文件。

不仅仅是 PDF 文件转 excel,如果编程能力再强一些,结合版面分析技术,PDF 转 Word 都不在话下。

而且使用也是非常方便,在完成 Python whl 包安装之后,简单几行代码即可完成快速试用。

最终结果会输出图片文件夹,Excel 表和文字识别结果,确实是非常方便。

传送门:

https://github.com/PaddlePaddle/PaddleOCR/blob/release/2.2/ppstructure/README_ch.md

版面分析与表格识别核心技术概述

不管是版面分析还是表格识别,现有方案可大致分为基于图像处理的传统方法和基于深度学习的方法。

(1)传统方法:版面分析比较著名的是 O’Gorman 在 1993 年 TPAMI 中发表的算法 Docstrum。通过自下而上的方法依次将图像中的黑白连通域划分为文字、文本行与文本块,从而得到版面布局。表格识别的传统方法通过腐蚀、膨胀等操作获得表格线、划分行列区域,然后将单元格与文本内容相结合重构为表格对象。但是传统算法主要问题在于,对于版面布局分析和表格结构的提取,图像处理的方法依赖各种阈值和参数的选择,对于不同场景下的文档图片难以保证泛化性。

(2)深度学习方法:除了直接使用检测模型来对版面内容进行分类以外,还融合了检测、分割、图神经网络、注意力机制等众多前沿技术能力。依赖算法工程师对于深度神经网络的精心设计,可以不再依赖阈值与参数,具有更好的泛化性。

PP-Structure 核心技术解读

  • 版面分析技术

PP-Structure 的版面分析技术,主要是对图片形式的文档进行版面分析,将文档划分为文字、标题、表格、图片以及列表 5 类区域(与 Layout-Parser 联合使用)。其核心技术思路与 Layout-Parser 项目密切合作,参考了 Layout-Parser 的工程结构设计,配合 PaddleDetection 开源的高效检测算法 PP-YOLO v2,在数据集 TableBank 和 PubLayNet 上 mAP 分别达到 93.6 和 96.2, NVIDIA Tesla P40 耗时仅需 66.6ms,且可以支持用户根据自己的数据自定义训练。

Layout-Parser 是开源的基于深度学习的文档图像分析工具箱,可用于布局检测,字符识别和许多其他文档处理任务,包含大量丰富模型,支持自定义 DL 模型,支持多个文档布局检测数据集。

GitHub 地址:

https://github.com/Layout-Parser/layout-parser

  • 表格识别技术

表格识别技术则主要使用基于注意力机制的图片描述模型 RARE,整体流程如下图所示,对于其中的表格区域进行表格识别处理。

表格识别的难点主要在于表格结构的提取,以及将表格信息与 OCR 信息融合。整体流程可以分为上下两部分,其中上半部分(黑色支路)是普通的 OCR 过程,通过(1)文本检测模块对表格图片进行单行文字检测,获得坐标,然后通过(2)文本识别模块识别模型得到文字结果。

而在下半部分的在蓝色支路中,表格图片首先经过(3)表格结构预测模块,获得每个 Excel 单元格的四点坐标与表格结构信息。结合黑色支路文本检测获得的单行文字文本框 4 点坐标,共同输入(4)Cell 坐标聚合模块,再通过(5)Cell 文本聚合模块,将属于同一单元格的文本拼接在一起。最后结合表格结构信息,通过(6)Excel 导出模块获得 Excel 形式的表格数据。

下面分别针每个模块分别展开介绍。

(1)文本检测模块 (2)文本识别模块

主要使用 PP-OCR 提供的检测和识别算法。

(3)表格结构预测模块,主要使用基于 Attention 的图片描述模型 RARE,RARE 模型可以实现:输入一张图片,通过带有注意力机制的网络输出一段文字,描述图片的内容,而针对于表格图片的图片描述网络,输入一张经过版面分析的表格图片,输出的是一串 HTML 字符(如下图所示)。表格的结构通过 HTML 的结构标记表示,其中的内容即为表格文本中的内容。通过进一步的 HTML 解析,可以获得每个文本的单元格四点坐标和表格结构信息。

(4)Cell 坐标聚合模块,主要用来解决如何将跨行单元格的文本重新拼接在一个单元格内的问题。它通过计算由文本检测算法获得的文本框坐标(红色框)与表格结构预测模块得到的 Cell 坐标(蓝色框)之间的 IOU 和顶点距离来进行单行到多行的聚合。使用 IOU 判断哪些红色框同属于一个蓝色框,使用顶点距离和 IOU 判断红色框的排列顺序。

(5)Cell 文本聚合模块,根据已有的红色文本框顺序,按照从上到下从左到右顺序利用(4)Cell 坐标聚合模块的结果将(2)文本识别结果和进行拼接,这样对于多行文本的单元格内容即可拼接成一个字符串。

(6)Excel 导出模块,将(3)表格结构预测结果 html 结果与(5)Cell 文本聚合模块文本结果结合,最终导出为 Excel 输出。

以上所有内容均在 PaddleOCR 项目开源,目前 star 数量超过 13.5k

相关延伸阅读:

PaddleOCR 历史表现回顾

  • 2020 年 6 月,8.6M 超轻量模型发布,GitHub Trending 全球趋势榜日榜第一。

  • 2020 年 8 月,开源 CVPR2020 顶会算法,再上 GitHub 趋势榜单!

  • 2020 年 10 月,发布 PP-OCR 算法,开源 3.5M 超超轻量模型,再下 Paperswithcode 趋势榜第一

  • 2021 年 1 月,发布 Style-Text 文本合成算法,PPOCRLabel 数据标注工具,star 数量突破 10000+,截至目前已经达到 11.5k,在《Github 2020 数字洞察报告》中被评为中国 GithubTop20 活跃项目。

  • 2021 年 4 月,开源 AAAI 顶会论文 PGNet 端到端识别算法,Star 突破 13k

  • 2021 年 8 月,开源版面分析与表格识别算法

文本检测识别效果:

这个最强 OCR 项目,你值得拥有:

https://github.com/PaddlePaddle/PaddleOCR

以上是关于京东太猛,手写hashmap又一次重现江湖的主要内容,如果未能解决你的问题,请参考以下文章

又一 OCR 神器面世!网友:这也太猛了...

手写HashMap,快手面试官直呼内行

手写HashMap,快手面试官直呼内行

手写HashMap,快手面试官直呼内行

Python摄像头换脸,现实版“易容术”重现江湖-

3 手写Java HashMap核心源码