3 - 集合框架(下)

1. Hash Map 详解

HashMap 是 Java 中常用的数据结构之一,用于存储键值对。在 HashMap 中,每个键都映射到一个唯一的值,可以通过键来快速访问对应的值,算法时间复杂度可以达到 O(1)

CRUD

//创建 HashMap 对象
HashMap<String, Integer> map = new HashMap<>();
//增加元素
map.put("xlin", 20);
map.put("linx", 25);
//删除元素
map.remove("xlin");
//修改元素
map.put("linx", 30);
/*
 * 修改采用的也是 put 方法
 * 因为 HashMap 的键是唯一的,再次 put 的时候会覆盖掉之前的键值对
 */
//查找元素
int age = map.get("linx");

1.1 hash 方法原理

1)hash 方法的作用

JDK8 中的 hash 方法源码:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hash 方法对计算键值对的位置起到了至关重要的作用

put 方法源码中就会去调用 hash 方法:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

调用私有的 putVal 方法:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    // 数组
    HashMap.Node<K,V>[] tab; 
    // 元素
    HashMap.Node<K,V> p; 

    // n 为数组的长度 i 为下标
    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);
}

HashMap 中 get 方法会调用 getNode() 方法:

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 && 
            ((k = first.key) == key || (key != null && key.equals(k))) )
            return first;
        // 如果第一个节点不是要查找的节点,则遍历节点链表查找
        if ((e = first.next) != null) {
            do {
                if (e.hash == hash && 
                ((k = e.key) == key || (key != null && key.equals(k))) )
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // 如果节点链表中没有找到对应的节点,则返回 null
    return null;
}

注意:(n - 1) & hash  看着是位运算,其实是取模运算

& 运算比 % 更加高效,并且当 b 为 2 的 n 次方时,存在下面这样一个公式:

a % b = a & (b-1)

2^{n}替换下 b 就是:

a % 2^{n} = a & (2^{n} - 1)

这也正好解释了为什么 HashMap 的数组长度要取 2 的整次方

 取模运算是为了计算数组的下标

  • put 的时候计算下标,把键值对放到对应的桶上。
  • get 的时候通过下标,把键值对从对应的桶上取出来。

总结:

hash 方是将 key 对象的 hashCode 值进行处理,得到最终的哈希值,可以实现哈希值优化的;哈希值优化的目的就是为了增加随机性,让数据元素更加均衡的分布,减少碰撞

1.2 HashMap 的扩容机制

HashMap 的底层用的也是数组。向 HashMap 里不停地添加元素,当数组无法装载更多元素时,就需要对数组进行扩容,以便装入更多的元素

注:容量的提升也会相应地提高查询效率,因为“桶”更多了,原来需要通过链表存储的(同一个索引的时候会通过链表链接,查询的时候需要遍历),扩容后可能就有自己的“坑”了(直接就能查出来)

1)resize 方法

HashMap 的扩容是通过 resize 方法来实现的,JDK 8 中融入了红黑树(链表长度超过 8 的时候,会将链表转化为红黑树来提高查询效率)

主要是 newCapacity 的计算方式:

int newCapacity = oldCapacity << 1;
if (newCapacity >= DEFAULT_INITIAL_CAPACITY && 
    oldCapacity >= DEFAULT_INITIAL_CAPACITY) {
    if (newCapacity > MAXIMUM_CAPACITY)
        newCapacity = MAXIMUM_CAPACITY;
} else {
    if (newCapacity < DEFAULT_INITIAL_CAPACITY)
        newCapacity = DEFAULT_INITIAL_CAPACITY;
}

2)transfer方法

transfer 方法用来转移,将旧的小数组元素拷贝到新的大数组中

void transfer(Entry[] newTable, boolean rehash) {
    // 新的容量
    int newCapacity = newTable.length;
    // 遍历小数组
    for (Entry<K,V> e : table) {
        while(null != e) {
            // 拉链法,相同 key 上的不同值
            Entry<K,V> next = e.next;
            // 是否需要重新计算 hash
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 根据大数组的容量,和键的 hash 计算元素在数组中的下标
            int i = indexFor(e.hash, newCapacity);
            // 同一位置上的新元素被放在链表的头部
            e.next = newTable[i];
            // 放在新的数组上
            newTable[i] = e;
            // 链表上的下一个元素
            e = next;
        }
    }
}

该方法接受一个新的 Entry 数组 newTable 和一个布尔值 rehash 作为参数,其中 newTable 表示新的哈希表,rehash 表示是否需要重新计算键的哈希值

3)Java8 扩容

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table; // 获取原来的数组 table
    int oldCap = (oldTab == null) ? 0 : oldTab.length; // 获取数组长度 oldCap
    int oldThr = threshold; // 获取阈值 oldThr
    int newCap, newThr = 0;
    if (oldCap > 0) { // 如果原来的数组 table 不为空
        if (oldCap >= MAXIMUM_CAPACITY) { // 超过最大值就不再扩充了,就只好随你碰撞去吧
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 没超过最大值,就扩充为原来的2倍
                 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);
    }
    // 计算新的 resize 上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr; // 将新阈值赋值给成员变量 threshold
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 创建新数组 newTab
    table = newTab; // 将新数组 newTab 赋值给成员变量 table
    if (oldTab != null) { // 如果旧数组 oldTab 不为空
        for (int j = 0; j < oldCap; ++j) { // 遍历旧数组的每个元素
            Node<K,V> e;
            if ((e = oldTab[j]) != null) { // 如果该元素不为空
                oldTab[j] = null; // 将旧数组中该位置的元素置为 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 { // 如果该元素是链表
                    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; // 将低位链表的尾结点指向 null,以便垃圾回收
                        newTab[j] = loHead; // 将低位链表作为新数组对应位置的元素
                    }
                    if (hiTail != null) { // 如果高位链表不为空
                        hiTail.next = null; // 将高位链表的尾结点指向 null,以便垃圾回收
                        newTab[j + oldCap] = hiHead; // 将高位链表作为新数组对应位置的元素
                    }
                }
            }
        }
    }
    return newTab; // 返回新数组
}

思路:

  1. 获取原来的数组 table,数组长度 oldCap,阈值 oldThr
  2. 如果原来的数组 table 不为空,则根据扩容规则计算新的数组长度 newCap,新的阈值 newThr,然后将原数组中的元素复制到新数组中
  3. 如果原来的数组 table 为空,但旧阈值 oldThr 不为零,则说明是通过带参数的构造函数创建的HashMap,此时将阈值作为新数组长度 newCap
  4. 如果原来的数组 table 和阈值 oldThr 都为零,则说明是通过无参数构造函数创建的 HashMap,此时将默认初始容量 DEFAULT_INITIAL_CAPACITY(16)和默认负载因子 DEFAULT_LOAD_FACTOR(0.75)计算出新数组长度 newCap 和新阈值 newThr
  5. 计算新阈值 threshold,并将其赋值给成员变量 threshold
  6. 创建新数组 newTab,并将其赋值给成员变量 table
  7. 如果旧数组 oldTab 不为空,则遍历旧数组的每个元素,将其复制到新数组中
  8. 返回新数组 newTab

4)总结

HashMap 的内部实现是通过一个数组和链表或红黑树的组合来实现的。当我们往 HashMap 中不断添加元素时,HashMap 会自动进行扩容操作(条件是元素数量达到负载因子(load factor)乘以数组长度时),以保证其存储的元素数量不会超出其容量限制

扩容机制:

  1. 在进行扩容操作时,HashMap 会先将数组的长度扩大一倍,然后将原来的元素重新散列到新的数组中。由于元素的散列位置是通过 key 的 hashcode 和数组长度取模得到的,因此在数组长度扩大后,元素的散列位置也会发生一些改变
  2. 在重新散列元素时,如果一个元素的散列位置发生了改变,那么它需要被移动到新的位置。如果新的位置上已经有元素了,那么这个元素就会被添加到链表的末尾,如果链表的长度超过了阈值(8个),那么它将会被转换成红黑树

扩容机制它可以保证 HashMap 的存储容量足够大,同时也可以保证 HashMap 的存储效率和检索效率。但是,由于扩容操作需要耗费一定的时间和空间,因此我们需要在使用 HashMap 时,合理地设置初始容量和负载因子,以避免过多的扩容操作

1.3 加载因子为0.75

加载因子是用来表示 HashMap 中数据的填满程度:

加载因子 = 填入哈希表中的数据个数 / 哈希表的长度

必须在“哈希冲突”与“空间利用率”两者之间有所取舍,尽量保持平衡

为了减少哈希冲突发生的概率,当 HashMap 的数组长度达到一个临界值的时候,就会触发扩容

临界值 = 初始容量 * 加载因子

 在默认情况下,一开始HashMap 的容量是 16,加载因子是 0.75

 参考 为什么是0.75?

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

static final float DEFAULT_LOAD_FACTOR = 0.75f;

1.4 线程不安全

三方面原因:

  • 多线程下扩容会死循环
  • 多线程下 put 会导致元素丢失
  • put 和 get 并发时会导致 get 到 null

1)多线程下扩容会死循环

HashMap 是通过拉链法来解决哈希冲突的,也就是当哈希冲突时,会将相同哈希值的键值对通过链表的形式存放

JDK 7 时,采用的是头部插入的方式来存放链表的,也就是下一个冲突的键值对会放在上一个键值对的前面。扩容的时候就有可能导致出现环形链表,造成死循环

不过,JDK 8 时已经修复了这个问题,扩容时会保持链表原来的顺序

2)多线程下 put 会导致元素丢失

多线程同时执行 put 操作时,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失

3)put 和 get 并发时会导致 get 到 null

线程 A 执行put时,因为元素个数超出阈值而出现扩容,线程B 此时执行get,有可能导致这个问题

4)解决

线程不安全的主要是因为它在进行插入、删除和扩容等操作时可能会导致链表的结构发生变化,从而破坏了 HashMap 的不变性

为了解决这个问题,Java 提供了线程安全的 HashMap 实现类 ConcurrentHashMap

ConcurrentHashMap 内部采用了分段锁(Segment),将整个 Map 拆分为多个小的 HashMap,每个小的 HashMap 都有自己的锁,不同的线程可以同时访问不同的小 Map,从而实现了线程安全。在进行插入、删除和扩容等操作时,只需要锁住当前小 Map,不会对整个 Map 进行锁定,提高了并发访问的效率


2. LinkedHashMap 详解

为了提高查找效率,HashMap 在插入的时候对键做了一次哈希算法,导致插入的元素是无序

那怎么保证键值对的插入顺序呢?

LinkedHashMap 继承了 HashMap,所以 HashMap 有的关于键值对的功能,它也有了

public class LinkedHashMap<K,V>
    extends HashMap<K,V> implements Map<K,V> {
}

LinkedHashMap 内部追加了双向链表,来维护元素的插入顺序

before 和 after 就是用来维护当前元素的前一个元素和后一个元素的顺序的

static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

2.1 插入顺序

HashMap 是无序的,LinkedHashMap 是可以维持插入顺序的

LinkedHashMap 并未重写 HashMap 的 put() 方法,而是重写了 put() 方法中需要调用的内部方法 newNode() 方法

HashMap.Node<K,V> newNode(int hash, K key, V value, HashMap.Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
            new LinkedHashMap.Entry<>(hash, key, value, e);
    linkNodeLast(p);
    return p;
}

LinkedHashMap 中添加键值对时,会将新节点插入到链表的尾部,并更新 before 和 after 属性,以保证链表的顺序关系——由 linkNodeLast() 方法来完成

/**
 * 将指定节点插入到链表的尾部
 * @param p 要插入的节点
 */
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail; // 获取链表的尾节点
    tail = p; // 将 p 设为尾节点
    if (last == null)
        head = p; // 如果链表为空,则将 p 设为头节点
    else {
        p.before = last; // 将 p 的前驱节点设为链表的尾节点
        last.after = p; // 将链表的尾节点的后继节点设为 p
    }
}

 在添加第一个元素的时候,会把 head 赋值为第一个元素,等到第二个元素添加进来的时候,会把第二个元素的 before 赋值为第一个元素,第一个元素的 after 赋值为第二个元素

2.2 访问元素

不仅能够维持插入顺序,还能够维持访问顺序。

访问包括调用 get() 方法、remove() 方法和 put() 方法

声明 LinkedHashMap 的时候指定三个参数

LinkedHashMap<String, String> map = new LinkedHashMap<>(16, .75f, true);

第一个参数和第二个参数指的是初始容量和负载因子

第三个参数如果为 true 的话,就表示 LinkedHashMap 要维护访问顺序;否则,维护插入顺序。默认是 false

Map<String, String> linkedHashMap = new LinkedHashMap<>(16, .75f, true);
linkedHashMap.put("1", "1234");
linkedHashMap.put("2", "1234");
linkedHashMap.put("3", "1234");
linkedHashMap.put("4", "1234");

System.out.println(linkedHashMap);
//{1=1234, 2=1234, 3=1234, 4=1234}
linkedHashMap.get("2");
System.out.println(linkedHashMap);
//{1=1234, 3=1234, 4=1234, 2=1234}
linkedHashMap.get("3");
System.out.println(linkedHashMap);
//{1=1234, 4=1234, 2=1234, 3=1234}

在使用 get() 方法访问元素后,在输出结果里排在最后;也就是说,最不经常访问的放在头部

使用 LinkedHashMap 来实现 LRU 缓存,LRU 是 Least Recently Used 的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰

/**
 * 自定义的 MyLinkedHashMap 类,继承了 Java 中内置的 LinkedHashMap<K, V> 类。
 * 用于实现一个具有固定大小的缓存,当缓存达到最大容量时,会自动移除最早加入的元素,
 * 以腾出空间给新的元素。
 *
 * @param <K> 键的类型
 * @param <V> 值的类型
 */
public class MyLinkedHashMap<K, V>
    extends LinkedHashMap<K, V> {

    private static final int MAX_ENTRIES = 5; 
    // 表示 MyLinkedHashMap 中最多存储的键值对数量

    /**
     * 构造方法,使用 super() 调用了父类的构造函数,并传递了三个参数:
       initialCapacity、loadFactor 和 accessOrder。
     *
     * @param initialCapacity 初始容量
     * @param loadFactor      负载因子
     * @param accessOrder     访问顺序
     */
    public MyLinkedHashMap(int initialCapacity, 
                           float loadFactor, 
                           boolean accessOrder) {
        super(initialCapacity, loadFactor, accessOrder);
    }

    /**
     * 重写父类的 removeEldestEntry() 方法,用于指示是否应该移除最早加入的元素。
     * 如果返回 true,那么将删除最早加入的元素。
     *
     * @param eldest 最早加入的元素
     * @return 如果当前 MyLinkedHashMap 中元素的数量大于 MAX_ENTRIES,返回 true,
     * 否则返回 false。
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > MAX_ENTRIES;
    }
}

MyLinkedHashMap 是一个自定义类,它继承了 LinkedHashMap,并且重写了 removeEldestEntry() 方法——使 Map 最多可容纳 5 个元素,超出后就淘汰

如何来维持访问顺序呢?

void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

afterNodeAccess() 会在调用 get() 方法的时候被调用,afterNodeInsertion() 会在调用 put() 方法的时候被调用,afterNodeRemoval() 会在调用 remove() 方法的时候被调用


3. TreeMap 详解

TreeMap 由红黑树实现,可以保持元素的自然顺序,或者实现了 Comparator 接口的自定义顺序

红黑树(英语:Red–black tree)是一种自平衡的二叉查找树(Binary Search Tree),结构复杂,但却有着良好的性能,完成查找、插入和删除的时间复杂度均为 log(n)

红黑树的示意图(R 即 Red「红」、B 即 Black「黑」):

           8B
         /   \
        4R    12R
       / \   /  \
      2B 6B 10B 14B
         / \
        5R 7R
  • 每个节点都只能是红色或者黑色
  • 根节点是黑色
  • 每个叶节点(NIL 节点,空节点)是黑色的。
  • 如果一个节点是红色的,则它两个子节点都是黑色的。也就是说在一条路径上不能出现相邻的两个红色节点。
  • 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点

 3.1 自然顺序

默认情况下,TreeMap 是根据 key 的自然顺序排列的

TreeMap<Integer,String> mapInt = new TreeMap<>();
mapInt.put(3, "123");
mapInt.put(2, "123");
mapInt.put(1, "123");
mapInt.put(5, "123");
mapInt.put(4, "123");

System.out.println(mapInt);

输出:

{1=123, 2=123, 3=123, 4=123, 5=123}

 看 TreeMap 的 put() 方法:

public V put(K key, V value) {
    Entry<K,V> t = root; // 将根节点赋值给变量t
    if (t == null) { // 如果根节点为null,说明TreeMap为空
        compare(key, key); // type (and possibly null) check,检查key的类型是否合法
        root = new Entry<>(key, value, null); // 创建一个新节点作为根节点
        size = 1; // size设置为1
        return null; // 返回null,表示插入成功
    }
    int cmp;
    Entry<K,V> parent;
    // split comparator and comparable paths,根据使用的比较方法进行查找
    Comparator<? super K> cpr = comparator; // 获取比较器
    if (cpr != null) { // 如果使用了Comparator
        do {
            parent = t; // 将当前节点赋值给parent
            cmp = cpr.compare(key, t.key); // 使用Comparator比较key和t的键的大小
            if (cmp < 0) // 如果key小于t的键
                t = t.left; // 在t的左子树中查找
            else if (cmp > 0) // 如果key大于t的键
                t = t.right; // 在t的右子树中查找
            else // 如果key等于t的键
                return t.setValue(value); // 直接更新t的值
        } while (t != null);
    }
    else { // 如果没有使用Comparator
        if (key == null) // 如果key为null
            throw new NullPointerException(); // 抛出NullPointerException异常
            Comparable<? super K> k = (Comparable<? super K>) key; 
            // 将key强制转换为Comparable类型
        do {
            parent = t; // 将当前节点赋值给parent
            cmp = k.compareTo(t.key); // 使用Comparable比较key和t的键的大小
            if (cmp < 0) // 如果key小于t的键
                t = t.left; // 在t的左子树中查找
            else if (cmp > 0) // 如果key大于t的键
                t = t.right; // 在t的右子树中查找
            else // 如果key等于t的键
                return t.setValue(value); // 直接更新t的值
        } while (t != null);
    }
    // 如果没有找到相同的键,需要创建一个新节点插入到TreeMap中
    Entry<K,V> e = new Entry<>(key, value, parent); // 创建一个新节点
    if (cmp < 0) // 如果key小于parent的键
        parent.left = e; // 将e作为parent的左子节点
    else
        parent.right = e; // 将e作为parent的右子节点
    fixAfterInsertion(e); // 插入节点后需要进行平衡操作
    size++; // size加1
    return null; // 返回null,表示插入成功
}

思想:

  • 首先定义一个Entry类型的变量t,用于表示当前的根节点;
  • 如果t为null,说明TreeMap为空,直接创建一个新的节点作为根节点,并将size设置为1;
  • 如果t不为null,说明需要在TreeMap中查找键所对应的节点。因为TreeMap中的元素是有序的,所以可以使用二分查找的方式来查找节点;
  • 如果TreeMap中使用了Comparator来进行排序,则使用Comparator进行比较,否则使用Comparable进行比较。如果查找到了相同的键,则直接更新键所对应的值;
  • 如果没有查找到相同的键,则创建一个新的节点,并将其插入到TreeMap中。然后使用fixAfterInsertion()方法来修正插入节点后的平衡状态;
  • 最后将TreeMap的size加1,然后返回null。如果更新了键所对应的值,则返回原先的值

cmp = k.compareTo(t.key) 这行代码,就是用来进行 key 比较的,由于此时 key 是 String,所以就会调用 String 类的 compareTo() 方法进行比较

public int compareTo(String anotherString) {
    // 获取当前字符串和另一个字符串的长度
    int len1 = value.length;
    int len2 = anotherString.value.length;
    // 取两个字符串长度的较短者作为比较的上限
    int lim = Math.min(len1, len2);
    // 获取当前字符串和另一个字符串的字符数组
    char v1[] = value;
    char v2[] = anotherString.value;

    int k = 0;
    // 对两个字符串的每个字符进行比较
    while (k < lim) {
        char c1 = v1[k];
        char c2 = v2[k];
        // 如果两个字符不相等,返回它们的差值
        if (c1 != c2) {
            return c1 - c2;
        }
        k++;
    }
    // 如果两个字符串前面的字符都相等,返回它们长度的差值
    return len1 - len2;
}

3.2 自定义排序 

如果自然顺序不满足,那就可以在声明 TreeMap 对象的时候指定排序规则

 TreeMap 提供了可以指定排序规则的构造方法:

public TreeMap(Comparator<? super K> comparator) {
    this.comparator = comparator;
}

 就可以在创建对象的时候进行指定比较器

TreeMap<Integer,String> mapIntReverse = new TreeMap<>(Comparator.reverseOrder());

Comparator.reverseOrder() 返回的是 Collections.ReverseComparator 对象,就是用来反转顺序的

private static class ReverseComparator
        implements Comparator<Comparable<Object>>, Serializable {
    // 单例模式,用于表示逆序比较器
    static final ReverseComparator REVERSE_ORDER
            = new ReverseComparator();

    // 实现比较方法,对两个实现了Comparable接口的对象进行逆序比较
    public int compare(Comparable<Object> c1, Comparable<Object> c2) {
        return c2.compareTo(c1); // 调用c2的compareTo()方法,以c1为参数,实现逆序比较
    }

    // 反序列化时,返回Collections.reverseOrder(),保证单例模式
    private Object readResolve() {
        return Collections.reverseOrder();
    }

    // 返回正序比较器
    @Override
    public Comparator<Comparable<Object>> reversed() {
        return Comparator.naturalOrder();
    }
}

TreeMap 能够至始至终按照指定的顺序排列,这对于需要自定义排序的场景有用

3.3 排序作用

TreeMap 的元素是经过排序的,那找出最大的那个,最小的那个,或者找出所有大于或者小于某个值的键来说,就很容易

//最后一个 key  其实就是最大的
Integer highestKey = mapInt.lastKey();
//第一个 key   其实就是最小的
Integer lowestKey = mapInt.firstKey();
//获取的是到指定 key 之前的 key
Set<Integer> keysLessThan3 = mapInt.headMap(3).keySet();
//获取的是指定 key 之后的 key(包括指定 key)
Set<Integer> keysGreaterThanEqTo3 = mapInt.tailMap(3).keySet();

例子:

TreeMap<Integer, String> treeMap = new TreeMap<>();
treeMap.put(1, "value1");
treeMap.put(2, "value2");
treeMap.put(3, "value3");
treeMap.put(4, "value4");
treeMap.put(5, "value5");

// headMap示例,获取小于3的键值对
Map<Integer, String> headMap = treeMap.headMap(3);
System.out.println(headMap); // 输出 {1=value1, 2=value2}

// tailMap示例,获取大于等于4的键值对
Map<Integer, String> tailMap = treeMap.tailMap(4);
System.out.println(tailMap); // 输出 {4=value4, 5=value5}

// subMap示例,获取大于等于2且小于4的键值对
Map<Integer, String> subMap = treeMap.subMap(2, 4);
System.out.println(subMap); // 输出 {2=value2, 3=value3}

4. Map 总结与选择

需要考虑以下因素:

  • 是否需要按照键的自然顺序或者自定义顺序进行排序。如果需要按照键排序,则可以使用 TreeMap;如果不需要排序,则可以使用 HashMap 或 LinkedHashMap。
  • 是否需要保持插入顺序。如果需要保持插入顺序,则可以使用 LinkedHashMap;如果不需要保持插入顺序,则可以使用 TreeMap 或 HashMap。
  • 是否需要高效的查找。如果需要高效的查找,则可以使用 LinkedHashMap 或 HashMap,因为它们的查找操作的时间复杂度为 O(1),而是 TreeMap 是 O(log n)。

总结 

特性TreeMapHashMapLinkedHashMap
排序支持不支持不支持
插入顺序不保证不保证保证
查找效率O(log n)O(1)O(1)
空间占用通常较大通常较小通常较大
适用场景需要排序的场景无需排序的场景需要保持插入顺序

5. ArrayDeque 详解(双端队列)

当需要使用栈时,Java 已不推荐使用Stack,而是推荐使用更高效的ArrayDeque(双端队列),底层基于数组实现。与 LinkedList 相比,ArrayDeque 的性能更优,因为它使用连续的内存空间存储元素,可以更好地利用 CPU 缓存,在大多数情况下也更快

原因:Stack 它的核心方法上都加了 synchronized 关键字以确保线程安全,当我们不需要线程安全(比如说单线程环境下)性能就会比较差

当需要使用栈时候,请首选ArrayDeque

// 声明一个双端队列
ArrayDeque<String> stack = new ArrayDeque<>();

// 增加元素
stack.push("沉默");
stack.push("王二");
stack.push("陈清扬");

// 获取栈顶元素
String top = stack.peek();
System.out.println("栈顶元素为:" + top); // 陈清扬

// 弹出栈顶元素
String pop = stack.pop();
System.out.println("弹出的元素为:" + pop); // 陈清扬

// 修改栈顶元素
stack.pop();
stack.push("小明");
System.out.println("修改后的栈为:" + stack); // [沉默, 小明]

// 遍历队列查找元素
Iterator<String> iterator = stack.iterator();
int index = -1;
String target = "王二";
while (iterator.hasNext()) {
    String element = iterator.next();
    index++;
    if (element.equals(target)) {
        break;
    }
}

if (index == -1) {
    System.out.println("元素 " + target + " 不存在于队列中");
} else {
    System.out.println("元素 " + target + " 在队列中的位置为:" + index);
}

ArrayDeque 又实现了 Deque 接口(Deque 又实现了 Queue 接口):

public class ArrayDeque<E> extends AbstractCollection<E>
                           implements Deque<E>, Cloneable, Serializable {
}

当我们需要使用队列的时候,也可以选择 ArrayDeque

ArrayDeque<String> queue = new ArrayDeque<>();

// 增加元素
queue.offer("沉默");
queue.offer("王二");
queue.offer("陈清扬");

// 获取队首元素
String front = queue.peek();
System.out.println("队首元素为:" + front); // 沉默

// 弹出队首元素
String poll = queue.poll();
System.out.println("弹出的元素为:" + poll); // 沉默

// 修改队列中的元素
queue.poll();
queue.offer("小明");
System.out.println("修改后的队列为:" + queue); // [陈清扬, 小明]

// 查找元素
Iterator<String> iterator = queue.iterator();
int index = 0;
while (iterator.hasNext()) {
    String element = iterator.next();
    if (element.equals("王二")) {
        System.out.println("元素在队列中的位置为:" + index); // 0
        break;
    }
    index++;
}

LinkedList不只是个 List,还是一个 Queue,它也实现了 Deque 接口:

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable{
}

当我们需要使用队列时,还可以选择 LinkedList

// 创建一个 LinkedList 对象
LinkedList<String> queue = new LinkedList<>();

// 添加元素  是添加到队尾
queue.offer("沉默"); 
queue.offer("王二");
queue.offer("陈清扬");
System.out.println(queue); // 输出 [沉默, 王二, 陈清扬]

// 删除元素  删除的是队首元素
queue.poll();
System.out.println(queue); // 输出 [王二, 陈清扬]

// 修改元素:LinkedList 中的元素不支持直接修改,需要先删除再添加
String first = queue.poll();
queue.offer("王大二");
System.out.println(queue); // 输出 [陈清扬, 王大二]

// 查找元素:LinkedList 中的元素可以使用 get() 方法进行查找
System.out.println(queue.get(0)); // 输出 陈清扬
System.out.println(queue.contains("沉默")); // 输出 false

// 查找元素:使用迭代器的方式查找陈清扬
// 使用迭代器依次遍历元素并查找
Iterator<String> iterator = queue.iterator();
while (iterator.hasNext()) {
    String element = iterator.next();
    if (element.equals("陈清扬")) {
        System.out.println("找到了:" + element);
        break;
    }
}

 ArrayDequeLinkedListDeque的两个通用实现

 官方更推荐使用ArrayDeque用作栈和队列

ArrayDeque是非线程安全的,该容器不允许放入null元素

5.1 栈和队列

Queue 与 Deque 的方法对应

Queue MethodEquivalent Deque Method说明
add(e)addLast(e)向队尾插入元素,失败则抛出异常
offer(e)offerLast(e)向队尾插入元素,失败则返回false
remove()removeFirst()获取并删除队首元素,失败则抛出异常
poll()pollFirst()获取并删除队首元素,失败则返回null
element()getFirst()获取但不删除队首元素,失败则抛出异常
peek()peekFirst()获取但不删除队首元素,失败则返回null

Stack 与 Deque 的方法对应 

Stack MethodEquivalent Deque Method说明
push(e)addFirst(e)向栈顶插入元素,失败则抛出异常
offerFirst(e)向栈顶插入元素,失败则返回false
pop()removeFirst()获取并删除栈顶元素,失败则抛出异常
pollFirst()获取并删除栈顶元素,失败则返回null
peek()getFirst()获取但不删除栈顶元素,失败则抛出异常
peekFirst()获取但不删除栈顶元素,失败则返回null

 6. PriorityQueue 优先级队列

PriorityQueue 是 Java 中的一个基于优先级堆的优先队列实现,它能够在 O(log n) 的时间复杂度内实现元素的插入和删除操作,并且能够自动维护队列中元素的优先级顺序

// 创建 PriorityQueue 对象
PriorityQueue<String> priorityQueue = new PriorityQueue<>();

// 添加元素到 PriorityQueue
priorityQueue.offer("沉默王二");
priorityQueue.offer("陈清扬");
priorityQueue.offer("小转铃");

// 打印 PriorityQueue 中的元素
System.out.println("PriorityQueue 中的元素:");
while (!priorityQueue.isEmpty()) {
    System.out.print(priorityQueue.poll() + " ");
}
/*
  输出:
  PriorityQueue 中的元素:
  小转铃 沉默王二 陈清扬 
*/

可以使用了 Comparator.reverseOrder() 方法指定了 PriorityQueue 的优先级顺序为降序

// 创建 PriorityQueue 对象,并指定优先级顺序
PriorityQueue<String> priorityQueue = new PriorityQueue<>(Comparator.reverseOrder());

// 添加元素到 PriorityQueue
priorityQueue.offer("沉默王二");
priorityQueue.offer("陈清扬");
priorityQueue.offer("小转铃");

// 打印 PriorityQueue 中的元素
System.out.println("PriorityQueue 中的元素:");
while (!priorityQueue.isEmpty()) {
    System.out.print(priorityQueue.poll() + " ");
}
/*
输出:
  PriorityQueue 中的元素:
  陈清扬 沉默王二 小转铃
*/

6.1 PriorityQueue的作用

主要作用是维护一组数据的排序,使得取出数据时可以按照一定的优先级顺序进行,当我们调用 poll() 方法时,它会从队列的顶部弹出最高优先级的元素

元素大小的评判可以通过元素本身的自然顺序(natural ordering),也可以通过构造时传入的比较器Comparator,或者元素自身实现 Comparable 接口)来决定

6.2 方法剖析

1)add()和 offer()

都是向优先队列中插入元素,参考数据结构里的大根堆,小根堆的实现方式

//offer(E e)
public boolean offer(E e) {
    if (e == null)//不允许放入null元素
        throw new NullPointerException();
    modCount++;
    int i = size;
    if (i >= queue.length)
        grow(i + 1);//自动扩容
    size = i + 1;
    if (i == 0)//队列原来为空,这是插入的第一个元素
        queue[0] = e;
    else
        siftUp(i, e);//调整位置,不破坏小顶堆性质
    return true;
}

//siftUp()
private void siftUp(int k, E x) {
    while (k > 0) {
        int parent = (k - 1) >>> 1;//parentNo = (nodeNo-1)/2
        Object e = queue[parent];
        if (comparator.compare(x, (E) e) >= 0)//调用比较器的比较方法
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = x;
}

 2)element()和 peek()

都是获取但不删除队首元素,也就是队列中权值最小的那个元素

//peek()
public E peek() {
    if (size == 0)
        return null;
    return (E) queue[0];//0下标处的那个元素就是最小的那个
}

3)remove()和 poll()

都是获取并删除队首元素

public E poll() {
    if (size == 0)
        return null;
    int s = --size;
    modCount++;
    E result = (E) queue[0];//0下标处的那个元素就是最小的那个
    E x = (E) queue[s];
    queue[s] = null;
    if (s != 0)
        siftDown(0, x);//调整
    return result;
}

4)remove(Object o)

删除队列中跟o相等的某一个元素(如果有多个相等,只删除一个)

分为 2 种情况:

  1. 删除的是最后一个元素。直接删除即可,不需要调整。
  2. 删除的不是最后一个元素,从删除点开始以最后一个元素为参照调用一次siftDown()即可
//remove(Object o)
public boolean remove(Object o) {
  //通过遍历数组的方式找到第一个满足o.equals(queue[i])元素的下标
    int i = indexOf(o);
    if (i == -1)
        return false;
    int s = --size;
    if (s == i) //情况1
        queue[i] = null;
    else {
        E moved = (E) queue[s];
        queue[s] = null;
        siftDown(i, moved);//情况2
        ......
    }
    return true;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值