每次看到两个字符串的题目时心跳慢半拍,因为这些题目真不是很好写。其中一些题目必须提前想到使用动态规划算法,厘清其中的状态转移方程才能下手。
本文对常见的以双字符串作为输入的算法题目进行分析,试图寻找共同的解题要点。下面就开始「撸串」~
本文题目难度标识:🟩简单,🟨中等,🟥困难。
两个字符串的相似度(动态规划) 两串字符串的相似度如何定义?我们有多重方式:
相似度指的是公共子串长度。公共子串越长,两串就越相似。 相似度指的是一个串转化到另一个串所需要的操作数。操作数越少,相似度越高。 相似度指的是公共子序列的长度。比如现有 S1 和 S2。存在 S3 满足其元素都在 S1 或 S2 出现(不要求连续出现),且在三个串出现顺序相同。S3 越长,相似度越高。 其实上面三种相似度的计算对应了算法中的三个经典问题:最长公共子串、编辑距离、最长公共子序列。其中,「最长公共子序列」和「最长公共子串」的缩写均为 LCS。
当在其他地方看到 LCS 时,需结合上下文理解该缩写指代的含义。
在学习动态规划算法时,LCS 问题是解释动态规划的经典例子,编辑距离问题也可使用动态规划。借助《算法导论》,我们回忆以下动态规划算法设计的基础知识。
动态规划算法设计步骤
动态规划算法的四个步骤:
刻画一个最优解的结构特征 递归地定义最优解的值 计算最优解的值,通常采用自底向上的方法 利用计算出的信息构造一个最优解 动态规划的实现方法
动态规划的两种等价的实现方法:
带备忘的自顶向下法(top-down with memoization [^memo])。按自然的递归形式编写过程,但保存每个子问题的解(数组或散列表),所以称之为带备忘的(memoized)。 自底向上法(bottom-up method)。恰当定义子问题的「规模」的概念,使得任何子问题都只依赖于「更小的」子问题的求解。因而我们可以将子问题按规模排序,按由小至大的顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已求解完毕,结果已经保存。每个子问题只需求解一次,当我们求解它(也是第一次遇到它)时,它的所有前提子问题都已求解完成。 两种方法得到的算法具有相同的渐近运行时间,仅有的差异是常量系数不同:
通常情况下,如果每个子问题都必须至少求解一次,自底向上动态规划算法更快。因为自底向上算法没有递归调用的开销,表的维护开销也更小。 如果子问题空间中的某些子问题完全不必求解,自顶向下法的备忘方法更快,因为它只会求解那些绝对必要的子问题。 看完本章,你能体会到写两个字符串动态规划的状态转移方程时的那种「撸串」的感觉了。
最长公共子序列 Longest Common Subsequence
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。如果不存在公共子序列 ,返回 0 。
一个字符串的子序列是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的公共子序列 是这两个字符串所共同拥有的子序列。
一些前置定义:
前缀:给定一个序列 X = < x 1 , x 2 , … x m > X=<x_{1},x_{2},\dots x_{m}> X = < x 1 , x 2 , … x m > ,对 i = 0 , 1 , … , m i=0,1,\dots,m i = 0 , 1 , … , m ,定义 X X X 的第 i i i 前缀为 X i = < x 1 , x 2 , … , x i > X_{i}= <x_{1},x_{2},\dots,x_{i}> X i = < x 1 , x 2 , … , x i > 。X 0 X_{0} X 0 表示空串。 子序列:给定一个序列 X = < x 1 , x 2 , … , x m > X= <x_{1},x_{2},\dots,x_{m}> X = < x 1 , x 2 , … , x m > ,另一个序列 Z = < z 1 , z 2 , … , z k > Z= <z_{1},z_{2},\dots,z_{k}> Z = < z 1 , z 2 , … , z k > 满足以下条件时称为 X 的子序列,即存在一个严格递增的 X 的下标序列 < i 1 , i 2 , … i k > <i_{1},i_{2},\dots i_{k}> < i 1 , i 2 , … i k > ,对所有 j = 1 , 2 , … , k j=1,2,\dots,k j = 1 , 2 , … , k ,满足 x i j = z j x_{i_{j}}=z_{j} x i j = z j 。 公共子序列:对于序列 X 和 Y,序列 Z 如果既是 X 的子序列又是 Y 的子序列,则 Z 是 X 和 Y 的公共子序列。 最长公共子序列长度:c [ i , j ] c[i,j] c [ i , j ] 表示 X i X_{i} X i 和 Y j Y_{j} Y j 的 LCS 长度 本小节是第一次引入动态规划算法,接下来将会用保姆级方式(按照《算法导论》中的方式)详细介绍这个问题动态规划方法步骤。
步骤 1:刻画最长公共子序列的特征 定理(LCS 最优子结构):令
X = < x 1 , x 2 , … , x m > X= <x_{1},x_{2},\dots,x_{m}> X = < x 1 , x 2 , … , x m > ,
Y = < y 1 , y 2 , … , y n > Y= <y_{1},y_{2},\dots,y_{n}> Y = < y 1 , y 2 , … , y n > 为两个序列,
Z = < z 1 , z 2 , … , z k > Z= <z_{1},z_{2},\dots,z_{k}> Z = < z 1 , z 2 , … , z k > 为 X 和 Y 的任意 LCS。
如果 x m = y n x_{m}=y_{n} x m = y n ,则 z k = x m = y n z_{k}=x_{m}=y_{n} z k = x m = y n 且 Z k − 1 Z_{k-1} Z k − 1 是 X m − 1 X_{m-1} X m − 1 和 Y n − 1 Y_{n-1} Y n − 1 的一个 LCS 如果 x m ≠ y n x_{m}\ne y_{n} x m = y n ,那么 z k ≠ x m z_{k}\ne x_{m} z k = x m 意味着 Z Z Z 是 X m − 1 X_{m-1} X m − 1 和 Y Y Y 的一个 LCS 如果 x m ≠ y n x_{m}\ne y_{n} x m = y n ,那么 z k ≠ y n z_{k}\ne y_{n} z k = y n 意味着 Z Z Z 是 X X X 和 Y n − 1 Y_{n-1} Y n − 1 的一个 LCS 注意,X、Y 序列第一个元素的序号为 1。
步骤 2: 一个递归解 定义 c [ i , j ] c[i,j] c [ i , j ] 表示 X i X_{i} X i 和 Y j Y_{j} Y j 的 LCS 长度。
c [ i , j ] = { 0 if i = 0 or j = 0 , c [ i − 1 , j − 1 ] + 1 if i , j > 0 and x i = y j , max { c [ i , j − 1 ] , c [ i − 1 , j ] } if i , j > 0 and x i ≠ y j c[i,j] = \begin{cases} 0 & \text{if } i=0 \text{ or } j=0, \\ c[i-1,j-1] + 1 & \text{if } i,j>0 \text{ and } x_i = y_j, \\ \max\{c[i,j-1], c[i-1,j]\} & \text{if } i,j>0 \text{ and } x_i \neq y_j \end{cases} c [ i , j ] = ⎩ ⎪ ⎪ ⎨ ⎪ ⎪ ⎧ 0 c [ i − 1 , j − 1 ] + 1 max { c [ i , j − 1 ] , c [ i − 1 , j ] } if i = 0 or j = 0 , if i , j > 0 and x i = y j , if i , j > 0 and x i = y j
步骤 3:计算 LCS 的长度 列出递归式后,我们发现 c[i,j] 总是和左、上或左上有关,这一点启发我们遍历二维数组的顺序。
c 表按行主次序计算表项(从左到右,从上到下计算)。
算法中表 b 是为了重构解用的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 LCS-LENGTH(X,Y) m=X.length n=Y.length 令 b[1 ..m,1 ..n] 和 c[0 ..m,0 ..n] 为新表 for i=1 to m c[i][0 ]=0 for j=0 to n c[0 ][j]=0 for i=1 to m for j=1 to n if X[i]==Y[i] c[i][j]=c[i-1 ][j-1 ]+1 b[i][j]="↖" else if c[i-1 ][j] >= c[i][j-1 ] c[i][j]=c[i-1 ][j] b[i][j]="⬆" else c[i][j]=c[i][j-1 ] b[i][j]="⬅" return c,b
带备忘版本,运行时间 O ( m n ) O(mn) O ( m n ) :
1 2 3 4 5 6 7 8 MEMOIZED-LCS-LENGTH(X, Y, i, j) if c[i, j] > -1 return c[i, j] if i == 0 or j == 0 return c[i, j] = 0 if x[i] == y[j] return c[i, j] = LCS-LENGTH(X, Y, i - 1 , j - 1 ) + 1 return c[i, j] = max(LCS-LENGTH(X, Y, i - 1 , j), LCS-LENGTH(X, Y, i, j - 1 ))
步骤 4:构造 LCS 1 2 3 4 5 6 7 8 9 10 PRINT-LCS(b, X, i, j) if i==0 or j== 0 return if b[i, j] == "↖" PRINT-LCS(b, X, i-1 , j-1 ) print x_i elseif b[i,j] == "⬆" PRINT-LCS(b, X, i-1 , j) else PRINT-LCS(b, X, i, j-1 )
运行时间 O ( m + n ) O(m+n) O ( m + n ) ,因为每次递归调用 i 和 j 至少有一个会减少 1。
算法改进:
即使需要重构解也可以去掉表 b。给定 c [ i , j ] c[i,j] c [ i , j ] 的值,我们完全可以在 O ( 1 ) O(1) O ( 1 ) 时间内判断使用了哪个方向。 如果不需要重构解,除了去掉 b 以外,可以压缩 c 的空间为一行多一点。 最长公共子串 Longest Common Substring 最长公共子串(Longest Common Substring)是指在两个或多个字符串中找出最长的共同连续 子序列。这里的关键词是「连续」,这也是它与最长公共子序列的本质区别。
给定两个字符串 str1 和 str2,输出两个字符串的最长公共子串题目保证 str1 和 str2 的最长公共子串存在且唯一。
数据范围:公式表示为 1 ≤ ∣ s t r 1 ∣ , ∣ s t r 2 ∣ ≤ 5000 1 \leq |str1|, |str2| \leq 5000 1 ≤ ∣ s t r 1 ∣ , ∣ s t r 2 ∣ ≤ 5 0 0 0 要求:空间复杂度 O ( n 2 ) O(n^2) O ( n 2 ) ,时间复杂度 O ( n 2 ) O(n^2) O ( n 2 ) 。
核心思想:
创建一个二维数组 dp[i][j],表示以字符串 str1 的第 i 个字符和字符串 str2 的第 j 个字符结尾的最长公共子串长度。
当两个字符相匹配时(str1.charAt(i-1)==str2.charAt(j-1)),我们可以基于之前的结果加 1:dp[i][j] = dp[i-1][j-1] + 1 当字符不匹配时,由于子串要求连续,我们需要重置计数:dp[i][j] = 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import java.util.*;public class Solution { public String LCS (String str1, String str2) { int len1 = str1.length(); int len2 = str2.length(); int [][] dp = new int [len1+1 ][len2+1 ]; int ans = 0 ; int end = -1 ; for (int i=1 ;i<=len1;i++){ for (int j=1 ;j<=len2;j++){ if (str1.charAt(i-1 )==str2.charAt(j-1 )){ dp[i][j] = dp[i-1 ][j-1 ] +1 ; }else { dp[i][j] = 0 ; } if (dp[i][j]>ans){ ans = dp[i][j]; end = j-1 ; } } } return str2.substring(end+1 -ans,end+1 ); } }
dp 数组可进行空间压缩。详见 站内文章 【刷题日记】动态规划题单 。
编辑距离 Edit Distance Levenshtein Distance,一般称为编辑距离(Edit Distance),Levenshtein Distance 只是编辑距离的其中一种)或者莱文斯坦距离,算法概念是俄罗斯科学家弗拉基米尔·莱文斯坦在 1965 年提出。
此算法的概念很简单:Levenshtein Distance 指两个字串之间,由一个转换成另一个所需的最少编辑操作次数,允许的编辑操作包括:
将其中一个字符替换成另一个字符(Substitutions) 插入一个字符(Insertions) 删除一个字符(Deletions) 主要的应用场景有:DNA 分析、拼写检查、语音识别、抄袭侦测和搜索建议。
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Solution { public int minDistance (String word1, String word2) { char [] cc1 = word1.toCharArray(); char [] cc2 = word2.toCharArray(); int [][] dp = new int [cc1.length+1 ][cc2.length+1 ]; dp[0 ][0 ] = 0 ; for (int i=1 ;i<=cc1.length;i++){ dp[i][0 ] = i; } for (int j=1 ;j<=cc2.length;j++){ dp[0 ][j] = j; } for (int i=1 ;i<=cc1.length;i++){ for (int j=1 ;j<=cc2.length;j++){ if (cc1[i-1 ]==cc2[j-1 ]){ dp[i][j] = dp[i-1 ][j-1 ]; }else { dp[i][j] = Math.min(dp[i-1 ][j],dp[i][j-1 ])+1 ; dp[i][j] = Math.min(dp[i][j],dp[i-1 ][j-1 ]+1 ); } } } return dp[cc1.length][cc2.length]; } }
复杂度分析:
时间复杂度 :O ( m n ) O(mn) O ( m n ) ,其中 m 为 word1 的长度,n 为 word2 的长度。 空间复杂度 :O ( m n ) O(mn) O ( m n ) ,我们需要大小为 O ( m n ) O(mn) O ( m n ) 的 D 数组来记录状态值。 最小覆盖子串(滑动窗口) 给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。
本题目我认为难点在于「尺蠖」型滑动窗口的构造。一些细节需要注意。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 class Solution { public String minWindow (String s, String t) { Map<Character,Integer> tmap = new HashMap <>(); Map<Character,Integer> cnt = new HashMap <>(); for (char c:t.toCharArray()){ tmap.merge(c,1 ,Integer::sum); } int len = s.length(); int l=0 ,r=-1 ; int ansl = -1 ; int ansr = -1 ; int minLen = Integer.MAX_VALUE; while (r<len){ r++; if (r<len && tmap.containsKey(s.charAt(r))){ cnt.merge(s.charAt(r),1 ,Integer::sum); } while (isValid(cnt,tmap) && l<=r){ if (r-l+1 <minLen){ ansl=l; ansr=r; minLen = r-l+1 ; } cnt.merge(s.charAt(l),-1 ,Integer::sum); l++; } } if (ansl==-1 ||ansr==-1 )return "" ; return s.substring(ansl,ansr+1 ); } public static boolean isValid (Map<Character,Integer> cnt,Map<Character,Integer> tmap) { for (Map.Entry<Character,Integer> entry:tmap.entrySet()){ char k = entry.getKey(); int v = entry.getValue(); if (cnt.getOrDefault(k,0 )<v){ return false ; } } return true ; } }
字符串匹配 单模式串的字符串匹配算法 详看 站内文章 字符串的匹配算法(单模式串) 。
正则表达式匹配算法(动态规划)
给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。
'.' 匹配任意单个字符 '*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖整个字符串 s 的,而不是部分字符串。
我认为,本题是「撸串」最复杂的题目。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 class Solution { public boolean isMatch (String s, String p) { char [] ccs = s.toCharArray(); char [] ccp = p.toCharArray(); boolean [][] dp = new boolean [s.length()+1 ][p.length()+1 ]; dp[0 ][0 ]=true ; for (int j=1 ;j<=p.length();j++){ if (ccp[j-1 ]=='*' )dp[0 ][j] = dp[0 ][j-2 ]; } for (int i=1 ;i<=s.length();i++){ for (int j=1 ;j<=p.length();j++){ if (ccp[j-1 ]=='*' ){ if (match(ccs[i-1 ],ccp[j-2 ])){ dp[i][j] = dp[i-1 ][j] || dp[i-1 ][j-2 ] || dp[i][j-2 ]; }else { dp[i][j] = dp[i][j-2 ]; } }else { if (match(ccs[i-1 ],ccp[j-1 ])){ dp[i][j] = dp[i-1 ][j-1 ]; } } } } return dp[s.length()][p.length()]; } public static boolean match (char a,char b) { if (a==b||b=='.' )return true ; return false ; } }
本文参考