Java集合框架

这次来学习下Java中几种常见的集合框架ArrayList、LinkedList、HashMap、HashSet、TreeMap、TreeSet等,均参考《Java编程的逻辑》。

1. ArrayList

ArrayList 本质上是一个动态数组(Array),第一次添加元素时,数组大小将变化为 DEFAULT_CAPACITY 10,不断添加元素后,会进行扩容。删除元素时,会按照位置关系把数组元素整体(复制)移动一遍。

基本原理

ArrayList内部有一个数组elementData,一般会有一些预留的空间,有一个整数size记录实际的元素个数,transient表示该变量不参与序列化:

private transient Object[] elementData;//随着实际元素个数的增多而重新分配
private int size;//实际的元素个数

接下来看下add方法和remove方法,add方法的代码如下所示:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount
    elementData[size++] = e;
    return true;
}

ensureCapacityInternal会判断数组是不是空的,如果是空的,则首次至少要分配的大小为DEFAULT_CAPACITY,DEFAULT_CAPACITY的值为10:

private void ensureCapacityInternal(int minCapacity) {
    if (elementData == EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}

接下来调用ensureExplicitCapacity,代码为:

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

如果需要的长度大于当前数组的长度,则调用grow方法:

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

排除边缘情况,长度增长的主要代码为:

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

右移一位相当于除2,因此newCapacity相当于oldCapacity的1.5倍。

remove方法的代码如下所示:

public E remove(int index) {
    rangeCheck(index);

    modCount++;
    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;
}

首先计算要移动的元素个数,从index往后的元素都往前移动一位,代码中也可看到实际调用System.arraycopy方法移动元素。elementData[–size] = null 操作会将size减一,同时将最后一个位置设为null,设为null后就不再引用原来对象,如果原来对象也不再被其他对象引用,就可以被垃圾回收。

ArrayList特点
  • 优点:
    • 内部采用动态数组实现,可以随机访问,按照索引位置进行访问效率很高,效率为O(1)
    • 添加元素的效率还可以,重新分配和拷贝数组的开销被平摊。具体来说,添加N个元素的效率为O(N)
    • 可以自动扩容,默认为每次扩容为原来的1.5倍
  • 缺点:
    • 插入和删除元素的效率比较低,因为需要移动元素,具体为O(N)
    • 除非数组已排序,否则按照内容查找元素效率比较低,性能与数组长度成正比
    • 线程不安全,如果想线程安全可以使用Vector或者CopyOnWriteList

2. LinkedList

与ArrayList不同随机访问效率很高,但插入和删除性能比较低,而LinkedList的特点与ArrayList几乎正好相反,LinkedList不能随机访问只能顺序访问元素,但是插入和删除效率比ArrayList高。

基本原理

实现原理上,LinkedList内部是一个双向链表而非动态数组,这就意味着每个元素在内存都是单独存放的而非像数组那样的连续存放,元素之间通过链接连在一起。LinkedList内部组成为如下三个实例变量,size表示链表长度,first指向头节点,last指向尾节点:

transient int size = 0;
transient Node<E> first;
transient Node<E> last;

Node类表示节点:

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

接下来看下add、remove方法,其实和链表的方法大致都是相同的,add方法的代码如下所示:

public boolean add(E e) {
    linkLast(e);
    return true;
}

主要调用了linkLast:

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;
    size++;
    modCount++;
}

例如如下代码:

List<String> list = new LinkedList<String>();
list.add("a");
list.add("b");

执行完第一行后,内部结构如下所示:
在这里插入图片描述
list.add(“a”)后:
在这里插入图片描述
list.add(“b”)后:
在这里插入图片描述
remove方法如下所示:

public E remove(int index) {
    checkElementIndex(index);
    return unlink(node(index));
}

checkElementIndex检查索引位置的有效性,如果无效,抛出异常,代码为:

private void checkElementIndex(int index) {
    if (!isElementIndex(index))
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

private boolean isElementIndex(int index) {
    return index >= 0 && index < size;
}

接着调用了unlink方法,代码为:

E unlink(Node<E> x) {
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    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;
    size--;
    modCount++;
    return element;
}

基本思路就是让x的前驱和后继直接链接起来,next是x的后继,prev是x的前驱,具体分为两步:

  • 让x的前驱的后继指向x的后继。如果x没有前驱,说明删除的是头节点,则修改头节点指向x的后继
  • 让x的后继的前驱指向x的前驱。如果x没有后继,说明删除的是尾节点,则修改尾节点指向x的前驱
LinkedList特点
  • 优点:
    • 按需分配空间,不需要预先扩容
    • 在两端添加、删除元素的效率很高,为O(1)
  • 缺点:
    • 线程不安全
    • 不可以随机访问,按照索引位置访问效率比较低,必须从头或尾顺着链接找,效率为O(N/2)
    • 在中间插入、删除元素,要先定位,效率比较低,为O(N)

3. Vector

基本用法

Vector 类实现了一个动态数组,主要用在事先不知道数组的大小,或者需要一个可以动态改变大小的数组的情况。Vector提供了三种基本的构造方法:

public vector() 
public vector(int initialcapacity,int capacityIncrement) 
public vector(int initialcapacity)

使用第一种默认构造方法时系统会自动对向量进行管理;若使用后两种方法,则系统将根据参数initialcapacity设定向量对象的容量(即向量对象可存储数据的大小),当真正存放的数据个数超过容量时,系统会扩充向量对象存储容量。参数capacityIncrement给定了每次扩充的扩充值。当capacityIncrement为0的时候,则每次扩充一倍,利用这个功能可以优化存储。

接下来看下Vector的几个主要方法:

//在此向量的指定位置插入指定的元素
void add(int index, Object element) 

//将index处的对象设置成obj,原来的对象将被覆盖
public final synchronized void setElementAt(Object obj,int index)

//在index指定的位置插入obj,原来对象以及此后的对象依次往后顺延
public final synchronized void insertElementAt(Object obj,int index) 
  
//移除此向量中指定位置的元素  
Object remove(int index) 

//从向量中删除obj,若有多个存在,则从向量头开始试,删除找到的第一个与obj相同的向量成员
public final synchronized void removeElement(Object obj) 

//删除向量所有的对象 
public final synchronized void removeAllElement() 

//删除index所指的地方的对象
public fianl synchronized void removeElementAt(int index) 
  
//从向量头开始搜索obj,返回所遇到的第一个obj对应的下标,若不存在此obj,返回-1
public final int indexOf(Object obj) 

//遍历
Iterator iterator = vector.iterator();
while (iterator.hasNext()) {
 	System.out.println(iterator.next());
}
与ArrayList区别
  • 线程安全性:ArrayList内部是通过数组实现的,允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要讲已经有数组的数据复制到新的存储空间中。当从ArrayList的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。Vector与ArrayList一样,也是通过数组实现的,不同的是它支持线程的同步(Synchronized),即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问效率比ArrayList慢。
  • 扩容大小不一致:Vector和ArrayList默认初始化大小都是10,但是当其容量超过初始值时,ArrayList默认扩容原有初始容量的50%,而Vector默认扩容原有初始容量的100%,如果在集合中使用数据量比较大的数据,用vector有一定的优势。

4. HashMap

HashMap中Map是指接口的意思,实现Map接口有多种方式,HashMap实现的方式利用了Hash。因此首先来看下Map接口的概念:

Map接口

Map有键和值的概念,一个键映射到一个值,Map按照键存储和访问值,键不能重复,即一个键只会存储一份,给同一个键重复设值会覆盖原来的值。Map接口的定义为:

public interface Map<K,V> {
    V put(K key, V value);//保存键值对
    V get(Object key);//根据键获取值
    V remove(Object key);//根据键删除键值对
    int size();//Map的大小
    boolean isEmpty();//判读大小是否为0
    boolean containsKey(Object key);//是否包含某个键
    boolean containsValue(Object value);//是否包含某个值
    void putAll(Map<? extends K, ? extends V> m);//批量保存
    void clear();//清空Map中所有键值对
    Set<K> keySet();//获取Map中所有键的集合
    Collection<V> values();//获取Map中所有值的集合
    Set<Map.Entry<K, V>> entrySet();//获取Map中的所有键值对
    interface Entry<K,V> {//嵌套接口,定义在Map接口内部,表示一条键值对
        K getKey();
        V getValue();
        V setValue(V value);
        boolean equals(Object o);
        int hashCode();
    }
    boolean equals(Object o);
    int hashCode();
}

使用示例及构造方法

接着来看下HashMap的遍历例子,循环1000次每次生成[0,3]内的数字,并将数字做为key存入HashMap中,value为该key生成的次数,并循环遍历HashMap得到每个key对应的value:

Random rnd = new Random();
Map<Integer, Integer> countMap = new HashMap<>();
for(int i=0; i<1000; i++){
    int num = rnd.nextInt(4);
    Integer count = countMap.get(num);
    if(count==null){
        countMap.put(num, 1);
    }else{
        countMap.put(num, count+1);
    }
}
for(Map.Entry<Integer, Integer> kv : countMap.entrySet()){
    System.out.println(kv.getKey()+","+kv.getValue());
}

可以看到,这里是使用了new HashMap<>();也就是默认的构造方法生成了一个HashMap对象,除此之外,HashMap还有如下构造方法:

public HashMap(int initialCapacity)
public HashMap(int initialCapacity, float loadFactor)

要理解这里initialCapacity和loadFactor参数的含义,需要了解HashMap的实现原理,HashMap内部有如下几个主要的实例变量:

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
transient int size;//实际键值对的个数
int threshold;
final float loadFactor;

table是一个Entry类型的数组,其中的每个元素指向一个单向链表,链表中的每个节点表示一个键值对,Entry是一个内部类,它的实例变量和构造方法代码如下:

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;//指向下一个Entry节点
    int hash;//key的哈希值

    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
}

table的初始值为EMPTY_TABLE,是一个空表,当添加键值对后,table就不是空表了,它会随着键值对的添加进行扩展。添加第一个元素时,默认分配的大小为16(可以由上面构造函数中的initialCapacity设置),不过并不是size大于16时再进行扩展,下次什么时候扩展与threshold有关。threshold表示阈值,当键值对个数size大于等于threshold时考虑进行扩展,而threshold等于table.length乘以另一个参数loadFactor,loadFactor是负载因子,表示整体上table被占用的程度。例如table.length为16,loadFactor为0.75,则threshold为12。

保存键值对

接下来从源码角度分析下HashMap保存键值对的步骤:

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    addEntry(hash, key, value, i);
    return null;
}

如果是第一次保存,首先会调用inflateTable()方法给table分配实际的空间,inflateTable的主要代码为:

private void inflateTable(int toSize) {
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);

    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
}

接下来,检查key是否为null,如果不为空,就调用hash方法计算key的hash值,有了hash值之后,就调用indexFor方法以计算应该将这个键值对放到table的哪个位置:

static int indexFor(int h, int length) {
    return h & (length-1);
}

indexFor实际上就是对hash值进行求模运算,运算结果就是要保存的位置。找到了保存位置i后,table[i]指向一个单向链表,接下来就在这个链表中逐个查找是否已经有这个键,比较的时候,是先比较hash值,hash相同的时候,再使用equals方法进行比较,因为hash是整数,比较的性能一般要比equals比较高,如果hash都不同,就没有必要调用equals方法了。

如果能找到,直接修改Entry中的value即可,如果没找到,则调用addEntry方法在给定的位置添加一条Entry:

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    createEntry(hash, key, value, bucketIndex);
}

首先判断空间够不够,不够的话,就得调用resize进行扩容,简单点来说,扩容就是扩充Entry数组的大小,然后重新计算每个元素在数组中的位置(rehash):

void resize(int newCapacity) {   //传入新的容量
    Entry[] oldTable = table;    //引用扩容前的Entry数组
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
        threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
        return;
    }
 
    Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
    transfer(newTable);                         //将数据转移到新的Entry数组里
    table = newTable;                           //HashMap的table属性引用新的Entry数组
    threshold = (int) (newCapacity * loadFactor);//修改阈值
}

从代码也可以看到,就是使用一个容量更大的Entry数组来代替已有的容量小的Entry数组,transfer()方法负责原有Entry数组的元素拷贝到新的Entry数组里:

 void transfer(Entry[] newTable) {
        Entry[] src = table;                   //src引用了旧的Entry数组
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
            Entry<K, V> e = src[j];             //取得旧Entry数组的每个元素
            if (e != null) {
                src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
                do {
                    Entry<K, V> next = e.next;
                    int i = indexFor(e.hash, newCapacity); //重新计算每个元素在数组中的位置
                    e.next = newTable[i]; //标记[1]
                    newTable[i] = e;      //将元素放在数组上
                    e = next;             //访问下一个Entry链上的元素
                } while (e != null);
            }
        }
  }
  static int indexFor(int h, int length) {
        return h & (length - 1);
  }

这里借鉴HashMap的扩容机制—resize()中的图解来说明上述扩容过程。
在这里插入图片描述
综上可以看到HashMap的扩容操作是一个非常消耗性能的操作,因此应该尽量避免扩容。避免扩容的方法是在初始化的时候设定map的容量,例如说,我们有1000个元素new HashMap(1000), hashmap自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000,我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题,这样通过预设元素的个数就能够有效的提高hashmap的性能。

如果空间够的话,则无须扩容,直接调用createEntry添加:

//新建一个Entry对象,并插入单向链表的头部,并增加size。
	void createEntry(int hash, K key, V value, int bucketIndex) {
	    Entry<K,V> e = table[bucketIndex];
	    table[bucketIndex] = new Entry<>(hash, key, value, e);
	    size++;
	}

上述就是保存键值对的主要代码,主要分成了以下几个步骤:

	1.计算键的哈希值
	2.根据哈希值得到保存位置(取模)
	3.插到对应位置的链表头部或更新已有值
	4.根据需要扩展table大小 

举个例子:

	Map<String,Integer> countMap = new HashMap<>();
	countMap.put("hello", 1);
	countMap.put("world", 3);
	countMap.put("position", 4);

在通过new HashMap()创建一个对象后,内存中的图示结构大概是:
在这里插入图片描述
接下来执行countMap.put(“hello”, 1),"hello"的hash值为96207088,模16的结果为0,所以插入table[0]指向的链表头部,内存结构会变为:

在这里插入图片描述
接下来执行countMap.put(“world”, 3);world"的hash值为111207038,模16结果为15,所以保存完"world"后,内存结构会变为:

在这里插入图片描述
接下来执行countMap.put(“position”, 4);"position"的hash值为771782464,模16结果也为0,table[0]已经有节点了,新节点会插到链表头部,内存结构会变为:
在这里插入图片描述
这里要注意的是,上述原理是jdk1.7中的HashMap,这种链表式结构会存在问题:如果每次存入时计算hash值都是同一个值 ,那么会造成链表中长度多长的问题;反之,如果每次计算hash值都是不同的值,又会造成HashMap的容量不断增大,因此jdk1.8后使用红黑树替代了链表,当链表元素超过8个将链表转换为红黑树。这个之后有时间再学习。

根据键获取值

步骤和保存键值对相似,这里只看下步骤:

  1. 计算键的hash值
  2. 根据hash找到table中的对应链表
  3. 在链表中遍历查找,遍历代码
  4. 逐个比较,先通过hash快速比较,hash相同再通过equals比较

由上也可看到HashMap的基本实现原理其实就是对链表的操作,存取的时候依据键的hash值,只在对应的链表中操作,不会访问别的链表,在对应链表操作时也是先比较hash值,相同的话才用equals方法比较,这就要求,相同的对象其hashCode()返回值必须相同且一个对象的哈希值不能变,如果键是自定义的类,就特别需要注意这一点。这也是自己在equals重载时hashCode和equals方法的一个关键约束,这里也扩展学习下这个问题,参考面试题:为什么要重写hashcode和equals方法?

当我们用HashMap存入自定义的类时,如果不重写这个自定义类的equals和hashCode方法,得到的结果会和我们预期的不一样。参考如下程序:
在这里插入图片描述
程序中定义了两个Key对象,它们的id都是1,我们设想的是26行的输出是k1键对应的值。但是最后会发现输出的为null。这里的原因一是没有重写hashCode方法,二是没有重写equals方法。

前面说过,在对应链表操作时也是先比较hash值,相同的话才用equals方法比较。而这里没有重写hashCode方法后,调用的其实仍是Object类的hashCode方法,而Object类的hashCode方法返回的hash值其实是对象的内存地址,这样k1和k2属于不同对象,自然内存地址也不相同,因此肯定输出为null。

那如果重写了hashcode确没有重写equals,这种情况下第一步比较hash值是相同了,因此接着才能用equals方法,如果没有重写equals的话会发现输出还是为null,因此没有重写的话系统就不得不调用Object类的equals方法,而Object的equals方法是根据两个对象的内存地址来判断,k1和k2一定不会相等,因此肯定会输出null。因此如果要在HashMap的“键”部分存放自定义的对象,一定要在这个对象里重载hashcode和equals方法。

HashMap特点
  • 优点:根据键保存和获取值的效率都很高,为O(1),每个单向链表往往只有一个或少数几个节点,根据hash值就可以直接快速定位。
  • 缺点:线程不安全,在多线程环境中不建议使用。因为HashMap的源码中存在一些成员变量比如size,并且方法中没有加锁,两个线程同时操作时,很容易出现上篇文章中我们说的线程不安全的问题。可以改用Hashtable和ConcurrentHashMap,它们都是线程安全的容器。

5.ConcurrentHashMap

JDK7下的CurrentHashMap

由一个Segment数组和多个HashEntry组成,主要实现原理是实现了锁分离的思路解决了多线程的安全问题。Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,也就是上面的提到的锁分离技术,而每一个Segment元素存储的是HashEntry数组+链表,这个和HashMap的数据存储结构一样。ConcurrentHashMap 与HashMap和Hashtable 最大的不同在于:put和 get 两次Hash到达指定的HashEntry,第一次hash到达Segment,第二次到达Segment里面的Entry,然后在遍历entry链表。

通过这种锁分离技术,ConcurrentHashMap将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
在这里插入图片描述

JDK8下的CurrentHashMap

JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap:
在这里插入图片描述

6. HashSet

与HashMap类似,实现Set接口也有多种方式,HashSet实现Set接口的方式利用了Hash。HashSet的构造方法有:

public HashSet()
public HashSet(int initialCapacity)
public HashSet(int initialCapacity, float loadFactor)
public HashSet(Collection<? extends E> c)

其中initialCapacity和loadFactor参数的含义与HashMap中的是一样的。HashSet的使用也很简单:

Set<String> set = new HashSet<String>();
set.add("hello");
set.add("world");
set.addAll(Arrays.asList(new String[]{"hello","老马"}));

for(String s : set){
    System.out.print(s+" ");
}

输出为:

hello 老马 world 

也就是被add进去的值只会保存一份,并且输出也没有什么特别的顺序。此外与HashMap类似,HashSet也要求元素重写hashCode和equals方法,且对两个对象,equals相同,则hashCode也必须相同,如果元素是自定义的类,需要注意这一点。

实现原理

HashSet内部是用HashMap实现的,它内部有一个HashMap实例变量:

private transient HashMap<E,Object> map;

Map有键和值,HashSet相当于只有键,值都是相同的固定值,这个值的定义为:

private static final Object PRESENT = new Object();

在这个基础上再看HashSet的add、remove方法:

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

add方法其实就是调用map的put方法,元素e用于键,值就是固定值PRESENT,put返回null表示原来没有对应的键,添加成功。

public boolean remove(Object o) {
    return map.remove(o)==PRESENT;
}

remove方法实际上是调用map的remove方法,返回值为PRESENT表示原来有对应的键且删除成功了。

7. 二叉树基础

HashMap有一个重要局限,键值对之间没有特定的顺序。Map接口有另一个重要的实现类TreeMap,在TreeMap中,键值对之间按键有序,TreeMap的实现基础是排序二叉树(具体来说是红黑树),因此首先了解下红黑树的概念。在了解红黑树之前,我们先来了解下排序二叉树与平衡二叉树,它们是红黑树的基础。

排序二叉树

排序二叉树也是二叉树,但它没有重复元素,而且是有序的二叉树,即对每个节点而言:

  • 如果左子树不为空,则左子树上的所有节点都小于该节点
  • 如果右子树不为空,则右子树上的所有节点都大于该节点

对于排序二叉树的遍历而言可以采用前序/中序/后序遍历,因此主要看下插入和删除操作:

  • 插入:插入元素首先要找插入位置,即新节点的父节点,与查找元素类似,从根节点开始往下找,其步骤为:

    • 1)与当前节点比较,如果相同,表示已经存在了,不能再插入
    • 2)如果小于当前节点,则到左子树中寻找,如果左子树为空,则当前节点即为要找的父节点
    • 3)如果大于当前节点,则到右子树中寻找,如果右子树为空,则当前节点即为要找的父节点
    • 4)找到父节点后,即可插入,如果插入元素小于父节点,则作为左孩子插入,否则作为右孩子插入
  • 删除:从排序二叉树中删除一个节点要复杂一些,有三种情况:节点为叶子节点;节点只有一个孩子;节点有两个孩子

    • 如果节点为叶子节点,可以直接删掉,并修改父节点的对应孩子为空即可
    • 如果节点只有一个孩子节点,则替换待删节点为孩子节点
    • 如果节点有两个孩子,则首先找该节点的直接前驱或直接后继(后继为右子树中最小的节点,这个后继一定没有左孩子),找到后替换待删节点为后继的内容,然后再删除直接前驱/直接后继节点。这样就将两个孩子的情况转换为了叶子节点或只有一个孩子的情况
      在这里插入图片描述
平衡二叉树

排序二叉树的形状与插入和删除的顺序密切相关,极端情况下,排序二叉树可能退化为一个链表,比如说,如果插入顺序为:1 3 4 6 7 8 9,则排序二叉树形状为:
在这里插入图片描述
这种状态我们称为高度不平衡的状态,与之相反的是高度平衡的状态,即任何节点的左右子树的高度差最多为一,满足这个高度平衡状态的排序二叉树被称为平衡二叉树或者AVL树。这里要学习的也是AVL树的插入与删除操作,核心思想为高度失衡与调整,也就是将一棵不平衡的二叉树变成平衡二叉树。平衡的调整共有四种情况:分别为LL,LR,RR,RL。在介绍具体调整方法前,先来介绍平衡二叉树的最小单元:
在这里插入图片描述
如上图所示,假设当前插入的节点为4,则4为C(Current当前)节点,3为P(Parent父)节点,1为U(Uncle叔叔)节点,2为G(Grandfather祖父)节点。接下来看下插入节点的四种调整方法:

  • LL:以P父节点为圆心,旋转祖父节点G。
    如下图为LL型情况:假如节点9为插入的节点,则节点16失衡,因为左子树高度为2,右子树高度为0,高度不平衡,11是其左孩子,9为其失衡节点的左孩子的左孩子,所以是LL型。这种情况下,以失衡节点的左孩子为旋转中心,让失衡节点进行一次右旋转即可。注意无论是怎样旋转,一定是以孩子节点为圆心的。
    在这里插入图片描述
  • RR:以P父节点为圆心,旋转祖父节点G。
    RR型与LL型是对称的,如下图所示,假如节点26为插入的节点C,则节点7失衡,左子树高度为1,右子树高度为4,高度不平衡,因此要以失衡节点的右孩子为旋转中心,对失衡节点进行一次左旋转即可,并且在旋转的过程中,节点7会和节点9“相撞”,让节点9成为自己的右孩子(可以理解为节点7降级了,节点9就是做为降级的补偿)。在这里插入图片描述
  • LR:先以当前新增节点C为圆心,旋转P点变成LL型,然后再按照LL型做调整。
    插入数据16,3,7后的结构如下图所示,节点16失去了平衡,3为16的左孩子,7为失衡节点的左孩子的右孩子,所以为LR型。因此先左旋转变成LL型,然后再按LL型进行右旋转。
    在这里插入图片描述
  • RL:和LR对称。
    插入节点18高度失衡,失衡节点为16,26为其右孩子,18为其右孩子的左孩子,为RL型。先右旋转变成RR型,然后再按RR型进行左旋转。
    在这里插入图片描述
    这里在旋转变成子节点时还有个记忆方法是最短路径法,总是往最短路径方向旋转:
    在这里插入图片描述
    删除操作与插入操作其实类似,左子树上节点的删除相当于我们在右子树上插入了一个新节点,而右子树上节点的删除相当于在左子树上插入了一个新节点。平衡二叉树的删除和排序二叉树一样也分为三种情况:
  • 被删除的节点为叶子节点,可以直接删掉,然后进行平衡的调整
    在这里插入图片描述
    例如上图,删节点7,因为是叶子节点,可以直接删除,删除后如下图所示:
    在这里插入图片描述
    删除后发现节点20失衡,需要进行调整,在左子树上删除节点其实就相当于在右子树上插入节点。找到节点20的右子树上的子节点30,发现节点30的左子树高度比右子树高,这就相当于在节点20的右子树节点30的左子树下插入了一个新的节点,这就需要进行RL型调整。先右旋转变成RR型:
    在这里插入图片描述
    然后再按RR型进行左旋转:
    在这里插入图片描述
  • 被删除的节点只有左子树或只有右子树:则替换待删节点为孩子节点并重新进行平衡处理
    在这里插入图片描述
    例如上图所示树中要删除节点40,先用其右子树与之替换,然后删除该节点,可以得到如下的形状:
    在这里插入图片描述
    可以发现该树失衡,失衡节点为30,删除右子树的节点相当于在左子树上插入新的节点,很明显是个RL型,因此首先左旋变成LL型:
    在这里插入图片描述
    然后再按照LL型做调整,也就是再进行一次右旋:
    在这里插入图片描述
  • 被删除的节点既有左子树,又有右子树:首先找该节点的直接前驱或直接后继(后继为右子树中最小的节点,这个后继一定没有左孩子),找到后,替换待删节点为前驱/后继的内容,然后再删除直接前驱/直接后继节点。这样就将两个孩子的情况转换为了叶子节点或只有一个孩子的情况
    在这里插入图片描述
    例如上图所示,想要删除节点20,首先用该节点的直接前驱(左子树中最大值这里也就是节点15)替换待删节点为前驱的内容:
    在这里插入图片描述
    接下来删除节点20:
    在这里插入图片描述
    节点10失衡,为LR型,因此先左旋变成LL型:
    在这里插入图片描述
    然后再进行一次右旋:
    在这里插入图片描述
红黑树

前面介绍了这么多,都是为了引入红黑树,与AVL树类似,红黑树也是一种平衡的排序二叉树,也是在插入和删除节点时通过旋转操作来平衡的,但它并不是高度平衡的,而是大致平衡的,所谓大致是指,它确保对于任意一条从根到叶子节点的路径,没有任何一条路径的长度会比其他路径长过两倍。此外,它对每个节点进行着色,颜色或黑或红,并对节点的着色有一些约束,满足这个约束即可以确保树是大致平衡的。有个红黑树动态插入删除的网站,大家有兴趣可以试试。

红黑树的定义如下:

  1. 每个节点或者是黑色,或者是红色。
  2. 根节点是黑色。
  3. 每个叶子节点(Nil节点)是黑色。
  4. 如果一个节点是红色的,则它的子节点必须是黑色的
  5. 从任意一个节点到叶子节点,经过的黑色节点是一样的(即黑色完美平衡状态)。

接下来看下插入节点的操作,这里要注意的是,默认新插入的节点为红色节点,之后无论怎样处理,最终要到达满足上述定义的大致平衡状态。增加节点时有4种状态:

  • 插入节点为根节点:要满足性质2,把插入节点作为根节点,并把节点由红色设置为黑色
  • 插入节点的父节点为黑色节点:由于插入的节点是红色的,当插入节点的黑色时,并不会影响红黑树的平衡,直接插入即可,无需做自平衡。如下图所示(注意叶子节点为虚节点,未在树中显示):
    在这里插入图片描述
  • 插入节点的父节点为红色节点并且 (叔叔节点为黑色节点或叔叔节点为空),和平衡二叉树失衡处理相同,分为LL、RR、LR、RL四种情况,不同的是多了一个变色操作。
    例如下图所示,插入节点57(当前插入节点为红色),其父节点60位红色节点,叔叔节点为空,为LL型,因此需要以节点60为圆心,对节点78进行右旋操作,并且右旋后要进行变色操作(将右旋后的节点60变成黑色,节点78变红色以满足上述5条定义)。
    在这里插入图片描述
    再举个LR型的例子,如果不是插入节点57而是65,那么首先要进行左旋变成LL,然后再进行右旋,最后再进行变色操作:
    在这里插入图片描述
  • 插入节点的父节点为红色节点并且叔叔节点为红色节点,则首先父节点和叔叔节点均变成黑色,祖父节点变成红色。如果祖父节点变红色之后不满足上述5条定义,则将祖父节点看做当前新增节点C,又按照这四种插入情况进行平衡调整。
    先看祖父节点是根节点的特殊情况,这种情况下祖父节点在变红色后可直接变黑色:
    在这里插入图片描述
    如果祖父节点不是根节点,就要把祖父节点看做新增的节点进行平衡处理:
    在这里插入图片描述
    例如这里增加节点888,GPU三节点均变色后,节点555和G节点775均为红色,这肯定是不对的,因此红黑树中不能有两个连续的红色节点,因此这里要把节点775看做新增节点C,节点775的父节点为红色,叔叔节点为黑色,又变成了我们上面说的第三种情况,并且这里明显为RR型,因此以P父节点555为圆心,左旋G祖父节点100并变色。

删除操作与平衡二叉树一样,也分为三种情况,替换方法是一样的,并且在替换后要进行平衡调整,以满足红黑树特性,这里由于篇幅原因就不展开,大家可以在上述网站中自己操作试试。

8. TreeMap

TreeMap的实现基础是红黑树,并且键值对之间按键有序。

基本用法

TreeMap有两个基本构造方法:

public TreeMap()
public TreeMap(Comparator<? super K> comparator)

第一个为默认构造方法,如果使用默认构造方法,要求Map中的键实现Comparabe接口,TreeMap内部进行各种比较时会调用键的Comparable接口中的compareTo方法。第二个接受一个比较器对象comparator,如果comparator不为null,在TreeMap内部进行比较时会调用这个comparator的compare方法,而不再调用键的compareTo方法,也不再要求键实现Comparable接口。

我们通过代码来理解TreeMap特点:

Map<String, String> map  = new TreeMap<>();
map.put("a", "abstract");
map.put("c", "call");
map.put("b", "basic");
map.put("T", "tree");

for(Entry<String,String> kv : map.entrySet()){
    System.out.print(kv.getKey()+"="+kv.getValue()+" ");
}

创建了一个TreeMap,但只是当做Map使用,与Map不同的是迭代输出是按键排序的:

T=tree a=abstract b=basic c=call 

这里也可以看到,默认的构造方法是按键升序排列的并且考虑到了大小写,如果希望按键降序排列/不考虑大小写,就要用到上面说的第二种构造方法:

  • 不考虑大小写:如果希望忽略大小写,可以传递一个比较器,String类有一个静态成员CASE_INSENSITIVE_ORDER,它就是一个忽略大小写的Comparator对象,那么替换构造方法为
	Map<String, String> map  = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
  • 按键降序排列:如果希望降序,可以传递一个不同的Comparator对象
	Map<String, String> map  = new TreeMap<>(new Comparator<String>(){
	    @Override
	    public int compare(String o1, String o2) {
	        //正常排序中,compare方法内,是o1.compareTo(o2),两个对象翻过来就成了降序
	        return o2.compareTo(o1);
	    }
	});

Collections类有一个静态方法reverseOrder()也可以返回一个逆序比较器,因此也可以如下写法:

	Map<String, String> map  = new TreeMap<>(Collections.reverseOrder());
  • 不考虑大小写&按键降序排列
	Map<String, String> map  = new TreeMap<>(
        Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));

此外,TreeMap使用键的比较结果对键进行排重,即使键实际上不同,但只要比较结果相同,它们就会被认为相同,键只会保存一份。比如如下代码:

	Map<String, String> map  = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
	map.put("T", "tree");
	map.put("t", "try");
	
	for(Entry<String,String> kv : map.entrySet()){
	    System.out.print(kv.getKey()+"="+kv.getValue()+" ");
	}

最终输出为:

	T=try 

看上去有两个不同的键"T"和"t",但因为比较器忽略大小写,所以只会有一个,键为第一次put时的,这里即"T",而值为最后一次put时的,这里即"try"。

高级用法
  • SortedMap接口:headMap/tailMap/subMap都返回一个视图,视图中包括一部分键值对,它们的区别在于键的取值范围:
	public interface SortedMap<K,V> extends Map<K,V> {
	    Comparator<? super K> comparator();
	    SortedMap<K,V> subMap(K fromKey, K toKey);// >=fromKey且<toKey的所有键
	    SortedMap<K,V> headMap(K toKey);// <toKey的所有键
	    SortedMap<K,V> tailMap(K fromKey);// >=fromKey的所有键
	    K firstKey();//返回第一个键
	    K lastKey();//返回最后一个键
	}
  • NavigableMap接口:NavigableMap扩展了SortedMap,主要增加了一些查找邻近键的方法:
   Map.Entry<K,V> floorEntry(K key);//邻近键是小于等于key的键中最大的
   Map.Entry<K,V> lowerEntry(K key);//邻近键是严格小于key的键中最大的
   Map.Entry<K,V> ceilingEntry(K key);//邻近键是大于等于key的键中最小的
   Map.Entry<K,V> higherEntry(K key);//邻近键是严格大于key的键中最小的 
   //如果没有对应的邻近键,返回值为null

例如:

	NavigableMap<String, String> map  = new TreeMap<>();
	map.put("a", "abstract");
	map.put("f", "final");
	map.put("c", "call");
	System.out.println(map.floorEntry("d"));//输出:c=call
	System.out.println(map.ceilingEntry("d"));//输出:f=final
	
基本实现原理

TreeMap内部主要有如下成员:

	private final Comparator<? super K> comparator;
	private transient Entry<K,V> root = null;
	private transient int size = 0;

comparator就是比较器,在构造方法中传递,如果没传就是null。size为当前键值对个数。root指向树的根节点,从根节点可以访问到每个节点,节点的类型为Entry。Entry是TreeMap的一个内部类,其内部成员和构造方法为:

	static final class Entry<K,V> implements Map.Entry<K,V> {
	    K key;
	    V value;
	    Entry<K,V> left = null;
	    Entry<K,V> right = null;
	    Entry<K,V> parent;
	    boolean color = BLACK;
	
	    Entry(K key, V value, Entry<K,V> parent) {
	        this.key = key;
	        this.value = value;
	        this.parent = parent;
	    }
	}

每个节点除了键(key)和值(value)之外,还有三个引用,分别指向其左孩子(left)、右孩子(right)和父节点(parent),对于根节点,父节点为null,对于叶子节点,孩子节点都为null,还有一个成员color表示颜色(红/黑)。接下来我们主要看下保存与删除的实现。

保存键值对主要实现如下,如果插入的节点为第一个节点:

	public V put(K key, V value) {
	    Entry<K,V> t = root;
	    if (t == null) {
	        compare(key, key); // type (and possibly null) check
	
	        root = new Entry<>(key, value, null);
	        size = 1;
	        modCount++;
	        return null;
	    }
	    ...
    

当添加第一个节点时,root为null,因此新建一个节点并设置root指向它,size设置为1,compare用于检查key的类型和null,如果类型不匹配或为null,compare方法会抛出异常。如果不是第一次插入,会执行如下的代码,添加的关键步骤是寻找父节点,寻找父节点根据是否设置了comparator分为两种情况,如果设置了comparator,则执行如下代码:

	int cmp;
	Entry<K,V> parent;
	// split comparator and comparable paths
	Comparator<? super K> cpr = comparator;
	if (cpr != null) {
	    do {
	        parent = t;
	        cmp = cpr.compare(key, t.key);
	        if (cmp < 0)
	            t = t.left;
	        else if (cmp > 0)
	            t = t.right;
	        else
	            return t.setValue(value);
	    } while (t != null);
	}
	

t一开始指向根节点,从根节点开始比较键,如果小于根节点,就将t设为左孩子,与左孩子比较,大于就与右孩子比较,就这样一直比,直到t为null或比较结果为0。如果比较结果为0,表示已经有这个键了,设置值然后返回。如果t为null,则当退出循环时,parent就指向待插入节点的父节点。如果没有设置comparator,则执行如下代码:

	else {
	    if (key == null)
	        throw new NullPointerException();
	    Comparable<? super K> k = (Comparable<? super K>) key;
	    do {
	        parent = t;
	        cmp = k.compareTo(t.key);
	        if (cmp < 0)
	            t = t.left;
	        else if (cmp > 0)
	            t = t.right;
	        else
	            return t.setValue(value);
	    } while (t != null);
	}
	

基本逻辑是一样的,只是如果没有设置comparator,则假设key一定实现了Comparable接口,使用Comparable接口的compareTo方法进行比较。找到父节点后,就是新建一个节点,根据新的键与父节点键的比较结果,插入作为左孩子或右孩子,并增加size,代码如下:

	Entry<K,V> e = new Entry<>(key, value, parent);
	if (cmp < 0)
	    parent.left = e;
	else
	    parent.right = e;
	fixAfterInsertion(e);
	size++;
	modCount++;
	

其中fixAfterInsertion(e)目的是调整树的结构,使之符合红黑树的约束。综上来说,插入操作基本思路就是通过循环比较找到父节点,并插入作为其左孩子或右孩子,然后调整以保持树的大致平衡。

根据键删除键值对的代码如下,首先根据key查找到节点,调用deleteEntry删除节点,然后返回原来的值:

	public V remove(Object key) {
	    Entry<K,V> p = getEntry(key);
	    if (p == null)
	        return null;
	
	    V oldValue = p.value;
	    deleteEntry(p);
	    return oldValue;
	}
	

删除节点根据是否有子节点和上面所述平衡二叉树相同分为三种情况。如果删除的节点既有左孩子又有右孩子时代码如下:

	private void deleteEntry(Entry<K,V> p) {
	    modCount++;
	    size--;
	    if (p.left != null && p.right != null) {
	        Entry<K,V> s = successor(p);
	        p.key = s.key;
	        p.value = s.value;
	        p = s;
	    } 
    

s为后继,当前节点p的key和value设置为了s的key和value,然后将待删节点p指向了s,这样就转换为了一个孩子或叶子节点的情况。
如果删除的节点只有一个孩子,代码如下:

	Entry<K,V> replacement = (p.left != null ? p.left : p.right);
	if (replacement != null) {
	    replacement.parent = p.parent;
	    if (p.parent == null)
	        root = replacement;
	    else if (p == p.parent.left)
	        p.parent.left  = replacement;
	    else
	        p.parent.right = replacement;
	
	    p.left = p.right = p.parent = null;
	
	    if (p.color == BLACK)
	        fixAfterDeletion(replacement);
	} else if (p.parent == null) { 
	 ...
	 

p为待删节点,replacement为要替换p的孩子节点,主体代码就是在p的父节点p.parent和replacement之间建立链接,以替换p.parent和p原来的链接,如果p.parent为null,则修改root以指向新的根。fixAfterDeletion重新平衡树。
最后如果要删除的节点无子节点,代码如下:

	...
	} else if (p.parent == null) {
	    root = null;
	} else { 
	    if (p.color == BLACK)
	        fixAfterDeletion(p);
	
	    if (p.parent != null) {
	        if (p == p.parent.left)
	            p.parent.left = null;
	        else if (p == p.parent.right)
	            p.parent.right = null;
	        p.parent = null;
	    }
	}
	

再具体分为两种情况,一种是删除最后一个节点,修改root为null,否则就是根据待删节点是父节点的左孩子还是右孩子,相应的设置孩子节点为null。

TreeMap特点

HashMap相比,TreeMap同样实现了Map接口,但内部使用红黑树实现,这决定了它有如下特点:

  • 按键有序,TreeMap同样实现了SortedMap和NavigableMap接口,可以方便的根据键的顺序进行查找,如第一个、最后一个、某一范围的键、邻近键等。并且为了按键有序,TreeMap要求键实现Comparable接口或通过构造方法提供一个Comparator对象
  • 根据键保存、查找、删除的效率比较高,为O(h),h为树的高度,在树平衡的情况下,h为log2(N),N为节点数

9. TreeSet

HashSet有一个重要局限,元素之间没有特定的顺序,此外Set接口还有另一个重要的实现类TreeSet,它是有序的,与HashSet和HashMap的关系一样,TreeSet是基于TreeMap的。

基本用法

TreeSet的基本构造方法有两个:

	public TreeSet()
	public TreeSet(Comparator<? super E> comparator)

默认构造方法假定元素实现了Comparable接口,第二个使用传入的比较器,不要求元素实现Comparable。TreeSet经常也只是当做Set使用,只是希望迭代输出有序,如下面代码所示:

	Set<String> words = new TreeSet<String>();
	words.addAll(Arrays.asList(new String[]{
	    "tree", "map", "hash", "map",     
	}));
	for(String w : words){
	    System.out.print(w+" ");
	}

输出如下,实现了排重和有序hash map tree ,如果希望忽略大小写进行比较,可以如下写法:

	Set<String> words = new TreeSet<String>(new Comparator<String>(){
	    @Override
	    public int compare(String o1, String o2) {
	        return o1.compareToIgnoreCase(o2);
	    }});
	words.addAll(Arrays.asList(new String[]{
	    "tree", "map", "hash", "Map",     
	}));
	System.out.println(words);

输出为[hash, map, tree],这里也看到都没有输出Map,这是因为Set是排重的,排重是基于比较结果的,结果为0即视为相同,"map"和"Map"虽然不同,但比较结果为0,所以只会保留第一个元素。

高级用法
  • SortedSet接口,与SortedMap接口类似,具体定义为:
	public interface SortedSet<E> extends Set<E> {
	    Comparator<? super E> comparator();
	    //大于等于fromElement,且小于toElement的所有元素 
	    SortedSet<E> subSet(E fromElement, E toElement);
	    //严格小于toElement的所有元素
	    SortedSet<E> headSet(E toElement);
	    //大于等于fromElement的所有元素
	    SortedSet<E> tailSet(E fromElement);
	    E first();//返回第一个元素
	    E last();//返回最后一个元素
	}
  • NavigableSet接口,与NavigableMap类似,NavigableSet接口扩展了SortedSet,主要增加了一些查找邻近元素的方法,例如:
	E floor(E e); //返回小于等于e的最大元素
	E lower(E e); // 返回小于e的最大元素
	E ceiling(E e); //返回大于等于e的最小元素
	E higher(E e); //返回大于e的最小元素

除此之外,NavigableSet还增加了一些方法,以更为明确的方式指定返回值中是否包含边界值,例如:

	NavigableSet<E> headSet(E toElement, boolean inclusive);
	NavigableSet<E> tailSet(E fromElement, boolean inclusive);
	NavigableSet<E> subSet(E fromElement, boolean fromInclusive,
	                           E toElement,   boolean toInclusive);

增加了对头尾的操作:

	E pollFirst(); //返回并删除第一个元素
	E pollLast(); //返回并删除最后一个元素

还增加了逆序访问的方法:

	NavigableSet<E> descendingSet();
	Iterator<E> descendingIterator();
基本实现原理

TreeSet的内部有如下成员:

	private transient NavigableMap<E,Object> m;
	private static final Object PRESENT = new Object();

m就是TreeMap,这里用的是更为通用的接口类型NavigableMap,PRESENT就是固定的共享值。接下来还是看下添加/删除元素的实现。添加元素add方法的代码如下所示:

	public boolean add(E e) {
	    return m.put(e, PRESENT)==null;
	}

实际上就是调用map的put方法,元素e用作键,值就是固定值PRESENT,put返回null表示原来没有对应的键,添加成功了。删除元素remove方法的代码如下所示:

	public boolean remove(Object o) {
	    return m.remove(o)==PRESENT;
	}

实际上就是调用map的remove方法,返回值为PRESENT表示原来有对应的键且删除成功了。

TreeSet特点

与HashSet相比,TreeSet同样实现了Set接口,但内部基于TreeMap实现,而TreeMap基于大致平衡的红黑树,这决定了它有如下特点:

  • 没有重复元素
  • 添加、删除元素、判断元素是否存在,效率比较高,为O(log2(N)),N为元素个数
  • 有序,TreeSet同样实现了SortedSet和NavigatableSet接口,可以方便的根据顺序进行查找和操作,如第一个、最后一个、某一取值范围、某一值的邻近元素等
  • 为了有序,TreeSet要求元素实现Comparable接口或通过构造方法提供一个Comparator对象

10. Hashtable

Hashtable在很大程度上和HashMap的实现差不多,同样是基于哈希表实现的,每个元素是一个key-value对,其内部也是通过单链表解决冲突问题,容量不足时会自动增长。这里主要看下它与HashMap的区别。

  • 父类不同:HashMap是继承自AbstractMap类,而HashTable是继承自Dictionary(已被废弃,详情看源代码)。不过它们都实现了同时实现了map、Cloneable(可复制)、Serializable(可序列化)这三个接口。
  • key和value是否允许null值: 这里key和value都是对象,并且不能包含重复key,但可以包含重复的value。Hashtable既不支持Null key也不支持Null value,而HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。当get()方法返回null值时,可能是 HashMap中没有该键,也可能使该键所对应的值为null。因此,在HashMap中不能由get()方法来判断HashMap中是否存在某个键, 而应该用containsKey()方法来判断。
  • 线程安全性:Hashtable的方法是同步的即每个方法中都加入了Synchronize,在多线程并发的环境下,可以直接使用Hashtable;而HashMap的方法不是,在多线程并发的环境下,可能会产生死锁等问题。虽然HashMap不是线程安全的,但是它的效率会比Hashtable要好很多,如果需要多线程操作的时候更推荐使用线程安全的ConcurrentHashMap而非Hashtable。
  • 是否提供contains方法:Hashtable则保留了contains,containsValue和containsKey三个方法,其中contains和containsValue功能相同。HashMap把Hashtable的contains方法去掉了,改成containsValue和containsKey,因为contains方法容易让人引起误解。
  • 遍历方式不同:Hashtable、HashMap都使用了Iterator。而由于历史原因,Hashtable还使用了Enumeration的方式。
  • 计算哈希值的方法不同:为了得到元素的位置,首先需要根据元素的 KEY计算出一个hash值,然后再用这个hash值来计算得到最终的位置。Hashtable直接使用对象的hashCode,hashCode是JDK根据对象的地址或者字符串或者数字算出来的int类型的数值。然后再使用除留余数发来获得最终的位置。 然而除法运算是非常耗费时间的。HashMap为了提高计算效率,将哈希表的大小固定为了2的幂,这样在取模预算时,不需要做除法,只需要做位运算。位运算比除法的效率要高很多。
  • 内部实现使用的数组初始化和扩容方式不同:Hashtable的初始长度是11,之后每次扩充容量变为之前的2n+1(n为上一次的长度)。而HashMap的初始长度为16,之后每次扩充变为原来的两倍。创建时,如果给定了容量初始值,那么Hashtable会直接使用你给定的大小,而HashMap会将其扩充为2的幂次方大小。

参考:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值