zoukankan      html  css  js  c++  java
  • 看完这篇 HashSet,跟面试官扯皮没问题了

    我是风筝,公众号「古时的风筝」,一个兼具深度与广度的程序员鼓励师,一个本打算写诗却写起了代码的田园码农!
    文章会收录在 JavaNewBee 中,更有 Java 后端知识图谱,从小白到大牛要走的路都在里面。

    之前的7000 字说清楚 HashMap已经详细介绍了 HashMap 的原理和实现,本次再来说说他的同胞兄弟 HashSet,这两兄弟经常被拿出来一起说,面试的时候,也经常是两者结合着考察。难道它们两个的实现方式很类似吗,不然为什么总是放在一起比较。

    实际上并不是因为它俩相似,从根本上来说,它俩本来就是同一个东西。再说的清楚明白一点, HashSet 就是个套了壳儿的 HashMap。所谓君子善假于物,HashSet 就假了 HashMap。既然你 HashMap 都摆在那儿了,那我 HashSet 何必重复造轮子,借你一样,何不美哉!

    HashSet

    HashSet 是什么

    下面是 HashSet的继承关系图,还是老样子,我们看一个数据结构的时候先看它的继承关系图。与 HashSet并列的还有 TreeSet,另外 HashSet 还有个子类型 LinkedHashSet,这个我们后面再说。

    HashSet 结构

    套壳 HashMap

    为啥这么说呢,在我第一次看 HashSet源码的时候,已经准备好了笔记本,拿好了圆珠笔,准备好好探究一下 HashSet的神奇所在。可当我按着Ctrl+鼠标左键进入源码的构造函数的时候,我以为我走错了地方,这构造函数有点简单,甚至还有点神奇。new 了一个 HashMap并且赋给了 map 属性。

    private transient HashMap<E,Object> map;
    
    public HashSet() {
      map = new HashMap<>();
    }
    

    再三确认没看错的情况下,我明白了,HashSet就是在HashMap的基础上套了个壳儿,我们用的是个HashSet,实际上它的内部存储逻辑都是 HashMap的那套逻辑。

    除了上面的那个无参类型的构造方法,还有其他的有参构造方法,一看便知,其实就是 HashMap包装了一层而已。

    public HashSet(Collection<? extends E> c) {
      map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
      addAll(c);
    }
    
    public HashSet(int initialCapacity, float loadFactor) {
      map = new HashMap<>(initialCapacity, loadFactor);
    }
    
    public HashSet(int initialCapacity) {
      map = new HashMap<>(initialCapacity);
    }
    
    

    用法

    HashSet应该算是众多数据结构中最简单的一个了,满打满算也就那么几个方法。

    public Iterator<E> iterator() {
      return map.keySet().iterator();
    }
    
    public int size() {
      return map.size();
    }
    
    public boolean isEmpty() {
      return map.isEmpty();
    }
    
    public boolean contains(Object o) {
      return map.containsKey(o);
    }
    
    public boolean add(E e) {
      return map.put(e, PRESENT)==null;
    }
    
    public boolean remove(Object o) {
      return map.remove(o)==PRESENT;
    }
    
    public void clear() {
      map.clear();
    }
    

    很简单对不对,就这么几个方法,而且你看每个方法其实都是对应的操作 map,也就是内部的 HashMap,也就是说只要你懂了 HashMap自然也就懂了 HashSet

    保证不重复

    Set接口要求不能有重复项,只要继承了 Set就要遵守这个规定。我们大多数情况下使用 HashSet也是因为它有去重的功能。

    那它是如何办到的呢,这就要从它的 add方法说起了。

    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
    public boolean add(E e) {
      return map.put(e, PRESENT)==null;
    }
    

    HashSetadd方法其实就是调用了HashMapput方法,但是我们都知道 put进去的是一个键值对,但是 HashSet存的不是键值对啊,是一个泛型啊,那它是怎么办到的呢?

    它把你要存的值当做 HashMap的 key,而 value 值是一个 finalObject对象,只起一个占位作用。而HashMap本身就不允许重复键,正好被HashSet拿来即用。

    如何保证不重复呢

    HashMap中不允许存在相同的 key 的,那怎么保证 key 的唯一性呢,判断的代码如下。

    if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
    

    首先通过 hash 算法算出的值必须相等,算出的结果是 int,所以可以用 == 符号判断。只是这个条件可不行,要知道哈希碰撞是什么意思,有可能两个不一样的 key 最后产生的 hash 值是相同的。

    并且待插入的 key == 当前索引已存在的 key,或者 待插入的 key.equals(当前索引已存在的key),注意== 和 equals 是或的关系。== 符号意味着这是同一个对象, equals 用来确定两个对象内容相同。

    如果 key 是基本数据类型,比如 int,那相同的值肯定是相等的,并且产生的 hashCode 也是一致的。

    String 类型算是最常用的 key 类型了,我们都知道相同的字符串产生的 hashCode 也是一样的,并且字符串可以用 equals 判断相等。

    但是如果用引用类型当做 key 呢,比如我定义了一个 MoonKey 作为 key 值类型

    public class MoonKey {
    
        private String keyTile;
    
        public String getKeyTile() {
            return keyTile;
        }
    
        public void setKeyTile(String keyTile) {
            this.keyTile = keyTile;
        }
    
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            MoonKey moonKey = (MoonKey) o;
            return Objects.equals(keyTile, moonKey.keyTile);
        }
    }
    

    然后用下面的代码进行两次添加,你说 size 的长度是 1 还是 2 呢?

    Map<MoonKey, String> m = new HashMap<>();
    MoonKey moonKey = new MoonKey();
    moonKey.setKeyTile("1");
    MoonKey moonKey1 = new MoonKey();
    moonKey1.setKeyTile("1");
    m.put(moonKey, "1");
    m.put(moonKey1, "2");
    System.out.println(hash(moonKey));
    System.out.println(hash(moonKey1));
    System.out.println(m.size());
    

    答案是 2 ,为什么呢,因为 MoonKey 没有重写 hashCode 方法,导致 moonkey 和 moonKey1 的 hash 值不可能一样,当不重写 hashCode 方法时,默认继承自 Object的 hashCode 方法,而每个 Object对象的 hash 值都是独一无二的。

    划重点,正确的做法应该是加上 hashCode的重写。

    @Override
    public int hashCode() {
      return Objects.hash(keyTile);
    }
    

    这也是为什么要求重写 equals 方法的同时,也必须重写 hashCode方法的原因之一。 如果两个对象通过调用equals方法是相等的,那么这两个对象调用hashCode方法必须返回相同的整数。有了这个基础才能保证 HashMap或者HashSet的 key 唯一。

    非线程安全

    由于HashMap不是线程安全的,自然,HashSet也不是线程安全啦。在多线程、高并发环境中慎用,如果要用的话怎么办呢,不像 HashMap那样有多线程版本的ConcurrentHashMap,不存在 ``ConcurrentHashSet`这种数据结构,如果想用的话要用下面这种方式。

    Set<String> set = Collections.synchronizedSet(new HashSet<String>());
    

    或者使用 ConcurrentHashMap.KeySetView也可以,但是,这其实就不是 HashSet了,而是 ConcurrentHashMap的一个实现了 Set接口的静态内部类,多线程情况下使用起来完全没问题。

    ConcurrentHashMap.KeySetView<String,Boolean> keySetView = ConcurrentHashMap.newKeySet();
    keySetView.add("a");
    keySetView.add("b");
    keySetView.add("c");
    keySetView.add("a");
    keySetView.forEach(System.out::println);
    

    LinkedHashSet

    如果说 HashSet是套壳儿HashMap,那么LinkedHashSet就是套壳儿LinkedHashMap。对比 HashSet,它的一个特点就是保证数据有序,插入的时候什么顺序,遍历的时候就是什么顺序。

    看一下它其中的无参构造函数。

    public LinkedHashSet() {
      super(16, .75f, true);
    }
    

    LinkedHashSet继承自 HashSet,所以 super(16, .75f, true);是调用了HashSet三个参数的构造函数。

    HashSet(int initialCapacity, float loadFactor, boolean dummy) {
      map = new LinkedHashMap<>(initialCapacity, loadFactor);
    }
    

    这次不是 new HashMap了,而是 new 了一个 LinkedHashMap,这就是它能保证有序性的关键。LinkedHashMap用双向链表的方式在 HashMap的基础上额外保存了键值对的插入顺序。

    HashMap中定义了下面这三个方法,这三个方法是在插入和删除键值对的时候调用的方法,用来维护双向链表,在LinkedHashMap中有具体的实现。

    // Callbacks to allow LinkedHashMap post-actions
    void afterNodeAccess(Node<K,V> p) { }
    void afterNodeInsertion(boolean evict) { }
    void afterNodeRemoval(Node<K,V> p) { }
    

    由于LinkedHashMap可以保证键值对顺序,所以,用来实现简单的 LRU 缓存。

    所以,如果你有场景既要保证元素无重复,又要保证元素有序,可以使用 LinkedHashSet

    最后

    其实你掌握了 HashMap就掌握了 HashSet,它没有什么新东西,就是巧妙的利用了 HashMap而已,新不新不要紧,好用才是最重要的。


    壮士且慢,先给点个赞吧,总是被白嫖,身体吃不消!

    我是风筝,公众号「古时的风筝」。一个兼具深度与广度的程序员鼓励师,一个本打算写诗却写起了代码的田园码农!你可选择现在就关注我,或者看看历史文章再关注也不迟。

  • 相关阅读:
    洛谷 P6851 【onu】贪心
    联赛模拟测试12 C. sum 莫队+组合数
    晚间测试4 哪一天她能重回我身边 神奇建图+基环树
    联赛模拟测试11 D. 甜圈 线段树维护哈希值
    CF741D Arpa’s letter-marked tree and Mehrdad’s Dokhtar-kosh paths 树上启发式合并(DSU ON TREE)
    CF788B Weird journey 题解
    联赛模拟测试10 C. 射手座之日
    晚间测试3 B. 单(single)
    CF538B Quasi Binary 思维题
    CF600E Lomsat gelral 树上启发式合并
  • 原文地址:https://www.cnblogs.com/fengzheng/p/13217974.html
Copyright © 2011-2022 走看看