字符串有很多比较经典的算法问题,例如:LIS(最长递增子序列)、LCS(最长公共子序列、最长公共子串)、LCP(最长公共前缀)、LPS(最长回文子序列、最长回文子串)、ED(最小编辑距离,也叫 “Levenshtein 距离”)、 KMP(一种字符串匹配的高效算法)。
上面列举的经典问题,在 Leetcode 中都有对应题型,这些也是笔试面试经常会遇到的基本题型。
下面来详细讲解这些经典算法问题:
LIS(Longest Increasing Subsequence)
LIS 是最长递增子序列(Leetcode 300),不要求子序列连续。其实,连续的情况也是一个经典的问题(称为 “Longest Continuous Increasing Subsequence”,见 Leetcode 674)。下面对这两种情况分别进行讲解。先来看 LIS,这个问题(Leetcode 300)的描述如下:
Given an unsorted array of integers, find the length of longest increasing subsequence.
Example:
Input: [10,9,2,5,3,7,101,18]
Output: 4
Explanation: The longest increasing subsequence is [2,3,7,101], therefore the length is 4.
Note:
1.There may be more than one LIS combination, it is only necessary for you to return the length.
2.Your algorithm should run in O( n 2 n^2 n2) complexity.
Follow up: Could you improve it to O(n log n) time complexity?
这个题目的思路不难,用动态规划是很容易的。令 dp[ i i i] 表示从数组开始到第 i + 1 i+1 i+1 个元素的最大递增子序列长度(之所以定义第 i + 1 i+1 i+1 个元素, 是因为数组中第一个元素序号为 0,所以第 i + 1 i+1 i+1 个元素的序号为 i i i)。设数组为 nums,则它的第 i + 1 i+1 i+1 (即序号为 i i i ) 的元素为 nums[ i i i],由于要考虑到 “递增”,所以需要比较 nums[ i i i] 与它之前的所有元素的大小,所以需要再增加一个维度 dp[ i i i][ j j j],要大于它前面的某些元素,那么它对应的 dp[ i i i] 会基于前面的元素对应的 dp 上增加1。举个例子, nums = [1, 3, 5, 2, 4, 7, 6],容易知道:dp[0] = 1,dp[1] = 2,dp[2] = 3,dp[3] = 2,在判断 dp[4] 的时候,由于 nums[4] 比前面的元素 1、3、2 都要大,那么当 j j j 遍历到元素 1 时,dp[4] = dp[0]+1 = 2,当 j j j 遍历到元素 3 时,dp[4] = dp[1]+1 = 3,当 j j j 遍历到元素 2 时,dp[4] = dp[3]+1 = 3,而由于是求 “最长递增子序列”,所以应该取所有 dp[4] 中最大的作为最后 dp[4] 的取值,即 dp[4] = max(dp[4], dp[ j j j]+1) ( j j j = 0, 1, 3)。
Python 代码如下:
def LIS(nums): if len(nums)==0: return 0 dp = [1 for _ in range(len(nums))] for i in range(1,len(nums)): for j in range(i): if nums[i]>nums[j]: dp[i] = max(dp[i],dp[j]+1) return max(dp)
上面这个解法是 O ( n 2 ) O(n^2) O(n2) 的时间复杂度,在题目中已经明确指出还有复杂度 O ( n log n ) O(n\text{log}n) O(nlogn) 的算法,下面给出它的思路:
以 nums = [3,5,6,2,4,5,7] 作为例子,从第一个元素 3 开始,逐步添加后续元素,看 “最长递增序列” 的长度变化,记录如下:
(1) 当第一个元素时, LCS 的列表为 [3];
(2) 当加入第二个元素时,LCS 的列表为 [3, 5],保留原有的一个长度的列表:
长度为1:[3]
长度为2:[3,5]
(3) 当加入第三个元素时,LCS 的列表为 [3, 5, 6],列表情况为:
长度为1:[3]
长度为2:[3, 5]
长度为3:[3, 5, 6]
(4) 当加入第四个元素时,元素 2 比任何一个元素都小,故只能生成长度为 1 的递增序列,由于比之前长度为 3 的元素要小,故将其替换掉,得到列表情况如下:
长度为1:[2]
长度为2:[3, 5]
长度为3:[3, 5, 6]
(5) 当加入第五个元素时,元素 4 只比上面长度为 1 里的元素大,得到 [3, 4],由于此时得到的长度为 2 的 [3, 4] 的尾端元素 4 比之前长度为 2 的 [3, 5] 的尾端元素 5 要小,故替换掉之前的长度为 2 的列表,得到的列表情况如下:
长度为1:[2]
长度为2:[3, 4]
长度为3:[3, 5, 6]
(4) 当加入第六个元素时,元素 5 比长度为 2 的 [3, 4] 的尾端大,比长度为 3 的 [3, 5, 6] 的尾端小,故长度为 3 的列表被替换成 [3, 4, 5],列表情况如下:
长度为1:[2]
长度为2:[3, 4]
长度为3:[3, 4, 5]
(4) 当加入第七个元素时,元素 7 比长度为 3 的 [3, 4, 5] 的尾端大,而且此时长度 3 是最大长度,故会生成新的长度为 4 的 [3, 4, 5, 7],列表情况如下:
长度为1:[2]
长度为2:[3, 4]
长度为3:[3, 4, 5]
长度为4:[3, 4, 5, 7]
所以最后的 LIS 的长度为 4。
上面的思路也很简单,相当于每次添加一个新的元素时,将这个元素与已有的所有列表中的尾端元素进行比较,如果它比最长的列表的尾端元素大,则以这个元素为尾端元素来新增加一个更长的列表,如果它介于长度为 m 和 m+1 的尾端元素大小之间,则在长度为 m 的列表尾端上增加上这个元素变成 m+1 的列表并替换掉之前的 m+1 的列表。
Python 代码如下:
# 后缀数组解法 def LIS(nums): tails = [0 for _ in range(len(nums))] # 构建尾部数组(初始化) max_len = 0 for x in nums: i, j = 0, max_len # 二分查找的两个指针 while i < j: m = (i + j) // 2 # 计算首尾指针对应的中间指针 if tails[m] < x: i = m + 1 # 当查询的值比 x小时,改变首指针 else: j = m # 当查询的值不比 x小时,改变尾指针 tails[i] = x # 二分查找到准确的 i后,用 x替换掉其尾部 max_len = max(i + 1, max_len) return max_len
下面再来看子序列连续的情况,即 “Longest Continuous Increasing Subsequence” (LCIS)。问题描述如下:
Given an unsorted array of integers, find the length of longest continuous increasing subsequence (subarray).
Example 1:
Input: [1,3,5,4,7]
Output: 3
Explanation: The longest continuous increasing subsequence is [1,3,5], its length is 3.
Even though [1,3,5,7] is also an increasing subsequence, it’s not a continuous one where 5 and 7 are separated by 4.
Example 2:
Input: [2,2,2,2,2]
Output: 1
Explanation: The longest continuous increasing subsequence is [2], its length is 1.
Note: Length of the array will not exceed 10,000.
这个 LICS 问题相对于上面的 LIS 而言,就非常简单了。对于数组 nums,LICS 问题只需要比较 nums[i] 与它前一个数 nums[i-1] 的大小就可以了。
Python 代码如下:
def LCIS(nums): if len(nums)==0: return 0 dp = [1]*len(nums) for i in range(1,len(nums)): if nums[i]>nums[i-1]: dp[i]=dp[i-1]+1 return max(dp)
LCS(Longest Common Subsequence、Longest Common Substring)
对于LCS,这是两个问题。一个是求字符串的最长公共子序列,一个是求字符串的最长公共子串。先来讲解最长公共子串。
比如两个字符串, s1 为 “abcddeeb”,s2 为 “ababcddeb”,那么要计算公共子串长度,最容易想到的是动态规划的方法。令 dp[i][j] 为 s1 前 i 个字符和 s2 前 j 个字符构成的子字符串的最长公共子串的长度。那么,显然,当 s1[i] == s2[j] 时,它的公共子串的长度 dp[i][j] 肯定比 dp[i-1][j-1] 要大 1,这个公式就是解这个题的核心。
Python 代码如下:
def LCS(s1, s2): len1 = len(s1); len2 = len(s2) max_len = 0 dp = [[0 for _ in range(len2)] for _ in range(len1)] for i in range(len1): for j in range(len2): if s1[i] == s2[j]: if i>0 and j>0: dp[i][j] = dp[i-1][j-1] + 1 else: dp[i][j] = 1 # 边界情况 if max_len < dp[i][j]: max_len = dp[i][j] return max_len
再来看最长公共子序列。子序列与子串的不同在于它并不要求连续,只要有字符是公共的就算一个。其中,核心公式 dp[i][j] = dp[i-1][j-1] + 1 没变,但增加了 s1[i] 与 s2[j] 不相等的情况,此时有 dp[i][j] = max(dp[i-1][j], dp[i][j-1]),也就是说,。
Python 代码如下:
def LCS(s1, s2): # 做了 padding 处理,使得包含空串的情况(自含了边界情况) dp = [[0 for _ in range(len(s2)+1)] for _ in range(len(s1)+1)] for i in range(1,len(s1)+1): for j in range(1,len(s2)+1): if s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1] + 1 else: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) return dp[len(s1)][len(s2)]
LCP(Longest Common Prefix)
这个问题(Leetcode 14)的描述如下:
Write a function to find the longest common prefix string amongst an array of strings.
If there is no common prefix, return an empty string “”.
Example 1:
Input: [“flower”,“flow”,“flight”]
Output: “fl”
Example 2:
Input: [“dog”,“racecar”,“car”]
Output: “”
Explanation: There is no common prefix among the input strings.
Note:
All given inputs are in lowercase letters a-z.
这个问题比较简单,只需要统计两个字符串前面公共字符的个数就可以了。时间复杂度是 O( n n n)。
Python 代码如下:
def LCP(strs): lens = list(map(len, strs)) if lens==[]: # 如果strs中没有单词,则肯定没有前缀,返回'' return '' min_lens = min(lens) # 取最小长度的单词的对应长度作为循环长度 prefix = '' for i in range(min_lens): char = list(map(lambda s:s[i],strs)) # 依次取每个单词的第i个字符 if char==[char[0]]*len(strs): # 如果所有单词的第i个字符相同,就存入prefix prefix+=char[0] else: # 当出现有不相同的字符时,就跳出循环 break return prefix
LPS(Longest Palindrome Subsequence、Longest Palindrome Substring)
LPS 也有两个问题,一个是最长回文子序列(Leetcode 516),一个是最长回文子串(Leetcode 5)。下面先讲解最长回文子序列,其问题如下:
Given a string s, find the longest palindromic subsequence’s length in s. You may assume that the maximum length of s is 1000.
Example 1:
Input: “bbbab”
Output: 4
One possible longest palindromic subsequence is “bbbb”.
Example 2:
Input: “aaabcdbcb”
Output: 5
One possible longest palindromic subsequence is “bcdcb”.
Python 代码如下:
def LPS(s): if s==s[::-1]: return len(s) # dp[i][j] 表示s[i..j]中的最大回文长度 dp = [[0 for _ in range(len(s))] for _ in range(len(s))] for i in range(len(s)): dp[i][i] = 1 # 单个字符肯定是回文(动态规划的base条件) for l in range(1,len(s)+1): # s[i..j]的长度范围从 1到 len(s) for i in range(len(s)-l+1): # 确定s[i..j]的长度为l后,i的取值最大为len(s)-l j = i+l-1 # 因为s[i..j]的长度为l,所以j-i应该为 l-1 if i<j: if s[i] == s[j]: dp[i][j] = dp[i+1][j-1]+2 else: dp[i][j] = max(dp[i+1][j],dp[i][j-1]) return dp[0][len(s)-1]
另一个问题是最长回文子串(Leetcode 5),这个问题相对之前的要简单许多,当然它的解法也有很多(可参考:最长回文子串(Longest Palindromic Substring))。下面先给出这个问题的描述,然后再用三种方法来解这个问题。问题描述如下:
Given a string s, find the longest palindromic substring in s. You may assume that the maximum length of s is 1000.
Example 1:
Input: “babad”
Output: “bab”
Note: “aba” is also a valid answer.
Example 2:
Input: “cbbd”
Output: “bb”
下面通过三种方法来解这个问题。
先考虑动态规划方法,与之前的最长回文子序列类似。
Python 代码如下:
def LPS(s): max_len = 1 # 最长回文子串长度 start = 0 # 最长回文子串起点 if s==s[::-1]: return s # dp[i][j] 表示s[i..j]中的最大回文子序列长度 dp = [[0 for _ in range(len(s))] for _ in range(len(s))] for l in range(1,len(s)+1): # s[i..j]的长度范围从 1到 len(s) for i in range(len(s)-l+1): # 确定s[i..j]的长度为l后,i的取值最大为len(s)-l j = i+l-1 # 因为s[i..j]的长度为l,所以j-i应该为 l-1 if i==j: dp[i][j] = True # 单个字符肯定是回文(动态规划的base条件) elif j-i==1: dp[i][j] = (s[i]==s[j]) else: dp[i][j] = ((s[i]==s[j]) and dp[i+1][j-1]) if(dp[i][j] and (max_len<j-i+1)): max_len = j-i+1 start = i return s[start:start+max_len]
上面的解法时间复杂度是 O( n 2 n^2 n2),在效率上并不高效,下面来介绍两种时间复杂度为 O( n n n) 的解法。
Python 代码如下:
def longestPalindrome(s): max_len = 0 # 最长回文子串长度 start = 0 # 最长回文子串起点 for i in range(len(s)): if i - max_len >= 0 and s[i-max_len:i+1] == s[i-max_len:i+1][::-1]: start = i - max_len max_len += 1 if i - max_len >= 1 and s[i-max_len-1:i+1] == s[i-max_len-1:i+1][::-1]: start = i - max_len -1 max_len += 2 return s[start:(start+max_len)]
另外一种时间复杂度为 O( n n n) 的算法是 Manacher 算法。
Python 代码如下:
def Manacher(s): s='#'+'#'.join(s)+'#' # 字符串首尾和中间都插入字符'#' RL=[0 for _ in range(len(s))] # RL是回文半径数组 MaxRight=0 Pos=0 Maxlen=0 for i in range(len(s)): if i<MaxRight: RL[i]=min(RL[2*Pos-i],MaxRight-i) else: #i在maxright右边,以i为中心的回文串还没扫到,此时,以i为中心向两边扩展 RL[i]=1 #RL=1:只有自己 #以i为中心扩展,直到左!=右or达到边界(先判断边界) while i-RL[i]>=0 and i+RL[i]<len(s) and s[i-RL[i]]==s[i+RL[i]]: RL[i]+=1 #更新Maxright pos: if RL[i]+i-1>MaxRight: MaxRight=RL[i]+i-1 Pos=i #更新最长回文子串的长; Maxlen=max(Maxlen,RL[i]) s=s[RL.index(Maxlen)-(Maxlen-1):RL.index(Maxlen)+(Maxlen-1)] s=s.replace('#','') return s
ED(Edit Distance)
最小编辑距离,又称 Levenshtein 距离,它是指两个字符串之间通过增、删、替换(改)三种变换能够使字符串相同的最小编辑次数,用来衡量两个字符串的字符距离(每一次增、删、替换,都算作距离为1)。问题(Leetcode )描述如下:
Given two words word1 and word2, find the minimum number of operations required to convert word1 to word2.
You have the following 3 operations permitted on a word:
1.Insert a character
2.Delete a character
3.Replace a character
Example:
Input: word1 = “intention”, word2 = “execution”
Output: 5
Explanation:
intention -> inention (remove ‘t’)
inention -> enention (replace ‘i’ with ‘e’)
enention -> exention (replace ‘n’ with ‘x’)
exention -> exection (replace ‘n’ with ‘c’)
exection -> execution (insert ‘u’)
Python 代码如下:
def minDistance(word1, word2): dp = [[0 for _ in range(len(word2)+1)] for _ in range(len(word1)+1)] for i in range(len(word1)+1): for j in range(len(word2)+1): if i==0: dp[i][j] = j if j==0: dp[i][j] = i if i !=0 and j!=0 and word1[i-1] == word2[j-1]: dp[i][j] = dp[i-1][j-1] if i !=0 and j!=0 and word1[i-1] != word2[j-1]: dp[i][j] = min(dp[i][j-1],dp[i-1][j],dp[i-1][j-1])+1 return dp[len(word1)][len(word2)]
KMP(D.E.Knuth,J.H.Morris,V.R.Pratt)
关于 KMP 算法是做什么的,以及 KMP 算法的原理这些基本问题,可以参考这两篇文章:[1] 和 [2]。下面主要给出 KMP 算法的实现过程以及代码:
def strStr(self, strings, strs): # strings 是需要匹配的串,而 strs 是模式串 # KMP算法的核心是公式:若i为恰不匹配序号,则下一步匹配为 strs[next[i]] # 这个next数是针对模式串而言的,通过计算最大公共前后缀来得到 next 数组 # 举例如下: # 比如模式串为 strs=“ABCABEFAC”,长度为9,那么next数组就是长度为9的数组 # next的元素得到如下: # 首先:令 next[0] = -1 # next[i+1]表示needle[0..i]的最大公共前后缀长度 # 由于strs[0] = “A”,没有前后缀,故next[1] = 0 # 由于strs[0..1]=“AB”,前缀为{A},后缀为{B},故最大公共前后缀为0,next[2]=0 # 由于strs[0..2]=“ABC”,前缀为{A,AB},后缀为{BC,C},故next[3]=0 # 后面同理,于是可得 next数组为 [-1,0,0,0,1,2,0,0,1] # 将 next数组加上序号,可得table如下: #------------------------------------- #| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <-- 这是 strs 模式串序号 #------------------------------------- #| A | B | C | A | B | E | F | A | C | <-- 这是 strs 模式串 #------------------------------------- #|-1 | 0 | 0 | 0 | 1 | 2 | 0 | 0 | 1 | <-- 这是 next 数组 #------------------------------------- # 比如,需要匹配的字符串为 “ABCDABCABABCABEFACDAB” # 那么,有如下匹配: # “ABCDABCABABCABEFACDAB” # “ABCABEFAC” # 可以看到在 i=3 时(即'D'与'A')出现不匹配,由于needle[3]=0,那么将第0序号开始对齐 # “ABCDABCABABCABEFACDAB” # “ABCABEFAC” # 再由于 i=0 时(即'D'与'A')出现不匹配,由于needle[0]=-1,故将第-1序号开始对齐 # “ABCDABCABABCABEFACDAB” # “ABCABEFAC” # 此时,i=5 时(即'A'与'E')不匹配,由于needle[5]=2,故将第2序号开始对齐 # “ABCDABCABABCABEFACDAB” # “ABCABEFAC” # 此时已经找到匹配字符串,完成 #============================================ if not strs: return 0 i, j, m, n = -1, 0, len(strings), len(strs) # i 是 next数组元素值,j是next数组序号 # (由于 next在python中是关键字,此处写作nexts) nexts = [-1] * n # next 数组初始化 while j < n - 1: if i == -1 or strs[i] == strs[j]: i, j = i + 1, j + 1 nexts[j] = i else: i = nexts[i] i = j = 0 while i < m and j < n: if j == -1 or strings[i] == strs[j]: i, j = i + 1, j + 1 else: j = nexts[j] if j == n: return i - j return -1
参考博文:
[1] KMP算法图解之过程实现
[2] 如果你看不懂KMP算法,那就看一看这篇文章