美文网首页
数据结构和算法

数据结构和算法

作者: 技术灭霸 | 来源:发表于2021-08-18 23:16 被阅读0次

    数据结构和算法是相辅相成的。数据结构是为算法服务的,算法要作用在特定的数据结构之上。 因此,我们无法孤立数据结构来讲算法,也无法孤立算法来讲数据结构。

    比如,因为数组具有随机访问的特点,常用的二分查找算法需要用数组来存储数据。但如果我们选择链表这种数据结构,二分查找算法就无法工作了,因为链表并不支持随机访问。

    10 个数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树

    10 个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法

    要学习它的“来历”“自身的特点”“适合解决的问题”以及“实际的应用场景”。

    千万不要被动地记忆,要多辩证地思考,多问为什么。

    一些可以让你事半功倍的学习技巧:

    1. 边学边练,适度刷题
    2. 多问、多思考、多互动
    3. 打怪升级学习法
    4. 知识需要沉淀,不要想试图一下子掌握所有

    学习的过程中,我们碰到最大的问题就是,坚持不下来。

    我们在枯燥的学习过程中,也可以给自己设立一个切实可行的目标,就像打怪升级一样。

    1、数组

    2、链表

    缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

    三种最常见的链表结构,它们分别是:单链表、双向链表和循环链表

    (1)单链表

    链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下个结点地址的指针叫作后继指针 next

    从我画的单链表图中,你应该可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。

    (2)循环链表

    当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题

    (3)双向链表

    双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。

    双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点。


    所以数组适合做查询,比如查询算法都是用数组,链表适合做储存,比如lru会考虑链表。

    如何轻松写出正确的链表代码?

    image.png

    还记得如何表示一个空链表吗?head=null 表示链表中没有结点了。其中 head 表示头结点指针,指向链表中的第一个结点。

    如果我们引入哨兵结点,在任何时候,不管链表是不是空,head 指针都会一直指向这个哨兵结点。我们也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表。

    哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑了。

    3、栈

    当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,我们就应该首选“栈”这种数据结构。

    比较经典的一个应用场景就是
    1、函数调用栈.
    2、栈在表达式求值中的应用
    3、栈在括号匹配中的应用

    leetcode上关于栈的题目大家可以先做20,155,232,844,224,682,496.

    4、队列

    循环队列

    循环队列,顾名思义,它长得像一个环。原本数组是有头有尾的,是一条直线。现在我们把首尾相连,扳成了一个环。

    阻塞队列

    阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。

    并发队列

    线程安全的队列我们叫作并发队列。最简单直接的实现方式是直接在 enqueue()、dequeue() 方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。

    5、跳表


    这种链表加多级索引的结构,就是跳表

    用跳表查询到底有多快?

    第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2,那第 k级索引结点的个数就是 n/(2k)。

    时间复杂度: O(m*logn)。

    跳表是不是很浪费内存?

    跳表需要存储多级索引,肯定要消耗更多的存储空间。

    跳表的空间复杂度是 O(n)。

    为什么 Redis 要用跳表来实现有序集合,而不是红黑树?

    Redis 中的有序集合支持的核心操作主要有下面这几个:

    • 插入一个数据;
    • 删除一个数据;
    • 查找一个数据;
    • 按照区间查找数据(比如查找值在 [100, 356] 之间的数据);
    • 迭代输出有序序列。

    其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高

    对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。

    还有,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。

    6、二叉树

    7、Trie树(字典树)

    Trie 树,也叫“字典树”。顾名思义,它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。



    Trie 树主要有两个操作

    1. 一个是将字符串集合构造成 Trie 树,就是一个将字符串插入到 Trie 树的过程。
    2. 另一个是在 Trie 树中查询一个字符串。

    Trie 树的变体有很多,都可以在一定程度上解决内存消耗的问题。比如,缩点优化

    在一组字符串中查找字符串,Trie 树实际上表现得并不好。它对要处理的字符串有及其严苛的要求。

    1. 第一,字符串中包含的字符集不能太大。我们前面讲到,如果字符集太大,那存储空间可能就会浪费很多。即便可以优化,但也要付出牺牲查询、插入效率的代价。
    2. 第二,要求字符串的前缀重合比较多,不然空间消耗会变大很多。
    3. 第三,如果要用 Trie 树解决问题,那我们就要自己从零开始实现一个 Trie 树,还要保证没有 bug,这个在工程上是将简单问题复杂化,除非必须,一般不建议这样做。
    4. 第四,我们知道,通过指针串起来的数据块是不连续的,而 Trie 树中用到了指针,所以,对缓存并不友好,性能上会打个折扣。

    综合这几点,针对在一组字符串中查找字符串的问题,我们在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,我们都不需要自己去实现,直接利用编程语言中提供的现成类库就行了。

    实际上,Trie 树只是不适合精确匹配查找,这种问题更适合用散列表或者红黑树来解决。Trie 树比较适合的是查找前缀匹配的字符串,也就是类似开篇问题的那种场景。

    8、散列表(Hash Table)

    散列表(哈希表)用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。

    一般有key和value,key会通过散列函数转换成散列值


    散列函数(哈希函数)

    散列表两个核心问题是散列函数设计和散列冲突解决

    散列函数设计的基本要求:

    1. 散列函数计算得到的散列值是一个非负整数;
    2. 如果 key1 = key2,那 hash(key1) == hash(key2);
    3. 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

    第一二点没啥问题,但第三点理解起来可能会有问题,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的MD5SHACRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。

    我们常用的散列冲突解决方法有两类,开放寻址法和链表法

    1. 开放寻址法

    开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。

    • 线性探测:如果存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

    • 二次探测:跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+12,hash(key)+22……

    • 双重散列:意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

    当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因

    2. 链表法

    所有散列值相同的元素我们都放到相同槽位对应的链表中。

    为什么HashMap使用链表法解决哈希冲突

    1、首先,链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。实际上,这一点也是我们前面讲过的链表优于数组的地方。

    2、链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于 1 的情况。接近 1 时,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成 10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。

    相关文章

      网友评论

          本文标题:数据结构和算法

          本文链接:https://www.haomeiwen.com/subject/vnwgvltx.html