数据结构与算法基础总结

2020-02-09  本文已影响0人  李大酱的大脖子

为什么学习数据结构与算法?

关于数据结构和算法,以前只是看过一些零散的文章或者介绍,从来都没有系统的去学习过。随着工作之余,看了几本书,读了一些高质量的专栏,也接触了一些有关梦想的故事,发现很多技术的底层都离不开数据结构,像Redis的跳表、Mysql中innodb引擎用到的B+树、java并发包用到的各种锁等等。如果我想继续深入的学下去,那么数据结构与算法这道坎儿,就得想着法给他迈过去。在一篇文章中我看到过这样一句话叫“我如何学会停止恐惧,并且爱上 Java 虚拟机”,我把目标从虚拟机换成了数据结构与算法,于是在某一天躁动的夜晚,我给自己来了一管鸡血,我想用我愚笨的脑子和懒惰又骄傲的性子去磨一磨这块名叫“数据结构与算法”的藏锋钝刀。

My learning progress

我买了一本书《数据结构与算法分析-java语言描述》,我也寥寥翻过了几页,然后就觉得这玩意儿可能需要大量的数学证明推导,相比之下王争的数据结构与算法专栏就明显要通俗易懂的多。我断断续续的学了一个月,终于,我一只脚踩在了数据结构与算法的门槛儿上,嗯,是门槛上,not 门槛内。出拳需要蓄力,数据结构与算法同样需要积累。我趁兴而来,不算败兴而归,我依然在路上。

基础总结

这里是正题,一些学习过程的记录。

数据结构的基础就是数组与链表

复杂度

复杂度是学习之前必须掌握的,分为时间复杂度和空间复杂度,时间复杂度又分为最大复杂度、最小复杂度、平均复杂度、摊还复杂度。在每每学到新的更高效的数据结构时,复杂度分析就是我们对其性能评估的最好标准。

数组

数组支持随机访问,通过数组下标随机访问的时间复杂度是O(1), 而数组查找元素时,即使是在排好序的数组中查找元素,使用二分法时间复杂度也是O(logn);

为什么通过数组下标随机访问的时间复杂度是O(1)?

数组是内存中的一块连续内存,并记录了数组的首地址,base_address,当随机访问数组元素时,可以通过寻址公式直接计算出元素存储的内存地址

base_address + i * data_type_size
data_type_size表示数组中元素的大小,同理二维数组array[m][n]的寻址公式:
array[i][j] = base_addr + (i * n + j)*data_type_size

如何优化数组中低效的插入和删除操作?

对于插入通常可以采用将新增元素位置的原始元素放到数组尾部,将新元素放到原始元素的位置,这样能避免数组数据的搬迁。删除时,可以先标记删除的元素,等数组空间不够用时触发一次批量的删除操作,这样能大大减少数组元素搬移操作(JVM标记清除法)。

数组的重要特性

数组可以借助 CPU 的缓存机制,预读数组中的数据,访问效率更高,而链表在内存中并不是连续存储所以对 CPU 缓存不友好,没办法有效预读。

CPU在从内存读取数据的时候,会先把读取到的数据加载到CPU缓存中,而CPU每次从内存中读取数据并不是只读取那个特定的内存地址,而是读取一个内存块,并保存在CPU缓存中,然后下次访问内存数据的时候就会先从CPU缓存中寻找,找不到才会去内存中读取。这样就实现了比内存访问速度更快的机制,也是CPU缓存的意义:为了弥补内存访问速度过慢与CPU执行速度过快的差异;对于数组来说,在加载某个下标的时候可以把以后几个下标元素也加载到CPU缓存中,这样速度会快于存储空间不连续的链表存储。

链表

数组之所以能够实现随机访问,就是因为内存空间是连续的,可以通过寻址公式直接找到元素内存地址,而链表刚好相反,链表占用的内存空间并不是连续的,它是通过指针将零散的内存串联到一起。所以如果内存中连续空间不足以申请创建指定长度的数组时,说不定可以申请创建同样长度的链表。

数组在扩容时,必须再申请一个更大的连续内存空间,把原数组拷贝进去,非常耗时。而链表本身没有长度限制,天然支持动态扩容,这就是数组与链表最大的区别。
链表中插入和删除操作不需要结点搬迁,因为它们内存空间本来就不是连续的,链表插入和删除的时间复杂度是O(1);

链表中的每个结点都只能通过后继指针知道下一个结点,所以当随机访问时只能从首结点往后逐个访问,所以时间复杂度是O(n);

常见的链表有双向链表、循环链表,需要区分使用场景。

双向链表

从结构上看,双向链表支持O(1)的时间复杂度通过指定结点找到前驱结点,因为这点导致双向链表在某些情况下的插入、删除等操作都比单向列表更简单高效:

双向链表就是典型的以空间换时间的例子,用双倍的内存消耗换取操作时间。并且对链表频繁进行插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,甚至频繁GC。

支持动态扩展的栈,在栈空间没有满的时候入栈的时间复杂度为O(1),栈空间满的时候,假设扩容为两倍空间k的栈,拷贝数据需要O(n)的时间复杂度,从k+1到2k每次入栈需要O(1)的时间复杂度,而前一次移动k个元素分摊到这k次元素的入栈时间复杂度上,可得,最好情况的时间复杂度是O(1),最坏情况的时间复杂度是O(n),摊还复杂度为O(1)。

通常情况下,摊还复杂度一般都等于最好情况下的时间复杂度。

栈的经典应用场景

  1. 函数调用栈,操作系统给每个线程分配了一块独立内存空间(被组织成栈),用来存储函数调用时的临时变量,每进入一个函数,都会将临时变量作为栈帧入栈,当调用函数执行完返回后,将这个函数对应的栈帧出栈。
  2. 表达式求值,四则运算对于编译器就是用两个栈来实现的,用一个栈保存操作数,另一个栈保存运算符。当从左到右遍历表达式时,碰到数字,就直接压入栈顶,当碰到运算符,就与运算符栈的栈顶运算符进行比较,如果优先级高于栈顶运算符,就压入栈顶,如果优先级低于或等于栈顶运算符,就将操作数栈顶的两个数字和运算符栈顶的运算符进行计算并将计算结果压入操作数栈(同时弹出已经运算过的数字与运算符),计算完成后继续与当前栈顶的运算符进行比较,低于或等于就继续运算直到高于时进行压栈。
  3. 括号,在扫描表达式中的括号时,会从左到右遍历将所有左括号依次压栈,当扫描到右括号时,从栈顶取出一个左括号进行比对(比对成功则左右括号弹栈),比对成功继续扫描,比对失败则括号非法。扫描完全部的有括号后,查看此时栈是否为空,空则全部正确。
  4. 浏览器的前进后退页面,也是用两个栈实现的,A栈用来存放查看的历史网页,当后退时,将栈顶的网页存入B栈,当前进时就从B栈顶取出网页存入A栈,A栈为空则不能后退,B栈为空则不能前进。当后退一次,打开了新页面,此时就无法再前进或后退到刚才的页面了,此时的操作就是清空B栈。

为什么函数调用要使用栈来保存临时变量呢?
函数调用是符合后进先出的,从调用函数进入被调用函数,需要保证每进入一个新的函数,都是一个新的作用域。在进入被调用函数的时候,在调用函数的栈顶分配一段栈空间给这个函数的变量,在被调用函数结束时,复原栈顶刚好回到调用函数的作用域内。

队列

队列最大的特点就是先进先出,跟栈一样,可以用数组实现即顺序队列,也可以用链表实现即链式队列。如果用数组实现的话,会涉及到数据搬移,可以用循环队列来解决,循环队列最关键的就是要确定队空和队满的情况。

队列的经典应用场景

  1. 阻塞队列就是在基本的队列上添加了额外的功能,队空时阻塞出队操作直到有数据入队,队满时阻塞入队操作直到有数据出队,基于此可以实现消费者-生产者模式,在生产者的入队频率高时,可以添加多个消费者加快出队操作。
  2. 并发队列就是在基本队列上实现了多线程操作队列的安全性。
  3. 基于链表的队列可以实现一个无限排队的无界队列,但是可能导致排队过多,请求处理的相应时间加长,不适合时间敏感的系统。
  4. 基于数组的队列可以实现有界队列,队列大小有限,超出的请求会直接被拒绝,这种方式适合对时间敏感的系统。
  5. 基于数组的循环队列,利用CAS原子操作,可以实现非常高效的并发队列。

如何实现无锁并发队列?
cas+数组

递归

实现递归需要找出递归模型,递归模型包含三要素:

  1. 返回值
  2. 调用单元处理了什么
  3. 终止条件

排序

有序度:数组中具有有序关系的元素对的个数,如果数组是完全有序,有序度为(n-1)*n/2,称为满有序度
逆序度:满有序度-有序度
排序就是减少逆序度,增加有序度的过程,最后达到满有序度则排序完成。

线性排序

线性排序对排序数据都有比较严苛的要求,应用不是非常广泛,如果数据特征比较符合这些排序算法的要求,应用这些算法会非常高效达到O(n)的时间复杂度。

  1. 简单二分法
    注意几个边界 while(low <= high), low+=1 high-=1, mid = (low + high)/2
    二分法依赖顺序数据结构的随机访问特性,即数组,二分法必须依赖有序的数组。
  2. 二分法变形
    通常来讲凡是能用二分法查找解决的,我们更倾向于用散列表和二叉查找树。即便是二分查找在内存使用上更节省,但是内存紧缺的情况并不多,实际上通过二分法找到值等于给定值的场景很少,二分法更适合“近似”查找问题,这这类问题上,二分查找的优势更加明显。

跳表

跳表结合了数组和链表的优点,跳表除了空间复杂度稍高,时间复杂度可以媲美二叉树,是非常优秀的数据结构。
跳表的难点在于索引的动态更新,可以通过随机函数,决定将添加的结点插入到第几层索引层中去。

散列表

散列表用的是数组支持下标随机访问数据的特性,所以散列表其实就是数组的扩展,由数组演化而来。

  1. 散列函数计算的值是非负整数;
  2. 如果key1 = key2,那hash(key1) = hash(key2);
  3. 如果key1 != key2,那 hash(key1) != hash(key2);

链表法相对于开放寻址法,对大装载因子的容忍度更高,更适合存储大数据量和大对象。因为对于比较小的对象而言,指针是比较消耗空间的,极端情况下会导致内存翻倍,而且因为链表的结点是零散分布在内存中的,不是连续的,对于CPU缓存来说也不够友好,这方面对于执行效率也有一定的影响。
链表法更加灵活,可以改造成跳表、红黑树。

  1. LinkedHashMap
    Linked不仅仅代表是使用链表来解决hash冲突,linkedHashMap是通过散列表和链表组合一起实现的,可以支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据(天然支持LRU缓存淘汰策略的缓存系统)。Linked指的是双向链表。
  2. HashMap
    初始大小16,装载因子0.75,每次扩容都是原始大小的2倍。采用链表法结局散列冲突。JDK1.8引入了红黑树,在链表长度大于8时,链表就会转化为红黑树,少于8个时会由红黑树转换为链表。

工业级散列表具有哪些特性
支持快速的查询、插入、删除操作
内存占用合理,不能浪费过多内存空间
性能稳定,极端情况下,散列表的性能也不会退化到无法接受的地步

如何设计工业级散列表

  1. 设计一个合适的散列函数
    让散列后的值分布的随机且均匀,尽量减少散列冲突,即使冲突后,分配到每个槽里的数据也比较均匀。散列函数也不能设计的太复杂,太复杂就会太耗时间,也会影响散列表的性能。
  2. 定义装载因子阈值,设计动态扩容策略
    随着数据增多,散列表总会出现装载因子过高超过阈值的情况,这个时候就需要启动动态扩容。
  3. 选择合适的散列冲突解决方法
    大部分情况下,链表法更加的普适。链表法还能改造成其他的数据结构进行优化,如红黑树、跳表等,即使数据都冲突到一个槽里,查找效率也是O(logn)而不会退化到O(n)。但是小规模数据,装载因子不高的散列表,比较适合开放寻址法。

树节点的插入和删除操作

插入时,对关注节点进行符合树特征的调整,调整后将父节点当成关注节点继续往上调整。
删除

AVL树

23树

节点有1或2个key,对应有2或3个节点,高度平衡
根节点到所有子节点的节点个数相同

234树

节点有1或2或3个key,对应有2或3或4个节点,高度平衡
根节点到所有子节点的节点个数相同

红黑树

关于删除:
https://www.jianshu.com/p/e136ec79235c
https://blog.csdn.net/qq_25940921/article/details/82184055

熟练写出深度优先和广度优先算法
非线性表数据结构

字符串匹配

-主串:原始串,模式串:用来去匹配的串

BM算法
核心思想:利用模式串本身的特点,在模式串的某个字符串与主串不能匹配的情况下,将模式串向后多移动几位,以此来减少移动的次数,提高匹配效率。

字符串匹配kmp算法(代码很牛逼)
参考

Trie(easy)
AC自动机

贪心算法

分治算法

回溯算法

穷举所有的可能,描述所有可能的递归

动态规划

算法脑图.png
上一篇 下一篇

猜你喜欢

热点阅读