閒扯hashmap和hashtable

4.1 hashmap

在JDK1.8中,HashMap做了一些改變:

 

JDK1.7中,發生哈希碰撞時,將鍵值對添加到鏈表頭部,JDK1.8是將鍵值對添加到鏈表尾部。

JDK1.8中,如果鏈表的長度超過8,將會將鏈表轉化爲紅黑樹。

容量的初始化:JDK1.7HashMap在構造時會對容量進行初始化,而JDK1.8是在首次向HashMap總中執行put操作時,對容量進行初始化,也就是說,JDK1.8HashMap使用了懶漢模式(在使用時才初始化),避免了初始化後卻不用的資源浪費。

那爲什麼要進行樹化的改造呢?

 

主要是爲了避免哈希碰撞拒絕服務攻擊。

 

從性能角度來看:解決哈希衝突時使用鏈表,插入和刪除的效率很高,只需O(1)的時間複雜度,但對於查詢而言,則需要O(n)的時間負責度。但紅黑樹的插入,刪除,查詢的最差時間複雜度爲O(logn)。惡意代碼可以利用大量數據與服務器交互,比如String的hashcode函數的強度很弱,有人可以很容易的構造出大量hashcode相同的String對象。如果向服務器一次提交數萬個hashcode相同的字符串,服務器的查詢時間過長,讓服務器的CPU被大量佔用,當有其他更多的請求時服務器會拒絕服務。而使用紅黑樹可以將查詢時間降低到一定的數量級,可以有效避免哈希碰撞拒絕服務攻擊。

---------------------

HashMap中對key求完hash值,在進行數組尋址時,使用的方法是位運算(代替的取模運算)。公式如下:

    (length - 1) & hash  // length爲HashMap的容量,是2的n次方

1

  在這裏插播一個小知識點:位運算(&)比模運算(%)效率高很多,原因是位運算直接對內存數據進行操作,不需要像模運算一樣轉成十進制,因此處理速度快。

 

    // 可以使用位運算代替模運算的原因,見以下公式:

    hash % 2^n = hash & (2^n -1)

   

    // 5 % 8 = 5 & 7 = 0110 & 0111 = 0110 = 5

    // 13 % 8 = 13 & 7 = 1110 & 0111 = 0110 =5

 

Hashtable中求完hash值,在進行數組尋址時,使用的取模運算。公式如下:

    int index = (hash & 0x7FFFFFFF) % tab.length;

    // 此處hash和0x7FFFFFFF的一次位與操作,是爲了保證得到的index值首位爲0(代表正數),其實就是在取絕對值。以避免負數計算index的複雜度

    // tab.length爲Hashtable的長度。默認初始化爲11,之後rehash每次擴容爲oldCapacity * 2 + 1

  前面說過,HashMap之所以不用取模的原因是爲了提高效率,爲什麼Hashtable還要使用?有人認爲,因爲HashTable是個線程安全的類,本來就慢,所以Java並沒有考慮效率問題,就直接使用取模算法了呢?但是其實並不完全是,Java這樣設計還是有一定的考慮在的,雖然這樣效率確實是會比HashMap慢一些。

  HashTable簡單的取模是有一定的考慮在的。這就要涉及到HashTable的構造函數和擴容函數。Hashtable的長度:默認初始化爲11,之後rehash每次擴容爲oldCapacity * 2 + 1。也就是說,HashTable的鏈表數組的默認大小是一個素數、奇數。之後的每次擴充結果也都是奇數。。

  由於HashTable會盡量使用素數、奇數作爲容量的大小。當哈希表的大小爲素數時,簡單的取模哈希的結果會更加均勻。這就是文章開頭所提到的,問題來源

 

那爲何hash要對素數取餘呢?

  常用的hash函數是選一個數m取模(餘數),這個數在課本中推薦m是素數,但是經常見到選擇m=2n,因爲對2n求餘數更快,並認爲在key分佈均勻的情況下,key%m也是在[0,m-1]區間均勻分佈的。但實際上,key%m的分佈同m是有關的。

  證明如下: key%m = key - xm,即key減掉m的某個倍數x,剩下比m小的部分就是key除以m的餘數。顯然,x等於key/m的整數部分,以floor(key/m)表示。假設key和m有公約數g,即key=ag, m=bg, 則 key - xm = key - floor(key/m)m = key - floor(a/b)m。由於0 <= a/b <= a,所以floor(a/b)只有a+1中取值可能,從而推導出key%m也只有a+1中取值可能。a+1個球放在m個盒子裏面,顯然不可能做到均勻。

  由此可知,一組均勻分佈的key,其中同m公約數爲1的那部分,餘數後在[0,m-1]上還是均勻分佈的,但同m公約數不爲1的那部分,餘數在[0, m-1]上就不是均勻分佈的了。把m選爲素數,正是爲了讓所有key同m的公約數都爲1,從而保證餘數的均勻分佈,降低衝突率。

 

4.1 HashMap

哈希:

Hash,一般翻譯做散列、雜湊,或音譯爲哈希,是把任意長度的輸入(又叫做預映射pre-image)通過散列算法變換成固定長度的輸出,該輸出就是散列值。這種轉換是一種壓縮映射,也就是,散列值的空間通常遠小於輸入的空間,不同的輸入可能會散列成相同的輸出,所以不可能從散列值來確定唯一的輸入值。簡單的說就是一種將任意長度的消息壓縮到某一固定長度的消息摘要的函數。

哈希表:

常用哈希函數

 

散列函數能使對一個數據序列的訪問過程更加迅速有效,通過散列函數,數據元素將被更快地定位。常用Hash函數有:

1直接尋址法。取關鍵字或關鍵字的某個線性函數值爲散列地址。即H(key)=keyH(key) = a·key + b,其中ab爲常數(這種散列函數叫做自身函數)

2 數字分析法。分析一組數據,比如一組員工的出生年月日,這時我們發現出生年月日的前幾位數字大體相同,這樣的話,出現衝突的機率就會很大,但是我們發現年月日的後幾位表示月份和具體日期的數字差別很大,如果用後面的數字來構成散列地址,則衝突的機率會明顯降低。因此數字分析法就是找出數字的規律,儘可能利用這些數據來構造衝突機率較低的散列地址。

3 平方取中法。取關鍵字平方後的中間幾位作爲散列地址。

4 摺疊法。將關鍵字分割成位數相同的幾部分,最後一部分位數可以不同,然後取這幾部分的疊加和(去除進位)作爲散列地址。

5 隨機數法。選擇一隨機函數,取關鍵字作爲隨機函數的種子生成隨機值作爲散列地址,通常用於關鍵字長度不同的場合。

6 除留餘數法。取關鍵字被某個不大於散列表表長m的數p除後所得的餘數爲散列地址。即 H(key) = key MOD p,p<=m。不僅可以對關鍵字直接取模,也可在摺疊、平方取中等運算之後取模。對p的選擇很重要,一般取素數或m,若p選的不好,容易產生碰撞。

 

  1. javastringhashcode實現
  2. public int hashCode() {
  3.     int h = hash;//hash 默認是 0
  4.     if (h == 0 && value.length > 0) { // 只算一次
  5.         char val[] = value;
  6.  
  7.         for (int i = 0; i < value.length; i++) {
  8.             h = 31 * h + val[i];
  9.         }
  10.         hash = h;
  11.     }
  12.     return h;
  13. }
  1. javaobjecthashcode實現:

 

哈希衝突處理方法:

1開放尋址法Hi=(H(key) + di) MOD m,i=1,2,…k(k<=m-1),其中H(key)散列函數m散列表長,di爲增量序列,可有下列三種取法:

1) di=1,2,3,…m-1,稱線性探測再散列;

2) di=1^2,-1^2,2^2,-2^2,3^2,…±k^2,(k<=m/2)稱二次探測再散列;

3) di=僞隨機數序列,稱僞隨機探測再散列。

2 散列法Hi=RHi(key),i=1,2,…k RHi均是不同的散列函數,即在同義詞產生地址衝突時計算另一個散列函數地址,直到衝突不再發生,這種方法不易產生聚集,但增加了計算時間。

3 鏈地址法(拉鍊法)

4 建立一個公共溢出區

 

 

package java.util;

 

import java.io.IOException;

import java.io.InvalidObjectException;

import java.io.Serializable;

import java.lang.reflect.ParameterizedType;

import java.lang.reflect.Type;

import java.util.function.BiConsumer;

import java.util.function.BiFunction;

import java.util.function.Consumer;

import java.util.function.Function;

import sun.misc.SharedSecrets;

 

public class HashMap<K,V> extends AbstractMap<K,V>

    implements Map<K,V>, Cloneable, Serializable {

 

    private static final long serialVersionUID = 362498820763181265L;

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    static final int MAXIMUM_CAPACITY = 1 << 30;

    static final float DEFAULT_LOAD_FACTOR = 0.75f;//爲什麼是0.75

    static final int TREEIFY_THRESHOLD = 8;

    static final int UNTREEIFY_THRESHOLD = 6;

static final int MIN_TREEIFY_CAPACITY = 64;

    /* ---------------- Fields -------------- */

    transient Node<K,V>[] table;  //數組

    transient Set<Map.Entry<K,V>> entrySet;

    transient int size;

    transient int modCount;

    int threshold;

    final float loadFactor;

 

    static class Node<K,V> implements Map.Entry<K,V> {

        final int 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; }

       //object  的hash函數

        public final int hashCode() {

            return Objects.hashCode(key) ^ Objects.hashCode(value);

        }

 

        public final V setValue(V newValue) {

            V oldValue = value;

            value = newValue;

            return oldValue;

        }

 

        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;

        }

    }

 

    /* ---------------- Static utilities -------------- */

    static final int hash(Object key) {

        int h;

        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

    }

 

 

    /* ---------------- Public operations -------------- */

    public HashMap(int initialCapacity, float loadFactor) {

        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);

    }

    public HashMap(int initialCapacity) {

        this(initialCapacity, DEFAULT_LOAD_FACTOR);

    }

    public HashMap() {

        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted

    }

    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);

            }

        }

    }

    public int size() {

        return size;

    }

    public boolean isEmpty() {

        return size == 0;

    }

    public V get(Object key) {

        Node<K,V> e;

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

    }

 

    final Node<K,V> getNode(int hash, Object key) {

        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

        if ((tab = table) != null && (n = tab.length) > 0 &&

            (first = tab[(n - 1) & hash]) != null) {

            if (first.hash == hash && // always check first node

                ((k = first.key) == key || (key != null && key.equals(k))))

                return first;

            if ((e = first.next) != null) {

                if (first instanceof TreeNode)

                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);

                do {

                    if (e.hash == hash &&

                        ((k = e.key) == key || (key != null && key.equals(k))))

                        return e;

                } while ((e = e.next) != null);

            }

        }

        return null;

    }

    public boolean containsKey(Object key) {

        return getNode(hash(key), key) != null;

    }

    public V put(K key, V value) {

        return putVal(hash(key), key, value, false, true);

    }

    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)

            n = (tab = resize()).length;

        if ((p = tab[i = (n - 1) & hash]) == null)

            tab[i] = newNode(hash, key, value, null);

        else {

            Node<K,V> e; K k;

            if (p.hash == hash &&

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

                e = p;

            else if (p instanceof TreeNode)

                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

            else {

                for (int binCount = 0; ; ++binCount) {

                    if ((e = p.next) == null) {

                        p.next = newNode(hash, key, value, null);

                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st

                            treeifyBin(tab, hash);

                        break;

                    }

                    if (e.hash == hash &&

                        ((k = e.key) == key || (key != null && key.equals(k))))

                        break;

                    p = e;

                }

            }

            if (e != null) { // existing mapping for key

                V oldValue = e.value;

                if (!onlyIfAbsent || oldValue == null)

                    e.value = value;

                afterNodeAccess(e);

                return oldValue;

            }

        }

        ++modCount;

        if (++size > threshold)

            resize();

        afterNodeInsertion(evict);

        return null;

    }

    final Node<K,V>[] resize() {

        Node<K,V>[] oldTab = table;

        int oldCap = (oldTab == null) ? 0 : oldTab.length;

        int oldThr = threshold;

        int newCap, newThr = 0;

        if (oldCap > 0) {

            if (oldCap >= MAXIMUM_CAPACITY) {

                threshold = Integer.MAX_VALUE;

                return oldTab;

            }

            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&

                     oldCap >= DEFAULT_INITIAL_CAPACITY)

                newThr = oldThr << 1; // double threshold

        }

        else if (oldThr > 0) // initial capacity was placed in threshold

            newCap = oldThr;

        else {               // zero initial threshold signifies using defaults

            newCap = DEFAULT_INITIAL_CAPACITY;

            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

        }

        if (newThr == 0) {

            float ft = (float)newCap * loadFactor;

            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?

                      (int)ft : Integer.MAX_VALUE);

        }

        threshold = newThr;

        @SuppressWarnings({"rawtypes","unchecked"})

            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];

        table = newTab;

        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)

                        newTab[e.hash & (newCap - 1)] = e;

                    else if (e instanceof TreeNode)

                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                    else { // preserve order

                        Node<K,V> loHead = null, loTail = null;

                        Node<K,V> hiHead = null, hiTail = null;

                        Node<K,V> next;

                        do {

                            next = e.next;

                            if ((e.hash & oldCap) == 0) {

                                if (loTail == null)

                                    loHead = e;

                                else

                                    loTail.next = e;

                                loTail = e;

                            }

                            else {

                                if (hiTail == null)

                                    hiHead = e;

                                else

                                    hiTail.next = e;

                                hiTail = e;

                            }

                        } while ((e = next) != null);

                        if (loTail != null) {

                            loTail.next = null;

                            newTab[j] = loHead;

                        }

                        if (hiTail != null) {

                            hiTail.next = null;

                            newTab[j + oldCap] = hiHead;

                        }

                    }

                }

            }

        }

        return newTab;

    }

    final void treeifyBin(Node<K,V>[] tab, int hash) {

        int n, index; Node<K,V> e;

        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)

            resize();

        else if ((e = tab[index = (n - 1) & hash]) != null) {

            TreeNode<K,V> hd = null, tl = null;

            do {

                TreeNode<K,V> p = replacementTreeNode(e, null);

                if (tl == null)

                    hd = p;

                else {

                    p.prev = tl;

                    tl.next = p;

                }

                tl = p;

            } while ((e = e.next) != null);

            if ((tab[index] = hd) != null)

                hd.treeify(tab);

        }

    }

  

  

 

    

}

 

 

4.2hashtable

 

package java.util;

 

import java.io.*;

import java.util.concurrent.ThreadLocalRandom;

import java.util.function.BiConsumer;

import java.util.function.Function;

import java.util.function.BiFunction;

import sun.misc.SharedSecrets;

 

public class Hashtable<K,V>

    extends Dictionary<K,V>

    implements Map<K,V>, Cloneable, java.io.Serializable {

    private transient Entry<?,?>[] table;

    private transient int count;

    private int threshold;

    private float loadFactor;

    private transient int modCount = 0;

    private static final long serialVersionUID = 1421746759512286392L;

 

    public Hashtable(int initialCapacity, float loadFactor) {

        if (initialCapacity < 0)

            throw new IllegalArgumentException("Illegal Capacity: "+

                                               initialCapacity);

        if (loadFactor <= 0 || Float.isNaN(loadFactor))

            throw new IllegalArgumentException("Illegal Load: "+loadFactor);

 

        if (initialCapacity==0)

            initialCapacity = 1;

        this.loadFactor = loadFactor;

        table = new Entry<?,?>[initialCapacity];

        threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);

    }

 

    public Hashtable(int initialCapacity) {

        this(initialCapacity, 0.75f);

    }

 

    public Hashtable() {

        this(11, 0.75f);

    }

 

  

    public Hashtable(Map<? extends K, ? extends V> t) {

        this(Math.max(2*t.size(), 11), 0.75f);

        putAll(t);

    }

 

 

    public synchronized int size() {

        return count;

    }

 

  

    public synchronized boolean isEmpty() {

        return count == 0;

    }

 

 

    public synchronized Enumeration<K> keys() {

        return this.<K>getEnumeration(KEYS);

    }

 

 

    public synchronized Enumeration<V> elements() {

        return this.<V>getEnumeration(VALUES);

    }

 

  

    public synchronized boolean contains(Object value) {

        if (value == null) {

            throw new NullPointerException();

        }

 

        Entry<?,?> tab[] = table;

        for (int i = tab.length ; i-- > 0 ;) {

            for (Entry<?,?> e = tab[i] ; e != null ; e = e.next) {

                if (e.value.equals(value)) {

                    return true;

                }

            }

        }

        return false;

    }

 

   

    public boolean containsValue(Object value) {

        return contains(value);

    }

 

 

    public synchronized boolean containsKey(Object key) {

        Entry<?,?> tab[] = table;

        int hash = key.hashCode();

        int index = (hash & 0x7FFFFFFF) % tab.length;

        for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {

            if ((e.hash == hash) && e.key.equals(key)) {

                return true;

            }

        }

        return false;

    }

 

    //先計算key的hash,得到數組下表,然後得到value

    @SuppressWarnings("unchecked")

    public synchronized V get(Object key) {

        Entry<?,?> tab[] = table;

        int hash = key.hashCode();

        int index = (hash & 0x7FFFFFFF) % tab.length;

        for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {

            if ((e.hash == hash) && e.key.equals(key)) {

                return (V)e.value;

            }

        }

        return null;

    }

 

   

    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

 

   // HashTable中,當key值的數量達到閥值時,需要重新擴展容器數量。調用rehash方法, 擴展容器的大小。

            hashTable是一個數組的鏈表,本身是一個 Entry[] 數組,裏面的一個對象放的是一個鏈表的結構。如果一個HashTable中key的hashcode相同,那麼它就放在同一個鏈表中。

 

   回到rehash中,在擴展容器本身的容量時,每個對象(key,value)的位置也會相應的發生調整

    @SuppressWarnings("unchecked")

    protected void rehash() {

        int oldCapacity = table.length;

        Entry<?,?>[] oldMap = table;

 

        // overflow-conscious code

        int newCapacity = (oldCapacity << 1) + 1;

        if (newCapacity - MAX_ARRAY_SIZE > 0) {

            if (oldCapacity == MAX_ARRAY_SIZE)

                // Keep running with MAX_ARRAY_SIZE buckets

                return;

            newCapacity = MAX_ARRAY_SIZE;

        }

        Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];

 

        modCount++;

        threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);

        table = newMap;

 

        for (int i = oldCapacity ; i-- > 0 ;) {

            for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {

                Entry<K,V> e = old;

                old = old.next;

 

                int index = (e.hash & 0x7FFFFFFF) % newCapacity;

                e.next = (Entry<K,V>)newMap[index];

                newMap[index] = e;

            }

        }

    }

 

    private void addEntry(int hash, K key, V value, int index) {

        modCount++;

 

        Entry<?,?> tab[] = table;

        if (count >= threshold) {

            // Rehash the table if the threshold is exceeded

            rehash();

 

            tab = table;

            hash = key.hashCode();

            index = (hash & 0x7FFFFFFF) % tab.length;

        }

 

        // Creates the new entry.

        @SuppressWarnings("unchecked")

        Entry<K,V> e = (Entry<K,V>) tab[index];

        tab[index] = new Entry<>(hash, key, value, e);

        count++;

    }

 

    //hashtable存值

    public synchronized V put(K key, V value) {

        // Make sure the value is not null

        if (value == null) {

            throw new NullPointerException();

        }

 

        // Makes sure the key is not already in the hashtable.

        Entry<?,?> tab[] = table;//初始化一個數組

        int hash = key.hashCode();//計算keyhash

        int index = (hash & 0x7FFFFFFF) % tab.length;//計算key放入數組的index

        @SuppressWarnings("unchecked")

        Entry<K,V> entry = (Entry<K,V>)tab[index];

        for(; entry != null ; entry = entry.next) {

            if ((entry.hash == hash) && entry.key.equals(key)) {

                V old = entry.value;

                entry.value = value;

                return old;

            }

        }

 

        addEntry(hash, key, value, index);//keyvalue分別錄入值

        return null;

    }

    public synchronized V remove(Object key) {

        Entry<?,?> tab[] = table;

        int hash = key.hashCode();

        int index = (hash & 0x7FFFFFFF) % tab.length;

        @SuppressWarnings("unchecked")

        Entry<K,V> e = (Entry<K,V>)tab[index];

        for(Entry<K,V> prev = null ; e != null ; prev = e, e = e.next) {

            if ((e.hash == hash) && e.key.equals(key)) {

                modCount++;

                if (prev != null) {

                    prev.next = e.next;

                } else {

                    tab[index] = e.next;

                }

                count--;

                V oldValue = e.value;

                e.value = null;

                return oldValue;

            }

        }

        return null;

    }

}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章