数据结构与算法 - 第二篇

268 阅读13分钟

1. 哈希表

1. 哈希思想

1. 哈希表是数组的一种扩展,底层依赖数组支持按下标快速访问元素的特性.没有数组,就没有哈希表.

2. 哈希表利用的就是数组按照下标访问元素的时间复杂度是O(1)这一特性.

3. 哈希函数将元素的键值映射为下标.

2. 哈希函数

1. 哈希函数是一个函数,经过哈希函数,元素的键值被转换为数组下标/int.

2. 哈希函数的基本要求

  1. 哈希函数生成的哈希值必须>=0 . 因为是数组的下标.
  2. 若k1=k2, 则 hash(k1)==hash(k2)
  3. 若k1!=k2, 则 hash(k1)!=hash(k2)
    • 找到一个完全没有哈希冲突的哈希函数几乎是不可能的.
    • 数组的空间有限,也会加大哈希冲突的概率

3. 哈希冲突的解决方法

1. 开放寻址法

2. 链表法

  1. 链表法是更加常用的解决哈希冲突的方法,将哈希值相同的元素放到同一个槽对应的链表上.
  2. 哈希表中槽的个数:m , 哈希表中已存储的数据个数:n , 装载因子k = n/m . 装载因子越大,说明链表长度越大,哈希表的性能越低.

2. 如何打造一个工业级的哈希表

1. 设计合适的哈希函数

1. 哈希函数要尽量简单,降低性能损耗

2. 哈希函数生成的哈希值要尽量随机且均匀分布,避免哈希值过度集中导致的链表长度过长性能退化

2. 解决装载因子过大,引入动态扩容

1. 装载因子超过阈值,就要触发自动扩容,避免链表过长性能下降.

3. 避免低效的扩容

1. 为了解决集中扩容耗时过多,我们将扩容操作穿插到多次插入操作中.

2. 当装载因子达到阈值,我们先申请内存创建一个新的哈希表,但并不将原哈希表中的数据立即搬移到新的哈希表中.

3. 每次新数据插入,都将其插入到新的哈希表,并从老哈希表中搬移一个数据到新哈希表中.

4. 这样每次插入新数据都很快,但是查询操作需要同时在新旧两个哈希表中进行查询,直到老哈希表中的数据已经完全搬移完毕,才会释放其内存空间.

4. 选择合适的哈希冲突解决方式

1. 开放寻址法

  1. 开放寻址法数据都存在数组中,可以有效的利用CPU缓存,加快查询速度.同时不涉及链表及指针,方便序列化.
  2. 开发寻址法解决哈希冲突,删除数据比较麻烦,不能直接删除,需要进行标记.
  3. 因为所有元素都存在数组中,所以装载因子必须<1, 而链表法链表的长度可以>1 , 这导致存储相同数量的元素,开放寻址法比链表法占用更多存储空间.
  4. 当数据量比较小,装载因子小的时候,适合开放寻址法.
  5. Java中的ThreadLocalMap使用开放寻址法解决哈希冲突.

2. 链表法

  1. 链表法解决哈希冲突的哈希表,数据都存储于链表中,链表可以用到时再创建,不需要提前占用内存空间,所以链表法比较省内存空间.
  2. 链表法在数据量增大时,只要哈希值比较均匀,性能下降不严重,而开放寻址法数据量大时候哈希冲突严重,性能退化严重.
  3. 链表法可以将链表转换为其他更高效的结构,如红黑树,避免链表过长导致的查询效率下降过多,红黑树的查询时间复杂度是O(logn).

5. Java中的HashMap

1. 初始大小是16,可以设置

2. 装载因子及动态扩容

  • 默认装载因子是0.75

3. 哈希冲突解决方法是链表法

  1. 当链表太长,链表会转化为红黑树
  2. 当红黑树中结点数量很小,又会转化为链表
  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万个整数中?

  1. 如果使用哈希表,1000万个整数,1000万*int(4字节) = 4000万byte = 4万KB = 40M
  2. 如果创建1个boolean数组,数组长度是1亿,每个索引下元素的值,代表该索引对应的整数是否存在,需要占用1亿*boolean. Java中1个boolean占用1个byte. 1亿byte = 10万KB = 100M
    • 答疑 | boolean类型占几个字节?
    • 在符合JVM规范的虚拟机中,
      • 如果boolean是单独使用:boolean占4个字节。
      • 如果boolean是以“boolean数组”的形式使用:boolean占1个字节
  3. 换一种思路,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. 布隆过滤器的要点

  1. 底层依然是基于数组
  2. 布隆过滤器相当于是位图的加强版,使用多个hash函数对同一个值获取多个哈希值/index.
  3. 在获取到的多个index位置上都存储数据.
  4. 使用布隆过滤器,可以使用更短长度的数组存储数据.
  5. 两个不同的数字,在定长数组限制下,可能产生相同的哈希值,但是多个哈希函数得到的哈希值都相同的概率极低.
  6. 布隆过滤器可能误判
    • 如果1个值经布隆过滤器判断不存在,一定不存在.
    • 如果1个值本来不存在,经布隆过滤器判断,可能是存在的.

3. 布隆过滤器适合对误判有一定容忍度,不需要完全准确的大规模判重场景. 例如网络爬虫的网址判重场景,可能有数十亿,数百亿的网址,需要判重.

4. Java中的BitSet就是布隆过滤器.

6. 哈希算法的应用

1. 哈希算法的定义

将任意长度的二进制值串映射为固定长度的二进制值串.

2. 哈希算法的要求

  1. 单向性,无法反向推导
  2. 对数据变化高度敏感,只要1个二进制位改变,结果就完全不一样
  3. 性能好,执行快速
  4. 哈希冲突概率小

3. 哈希算法的应用

  1. 安全加密 / MD5,SHA,DES,AES
  2. 唯一标识 / 对海量文件进行唯一性标识
  3. 数据/文件校验. 例如多线程下载多个文件块,可以将每个文件块的哈希值标明,客户端下载成功后自行计算后校验.
  4. 哈希函数本身就是哈希算法的应用
  5. 负载均衡
    • 将客户端IP或会话ID计算哈希值,与服务器的个数取余,得到由哪一台服务器处理指定链接.
  6. 数据分片 / 就是将海量的文件路径,根据文件哈希值,将hash-路径构建的哈希表存储到不同的机器上,用于高速的查询.
  7. 分布式存储
    • 类似于数据分片,但分布式存储经常用于分布式缓存,将海量的文件/数据本身 根据其hash值存储到不同服务器.

4. 哈希算法和密码安全

存储密码明文 -> 存储密码的哈希值 -> 彩虹表手机常见密码哈希值可破解简单密码 -> 使用密码 + 盐 以指定方法混合后再计算哈希值的方法,对抗彩虹表.

7. 树

1. 树是一种非线性表.

1. 节点的高度: 节点到叶子节点的最长路径长度

2. 节点的深度: 根节点到这个结点的路径长度

3. 节点的层: 节点的深度 + 1

4. 树的高度: 根节点的高度

2. 二叉树

1. 二叉树: 每个节点最多有左右两个子节点.

2. 满二叉树: 叶子节点都在最底层,除叶子节点外,其他所有结点都有2个子节点.

3. 完全二叉树: 叶子节点都分布在底层及倒数第二层,且底层的叶子节点都是靠左排列.

  • 满二叉树是完全二叉树的一种特殊情况

3. 二叉树的存储

1. 基于指针的链式存储方式

2. 基于数组的顺序存储方式

3. 基于数组的顺序存储方式,根节点下标从1开始,这样其他所有节点的下标值计算都更简单/性能更好

  1. 根节点下标从1开始,所有节点的 左子节点下标: i2, 右子节点下标: i2+1 , 父节点下标: i/2
  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. 删除

要删除一个节点有如下几种情况:

  1. 要删除的节点不存在
    • return
  2. 要删除的节点无子节点
    • 父节点对应指针置空
  3. 要删除的节点只有1个子节点
    • 父节点对应指针指向该结点的子节点
  4. 要删除的结点有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. 哈希表不能取代二叉查找树的原因

  1. 哈希表中数据是无序的,而二叉查找树只要中序遍历就可以有序输出数据.
  2. 哈希表扩容耗时较多,当遇到哈希冲突性能不稳定.而平衡的二叉查找树性能很稳定,logn.
  3. 哈希表的构造比二叉查找树复杂,哈希函数的设计,冲突解决,扩容,缩容等.而平衡二叉查找树仅需要考虑左右子树高度的平衡.
  4. 二叉查找树时间复杂度是O(logn),性能非常好,且不涉及哈希计算,不涉及哈希冲突的解决,在数据规模不大情况下,比O(1)的时间复杂度性能更好.

9. 平衡二叉查找树

1. 树 -> 二叉树 -> 二叉查找树 -> 平衡二叉查找树 -> 红黑树

2. 平衡二叉查找树的严格定义

  1. 首先是二叉查找树.
  2. 任意节点的左右子树高度相差不能大于1.

3. 平衡二叉查找树的平衡,目的就是让整棵树尽可能的矮,左右尽可能对称,避免高度太大导致的性能退化.只要一个二叉查找树的高度不比logn大很多,就可以认为属于平衡二叉查找树.

4. 红黑树是一种近似平衡的二叉查找树,为了解决数据动态更新导致的二叉查找树性能退化而创造出来.

  1. 红黑树维护平衡的成本相对严格意义的平衡二叉查找树更低,但性能损失并不大.
  2. 大多数语言提供了封装好的红黑树实现类.

10. B+树

1. B+树是适用于构建数据库索引的一种数据结构

2. 数据库索引的要求:

  1. 按照值查找数据
  2. 按照区间查找数据
  3. 按照区间从大到小,从小到大双向查找数据

3. 目前学到的数据结构,能高效查询,且按顺序输出数据的,就是二叉查找树,但是二叉查找树不能实现按照区间双向查询. B+树就是从二叉查找树改造而来.

  1. 首先在二叉查找树底层加1个双向有序链表
  2. 将二叉树改变为多叉树
    • 多叉树的好处是,可以降低树的高度
    • 索引的数据量非常大,不可能全存在内存中,肯定要存在磁盘中,树的高度太大,每一层结点都要重新访问一次磁盘.多叉树有效降低了IO访问次数.
  3. 一般情况,B+树的根节点被存在内存中,其他结点存储在磁盘中.