1. 哈希表
1. 哈希思想
1. 哈希表是数组的一种扩展,底层依赖数组支持按下标快速访问元素的特性.没有数组,就没有哈希表.
2. 哈希表利用的就是数组按照下标访问元素的时间复杂度是O(1)这一特性.
3. 哈希函数将元素的键值映射为下标.
2. 哈希函数
1. 哈希函数是一个函数,经过哈希函数,元素的键值被转换为数组下标/int.
2. 哈希函数的基本要求
- 哈希函数生成的哈希值必须>=0 . 因为是数组的下标.
- 若k1=k2, 则 hash(k1)==hash(k2)
- 若k1!=k2, 则 hash(k1)!=hash(k2)
- 找到一个完全没有哈希冲突的哈希函数几乎是不可能的.
- 数组的空间有限,也会加大哈希冲突的概率
3. 哈希冲突的解决方法
1. 开放寻址法
2. 链表法
- 链表法是更加常用的解决哈希冲突的方法,将哈希值相同的元素放到同一个槽对应的链表上.
- 哈希表中槽的个数:m , 哈希表中已存储的数据个数:n , 装载因子k = n/m . 装载因子越大,说明链表长度越大,哈希表的性能越低.
2. 如何打造一个工业级的哈希表
1. 设计合适的哈希函数
1. 哈希函数要尽量简单,降低性能损耗
2. 哈希函数生成的哈希值要尽量随机且均匀分布,避免哈希值过度集中导致的链表长度过长性能退化
2. 解决装载因子过大,引入动态扩容
1. 装载因子超过阈值,就要触发自动扩容,避免链表过长性能下降.
3. 避免低效的扩容
1. 为了解决集中扩容耗时过多,我们将扩容操作穿插到多次插入操作中.
2. 当装载因子达到阈值,我们先申请内存创建一个新的哈希表,但并不将原哈希表中的数据立即搬移到新的哈希表中.
3. 每次新数据插入,都将其插入到新的哈希表,并从老哈希表中搬移一个数据到新哈希表中.
4. 这样每次插入新数据都很快,但是查询操作需要同时在新旧两个哈希表中进行查询,直到老哈希表中的数据已经完全搬移完毕,才会释放其内存空间.
4. 选择合适的哈希冲突解决方式
1. 开放寻址法
- 开放寻址法数据都存在数组中,可以有效的利用CPU缓存,加快查询速度.同时不涉及链表及指针,方便序列化.
- 开发寻址法解决哈希冲突,删除数据比较麻烦,不能直接删除,需要进行标记.
- 因为所有元素都存在数组中,所以装载因子必须<1, 而链表法链表的长度可以>1 , 这导致存储相同数量的元素,开放寻址法比链表法占用更多存储空间.
- 当数据量比较小,装载因子小的时候,适合开放寻址法.
- Java中的ThreadLocalMap使用开放寻址法解决哈希冲突.
2. 链表法
- 链表法解决哈希冲突的哈希表,数据都存储于链表中,链表可以用到时再创建,不需要提前占用内存空间,所以链表法比较省内存空间.
- 链表法在数据量增大时,只要哈希值比较均匀,性能下降不严重,而开放寻址法数据量大时候哈希冲突严重,性能退化严重.
- 链表法可以将链表转换为其他更高效的结构,如红黑树,避免链表过长导致的查询效率下降过多,红黑树的查询时间复杂度是O(logn).
5. Java中的HashMap
1. 初始大小是16,可以设置
2. 装载因子及动态扩容
- 默认装载因子是0.75
3. 哈希冲突解决方法是链表法
- 当链表太长,链表会转化为红黑树
- 当红黑树中结点数量很小,又会转化为链表
- 为什么要互相转换: 因为数据量很小时,红黑树的性能优势不明显,且维护红黑树的平衡比较消耗性能.
4. 哈希函数:HashMap的哈希函数追求的是简单高效,分布均匀.
3. LRU缓存淘汰算法
public class LRUCache{
public class DLinkedNode{
public int key;
public int value;
public DLinkedNode prev;
public DLinkedNode next;
public DLinkedNode(int key, int value){
this.key = key;
this.value = value;
}
}
private Map<Integer,DLinkedNode> cache = new HashMap<Integer,DLinkedNode>();
private int size;
private int capacity;
private DLinkedNode head;
private DLinkedNode tail;
public LRUCache(int capacity){
this.size = 0;
this.capacity = capacity;
this.head = new DLinkedNode(-1,-1);
this.tail = new DLinkedNode(-1,-1);
this.head.next = tail;
this.head.prev = null;
this.tail.next = null;
this.tail.prev = head;
}
public int get(int key){
if(size == 0){
return -1;
}
DLinkedNode node = cache.get(key);
if(node == null){
return -1;
}
removeNode(node);
addNodeAtHead(node);
return node.value;
}
public void put(int key, int value){
DLinkedNode node = cache.get(key);
if(node != null){
node.value = value;
removeNode(node);
addNodeAtHead(node);
return;
}
if(size == capacity){
cache.remove(tail.prev.key);
removeNode(tail.prev);
size--;
}
DLinkedNode newNode = new DLinkedNode(key,value);
addNodeAtHead(newNode);
cache.put(key,newNode);
size++;
}
public void remove(int key){
DLinkedNode node = cache.get(key);
if(node != null){
cache.remove(node.key);
removeNode(node);
size--;
}
}
public void removeNode(DLinkedNode node){
node.prev.next = node.next;
node.next.prev = node.prev;
}
public void addNodeAtHead(DLinkedNode node){
node.next = head.next;
node.next.prev = node;
head.next = node;
node.prev = head;
}
}
1. Java中的LinkedHashMap验证
public void f1() {
LinkedHashMap<String, Integer> map = new LinkedHashMap<String, Integer>() {
private static final long serialVersionUID = 1L;
@Override
protected boolean removeEldestEntry(java.util.Map.Entry<String, Integer> eldest) {
return size() >= 8;
}
};
for (int i = 0; i < 20; i++) {
map.put("" + i, i);
}
for (Map.Entry<String, Integer> item : map.entrySet()) {
System.out.println("key:"+item.getKey() + " ; value:"+item.getValue());
}
}
//打印结果
key:13 ; value:13
key:14 ; value:14
key:15 ; value:15
key:16 ; value:16
key:17 ; value:17
key:18 ; value:18
key:19 ; value:19
public void f1() {
LinkedHashMap<String, Integer> map = new LinkedHashMap<String, Integer>(16, 1.0F, true) {
private static final long serialVersionUID = 1L;
@Override
protected boolean removeEldestEntry(java.util.Map.Entry<String, Integer> eldest) {
return size() >= 8;
}
};
for (int i = 0; i < 20; i++) {
map.put("" + i, i);
}
map.get("19");
map.get("18");
map.get("17");
Iterator<Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Entry<String, Integer> item = iterator.next();
System.out.println("key:" + item.getKey() + " ; value:" + item.getValue());
}
}
//打印结果
key:13 ; value:13
key:14 ; value:14
key:15 ; value:15
key:16 ; value:16
key:19 ; value:19
key:18 ; value:18
key:17 ; value:17
1. Java中的LinkedHashMap,可以根据访问顺序进行遍历.
2. 每插入1个数据,若数据存在,就将该数据移动到双向链表的tail. 若数据不存在,直接将新的结点放到tail.
3. 每访问1个数据,和插入一样,若数据存在就将其移动到tail.
4. LinkeHashMap遍历,从head到tail进行遍历,也就是按照'访问顺序'进行遍历.
5. 若插入新的数据需要移除老数据,就从head的位置开始移除.
4. 位图
1. 时间复杂度不能完全代表代码的执行时间,时间复杂度只是表示时间随数据规模的变化趋势,并不能度量在特定数据规模下,代码执行的时间具体是多少.如果时间复杂度的系数是10,通过优化后降低为1,也是巨大的性能提升.
2. 有1000万个范围在1-1亿的整数,取另一个1-1亿的整数,如何判断该整数是否在那1000万个整数中?
- 如果使用哈希表,1000万个整数,1000万*int(4字节) = 4000万byte = 4万KB = 40M
- 如果创建1个boolean数组,数组长度是1亿,每个索引下元素的值,代表该索引对应的整数是否存在,需要占用1亿*boolean. Java中1个boolean占用1个byte. 1亿byte = 10万KB = 100M
- 答疑 | boolean类型占几个字节?
- 在符合JVM规范的虚拟机中,
- 如果boolean是单独使用:boolean占4个字节。
- 如果boolean是以“boolean数组”的形式使用:boolean占1个字节
- 换一种思路,char占2个字节,2个字节就是16个二进制位,1亿个二进制位,只需要625W个char.仅需要12.5M.
public class Bitmap{
private char[] bytes;
private int nbits;
public Bitmap(int nbits){
this.nbits = nbits;
this.bytes = new char[nbits/16 + 1];
}
public void set(int k){
if(k > nbits){
return;
}
int byteIndex = k / 16;
int bitIndex = k % 16;
bytes[byteIndex] |= (1 << bitIndex);
}
public boolean get(int k){
if(k > nbits){
return false;
}
int byteIndex = k / 16;
int bitIndex = k % 16;
return (bytes[byteIndex] & (1 << bitIndex)) != 0;
}
}
3. 位图本质就是使用1个二进制位来表示1个数字,用于判定指定范围内某个数组是否存在.
1. 当数据范围不大的情况下,使用位图可以节省内存.
2. 当数据范围非常大,远超过数据数量的时候,因为位图需要数据范围个二进制位,会导致位图/数组占用的内存比哈希表等数据结构更大!
5. 布隆过滤器
1. 布隆过滤器就是为了解决在数据范围太大情况下,使用位图占用内存不升反降的问题.
2. 布隆过滤器的要点
- 底层依然是基于数组
- 布隆过滤器相当于是位图的加强版,使用多个hash函数对同一个值获取多个哈希值/index.
- 在获取到的多个index位置上都存储数据.
- 使用布隆过滤器,可以使用更短长度的数组存储数据.
- 两个不同的数字,在定长数组限制下,可能产生相同的哈希值,但是多个哈希函数得到的哈希值都相同的概率极低.
- 布隆过滤器可能误判
- 如果1个值经布隆过滤器判断不存在,一定不存在.
- 如果1个值本来不存在,经布隆过滤器判断,可能是存在的.
3. 布隆过滤器适合对误判有一定容忍度,不需要完全准确的大规模判重场景. 例如网络爬虫的网址判重场景,可能有数十亿,数百亿的网址,需要判重.
4. Java中的BitSet就是布隆过滤器.
6. 哈希算法的应用
1. 哈希算法的定义
将任意长度的二进制值串映射为固定长度的二进制值串.
2. 哈希算法的要求
- 单向性,无法反向推导
- 对数据变化高度敏感,只要1个二进制位改变,结果就完全不一样
- 性能好,执行快速
- 哈希冲突概率小
3. 哈希算法的应用
- 安全加密 / MD5,SHA,DES,AES
- 唯一标识 / 对海量文件进行唯一性标识
- 数据/文件校验. 例如多线程下载多个文件块,可以将每个文件块的哈希值标明,客户端下载成功后自行计算后校验.
- 哈希函数本身就是哈希算法的应用
- 负载均衡
- 将客户端IP或会话ID计算哈希值,与服务器的个数取余,得到由哪一台服务器处理指定链接.
- 数据分片 / 就是将海量的文件路径,根据文件哈希值,将hash-路径构建的哈希表存储到不同的机器上,用于高速的查询.
- 分布式存储
- 类似于数据分片,但分布式存储经常用于分布式缓存,将海量的文件/数据本身 根据其hash值存储到不同服务器.
4. 哈希算法和密码安全
存储密码明文 -> 存储密码的哈希值 -> 彩虹表手机常见密码哈希值可破解简单密码 -> 使用密码 + 盐 以指定方法混合后再计算哈希值的方法,对抗彩虹表.
7. 树
1. 树是一种非线性表.
1. 节点的高度: 节点到叶子节点的最长路径长度
2. 节点的深度: 根节点到这个结点的路径长度
3. 节点的层: 节点的深度 + 1
4. 树的高度: 根节点的高度
2. 二叉树
1. 二叉树: 每个节点最多有左右两个子节点.
2. 满二叉树: 叶子节点都在最底层,除叶子节点外,其他所有结点都有2个子节点.
3. 完全二叉树: 叶子节点都分布在底层及倒数第二层,且底层的叶子节点都是靠左排列.
- 满二叉树是完全二叉树的一种特殊情况
3. 二叉树的存储
1. 基于指针的链式存储方式
2. 基于数组的顺序存储方式
3. 基于数组的顺序存储方式,根节点下标从1开始,这样其他所有节点的下标值计算都更简单/性能更好
- 根节点下标从1开始,所有节点的 左子节点下标: i2, 右子节点下标: i2+1 , 父节点下标: i/2
- 根节点下标从0开始,所有节点的 左子节点下标: i*2+1, 右子节点下标: (i+1)*2 , 父节点下标: (i-1)/2
4. 对于完全二叉树,基于数组存储,更节省内存,因为元素间不会留空, 对于非完全二叉树,往往使用链式存储方式存储.
4. 二叉树的遍历
1. 前序遍历 : 自身 -> 左子树 -> 右子树
2. 中序遍历 : 左子树 -> 自身 -> 右子树
3. 后序遍历 : 左子树 -> 右子树 -> 自身
//前序遍历
public void preOrder(Node node){
if(node == null){
return;
}
sys:node.data;
preOrder(node.left);
preOrder(node.right);
}
//中序遍历
public void inOrder(Node node){
if(node == null){
return;
}
inOrder(node.left);
sys:node.data;
inOrder(node.right);
}
//后序遍历
public void postOrder(Node node){
if(node == null){
return;
}
postOrder(node.left);
postOrder(node.right);
sys:node.data;
}
8. 二叉查找树
1. 二叉查找树是为了实现快速查找产生的.不仅支持快速查找,还支持快速插入,删除.
2. 二叉查找树的任意一个节点,其左子树中每个节点的值都小于这个结点的值; 右子树中每个节点的值都大于这个结点的值.
3. 二叉查找树的查找,插入,删除
1. 查找
public Node find(int data){
Node c = root;
while(c != null){
if(c.data > data){
c = c.left;
}else if(c.data < data){
c = c.right;
}else{
return c;
}
}
return null;
}
2. 插入
public void insert(int data){
if(root == null){
root = new Node(data);
return;
}
Node c = root;
while(c != null){
if(c.data > data){
if(c.left == null){
c.left = new Node(data);
return;
}
c = c.left;
}else{
if(c.right == null){
c.right = new Node(data);
return;
}
c = c.right;
}
}
}
3. 删除
要删除一个节点有如下几种情况:
- 要删除的节点不存在
- return
- 要删除的节点无子节点
- 父节点对应指针置空
- 要删除的节点只有1个子节点
- 父节点对应指针指向该结点的子节点
- 要删除的结点有2个子节点
- 找到该节点右子树中最小的叶子节点min
- 替换两者的值,然后删除min即可
public void delete(int data){
if(root == null){
return;
}
Node p = root;
Node pp = null;
while(p != null && p.data != data){
pp = p;
if(data > p.data){
p = p.right;
}else{
p = p.left;
}
}
if(p == null){
return;
}
if(p.left != null && p.right != null){
Node min = p.right;
Node minP = p;
while(min.left != null){
minP = min;
min = min.left;
}
p.data = min.data;
p = min;
pp = minP;
}
//
Node child = null;
if(p.left != null){
child = p.left;
}else if(p.right != null){
child = p.right;
}
if(pp == null){
//说明删除的是根节点
//这里觉得有问题,删除根节点的情况,pp也不应该是null
root = child;
}else if(pp.left == p){
pp.left = child;
}else{
pp.right = child;
}
}
4. 中序遍历二叉查找树,可以从小到大有序输出数据.
4. 哈希表不能取代二叉查找树的原因
- 哈希表中数据是无序的,而二叉查找树只要中序遍历就可以有序输出数据.
- 哈希表扩容耗时较多,当遇到哈希冲突性能不稳定.而平衡的二叉查找树性能很稳定,logn.
- 哈希表的构造比二叉查找树复杂,哈希函数的设计,冲突解决,扩容,缩容等.而平衡二叉查找树仅需要考虑左右子树高度的平衡.
- 二叉查找树时间复杂度是O(logn),性能非常好,且不涉及哈希计算,不涉及哈希冲突的解决,在数据规模不大情况下,比O(1)的时间复杂度性能更好.
9. 平衡二叉查找树
1. 树 -> 二叉树 -> 二叉查找树 -> 平衡二叉查找树 -> 红黑树
2. 平衡二叉查找树的严格定义
- 首先是二叉查找树.
- 任意节点的左右子树高度相差不能大于1.
3. 平衡二叉查找树的平衡,目的就是让整棵树尽可能的矮,左右尽可能对称,避免高度太大导致的性能退化.只要一个二叉查找树的高度不比logn大很多,就可以认为属于平衡二叉查找树.
4. 红黑树是一种近似平衡的二叉查找树,为了解决数据动态更新导致的二叉查找树性能退化而创造出来.
- 红黑树维护平衡的成本相对严格意义的平衡二叉查找树更低,但性能损失并不大.
- 大多数语言提供了封装好的红黑树实现类.
10. B+树
1. B+树是适用于构建数据库索引的一种数据结构
2. 数据库索引的要求:
- 按照值查找数据
- 按照区间查找数据
- 按照区间从大到小,从小到大双向查找数据
3. 目前学到的数据结构,能高效查询,且按顺序输出数据的,就是二叉查找树,但是二叉查找树不能实现按照区间双向查询. B+树就是从二叉查找树改造而来.
- 首先在二叉查找树底层加1个双向有序链表
- 将二叉树改变为多叉树
- 多叉树的好处是,可以降低树的高度
- 索引的数据量非常大,不可能全存在内存中,肯定要存在磁盘中,树的高度太大,每一层结点都要重新访问一次磁盘.多叉树有效降低了IO访问次数.
- 一般情况,B+树的根节点被存在内存中,其他结点存储在磁盘中.