算法-字符串问题
算法-查找子串
1. 最长无重复字符子串
给定一个字符串,找出不含重复字符的最长子串的长度。
示例:
- "abcabcbb" 最长非重复子串为 "abc",长度为 3。
- "" 最长非重复子串为 "",长度为 0。
1.1 双重循环暴力法
遍历每一个字符,并以该字符为起点,向后再次遍历整个字符串,如果下一个字符是新字符则当前子串长度 + 1,否则跳出内层循环,并比较当前子串和记录的最大子串长度,如果当前子串更长,则更新最长子串的长度记录。
1 | class Solution_DoubleLoop { |
1.2 滑动窗口法(List)
用「窗口」的概念表示一段子串,左侧表示子串的起始字符,右侧表示子串的终止字符,窗口初始宽度为 0,遍历字符串,每次将右侧一个字符加入窗口,即窗口「右侧」向右「扩张」一个字符;
- 如果窗口内子串仍符合条件,且长度大于记录的最大子串长度,则更新最大子串长度记录。
- 否则窗口的「左侧」向右不断「缩小」,直到窗口内的子串再次符合条件为止。
由于滑动窗口需要频繁修改窗口的起始和终止范围,所以可以用 List 来作为窗口,List 可以很方便操作首位和末尾,用 List 的首位表示窗口起始下标,List 的末位表示窗口终止下标。
1 | class Solution_SlidingWindow_List { |
1.3 滑动窗口法(Map)
用「窗口」的概念表示一段子串,左侧表示子串的起始字符,右侧表示子串的终止字符,窗口初始宽度为 0,遍历字符串,每次将右侧一个字符加入窗口,即窗口「右侧」向右「扩张」一个字符;
- 如果窗口内子串仍符合条件,且长度大于记录的最大子串长度,则更新最大子串长度记录。
- 否则窗口的「左侧」向右不断「缩小」,直到窗口内的子串再次符合条件为止。
使用 List 解法最大的问题在于,如果新加入一个字符后,窗口内子串不满足条件,也即与窗口内第 i
个字符重复,则需要一个内层循环从窗口的首位 0 一直 remove
到第 i
个元素。因此可以改用 Map,用一个单独的变量 left
保存窗口的起始下标(终止下标就是当前遍历的字符下标);
- 每次窗口成功扩张时(说明新字符加入后满足条件),都记录新的字符
char
及其对应的下标i
,并且比较left
到新字符下标的长度是否大于记录的最大子串长度,如果是则更新最大子串长度记录。 - 否则可以直接通过
Map.get(char)
一步获取到子串中重复字符的下标i
;- 直接将
left
指向子串中重复字符的下一位i + 1
; - 将新的字符(即重复的字符)的下标更新到 Map 中。
- 直接将
注意:left
始终只会向右移动。 由于采用 Map 记录字符及对应下标,所以发现重复字符时,left
直接移动到窗口内子串的重复字符下一位,这就导致 last left
到 new left
中间的字符,在 Map 中的下标并没有更新也没有被移除,而下一个字符有可能命中这中间的字符,也即 Map.contains(char) == true
,但实际上窗口已经不包括这些字符了,也即 left
指向的下标已经大于这些重复字符的下标,因此这种情况下即使 Map.contains(char) == true
,也不应该视为字符重复,所以仅当 Map.get(char) >= left
时,才应该更新 left
。
例如 "abba",用 []
表示滑动窗口,其包括的就是满足条件的子串;left
表示窗口左侧字符的下标;max
表示满足条件的最长子串长度:
遍历下标 i |
滑动窗口 | 窗口状态 | left | max |
---|---|---|---|---|
初始化 | []abba |
- | 0 | 0 |
0 | [a]bba |
正确,Map(a) = 0 |
0 | i - left + 1 == 1 |
1 | [ab]ba |
正确,Map(b) = 1 |
0 | i - left + 1 == 2 |
2 | [abb]a |
字符重复,将 left 指向重复字符下一位 | Map(b) + 1 == 2 |
2 |
ab[b]a |
更新重复字符的 Map 下标,Map(b) = 2 |
2 | 2 | |
3 | ab[ba] |
正确,尽管字符 "a" 重复,但不在窗口内(Map(a) == 0 < left == 2 ),不更新 left |
2 | 2 |
1 | class Solution_SlidingWindow_Map { |
2 最长回文子串
从给定的字符串中找出最长的回文子串,假定字符串一定不为空,即 s.length() >= 1
。
示例:
- 输入 "babad",输出 "bab" 或 "aba"
- 输入 "cbbd",输出 "bb"
2.1 中心扩散法
遍历每个字符,比较「中心字符」向左和向右 n 个字符是否相等;
- 如果相等并且长度大于记录的最大长度,则更新最大长度记录;
- 重复该比较,直到遇到不想等的字符为止。
- 否则遍历到下一个字符重复上述判断。
注意:因为同一个字符重复也可以算作回文,所以「中心字符」并不一定只是「1 个」字符,也可能是几个重复的字符,因此在寻找下一个中心字符时,需要先向左右判断是否有重复字符,如果有则合并到「中心」,因此开始扩散前,需要先记录「中心字符」的左侧下标和右侧下标。
例如 s = "acbbcd"
,用 []
表示中心字符,max
表示当前中心字符最长子串长度,maxSub
表示最终解的最长子串长度:
遍历下标 i |
扩散中心 | 中心范围 | 状态 | sub |
maxSub |
---|---|---|---|---|---|
初始化 | []acbbcd |
left = 0 right = 0 |
初始化 | "" | "" |
0 | [a]cbbcd |
left = i - 1 right = i + 1 |
s[left] != s[right] 扩散终止 |
"a" | "a" |
1 | a[c]bbcd |
left = i - 1 right = i + 1 |
s[left] != s[right] 扩散终止 |
"c" | "c" |
2 | ac[b]bcd |
left = i - 1 right = i + 1 |
s[i] == s[right] 需要向右合并中心 |
"b" | "b" |
ac[bb]cd |
left = i - 1 right++ |
s[left] == s[right] 可以向左右扩散 |
"bb" | "bb" | |
a[cbbc]d |
left-- right++ |
s[left] != s[right] 扩散终止 |
"cbbc" | "cbbc" | |
3 | acb[b]cd |
left = i - 1 right = i + 1 |
s[i] == s[left] 需要向左合并中心 |
"b" | "cbbc" |
ac[bb]cd |
left-- right = i + 1 |
s[left] == s[right] 可以向左右扩散 |
"bb" | "cbbc" | |
a[cbbc]d |
left-- right++ |
s[left] != s[right] 扩散终止 |
"cbbc" | "cbbc | |
4 | acbb[c]d |
left = i - 1 right = i + 1 |
s[left] != s[right] 扩散终止 |
"c" | "cbbc" |
5 | acbbc[d] |
left = i - 1 right = i + 1 |
s[left] != s[right] 扩散终止 |
"d" | "cbbc" |
1 | class Solution_CenterSpread { |
2.2 动态规划法
中心扩散法最主要的问题,是每次遍历时都需要重复向左右两边合并中心、比较两侧、判断扩散等。但实际上,如果有一段回文子串 sub
,并且该子串的左侧字符 s[left]
和右侧字符 s[right]
相等,那么 s[left] + sub + s[right]
也是一段回文子串。
反过来说,假如 s[left] + sub + s[right]
是一段回文子串,则需要满足 s[left] == s[right]
,以及 sub
也是一段回文子串。
再进一步:
- 当
s[left] != s[right]
时,则s[left]...s[right]
这段范围的子串「一定不是」回文子串。 - 当
s[left] == s[right]
时,假设被s[left]
和s[right]
包住的中间的字符串是sub
,那么如果sub
是回文子串,则s[left]...s[right]
就是回文子串,否则就不是。
因此,如果 s[left]...s[right]
是回文子串,则需要满足 s[left] == s[right]
,并且 s[left + 1]...s[right - 1]
也是回文子串,这就产生了递归推导。
注意:这种思路下,需要规定 right
一定不小于 left
,可以通过两层循环控制。此外,还需要区分不同情况:
- 只选中 1 个字符,即
left == right
,这种情况肯定是回文子串。 - 选中 2 个字符,即
right - left == 1
,这种情况如果s[left] == s[right]
则是回文子串,否则不是。 - 选中 3 个或以上字符,即
right - left >= 2
,这种情况则仅当s[left] == s[right]
并且s[left + 1]...s[right - 1]
也是回文子串时,s[left]...s[right]
才是回文子串;- 然后再对
s[left + 1]...s[right - 1]
同样采用上述步骤递归判断。
- 然后再对
1 | class Solution_DynamicProgramming { |
3. 正则匹配
给定一串文本 text
和一串正则表达式 regular
,判断表达式是否匹配(需匹配整串文本,而非部分字串)。
- 文本仅包含小写字母;正则表达式仅包含小写字母、'.'、'*';
- '.' 可以匹配任意单个字符;
- '*' 可以匹配零个或多个其前面的字符;
示例:
- 例 1:
text == "aa", regular == "a"
输出 false,"a"
无法匹配完整字符串"aa"
;- 例 2:
text == "abb", regular == "abc*b"
输出 true,"c*"
可以匹配零个"c"
;- 例 3:
text == "abcde", regular == ".*"
输出 true,".*"
表示可以匹配任意数量个("*"
)任意字符("."
);原题链接:正则表达式匹配
3.1 动态规划法
3.1.1 分析匹配过程
动态规划法的核心思想即为:通过已获取到的信息决定下一步策略。分析该正则匹配可以发现其具有明显的「顺序匹配」特征,文本与正则表达式总是从左至右开始匹配的,因此前一步是否匹配决定了下一步能否匹配。例如假设 text == "abccd", regular == "a*bd"
,根据从左至右匹配的顺序有:
- 第 1 步:
text[0] == "a", regular[0] == "a"
,匹配; - 第 2 步:
text[1] == "b", regular[1] == "*"
,判断通配符与前一字符作为整体regular[0~1] == "a*"
匹配text[0]
;- 因此如果此前所有部分(
text[0]
与regular[0]
)均匹配,则截至当前部分都是匹配的;
- 因此如果此前所有部分(
- 第 3 步:
text[1] == "b", regular[2] == "b"
,匹配;- 因此如果此前所有部分(
text[0]
与regular[0~1]
)均匹配,则截至当前部分都是匹配的;
- 因此如果此前所有部分(
- 第 4 步:
text[2] == c, regular[3] == "d"
,匹配失败,且不存在regular[4] == *
无法组成"d*"
;
观察上述过程可以发现,「截止到前 n 部分是否匹配」的结果由「第 (n - 1) 步是否匹配」与「第 n 步是否匹配」共同组成,这是一个不断迭代的过程。
3.1.2 抽象匹配问题
假设迭代至某一步时目标需要匹配的文本串下标为 i
、需要匹配的正则式下表为 j
,用 matchedLength[i+1][j+1]
表示 text[0~i]
与 regular[0~j]
是否匹配,则原问题转变为如何判断 matchedLength[n][m]
。
特别提示:
matchedLength
记录的是对应 长度 的文本串与正则式是否匹配,因此当 text 与 regular 分别匹配到下标为 i 和 j 的字符时,对应长度则为n = i + 1
及m = j + 1
,因此对应已匹配的长度则记录为matchedLength[n][m]
;- 反之,
matchedLength[i][j]
记录的是「text 的前 i 个字符与 regular 的前 j 个字符是否匹配」,因此其记录的字符串范围为text[0~(i-1)]
与regular[0~(j-1)]
;- 对于
matchedLength[n][m]
,当n == 0
或m == 0
时则对应了 text 为空或 regular 为空的情况;
因此当 n == text.length(), m == regular.length()
时,matchedLength[n][m]
即为判断完整文本串与完整正则式是否匹配了。
3.1.3 拆解匹配规则
根据上文可知,对于匹配过程中的某一步,假设此时待匹配的字符分别为 text[i]
与 regular[j]
,则推算的目标为:matchedLength[i+1][j+1]
,此时可将推算拆解为以下规则:
(0)已知定义
当 text 为空时,regular 仅在以下条件时才可与之匹配:
- regular 也为空,此时即为:
1 | // When: text == regular == empty |
- regular 不为空,但全部由通配体(即字符 +
"*"
或".*"
)组成,由于每一个通配体占 2 字符,换言之如果 regular 遍历到"*"
时,则直接删去 2 字符,如果剩余的前面部分为空、或仍旧全部由通配体组成,则整串正则式也可以与空文本匹配:
1 | // When: text == empty, regular[j] == "*" |
(1)字符匹配
例如:
text = "abcaa", regular = "abc*"
,当i == 1, j == 1
时,text[1] == regular[1] == "b"
。
当字符匹配(text[i] == regular[j] || regular[j] == "."
)时,意味着如果该字符之前的部分(text[0~(i-1)]
与 regular[0~(j-1)]
)可以匹配,则 text[0~i]
与 regular[0~j]
就能匹配,也即:
1 | // When: text[i] == regular[j] |
(2)字符不匹配
例如:
text = "abcaa", regular = "ac*bc"
,当i == 1, j == 1
时,text[1] == "b", regular[1] == "c"
。
当字符不匹配(text[i] != regular[j]
)时,matchedLength[i+1][j+1] = false
。
当然,如果后面跟随了通配符 "*"
则可以作为整体匹配 零次或多次,但因为匹配是从前往后的顺序,因此在下一轮匹配到 "*"
时再区分这种情况。
(3)遇到通配符
当遇到通配符时,其必须与前一个字符作为整体,可与字符匹配 零次或多次,因此分情况考虑:
- 通配符前一位字符与文本仅匹配一次:例如
text = "abc", regular = "abc*"
,当"c"
与"c*"
相匹配时,意味着各自去掉这部分后,如果各自剩下的前面部分互相匹配,则整整串文本与整串正则就能匹配:
1 | // When: regular[j] == "*" && text[i] == regular[j - 1] |
- 通配符前一位字符与文本匹配多次(以 2 次为例):例如
text = "abcc", regular = "abc*"
,当text[2] == text[3] == "c"
与"c*"
相匹配时,意味着如果去掉文本串中当前匹配的text[3]
,剩余的前面部分与正则相匹配(在这个小例中是必然的),则整串文本与整串正则就能匹配:
1 | // When: regular[j] == "*" && text[i-1] == text[i] == regular[j-1] |
- 通配符前一位字符与文本不匹配:例如
text = "ab", regular = "abc*"
,当"c*"
作为整体被匹配零次时,意味着如果去掉正则中的这一部分,剩余前面的部分与文本相匹配,则整串文本与整串正则就能相匹配:
1 | // When: regular[j] == "*" && text[i] != regular[j-1] |
- 通配符前一位字符与文本能匹配但作为整体不匹配:例如
text = "ab", regula = "abb*"
,当"b*"
作为整体被匹配零次时,同样意味着去掉正则中的这一部分,剩余前面的部分与文本相匹配则整串文本与整串正则就能相匹配:
1 | // When: regular[j] == "*" && text[i] == regular[j-1] && text[0~i] matched regular[0~(j-2)] |
3.1.4 代码实现
经过上文的拆解,可以总结出代码实现如下:
1 | public class RegularExpressMatcher { |