算法-字符串问题

算法-查找子串

1. 最长无重复字符子串

给定一个字符串,找出不含重复字符的最长子串的长度。

示例:

  • "abcabcbb" 最长非重复子串为 "abc",长度为 3。
  • "" 最长非重复子串为 "",长度为 0。

1.1 双重循环暴力法

遍历每一个字符,并以该字符为起点,向后再次遍历整个字符串,如果下一个字符是新字符则当前子串长度 + 1,否则跳出内层循环,并比较当前子串和记录的最大子串长度,如果当前子串更长,则更新最长子串的长度记录。

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
class Solution_DoubleLoop {
public int lengthOfLongestSubstring(String s) {
if (s == null || s.length() <= 0) {
return 0;
}
int maxCount = 0;
char[] charArray = s.toCharArray();
// 外层循环遍历整个字符串
for (int charIndex = 0; charIndex < charArray.length; charIndex++) {
int maxSubCount = 0;
String maxSubStr = "";
// 内层循环从
for (int subIndex = charIndex; subIndex < charArray.length; subIndex++) {
if (maxSubStr.contains("" + charArray[subIndex])) {
break;
} else {
maxSubCount++;
maxSubStr += charArray[subIndex];
}
}
if (maxSubCount > maxCount) {
maxCount = maxSubCount;
}
}
return maxCount;
}
}

1.2 滑动窗口法(List)

用「窗口」的概念表示一段子串,左侧表示子串的起始字符,右侧表示子串的终止字符,窗口初始宽度为 0,遍历字符串,每次将右侧一个字符加入窗口,即窗口「右侧」向右「扩张」一个字符;

  • 如果窗口内子串仍符合条件,且长度大于记录的最大子串长度,则更新最大子串长度记录。
  • 否则窗口的「左侧」向右不断「缩小」,直到窗口内的子串再次符合条件为止。

由于滑动窗口需要频繁修改窗口的起始和终止范围,所以可以用 List 来作为窗口,List 可以很方便操作首位和末尾,用 List 的首位表示窗口起始下标,List 的末位表示窗口终止下标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution_SlidingWindow_List {
public int lengthOfLongestSubstring(String s) {
List<Character> charList = new ArrayList<>();
int max = 0;
for (int index = 0; index < s.length(); index++) {
if (charList.contains(s.charAt(index))) {
int repeatIndex = charList.indexOf(s.charAt(index));
for (int rmIndex = 0; rmIndex <= repeatIndex; rmIndex++) {
charList.remove(0);
}
charList.add(s.charAt(index));
} else {
charList.add(s.charAt(index));
max = Math.max(max, charList.size());
}
}
return max;
}
}

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 leftnew 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution_SlidingWindow_Map {
public int lengthOfLongestSubstring(String s) {
// 记录遍历过的字符及对应的下标,用于遇到重复字符时,直接移动窗口起始下标到子串重复位的下一位。
Map<Character, Integer> charMap = new HashMap<>();
// 表示当前有效子串的起始下标,配合最新字符下标可计算窗口范围(即子串长度)。
int left = 0;
// 最大窗口范围(即子串长度)。
int max = 0;
for (int index = 0; index < s.length(); index++) {
char next = s.charAt(index);
// 只有存在该字符「且记录的位置大于当前窗口起始坐标时」才移动窗口,否则例如 abba:
// - 当 index == 2,left = get(b) + 1 = 2,
// - 当 index == 3,left = get(a) + 1 = 1,显然不对,此时应该让 left 保持为 2。
if (charMap.containsKey(next) && charMap.get(next) >= left) {
left = charMap.get(next) + 1;
} else {
max = Math.max(max, (index - left + 1));
}
// 不论是否重复都需要记录,如果重复了就需要更新
charMap.put(next, index);
}
return max;
}
}

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
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
class Solution_CenterSpread {
public String longestPalindrome(String s) {
// 最长回文子串
String maxSub = "";
// 每一轮遍历中当前中心字符的回文子串
String sub = "";
// 每一轮遍历中当前中心字符的左侧下标
int left = 0;
// 每一轮遍历中当前中心字符的右侧下标
int right = 0;
for (int i = 0; i < s.length(); i++) {
left = i - 1;
right = i + 1;
// 最长子串至少也是当前字符
sub = "" + s.charAt(i);
// 先向左寻找,与当前字符重复的字符都纳入「中心字符」,直到遇到第一个不重复的为止。
while (left >= 0 && s.charAt(left) == s.charAt(i)) {
sub = s.charAt(left) + sub;
left--;
}
// 再向右寻找,与当前字符重复的字符都纳入「中心字符」,直到遇到第一个不重复的为止。
while (right < s.length() && s.charAt(right) == s.charAt(i)) {
sub = sub + s.charAt(right);
right++;
}
// 然后同时向两边扩散,如果两侧字符相等,则都纳入子串,直到遇到不相等的一对。
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
sub = s.charAt(left) + sub + s.charAt(right);
left--;
right++;
}
// 如果当前「中心字符」的回文子串大于记录的最长子串,则更新记录。
if (sub.length() > maxSub.length()) {
maxSub = sub;
}
}
return maxSub;
}
}

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
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
class Solution_DynamicProgramming {
public String longestPalindrome(String s) {
// 最长回文子串在原字符串的起始下标
int maxSubLeft = 0;
// 最长回文子串的长度
int maxSubLength = 0;
// 用一个二维数组 dp[left][right] 表示从 s[left]...s[right] 是否为回文子串,
// dp[left][right] 初始化时默认值全为 false。
boolean [][] dp = new boolean[s.length()][s.length()];
// 因为 dp[left][right] 数组的定义,right 一定大于等于 left
for (int right = 0; right < s.length(); right++) {
for (int left = 0; left <= right; left++) {
// 如果 s[left] == s[right],分为三种情况
// (1)left == right
// 表示当前 1 个字符,则肯定是回文子串,dp[left][right] = true
// (2)right - left = 1
// 表示 2 个相邻重复字符,既然相等则肯定也是回文子串,dp[left][right] = true
// (3)right - left > 1
// 表示子串至少包含 3 个字符,例如 aba 或 abba,则这个子串是否为回文子串,
// 依赖于 s[left + 1]...s[right - 1] 是否为回文子串,
// 即依赖于 dp[left + 1][right - 1] 是否为 true。
// 以上情况(1)和情况(2),都能直接确定 dp[left][right] = true
if (s.charAt(left) == s.charAt(right)) {
if (right - left <= 1) {
// 对应情况(1)和情况(2)
dp[left][right] = true;
} else if (dp[left + 1][right - 1]) {
// 对应情况(3)
dp[left][right] = true;
}
}
// dp[left][right] 数组初始化时默认所有元素都是 false,
// 所以如果 dp[left][right] == true,
// 就表示上面的条件种判断了 s[left]...s[right] 为回文子串,
// 则如果这个回文子串的长度大于记录的最大长度,就可以更新记录。
if (dp[left][right] && right - left + 1 > maxSubLength) {
maxSubLeft = left;
maxSubLength = right - left + 1;
}
}
}
// String#substring(startIndex, endIndex) 返回的是一个 [左闭, 右开) 区间
return s.substring(maxSubLeft, maxSubLeft + maxSubLength);
}
}

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 + 1m = j + 1,因此对应已匹配的长度则记录为 matchedLength[n][m]
  • 反之,matchedLength[i][j] 记录的是「text 的前 i 个字符与 regular 的前 j 个字符是否匹配」,因此其记录的字符串范围为 text[0~(i-1)]regular[0~(j-1)]
  • 对于 matchedLength[n][m],当 n == 0m == 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
2
// When: text == regular == empty
matchedLength[0][0] = true,
  • regular 不为空,但全部由通配体(即字符 + "*"".*")组成,由于每一个通配体占 2 字符,换言之如果 regular 遍历到 "*" 时,则直接删去 2 字符,如果剩余的前面部分为空、或仍旧全部由通配体组成,则整串正则式也可以与空文本匹配:
1
2
// When: text == empty, regular[j] == "*"
matchedLength[0][j+1] = matchedLength[0][j-1],

(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
2
// When: text[i] == regular[j]
matchedLength[i+1][j+1] = matchedLength[i][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
2
// When: regular[j] == "*" && text[i] == regular[j - 1]
matchedLength[i+1][j+1] = matchedLength[i][j-1],
  • 通配符前一位字符与文本匹配多次(以 2 次为例):例如 text = "abcc", regular = "abc*",当 text[2] == text[3] == "c""c*" 相匹配时,意味着如果去掉文本串中当前匹配的 text[3],剩余的前面部分与正则相匹配(在这个小例中是必然的),则整串文本与整串正则就能匹配:
1
2
// When: regular[j] == "*" && text[i-1] == text[i] == regular[j-1]
matchedLength[i+1][j+1] = matchedLength[i][j+1],
  • 通配符前一位字符与文本不匹配:例如 text = "ab", regular = "abc*",当 "c*" 作为整体被匹配零次时,意味着如果去掉正则中的这一部分,剩余前面的部分与文本相匹配,则整串文本与整串正则就能相匹配:
1
2
// When: regular[j] == "*" && text[i] != regular[j-1]
matchedLength[i+1][j+1] = matchedLength[i+1][j-1],
  • 通配符前一位字符与文本能匹配但作为整体不匹配:例如 text = "ab", regula = "abb*",当 "b*" 作为整体被匹配零次时,同样意味着去掉正则中的这一部分,剩余前面的部分与文本相匹配则整串文本与整串正则就能相匹配:
1
2
// When: regular[j] == "*" && text[i] == regular[j-1] && text[0~i] matched regular[0~(j-2)]
matchedLength[i+1][j+1] = matchedLength[i+1][j-1],

3.1.4 代码实现

经过上文的拆解,可以总结出代码实现如下:

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
47
48
49
50
51
52
53
54
55
56
57
public class RegularExpressMatcher {
public boolean isMatch(String text, String regular) {
// 尽管按照题意 text 与 regular 不会为空,但作为代码仍需对边界值保护。
if (text == null || text.length() <= 0 || regular == null || regular.length() <= 0) {
return true;
}
if (regular.equals(".*")) {
return true;
}

boolean [][]matchedLength = new boolean[text.length() + 1][regular.length() + 1];

// 0. 已知定义:
// - 当 text 与 regular 均为空时,视作匹配;
matchedLength[0][0] = true;
// - 否则仅当 regular 全部由通配体组成时才匹配;
for (int j = 0; j < regular.length(); j++) {
if (regular.charAt(j) == '*') {
matchedLength[0][j + 1] = matchedLength[0][j - 1];
}
}

for (int i = 0; i < text.length(); i++) {
for (int j = 0; j < regular.length(); j++) {
if (regular.charAt(j) == '*') {
if (isCharMatched(text, i, regular, j - 1)) {
// 1. 当遇到通配符、且其前一个字符与文本相同时,则可能匹配零次、一次、多次:
// - 零次:"ab" 与 "abb*",说明可以去掉正则部分
// - 一次:"ab" 与 "ab*",说明可以各自去掉文本与正则部分
// - 多次:"abbb" 与 "ab*",说明可以去掉一个文本部分
matchedLength[i + 1][j + 1]
= matchedLength[i + 1][j - 1] // 零次
|| matchedLength[i][j - 1] // 一次
|| matchedLength[i][j + 1]; // 多次
} else {
// 2. 当遇到通配符、且前一个字符与文本不同时,则通配体匹配了零次,相当于去掉通配体:
matchedLength[i + 1][j + 1] = matchedLength[i + 1][j - 1];
}
} else if (isCharMatched(text, i, regular, j)) {
// 3. 当普通字符相同或为 "." 时,则如果各自去掉字符后剩余的匹配,则整串匹配:
matchedLength[i + 1][j + 1] = matchedLength[i][j];
}
}
}

// 最终需要知道的是整串文本(text[0~i])与整串正则(regular[0~j])是否匹配,
// 因此返回的即为 matchedLength[n][m],其中 n == i+1 == text.length(), m == j+1 == regular.length()。
return matchedLength[text.length()][regular.length()];
}

private boolean isCharMatched(String text, int txtIndex, String regular, int regIndex) {
if (regular.charAt(regIndex) == '.') {
return true;
}
return (text.charAt(txtIndex) == regular.charAt(regIndex));
}
}