数据结构下篇 - 复合数据结构【不看后悔】

567

「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。

本篇文章属于数据结构连载篇的其中之一,共分为上中下三篇。上篇主要介绍线性数据结构数组、链表、栈、队列;中篇介绍树和图;下篇介绍复合数据结构哈希表、跳表、线段树等。希望大家持续关注,谢谢。

1 哈希表

哈希表是最常用的一种数据结构,也就是我们Java中的HashMap,我们一般在做服务的本地缓存都会采用Map这种数据结构,因为Map的查找时间复杂度为O(1),当然如果出现hash碰撞,时间复杂度会升高。

image.png

HashMap存储的是一个Key-Value键值对的集合,每一个键值对也叫做Entry,这个Entry也就HashMap的主要构成,Java中的HashMap采用Entry数组+链表或者Entry数组+红黑树的形式存储数据,1.8之前老版本JDK只有数组+链表的形式。

HashMap 中 Entry 的结构是有多种实现的,普通节点(数组和链表使用),和树节点(红黑树使用)

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        
        //省略一些方法
        ...
    }
    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        //省略一些方法
        ...
    }

要想了解HashMap的原理我们就需要探究一下HashMap的put方法做了哪些事情。

  • 调用hashMap.put(”A“, 1) ;插入一个key为A,value为1的元素

  • 先会调用hash(key),获取key对应的hash值,用于计算数组下标

    高明之处: 如果直接对hashCode取模则效率很低,这里先采用位运算(计算机效率最高的运算)得到hash值再取模性能就会好很多,并且数组下标的取值也依赖于hashCode的后几位(因为数组的长度不会特别长,【数组长度-1】转化为二进制的位数就是有效的几位),并且再与高十六位异或更能让得到的数组下标离散。

    static final int hash(Object key) {
        int h;
        // hashCode异或hashCode的高16(^是异或,>>>是无符号右移) 
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    // 本地方法
    public native int hashCode();
    
  • 判断数组是否被创建,如果没有创建,就创建数组

  • 通过(i = (n - 1) & hash)获取数组下标,判断是否有值,没有值,就创建一个entry节点放在数组的对应位置

    高明之处: 假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。这样取模效果就会很好,如果长度为10,Length-1的结果为十进制的9,二进制为1001,这样在进行与运算时,就会出现很多一样的情况,比如hash后四为1011,1111这样进行 (n - 1) & hash就会触发hash冲突,哈希表越少爆发哈希冲突越好,所以数组的位数一般是2的幂,这样可以保证其转化为的二进制位数全是1,有效降低哈希冲突(这里一定要熟悉位运算)。

  • 如果数组下标是有值的,且key与当前存放的key是相同的,则替换掉原来的value

  • 如果该节点存放的是TreeNode,则执行对红黑树的查找,如果有key就替换value,如果没有就新插入一个节点

  • 如果该节点又不是TreeNode则说明还没有进化成为红黑树,此处存放的就是链表

  • 遍历链表并对遍历的节点个数计数,如果在链表中找到相同的key则替换value

  • 如果没找到,则需要判断链表当前有几个节点,如果小于8个直接插入在链表尾部即可

  • 如果超过或者等于8个则需要转化成为红黑树

  • 最后插入节点的时候会比较数组的大小是否需要扩容,需要扩容则对数组进行扩容

HashMap核心逻辑,感兴趣的可以阅读一下

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 1st
                            treeifyBin(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 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;
    }

注:删除逻辑与之差不多,只不过红黑树退化为链表时是6个节点,设置不一样的值可以避免此处来回变化,造成性能损耗。

get方法

  • 获取到对应的hash值,找到数组下标,判断数组中存放是不是该key,是的话直接返回该key的value
  • 如果key不是,且下一个节点是空的,返回null
  • 如果不是则判断是不是树节点,如果是则对红黑树进行查找
  • 如果不是则遍历链表进行查找
  • 如果都没有则返回null

源码如下

    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) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

2 跳表

跳表可能有的同学熟悉,有的同学陌生,熟悉Redis数据结构的都知道Redis中有一个zset结构,底层就是通过跳表实现的。

我们知道搜索数组中的数据时,可以通过二分查找使查询的复杂的时间复杂度降低为O(logn),那么把数组换成链表呢?我们该如何优化查询速度呢?

首先链表说无法直接通过二分查找进行优化的,因为我们不知道每一个节点的存储位置,无法直接定位到中间节点。我们只能将链表进行一下改造,相当于给链表的数据加上索引,访问的时候先访问索引,通过索引实现二分查找,这就是跳表。

跳表是典型的用空间换时间的一种数据结构,通过冗余索引,从而让链表具备二分搜索的特性。

image.png

注意:

当跳表新增数据时会随机判定是否要将其向上提升一层,这个概率是50%;删除节点时,如果一层的节点被删完,则会删除该层。

所以,跳表的索引并不一定是均匀分布的。

小知识:为什么Redis的zset会选用跳表,而不是红黑树,平衡二叉收索树呢?

因为缓存数据需要支持随机的插入和删除,所以它 不适合使用数组来实现,对于排序来说,我们也很容易就想到 红黑树/ 平衡树 这样的数据结构可以很好的做到二分查找,为什么 Redis 不使用这样一些结构呢?

  • 性能考虑: 在高并发的情况下,红黑树和平衡树都需要通过自选保证树的自平衡,这样的操作可能涉及整棵树的变化,相对来说跳跃表的变化只涉及局部,性能上跳表要好很多(空间换时间);
  • 实现考虑: 时间复杂度与红黑树相同的情况下,跳跃表实现起来更简单,也更加直观;

3 线段树

线段树的构成是由二叉树+数组,它也是对二叉搜索树的一种优化,线段树每个节点存储的是一个数组下标的区间和求得的结果,使其可以对编号连续的一些数据进行修改或者统计操作,修改和统计的复杂度都是O(logn)。

想要借助线段树这种数据结构的特性进行统计,就必须得符合区间加法(可以通过分成的子区间来得到[L,R]的统计结果)。

符合区间加法的例子:

  • 数字之和(SUM):总数字之和 = 左区间数字之和 + 右区间数字之和
  • 最大公因数(GCD):总最大公因数 = gcd( 左区间GCD , 右区间GCD );
  • 最大值(MAX):总最大值=max(左区间最大值,右区间最大值)
  • 平方和...

不符合区间加法的例子:

  • 众数:只能知道最下层的众数,没法求总区间的众数
  • ...

如下图,我们就可以快速通过线段树获取数组内某一段数据的合,比如全部数据的总和就是104,下标[5,8]的和就是51。

我们就可以把数组看作线段,树中的节点存储的是线段的一段,这就是线段树。

image.png