2024年9月

HashMap是Java中最常用的集合类框架,也是Java语言中非常典型的数据结构,


HashSet

HashMap
者在Java里有着相同的实现,前者仅仅是对后者做了一层包装,也就是说
HashSet
里面有一个
HashMap
(适配器模式)。因此了解
HashMap源码也就了解HashSet了

介绍

  • Key的存储方式是基于哈希表的

  • HashMap是 Map 接口 使用频率最高的实现类。

  • 允许使用null键和null值,与HashSet一样,不保证映射的顺序。

  • 所有的key构成的集合是无序的、唯一不可重复的。所以,key所在的类要重写:equals()和hashCode()

  • 所有的value构成的集合是Collection:无序的、可以重复的。所以,value所在的类要重写:equals()

  • 一个key-value构成一个entry

  • 所有的entry构成的集合是Set:无序的、不可重复的

  • HashMap 判断两个 key 相等的标准是:两个 key 通过 equals() 方法返回 true,hashCode 值也相等。

  • HashMap 判断两个 value 相等的标准是:两个 value 通过 equals() 方法返回 true

底层原理介绍

底层数据结构和初始属性

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //  HashMap的默认初始容量,16
static final int MAXIMUM_CAPACITY = 1 << 30;//HashMap的最大支持容量,2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f;//HashMap的默认加载因子
static final int TREEIFY_THRESHOLD = 8;//Bucket中链表长度大于该默认值,转化为红黑树
static final int UNTREEIFY_THRESHOLD = 6;//Bucket中红黑树存储的Node小于该默认值,转化为链表
/**
* 桶中的Node被树化时最小的hash表容量。
*(当桶中Node的数量大到需要变红黑树时,
* 若hash表容量小于MIN_TREEIFY_CAPACITY时,
* 此时应执行resize扩容操作这个MIN_TREEIFY_CAPACITY的值至少是TREEIFY_THRESHOLD的4倍。)
*/
static final int MIN_TREEIFY_CAPACITY = 64;

//存储元素的数组,总是2的n次幂
//通过数组存储,数组的元素是具体的Node<K,V>,这个Node有可能组成红黑树,可能是链表
transient Node<K,V>[] table;
//存储具体元素的集
 transient Set<Map.Entry<K,V>> entrySet;
//HashMap中存储的键值对的数量
transient int size;
//扩容的临界值,=容量*加载因子
int threshold;

//The load factor for the hash table.
final float loadFactor;

为什么默认负载因子是 0.75?官方答案如下:

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

上面的意思,简单来说是默认负载因子为 0.75,是因为它提供了空间和时间复杂度之间的良好平衡。 负载因子太低会导致大量的空桶浪费空间,负载因子太高会导致大量的碰撞,降低性能。0.75 的负载因子在这两个因素之间取得了良好的平衡。也就是说官方并未对负载因子为 0.75 做过的的解释,只是大概的说了一下,0.75 是空间和时间复杂度的平衡,但更多的细节是未做说明的,Stack Overflow 进行了
负载因子的科学推测
,感兴趣的可以学习学习

构造方法

//空参构造,初始化加载因子
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

//有参构造,可以初始化初始容量大小和加载因子
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);//扩容的临界值,= 容量*加载因子
}

put方法

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

先计算key的hash值,再对其put

static final int hash(Object key) {
    int h;
    //为什么要右移16位? 默认长度为2^5=16,与hash值&操作,容易获得相同的值。
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里分为了三步:

  1. h=key.hashCode() //第一步 取hashCode值
  2. h^(h>>>16) //第二步 高位参与运算,减少冲突,hash计算到这里
  3. return h&(length-1); //第三步 取模运算,计算数据在桶中的位置,这里看后面的源码

第3步(n-1)&hash原理:

  • 实际上(n-1) & hash等于 hash%n都可以得到元素在桶中的位置,但是(n-1)&hash操作更快。

  • 取余操作如果除数是 2 的整数次幂可以优化为移位操作。这也是为什么扩容时必须是必须是2的n次方

位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。

而计算hash是通过同时使用hashCode()的高16位异和低16位实现的(h >>> 16):这么做可以在数组比较小的时候,也能保证考虑到高低位都参与到Hash的计算中,可以减少冲突,同时不会有太大的开销。

hash值其实是一个int类型,二进制位为32位,而HashMap的table数组初始化size为16,取余操作为hashCode & 15 ==> hashCode & 1111 。这将会存在一个巨大的问题,1111只会与hashCode的低四位进行与操作,也就是hashCode的高位其实并没有参与运算,会导很多hash值不同而高位有区别的数,最后算出来的索引都是一样的。 举个例子,假设hashCode为1111110001,那么1111110001 & 1111 = 0001,如果有些key的hash值低位与前者相同,但高位发生了变化,如1011110001 & 1111 = 0001,1001110001 & 1111 = 0001,显然在高位发生变化后,最后算出来的索引还是一样,这样就会导致很多数据都被放到一个数组里面了,造成性能退化。 为了避免这种情况,HashMap将高16位与低16位进行异或,这样可以保证高位的数据也参与到与运算中来,以增大索引的散列程度,让数据分布得更为均匀(个人认为是为了分布均匀)

put流程如下:

// 第四个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时才会进行 put 操作
// 第五个参数 evict 我们这里不关心
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)//初始判断,初始数组为空时
        // resize()初始数组,需要进行扩容操作
        n = (tab = resize()).length;

    //这里就是上面的第三步,根据key的hash值找到数据在table中的位置
    if ((p = tab[i = (n - 1) & hash]) == null)
        //通过hash找到的数组下标,里面没有内容就直接赋值
        tab[i] = newNode(hash, key, value, null);
    else {//如果里面已经有内容了
        Node<K,V> e; K k;
        if (p.hash == hash &&
            //hash相同,key也相同,那就直接修改value值
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            //key不相同,且节点为红黑树,那就把节点放到红黑树里
            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;
            }
        }
        //传入的K元素已经存在,直接覆盖value
        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 void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //检查是否满足转换成红黑树的条件,如果数组大小还小于64,则先扩容
    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);
    }
}

总结put方法流程:

  1. 如果table没有初始化就先进行初始化过程

  2. 使用hash算法计算key的索引判断索引处有没有存在元素,没有就直接插入

  3. 如果索引处存在元素,则遍历插入,有两种情况,


    • 一种是链表形式就直接遍历到尾端插入,

    • 一种是红黑树就按照红黑树结构

  4. 插入链表的数量大于阈值8,且数组大小已经大等于64,就要转换成红黑树的结构

  5. 添加成功后会检查是否需要扩容

数组扩容

//table数组的扩容操作
final Node<K,V>[] resize() {
    //引用扩容前的node数组
    Node<K,V>[] oldTab = table;
    //旧的容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //旧的阈值
    int oldThr = threshold;
    //新的容量、阈值初始化为0
    int newCap, newThr = 0;
    
    //计算新容量
    if (oldCap > 0) {
        //如果旧容量已经超过最大容量,让阈值也等于最大容量,以后不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //没超过最大值,就令newcap为原来容量的两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //如果旧容量翻倍没有超过最大值,且旧容量不小于初始化容量16,则翻倍
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        //旧容量oldCap = 0时,但是旧的阈值大于0,令初始化容量设置为阈值
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        //两个值都为0的时候使用默认值初始化
        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;
                //如果e没有next节点,证明这个节点上没有hash冲突,则直接把e的引用给到新的数组位置上
                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;
                    //从这条链表上第一个元素开始轮询,如果当前元素新增的bit是0,则放在当前这条链表上
                    //如果是1,则放在"j+oldcap"这个位置上,生成“低位”和“高位”两个链表
                    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);
                    //低位链表放到j这个索引的位置上
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //高位链表放到(j+oldCap)这个索引的位置上
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

显然,HashMap的扩容机制,就是当达到扩容条件时会进行扩容。扩容条件就是当HashMap中的元素个数超过临界值时就会自动扩容(threshold = loadFactor * capacity)。如果是初始化扩容,只执行resize的前半部分代码,但如果是随着元素的增加而扩容,HashMap需要重新计算oldTab中每个值的位置,即重建hash表,随着元素的不断增加,HashMap会发生多次扩容,这样就会非常影响性能。所以一般建议创建HashMap的时候指定初始化容量

但是当使用HashMap(int initialCapacity)来初始化容量的时候,HashMap并不会使用传进来的initialCapacity直接作为初始容量。JDK会默认计算一个相对合理的值当做初始容量。所谓合理值,其实是找到第一个比用户传入的值大的
2的幂
。也就是说,当new HashMap(7)创建HashMap的时候,JDK会通过计算,创建一个容量为8的Map;当new HashMap(9)创建HashMap的时候,JDK会通过计算,创建一个容量为16的Map。当然了,当创建一个
HashMap
时,表的大小并不会立即分配,而是在第一次put元素时进行分配,并且分配的大小会是大于或等于初始容量的最小的2的幂。

一般来说,initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loaderfactor)默认为 0.75,如果暂时无法确定初始值大小,请设置为 16(即默认值)。HashMap 需要放置 1024 个元素,由于没有设置容量初始大小,随着元素增加而被迫不断扩容,resize() 方法总共会调用 8 次,反复重建哈希表和数据迁移。当放置的集合元素个数达千万级时会影响程序性能。

//初始化容量
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
    //保证initialCapacity在合理范围内,大于0小于最大容量
    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);
}

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    //|=计算方式:两个二进制对应位都为0时,结果等于0,否则结果等于1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
/**
 * 红黑树分裂方法
 */
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
     //当前这个节点的引用,即这个索引上的树的根节点
     TreeNode<K,V> b = this;
     // Relink into lo and hi lists, preserving order
     TreeNode<K,V> loHead = null, loTail = null;
     TreeNode<K,V> hiHead = null, hiTail = null;
     //高位低位的初始树节点个数都设成0
     int lc = 0, hc = 0;
     for (TreeNode<K,V> e = b, next; e != null; e = next) {
            next = (TreeNode<K,V>)e.next;
           e.next = null;
           //bit=oldcap,这里判断新bit位是0还是1,如果是0就放在低位树上,如果是1就放在高位树上,这里先是一个双向链表
           if ((e.hash & bit) == 0) {
               if ((e.prev = loTail) == null)
                    loHead = e;
               else
                    loTail.next = e;
               loTail = e;
               ++lc;
            }
            else {
                if ((e.prev = hiTail) == null)
                    hiHead = e;
                else
                    hiTail.next = e;
                hiTail = e;
                ++hc;
            }
       }
 
       if (loHead != null) {
            if (lc <= UNTREEIFY_THRESHOLD)
                //!!!如果低位的链表长度小于阈值6,则把树变成链表,并放到新数组中j索引位置
                tab[index] = loHead.untreeify(map);
            else {
                tab[index] = loHead;
                //高位不为空,进行红黑树转换
                if (hiHead != null) // (else is already treeified)
                    loHead.treeify(tab);
            }
        }
        if (hiHead != null) {
            if (hc <= UNTREEIFY_THRESHOLD)
                tab[index + bit] = hiHead.untreeify(map);
            else {
                tab[index + bit] = hiHead;
                if (loHead != null)
                    hiHead.treeify(tab);
            }
        }
}
/**
 * 将树转变为单向链表
 */
final Node<K,V> untreeify(HashMap<K,V> map) {
     Node<K,V> hd = null, tl = null;
     for (Node<K,V> q = this; q != null; q = q.next) {
          Node<K,V> p = map.replacementNode(q, null);
          if (tl == null)
                hd = p;
          else
               tl.next = p;
          tl = p;
      }
     return hd;
}
/**
 * 链表转换为红黑树,会根据红黑树特性进行颜色转换、左旋、右旋等
 */
final void treeify(Node<K,V>[] tab) {
      TreeNode<K,V> root = null;
      for (TreeNode<K,V> x = this, next; x != null; x = next) {
             next = (TreeNode<K,V>)x.next;
           x.left = x.right = null;
           if (root == null) {
                x.parent = null;
                x.red = false;
                root = x;
           }
           else {
                K k = x.key;
                int h = x.hash;
                Class<?> kc = null;
                for (TreeNode<K,V> p = root;;) {
                    int dir, ph;
                    K pk = p.key;
                    if ((ph = p.hash) > h)
                         dir = -1;
                    else if (ph < h)
                         dir = 1;
                    else if ((kc == null &&
                              (kc = comparableClassFor(k)) == null) ||
                             (dir = compareComparables(kc, k, pk)) == 0)
                          dir = tieBreakOrder(k, pk);
 
                    TreeNode<K,V> xp = p;
                    if ((p = (dir <= 0) ? p.left : p.right) == null) {
                        x.parent = xp;
                        if (dir <= 0)
                            xp.left = x;
                        else
                            xp.right = x;
                        //进行左旋、右旋调整
                        root = balanceInsertion(root, x);
                        break;
                    }
                 }
            }
        }
        moveRootToFront(tab, root);
}

总结HashMap的实现:

  1. HashMap的内部存储结构其实是 数组+ 链表+ 红黑树 的结合。当实例化一个HashMap时,会初始化initialCapacity和loadFactor,此时还不会创建数组
  2. 在put第一对映射关系时,系统会创建一个长度为initialCapacity(默认为16)的Node数组,这个长度在哈希表中被称为容量(Capacity),在这个数组中可以存放元素的位置我们称之为“桶”(bucket),每个bucket都有自己的索引,系统可以根据索引快速的查找bucket中的元素。
  3. 每个bucket中存储一个元素,即一个Node对象,但每一个Node对象可以带一个引用变量next,用于指向下一个元素,因此,在一个桶中,就有可能生成一个Node链。也可能是一个一个TreeNode对象,每一个TreeNode对象可以有两个叶子结点left和right,因此,在一个桶中,就有可能生成一个TreeNode树。而新添加的元素作为链表的last,或树的叶子结点

总结HashMap的扩容和树形化:

  1. 当HashMap中的元素个数超过数组大小(数组总大小length,不是数组中个数size)*loadFactor 时,就会进行数 组扩容,loadFactor的默认值(DEFAULT_LOAD_FACTOR)为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小(DEFAULT_INITIAL_CAPACITY)为16,那么当HashMap中元素个数超过16
    0.75=12(这个值就是代码中的threshold值,也叫做临界值)的时候,就把数组的大小扩展为 2
    16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能
  2. 当HashMap中的其中一个链的对象个数如果达到了8个,此时如果capacity没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链会变成树,结点类型由Node变成TreeNode类型。当然,如果当映射关系被移除后,下次resize方法时判断树的结点个数低于6个,也会把树再转为链表。

即当数组的某一个索引位置上的元素以链表形式存在的数据个数>8且当前数组的长度>64时,此时此索引位置上的所有数据改为使用红黑树存储

get方法

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) {
            //1、判断第一个元素是否与key匹配
            if (first.hash == hash &&
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                //2、判断链表是否红黑树结构
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                //3、如果不是红黑树结构,直接循环判断
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
}


/**
  * 这里面情况分很多中,主要是因为考虑了hash相同但是key值不同的情况,查找的最核心还是落在key值上
  */
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
      TreeNode<K,V> p = this;
      do {
           int ph, dir; K pk;
           TreeNode<K,V> pl = p.left, pr = p.right, q;
           //判断要查询元素的hash是否在树的左边
           if ((ph = p.hash) > h)
                p = pl;
           //判断要查询元素的hash是否在树的右边
                else if (ph < h)
                p = pr;
           //查询元素的hash与当前树节点hash相同情况
           else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                return p;
           //上面的三步都是正常的在二叉查找树中寻找对象的方法
           //如果hash相等,但是内容却不相等
           else if (pl == null)
                p = pr;
           else if (pr == null)
                p = pl;
           //如果可以根据compareTo进行比较的话就根据compareTo进行比较
           else if ((kc != null ||
                    (kc = comparableClassFor(k)) != null) &&
                    (dir = compareComparables(kc, k, pk)) != 0)
                p = (dir < 0) ? pl : pr;
                //根据compareTo的结果在右孩子上继续查询
            else if ((q = pr.find(h, k, kc)) != null)
                return q;
            //根据compareTo的结果在左孩子上继续查询
            else
                p = pl;
      } while (p != null);
      return null;
}

总结get方法:

  1. 首先通过hash()函数得到对应数组下标,然后依次判断。
  2. 判断第一个元素与key是否匹配,如果匹配就返回参数值;
  3. 判断链表是否红黑树,如果是红黑树,就进入红黑树方法获取参数值;
  4. 如果不是红黑树结构,直接循环遍历链表判断,直到获取参数为止;

remove方法

jdk1.8的删除逻辑实现比较复杂,删除时有红黑树节点删除和调整:

  1. 默认判断链表第一个元素是否是要删除的元素;
  2. 如果第一个不是,就继续判断当前冲突链表是否是红黑树,如果是,就进入红黑树里面去找;
  3. 如果当前冲突链表不是红黑树,就直接在链表中循环判断,直到找到为止;
  4. 将找到的节点,删除掉,如果是红黑树结构,会进行颜色转换、左旋、右旋调整,直到满足红黑树特性为止;

HashSet

  • Set 不能存放重复元素,无序的,允许一个null(基于HashMap 实现,HashMap的key可以为null);

  • Set 基于 Map 实现,Set 里的元素值就是 Map的键值。

HashSet 基于 HashMap 实现。放入HashSet中的元素实际上由HashMap的key来保存,而HashMap的value则存储了一个静态的Object对象。

底层源码

public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable {
    static final long serialVersionUID = -5024744406713321676L;

    private transient HashMap<E,Object> map; //基于HashMap实现
    //...
}

关于作者

来自一线程序员Seven的探索与实践,持续学习迭代中~

本文已收录于我的个人博客:
https://www.seven97.top

公众号:seven97,欢迎关注~

概述

跳跃表(SkipList)是链表加多级索引组成的数据结构。链表的数据结构的查询复条度是 O(N)。为了提高查询效率,可以在链表上加多级索引来实现快速查询。跳跃表不仅能提高搜索性能。也能提高插入和删除操作的性能。索引的层数也叫作跳跃表的高度


查找

在跳跃表的结构中会首先从顶层开始查找,当顶层不存在时向下一层查找,重复此查找过程直到跳跃到原始链表。如图所示,在 [1,3,4,10,1,20] 的链表中查找 10,首先从二级索引中查找,由于 1 和 4 都比 10 小,因此接着在一级索引查找,由于 10 大于 4 小于 11,因此接着向下查找,原始链表中 4 的下一个节点 10 便是需要查找的数据


插入

首先按照查找流程找到待插入元素的前驱和后继,然后按照随机算法生成一个高度值 height,最后将待插入的节点按照高度值生成一个垂直节点(这个节点的层数正好等于高度值),并将其插人跳跃表的多条链表中。如果高度值 heigh 大于插入前跳跃表的高度,那么跳跃表的高度被提升为 height,同时需要更新头节点和尾节点的指针指向。如果 height 小于或等于跳跃表的高度,那么需要更新待插入元素的前驱和后继的指针指向。在 [1,3,4.10,11,20] 的跳跃表中插入 18 的过程如图所示


删除

在删除节点时首先需要找到待删除的节点在每一层的前驱和后继,接着将其前驱节点的后继替换为待删除的节点的后继,删除 4 的过程如图所示


代码实现

import java.util.Random;
import java.util.Stack;

class SkipNode<T> {

    int key;
    T value;
    SkipNode right,down;//左右上下四个方向的指针
    public SkipNode (int key,T value) {
        this.key=key;
        this.value=value;
    }

}

public class SkipList <T> {

    SkipNode headNode;//头节点,入口
    int highLevel;//层数
    Random random;// 用于投掷硬币
    final int MAX_LEVEL = 32;//最大的层
    
    SkipList(){
        random=new Random();
        headNode=new SkipNode(Integer.MIN_VALUE,null);
        highLevel=0;
    }
    
    public SkipNode search(int key) {
        SkipNode team=headNode;
        while (team!=null) {
            if(team.key==key){
                return  team;
            }
            else if(team.right==null) { //右侧没有了,只能下降
                team=team.down;
            }
            else if(team.right.key>key) { //需要下降去寻找
                team=team.down;
            }
            else { //右侧比较小向右
                team=team.right;
            }
        }
        return null;
    }

    public void delete(int key) { //删除不需要考虑层数
        SkipNode team=headNode;
        while (team!=null) {
            if (team.right == null) { //右侧没有了,说明这一层找到,没有只能下降
                team=team.down;
            }
            else if(team.right.key==key) { //找到节点,右侧即为待删除节点
                team.right=team.right.right;//删除右侧节点
                team=team.down;//向下继续查找删除
            }
            else if(team.right.key>key) { //右侧已经不可能了,向下
                team=team.down;
            }
            else { //节点还在右侧
                team=team.right;
            }
        }
    }
    
    public void add(SkipNode node) {
    
        int key=node.key;
        SkipNode findNode=search(key);
        if(findNode!=null) { //如果存在这个key的节点
        
            findNode.value=node.value;
            return;
        }

        Stack<SkipNode>stack=new Stack<SkipNode>();//存储向下的节点,这些节点可能在右侧插入节点
        SkipNode team=headNode;//查找待插入的节点   找到最底层的哪个节点。
        while (team!=null) {//进行查找操作
            if(team.right==null) { //右侧没有了,只能下降
                stack.add(team);//将曾经向下的节点记录一下
                team=team.down;
            }
            else if(team.right.key>key) { //需要下降去寻找
                stack.add(team);//将曾经向下的节点记录一下
                team=team.down;
            }
            else { //向右
                team=team.right;
            }
        }

        int level=1;//当前层数,从第一层添加(第一层必须添加,先添加再判断)
        SkipNode downNode=null;//保持前驱节点(即down的指向,初始为null)
        while (!stack.isEmpty()) {
            //在该层插入node
            team=stack.pop();//抛出待插入的左侧节点
            SkipNode nodeTeam=new SkipNode(node.key, node.value);//节点需要重新创建
            nodeTeam.down=downNode;//处理竖方向
            downNode=nodeTeam;//标记新的节点下次使用
            if(team.right==null) {//右侧为null 说明插入在末尾
                team.right=nodeTeam;
            }
            //水平方向处理
            else {//右侧还有节点,插入在两者之间
                nodeTeam.right=team.right;
                team.right=nodeTeam;
            }
            //考虑是否需要向上
            if(level>MAX_LEVEL)//已经到达最高级的节点啦
                break;
            double num=random.nextDouble();//[0-1]随机数
            if(num>0.5)//运气不好结束
                break;
            level++;
            if(level>highLevel) { //比当前最大高度要高但是依然在允许范围内 需要改变head节点
                highLevel=level;
                //需要创建一个新的节点
                SkipNode highHeadNode=new SkipNode(Integer.MIN_VALUE, null);
                highHeadNode.down=headNode;
                headNode=highHeadNode;//改变head
                stack.add(headNode);//下次抛出head
            }
        }
    }
    
    public void printList() {
        SkipNode teamNode=headNode;
        int index=1;
        SkipNode last=teamNode;
        while (last.down!=null){
            last=last.down;
        }
        while (teamNode!=null) {
            SkipNode enumNode=teamNode.right;
            SkipNode enumLast=last.right;
            System.out.printf("%-8s","head->");
            while (enumLast!=null&&enumNode!=null) {
                if(enumLast.key==enumNode.key) {
                    System.out.printf("%-5s",enumLast.key+"->");
                    enumLast=enumLast.right;
                    enumNode=enumNode.right;
                }
                else {
                    enumLast=enumLast.right;
                    System.out.printf("%-5s","");
                }
            }
            teamNode=teamNode.down;
            index++;
            System.out.println();
        }
    }
}

模板方法模式(Template Method Pattern)也称之为模板模式(Template Pattern),是设计模式中最简单的模式之一。

先来看定义:
定义一个操作中算法的骨架(模板),将一些步骤延迟到子类中,模板方法使得子类可以不改变算法的结构即可重新定义算法某些特定的步骤。
这个定义还是有一些晦涩,我的理解是这样的:(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )
在父类中我们可以定义一块业务的整体实现过程,但是针对某些步骤的具体实现逻辑,我们可以暂时先只定义一个抽象方法,在未来定义子类的过程中,实现/重写该方法。
这个模式主要是为了解决,很多场景中,我们并不知道未来实际使用中,具体需要怎么实现,甚至会出现多个具体实现,针对此,我们可以先定义父类中已经明确的业务。
大致的调用结构如下:

它是面向对象的23种设计模式中的一种,属于行为模式的范围。
来看示例代码:

音乐播放器抽象类

1 packagecom.example.demo.learn.pattern.behavior.templatemethod;2 
3 importlombok.extern.slf4j.Slf4j;4 
5 /**
6 * @discription7  */
8 @Slf4j9 public abstract classAbstractMusicPlayer {10     public voidstartUp() {11 showFrame();12 doCustomizedOpt();13 }14 
15     protected abstract voidplayWelcomeMsg();16 
17     protected voidshowFrame() {18 showMainFrame();19 playWelcomeMsg();20 }21 
22     protected abstract voiddoCustomizedOpt();23 
24 
25     protected abstract voidshowMainFrame();26 }

酷猫播放器

1 packagecom.example.demo.learn.pattern.behavior.templatemethod;2 
3 importlombok.extern.slf4j.Slf4j;4 
5 /**
6 * @discription7  */
8 @Slf4j9 public class CoolCatPlayer extendsAbstractMusicPlayer{10 @Override11     protected voidplayWelcomeMsg() {12       log.warn("hi man");13 }14 
15 @Override16     protected voiddoCustomizedOpt() {17         log.warn("您有一份价值99元的免费礼品待领取,快点击下方链接");18 }19 
20 @Override21     protected voidshowMainFrame() {22         log.warn("打开酷猫音乐主界面");23 }24 }

酷他音乐盒

1 packagecom.example.demo.learn.pattern.behavior.templatemethod;2 
3 importlombok.extern.slf4j.Slf4j;4 
5 /**
6 * @discription7  */
8 @Slf4j9 public class CoolHePlayer extendsAbstractMusicPlayer {10 @Override11     protected voidplayWelcomeMsg() {12         log.warn("欢迎来到酷他音乐盒");13 }14 
15 @Override16     protected voiddoCustomizedOpt() {17         log.warn("一刀999,和兄弟一起战个痛快");18 }19 
20 @Override21     protected voidshowMainFrame() {22         log.warn("打开酷他音乐盒主界面");23 }24 }

执行主类

1 packagecom.example.demo.learn.pattern.behavior.templatemethod;2 
3 /**
4 * @discription5  */
6 
7 public classPatternMain {8     public static voidmain(String[] args) {9         AbstractMusicPlayer coolCat = newCoolCatPlayer();10 coolCat.startUp();11 
12         AbstractMusicPlayer coolHe = newCoolHePlayer();13 coolHe.startUp();14 }15 }

输出如下

15:38:12.515 [main] WARN com.example.demo.learn.pattern.behavior.templatemethod.CoolCatPlayer -打开酷猫音乐主界面15:38:12.518 [main] WARN com.example.demo.learn.pattern.behavior.templatemethod.CoolCatPlayer -hi man15:38:12.518 [main] WARN com.example.demo.learn.pattern.behavior.templatemethod.CoolCatPlayer -您有一份价值99元的免费礼品待领取,快点击下方链接15:38:12.518 [main] WARN com.example.demo.learn.pattern.behavior.templatemethod.CoolHePlayer -打开酷他音乐盒主界面15:38:12.518 [main] WARN com.example.demo.learn.pattern.behavior.templatemethod.CoolHePlayer -欢迎来到酷他音乐盒15:38:12.518 [main] WARN com.example.demo.learn.pattern.behavior.templatemethod.CoolHePlayer - 一刀999,和兄弟一起战个痛快

我们定义了一个播放器的类,并且约定了播放器启动时,我们需要具体做的业务:

类图

步骤1、打开主界面

步骤2、做一些定制的用户操作
但是有多个播放器(如酷猫音乐、酷他音乐盒),他们的主界面和用户定制操作都各有不同,因此我们可以先只定义以上操作的抽象方法,对于具体操作的实现留给子类完成即可。

模板方法核心的步骤就是2点:
父类中定义骨架(模板),组织各种定义好的抽象方法
子类根据实际业务实现抽象方法

前文回顾


在上篇文章中,我们约定了一种衡量格子价值的方式,如下表。

综合价值排序 己方价值 敌方价值 对应的奖励数值
1 Lv1 ? \(2^{20}\)
2 ? Lv1 \(2^{16}\)
3 Lv2 ? \(2^{12}\)
4 Lv2 \(2^{8}\)
5 Lv3 \(2^{4}\)
6 Lv4 \(2^{0}\)

在该表中,对不同的情形,设计了不同的奖励数值,这些数值大多是采用经验公式,人为估计的数值,并不是最优良的数值。同样的,在上表中的除前两类为,其余都可根据实际情况进一步的细分权重,这里给出一个样例供大家参考/理解:

综合价值排序 己方价值 敌方价值 对应的奖励数值
3.1 Lv2 Lv2 \(2^{13}\)
3.2 Lv2 Lv3 \(2^{12}\)
3.3 Lv2 Lv4 \(2^{11}\)

同样是能构成杀招(Lv2等级),能顺便堵死对面杀招/优良的位置自然是更好的。

在附录中给出了详细的权重表

本篇中我们将基于遗传算法讨论如何让AI学习奖励值。

遗传算法概述


遗传算法(Genetic Algorithm, GA)是一种模拟自然选择和遗传机制的优化算法。它用于寻找问题的最优解,特别适用于复杂的优化问题和搜索问题。遗传算法基于达尔文的自然选择理论,通过模拟生物进化过程来逐步改进解决方案。

遗传算法的基本步骤如下:

  1. 初始化:创建一个初始种群,种群中的每一个个体(通常称为染色体或解)是问题的一个潜在解。

  2. 评估:计算种群中每个个体的适应度值,这通常通过一个目标函数来进行,适应度值表示个体的优劣。

  3. 选择:根据适应度值选择个体进行繁殖,优良个体有更高的概率被选择,以生成下一代种群。

  4. 交叉(Crossover):通过将两个个体的部分基因交换,生成新的个体。这一步模仿了生物的交配过程,可以产生新的解。

  5. 变异(Mutation):在某些个体中随机改变基因,以引入新的基因变异。这一步帮助算法跳出局部最优解,增加解的多样性。

  6. 替换:将一部分个体替换为最优良的个体,保留最优秀的基因,使得种群的型状不会出现下降或震荡。

  7. 终止:判断算法是否满足终止条件,如达到最大迭代次数或找到足够好的解。如果满足条件,算法终止,返回最优解;否则,返回第2步。

遗传算法实现思路

初始化


本文所设计的AI决策方案共包含12个参数,其中11个是奖励权重
\(R_i\)
,1个是对劣质选项接受度
\(K\)

我们可以定义
\(N\)
个智能体,分别用初始权重进行初始化,一般来说,
\(N\)
可以取10~100,最好选择偶数,否则会有一些不必要的麻烦。

初始化过程可以用数学公式表示为:

\[W_i^{t=0} = W_0
\]

其中,
\(W_0\)
表示初始权重,
\(W_i^{t=0}\)
表示第
\(t\)
代的第
\(i\)
个个体。

评估


本例中,采用让AI对弈的方式,根据AI在棋局中的表现评估AI得分,具体流程如下:

  1. 生成一个从1到N的随机排列,并将其按顺序分配给AI
  2. 将序号为1、3、5、...的AI与序号为2、4、6、...的AI对弈
  3. 将棋局结果记录到AI得分表内。
  4. 是否完成
    \(N_R\)
    轮对局,倘若未完成,则返回到1。
  5. 对AI进行排名。

交叉


当完成排名时,让排名后50%的AI及前50%的AI两两组合,其数学公式如下

\[\begin{align*}
W_{i}^{t+1}&=W_i^t\times(1-c)+W_{i-50}^t\times c, &N/2 \leq &t \leq N \\
W_{i}^{t+1}&=W_i^t\times(1-c)+W_{i+50}^t\times c, &0 \leq &t \leq N/2
\end{align*}
\]

其中:
\(c\)
为学习因子(交叉率),表示AI在学习过程中对新知识(权重)的接受程度,
\(c\)
越大,AI越倾向于接受新权重,
\(c\)
越小,AI越倾向于保留旧权重。交叉率
\(c\)
一搬可取
\(0.01\sim0.3\)

替换


首先定义局部最优个体和全局最优个体。

  • 局部最优
    \(W_b^t\)
    :如果一个个体在本轮中的综合成绩排名为第一名(胜场最多),那么称其为局部最优个体。

  • 全局最优
    \(W_B\)
    :当只进行一轮迭代时,全局最优个体等于局部最优个体,即:
    \(W_B=W_b^{t=0}\)
    。当进行了不止一局游戏时,将新的局部最优个体与全局最优个体进行
    \(N_R\)
    轮对局,倘若全局最优个体获胜,则其依旧为全局最优个体,倘若其失败,则局部最优个体成为新的全局最优个体。可以用数学公式表示为:

\[W_B=
\begin{cases}
W_b^t &\text{if}\;W_b^t \;\text{win},\\
W_B &\text{otherwise}.
\end{cases}
\]

为了保留最优的性状,将排名靠后的部分个体替换为全局最优个体,记替换率为
\(s\)
,一般取
\(0.02\sim 0.1\)

变异


在变异过程中,个体的基因发生随机的改变。定义变异系数
\(m\)
,其绝对了变异的程度,一般来说
\(m\)
的范围在
\(0.01\sim0.1\)
数学公式如下:

\[W_{i,j}^{t}=W^t_{i,j}\times (1+m_j)
\]

其中
\(W_{i,j}^{t}\)
表示第
\(t\)
代的第
\(i\)
个个体的第
\(j\)
个权重,
\(m_j\)
是在
\((-m,m)\)
内的随机数。

流程汇总


以下给出遗传算法学习的流程

  1. 初始化种群

  2. 创建棋局,各个个体互相对战,统计得分并进行排名

  3. 判断是否达到停止条件,若不是则继续。

  4. 依排名将个体两两匹配,进行交叉操作

  5. 将排名靠后的个体分别替换为局部最优个体和全局最优个体

  6. 进行变异操作

  7. 转至步骤2

附录

行为优先级

  • Lv1:下子直接取胜,或在一回合内取胜。
  • Lv2:下在大概率在若干回合内取胜。
  • Lv3:能够迫使对方一直防御。
  • Lv4:收益较低。

初始权重表

综合价值排序 己方价值 敌方价值 对应的奖励数值
1 Lv1 ? \(2^{20}\)
2 ? Lv1 \(2^{16}\)
3.1 Lv2 Lv2 \(2^{13}\)
3.2 Lv2 Lv3 \(2^{12}\)
3.3 Lv2 Lv4 \(2^{11}\)
4.1 Lv3 Lv2 \(2^{9}\)
4.2 Lv4 Lv2 \(2^{8}\)
5.1 Lv3 Lv3 \(2^{6}\)
5.2 Lv3 Lv4 \(2^{4}\)
6.1 Lv4 Lv3 \(2^{2}\)
6.2 Lv4 Lv4 \(2^{0}\)

符号说明

符号 意义 数值范围
\(W\) 个体(权重) -
\(R\) 行动的奖励 -
\(K\) 对劣选项的接受程度 -
\(N\) 种群大小 10~100
\(N_R\) 评估时的对局轮数 10~100
\(T\) 迭代次数 20~500
\(c\) 交叉率 0.01~0.03
\(s\) 替换率 0.02~0.1
\(m\) 变异率 0.01~0.1

LeetCode516 .最长回文子序列

题目叙述:

力扣题目链接(opens new window)

给你一个字符串
s
,找出其中最长的回文子序列,并返回该序列的长度。

子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。

示例 1:

输入:s = "bbbab"
输出:4
解释:一个可能的最长回文子序列为 "bbbb" 。

示例 2:

输入:s = "cbbd"
输出:2
解释:一个可能的最长回文子序列为 "bb" 。

提示:

  • 1 <= s.length <= 1000
  • s
    仅由小写英文字母组成

动态规划思路

  • 我们在上文中已经介绍了
    回文子串
    ,那么我们可以沿用
    回文子串
    的思想解决这道题,但是我们首先得明确
    回文子串

    回文子序列
    的区别

  • LeetCode647.回文子串
    求的是回文子串,而本题要求的是回文子序列, 要搞清楚这两者之间的区别。

  • 回文子串是要连续的,回文子序列可不是连续的!
    回文子串,回文子序列都是动态规划经典题目。

  • 回文子串,可以做这两题:


    • 647.回文子串

    • 5.最长回文子串

思路其实是差不多的,但本题要比求回文子串简单一点,因为情况少了一点。

动规五部曲分析如下:

1.确定状态变量及其含义

  • 我们设立dp数组,dp[i]] [j] 表示s字符串在
    [i,j]
    范围内最长回文子序列的长度。(
    j
    >=
    i
  • 那么我们确立了状态变量
    dp[i][j]
    ,那么我们就要开始处理递推公式和如何初始化了

2.确定递推公式

  • 在这里,我们最重要的就是判断
    s[i],s[j]
    之间的关系
    • s[i]==s[j]
      此时,
      dp[i][j]=dp[i+1][j-1]+2
  • 为什么是+2呢?因为本题是最长回文子序列,当
    s[i]==s[j]
    时,
    [i,j]
    范围内至少有
    dp[i+1][j-1]+2
    这个大小的最长回文子序列,+2就是加上
    s[i],s[j]
    这两个字符。

516.最长回文子序列

  • 如果
    s[i]与s[j]不相同
    ,说明
    s[i]和s[j]
    的同时加入 并不能增加
    [i,j]
    区间回文子序列的长度,那么分别加入
    s[i]、s[j]
    看看哪一个可以组成最长的回文子序列。

加入
s[j]
的回文子序列长度为
dp[i + 1] [j]

加入
s[i]
的回文子序列长度为
dp[i] [j - 1]

那么
dp [i] [j]
一定是取最大的,即:
dp [i] [j] = max(dp [i + 1] [j], dp[i] [j - 1])
;

516.最长回文子序列1

3.如何初始化dp数组

  • 首先,我们得处理特殊情况,当
    i==j
    的时候,这个时候在
    [i,j]
    范围内只有一个字符,使用
    dp[i][j]=dp[i+1][j-1]+2
    会导致当前处理的子串的左边界大于右边界,此时我们就得特殊处理一下,当处理的子串只有一个字符时,
    i==j
    ,并且
    dp[i][j]
    显然等于1,因为单个字符也是回文子序列,并且这个回文子序列的长度是1。
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
for (int i = 0; i < s.size(); i++) dp[i][i] = 1;

4. 确定遍历顺序

从递归公式中,可以看出,
dp[i][j]
依赖于
dp[i + 1][j - 1]

dp[i + 1][j] 和 dp[i][j - 1]
,如图:

img

  • 所以说我们想要得到
    dp[i][j]
    ,必须从左下方开始,向着右上方的方向进行递推。
  • 所以说遍历顺序就是从下到上,从左到右
        //开始对dp数组进行从下到上,从左到右进行赋值。
        for(int i=s.size()-1;i>=0;i--){
            for(int j=i+1;j<s.size();j++){
                if(s[i]==s[j]) dp[i][j]=dp[i+1][j-1]+2;
                else dp[i][j]=max(dp[i][j-1],dp[i+1][j]);
            }
        }

5.举例打印dp数组

输入s:"cbbd" 为例,dp数组状态如图:

516.最长回文子序列3

红色框即:
dp[0][s.size() - 1];
为最终结果。

最终代码:

//最长回文子序列
class Solution {
public:
    int longestPalindromeSubseq(string s) {
        //创建二维的dp数组
        vector<vector<int>> dp(s.size(),vector<int>(s.size(),0));
        //初始化dp数组,首先要将i和j相等的时候,也就是只有一个字符的子序列,它的dp值赋值为1
        for(int i=0;i<s.size();i++) dp[i][i]=1;
        //开始对dp数组进行从下到上,从左到右进行赋值。
        for(int i=s.size()-1;i>=0;i--){
            for(int j=i+1;j<s.size();j++){
                if(s[i]==s[j]) dp[i][j]=dp[i+1][j-1]+2;
                else dp[i][j]=max(dp[i][j-1],dp[i+1][j]);
            }
        }
        //最后,从0-s.size()-1这个范围的最长回文子序列的长度就是我们需要的答案。
        return dp[0][s.size()-1];
    }
};

注明

  • 本文中引用了作者
    代码随想录
    的部分图片和原文,若想深入了解,可以去原作者的文章阅读
  • 代码随想录