数据结构与算法笔记(十)

一、哈希(Hash)查找
1.哈希表
    要了解哈希查找,首先要知道什么是哈希表。哈希表,又称散列表,是通过对键(key)进行计算能直接得到要访问的数据的位置,即将数据存储的位置与该数据的key之间建立一种确定的函数关系,这个函数关系叫做散列函数,而存放数据的数组就称之为哈希表或散列表。
    之前我们所学的查找都是通过对关键字key与给定值的比较来确定key的位置。效率和查找的次数相挂钩。而哈希表中的查找则不同,他是通过对key的散列运算直接得到key存储的位置(数组的索引查找也是通过数学运算来确定位置),效率要高得多。由此我们队哈希表有个初步的印象:
  • 特点:查找速度极快。
  • 结构:常用结构是数组+链表。
c9fcc3cec3fdfc035f8e2b9cd63f8794a4c22624.jpg
 
    那么哈希表是如何实现快速查找的呢。答案是通过散列函数对待查找的key进行散列运算得到数据元素在数组中的索引,这样就可以通过索引直接访问到数组中对应数据。想法虽然很美好,但这只是理想情况下才能达到的情形,即理想状态下key与数组的索引是一一对应的关系,每个key都有唯一的索引与之对应。然而实际情况却是索引可能与多个key相对应,即存在多个key进行散列运算后得到的索引是相同的。这就导致哈希表不能仅仅是数组结构(若仅仅是数组,则当存在key冲突时,数据将出现丢失的情况),还必须加上链表的结构,以保证当出现冲突时,数据元素仍有空间存储。因此,哈希表常用的结构会是数组+链表的形式。
    散列函数常用的方法:
  1. 若key是正整数,则常用除留取余法,即假设数组长度为N,则取key/N的余数作为key在数组中的索引,f(key)=key %N。N一般是素数。
  2. 直接定址法:即通过f(key)=a*key+b来直接得到索引地址。
  3. 随机数法:即f(key)=random(key)。
  4. 平方取中法:取key*key的中间几位数字。
    还有其他的散列方法,这里就不一一做介绍了。
 
2.哈希表在Java中的应用
    哈希表在Java中应用主要是HashMap和HashTable,下面就以HashMap为例了解哈希表的添加和删除数据的过程。
    HashMap的继承关系如下图:
d5ed2011d6d140b816a1218ce8c2b84adf1.jpg
    由前面的学习,我们已经知道Map接口和AbstractMap抽象类的具体作用,下面我们直接看HashMap的构造方法部分源码:

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
     //hashmap的初始化默认容量,为16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

        //最大容量,即2的31次方
    static final int MAXIMUM_CAPACITY = 1 << 30;

    //默认负载因子,即当hashmap中数组table使用率达到0.75(即数组中有百分75地方存有数据),就要进行扩容,hashmap扩容直接变大2倍。
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

        //当链表长度超过8时,不在使用链表结构,换成红黑树
    static final int TREEIFY_THRESHOLD = 8;
        
        //当红黑树的结点数小于6时,改成链表结构    
    static final int UNTREEIFY_THRESHOLD = 6;

        //hashmap转为红黑树时要求的最小容量,即只有容量大于64且链表长度超过8才能使用红黑树
    static final int MIN_TREEIFY_CAPACITY = 64;

        //hashmap中对Map.Entry接口的实现,jdk1.8之前使用的还是Entry,1.8后变为Node。这是因为1.8后当链表中Node的个数大于8,就会将链表转化成红黑树,用以提高查找的速度
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;    //key经过散列函数后的hash值
        final K key;        
        V value;
        Node<K,V> next;    //链表的下个结点

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

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        //Node的哈希码是有key和value的哈希码相加
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }


        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        //用于判断两个Node是否相同
        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

   
    
        //hashmap的散列函数,即f(key)=key.hashcode^(h>>>16)。(h>>>16)表示h无符号右移16位,相当于除以2的16次方,^表示按位与。
        //当key为null时的hash对应为0,这表示hashmap中允许存在key为null的结点,但只能有一个,且key为null的结点必然处于数组中索引为0的位置,因为hash为0只能对应数组索引0
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

        //返回一个2的n次方值,该值为最接近的大于或等于cap的2的n次方值
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

        //存放链表起点或红黑树根结点的数组
    transient Node<K,V>[] table;
        
        //hashmap中存放所有键值对的set集合
    transient Set<Map.Entry<K,V>> entrySet;

        //hashmap中的数据个数
    transient int size;

    //hashmap中允许容纳Node结点的最大数量,threshold=cap *loadFactor
    //也就是说负载因子越大,相同table.length的情形下hashmap存放数据越多,但负载因子不建议随意更改(负载因子是可以大于1的)。
    int threshold;
        
        //hashmap的负载因子,默认值是DEFAULT_LOAD_FACTOR=0.75
    final float loadFactor;

        //带初始容量和负载因子的构造方法
    public HashMap(int initialCapacity, float loadFactor) {
        //判断初始容量initialCapacity是否合法
        if (initialCapacity < 0)    
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);    //令数组可用空间值为2的n次方。
    }
    
        //使用默认负载因子的构造器
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

        //使用默认负载因子即默认初始化容量的构造器
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
        
        //以传入的m为基础初始化一个hashmap
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        if (s > 0) {
            if (table == null) { // pre-size
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                if (t > threshold)
                    threshold = tableSizeFor(t);
            }
            else if (s > threshold)
                resize();    //扩容方法,下面会讲解
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);    //新增方法,下面会讲解
            }
        }
    }

}

 

    可以看出,构造器做的事不多,只是初始化了loadFactor和threshold两个变量,连table数组都没有初始化,可见hashmap中使用的table数组必然是懒加载,在put数据时才初始化,下面来看看put方法的过程:
 

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);    //实际执行键值对新增的方法是putVal(),hash(key)则是对key进行散列运算
}

//
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)    //当table数组为null时,执行tab=resize()方法,即创建一个新的table数组。
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)        //判断hash对应的数组索引是否为null,为null表示该索引下尚未有数据,直接将新增键值对作为链表头结点存入该索引
        tab[i] = newNode(hash, key, value, null);
    else {        //该索引下链表不为null,则向链表中执行新增或替换操作
        Node<K,V> e; K k;

        if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))    //判断key对应的node结点在是否是p结点,若是,则p就是要找的结点
            e = p;
        else if (p instanceof TreeNode)  
            //判断p结点是否是treeNode,如果是,就采用红黑树新增结点方法putTreeVal(),本篇不涉及红黑树,有兴趣可以看之前关于TreeMap的笔记      
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {        //到这,表示p结点为普通链表中的一个结点
            //遍历以p为起点的链表,binCount统计链表的结点数
            for (int binCount = 0; ; ++binCount) {
                //判断p的下个结点是否为null,为空表示新增数据在表中不存在,直接添加新节点到表尾
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);    //添加新节点到表尾。
                    if (binCount >= TREEIFY_THRESHOLD - 1)     //判断链表长度是否超过8,超过8就要将链表转化成红黑树
                        treeifyBin(tab, hash);
                    break;
                }
                //判断当前结点e是否和将要新增的结点key相同/hash相同,若相同表示key已存在,退出查找。
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }

        //判断e是否为null,不为null,表示要put的结点在表中已存在,用新值value替换旧值e.value即可。
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)    //onlyIfAbsent如果为true,表示不允许value值的替换,即只能新增不能替换
                e.value = value;
            afterNodeAccess(e);    //该方法用于LinkedHashMap中,此处为空方法,啥也没干
            return oldValue;    //返回旧值
        }
    }
    ++modCount;        //快速失败机制相关,这里不讨论
    if (++size > threshold)    //新增数据后,数据个数是否超过临界值,超过要扩容
        resize();
    afterNodeInsertion(evict);        //该方法用于LinkedHashMap中,此处为空方法,啥也没干
    return null;
}

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;        //获取当前的table数组
    int oldCap = (oldTab == null) ? 0 : oldTab.length;    //获取table数组的长度
    int oldThr = threshold;    //当前hashmap扩容的临界值
    int newCap, newThr = 0;
    //判断旧table数组的长度是否大于0,大于0表示不是对table数组的初始化,而是正常扩容
    if (oldCap > 0) {
        //旧容量大于int数的最大值,则不能再扩容(达到能扩容的最大值)
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&     //新容量为oldCap扩大2倍   
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;     //临界值扩大2倍
    }
    else if (oldThr > 0)     
        // oldCap==0,表示table数组为null,需要初始化,此时若旧临界值大于0,则将其作为数组的初始化长度
        // 由构造方法中的this.threshold = tableSizeFor()可知数组长度只能是2的n次方。
        newCap = oldThr;
    else {               // 未指定threshold值时,采用默认值初始化数组长度,该条件用不到,因为4个构造方法都会初始化threshold值,oldThr不存在为0的情况
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //新的临界值若为0,则根据临界值的定义为newThr赋初值
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;    //将扩容后的临界值赋予threshold
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];    //得到扩容后的数组
    table = newTab;        //将扩容后的数组赋予table

    //判断旧数组是否为空。若不为null,将旧table中的数据转移到新的数组中
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                    
                if (e.next == null)        
                    //若e链表的长度为1.即oldTab[j]中只有一个表头,就直接放到新的数组中。
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)    //结点是红黑树的情况,这里不深究红黑树的情形
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { 
                    //链表长度大于1时链表中数据迁移到新数组中的规则。
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //判断链表中的结点是否需要移动,(e.hash & oldCap) == 0表示e结点在在新数组中的索引位置不变。
                        //例子:e.hash=6,二进制为0000 0110。oldCap假设为16,二进制位0001 0000。newCap为32,二进制位0010 0000
                        //(e.hash & oldCap)=0000 0110 & 0001 0000=0000 0000 ;
                        //(e.hash & oldCap-1)=0000 0110 & 0000 1111=0000 0110 ;
                        //(e.hash & newCap)=0000 0110 & 0010 0000=0000 0000
                        //(e.hash & newCap-1)=0000 0110 & 0001 1111=0000 0110 ;
                        //例子:e.hash=19,二进制为0001 0011。oldCap假设为16,二进制位0001 0000。newCap为32,二进制位0010 0000
                        //(e.hash & oldCap)=0001 0011 & 0001 0000=0001 0000 ;
                        //(e.hash & oldCap-1)=0001 0011 & 0000 1111=0000 0011 ;
                        //(e.hash & newCap)=0001 0011 & 0010 0000=0000 0000
                        //(e.hash & newCap-1)=0001 0011 & 0001 1111=0001 0011 ;
                        //有上面就可以看出当(e.hash & oldCap) == 0时,(e.hash & oldCap-1)与(e.hash & newCap-1)的值时相同的,即在新数组中索引不变
                        //而当(e.hash & oldCap) != 0 时,(e.hash & oldCap-1)与(e.hash & newCap-1)的值不相同的,即在新数组的索引变了
                        if ((e.hash & oldCap) == 0) {
                            //将e为起点的链表,分为两个链表,一个链表是由索引在新数组中不变的结点组成的链表lo,另一个是由索引在新数组中发生改变的所有结点组成的链表hi
                            //由于数组扩容是直接变大2倍,可得出所有索引改变的结点在新数组中的索引是相同的,即,若有(e.hash & oldCap-1)=3,(e.hash & newCap-1)=19
                            //则与e结点在同一个链表中的其他结点的索引位置要么是3,要么是19。
                            if (loTail == null)        //将索引不变的所有结点组成一个新的链表lo
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)        //索引改变的所有结点组成一个新的链表hi
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {        //将索引不变的lo链表放到数组的j位置
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {        //将索引改变的hi链表放到数组的j+oldCap位置
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
 
    可以看出hashmap中的put过程还是比较复杂的,要考虑扩容,是否使用红黑树等,但这样也是为了保证查找的效率。因为哈希表难以避免的一个问题便是:随着数据的逐渐增多,即使再好的散列算法,也不能避免冲突的次数的增加,而哈希冲突的增加,必然使得链表长度过长,从而降低查找的效率(哈希表中数组的查询事件复杂度为O(1),链表的查询时间复杂度则为O(n)),为了保证哈希表的查找效率,只能从减少冲突或降低链表的时间复杂度两个方面入手,这就有了数组扩容和红黑树结构的使用。

    讨论完put,下面来看看get方法。


public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;    //实际执行查找的方法getNode()
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //判断table数组是否有数据且(n - 1) & hash所在的索引是否有数据,即要查找的结点是否在数组中存在
    if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {
        //查找的hash值是否是first头结点,是的话,直接返回头结点。
        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //链表中是否有其他结点,即hash值所对应的索引下是否只有头结点first一个结点。若只有first一个结点,则要查找的结点不存在。
        if ((e = first.next) != null) {
            //若是红黑树的话,执行红黑树中查找方法getTreeNode
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {        //遍历链表查找对应的key是否存在。
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
 

    可以看出hashmap中查找相对简单,因为hashmap就是为了方便查找的,下面在看看删除的源码:


public V remove(Object key) {
    Node<K,V> e;
    //删除操作真正的执行方法removeNode()。要删除的结点存在就将其从hashmap中移除且返回value,不存在则返回null。
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;

    //table数组不为null且要删除的结点的在数组中存在时,继续执行删除操作
    if ((tab = table) != null && (n = tab.length) > 0 &&(p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        //判断要删除的结点是否为数组中对应索引的头结点p,若不是则继续遍历查找
        if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            //若是红黑树,则执行红黑树的查找方法getNodeTree()
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {    
                //普通链表结构,直接遍历查询待删除结点
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        //node即为查找到的待删除结点,为null则表示要删除的结点不存在。matchValue表示是否比对value的值,即要key和value一致菜删除
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            //若结点是treeNode,执行红黑树的删除方法
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)    //node为链表的头结点时
                tab[index] = node.next;
            else    //node为普通结点时
                p.next = node.next;
            ++modCount;
            --size;    //数据数减1
            afterNodeRemoval(node);    //LinkedHashMap中用到的方法,此处无用,是个空方法。
            return node;
        }
    }
    return null;
}
 
    以上便是hashmap的一些基本实现。可以看出hashmap有如下特点:
  • 基本底层实现是数组加链表,当链表结点数超过8个,链表转化成红黑树的结构。
  • 允许一个key为null的键值对,value为null不限制。
  • 数据元素时无序的,且每次扩容都有可能改变。
  • 插入,查找的效率高,耗时操作基本都是用在扩容上。
 
3.HashTable的简单介绍
    HashTable的底层与HashMap基本相同(jdk1.8后差别就比较大了),用法也基本一致,差别只是在增删改查的方法上加了 synchronized。保证线程安全。因此,HashTable和HashMap相比有:
  • HashMap: 线程不安全,效率高。允许key或value为null。
  • HashTable: 线程安全,效率低。不允许key或value为null

转载于:https://my.oschina.net/bzhangpoorman/blog/3018990

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值