当前位置: 首页 > ops >正文

数据结构之Map和Set

系列文章目录

数据结构之ArrayList_arraylist o(1) o(n)-CSDN博客

数据结构之LinkedList-CSDN博客

数据结构之栈_栈有什么方法-CSDN博客

数据结构之队列-CSDN博客

数据结构之二叉树-CSDN博客

数据结构之优先级队列-CSDN博客

常见的排序方法-CSDN博客


目录

系列文章目录

前言

一、二叉搜索树

1. 查找节点

2. 插入节点

3. 删除节点

4. 二叉搜索树的性能

二、TreeMap和TreeSet

1. Map接口

1. 常用方法

2. 注意事项

3. TreeMap 和 HashMap 的区别

2. Set接口

1. 常用方法

2. 注意事项

3. TreeSet 和 HashSet 的区别

三、哈希表

1. 哈希冲突

2. 哈希桶的模拟实现

3. 泛型哈希桶的实现

4. 哈希表源码



前言

本文系统介绍了常见数据结构及其实现原理,主要包括二叉搜索树、哈希表、TreeMap/TreeSet等内容。详细讲解了二叉搜索树的查找、插入、删除操作及其时间复杂度分析;对比了TreeMap与HashMap、TreeSet与HashSet的特性差异;深入剖析了哈希冲突的解决策略,包括开散列法的具体实现。文章还解读了JDK中HashMap的核心源码,包括初始化、扩容机制、树化条件等关键实现细节。通过理论分析与代码示例相结合的方式,全面阐述了这些数据结构的底层实现原理和应用场景。


一、二叉搜索树

BinarySearchTree: 二叉搜索树的类;

TreeNode: 静态内部类,用于创建树的节点;

root: 表示二叉搜索树的根节点;

public class BinarySearchTree {static class TreeNode{public int val;public TreeNode left;public TreeNode right;public TreeNode(int val){this.val = val;}}public TreeNode root = null;// 二叉搜索树的方法// ......
}

1. 查找节点

二叉搜索树左子树的节点值都比根节点小,右子树的节点值都比根节点大;

search(int val): boolean

定义节点 cur 用于遍历二叉搜索树,如果要查找的值 val 大于当前节点 cur 的值,去右子树找,如果 val 小于当前节点 cur 的值,去左子树找,找到返回 true,没找到返回 false;

    public boolean search(int val){TreeNode cur = root;while(cur != null){if(cur.val < val){cur = cur.right;}else if(cur.val > val){cur = cur.left;}else{return true;}}return false;}

2. 插入节点

insert(int val): boolean

定义节点 cur 用于遍历二叉搜索树,定义节点 parent,表示 cur 的父节点;

如果要查插入的值 val 大于当前节点 cur 的值,parent 指向 cur,cur 去右子树继续搜索;

如果要查插入的值 val 小于当前节点 cur 的值,parent 指向 cur,cur 去左子树继续搜索;

如果找到这个值为 val 的节点,表示这个节点已经存在了,不能再进行插入,因此返回 false;

如果 cur 为 null,表示这个位置就是新插入节点的位置,使用 parent 指向它即可;

    public boolean insert(int val){TreeNode node = new TreeNode(val);if(root == null){root = node;return true;}TreeNode parent = null;TreeNode cur = root;while(cur != null){if(cur.val < val){parent = cur;cur = cur.right;}else if(cur.val > val){parent = cur;cur = cur.left;}else{return false;}}if(parent.val < val){parent.right = node;}else{parent.left = node;}return true;}

3. 删除节点

remove(int val): void 搜索值为 val 的节点,搜索到执行删除逻辑;

removeNode(TreeNode parent, TreeNode cur): void 删除 cur 节点;

删除逻辑:

找到要删除的节点 cur;

分以下三种情况进行讨论:

  • 1. cur.left == null;
  • 2. cur.right == null;
  • 3. cur.left != null && cur.right != null;

每种情况又分三种情况进行讨论:

  • cur 节点是二叉搜索树的根节点;
  • cur 节点是 parent 节点的左子树;
  • cur 节点是 parent 节点的右子树;

讨论第一种情况 cur.left == null:

  • 如果 cur 是二叉搜索树的根节点,直接让 root = cur.right 即可;
  • 如果 cur 是 parent 节点的左子树,直接让 parent.left = cur.right 即可;
  • 如果 cur 是 parent 节点的右子树,直接让 parent.right = cur.right 即可;

讨论第二种情况 cur.right == null:

  • 如果 cur 是二叉搜索树的根节点,直接让 root = cur.left 即可;
  • 如果 cur 是 parent 节点的左子树,直接让 parent.left = cur.left 即可;
  • 如果 cur 是 parent 节点的右子树,直接让 parent.right = cur.left 即可;

讨论第三种情况 cur.left != null && cur.right != null:

思路:以左子树最右边的节点或者右子树最左边的节点,代替 cur 节点,再删除左子树最右边的节点或者右子树最左边的节点;

下面以找右子树最左边的节点为例:

定义 tp 指向 cur,t 指向 cur.right,持续向左搜索,直至 t.left == null,表示 t 就是右子树最左边的节点了,更新 cur 的值;

此时分两种情况:

  • 1. cur 右树的左子树不为空:此时 tp.left = t.right 即可;
  • 2. cur 右树的左子树为空:tp.right = t.right 即可;
    public void remove(int val){TreeNode cur = root;TreeNode parent = null;while(cur != null){if(cur.val > val){parent = cur;cur = cur.left;}else if(cur.val < val){parent = cur;cur = cur.right;}else{removeNode(parent, cur);}}}private void removeNode(TreeNode parent, TreeNode cur){if(cur.left == null){if(cur == root){root = cur.right;}else if(cur == parent.left){parent.left = cur.right;}else{parent.right = cur.right;}}else if(cur.right == null){if(cur == root){root = cur.left;}else if(cur == parent.left){parent.left = cur.left;}else{parent.right = cur.left;}}else{TreeNode tp = cur;TreeNode t = cur.right;while(t.left != null){tp = t;t = t.left;}cur.val = t.val;if(tp.right == t){tp.right = t.right;}else{tp.left = t.right;}}}

4. 二叉搜索树的性能

 如果二叉搜索树是一棵单分支树(右单树),那么查询元素的效率就会退化为 O(n);

因此为了解决这个问题引入了高度平衡的二叉搜索树(AVL树),AVL 树在插入节点或删除节点的时候会进行左旋转或右旋转,维持高度平衡的特性,以保证查询效率;

Java 当中 Map 和 Set 接口的底层实际上是红黑树,红黑树是一棵近似平衡的二叉搜索树;

二、TreeMap和TreeSet

1. Map接口

Map 是一个接口类,存储的是一个 <K, V> 结构的键值对,K 唯一,不能重复;

1. 常用方法

get(Object key): V 返回 key 对应的 value;

getOrDefaultValue(Object key, V defaultValue): V 返回 key 对应的 value,如果 key 不存在,就返回 defaultValue;

put(K key, V value): V 设置 key 对应的值为 value;

remove(Object key): V 删除 key 对应的映射;

keySet(): Set<K> 返回不重复的 key 的集合;

values(): Collection<V> 返回 value 的可重复集合;

entrySet(): Set<Map.Entry<K, V>> 返回所有的 key-value 映射关系;

containsKey(Object key): boolean 查询是否包含 key;

containsValue(Object value): boolean 查询是否包含 value;

2. 注意事项

Map 是接口,不能实例化对象,TreeMap 和 HashMap 实现了这个接口,可以实例化对象;

Map 中 key 是唯一的,value 是可以重复的;

在 TreeMap 中插入键值对,key 不能为空,且必须是可比较的,value 可以为空,但 HashMap 中的 key 和 value 都可以为空;

Map 中的 key 不能修改,如果要修改,只能删除再重新添加;

3. TreeMap 和 HashMap 的区别

  • TreeMap 底层是红黑树;
  • HashMap 底层是哈希桶;
  • TreeMap 插入,删除,查找元素的时间复杂度是 log(n);
  • HashMap 插入,删除,查找元素的时间复杂度是 O(1);
  • TreeMap 中的 key 是有序的;
  • HashMap 中的元素是无序的;
  • TreeMap 中元素的查找,插入,删除都是需要比较的;
  • HashMap 中元素的查找,插入,删除不需要比较(通过哈希函数计算哈希地址);
  • TreeMap 中的 key 必须要能够比较,否则会抛异常;
  • HashMap 中的自定义类型需要重写 equals() 和 hashCode() 方法;
  • TreeMap 适用于需要 key 有序的场景;
  • HashMap 适用于更高的时间性能的场景;

2. Set接口

Set 是继承 Collection 的接口类,其中只存储 key;

1. 常用方法

add(E e): boolean 添加元素,重复元素不能被添加成功;

clear(): void 清空集合;

contains(Object 0): boolean 判断 o 是否在集合中;

remove(Object o): boolean 删除集合中的 o;

size(): int 返回 set 中元素的个数;

isEmpty(): boolean 检查 set 是否为空;

toArray(): Object[] 将 set 中的元素作为数组返回;

containsAll(Collection<?> c): boolean 判断 c 中的元素是否在 set 中全部存在;

addAll(Collection<? extends E> c): boolean 将 c 中的元素添加到 set 中;

2. 注意事项

Set 是继承 Collection 的接口类;

Set 中只能存储 key,并且 key 唯一;

TreeSet 的底层是使用 Map 实现的,使用 key 与 Object 的一个默认对象作为键值对插入到 Map 中;

Set 的最大作用就是对集合中的元素去重;

实现 Set 接口的有 TreeSet 和 HashSet;

Set 中的 key 不能修改,如果要修改,只能删除后再重新添加;

TreeSet 不能插入空元素,HashSet 可以;

3. TreeSet 和 HashSet 的区别

  • TreeSet 底层是红黑树;
  • HashSet 底层是哈希桶;
  • TreeSet 插入,删除,查找元素的时间复杂度是 log(n);
  • HashSet 插入,删除,查找元素的时间复杂度是 O(1);
  • TreeSet 中的 key 是有序的;
  • HashSet 中的元素是无序的;
  • TreeSet 中元素的查找,插入,删除都是需要比较的;
  • HashSet 中元素的查找,插入,删除不需要比较(通过哈希函数计算哈希地址);
  • TreeSet 中的 key 必须要能够比较,否则会抛异常;
  • HashSet 中的自定义类型需要重写 equals() 和 hashCode() 方法;
  • TreeSet 适用于需要 key 有序的场景;
  • HashSet 适用于更高的时间性能的场景;

三、哈希表

哈希表通过哈希函数,可以使 key 和 value 建立一一对应的映射关系,因此在哈希表中,插入,删除,更新和查询元素的时间复杂度为 O(1);

1. 哈希冲突

哈希冲突:当两个不同的元素,通过哈希函数的计算,生成了相同的哈希地址,这时候就会产生哈希冲突;

避免哈希冲突的方式通常是合理设计哈希函数;

解决哈希冲突的方式有两种:闭散列和开散列;

闭散列:也叫开放定址法。当发生哈希冲突时,如果哈希表没有被装满,说明哈希表中一定还有空位置,因此把 key 放到冲突位置的下一个空位置去;

闭散列寻找下一个空位置常用的方式有线性探测以及二次探测;但是占用下一个空位置,必然会引发新的哈希冲突;

开散列:又叫链地址法(开链法)。首先对 key 进行哈希计算,如果产生相同的哈希地址,就把产生这个地址的多个元素都放入到一个集合里面,每一个这样的集合都称为桶,桶中的元素通过一个单链表串联起来,头结点存储在哈希表中;

2. 哈希桶的模拟实现

Node: 静态内部类,表示哈希表的节点,包括 key,value,next;

array: 存放节点的数组;

usedSize: 哈希表中元素的个数;

hashBuck(): 哈希表的构造方法,默认开辟的数组长度是 10;

public class HashBuck {static class Node{public int key;public int val;public Node next;public Node(int key, int val){this.key = key;this.val = val;}}private Node[] array;private int usedSize;public HashBuck(){this.array = new Node[10];}// 哈希表的方法// ......
}

哈希表元素的插入逻辑:

首先使用哈希函数计算 key 对应的哈希值,哈希值模上数组的长度,得到存放的数组下标 ;

得到下标后,将元素插入到 index 位置的链表中,可以头插,也可以尾插;

插入完成后,计算负载因子 usedSize / array 数组的长度;

如果负载因子大于等于 0.75,进行扩容,扩容的时候要注意重新哈希;

如果负载因子小于 0.75,什么也不用做;

    public void put(int key, int val){int index = key % this.array.length;Node cur = this.array[index];while(cur != null){if(cur.key == key){cur.val = val;return;}cur = cur.next;}Node node = new Node(key, val);node.next = this.array[index];this.array[index] = node;this.usedSize++;double loadFactor = loadFactor();if(loadFactor >= 0.75){resize();}}private double loadFactor(){return this.usedSize * 1.0 / this.array.length;}private void resize(){int size = 2 * this.array.length;Node[] tmpArr = new Node[size];for(int i = 0; i < array.length; i++){Node cur = array[i];while(cur != null){Node curNext = cur.next;int newIndex = cur.key % size;cur.next = tmpArr[newIndex];tmpArr[newIndex] = cur;cur = curNext;}}this.array = tmpArr;}

哈希表的查询逻辑:

 根据给定的 key 计算数组的下标 ;

遍历下标 index 对应链表,判断是否存在 key,存在返回对应 val,不存在返回 -1;

    public int get(int key){int index = key % this.array.length;Node cur = this.array[index];while(cur != null){if(cur.key == key){return cur.val;}cur = cur.next;}return -1;}

3. 泛型哈希桶的实现

泛型哈希桶的实现需要注意,元素之间比较不能使用等号,而是需要使用 equals() 方法;

如果是自定义类型,需要重写 equals() 方法 和 hashCode() 方法;

public class HashBuckUpgrade<K, V> {static class Node<K, V>{public K key;public V val;public Node<K, V> next;public Node(K key, V val){this.key = key;this.val = val;}}private Node<K, V>[] array;private int usedSize;public HashBuckUpgrade(){this.array = new Node[10];}public void put(K key, V val){int hash = key.hashCode();int index = hash % this.array.length;Node cur = this.array[index];while(cur != null){if(cur.key.equals(key)){cur.val = val;return;}cur = cur.next;}Node<K, V> node = new Node<>(key, val);if(this.array[index] == null){this.array[index] = node;}else{cur = this.array[index];while(cur.next != null){cur = cur.next;}cur.next = node;}this.usedSize++;double loadFactor = loadFactor();if(loadFactor >= 0.75){resize();}}private double loadFactor(){return this.usedSize * 1.0 / this.array.length;}private void resize(){int size = 2 * this.array.length;Node<K, V>[] tmp = new Node[size];for(int i = 0; i < this.array.length; i++){Node<K, V> cur = this.array[i];while(cur != null){int hash = cur.key.hashCode();int newIndex = hash % tmp.length;Node curNext = cur.next;if(tmp[newIndex] == null){tmp[newIndex] = cur;}else{Node<K, V> t = tmp[newIndex];while(t.next != null){t = t.next;}t.next = cur;}cur.next = null;cur = curNext;}}this.array = tmp;}public V get(K key){int hash = key.hashCode();int index = hash % this.array.length;Node<K, V> cur = this.array[index];while(cur != null){if(cur.key.equals(key)){return cur.val;}cur = cur.next;}return null;}
}

equals() 和 hashCode() 的区别:

hashCode 用于生成哈希值,哈希值用于计算哈希数组的下标;

按照下标找到哈希桶后,需要遍历,元素之间需要使用 equals() 方法进行比较;

4. 哈希表源码

属性:

DEFAULT_INITIAL_CAPACITY:哈希表的默认大小为 16;

 MAXIMUM_CAPACITY:哈希表最大容量;

DEFAULT_LOAD_FACTOR:负载因子;

TREEIFY_THRESHOLD:链表的长度超过 8,数组的长度超过 64,会发生树化;

UNTREEIFY_THRESHOLD:当链表的长度小于 6,解树化;

MIN_TREEIFY_CAPACITY:当数组的长度超过 64,链表的长度超过 8,会发生树化;

public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16static final int MAXIMUM_CAPACITY = 1 << 30;static final float DEFAULT_LOAD_FACTOR = 0.75f;static final int TREEIFY_THRESHOLD = 8;static final int UNTREEIFY_THRESHOLD = 6;static final int MIN_TREEIFY_CAPACITY = 64;
}

构造方法:

HashMap(int initialCapacity, float loadFactor): 

如果默认容量小于 0,抛出异常;

如果默认容量超过最大容量,赋值为最大容量; 

如果负载因子小于等于 0,抛出异常;

如果给定一个容量,会调用 tableSizeFor() 方法,给分配一个接近 2^n 的容量,2^n >= initialCapacity;

虽然计算完了容量,但是还是没有给数组分配内存,只有在添加元素的时候,才会调用 resize() 方法分配内存;

HashMap(): 调用无参的构造方法时,是不会分配内存的,只有在第一次插入元素的时候,才会调用 resize() 方法分配内存;

resize(): Node<K, V>[]:

如果数组为空,就根据容量进行初始化,给数组分配内存; 

    public HashMap(int initialCapacity, float loadFactor) {if (initialCapacity < 0)throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);if (initialCapacity > MAXIMUM_CAPACITY)initialCapacity = MAXIMUM_CAPACITY;if (loadFactor <= 0 || Float.isNaN(loadFactor))throw new IllegalArgumentException("Illegal load factor: " +loadFactor);this.loadFactor = loadFactor;this.threshold = tableSizeFor(initialCapacity);}public HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);}public HashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted}final Node<K,V>[] resize() {Node<K,V>[] oldTab = table;int oldCap = (oldTab == null) ? 0 : oldTab.length;int oldThr = threshold;int newCap, newThr = 0;if (oldCap > 0) {if (oldCap >= MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return oldTab;}else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // double threshold}else if (oldThr > 0) // initial capacity was placed in thresholdnewCap = oldThr;else {               // zero initial threshold signifies using defaultsnewCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}if (newThr == 0) {float ft = (float)newCap * loadFactor;newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);}threshold = newThr;@SuppressWarnings({"rawtypes","unchecked"})Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;if (oldTab != null) {for (int j = 0; j < oldCap; ++j) {Node<K,V> e;if ((e = oldTab[j]) != null) {oldTab[j] = null;if (e.next == null)newTab[e.hash & (newCap - 1)] = e;else if (e instanceof TreeNode)((TreeNode<K,V>)e).split(this, newTab, j, oldCap);else { // preserve orderNode<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;elseloTail.next = e;loTail = e;}else {if (hiTail == null)hiHead = e;elsehiTail.next = e;hiTail = e;}} while ((e = next) != null);if (loTail != null) {loTail.next = null;newTab[j] = loHead;}if (hiTail != null) {hiTail.next = null;newTab[j + oldCap] = hiHead;}}}}}return newTab;}

在哈希表中放元素:

hash(Object key): int 调用对象的 hashCode() 方法计算 key 的哈希值,将高 16 位和低 16 位异或,目的是让哈希值在数组中的分布更加均匀;

putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict): V

如果数组为空,会调用 resize() 方法进行扩容,给数组分配内存;

计算节点存放的下标,如果下标中的结点是空,直接把该节点放到数组中在,作为头结点;

如果下标中的节点不为空,判断是不是树的节点,如果是树的节点,就把新节点插入到树中;

如果是链表的节点,执行尾插法,把新节点插入到链表的后面;如果链表的长度大于等于 8,就调用 treeifBin() 方法,可能会进行树化;

    public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)resize();afterNodeInsertion(evict);return null;}

treeifBin(Node<K, V>[] tab, int hash): void

如果数组为空或者数组的长度小于 64,就调用 resize() 方法,进行扩容,否则就执行树化操作;

    final void treeifyBin(Node<K,V>[] tab, int hash) {int n, index; Node<K,V> e;if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)resize();else if ((e = tab[index = (n - 1) & hash]) != null) {TreeNode<K,V> hd = null, tl = null;do {TreeNode<K,V> p = replacementTreeNode(e, null);if (tl == null)hd = p;else {p.prev = tl;tl.next = p;}tl = p;} while ((e = e.next) != null);if ((tab[index] = hd) != null)hd.treeify(tab);}}
http://www.xdnf.cn/news/14704.html

相关文章:

  • 打造地基: App拉起基础小程序容器
  • linux面试常考
  • 正交视图三维重建 笔记 2d线到3d线
  • 使用deepseek制作“喝什么奶茶”随机抽签小网页
  • Jina-Embeddings-V4:多模态向量模型的革命性突破与实战指南
  • Python生成器表达式最佳实践指南:何时使用与高效选择
  • Flutter基础(控制器)
  • Python基础(吃洋葱小游戏)
  • LINUX628 NFS 多web;主从dns;ntp;samba
  • WOE值:风险建模中的“证据权重”量化术——从似然比理论到FICO评分卡实践
  • SpringMVC系列(五)(响应实验以及Restful架构风格(上))
  • H6-108QB2W QILSTE/旗光
  • WebRTC(十二):DTLS
  • Cesium快速入门到精通系列教程十一:Cesium1.74中高性能渲染上万Polyline
  • 2025第十五届上海生物发酵展:江苏健达干燥盛装赴会
  • 数据结构:最小生成树—Prim(普里姆)与Kruskal(克鲁斯卡尔)算法
  • 使用asyncio构建高性能网络爬虫
  • Linux离线搭建Redis (centos7)详细操作步骤
  • Python助力自动驾驶:深度学习模型优化全攻略
  • Flutter基础(Riverpod)
  • 用AI给AR加“智慧”:揭秘增强现实智能互动的优化秘密
  • 【学习笔记】深入理解Java虚拟机学习笔记——第12章 Java内存模型与线程
  • RNN(循环神经网络)与LSTM(长短期记忆网络)输出的详细对比分析
  • 战神授权后台报错:Parse error: syntax error, unexpected end of file in解决办法
  • zookeeper Curator(3):Watch事件监听
  • 搭建Flink分布式集群
  • 深入详解:随机森林算法——概念、原理、实现与应用场景
  • Spring Cloud:高级特性与最佳实践
  • Python基础知识之文件
  • 深入剖析 CVE-2021-3560 与 CVE-2021-4034:原理、区别与联系