最重要的复杂度问题:
时间复杂度: 并不表示代码真正的执行时间 只表示代码执行时间随数据规模增长的变化趋势(所以即使某段代码常量1000000 虽然对这段代码执行时间来说是有影响 但是只要不涉及n 我们就忽略)
所有代码的执行时间 T(n) 与每行代码的执行次数成正比 T(n) = O(f(n)) O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。
低阶、常量、系数 都就可以忽略 只需要记录一个最大量级
分析方法:(1)只关注循环执行次数最多的那一段代码就可以了
(2)总复杂度等于量级最大的那段代码的复杂度
(3)嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
多项式时间: O(1) 只要代码的执行时间不随 n 的增大而增长 算法中不存在循环语句、递归语句
O(logn)(循环不断乘以一个数以至于达到n) O(nlogn)(其实就是O(logn) 循环n次) 底数可以忽略 直接写成O(logn)
O(n) :如果两个变量m n 不知道哪个大 那就是O(m+n)
O(n平方) O(n立方) O(nk次方)
非多项式时间:O(2的n次方) O(n!) np问题 当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长
---为了表示代码在不同情况下 n的不同时间复杂度(例如有时候n循环提前结束):
(1)最好情况时间复杂度:
理想情况下执行这段代码的时间复杂度
(2)最坏情况时间复杂度
(3)平均情况时间复杂度(加权平均时间复杂度)
(4)均摊时间复杂度:分析方法:平摊分析
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,一般等于最好情况时间复杂度
空间复杂度:比较简单 就是判断占用空间多少(也是以n计算) 一般是O(1)、O(n)、O(n2)
数据结构
(1)数组:一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据
提一点 在数组中插入如果不理会顺序 可以直接在该位置插入 把这个位置的数字放到末尾 ---快排
在数组中删除也可以在该位置删除然后把后末尾的移动过去 或者先标记好删除的元素 在最后没空间的时候一次性来一次删除操作----jvm的清理机制
arraylist 和 数组的选择:arraylist只能用包装类 拆包装包是有性能消耗的 --开发底层代码例如框架的时候可以选择数组
数据大小已知 并且不需要那么多封装好的api 可以直接用数组
在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高
(2)链表:通过指针将一组零散的内存块串联在一起
链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。
对链表进行频繁的插入、删除操作会导致频繁的内存申请释放===》内存碎片====》频繁的GC
单链表
双向链表 (例如LinkedHashMap):每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点 ------比单链表优点: 如果需要在某个节点前插入一个节点或者删除所在的节点 可以直接索引到他的前驱节点而不用重新遍历
循环链表:循环链表的尾结点指针是指向链表的头结点
小应用:可以用 散列表+单链表 实现LRU缓存
----------------怎么写好链表代码:e.g 反转 有序链表合并等--------------------------
(1)理解好指针
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量
(2)警惕指针丢失(自己指向自己)和内存泄漏
(3)利用哨兵(没有值)简化实现难度: 哨兵解决边界问题
针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理
引入哨兵节点后 head指针会一直指向这个哨兵节点 此时进行插入 删除就不用特殊处理第一个和最后一个节点
(4)注意监测边界条件:
如果链表为空时,代码是否能正常工作?
如果链表只包含一个结点时,代码是否能正常工作?
如果链表只包含两个结点时,代码是否能正常工作?
代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
链表的几个问题:
单链表反转: 递归法 遍历法
链表中环的检测 :快慢指针法 足迹法
两个有序的链表合并 :while循环法(跟合并两个有序数组是一样的) 递归法(专门针对链表的)
删除链表倒数第 n 个结点: 递归计数法 还有一个牛逼的:双指针法!
求链表的中间结点: 先遍历一次记录个数n 再遍历n/2拿中间节点 仍然可以用双指针法(两倍速度的遍历到末尾时 一倍速度的就是中间)!
(3)栈
顺序栈:用数组实现的栈
链式栈:用链表实现的栈
(4)队列:可以应用在任何有限资源池中,用于排队请求
顺序队列:用数组实现的队列
链式队列:用链表实现的队列
循环队列:解决顺序队列入栈数据搬移导致时间复杂度o(n)的问题
循环队列满:规定为尾指针的下一个指针到头指针时 (也可以设置一个标识位来标志是满还是空 因为两个状态都是head = tail) : (tail+1)%n=head 会浪费一个空间
阻塞队列:
并发队列:线程安全的阻塞队列
(5)二叉树
每个节点最多有两个子节点(包括满二叉树和完全二叉树)
1链式存储法 : 三个字段 数据 指向左右子节点的指针
2数组存储法:从1开始存储 节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点 --------非完全二叉树会浪费很多空间(所以完全二叉树的规定是最后两层 且都靠左 并且完全二叉树用数组存储最省空间)
二叉树的前、中、后序遍历就是一个递归的过程
二叉树遍历:每个节点最多访问两次,时间复杂度o(n)
(6)二叉查找树(二叉搜索树,二叉排序树:因为可以中序遍历o(n)输出一个有序的数据序列):支持快速查找 插入 删除一个数据 类似o(1)的散列表
在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
时间复杂度:三个都是跟高度成正比
平衡二叉查找树的高度接近 logn :O(logn) 但是有的不平衡的二叉查找树如果像链表一样就退化成o(n)了 所以平衡很重要
散列表插入删除查找都是o(1),那即使是平衡二叉查找树的优点在哪里?
1散列表数据是无序的, 要输出有序的数据需要另外排序,而二叉查找树只需要中序遍历既可以o(n)输出
2散列表扩容耗时很多 而且容易因为散列冲突而导致时间复杂度不稳定 但是平衡二叉树使用起来稳定趋近于o(logn)
3哈希函数耗时 且 o(1)的常量查找时间并不一定比 o(logn)小
4散列表需要考虑 散列因子 扩容 缩容 哈希冲突 哈希函数性能等 平衡二叉树只需要考虑平衡(且这点的解决方法已很成熟)
(7)平衡二叉查找树avl:目的只是为了尽量保证左右子树高度低一点 为了解决二叉查找树因为动态更新导致的性能退化问题 时间复杂度就更稳定于o(logn)(红黑树不是完全定义上的平衡二叉查找树 但是只要在logn量级附近即可 ) 且为了维持高度的平衡 插入删除都要做调整 所以比较复杂耗时 对于插入删除操作多的不适合使用avl
二叉查找树 + 二叉树中任意一个节点的左右子树的高度相差不能大于 1
红黑树: 高度只比avl树最多大了一倍(不准确 实际上红黑树性能更好)
根节点是黑色
红黑树中的节点,一类被标记为黑色,一类被标记为红色
每个叶子节点不存储数据 都是黑色的空节点(NIL)
父子之间不会出现相连的红色节点,被黑色节点分开
每个节点到达他可以到达的叶子节点中经过的路径 每个路径上的黑色节点数量相同
红黑树只是近似平衡 以保证插入 删除 查找各项性能都比较稳定 都是o(logn)
比较一下对于插入删除查找常用的算法:
散列表:插入删除查找都是O(1), 是最常用的,但其缺点是不能顺序遍历以及扩容缩容的性能损耗。适用于那些不需要顺序遍历,数据更新不那么频繁的。
跳表:插入删除查找都是O(logn), 并且能顺序遍历。缺点是空间复杂度O(n)。适用于不那么在意内存空间的,其顺序遍历和区间查找非常方便。
红黑树:插入删除查找都是O(logn), 中序遍历即是顺序遍历,稳定。缺点是难以实现,去查找不方便。其实跳表更佳,但红黑树已经用于很多地方了。
--红黑树调整: 遇到哪种排布进行哪种调整 (红黑树规定最后叶子节点一定是黑色空节点是为了让插入删除时候平衡操作更有规律 简洁 实际中使用公用的一个黑色节点即可)
插入操作:插入的节点必须是红色的 + 新插入的节点都是放在叶子节点上 ,插入后破坏了红黑树 就需要 左旋转或者右旋转来重平衡
删除操作:两次调整 分别解决 红黑树定义的 第四个 和 第三个问题
(8)递归树: 借用递归树可以分析递归算法的时间复杂度
(9)堆: 堆排序 空间复杂度o(1) 时间复杂度o(nlogn)
堆 是一种特殊的树 : 完全二叉树 + 每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值(大顶堆 小顶堆)
1既然是完全二叉树 那就可以使用数组来存储 是比较节省空间的
2 插入操作因为都是插入在最后的节点 数组的末尾 所以肯定要进行调整
注意的是删除堆顶操作的时候 可以移动最后一个节点 放到堆顶 然后往下比较看谁是小的 一个一个交换 来满足完全二叉树的要求
插入和删除堆顶 都是 o(logn) ,因为不想二叉搜索树 可以很快定位到特定节点 所以查找和根据指定数删除比较慢
----堆应用:
1优先级队列 因为堆插入删除o(logn) 比循环数据进行判断o(n)强
2基于优先队列的高性能定时器 :定时器可以在最小堆顶元素在需要调用的时候再进行触发任务 然后重新堆化拿到最小堆顶元素 计算下一次最近的任务
3基于优先队列的多路合并文件
4计算 静态 / 动态 top k 问题 建立好一个静态数组前k大的堆 (k+1个元素的数组) ,动态的话在新插入数据的时候更新O(nlogK)
5利用堆求动态数据中 中位数,可以推广到求任何数据中n%位置的值 利用一个大顶堆存储小的数 小顶堆存大的数(利用堆的目的就是动态数据的插入和删除可以用堆来操作 o(logn))
(10)图:
1利用二维数组存储 无向图 有向图 值就是权重
缺点:容易浪费内存空间
优点:存储方式简单直接 获取节点关系方便直接高效
运算方便
2临接表存储方式:
为每个节点开辟一个数组的位置 位置上存储该节点指向的链表(其实就是散列表)
优点:省空间
缺点:查询不方便
可以利用临接表和逆临接表来存储指向 和 被指向
可以利用图进行深度优先 和 广度优先搜索
(11)trie树 字典树(搜索引擎使用) 多叉树 字符串前缀公用同一个节点
专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题
构建时间复杂度:o(n)所有字符串长度和
构建好后的查询时间复杂度:o(k) k是要查询的字符串的长度,显而易见 很快速
以内存换时间(每个节点维持26个空间的数组 虽然可以对数据结构进行变形,或者缩点优化)
缺点:
字符集不能太大 会浪费存储空间 --即使优化也牺牲查询 插入效率的代价
要求字符串前缀重合比较多 不然空间消耗会变大 (看具体的空间策略)
因为用的指针 对缓存不友好
优点:更适合用来查找前缀匹配的字符串,例如百度搜索 通过前缀搜索可能存在的全文 或者自动补全功能,红黑树和散列表更适合精确查找
单模式串匹配:每个模式串需要重新扫描一遍主串
多模式串匹配:只需要扫描一遍主串 就可以知道多个模式串是否存在 例如trie
ac自动机 改良版本的tire树 类似加了next函数的KMP:
(1)将多个模式串构建成 AC 自动机:
模式串构建成 Trie 树 + 在 Trie 树上构建失败指针
(2)在ac自动机中匹配主串
算法
(1)递归:
*****一个问题的解可以分解为几个子问题的解:最重要的概念 问题都是通过这个概念来列出数学等式
这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
存在递归终止条件
怎么写?:写出递推公式,找到终止条件 (通过逻辑 列出递归公式 翻译成代码即可 不用思考更细节的每一次是怎么调用的 代码思维就是这么简单 只需要代码的逻辑跟公式的逻辑相吻合就是成功的)
写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码,把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤
防止递归导致栈溢出:设计一个变量记录递归层次 达到后跑出异常
避免重复计算:在递归时候遇到多个分支的情况下 这个分支和另外一个分支的子分支可能会重复计算,可以通过数据结构(散列表或者其他)来保存求解过的f(k)
缺点: 经常会有时间复杂度(递归调用相当于循环 O(n))+空间复杂度(每一次递归都压栈至少O(n)) 都很大的情况(空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题)
优点:递归代码的表达力很强,写起来非常简洁
---怎么改成非递归----:可以用循环(动态规划) !!!!搞清楚递归和动态规划!!
(2)排序:
怎么分析一个算法:
1 最好情况、最坏情况、平均情况时间复杂度 并且知道 排序算法在不同数据下的性能表现。
2 时间复杂度的系数、常数 、低阶 (实际的算法评估就不能省略了 因为n也不是无穷大)
3比较次数和交换(或移动)次数
4算法的稳定性 (在实际业务中遍历对象的时候可以简化排序操作)
开始学习实际算法:
逆序度 = 满有序度 - 有序度。我们排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是n*(n-1)/2–初始有序度 -----可以求解平均时间复杂度
--------------------------------------------------O(n2)适合小规模的数据排序(这是理论 实际应用得看具体系数 数据量小的时候可能o(n2)更好)-------------------------------------------------------------------
1冒泡排序:冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作: 元素的比较+元素的对换
空间复杂度 o(1)
稳定的排序算法
最好情况时间复杂度是 O(n) 最坏情况时间复杂度为 O(n2) 通过有序度可以知道:平均情况下的时间复杂度就是 O(n2)
2插入排序:元素的比较+元素的移动
取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束
对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度
空间复杂度:o(1)
稳定
时间复杂度: 最好o(n) 最差o(n2)
平均:通过n*(n-1)/2 取平均数n*(n-1)/4 所以是o(n2) (注意如果通过复杂度来计算 是包括了找到插入的位置+移位操作的 因为表示的是最终的一种有序的状态)
*****时空复杂度都相同,但是插入比冒泡好 因为就实际代码来说 插入排序只需要一次赋值操作 而冒泡需要3次 ******
3选择排序:
每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾
空间复杂度:o(1)
时间复杂度 :最好最差平均都是o(n2)
不是稳定的!:在选择最小的+交换的时候 可能会打乱被交换的元素在原本元素中跟他相等的值得顺序
判断是否稳定 就是看这个算法改变位置的操作是否会改变原本相等数组元素的顺序! 很大一个特点就是冒泡和插入在遍历的过程中都是保持着顺序的 而选择排序直接选一个最小的进行交换 无视了原数组中的顺序
--------------------------------------------------------------O(nlogn)适合大规模的数据排序----------------------------------------------------------------------
1归并排序(分治思想 意味着可以用递归来实现)
归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了
可以利用哨兵简化merge()
稳定的算法:合并函数并没有改变原本元素的顺序
最好情况,最坏情况,还是平均情况,时间复杂度都是 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 的
时间复杂度:大部分情况下的时间复杂度都可以做到 O(nlogn),只有在极端情况下,才会退化到 O(n2)
空间复杂度:O(n2)
如何解决快速排序最坏情况下o(n2)的情况?:因为出现这种情况是分区选择问题(数组原本就有序选择了最后一个节点 最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。)
(1)可以选取 首尾中(看数据大小 可以选择多) 取中位 当作分区点
(2)随机取数法 随机----》间复杂度退化为最糟糕的 O(n2) 的情况,出现的可能性不大
如何解决递归栈溢出?:
(1)限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归
(2)在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制 -》 把使用栈内存转换为使用堆内存
3 堆排序 空间复杂度o(1) 时间复杂度o(nlogn) ,实际应用中快排比堆排好 为什么呢?
1建堆o(n) 两种方式 一个一个插入时候立马调整 或者 全部元素插入完毕之后从下往上调整
2排序 O(nlogn) 建堆成功后 把堆顶元素删除(跟堆末尾交换) 然后重新调整 再删除 就能每次都把最大值放在末尾了
所以总的时间是 O(nlogn)
不稳定 ---- 因为存在堆顶和尾的交换
缺点: 为什么咩有快排好
1数据访问是跳着访问的 例如建堆的时候 需要 1 2 4 8 16地访问 对cpu缓存不好
2因为有o(n)的建堆 时间复杂度其实会比快排更高 逆序度可能会变高 交换次数更多 ---例如原本有序的建堆后却变成无序的了
---------------------------------------------------o(n)非基于比较的排序算法,都不涉及元素之间的比较操作---------------------------------------------------------------------------
1桶排序:
将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了
时间复杂度:桶的个数 m 接近数据个数 n 时 为O(n)
比较适合在外排中,把大文件按顺序桶在外存中分成几个文件 读取每个文件进行排序 然后按顺序把文件写入一个大的文件即可 ,如果不均匀就在特定的大小中进行分割
不适合的情况:数据范围k远大于n
稳定
2计数排序:
其实就是 n个元素 0 - k 每个桶只有一个元素 桶的大小是从0 - k 每个桶内都是相等的元素 所以o(n)
一般可以通过数组完成计数排序,一个原始数组数量n 值0 - k 遍历存储在一个k大小的数组b 数组元素进行叠加处理 此时每个元素的值就是对应在排序好的数组的下标值+1 ,逆序遍历原数组 发现一个 k 就找对应的b数组的对应下标里的值 -1 存储在最终的数组中(更新b数组对应的值 -1)
试用条件: 如果k远大于n 不合适
只能给非负整数排序
稳定
3基数排序:例子 10万个 11位数的手机号进行排序
从尾到头每一位进行稳定的排序 例如桶排序 -> o(n)
适用范围:
(1)需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了
(2)每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了
可以解决桶排序和计数排序 k 》》 n的问题
(3)查找算法:
(1)二分查找:
针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0,可以用循环或者递归实现(假设此时是不存在重复数据的)
O(logn):有时候在实际算法中 O(logn)可能比o(1)更好 因为o(1)可以是o(100000) 但是O(log2n)其实可以很小
代码编写三个条件:
1low<=high,而不是 low
2mid = low+((high-low)>>1)
3low=mid+1,high=mid-1 如果直接写成 low=mid 或者 high=mid 在集合中并没有这个元素的时候会死循环
局限性:
1 不可以应用在链表上 只能是顺序表(数组 因为需要根据下标随机访问 否则复杂度太高)
2 针对的是有序的数据
3只能用在插入、删除操作不频繁,一次排序多次查找的场景中
4只有数据量比较大的时候,二分查找的优势才会比较明显(但是如果即使数据量不大 但每次比较时间过长 想要减少比较次数 也可以使用二分)
5太大的数据也不能用二分,因为数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻
---****最难的是二分查找的变形: 要注意三点: 终止条件、区间上下界更新方法、返回值选择
变体一:查找第一个值等于给定值的元素(存在重复的数据)
找到一个相等的值后需要判断是否在0上或者前一位是不是不是这个值了
变体二:查找最后一个值等于给定值的元素
找到一个相等的值后需要判断是否在n-1上或者后一位是不是不是这个值了
变体三:查找第一个大于等于给定值的元素
在大于等于某个元素后判断是否是0上的元素或者前一个元素是否小于给定值
变体四:查找最后一个小于等于给定值的元素
求值等于给定值的二分查找确实不怎么会被用到,在内存不紧张的情况下 可以用散列表、二叉树实现(虽然二分查找不需要额外内存 但是二分查找毕竟是数组 对插入删除等动态数据操作不友好),但是“近似”查找问题就不能用散列表 二叉树了 只能用变形二分查找
附:如果数组是循环数组,两个递增组合成的 二分查找照样适用 只不过我们每次看划分出来的递增数组中是否存在你要的元素了
(2)跳表(链表加多级索引的结构): 可以支持二分查找的链表(因为改造过) redis的有序集合也是用跳表
一种各方面性能都比较优秀的动态数据结构,可以支持快速的插入、删除、查找操作,写起来也不复杂,甚至可以替代红黑树(也支持快速的插入、删除、查找)
最高等级索引只有两个节点(间隔两个取一个索引)情况下可达到 时间复杂度O(logn) :即在单链表上实现了二分查找(空间换时间)
空间复杂度:O(n)
插入、删除操作:O(logn)
跳表索引动态更新:通过 随机函数 来维护,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中
redis有序集合使用跳表而不是红黑树:
插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。跳表代码相对更容易写,更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗
(3)散列表:是数组的一种扩展,由数组演化而来,插入删除查找都是o(1)
通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置
三个条件:
散列函数计算得到的散列值是一个非负整数;
如果 key1 = key2,那 hash(key1) == hash(key2);
如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
解决散列冲突:
开放询址法 :线性探测 、二次探测 、双重散列
优点 :可有效利用缓存
方便序列化
缺点:删除数据麻烦 需要特殊标记
载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间
总结:当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因
链表法
优点:内存利用率高
对大装载因子的容忍度更高
缺点:为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,且对缓存不友好
总结:基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表
怎么设计合理的散列函数:
1不能太复杂,不能消耗太多时间 时间复杂度要尽量低
2散列函数生成的值要尽可能随机并且均匀分
--(1)数据分析法
(2)总数求模取余法
装载因子过大怎么办?:
扩容,需要通过散列函数重新计算每个数据的存储位置
如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1
如何解决扩容时时间复杂度过高?:
将扩容操作穿插在插入操作的过程中,分批完成,当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程;对于查询操作:我们先从新散列表中查找,如果没有找到,再去老的散列表中查找,这种实现方式,任何情况下,插入一个数据的时间复杂度都是 O(1) (虽然通过均摊)
哈希算法应用:
安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储
基于哈希的字符串匹配:
(1)BF算法:
在主串中从0开始一直查找到n-m位置符不符合
时间复杂度:O(n*m)
实际中却比较常用:一般主串 模式串不会特别长 而且不需要每次比较m个 比较到不匹配就结束了
简单 一般性能满足后首先选择算法简单的 这样出错概率小 也好排查问题
(2)RK算法:
哈希n-m+1个值 跟模式串的哈希值进行比较 比较数字就能快很多
用特殊的哈希值算法来提高效率 因为模式串中一定长度字符前后是有规律的 所以一次扫描o(n)即可以算出 时间复杂度也是o(n) 最坏会退化到o(m*n)
(3)BM算法:匹配到不合适的时候往后跳
1坏字符规则
2好后缀规则
(4)KMP算法:对于已经比较过的好前缀 在遇到坏字符后能否跳过
可事先在模式串中计算好最长后缀和相匹配的最长前缀 , 当最好前缀匹配一段后遇到一个不好的字符 就在模式串最好前缀中找到一段最长的能匹配主串前缀中的最长后缀的部分 然后移动(也是事先可以计算出每个最长前缀中可以移动多少位置)
最难在于预求下标: 如果最长串的下一个字符和模式串下一个相等,那就是这次的最长的。如果不等需要找前一个的次长串, 还是不等就继续找次长串(次长串的找法最难 上一个的次长串就是上一个的最长串的最长串) 也就是模式串中最长前缀慢慢缩小 ,通过上一次来计算下一次的来节省时间
空间复杂度: o(模式串长度+原串长度)
-------------红本书-----------------
递归
有几个必须满足的条件
(1)方法第一条语句一定包含一个return 的 if 语句
(2)*******递归一定是解决一个规模更小的子问题 : 最重要的概念 问题都是通过这个概念来列出数学等式
(3)递归的父问题和子问题之前不该有交集 因为父问题讨论过的范围就没必要让子问题重复讨论了