剑指Offer算法课(三)字符串

120 阅读8分钟

字符串的基础知识

在统计字母出现次数的问题中,双指针哈希表都是常用的技术。


面试题14:字符串中的变位词

leetcode.cn/problems/MP…

给定两个字符串 s1 和 s2,写一个函数来判断 s2 是否包含 s1 的某个变位词。两个字符串都是由小写英文字母构成。换句话说,第一个字符串的排列之一是第二个字符串的 子串 。

ANSWER

首先理解题目,s1的变位词共有m!个,其中m=s1.length()。显然不建议用暴力方法求解(先得出所有s1的变位词,然后用contains判断是否s2含有这个变位词)。

换个思路,“变位词”意味着字符串中每个字符的顺序不重要,只需要关注“是否存在”这个字符即可,哈希表是不关心顺序只关心有无的数据结构,因此这里使用哈希表作为解题思路。同时要考虑字符串长度相等,因此我们基于双指针使用滑动窗口。让长度为m的窗口在字符串s2上面从左向右滑动,每一次滑动意味着删除最左侧的字符,同时在最右侧增加一个字符。

因为字符串都是由小写英文字母构成,因此可以使用长度26的数组代替哈希表,更进一步的话,使用位运算。

这种解法需要扫描s1、s2各一次,故时间复杂度O(m+n),不需要额外开辟空间(使用长度不超过26的数组),空间复杂度O(1)

public boolean checkInclusion(String s1, String s2) {
    if (s2.length() < s1.length()) return false; // 长度不符直接失败
    int[] counts = new int[26];
    for (int i=0; i<s1.length(); i++) { // 初始化,首先看s2最左侧长度为m的子字符串
        counts[s1.charAt(i) - 'a']++;
        counts[s2.charAt(i) - 'a']--;
    }
    if (areAllZero(counts)) return true;
    for (int i=s1.length(); i<s2.length(); ++i) {
        counts[s2.charAt(i) - 'a']--;
        counts[s2.charAt(i - s1.length() - 'a']++; // 窗口右移
        if (areAllZero(counts)) return true;
    }
    return false;
}

private boolean areAllZero(int[] counts) {
    for (int count : counts) {
        if (count != 0) return false;
    }
    return true;
}

面试题15:字符串中所有的变位词

leetcode.cn/problems/Va…

给定两个字符串 s 和 p,找到 s 中所有 p 的 变位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

ANSWER

与上一题相似度99%。

public boolean findAllAnagrams(String s1, String s2) {
    List<Integer> indices = new ArrayList();
    if (s2.length() < s1.length()) return indices;
    int[] counts = new int[26];
    int i = 0;
    for (; i<s1.length(); i++) { // 初始化,首先看s2最左侧长度为m的子字符串
        counts[s1.charAt(i) - 'a']++;
        counts[s2.charAt(i) - 'a']--;
    }
    if (areAllZero(counts)) indices.add(0);
    for (; i<s2.length(); ++i) {
        counts[s2.charAt(i) - 'a']--;
        counts[s2.charAt(i - s1.length() - 'a']++; // 窗口右移
        if (areAllZero(counts)) indices.add(i - s1.length() + 1);
    }
    return indices;
}

private boolean areAllZero(int[] counts) {
    for (int count : counts) {
        if (count != 0) return false;
    }
    return true;
}

面试题16:不含重复字符的最长字符串

leetcode.cn/problems/wt…

给定一个字符串 s ,请你找出其中不含有重复字符的 最长连续子字符串 的长度。

ANSWER

题目中涉及到“子字符串”,很有可能是双指针法。“不含重复字符”,考虑哈希表。

知识点:ASCII码共有256个字符。

使用一个count[]数组来记录每个字符在窗口字符串中出现的次数,如果出现次数均不大于1,说明这是一个无重复字符的子字符串。

该解法需要扫描s一次,故时间复杂度O(n),空间复杂度O(1)

public int lengthOfLongestSubstring(String s) {
    if (s.length() == 0) return 0;
    int[] counts = new int[256];
    int i = 0;
    int j = -1;
    int longest = 1;
    for (; i<s.length(); ++i) {
        counts[s.charAt(i)]++;
        while (hasGreaterThan1(counts)) {
            ++j; // 左指针右移
            counts[s.charAt(j)]--;
        }
        longest = Math.max(i - j, longest);
    }
    return longest;
}

private boolean hasGreaterThan1(int[] counts) {
    for (int i : counts) {
        if (i > 1) return true;
    }
    return false;
}

优化策略: 使用一个局部变量countDup记录当前有多少个字符出现次数大于1,在遍历过程中对该变量+1/-1,当countDup=0时,说明子字符串没有重复字符。这样可以无需遍历counts[]。


面试题17:包含所有字符的最短字符串

leetcode.cn/problems/M1… 给定两个字符串 s 和 t 。返回 s 中包含 t 的所有字符的最短子字符串。如果 s 中不存在符合条件的子字符串,则返回空字符串 "" 。
如果 s 中存在多个符合条件的子字符串,返回任意一个。
注意: 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。

ANSWER

思路仍然是双指针+哈希表。因为要找到包含t所有字符的子字符串,因此使用变量count记录当前窗口尚未出现的t字符的个数,当count减少到0时,说明找到了这样一个子字符串。使用哈希表而不是数组来记录当前扫描情况,是因为哈希表可以用O(1)时间判断出窗口中是否含有某一个字符。

只需要扫描字符串一次,时间复杂度O(n),对于只包含ASCII字符的字符串,空间复杂度O(1)

代码中有两个中间变量用于记录状态:

  • charToCount:哈希表,记录t中各个字符出现的次数,用于记录平移窗口内的状态
  • count:t中出现的字符经去重后的总数,用于判断当前窗口是否已经满足条件
public String minWindow(String s, String t) {
    HashMap<Character, Integer> charToCount = new HashMap<>();
    for (char ch : t.toCharArray()) {
        charToCount.put(ch, charToCount.getOrDefault(ch, 0) + 1);
    }
    int count = charToCount.size(); // count记录了t字符去重后的总数
    int start=0, end=0, minStart=0, minEnd=0;
    int minLength = Integer.MAX_VALUE;
    while (end<s.length() || (count==0 && end==s.length())) { // 循环终止条件:未到末尾
        if (count > 0) { // 当前还没有完全含有t的字符
            char endCh = s.charAt(end);
            if (charToCount.containsKey(endCh)) {
                charToCount.put(endCh, charToCount.get(endCh) - 1); // 新的endCh是t中的字符,更新哈希表
                if (charToCount.get(endCh) == 0) {
                    count--; // endCh已经全部找到
                }
            }
            end++;
        } else {
            if (end - start < minLength) { // 发现更优解
                minLength = end - start;
                minStart = start;
                minEnd = end;
            }
            char startCh = s.charAt(start);
            if (charToCount.containsKey(startCh)) {
                charToCount.put(startCh, charToCount.get(startCh) + 1);
                if (charToCount.get(startCh) == 1) {
                    count++; // 左指针右移,将startCh移出窗口
                }
            }
            start++;
        }
    }
    return minLength < Integer.MAX_VALUE ? s.substring(minStart, minEnd) : "";
}

面试题18:有效的回文

leetcode.cn/problems/Xl…

给定一个字符串 s ,验证 s 是否是 回文串 ,只考虑字母和数字字符,可以忽略字母的大小写。本题中,将空字符串定义为有效的 回文串 。

ANSWER

首先明确回文字符串的定义——不论从前向后还是从后向前,得到的字符串都是相同的。意味着首尾两个字符相同,继续向内则继续相同,使用双指针法进行扫描。

只考虑字母和数字 --> 遇到其它字符(如标点符号)则跳过。

可以忽略字母大小写 --> 预处理时将字符全转为小写。

只需要扫描一次s,时间复杂度O(n)

**提醒:**如果在面试中遇到此题,需要与面试官确认回文的判断条件,如大小写、标点是否统计在内等。

public boolean isPalindrome(String s) {
    int i = 0;
    int j = s.length() - 1;
    while (i < j) { // 终止条件
        char ch1 = s.charAt(i);
        char ch2 = s.charAt(j);
        if (!Character.isLetterOrDigit(ch1)) { // 跳过非字母数字
            i++;
        } else if (!Character.isLetterOrDigit(ch2)) {
            j--;
        } else {
            ch1 = Character.toLowerCase(ch1);
            ch2 = Character.toLowerCase(ch2);
            if (ch1 != ch2) return false;
            i++;
            j--;
        }
    }
    return true;
}

面试题19:最多删除一个字符得到回文

leetcode.cn/problems/RQ…

给定一个非空字符串 s,请判断如果最多从字符串中删除一个字符能否得到一个回文字符串。

ANSWER

判断整个字符串可否通过删除一个字符(可以啥也不删)得到一个回文字符串,首尾双指针启动,找到首个差异字符,然后查看导致这个差异的两个字符如果删除任一个可否得到回文。

边界条件 --> 字符串长度为奇数,扫描时恰好到达中点。

需要扫描一遍字符串,时间复杂度O(n)

**提醒:**如果在面试中遇到此题,需要与面试官确认回文的判断条件,如大小写、标点是否统计在内等。

public boolean validPalindrome(String s) {
    int start = 0;
    int end = s.length() - 1;
    for (; start < s.length() / 2; ++start, --end) { // 只需要扫描一半即可
        if (s.charAt(start) != s.charAt(end)) {
            break; // 找到差异点
        }
    }
    return start == s.length()/2 || isPalindrome(s, start, end-1) || isPalindrome(s. start+1, end);
}

// isPalindrome()实现略

面试题20:回文子字符串的个数

leetcode.cn/problems/a7… 给定一个字符串 s ,请计算这个字符串中有多少个回文子字符串。具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。

ANSWER

提问回文字符串的总个数,采用从中心向两边扩散法,遍历s,假设每一个字符都是中心,扩散过程判断是否为回文。

边界条件 --> 回文字符串长度可能为奇数、偶数。

需要两层遍历,时间复杂度O(n^2),空间O(1)

public int countSubstrings(String s) {
    if (s == null || s.length() == 0) return 0; // 永远不要忘记对输入先进行检查
    int count = 0;
    for (int i=0; i<s.length(); i++) {
        count += countPalindrome(s, i, i); // 以i为中心,奇数长度
        count += countPalindrome(s, i, i+1); // 以i、i+1为中心,偶数长度
    }
    return count;
}

privatet int countPalindrome(String s, int start, int end) {
    int count = 0;
    while (start >= 0 && end <= s.length() && s.charAt(start)==s.charAt(end)) { // 找到一个,继续扩散
        count++;
        start--;
        end++;
    }
    return count;
}

总结

  • 回文字符串类问题:首先要明确回文的判断条件,其次注意字符串可能为奇数or偶数长度