小測試:HashSet可以插入重複的元素嗎?

2023-11-05 18:01:12

  Set的定義是一群不重複的元素的集合容器。也就是說,只要使用Set元件,應該是要保證相同的資料只能寫入一份,要麼報錯,要麼忽略。當然一般是直接忽略。

  如題,HashSet是Set的一種實現,自然也符合其基本的定義。它的自然表現是,一直往裡面插入資料,然後最後可以得到全部不重複的資料集合,即直到天然去重的效果。

 

1. 簡單使用如下

  先插入幾個元素,得到的結果是沒有重複的結果數量。

    @Test
    public void testSetAdd() {
        Set<String> data = new HashSet<>();
        data.add("a");
        data.add("b");
        data.add("a");
        Assert.assertEquals("數量不正確", 2, data.size());
    }

  簡單說下HashSet的實現原理,它是基於HashMap實現的一種set容器,直白說就是HashSet內部維護了一個HashMap的範例,插入和刪除時委託給HashMap去實現,而HashMap中的Key就是HashSet中的值,HashMap的value就是一個常數Object.

    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();

    /**
     * Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
     * default initial capacity (16) and load factor (0.75).
     */
    public HashSet() {
        map = new HashMap<>();
    }

    /**
     * Adds the specified element to this set if it is not already present.
     * More formally, adds the specified element <tt>e</tt> to this set if
     * this set contains no element <tt>e2</tt> such that
     * <tt>(e==null&nbsp;?&nbsp;e2==null&nbsp;:&nbsp;e.equals(e2))</tt>.
     * If this set already contains the element, the call leaves the set
     * unchanged and returns <tt>false</tt>.
     *
     * @param e element to be added to this set
     * @return <tt>true</tt> if this set did not already contain the specified
     * element
     */
    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }

  還是比較清晰的。

 

2. HashSet保證元素不重複的原理

  上節講了HashSet是基於HashMap實現的,只不過它忽略了HashMap中的value資訊。那麼它怎麼樣保證不重複呢,自然也是依賴於HashMap了,HashMap中要保證key不重複有兩個點:一是hashCode()要返回相同的值;二是equals()要返回true;換句話說就是要我們絕對認為該物件就是同一個時,才會替換原來的值。即要重寫 hashCode()和equals()方法。樣例如下:

class TableFieldDesc {

    private String fieldName;

    private String alias;

    public TableFieldDesc(String fieldName, String alias) {
        this.fieldName = fieldName;
        this.alias = alias;
    }

    @Override
    public int hashCode() {
        return Objects.hash(fieldName, alias);
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        TableFieldDesc that = (TableFieldDesc) o;
        return Objects.equals(fieldName, that.fieldName) &&
                Objects.equals(alias, that.alias);
    }

}

  這樣一來的話, new TableFieldDesc("f_a", "a") 與 new TableFieldDesc("f_a", "a") 就可以相等了,也就是說,如果有兩個這樣的元素插入,只會被當作同一個來處理了,從而達到去重的效果。測試如下:

    @Test
    public void testSetAdd2() {
        Set<TableFieldDesc> data = new HashSet<>();
        data.add(new TableFieldDesc("f_a", "a"));
        data.add(new TableFieldDesc("f_a", "a"));
        Assert.assertEquals("數量不正確", 1, data.size());
    }

 

3. HashSet真能夠保證不插入重複元素嗎?

  如題,hashSet真的能夠保證不插入重複元素嗎?我們常規理解好像是的,但是實際上還是有點問題。一般地,我們要求HashMap的key是不可變的,為什麼會有這種要求呢?因為簡單啊。但是,實際場景需要,也允許可變,就是要做到上節說的hashCode與equals重寫。看起來一切都很美好,但是真的就沒問題了嗎?其實是有的。如下:

    @Test
    public void testSetAdd3() {
        Set<TableFieldDesc> data = new HashSet<>();
        TableFieldDesc fa = new TableFieldDesc("f_a", "a");
        data.add(fa);
        // 將f_a 改成了f_b,即 new TableFieldDesc("f_b", "a");
        fa.setFieldName("f_b");

        TableFieldDesc fb = new TableFieldDesc("f_b", "a");
        data.add(fb);
        System.out.println("data:" + data);
        // 此處就插入了重複的元素了
        Assert.assertEquals("數量不正確", 2, data.size());
    }

  如上就是,插入了兩個重複的元素了,列印資訊為:

data:[TableFieldDesc{fieldName='f_b', alias='a'}, TableFieldDesc{fieldName='f_b', alias='a'}]

  完整的TableFieldDesc描述如下:

class TableFieldDesc {

    private String fieldName;

    private String alias;

    public TableFieldDesc(String fieldName, String alias) {
        this.fieldName = fieldName;
        this.alias = alias;
    }

    public void setFieldName(String fieldName) {
        this.fieldName = fieldName;
    }

    public void setAlias(String alias) {
        this.alias = alias;
    }

    @Override
    public int hashCode() {
        return Objects.hash(fieldName, alias);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        TableFieldDesc that = (TableFieldDesc) o;
        return Objects.equals(fieldName, that.fieldName) &&
                Objects.equals(alias, that.alias);
    }

    @Override
    public String toString() {
        return "TableFieldDesc{" +
                "fieldName='" + fieldName + '\'' +
                ", alias='" + alias + '\'' +
                '}';
    }
}
View Code

  為什麼會這樣呢?就像測試用例中寫的,先插入了一個元素,然後再改變裡面的某個值,隨後再插入一個改變過之後的值,就重複了。因為hashCode是在插入的時候計算的,而當後續使用者改變key的資料值,導致hashCode變更,這時就存在,在對應的slot上,不存在對應元素的情況,所以下次再插入另一個相同元素時,就被認為元素不存在從而插入重複資料了。

  更嚴重的,當元素資料達到一定的時候,會存在擴容,會重複遷移所有元素,可能還會存在hash重新計算從而將重複的元素變為不重複的情況,就更玄乎了。(不過幸好,HashMap中的擴容不會重新計算hash,它會保留原來的hash,所以重複的元素永遠會重複。)

  結語警示:如果想用Set容器去做去重的工作,需要仔細瞭解其實現原理,而非想當然的認為會去重。能做到不改變key值就儘量避開,甚至不暴露修改資料的方法,即做到物件不可變的效果。從而避免踩坑。