🚩DS - 数据结构
树
🌟比较不同的树
- 二叉查找树(BST) :解决了排序的基本问题,但是由于无法保证平衡,可能退化为链表;
- 平衡二叉树(AVL) :通过旋转解决了平衡的问题,但是旋转操作效率太低;
- 🌟红黑树 :通过舍弃严格的平衡和引入红黑节点,解决了 平衡二叉树旋转效率过低的问题,但是在磁盘等场景下,树仍然太高,IO 次数太多;
- B 树 :通过将二叉树改为多路平衡查找树,解决了树过高的问题;
- B+树 :在 B 树的基础上
- 将非叶节点改造为不存储数据的纯索引节点,适合放入内存中;
- 进一步降低了树的高度,查询性能好;
- 叶节点使用指针连接成链表,范围查询更加高效。
- 跳表:用于 Redis 的 zset,解决了链表查询效率低下的问题。
红黑树
什么是红黑树?
自平衡的二叉查找树,在进行插入和删除等可能会破坏树的平衡的操作时,需要重新自处理达到平衡状态。
- 根和叶子节点是黑色的,插入的节点是红色的。
- 叶子节点是空节点。
- 每个红色节点必有黑色子节点。
- 任意一结点到每个叶子结点的路径都包含数量相同的黑结点。
通过左旋、右旋和变色来实现自平衡:
左旋只影响旋转结点和其右子树的结构,把右子树的结点往左子树挪了。右旋只影响旋转结点和其左子树的结构,把左子树的结点往右子树挪了。
红黑树有什么好处?
- 红黑树是效率相对较高的当我们插入和删除数据相对频繁的时候
- 查询性能略微逊色于AVL树
- 红黑树是自我平衡的所有操作的复杂度最多是O(logn)
- 不管怎么变化,只有红黑两个常数,任何不平衡都会在三次旋转之内解决
红黑树场景
TreeSet、TreeMap、HashMap
前缀树
Trie(发音类似 “try”)或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补完和拼写检查。
请你实现 Trie 类:
Trie()
初始化前缀树对象。void insert(String word)
向前缀树中插入字符串word
。boolean search(String word)
如果字符串word
在前缀树中,返回true
(即,在检索之前已经插入);否则,返回false
。boolean startsWith(String prefix)
如果之前已经插入的字符串word
的前缀之一为prefix
,返回true
;否则,返回false
。
JDK 源码
Queue 有哪些?
- BlockingQueue:提供了线程安全的队列访问方式
- PriorityQueue:优先级队列是一个可以排序的队列
- Deque:一个线性 collection,支持在两端插入和移除元素。名称 deque 是“double ended queue(双端队列)”的缩写,通常读为“deck”。
Set 有哪些结构?
- HashSet:哈希表,无序
- LinkedHashSet:底层数据结构是双向链表和哈希表,有序。由链表保证元素有序,由哈希表保证元素唯一。
- TreeSet:底层数据结构是红黑树,内部实现排序,也可以自定义排序规则。
🌟ArrayList 源码分析
- 默认大小为 10(初始为 0,第一次添加变为 10)
- 每次扩容 1.5 倍:0 -> 10 -> 15 -> 22 -> 33 -> 49 …
- 数组 -> list:Arrays.asList() 是浅拷贝,修改数组会影响 list,因为指向同一个内存地址
- list -> 数组:list.toArray() 是深拷贝,调用 Arrays.copyOf(data,size)
- ArrayList 是动态数组,LinkedList 是双向链表,都不是线程安全的,除非 Collections.synchronizedList()
🌟HashMap 源码分析
put 方法:
- table 为空或 ++size > *threshold(默认 12 = 数组长度(默认 16) 0.75) **时触发扩容机制 resize()
- 每次扩容 «1 (即*2)
- jdk8 之前头插法会导致死循环,jdk8 采用尾插法,避免扩容 resize() 迁移时的死循环
- hash(key) 得到数组索引
- 扰动算法 (hash^hash»>16)异或操作使 hash 值更均匀,减少 hash 冲突
- hash&(n-1), n 为 2n ,等价于 hash%n ,位运算性能更好
- key 存在则修改,不存在则添加
HashMap 1.7和1.8的区别?
JDK8 引入了红黑树:
- 链表 -> 红黑树:链表长度 >= 8 && 数组长度 >= 64 时
- 红黑树 -> 链表:resize()扩容时,树节点<=6
ConcurrentHashMap 源码分析
锁分段技术?/ HashMap 1.7和1.8的区别?
原理:把数据分成很多段储存,每一段都对应一把锁,读取数据不加锁,写操作时能够将锁的粒度保持尽量的小,不用对整个ConcurrentHashMap加锁。
- JDK7:Segment + HashEntry 来保证线程安全 【不可扩容】
- Segment extends ReentrantLock(继承可重入锁)
- HashEntry则用于存储键值对数据
- 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组中的数据进行修改时,必须先获得它对应的Segment锁
- JDK8:CAS + synchronized 来保证线程安全,减少了锁的粒度
- 数组+链表:类似 HashMap,长度>8 变为红黑树【处理哈希冲突】
transient volatile int count
:volatile的语义可以保证写操作在读操作之前,也就保证了写操作对后续的读操作都是可见的,这样后面get的后续操作就可以拿到完整的元素内容- 对每个数组元素Node加锁,锁的粒度更细,并发度更高(只要 hash 不冲突就无并发问题)。
【get】为什么读操作不加锁?
因为Node的元素和指针是用volatile修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。此外,volatile修饰数组对get操作没有效果,但可以在扩容的时候对其他线程具有可见性。 volatile关键字:通过禁止指令重排,保证可见性、有序性。但不保证原子性。使用volatile关键字会强制将修改的值立即写入主存,并导致缓存变量的缓存行无效。 但是,当节点是红黑树的时候,如果树正在变色旋转并且错查询的值不是红黑树的头节点,会加读写锁。
【put】如何保证插入数据线程安全?
首先判空,非空情况下使用**扰动函数(高16位与低16位做异或,再和Hash_bit做与运算)**计算key的hash值,
- 初始化数组采用懒汉式(第一次put时才初始化),线程安全通过一个volatile修饰的变量sizeCtl结合CAS来实现
- 当寻址后的数组位置没有被占用时,直接基于主内存地址,通过CAS将创建的Node节点插入到当前位置
- 当寻址到的位置正在进行扩容/迁移操作时(hash=MOVED=-1),执行
helpTransfer
来协助其他线程进行扩容/迁移- 当 sizeCtl 为负值(
-(1+n)
)时,表示有 n 个线程正在扩容 - 迁移:先计算当前线程需要转移对节点范围,再进行数据迁移
- 当 sizeCtl 为负值(
- 当出现hash冲突时,对单个数组元素加 synchronized 锁,确保线程安全地将节点插入到链表或红黑树(链表长>8时调用
treeifyBin(tab,i)
转红黑树)中,若key存在替换旧值,若key不存在将kv追加到链表尾部
ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?
迭代器iterator是弱一致性的,因为在迭代过程中可以向map中添加元素;而HashMap是强一致性的。
HashSet
- 底层基于 HashMap 的 keySet 来实现
- 如果添加的值相同,保留最新的,因为HashMap 的底层逻辑是:存在则修改,不存在则添加。
基础
排序算法比较
稳定排序算法的优点在于可以保持相等元素的相对顺序,这在某些应用场景下非常重要,例如按照年龄和姓名对学生名单排序时,如果年龄相同,则应该按照姓名的字母顺序排序,这就需要使用稳定排序算法。
贪心算法 vs 动态规划?
站在全局角度来看,对于每个可选项,贪心算法是只保留一种决策(要么最优,要么非最优) ,动态规划保留了它的一个决策集,从中取最优。 当然,算法有优势就有劣势,贪心算法虽然适用条件苛刻,但复杂度低,当可选集太大时,动态规划这种穷举策略会比较耗时,而贪心算法虽然并不一定能找到最优解,但也可以找到一个接近最优解的解。 综上,贪心算法选的是当前最优解,而动态规划是通过子问题的最优解推出当前的最优解。