字符串匹配
BF算法
BF算法是最简单也是最暴力的一种方法,即模式串依次在主串的第i个位置开始进行比较,相同则继续比较,不同就移至下一位重新比较。
1 | kotlin复制代码package algorithm.string_match; |
极端情况下时间复杂度为O(n*m)(如:主串”aaaaaaaaaa…..“ 子串”aaaaaab“),尽管时间复杂度很高,但他是最常用的字符串匹配算法。原因如下:
1、在大多数情况下,主串和子串的长度不会特别长,同时子串在中途遇到不能匹配的字符时,就停止匹配了,所以不会完全遍历子串
2、该算法实现简单,当出现bug时也容易发现并修正。
RK算法
其思路是利用哈希算法,求得主串下的所有与模式串相同长度的子串的哈希值,这样只要比较哈希值即可确认主串中是否存在子串。关键就在于求主串下所有子串的哈希值所消耗的时间。
由图可知h[i]与h[i-1]存在一定的关系。
用数学表达可知,h[i] = 26h[i - 1] - 26^m(s[i - 1] - 'a') + s[i + m - 1] - 'a'
.
1 | arduino复制代码package algorithm.string_match; |
由关系表达式,只需要O(n)的时间复杂度即可求得主串下所有子串的哈希值,用set判断模式串的哈希值是否存在也只需要O(n)的时间复杂度,故该方法的时间复杂度为O(n),但存在缺陷,如果模式串和主串的长度都非常的长,超过了数值的范围,那么这种方法就不可取了,这时候只能允许哈希冲突,当存在哈希值的时候再使用BK算法进行判断。
BM
1 | ini复制代码package algorithm.string_match; |
KMP算法
1 | ini复制代码package algorithm.string_match; |
字典树(Trie)
不同于kmp、rk、bm等算法,模式串-主串。他是将所有字符串统一加入到字典中,后续判断字符串是否存在于字典树中。
实现字典树
1 | ini复制代码package algorithm.string_match; |
字典树的优缺点
字典树是一种空间换时间的思路,其本质虽然是避免存储相同前缀的字符串,但重复前缀并不多的情况下,每一个节点都需要存储26个字符,如果需要存储中文字符、数字等,那么需要更多的存储空间。面对空间浪费的情况,可以选择舍弃一定的查询效率,选择红黑树、有序数组、跳表、散列表等。
总结:Trie树使用的场景
1、不适用于字符集特别大的情况
2、要求字符串的前缀重合比较多
3、Trie由于用到了指针,所以对cpu缓存不友好。
Trie树不适合精确查找,比较适合查找前缀匹配的字符。因此可以用于搜索关键字的提示。
贪心算法
贪心算法是一种算法思想。指在对问题求解时,总是做出在当前看来是最好的选择。典型应用于哈夫曼编码。
在给定限定值和期望值的情况下,要求在满足限定值时使期望值达到最大。这个时候可以使用贪心算法。
当背包容量一定时,要求背包内的价值最大,可以对各个豆子求单位容量下的价值,然后从大到小依次放入背包中。
但有时贪心算法得到的也不是最优值。
贪心算法的思路,先找到和S相连权的最小边,直到找到T,路径为S -》 A -》E -》 T ,而最短路径为S -》B -》 D -》 T。
实战题
435. 无重叠区间 - 力扣(LeetCode) (leetcode-cn.com)
402. 移掉 K 位数字 - 力扣(LeetCode) (leetcode-cn.com)
分治算法
分而治之,将原问题划分成n个规模较小,且结构与原问题相似的子问题,递归的解决这个子问题,再合并结果。
应用举例
求逆序对,暴力法为O(n^2),可以采取分治法的思路,将原数组划分为A、B两个子数组,然后查询A数组内的逆序对和B数组内逆序对,以及A、B之间的逆序对。
1 | ini复制代码class Solution { |
剑指 Offer 51. 数组中的逆序对 - 力扣(LeetCode) (leetcode-cn.com)
回溯算法
回溯算法即枚举所有的解。
典型应用
八皇后
1 | ini复制代码class Solution { |
面试题 08.12. 八皇后 - 力扣(LeetCode) (leetcode-cn.com)
0-1背包
1 | arduino复制代码public int maxW = Integer.MIN_VALUE; //存储背包中物品总重量的最大值 |
正则表达式
“*“匹配0个到多个任意字符,”.”匹配0个或1个任意字符。实现正则表达式。
1 | typescript复制代码package algorithm; |
动态规划(一)
以0-1背包为例,用回溯法解决:
1 | arduino复制代码// 回溯算法实现。注意:我把输入的变量都定义成了成员变量。 |
如图,有很多重复的计算,可以通过备忘录避免冗余计算。
1 | ini复制代码private int maxW = Integer.MIN_VALUE; // 结果放到maxW中 |
采用动规思路,可以将求解分为n个阶段,每个阶段会决策物品是否放入背包,决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,我们把每一层重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。我们可以通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过w个(w表示背包的承载重量)
1 | ini复制代码// weight:物品重量,n:物品个数,w:背包可承载重量 |
回溯法的时间复杂度根据回溯树可知,为O(2 ^ n),而动规的时间复杂度为O(n*w),可以对空间复杂度进行进一步的优化。
1 | arduino复制代码public static int knapsack2(int[] items, int n, int w) { |
当要求满足最大价格限制前提下,使总价格最大:
1 | ini复制代码private int maxV = Integer.MIN_VALUE; // 结果放到maxV中 |
由图可知,在(i,cw)相同的情况下,只需要考虑value最大的即可,此时回溯法无法用备忘录解决。
动规实现:
1 | ini复制代码public static int knapsack3(int[] weight, int[] value, int n, int w) { |
思考
双十一想要薅羊毛,提供满200的优惠,现购物车有一系列商品,要求在能薅羊毛的前提下,使得商品价格最少,即大于200的最小值。
1 | ini复制代码// items商品价格,n商品个数, w表示满减条件,比如200 |
练习
剑指 Offer II 100. 三角形中最小路径之和 - 力扣(LeetCode) (leetcode-cn.com)
动态规划(二)
一模型三特征
1、最优子结构。最优子结构指的是,问题的最优解包含子问题的最优解。反过来说就是,我们可以通过子问题的最优解,推导出问题的最优解。
2、无后效性。第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。
3、重复子问题。不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态
解决动规的思路
1、状态转移表法。回溯算法实现-定义状态-画递归树-找重复子问题-画状态转移表-根据递推关系填表-将填表过程翻译成代码。
2、状态转移方程。找最优子结构-写状态转移方程-将状态 转移方程翻译成代码。
四种思想的比较
回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最 优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了。
尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重 复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划 之所以高效,就是因为回溯算法实现中存在大量的重复子问题。
贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(“贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起 来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。)
思考
322. 零钱兑换 - 力扣(LeetCode) (leetcode-cn.com)
1 | ini复制代码class Solution { |
动态规划(三)
在搜索引擎中输入错误的单词时,搜索引擎提供一个拼音纠错功能。他是如何量化两个字符之间的相似程度的呢?
编辑距离是将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越 小,说明两个字符串的相似程度越大。对于两个完全相同的字符串来说,编辑距离就是0。
编辑距离有两种计算方式。一个是莱文斯坦距离,另一个是最长公共子串长度。莱文斯坦距离允许增加、删除、 替换字符这三个编辑操作,最长公共子串长度只允许增加、删除字符这两个编辑操作。分析相似度时,莱文斯坦距离的大小,表示两个字符串差异的大小;而最长公共子串的大小,表示两个字符串相似程度的大小。
莱文斯坦距离
如果a[i]与b[j]匹配,我们递归考察a[i+1]和b[j+1]。如果a[i]与b[j]不匹配,那我们有多种处理方式可选:
- 可以删除a[i],然后递归考察a[i+1]和b[j];
- 可以删除b[j],然后递归考察a[i]和b[j+1];
- 可以在a[i]前面添加一个跟b[j]相同的字符,然后递归考察a[i]和b[j+1];
- 可以在b[j]前面添加一个跟a[i]相同的字符,然后递归考察a[i+1]和b[j];
- 可以将a[i]替换成b[j],或者将b[j]替换成a[i],然后递归考察a[i+1]和b[j+1]。
1 | ini复制代码package algorithm.edit_distance; |
递归树可知,(i,j)相同时,保留更小的edist,由此推断出状态转移方程
如果:a[i]!=b[j],那么:min_edist(i, j)就等于: min(min_edist(i-1,j)+1, min_edist(i,j-1)+1, min_edist(i-1,j-1)+1) 如果:a[i]==b[j],那么:min_edist(i, j)就等于: min(min_edist(i-1,j)+1, min_edist(i,j-1)+1,min_edist(i-1,j-1)) 其中,min表示求三数中的最小值。
1 | ini复制代码public int lwstDP(char[] a, int n, char[] b, int m) { |
最长公共子串
1 | ini复制代码package algorithm.edit_distance; |
优化纠错功能
针对纠错效果不好的问题,我们有很多种优化思路,我这里介绍几种。
- 我们并不仅仅取出编辑距离最小的那个单词,而是取出编辑距离最小的TOP 10,然后根据其他参数,决策选择哪个单词作为拼写纠错单词。
- 比如使用搜索热门程度来决定哪个单词作为拼写纠错单词。 我们还可以用多种编辑距离计算方法,比如今天讲到的两种,然后分别编辑距离最小的TOP 10,然后求交集,用交集的结果,再继续优化处理。
- 我们还可以通过统计用户的搜索日志,得到最常被拼错的单词列表,以及对应的拼写正确的单词。搜索引擎在拼写纠错的时候,首先在这个最长被拼错单词列表中查找。如果一旦找到,直接返回对应的正确的单词。 这样纠错的效果非常好。
- 我们还有更加高级一点的做法,引入个性化因素。针对每个用户,维护这个用户特有的搜索喜好,也就是常用的搜索关键词。当用户输入错误的单词的时候,我们首先在这个用户常用的搜索关键词中,计算编辑距 离,查找编辑距离最小的单词。
针对纠错性能方面,我们也有相应的优化方式。我讲两种分治的优化思路。 - 如果纠错功能的TPS不高,我们可以部署多台机器,每台机器运行一个独立的纠错功能。当有一个纠错请求的时候,我们通过负载均衡,分配到其中一台机器,来计算编辑距离,得到纠错单词。
- 如果纠错系统的响应时间太长,也就是,每个纠错请求处理时间过长,我们可以将纠错的词库,分割到很多台机器。当有一个纠错请求的时候,我们就将这个拼写错误的单词,同时发送到这多台机器,让多台机器并 行处理,分别得到编辑距离最小的单词,然后再比对合并,最终决定出一个最优的纠错单词.
最短距离
迪杰斯特拉算法
单源最短路径(一个顶点到另一个顶点) 时间复杂度O(ElogV)
1 | ini复制代码package algorithm.shortest_road; |
弗洛伊德算法
所有点到所有点 最短路径的算法。时间复杂度O(n^3)
1 | ini复制代码package algorithm.shortest_road; |
区别: Dijkstra 算法:每次从「未求出最短路径」的点中 取出 最短路径的点,并通过这个点为「中转站」刷新剩下「未求出最短路径」的距离。
Dijkstra 的算法在图中的效果像是:以起点为中心像是一个涟漪一样在水面上铺开。
Floyd 算法在图中的效果像是:一个一个多点的小涟漪,最后小涟漪铺满整个水面。
练习:
网络延迟时间 - 网络延迟时间 - 力扣(LeetCode) (leetcode-cn.com)
位图
思考:如何实现网页爬虫的url去重问题?
对于这个问题,要实现的功能是,添加一个url以及查询一个url,同时要求复杂度尽可能的高。显然,散列表是率先想到的方法,无论添加还是查找都只需要O(1)的时间复杂度,然而对于查找操作,由于是url所以这会涉及到字符串匹配的问题,同时如果爬取的url很多,那么也会消耗极大的内存空间。对于后者,可以采用分治的思路,即利用哈希算法,多台机器存储url。
那么,如何在提高时间复杂度的前提下,尽可能使内存占用率小呢?
可以使用位图来解决这个方法,假设有一千万个数,每个数的范围是1到1亿之间,我们需要判断一个数是否存在于这个集合中,不存在则添加。
1 | java复制代码package algorithm.bit; |
如果是动态的添加数据,即未知数据量的大小,以及数据所在的范围呢?
那这时候就需要进行动态扩容,可以先初始化四个long类型数组,第一个数组不放数据,剩下的三个数组的操作和位图一致,假设添加的数字为67540,则对应的数组下标应该为1055,对应的位应该为20,此时就需要对原数组扩容,下标为4的数组和下标为0的数组一样仍然不存储数据,此时下标为4的值转化为2进制110000011100
,其中前32位表示后续连续存储数据的数组,后32位表示跨越的数组,此时数组中下标为5的位置就存储67540的值。当判断数据是否存在时,则可以通过解析存储跨度信息的下标,即从0开始解析,然后依次判断。
当允许存在精确误差时,可以使用布隆过滤器,布隆过滤器是基于位图实现的,其目的是在丢失部分精确性的前提下进一步节省内存空间。实现原理是,对于一个数字,通过多个哈希算法求得多个下标,并设置为1,当判断是否存在时,同样也是用多个哈希算法求得对应下标,判断是否为1.其产生误差的原因如图:
因此在允许误差的情况下,可以使用布隆过滤器对网页爬虫的url去重。
思考
假设我们有1亿个整数,数据范围是从1到10亿,如何快速并且省内存地给这1亿个数据从小到大排序?
答:可以使用位图,将数字添加到位图中,依次读取值为1的下标。
本文转载自: 掘金