教妹學 Java:大有可為的集合


00、故事的起源

“二哥,上一篇《泛型》的反響效果怎么樣啊?”三妹對她提議的《教妹學 Java》專欄很是關心。

“有人評論說,‘二哥你敲代碼都敲出幻想了啊。’”

“呵呵,這句話充斥着滿滿的諷刺意味啊。”三妹有點難過了起來。

“不過,也有人評論說,‘建議這個系列的文章多寫啊,因為我花了半個月都沒看懂《 Java 編程思想》中關於泛型的講解,但再看完這篇文章后終於融會貫通了,比心。’”

“二哥,你能不能先說好消息啊?真是的。我也要給這位暖心的讀者比心了。”三妹說完這句話就在我面前比了一個心,我瞅了她一眼,發現她之前的愁容也無影無蹤了。

“那接下來,二哥還要繼續寫嗎?”我看到了三妹深情的目光。

“嗯,我想該寫集合了。”

“那就讓我繼續來提問吧,二哥你繼續來回答。”三妹已經躍躍欲試了。

01、二哥,什么是集合啊?

三妹,聽哥慢慢給你講啊。

JDK 1.2 的時候引入了集合的概念,用來包含一組數據結構。與數組不同的是,這些數據結構的存儲空間會隨着元素增加而動態增加。其中,有一些集合類支持添加重復元素,而另一些不支持;有一些支持添加 null 元素,而另一些不支持。

可以根據繼承體系將集合分為兩大類,一類實現了 Collection 接口(見圖 1),另一類實現了 Map 接口(見圖 2)。

圖 1圖 1

介紹一下圖 1:

1)Collection 是所有集合類的根接口。

2)Set 接口的實現類不允許重復的元素,例如 HashSetLinkedHashSet

3)List 接口的實現類允許重復元素,可通過 index 訪問對應位置上的元素,例如 LinkedListArrayList

4)Queue 接口的實現類允許在隊列的尾部或者頭部增加或者刪除元素,例如 PriorityQueue

圖 2圖 2

介紹一下圖 2:

1)HashMap 是最常用的 Map,可以根據鍵直接獲取對應的值,它根據鍵的 hashCode 值存儲數據,所以訪問速度非常快。HashMap 最多只允許一條記錄的鍵為 null (多條會覆蓋);但允許多條記錄的值為 null

2)TreeMap 能夠把它保存的記錄根據鍵(不允許鍵的值為 null)排序,默認是升序,也可以指定排序的比較器,當用迭代器(Iterator)遍歷 TreeMap 時,得到的記錄是排過序的。

3)Hashtable 的鍵和值均不允許為 null,是線程同步的,也就是說任一時刻只有一個線程能寫 Hashtable,線程同步會消耗掉一些性能,因此 Hashtable 在寫入時花費的時間也會比較多。

4)LinkedHashMap 保存了記錄的插入順序,當用迭代器(Iterator)遍歷 LinkedHashMap 時,先得到的記錄肯定是先插入的。鍵和值均允許為 null

有了集合的幫助,程序員不再需要親自實現元素的排序、查找等底層算法了。另外,基於數組實現的集合類在頻繁讀取時性能更佳,比如說 ArrayList;基於隊列實現的集合類在頻繁增加、更新、刪除數據時效率更高,比如說 LinkedList;程序員所要做的就是,根據業務需要選擇適當的集合類,至於性能調優嘛,可以微信找二哥。

02、二哥,LinkedList 和 ArrayList 有什么區別啊?

三妹,剛提完問題就打盹啊,繼續聽哥給你慢慢講啊。

LinkedList 其實是一個雙向鏈表,來看源碼。

public class LinkedList<E>
{
    transient int size = 0;

    /**
     * Pointer to first node.
     * Invariant: (first == null && last == null) ||
     *            (first.prev == null && first.item != null)
     */
    transient Node<E> first;

    /**
     * Pointer to last node.
     * Invariant: (first == null && last == null) ||
     *            (last.next == null && last.item != null)
     */
    transient Node<E> last;

    private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }
}

1)LinkedList 包含一個非常重要的內部類——NodeNode 是節點所對應的數據結構,item 為當前節點的值,prev 為上一個節點,next 為下一個節點——這也正是“雙向”鏈表的原因。firstLinkedList 的第一個節點,last 為最后一個節點。

2)sizeLinkedList 的節點個數。當往 LinkedList 添加一個元素時,size+1,刪除一個元素時,size-1。

ArrayList 其實是一個動態數組,來看源碼。

public class ArrayList<E>
{
     /**
     * The array buffer into which the elements of the ArrayList are stored.
     * The capacity of the ArrayList is the length of this array buffer. Any
     * empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
     * will be expanded to DEFAULT_CAPACITY when the first element is added.
     */
    transient Object[] elementData; // non-private to simplify nested class access

    /**
     * The size of the ArrayList (the number of elements it contains).
     *
     * @serial
     */
    private int size;
}

1)elementDataObject 類型的數組,用來保存添加到 ArrayList 中的元素。如果通過默認構造參數創建 ArrayList 對象時,elementData 的默認大小是 10。當 ArrayList 容量不足以容納全部元素時,就會重新設置容量,新的容量 = 原始容量 + (原始容量 >> 1)(參照以下代碼)。

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    elementData = Arrays.copyOf(elementData, newCapacity);
}

>> 運算符還沒有駕馭了。不過,通過代碼測試后的結論是,當原始容量為 10 的時候,新的容量為 15;當原始容量為 20 的時候,新的容量為 30。

2) sizeArrayList 的元素個數。當往 ArrayList 添加一個元素時,size+1,刪除一個元素時,size-1。

由於 LinkedListArrayList 底層實現的不同(一個雙向鏈表,一個動態數組),它們之間的區別也很一目了然。

關鍵點1 :LinkedList 在添加(add(E e))、插入(add(int index, E element))、刪除(remove(int index))元素的性能上遠超 ArrayList

為什么呢?先來看 ArrayList 的相關源碼。

// ensureCapacityInternal() 方法內部會調用 System.arraycopy()
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

public void add(int index, E element) {
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    elementData[index] = element;
    size++;
}

public E remove(int index) {
    E oldValue = elementData(index);

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}

觀察 ArrayList 的源碼,就能夠發現,ArrayList 在添加、插入、刪除元素的時候,會有意或者無意(擴容)的調用 System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length) 方法,該方法對性能的損耗是非常嚴重的。

再來看 LinkedList 的相關源碼。

/**
 * Links e as last element.
 */
void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
}
/**
 * Unlinks non-null node x.
 */
unlink(Node<E> x) {

    if (prev == null) {
        first = next;
    } else {
        prev.next = next;
        x.prev = null;
    }

    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
        x.next = null;
    }

    x.item = null;
    return element;
}

LinkedList 不存在擴容的問題,也不需要對原有的元素進行復制;只需要改變節點的數據就好了。

關鍵點2:LinkedList 在查找元素時要慢於 ArrayList

為什么呢?先來看 LinkedList 的相關源碼。

/**
 * Returns the (non-null) Node at the specified element index.
 */
Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

觀察 LinkedList 的源碼,就能夠發現, LinkedList 在定位 index 的時候會先判斷位置(是在 1 / 2 的前面還是后面),再從前往后或者從后往前執行 for 循環依次找。

再來看 ArrayList 的相關源碼。

@SuppressWarnings("unchecked")
elementData(int index) {
    return (E) elementData[index];
}

ArrayList 直接根據 index 從數組中取出該位置上的元素,不需要 for 循環遍歷啊——這樣顯然更快!

03、二哥,HashMap 和 TreeMap 有什么區別啊?

三妹,提問題越來越有藝術了啊?繼續聽哥給你慢慢講啊。

HashMap 存儲的是鍵值對,其鍵是一個哈希碼(Hash 的直譯,也稱作散列)。來看源碼。

public class HashMap<K,V>
{
    transient Node<K,V>[] table;
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    }
    public HashMap(int initialCapacity, float loadFactor) {
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
}

1)table 是一個 Node 數組,而 Node 是一個單向鏈表(只有 next)。HashMap 的鍵值對就存儲在 table 數組中。

2)loadFactor 就是大名鼎鼎的加載因子,默認的加載因子是 0.75, 據說這是在時間和空間成本上尋求的一種折衷。

3)initialCapacity 就是初始容量,默認為 16。
  
4)thresholdHashMap 的閾值——判斷是否需要對 HashMap 進行擴容,threshold 的值 = 容量 * 加載因子,當 HashMap 中存儲的數據數量達到 threshold 時,就需要將 HashMap 的容量加倍。

“初始容量” 和 “加載因子”對 HashMap 的性能影響頗大。容量是 HashMap 中桶(見下圖)的數量,初始容量只是 HashMap 在創建時的容量。加載因子是 HashMap 在其容量自動增加之前可以達到多滿的一種尺度。

TreeMap 存儲的是有序的鍵值對,基於紅黑樹(Red-Black tree)實現。可以在初始化的時候指定鍵位的排序方式,如果沒有指定的話就根據鍵位的自然順序進行排序。來看源碼。

public class TreeMap<K,V>
{
    private final Comparator<? super K> comparator;
    private transient Entry<K,V> root;
    private static final boolean RED   = false;
    private static final boolean BLACK = true;
    static final class Entry<K,V> implements Map.Entry<K,V> {
        K key;
        V value;
        Entry<K,V> left;
        Entry<K,V> right;
        Entry<K,V> parent;
        boolean color = BLACK;
    }
}

1)root 是紅黑樹的根節點,是一個 Entry 類型(按照 key 進行排序),包含了 key(鍵)、value(值)、left(左邊的子節點)、right(右邊的子節點)、parent(父節點)、color(顏色)。

2)comparator 是紅黑樹的排序方式,是一個 Comparator 接口類型,該接口里面有一個 compare 方法,有兩個參數 T o1T o2,是泛型的表示方式,表示待比較的兩個對象,該方法的返回值是一個整形, o1大於o2,返回正整數; o1等於o2,返回0;o1小於o3,返回負整數。

總結一下就是,HashMap 適用於在 Map 中插入、刪除和定位元素;TreeMap 適用於按自然順序或自定義順序遍歷鍵(key)。

04、二哥,再講講二分查找唄!

三妹,沒有任何問題,包在我身上。不過,在講之前,你能先去給哥泡杯咖啡嗎?

通常,我們從數組中查找一個元素時,需要對整個數組進行遍歷。但如果這個數組是排序過的,就可以進行二分查找了。

二分查找的方式:

第一步,將數組中間位置上的元素與要查找的對象進行比較,如果兩者相等,則查找成功;否則進行第二步。

第二步,利用中間位置將數組分割成前、后兩個子集。

第三步,比較要查找的對象與中間位置上的元素,如果前者大於后者,則在后面的子集中按照之前的方式進行查找;否則,在前面的子集中按照之前的方式進行查找。

這樣做可以將查找范圍縮減一半,大大的減少了查詢的次數。

Collections 類的 binarySearch() 方法實現了二分查找這個算法,可以直接使用,前提是先要排序,否則將返回 -2。源碼如下。

private static <T>
int indexedBinarySearch(List<? extends Comparable<? super T>> list, T key) {
    int low = 0;
    int high = list.size()-1;

    while (low <= high) {
        int mid = (low + high) >>> 1;
        Comparable<? super T> midVal = list.get(mid);
        int cmp = midVal.compareTo(key);

        if (cmp < 0)
            low = mid + 1;
        else if (cmp > 0)
            high = mid - 1;
        else
            return mid; // key found
    }
    return -(low + 1);  // key not found
}

我們來測試一下。

List<String> list1 = new ArrayList<>();
list1.add("沉");
list1.add("默");
list1.add("王");
list1.add("二");

Collections.sort(list1); // 先要排序
System.out.println(Collections.binarySearch(list1, "王")); // 2

05、故事的未完待續

“二哥,終於講完《集合》了,喝口咖啡吧!”三妹的態度很體貼。

“謝謝。”

“二哥,如果這篇文章繼續遭受到批評,你會不會氣餒啊?”三妹眨了眨眼睛,繼續問我,我看到她長長的睫毛,真的很美。

“嗯,對於作者來說,當然希望文章能夠得到正面的反饋,如果是負面的反饋,那也在我的意料之中。”

“為啥?”三妹很好奇。

“《教妹學 Java》是一種創新的寫作手法,市面上還沒有,新鮮、有趣的事物總需要一段時間才能被大眾接受,否則也就不叫創新了。”

“二哥,為你的勇氣點贊!”看到三妹很為我驕傲的樣子,我的心里盛開了一朵牡丹花。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM