为什么学习数据结构与算法?
关于数据结构和算法,以前只是看过一些零散的文章或者介绍,从来都没有系统的去学习过。随着工作之余,看了几本书,读了一些高质量的专栏,也接触了一些有关梦想的故事,发现很多技术的底层都离不开数据结构,像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)的时间复杂度通过指定结点找到前驱结点,因为这点导致双向链表在某些情况下的插入、删除等操作都比单向列表更简单高效:
- 删除节点
删除节点通常有两个方式,1-删除值等于给定值的结点,2-删除给定指针指向的节点
对于删除1,尽管链表单纯的删除操作时O(1),但是为了查找到值等于给定值的结点,必须从链表头逐个遍历,直到找到为止,遍历查找的时间复杂度是O(n),根据时间复杂度的加法原则,这种删除的时间复杂度为O(n);
对于删除2,当我们已经找到要删除的节点,但是删除某个节点必须知道其前驱节点(要让前驱指针指向删除元素的下一个元素),寻找前驱结点的时间复杂度是O(n)。但是对于双向链表来说,通过指定结点找到前驱结点的时间复杂度是O(1)。 - 插入节点
在链表的某个节点前面插入一个节点,双向链表只需要O(1)的时间复杂度,单向链表需要O(n)的时间复杂度。 - 查询
对于一个有序链表,如果是双向链表,我们记住上次查询的位置P,在查找时比较元素与P的大小,决定往前遍历还是往后遍历,平均只需要查找一半的元素。
双向链表就是典型的以空间换时间的例子,用双倍的内存消耗换取操作时间。并且对链表频繁进行插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,甚至频繁GC。
栈
支持动态扩展的栈,在栈空间没有满的时候入栈的时间复杂度为O(1),栈空间满的时候,假设扩容为两倍空间k的栈,拷贝数据需要O(n)的时间复杂度,从k+1到2k每次入栈需要O(1)的时间复杂度,而前一次移动k个元素分摊到这k次元素的入栈时间复杂度上,可得,最好情况的时间复杂度是O(1),最坏情况的时间复杂度是O(n),摊还复杂度为O(1)。
通常情况下,摊还复杂度一般都等于最好情况下的时间复杂度。
栈的经典应用场景
- 函数调用栈,操作系统给每个线程分配了一块独立内存空间(被组织成栈),用来存储函数调用时的临时变量,每进入一个函数,都会将临时变量作为栈帧入栈,当调用函数执行完返回后,将这个函数对应的栈帧出栈。
- 表达式求值,四则运算对于编译器就是用两个栈来实现的,用一个栈保存操作数,另一个栈保存运算符。当从左到右遍历表达式时,碰到数字,就直接压入栈顶,当碰到运算符,就与运算符栈的栈顶运算符进行比较,如果优先级高于栈顶运算符,就压入栈顶,如果优先级低于或等于栈顶运算符,就将操作数栈顶的两个数字和运算符栈顶的运算符进行计算并将计算结果压入操作数栈(同时弹出已经运算过的数字与运算符),计算完成后继续与当前栈顶的运算符进行比较,低于或等于就继续运算直到高于时进行压栈。
- 括号,在扫描表达式中的括号时,会从左到右遍历将所有左括号依次压栈,当扫描到右括号时,从栈顶取出一个左括号进行比对(比对成功则左右括号弹栈),比对成功继续扫描,比对失败则括号非法。扫描完全部的有括号后,查看此时栈是否为空,空则全部正确。
- 浏览器的前进后退页面,也是用两个栈实现的,A栈用来存放查看的历史网页,当后退时,将栈顶的网页存入B栈,当前进时就从B栈顶取出网页存入A栈,A栈为空则不能后退,B栈为空则不能前进。当后退一次,打开了新页面,此时就无法再前进或后退到刚才的页面了,此时的操作就是清空B栈。
为什么函数调用要使用栈来保存临时变量呢?
函数调用是符合后进先出的,从调用函数进入被调用函数,需要保证每进入一个新的函数,都是一个新的作用域。在进入被调用函数的时候,在调用函数的栈顶分配一段栈空间给这个函数的变量,在被调用函数结束时,复原栈顶刚好回到调用函数的作用域内。
队列
队列最大的特点就是先进先出,跟栈一样,可以用数组实现即顺序队列,也可以用链表实现即链式队列。如果用数组实现的话,会涉及到数据搬移,可以用循环队列来解决,循环队列最关键的就是要确定队空和队满的情况。
队列的经典应用场景
- 阻塞队列就是在基本的队列上添加了额外的功能,队空时阻塞出队操作直到有数据入队,队满时阻塞入队操作直到有数据出队,基于此可以实现消费者-生产者模式,在生产者的入队频率高时,可以添加多个消费者加快出队操作。
- 并发队列就是在基本队列上实现了多线程操作队列的安全性。
- 基于链表的队列可以实现一个无限排队的无界队列,但是可能导致排队过多,请求处理的相应时间加长,不适合时间敏感的系统。
- 基于数组的队列可以实现有界队列,队列大小有限,超出的请求会直接被拒绝,这种方式适合对时间敏感的系统。
- 基于数组的循环队列,利用CAS原子操作,可以实现非常高效的并发队列。
如何实现无锁并发队列?
cas+数组
递归
实现递归需要找出递归模型,递归模型包含三要素:
- 返回值
- 调用单元处理了什么
- 终止条件
- 如何一步跳出层层递归?
抛异常,在递归执行到指定的地方抛出异常,调用处catch该异常,并进行后续业务处理。 - 理解递归
递归实质上就是系统帮你压栈的过程,系统在压栈的时候会保留现场,相当于在当时的那一栈暂停拍了个快照,等到这次递归完成返回了,就从上次暂停的时刻继续执行。 - 递归执行过程中会出现很多重复的步骤,添加合适的缓存能减少这些重复步骤(动态规划)。
排序
有序度:数组中具有有序关系的元素对的个数,如果数组是完全有序,有序度为(n-1)*n/2,称为满有序度
逆序度:满有序度-有序度
排序就是减少逆序度,增加有序度的过程,最后达到满有序度则排序完成。
冒泡排序
最好情况下,只需要一次冒泡,时间复杂度为O(1), 最坏的情况下需要冒泡(n-1)! = (n-1+1)(n-1)/2=(n-1)n/2即O(n^2);
冒泡排序是稳定排序、原地排序插入排序
将数组分为已排序空间和未排序空间,依次将未排序空间的元素与已排序空间内元素遍历比较并插入到合适的位置。
对一个给定的序列,移动的次数等于总是固定的等于逆序度;
插入排序是稳定排序、原地排序选择排序
每次找到最小元素依次与左边元素调换位置
如3、2、3、1第一次3就会被换到第二个3的后面,所以选择排序不是稳定排序,是原地排序。
插入排序的性能优于冒泡排序,而且插入排序的算法思路有很大优化空间,如希尔排序。希尔排序
希尔排序的时间复杂度与增量序列相关。
希尔排序不是稳定排序归并排序
时间复杂度O(nlogn),不是原地排序,是稳定排序算法
归并排序总结:归并排序使用了分治思想,分而治之,将一个大问题分解为多个小问题。将数组递归均分一半,递推公式:merge_sort(p...r) = merge(merge_sort(p...q), merge_sort(q+1...r))
终止条件:p >= r 不用再继续分解
使最后均分出来的子数组的元素只有一个,对子数组排序,然后将子数组合并。快速排序
大部分情况下是O(nlogn),极端情况下会退化到O(n^2),快排是原地排序,不是稳定排序算法
快速排序总结:
如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。
我们遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分成了三个部分,前 面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的。
根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,就说明所有的数据都有序了。 如果我们用递推公式来将上面的过程写出来的话,就是这样:
递推公式:quick_sort(p...r) = quick_sort(p...q-1) + quick_sort(q+1, r)
终止条件:p >= r
线性排序
线性排序对排序数据都有比较严苛的要求,应用不是非常广泛,如果数据特征比较符合这些排序算法的要求,应用这些算法会非常高效达到O(n)的时间复杂度。
桶排序
将要排序的数据分到几个有序的桶里,每个桶里的数据单独进行排序,桶内排完序后,再把每个桶里的数据按照桶的顺序依次取出,组成的序列就是有序的了。
时间复杂度分析:
假如将n个数据分到m个桶中,每个桶中n/m=k个数据,每个桶内使用快速排序,时间复杂度就是 O(mklog(k))=O(n*log(n/m)),当桶的个数m无限接近于n时,桶排序的时间复杂度就是O(n)了。计数排序
计数排序是桶排序的一种特殊情况,当排序范围不是太大,比如最大值就是K,此时就可以用k个桶,每个桶内的数据都是有序的,省掉了桶内排序的时间。
典型应用场景如分数,因为没有桶内排序,只需要遍历一遍数据放到桶里,再依次从桶内取数据就能完成排序,所以时间复杂度是O(n)。基数排序
基数排序也是桶子排序,把每一位的数值范围设置成多个桶,先按照低位分别放到有序的桶里比较,取出排序结果,再依次比较高位排序。
应用场景:非等长的数字可以通过补位实现,字母可以通过ASCII值来比较。
问题:为什么要从低位开始向高位排序?
如果要从高位排序, 那么次高位的排序会影响高位已经排好的大小关系. 在数学中, 数位越高,数位值对数的大小的影响就越大.
问题:为什么基数排序时桶排序(按照每位来排序)的算法要是稳定性的呢?
因为最后一次最高位排序的时候要是非稳定排序的话,就只会考虑最高位的顺序,之前低位排好的顺序就可能被打乱,导致结果还是乱序。二分法
- 简单二分法
注意几个边界 while(low <= high), low+=1 high-=1, mid = (low + high)/2
二分法依赖顺序数据结构的随机访问特性,即数组,二分法必须依赖有序的数组。 - 二分法变形
通常来讲凡是能用二分法查找解决的,我们更倾向于用散列表和二叉查找树。即便是二分查找在内存使用上更节省,但是内存紧缺的情况并不多,实际上通过二分法找到值等于给定值的场景很少,二分法更适合“近似”查找问题,这这类问题上,二分查找的优势更加明显。
跳表
跳表结合了数组和链表的优点,跳表除了空间复杂度稍高,时间复杂度可以媲美二叉树,是非常优秀的数据结构。
跳表的难点在于索引的动态更新,可以通过随机函数,决定将添加的结点插入到第几层索引层中去。
散列表
散列表用的是数组支持下标随机访问数据的特性,所以散列表其实就是数组的扩展,由数组演化而来。
- 散列函数
我们把键Key或者关键字转化为数组下标的映射方法叫做散列函数,散列函数计算的值叫做散列值或哈希值。散列函数的设计方法:直接寻址法、平方取中法、折叠法、随机数法。
- 散列函数计算的值是非负整数;
- 如果key1 = key2,那hash(key1) = hash(key2);
- 如果key1 != key2,那 hash(key1) != hash(key2);
装载因子
不管使用哪种探测方式,散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了保证散列表的操作效率,一般情况下我们需要保证散列表中有一定比例的空闲位。这个比例就是装载因子:
散列表的装载因子 = 填入表中的元素个数 / 散列表的长度。散列冲突(鸽巢原理)
散列函数中要求的第三点几乎是不可能的,要将无限的数据,用有限空间对应的有限hash值来一一对应,必定是不可能的,也就带来了哈希冲突。开放寻址法
当出现散列冲突时,就重新探测一个空闲位置,将其插入。
线性探测
在往散列表插入数据时,如果某个数据经过散列函数计算的位置已经被占用,此时就从当前位置依次往后寻找空闲位置,直到找到为止。
在查找元素时,通过散列函数计算到的散列值找到对应下标位置的数据,然后和要查找的元素比较。如果相等,则说明是要找的元素,不相等则按顺序依次往后寻找,如果遍历到了数组中的空闲位置还没找到,说明要查找的元素不在散列表中。
删除元素:当删除散列表中的元素不能直接删除将位置置为空,因为空位置用来作为查询元素是否查找到的标志。我们可以将要删除的元素标记为deleted。这样当线性探测探测到deleted的空间就不会停下,而是继续往后探测。
二次探测
线性探测的步长是1,二次探测的步长是原来的2次方,也就是碰到位置占用时,每次往后探测1^2 个位置、2^2 个位置...。
双重散列
不仅使用一个散列函数,准备多个散列函数,每次先用第一个散列函数,如果计算到的位置已经被占用就使用第二个函数,以此类推直到找到空闲的存储位置。
数据量比较小,装载因子小的时候适合开放寻址法。链表法
链表法相对于开放寻址法要简单的多,在散列表中每个桶(bucket)或者槽(slot)会对应一个链表,所有散列值相同的元素都放在相同槽位对应的链表中。
插入操作,无论是槽位还是链表时间复杂度都是O(1),当查找、删除一个元素的时间复杂度跟链表的长度成正比,也就是O(k), k = n / m,n是散列表中数据个数,m是散列表中槽的个数。
链表法相对于开放寻址法,对大装载因子的容忍度更高,更适合存储大数据量和大对象。因为对于比较小的对象而言,指针是比较消耗空间的,极端情况下会导致内存翻倍,而且因为链表的结点是零散分布在内存中的,不是连续的,对于CPU缓存来说也不够友好,这方面对于执行效率也有一定的影响。
链表法更加灵活,可以改造成跳表、红黑树。
散列表碰撞攻击
通过精心构造的数据,使得所有数据都被分配到同一个槽中,让散列表操作的时间复杂度退化到O(n),在散列表元素数量足够大时,就会导致操作时间被急剧拉长。动态扩容
散列表的装载因子变大时,散列冲突就会难以接受需要动态扩容。
针对散列表的扩容,数据搬迁通常更为复杂,因为散列表的大小变了,数据的存储位置也变了,需要重新用散列函数为每个元素重新计算散列值对应的位置。
没有扩容时,插入操作的时间复杂度是O(1),最坏情况下,扩容时的时间复杂度是O(n),通过摊还算法可以得出,时间复杂度接近最好情况O(1)。
装载因子需要权衡时间和空间复杂度。如果内存紧张,对执行效率要求不高,可以增加装载因子的值。如果内存不紧张,对执行效率要求高,可以减小装载因子的值。如何避免低效扩容
如果原散列表存了1G数据,此时空间已满,再插入数据,就需要先重新计算1G数据的散列值进行搬迁然后再插入新数据。此时一次性扩容带来的个别数据极低的效率,可能会让用户崩溃。
将扩容的操作穿插在新的插入操作中,分批完成。当空间已满时,我们只申请新的控件而不搬迁数据,当每次插入新的数据时从数组首部取出一个数据重新计算存储位置然后携带到新的空间。这样经过多次操作后,老数据就会一点点的搬迁完毕。
查询时需要先查询新的散列表,如果没有查询到再到老的散列表中查询,这种实现方式就使得插入数据的时间复杂度都是O(1)。工业级散列表
- LinkedHashMap
Linked不仅仅代表是使用链表来解决hash冲突,linkedHashMap是通过散列表和链表组合一起实现的,可以支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据(天然支持LRU缓存淘汰策略的缓存系统)。Linked指的是双向链表。 - HashMap
初始大小16,装载因子0.75,每次扩容都是原始大小的2倍。采用链表法结局散列冲突。JDK1.8引入了红黑树,在链表长度大于8时,链表就会转化为红黑树,少于8个时会由红黑树转换为链表。
工业级散列表具有哪些特性:
支持快速的查询、插入、删除操作
内存占用合理,不能浪费过多内存空间
性能稳定,极端情况下,散列表的性能也不会退化到无法接受的地步
如何设计工业级散列表:
- 设计一个合适的散列函数
让散列后的值分布的随机且均匀,尽量减少散列冲突,即使冲突后,分配到每个槽里的数据也比较均匀。散列函数也不能设计的太复杂,太复杂就会太耗时间,也会影响散列表的性能。 - 定义装载因子阈值,设计动态扩容策略
随着数据增多,散列表总会出现装载因子过高超过阈值的情况,这个时候就需要启动动态扩容。 - 选择合适的散列冲突解决方法
大部分情况下,链表法更加的普适。链表法还能改造成其他的数据结构进行优化,如红黑树、跳表等,即使数据都冲突到一个槽里,查找效率也是O(logn)而不会退化到O(n)。但是小规模数据,装载因子不高的散列表,比较适合开放寻址法。
树
- 树的基础概念
- 高度:从叶子结点高度为0往上递增到跟结点
- 深度:从根结点高度为0往下递增到叶子结点
- 层:根结点层数为1,往下递增
- 叶子结点全部在最后一层叫做满二叉树
- 叶子结点都在最后两层,且最后一层的叶子结点都靠左排列,除了最后一层其他层的结点个数要达到最大,叫做完全二叉树
- 完全二叉树在数组存储时能节省空间,数据连续存储
- 二叉树的遍历【掌握】:前序遍历 ,中序遍历, 后序遍历, 按层遍历
- 二叉查找树基本操作:查找、插入、删除
- 二叉树的删除操作:当要删除的结点有两个子结点时,找到这个结点的右子树中的最小结点或者左子树中的最大结点(也可以说是用中序遍历得到的待删除节点的前驱节点或后继节点),将其替换到删除节点上(也可以简单把删除的结点标记为已删除)
- 二叉查找树,用中序遍历,可以输出有序的数据序列,时间复杂度是O(n),二叉查找树又叫二叉排序树
- 二叉查找树需要支持重复数据时:1.通过链表扩展相同的数据存储到同一结点上;2.如果碰到一个值与要插入的数据值相同,就把要插入的数据放到这个结点的右子树,把新插入的数据当做大于这个结点来处理。当要查找数据时,找到相同值时,继续在右子树中查找,直到遇到叶子结点。
- 二叉查找树的时间复杂度分析:
- 树左右子树及其不平衡时退化为数组,查找,插入、删除的时间复杂度是O(n)
- 满二叉树或完全二叉树时,时间复杂度就是树高O(height),也就是求n个节点的完全二叉树的高度。第K层结点数为2^(K-1),高度接近于logn,平衡二叉树的插入、删除、查找操作的时间复杂度比较稳定都是O(logn);
- 二叉树与散列表:
- 散列表数据无序
- 散列表扩容耗时
- 尽管散列表的查找时间复杂度是常量级的,但是因为哈希冲突的存在,这个常量不一定比logn小
- 散列表需要考虑的东西太多,散列函数的设计、冲突解决方法、扩容、缩容
- 为了避免过多的哈希冲突,装载因子不能太大,特别是基于开放寻址法,会浪费一定的内存空
- 二叉树、平衡二叉树、平衡二叉查找树、红黑树
1.平衡二叉树:二叉树中任意一个节点的左右子树的高度相差不能大于1,完全二叉树和满二叉树都是平衡二叉查找树,但是非完全二叉也有可能是平衡二查找树;
2.二叉查找树,有序的二叉树,增加删除都需要满足有序性;
3.平衡二叉查找树不仅满足平衡二叉树的定义,还满足二叉查找树的特点。
4.二叉树与平衡二叉树:插入的左旋右旋,删除时的特殊判断
5.掌握平衡二叉树的左旋右旋
6.平衡二叉树中的平衡是指让树的形态看起来比较左右对称,不会出现左右子树高度相差悬殊,这样就能使整棵树的高度不至于太高,插入、删除、查找操作的效率就会高一些,只要高度不超过log2n仍然可以定义为一颗合格的平衡二叉树。
树节点的插入和删除操作
插入时,对关注节点进行符合树特征的调整,调整后将父节点当成关注节点继续往上调整。
删除
- 删除的是什么节点
- 删除了节点之后是否满足树的特征
- 删除场景组合:一个key或非1个key的节点和是否为叶子节点的组合
- 无论怎么删除,第一步都是将删除节点换成前驱后继的叶子结点,把问题转换成叶子结点的删除后树失衡的调整。
AVL树
- AVL树是平衡二叉树的一种,严格符合二叉平衡树的定义,任何节点的左右子树的高度差不超过1,是一种高度平衡的二叉查找树。
- AVL树是高度平衡的二叉树,所以查找的效率很高,但是为了维持这种高度的平衡每次插入、删除都要做调整,比较复杂耗时。对于插入、删除比较频繁的数据集合,不适合用AVL树。
23树
节点有1或2个key,对应有2或3个节点,高度平衡
根节点到所有子节点的节点个数相同
234树
节点有1或2或3个key,对应有2或3或4个节点,高度平衡
根节点到所有子节点的节点个数相同
红黑树
红黑树由234树转化而来,只需将红色节点合并到父节点中即可,一个红色子节点的父节点变成2个key,两个红色子节点的父节点变成3个key
红黑树不算严格意义上的平衡二叉树,从根节点到叶子节点的最长路径有可能是最短路径的一倍;
红黑树做到近似平衡,所以在维护时的成本就比AVL树低,插入、删除、查找的时间复杂度都是O(logn)这种性能稳定的平衡二叉树适合在工业级应用上;
-
红黑树性质
- 节点是红色或黑色
- 根节点是黑色
- 每个叶子节点都是黑色的空节点(NIL),叶子节点不存储数据
- 从每个叶子到根的所有路径上不能有两个连续的红色节点,红色节点是被黑色节点隔开的
- 从任一节点到其叶子节点的所有路径都包含数目相同的黑色节点
从根到叶子的最长可能路径不多于最短的可能路径的两倍
以删除的黑色叶子节点为其父节点的左孩子为例,如下:
1.当兄弟节点为红色时,互换父节点与兄弟节点的颜色,然后对当前节点的父节点进行左旋
2.当兄弟节点的左孩子与右孩子都为黑色(或没有子节点)时,设置兄弟节点的颜色为红色,然后将父节点看作当前节点,然后重新根据1,2,3,4的情况,调整当前节点
3.为当兄弟节点的左孩子为红色,右孩子为黑色或空时,则互换兄弟节点与兄弟节点左孩子节点的颜色,再对兄弟节点进行右旋,修正当前节点的兄弟节点,再将兄弟节点与父节点互换颜色,并修改兄弟节点的右孩子的颜色为黑色,最后对当前节点x的父节点进行左旋
4.当兄弟节点的右节点为红色时,兄弟节点与父节点互换颜色,并修改兄弟节点的右孩子的颜色为黑色,然后对当前节点x的父节点进行左旋
若删除的黑色叶子节点为其父节点的右孩子,与以上互为镜像。
以上的情况,最后当前节点若为根或者为红色,则将当前节点调整为黑色,然后完成调整。满二叉树像AVL、2-3树、2-3-4树都是平衡的,每个节点的前驱节点和后继节点都是叶子结点。但是红黑树节点的前驱节点和后继节点不一定是叶子结点,有可能是只有左子树或者只有右子树的父节点。
若前驱节点或后继节点只有左孩子或只有右孩子的父节点,则父节点必定是黑色,且仅有的子节点必定为红色;
关于删除:
https://www.jianshu.com/p/e136ec79235c
https://blog.csdn.net/qq_25940921/article/details/82184055
堆
- 掌握堆的插入、删除、建堆
- 堆的应用
- 优先级队列:合并有序小文件、高性能定时器
- 求TopK
- 求中位数:利用两个堆,大顶堆和小顶堆,如求99%
图
熟练写出深度优先和广度优先算法
非线性表数据结构
顶点:图中的元素
边:图中顶点与任意其他顶点建立的连接关系
度:顶点与其他顶点的边的数量
有向图:顶点与顶点的连接关系是单向的
无向图:顶点与顶点的连接关系是双向的
入度:有多少边指向这个顶点
出度:这个顶点指向其他顶点边的数量
带权图:每条边都有权重,如QQ好友的亲密度
邻接矩阵:底层依赖于二维数组,对于无向图,顶点i与顶点j有边,将arr[i][j]和arr[j][i]标记为1,有向图就只需存储一个。对于带权图存储相应的权重
邻接表:类似于散列表,每个顶点对应一个链表,链表中存储的是这个顶点相连接的其他顶点,对于有向图,每个顶点的链表存储指向的其他顶点
邻接矩阵优缺点:对于无向图来说浪费了一半空间,如果存储的是稀疏图,顶点很多但是边不多就更加浪费空间了。但是邻接矩阵存储方式简单、直接、基于数组,获取两个顶点关系时非常高效。其次方便计算可以将很多图的运算转换成矩阵之间的运算,如求最短路径有Floyd-Warshall算法
邻接表优缺点:用有向图时更省空间,查找更耗时间,链表的存储对于缓存不够友好,查询关系不够高效,但是基于链表我们可以将链表换成红黑树、跳表等。
-
应用:
- 邻接表:适合内存中使用
- 关系库:持久化存储
- 图数据库:超大图、涉及大量专业的图运算
深度优先与广度优先算法,被称为暴力搜索法,试用于状态空间不大,也就是图不大的搜索
广度优先bfs,理解为地毯式搜索,从起顶点开始,依次往外遍历,需要借助队列,遍历得到的路径就是起顶点到目标顶点的最短路径;
深度优先dfs用了回溯思想,非常适合递归实现,换种说法深度优先是借助栈来实现的;
深度优先和广度优先搜索的时间复杂度都是O(E),空间复杂度都是O(V),V是图顶点个数,E是图边的个数。借鉴
字符串匹配
-主串:原始串,模式串:用来去匹配的串
- BF算法(Brute Force)暴力匹配算法,从主串的第一个字符开始,截取模式串长度相同的串进行字符逐个比对,主串长度n,模式串长度m,最坏情况下每次需要比对m个字符,需要比对n-m+1次,这种算法最坏情况下的时间复杂度是O(n*m);
- RK算法(Rabin-Karp)算法,在BF算法的基础上,将验证主串与子串匹配这一步加入哈希算法,然后只需比较两个串的哈希值即可,每次比较的时间复杂度是O(1),总共需要比较n-m+1次,所以时间复杂度是O(n)。
- 使用字符串中包含字符类型的数量为进制数,依次对字符串按照这种进制换算出十进制结果作为哈希值。然后每次计算串的哈希值是,因为相邻的串只有第一个串的首字符和第二个串的尾字符不同,所以可以减少重复计算。进制中次方运算可以先计算出来放到哈希表中进一步优化。
- 进制数如果太大导致超出整数范围,可以考虑减少进制数,在遇到哈希冲突时,比如哈希值一致时,重新比较字符串即可。
- 思考题:二维字符串矩阵中查找另一个二维字符串矩阵?
· 将二维矩阵的每一列去做匹配,二维问题变成一维问题参考
BM算法
核心思想:利用模式串本身的特点,在模式串的某个字符串与主串不能匹配的情况下,将模式串向后多移动几位,以此来减少移动的次数,提高匹配效率。
- BM算法的两种方式,好后缀和坏字符,其中好后缀可以单独使用,坏字符比较消耗内存,两种方式结合使用能更大的提高搜索效率。
- 坏字符:当碰到不能匹配的坏字符时,如果模式串中不存在坏字符,则直接将模式串移动到坏字符后面,如果存在,则在模式串中找到并记录下标最大的坏字符的下标xi,主串坏字符对应模式串的下标记作si,然后移动si-xi位,将坏字符对齐;
- 坏字符,si-xi可能为负数的场景,主串为aaaaaa,模式串为baaa,获取si=0,xi=3;
- 好后缀1,主串的后缀串与模式串部分重叠,将好后缀放到模式串中查找(排除掉最后一种情况)找到下标最大的位置,然后移动模式串让这部分对齐,如果不存在则直接移动到好后缀的后面;
- 好后缀2,基于1,可能存在好后缀的后缀可能与模式串的前缀串重叠,此时,将模式串的前缀串移动对齐到好后缀的后缀处。
- 在用坏字符与好后缀计算模式串向后滑动的位数时,可以取最大的移动位数,这样即可避免坏字符移动负数位数的场景。
- 好后缀的两大原则
- 在模式串中,查找跟好后缀匹配的另一个子串;
- 在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串
- BM算法实现
- 在模式串中查找坏字符出现的最大下标时,可以通过记录模式串中每个字符和字符在模式串中出现的最大坐标位置,构建哈希表加快搜索效率;
- 预先计算模式串的每个后缀子串对应的另一个可匹配子串(下标最大的那个)的位置,因为后缀串的最后一个字符的位置是固定的,所以只需要记录长度就可以了,通过长度获取一个唯一的后缀子串。(定义一个suffix数组,key是长度value是子串的下标位置);
- 2中应对了好后缀在模式串中存在的场景,需要一个boolean类型的prefix数组记录模式串的后缀子串是否能匹配模式串的前缀子串。
字符串匹配kmp算法(代码很牛逼)
参考
- 关键思想1:主串指针永不回溯,只需要移动模式串的指针
- 关键思想2:next数组,动态规划求最长可匹配前缀子串,用发生过的推未来,先求次长可匹配后缀对应的前缀的后一位字符是否匹配,不能匹配继续找次次长,直到找到
Trie(easy)
AC自动机
- AC自动机的构建分两部分:1.多个模式串构建Trie树;2.在Trie树上构建失败指针(相当于KMP中的next数组,即用来获取当前模式串的最长可匹配后缀对应的所有模式串匹配的前缀);
- 每个节点的失败指针只有可能出现在它所在层的上一层;
- 像KMP算法一样,可以通过已经求得的深度更小的节点的失败指针来推导;也就是说逐层依次求解每个节点的失败指针;
- 失败指针的构建过程就是按层遍历树的过程;
贪心算法
- 每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据;
- 用贪心算法解决问题的思路,并不总能给出最优解;
- 假设有100、99、1三种面值的币,396元最少的币数量怎么选择,用贪心算法在当前情况下选择面值最大的会使币最少,结果是3张100和和96张1元,而不是3张99
- 给定一个加权图,求起点到终点的权值和最小,第一段路径有1,2,4,1-4-4,2-2-2,根据贪心算法第一段路径优先选择1,使得最后结果1-4-4权值和并不是最优解
- 三个小问题
- 分糖果,m个糖果和n个小孩,m
- 钱币找零,支付k元,有1、2、5、10、20、50、100等面值的币,求最小纸币树,先用面额最大的纸币,不够用次大的,以此类推;
- 区间覆盖,有n个区间,求从x到y中最多的区间数,从x开始找第一个贴近x并且给右侧让出最大空间的区间,以此类推。
- 分糖果,m个糖果和n个小孩,m
- 典型问题
霍夫曼编码,根据文本中每个字符出现的频率,选择不同长度的编码方式,并且各个字符的编码之间不会出现某个编码是另一个编码前缀的情况:- 找出字符和字符对应的频率,按频率顺序将字符依次加入队列;
- pop出频率最低的两个字符构成树节点,对该两节点生成新的父节点且频率等于子节点频率之和,父节点入栈;
- 重复2,最后构建成树,树所有指向左子节点边权重都是0,右子节点都是1,然后根据从根节点到子节点的路径排列得出每个节点的二进制编码。
分治算法
- 分治算法概括为“分而治之”,将原问题分解为n个规模较小而结构与原问题相似的子问题,递归的解决这些子问题,然后再合并结果,就能得到原问题的解;
- 分治算法的递归实现中,涉及三个操作:
- 分解:将原问题分解为一系列子问题;
- 解决:递归地求解各个子问题,若子问题足够小,则直接求解;
- 合并:将子问题合并成原问题。
- 分治算法能解决的问题,一般满足以下条件:
- 原问题与分解成的问题具有相同的模式;
- 原问题分解成的问题可以独立求解,子问题之间没有相关性;
- 具有分解终止条件,也就是当问题足够小时,能直接求解;
- 可以将子问题合并成原问题,而这个合并的操作的复杂度不能太高,否则就起不到减少算法总体复杂度的效果了。
- 重点复习归并排序,用分治算法求有序度
回溯算法
穷举所有的可能,描述所有可能的递归
- 01背包(重点)
- 八皇后
- 正则表达式
动态规划
- 能够避免直接递归实现中出现的重复运算的技术就是动态规划参考
- 满足“一个模型三个特征”的问题适合用动态规划来解决;
- 一个模型:问题可以抽象成分阶段决策最优解模型;
- 三个特征:最优子解、无后效性、重复子问题
- 动态规划的两种解题思路:
- 状态转移表法:回溯算法实现-定义状态-画递归树-找重复子问题-画状态转移表-根据递推关系填表-将填表过程翻译成代码;
-
状态转移工程法:找最优子结构-写状态转移方程-将状态转移方程翻译成代码;