算法细节系列(33):再见字符串(1)
详细代码可以fork下Github上leetcode项目,不定期更新。
题目摘自leetcode:
- Leetcode 008. String to Integer (atoi)
- Leetcode 065. Valid Number
- Leetcode 005. Longest Palindromic Substring
- Leetcode 076. Minimum Window Substring
- Leetcode 003. Longest Substring Without Repeating Characters
- Leetcode 214.Shortest Palindrome
Leetcode 008. String to Integer (atoi)
字符串的题不是难,是烦,要考虑的边界条件太多了,但拿来练练还是不错的。
判断当前String能否转成Integer
此题还不算复杂,遇到空格直接跳过,所以刚开始检测出”+”or”-“,否则返回0,当遇到”+”or”-“时,i++,检测数字就行。此题的难点在于如何解决溢出问题。
刚开始的想法在循环外解决溢出,但发现long也有可能溢出,所以它的解决是,一旦超过int的范围就返回。
解决溢出的判断条件为:
if (Integer.MAX_VALUE / 10 < val || Integer.MAX_VALUE / 10 == val && Integer.MAX_VALUE % 10 < (c[i] - '0'))
return flag == 1 ? Integer.MAX_VALUE : Integer.MIN_VALUE;
代码如下:
public int myAtoi(String str) {
if (str.isEmpty()) return 0;
char[] c = str.toCharArray();
int i = 0;
while (i < c.length && c[i] == ' ') i++;
int flag = 1;
if (c[i] == '+' || c[i] == '-'){
flag = c[i++] == '-' ? -1 : 1;
}
long val = 0;
while (i < c.length && Character.isDigit(c[i])) {
if (Integer.MAX_VALUE / 10 < val || Integer.MAX_VALUE / 10 == val && Integer.MAX_VALUE % 10 < (c[i] - '0'))
return flag == 1 ? Integer.MAX_VALUE : Integer.MIN_VALUE;
val = val * 10 + (c[i++] - '0');
}
val = val * flag;
return (int)val;
}
Leetcode 065. Valid Number
这道题有点类似模式匹配的味道,同样需要考虑很多情况。遇到情况较多的字符串匹配,可以用DFA来解决,第一次尝试用DFA来处理字符串匹配问题,很有趣,不过在了解它强大之前,先看看根据规则匹配的直观代码:
public boolean isNumber(String s) {
if (s.isEmpty()) return false;
int n = s.length();
char[] c = s.toCharArray();
boolean isValid = true;
int i = 0;
while (i < n && c[i] == ' ') i++;
if (i == n || !(c[i] == '+' || c[i] == '-' || c[i] == '.' || Character.isDigit(c[i]))) return false;
//'+' or '-'
if (c[i] == '+' || c[i] == '-'){
i++;
}
if (c[i] == '.'){
i++;
if (i < n && c[i] == 'e') return false;
if (i < n && Character.isDigit(c[i])){
while (i < n && Character.isDigit(c[i])) i++;
if (i < n && c[i] == 'e'){
i++;
while (i < n && c[i] == ' ') i++;
if (i == n || ! (Character.isDigit(c[i]) || c[i] == '+' || c[i] == '-')) return false;
if (i < n && (c[i] == '+' || c[i] == '-')) i++;
if (i == n) return false;
while (i < n && Character.isDigit(c[i])) i++;
while (i < n && c[i] == ' ') i++;
if (i == n) return true;
return false;
}
while (i < n && c[i] == ' ') i++;
if (i == n) return true;
}
return false;
}
if (i == n || !Character.isDigit(c[i])) return false;
long num = 0;
while (i < n && Character.isDigit(c[i])){
num = 10 * num + c[i++] - '0';
}
while (i < n && c[i] == ' ') i++;
if (i == n) return isValid;
if (!(c[i] == '.' || c[i] == 'e')) return false;
if (!Character.isDigit(c[i-1]) && c[i] == '.') return false;
if (!Character.isDigit(c[i-1]) && c[i] == 'e') return false;
if (c[i] == '.'){
i++;
if (i < n && c[i] == 'e'){
i++;
while (i < n && c[i] == ' ') i++;
if (i == n || ! (Character.isDigit(c[i]) || c[i] == '+' || c[i] == '-')) return false;
if (i < n && (c[i] == '+' || c[i] == '-')) i++;
if (i == n) return false;
while (i < n && Character.isDigit(c[i])) i++;
while (i < n && c[i] == ' ') i++;
if (i == n) return true;
return false;
}
if (i < n && Character.isDigit(c[i])){
while (i < n && Character.isDigit(c[i])) i++;
if (i < n && c[i] == 'e'){
i++;
while (i < n && c[i] == ' ') i++;
if (i == n || ! (Character.isDigit(c[i]) || c[i] == '+' || c[i] == '-')) return false;
if (i < n && (c[i] == '+' || c[i] == '-')) i++;
if (i == n) return false;
while (i < n && Character.isDigit(c[i])) i++;
while (i < n && c[i] == ' ') i++;
if (i == n) return true;
return false;
}
while (i < n && c[i] == ' ') i++;
if (i == n) return true;
return false;
}
while (i < n && c[i] == ' ') i++;
if (i == n) return true;
return false;
}
if (i < n && c[i] == 'e'){
i++;
while (i < n && c[i] == ' ') i++;
if (i == n || ! (Character.isDigit(c[i]) || c[i] == '+' || c[i] == '-')) return false;
if (i < n && (c[i] == '+' || c[i] == '-')) i++;
if (i == n) return false;
while (i < n && Character.isDigit(c[i])) i++;
while (i < n && c[i] == ' ') i++;
if (i == n) return true;
return false;
}
return isValid;
}
这是我的第一个版本,但在写代码时发现,很多模式是重复的,比如判断e后面是否为数字的情况,这一状态的判断将被用在很多处地方,所以完全可以重用很多代码。
上述复杂的代码完全可以用一幅图替代,如下:
根据规则我们不难写出这样的状态转移图,真的是very badly poor at DFA啊,根据状态转移图写出的DFA代码居然还是那么长,代码如下:
static int[][] DFA = {
{0,0,0,0,0,0,0,0,0,0},
{0,1,2,3,4,0,0,0,0,0},
{0,0,0,3,4,0,0,0,0,0},
{0,0,0,3,0,5,6,0,0,9},
{0,0,0,0,0,5,0,0,0,0},
{0,0,0,0,0,5,6,0,0,9},
{0,0,0,0,0,0,0,7,8,0},
{0,0,0,0,0,0,0,0,8,0},
{0,0,0,0,0,0,0,0,8,9},
{0,0,0,0,0,0,0,0,0,9}
};
public boolean isNumber(String s) {
if (s.isEmpty()) return false;
int n = s.length();
char[] c = s.toCharArray();
int currentState = 1;
for (int i = 0; i < n; ++i){
if (c[i] == ' '){
if (currentState == 1) currentState = DFA[currentState][1];
else if (currentState == 9) currentState = DFA[currentState][9];
else if (currentState == 3) currentState = DFA[currentState][9];
else if (currentState == 5) currentState = DFA[currentState][9];
else if (currentState == 8) currentState = DFA[currentState][9];
else return false;
}
else if (c[i] >= '0' && c[i] <= '9'){
if (currentState ==1) currentState = DFA[currentState][3];
else if (currentState ==2) currentState = DFA[currentState][3];
else if (currentState ==3) currentState = DFA[currentState][3];
else if (currentState ==4) currentState = DFA[currentState][5];
else if (currentState ==5) currentState = DFA[currentState][5];
else if (currentState ==7) currentState = DFA[currentState][8];
else if (currentState ==6) currentState = DFA[currentState][8];
else if (currentState ==8) currentState = DFA[currentState][8];
else return false;
}
else if (c[i] == '.'){
if (currentState ==1) currentState = DFA[currentState][4];
else if (currentState ==2) currentState = DFA[currentState][4];
else if (currentState ==3) currentState = DFA[currentState][5];
else return false;
}
else if (c[i] == '+' || c[i] == '-'){
if (currentState ==1) currentState = DFA[currentState][2];
else if (currentState ==6) currentState = DFA[currentState][7];
else return false;
}
else if (c[i] == 'e'){
if (currentState == 3) currentState = DFA[currentState][6];
else if (currentState == 5) currentState = DFA[currentState][6];
else return false;
}
else return false;
}
return currentState == 9 || currentState == 3 || currentState == 5 || currentState == 8;
}
核心思想:遍历所有字符,如果状态没有发生转移的情况return false,其他情况下,有限自动机运行着,如果最终能够抵达3,5,8,9这四个状态说明匹配成功。
这是最直观的代码,而且仔细观察代码会发现会有很多if else的判断,比如:
if (currentState ==1) currentState = DFA[currentState][3];
else if (currentState ==2) currentState = DFA[currentState][3];
else if (currentState ==3) currentState = DFA[currentState][3];
else if (currentState ==4) currentState = DFA[currentState][5];
else if (currentState ==5) currentState = DFA[currentState][5];
else if (currentState ==7) currentState = DFA[currentState][8];
else if (currentState ==6) currentState = DFA[currentState][8];
else if (currentState ==8) currentState = DFA[currentState][8];
else return false;
这是因为在不同的状态下,检查到数字情况下的转移过程不一定完全相同,所以我们需要单独判断以应对不同情况。
当然我们也不一定要完全使用DFA,代码还可以简化,如下:
public boolean isNumber(String s) {
if (s.isEmpty()) return false;
int n = s.length();
char[] c = s.toCharArray();
int currentState = 1;
for (int i = 0; i < n; ++i){
if (c[i] == ' '){
if (currentState == 1) currentState = 1;
else if (currentState == 9 || currentState == 3) currentState = 9;
else if (currentState == 5 || currentState == 8) currentState = 9;
else return false;
}
else if (c[i] >= '0' && c[i] <= '9'){
if (currentState ==1 || currentState == 2 || currentState == 3) currentState = 3;
else if (currentState ==4 || currentState == 5) currentState = 5;
else if (currentState ==7 || currentState == 6 || currentState == 8) currentState = 8;
else return false;
}
else if (c[i] == '.'){
if (currentState ==1 || currentState == 2) currentState = 4;
else if (currentState ==3) currentState = 5;
else return false;
}
else if (c[i] == '+' || c[i] == '-'){
if (currentState ==1) currentState = 2;
else if (currentState ==6) currentState = 7;
else return false;
}
else if (c[i] == 'e'){
if (currentState == 3 || currentState == 5) currentState = 6;
else return false;
}
else return false;
}
return currentState == 9 || currentState == 3 || currentState == 5 || currentState == 8;
}
所以只要能够画出状态转移图,就能写出简洁的代码。不过这现象很有趣,原本很复杂的代码逻辑,一旦用DFA解决,整个代码结构就变得清晰易懂,这是为什么?
- 我个人认为DFA的一个最大特点就是对状态的抽象,在一个DFA中,我们能看到很多状态以及状态之间的转移。最先版本的代码中,为了判断一个e后面的逻辑,我们总共写了好几处,无非是因为有很多状态都能抵到e,代码自然复杂,而DFA把e后续的状态用一套逻辑给解决了。
- 第二点,标记和信息的区别,我们只需要判断当前字符串是否符合某种语法规则,所以我们强调的是对标记的识别而不是信息的采集。
可以把DFA想象成一个有内在逻辑的黑盒,我们给它输入信息,如果信息符合DFA的逻辑,它就会发生状态转移,而如果信息流能够一下抵达DFA的终态,说明匹配成功,而DFA一旦在中间出现卡顿,则匹配失败。DFA之所以能够简化大量代码,是因为它在构造逻辑时就去重了。可见状态抽象的重要性,起码能让我们找到同质的状态。
Leetcode 005. Longest Palindromic Substring
一开始以为palindrome可以不连续,但发现题解中都指明最长palindrome连续,两种做法,一种比较直接,直接从一个字符扩展palindrome,并记录最大值,不断比较就好。
另一种方法使用dp,如果有更大的区间,则更新,注意下遍历方向性问题就好了。
代码如下:(扩展,奇偶性判断挺巧妙的)
int lo, hi, max;
public String longestPalindrome(String s) {
int n = s.length();
if (n == 0) return "";
for (int i = 0; i < n - 1; ++i){
extendPalindrome(s.toCharArray(), i, i);
extendPalindrome(s.toCharArray(), i, i+1);
}
return s.substring(lo, hi + 1);
}
private void extendPalindrome(char[] c, int i, int j){
while (i >= 0 && j < c.length && c[i] == c[j]){
i--;
j++;
}
if (j - i - 1 > max){
max = j - i - 1;
lo = i + 1;
hi = j - 1;
}
}
动态规划法:
public String longestPalindrome(String s) {
int n = s.length();
if (n == 0) return "";
char[] cs = s.toCharArray();
boolean[][] dp = new boolean[n][n];
String ans = "";
for (int i = 0; i < n; ++i) dp[i][i] = true;
ans = s.substring(0, 1);
for (int i = 0; i < n; ++i){
for (int j = i - 1; j >= 0; --j){
if (cs[j] == cs[i]){
dp[j][i] = dp[j+1][i-1] && i - j + 1 >= 3 || i - j + 1 == 2;
}
if (dp[j][i] && (i - j + 1) > ans.length()){
ans = s.substring(j,i+1);
}
}
}
return ans;
}
前者速度居然要快些,可能是少了很多subString的操作。
Leetcode 076. Minimum Window Substring
思路:
S = "ADOBECODEBANC"
T = "ABC"
先构造一个有效的窗口,在S中,有效窗口为"ADOBEC",里面还有"ABC"
如何判断有效窗口?(letterCnt计数)
更新最小窗口,很简单,在后续加入新的字符时,判断是否为T中的元素?
否:不做任何操作
是:又分为两种情况
a. "ADOBECODEB",此时不能更新窗口,因为一旦A被删除,此窗口就少了A元素
b. "ADOBECODEBA",此时可以删除A元素,得到最新的窗口为"CODEBA",这里还连带删除了B元素
这是思路,可程序怎么写?
不管是a还是b,窗口更新都是从左至右一个一个元素比较,所以我们可以定义一个slow指针,用来表示窗口的左侧。
此时就需要判断每个slow指向的元素是否能够删除,所以我们需要定一个合法窗口,用
int[] window = new int[128] 表示
其实就是一个map,但这map功能强大
第一,可以记录窗口中T中每个字符的个数,这样我们就可以判断slow指向的元素是否可以删除。
第二,窗口真正的含义被改写,不再是数组中的某个固定的区间,简化了大量判断逻辑(很有用的想法,没hold住)。
总结:
- 把握住了窗口的性质,只需要根据窗口符合的性质建立数据结构即可,而不是真的定义一个“区间窗口”,否则带来的边界更新复杂又痛苦。
- 窗口滑动,可以看成两个指针在数组中相对位置的改变,用双指针同样降低了代码复杂性。
代码如下:
public String minWindow(String s, String t) {
if (t.length() > s.length()) return "";
int[] map = new int[128];
int[] window = new int[128];
char[] ts = t.toCharArray();
for (int i = 0; i < t.length(); ++i){
map[ts[i] - 'A']++;
}
char[] ss = s.toCharArray();
int letterCnt = 0;
int slow = 0;
int min = Integer.MAX_VALUE;
int lo = -1, hi = -1;
for (int i = 0; i < s.length(); ++i){
if (map[ss[i] - 'A'] != 0){
if (letterCnt < t.length() && window[ss[i] - 'A'] < map[ss[i] - 'A']){
letterCnt++;
}
window[ss[i] - 'A']++;
}
if(letterCnt >= t.length()){
while(slow < s.length() && (window[ss[slow] - 'A'] == 0 || window[ss[slow] - 'A'] > map[ss[slow] - 'A'])){
if (window[ss[slow] - 'A'] != 0){
window[ss[slow] - 'A']--;
}
slow++;
}
if (i - slow + 1 < min){
min = i - slow + 1;
lo = slow;
hi = i;
}
}
}
if (lo == -1 && hi == -1) return "";
return s.substring(lo, hi + 1);
}
此处隐含了很多细节问题,自行体会。
这是我最先的版本,无法通过一些个别案例,对窗口的建立没把握住,所导致的代码复杂度也成倍上升。(有趣的课题,可以研究研究)
public String minWindow(String s, String t) {
int[] map = new int[128];
int[] pos = new int[128];
for (int i = 0; i < t.length(); ++i){
map[t.charAt(i) - 'A']++;
pos[t.charAt(i) - 'A']++;
}
int letterCounter = 0;
int n = s.length();
char[] cs = s.toCharArray();
int[] queue = new int[s.length()];
int fir = 0, lst = 0;
int min = Integer.MAX_VALUE,lo = -1, hi = -1;
for (int i = 0; i < n; ++i){
if (map[cs[i]-'A'] != 0 && letterCounter != t.length()){
queue[lst++] = i;
map[cs[i]- 'A']--;
letterCounter++;
if (letterCounter == t.length()){
//total
int len = queue[lst-1] - queue[fir] + 1;
min = len;
lo = queue[fir];
hi = queue[lst -1];
continue;
}
}
if (letterCounter == t.length()){
if (pos[cs[i]-'A'] != 0){
queue[lst++] = i;
int l = fir;
int r = lst - 1;
while (l < r){
char key = cs[queue[l]];
for (int k = r; k >= l; --k){
if (cs[queue[k]] == key){
if (k == l){
r = l;
break;
}
l++;
r--;
break;
}
}
}
fir = l;
int len = queue[lst -1] - queue[fir] + 1;
if (len < min){
min = len;
lo = queue[fir];
hi = queue[lst - 1];
}
}
}
}
if (lo == -1 && hi == -1) return "";
return s.substring(lo, hi+1);
}
Leetcode 003. Longest Substring Without Repeating Characters
核心思路:
- 记录每个字符对应的下标,用map存,如果遇到map中存在的字符,进行更新操作,更新规则为当前下标
i - map.get(ss[i])
。
如:
a b c a b c
0 1 2 3 4 5
map : {a = 0, b = 1, c = 2}
当遇到下一个a时,i = 3
len = i - {a = 0} = 3
每次更新成最小的即可。
遇到"abc"这种情况怎么处理?
a b c
0 1 2
此时无法根据map.contains(key)来判断,用一个slow指针,表示当前更新窗口的合法左区间
slow = 0
if (!map.contains[ss[i]]) 更新长度
if (map.constains[ss[i]]) 两种情况
lst = map.get(ss[i])
a. 当前窗口左区间slow > lst,说明合法窗口在slow,而不在lst,更新规则为:max = i - slow + 1;
b. 当前窗口左区间slow < lst,说明合法窗口要调整右移,slow = lst + 1; 更新规则为:max = i - lst;
代码如下:
public int lengthOfLongestSubstring(String s) {
if (s.isEmpty()) return 0;
Map<Character, Integer> map = new HashMap<>();
int max = 0;
char[] ss = s.toCharArray();
int n = ss.length;
int slow = 0;
for (int i = 0; i < n; ++i){
if(!map.containsKey(ss[i])){
map.put(ss[i],i);
if (i - slow + 1 > max){
max = i - slow + 1;
}
}else{
int lst = map.get(ss[i]);
if (lst < slow){
if (i - slow + 1 > max){
max = i - slow + 1;
}
}
else{
if (i - lst > max){
max = i - lst;
}
slow = lst + 1;
}
map.put(ss[i], i);
}
}
return max;
}
现在可以开始神奇的代码约简了,首先slow,可以用slow = Math.max(slow, lst + 1)
统一更新,这样,更新规则可以完全合并,如下:
public int lengthOfLongestSubstring(String s) {
if (s.isEmpty()) return 0;
Map<Character, Integer> map = new HashMap<>();
int max = 0;
char[] ss = s.toCharArray();
int n = ss.length;
int slow = 0;
for (int i = 0; i < n; ++i){
if(map.containsKey(ss[i])){
int lst = map.get(ss[i]);
slow = Math.max(slow, lst + 1);
}
if (i - slow + 1 > max){
max = i - slow + 1;
}
map.put(ss[i],i);
}
return max;
}
最后再把max用一句话表示即可,代码如下:
public int lengthOfLongestSubstring(String s) {
if (s.isEmpty()) return 0;
Map<Character, Integer> map = new HashMap<>();
int max = 0;
char[] ss = s.toCharArray();
int n = ss.length;
int slow = 0;
for (int i = 0; i < n; ++i){
if(map.containsKey(ss[i])){
int lst = map.get(ss[i]);
slow = Math.max(slow, lst + 1);
}
max = Math.max(max, i - slow + 1);
map.put(ss[i],i);
}
return max;
}
Leetcode 214.Shortest Palindrome
思路:
比较暴力的一种做法,但居然AC了。
a a c e c a a a
0 1 2 3 4 5 6 7
isPalindrome(s,0,7) 判断字符串s在0-7内是否为回文.
if not
添加“a”,判断isPalindrome(s,0,6)是否为回文。
直到余下的某个i,isPalindrome(s,0,i)为回文,返回
代码如下:
public String shortestPalindrome(String s) {
if (s.length() <= 1) return s;
int n = s.length();
char[] cs = s.toCharArray();
StringBuilder sb = new StringBuilder();
for (int i = n - 1; i >= 0; --i) {
if (isPalindrome(cs, 0, i)) return sb.toString() + s.substring(0, i+1) + sb.reverse().toString();
else sb.append(cs[i]);
}
return "";
}
private boolean isPalindrome(char[] c, int i, int j){
while (i < j){
if (c[i] != c[j]) return false;
i++;
j--;
}
return true;
}
此题还有一种更聪明的做法,采用KMP Table,学习KMP的时候再来看看它的精髓吧。