数据结构与算法之美笔记——散列表(下)

摘要:

在实际生产中,散列表常常与链表结合使用,这种结合使链表操作效率得到提高,也使散列表无序的数据可以保持有序,可谓双赢。

散列表与链表的互补

链表查找操作的时间复杂度为 ,而插入和删除操作都需要基于查找操作,这也导致了原本高效的插入和删除执行效率下降。而散列表也不是十全十美,因为数据需要散列存储的原因,导致数据成为无序状态,需要数据排序显示或者查找某个范围内的数据时散列表就犯了难。

但链表和散列表的优点正好互补对方的缺点,简直天生一对。那如何将链表与散列表有效结合进来,以解决对方的难题呢?当然不会像散列表中为了解决散列冲突使用链表法如此简单,但也是以此为基础的。在链表的基础上链表结点还需要存储一个指针,这个指针用于构建散列表中槽位上为解决散列冲突的链表,以下我们简称这个指针为 「hnext」。原本的链表也不是一般的链表,需要使用双向链表,至于为何必需使用双向链表稍后会详细解释,这时一个结点就需要存储三个指针,分别是双向链表中的前驱指针,后驱指针和散列表中的 hnext,于是我们使用一个结点构建了两个维度的链表。

数据结构与算法之美笔记——散列表(下)_第1张图片
image

如何协作

既然散列表和链表已经找到合适队形,接下来就是协调动作了。无论在散列表还是链表中都有查找、插入和删除操作,那我们就看一下这对好搭档是如何完成这些工作的。

如果是链表的查找就需要逐个结点顺序查找,但与散列表结合后可以根据利用散列函数在 的时间复杂度下查找到结点所处的槽位,最后只用顺序查找槽位对应的链表即可,槽位对应的链表结点数量较少,逐个查找结点的时间复杂度也可以视为 ,链表原先查找操作 的时间复杂度就降低为 。相应的,以查找为基础的链表的插入和删除操作时间复杂度也为 ,执行效率得到了很大的提高,但插入时不仅要在链表对应位置插入结点,还需要在散列表槽位对应链表中插入相应结点,删除操作也是如此。

前面我们提到,与散列表结合的链表需要是双向链表,那试想一下,如果只使用单向链表插入和删除的时间复杂度是否还能达到 ?如果使用的是单向链表进行操作,查找的时间复杂度不会受到影响依然是 ,但当查找到目标结点时,无论是插入还是删除操作都需要获取当前结点的前驱结点,单向链表只能通过遍历链表结点解决,那插入和删除的时间复杂度又回到了 ,而只有双向链表能够在 的时间复杂度下获取前驱结点,这也是链表与散列表结合时需要使用双向链表的原因。

既然链表从散列表那里获得了提高执行效率的能力,那链表又能给予散列表什么呢?散列表因为会将数据散列存储,数据顺序自然会被打乱,而如何有序输出数据或者查找某个范围内的数据类似的操作,散列表都需要借助链表的能力。单纯以链表来说,虽然保持数据有序轻而易举,但是查找某个范围内的数据执行效率就不高,但链表还有种扩展结构——跳表,跳表对于某个范围内的数据查找或者目标数据的查找时间复杂度都是 ,效率极高。

讲到这里大家可能就有疑问,为啥不直接将数据存储在跳表就好,还需要散列表干啥?确实,如果只有查找链表目标结点或者某个数据范围内的结点这样的需求,只使用跳表也没什么不妥,但如果有多个数据维度的查询呢?例如,期末考试后发放学生考试成绩,有需求即可以通过学生学号查询某个学生的成绩,也可以查询分数在某个范围内学生,这种需求下可以使用散列表来通过学号快速查找某个学生,也可以通过跳表查找某个分数范围内的学生,甚至因为链表保持了数据的有序,也可以输出分数排名前 n 名的学生。

实际运用

链表有个经典的使用场景「LRU(Lastest Recent Use) 缓存淘汰策略」,这是一个达到缓存限制时有新的数据需要存储,应该清楚哪个旧数据的问题。例如需要缓存访问过的网址,如果一个网址被访问就插入缓存链表,插入过程会先查询这个网址是否已经在缓存中存储过,如果存储过将数据结点移动至链表尾部,如果没有缓存过就直接在链表尾部插入被访问的网址数据结点,如果达到缓存限制就将链表头删除再做插入操作。

如果只是单独使用链表实现 LRU 的话,插入缓存的时间复杂度是 ,按照前面讲的结合散列表的方式可以将时间复杂度提高至 ,接下来看看实现 LRU 缓存的代码片段吧。

private class Node {
    private Node prev;
    private Node next;
    private Node hnext;
    private int data;

    public Node() {}

    public Node(int data) {
        this.data = data;
    }
}

public void cache(int data) {
    int hashKey = hash(data);
    if(hashMap[hashKey] == null) {
        Node node = new Node(data);
        insertLinkedTail(node);
        hashMap[hashKey] = node;
    } else {
        Node hashNode = hashMap[hashKey];
        while(hashNode.data != data && hashNode.hnext != null) {
            hashNode = hashNode.next;
        }

        if(hashNode.data == data) {
            deleteLinkedNode(hashNode);
            insertLinkedTail(hashNode);
        } else {
            Node node = new Node(data);
            hashNode.hnext = node;
            insertLinkedTail(node);
        }
    }
}

总结

链表与散列表结合并不只是使用链表法解决散列冲突这么简单,结合的原因还在于链表可以利用散列表的特点快速查找目标结点,散列表也可以利用链表的特点保证存储数据的有序性,这样互补的特性造就了这对黄金搭档。


文章中如有问题欢迎留言指正
本章节代码已经上传 GitHub,可点击跳转查看代码详情。
数据结构与算法之美笔记系列将会做为我对王争老师此专栏的学习笔记,如想了解更多王争老师专栏的详情请到极客时间自行搜索。

你可能感兴趣的:(数据结构与算法之美笔记——散列表(下))