字符串的基础知识
字符串由任意长度(长度可能为 0)的字符组成,是编程语言中表示文本的数据类型。Java 中用定义的类型 String 来表示字符串……
Java 中的 String 类型所表达的字符串是无法改变的,也就是说,只能对字符串进行读操作。如果对字符串进行写操作,那么修改的内容在返回值的字符串中,原来的字符串保持不变……
由于每次对 String 实例进行修改将创建一个新的 String 实例,因此如果连续多次对 String 实例进行修改将连续创建多个新的 String 实例,不必要的内存开销较大。所以可以创建一个 StringBuilder 实例,因为它能容纳修改后的结果
双指针
第 2 章用两个指针来定位一个子数组,其中一个指针指向数组的第 1 个数字,另一个指针指向数组的最后一个数字,那么两个指针之间所包含的就是一个子数组
如果将字符串看成一个由字符组成的数组,那么也可以用两个指针来定位一个子字符串,其中一个指针指向字符串的第 1 个字符,另一个指针指向字符串的最后一个字符,两个指针之间所包含的就是一个子字符串
可以在移动这两个指针的同时,统计两个指针之间的字符串中字符出现的次数,这样可以解决很多常见的面试题,如在一个字符串中定位另一个字符串的变位词等
由于这种类型的面试题都与统计字母出现的次数有关,我们经常使用哈希表来存储每个元素出现的次数,因此解决这种类型的面试题通常需要同时使用双指针和哈希表
面试题 14:字符串中的变位词
题目:输入字符串 s1 和 s2,如何判断字符串 s2 中是否包含字符串 s1 的某个变位词?如果字符串 s2 中包含字符串 s1 的某个变位词,则字符串 s1 至少有一个变位词是字符串 s2 的子字符串。假设两个字符串中只包含英文小写字母。例如,字符串 s1 为 “ac”,字符串 s2 为 “dgcaf”,由于字符串 s2 中包含字符串 s1 的变位词 “ca”,因此输出为 true。如果字符串 s1 为 “ab”,字符串 s2 为 “dgcaf”,则输出为 false
public static 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++) {
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 static boolean areAllZero(int[] counts) {
for (int count : counts) {
if (count != 0) {
return false;
}
}
return true;
}
在上述函数 checkInclusion 中,第 2 个 for 循环中的下标 i 相当于第 2 个指针,指向子字符串的最后一个字符。第 1 个指针指向下标为 i-s1.length() 的位置。两个指针之间的子字符串的长度一直是字符串 s1 的长度
上述基于双指针和哈希表的算法需要扫描字符串 s1 和 s2 各一次。如果它们的长度分别是 m 和 n,那么该算法的时间复杂度是 O(m+n)。这种解法用到了一个数组。数组的长度是英文小写字母的个数(即 26),是一个常数,也就是说,数组的大小不会随着输入字符串长度的变化而变化,因此空间复杂度是 O(1)
面试题 15:字符串中的所有变位词
题目:输入字符串 s1 和 s2,如何找出字符串 s2 的所有变位词在字符串 s1 中的起始下标?假设两个字符串中只包含英文小写字母。例如,字符串 s1 为 “cbadabacg”,字符串 s2 的两个变位词 “cba” 和 “bac” 是字符串 s1 中的子字符串,输出它们在字符串 s1 中的起始下标 0 和 5
public static List<Integer> findAnagrams(String s1, String s2) {
List<Integer> indices = new LinkedList<>();
if (s1.length() < s2.length()) {
return indices;
}
int[] counts = new int[26];
int i = 0;
for (; i < s2.length(); i++) {
counts[s2.charAt(i) - 'a']++;
counts[s1.charAt(i) - 'a']--;
}
if (areAllZero(counts)) {
indices.add(0);
}
for (; i < s1.length(); i++) {
counts[s1.charAt(i) - 'a']--;
counts[s1.charAt(i - s2.length()) - 'a']++;
if (areAllZero(counts)) {
indices.add(i - s2.length() + 1);
}
}
return indices;
}
辅助函数 areAllZero 和面试题 14 的代码中一样,所以此处不再重复介绍
同样,这种解法的时间复杂度也是 O(n),空间复杂度是 O(1)
面试题 16:不含重复字符的最长子字符串
题目:输入一个字符串,求该字符串中不含重复字符的最长子字符串的长度。例如,输入字符串 “babcca”,其最长的不含重复字符的子字符串是 “abc”,长度为 3
public static int lengthOfLongestSubstring(String s) {
if (s.length() == 0) {
return 0;
}
int[] counts = new int[256];
int i = 0;
int j = -1;
int longest = 1;
int countDup = 0;
for (; i < s.length(); i++) {
counts[s.charAt(i)]++;
if (counts[s.charAt(i)] == 2) {
countDup++;
}
while (countDup > 0) {
++j;
counts[s.charAt(j)]--;
if (counts[s.charAt(j)] == 1) {
countDup--;
}
}
longest = Math.max(i - j, longest);
}
return longest;
}
由于这个题目没有说明字符串中只包含英文字母,那么就有可能包含数字或其他字符,因此字符就可能不止 26 个。假设字符串中只包含 ASCII 码的字符。由于 ASCII 码总共有 256 个字符,因此用来模拟哈希表的数组的长度就是 256
变量 countDup 用来存储哈希表中大于 1 的数字的个数,即子字符串中重复字符的个数……
面试题 17:包含所有字符的最短字符串
题目:输入两个字符串 s 和 t,请找出字符串 s 中包含字符串 t 的所有字符的最短子字符串。例如,输入的字符串 s 为 “ADDBANCAD”,字符串 t 为 “ABC”,则字符串 s 中包含字符 ‘A’、‘B’ 和 ‘C’ 的最短子字符串是 “BANC”。如果不存在符合条件的子字符串,则返回空字符串 “”。如果存在多个符合条件的子字符串,则返回任意一个
public static 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();
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) {
char endCh = s.charAt(end);
if (charToCount.containsKey(endCh)) {
charToCount.put(endCh, charToCount.get(endCh) - 1);
if (charToCount.get(endCh) == 0) {
count--;
}
}
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++;
}
}
start++;
}
}
return minLength < Integer.MAX_VALUE ? s.substring(minStart, minEnd) : "";
}
在上述代码中,变量 count 是出现在字符串 t 中但还没有出现在字符串 s 中的子字符串中的字符的个数。变量 start 相当于第 1 个指针,指向字符串 s 的子字符串中的第 1 个字符,变量 end 相当于第 2 个指针,指向字符串 s 的子字符串中的最后一个字符。当变量 count 等于 0 时,两个指针之间的子字符串就包含字符串 t 中的所有字符
这里哈希表使用了 Java 中的类型 HashMap,而没有和之前几个题目一样用数组模拟。这是因为用类型 HashMap 可以非常方便地判断一个字符在字符串 t 中是否出现。如果一个字符在字符串 t 中出现,那么哈希表中一定包含该字符的键
上述代码中只有一个 while 循环,用来把两个变量从 0 增加到字符串 s 的长度。如果字符串的长度是 n,那么时间复杂度就是 O(n)。可以使用一个哈希表来统计每个字符出现的次数。哈希表的键为字符,假设字符串中只有英文字母,那么哈希表的大小不会超过 256,辅助空间的大小不会随着字符串长度的增加而增加,因此空间复杂度是 O(1)
回文字符串
回文是一类特殊的字符串。不管是从头到尾读取一个回文,还是颠倒过来从尾到头读取一个回文,得到的内容是一样的。英语中有很多回文单词,如 “noon” 和 “madam” 等。如果不考虑字符串中的空格和标点符号,并且忽略字母大小写的不同,那么还有更多有意思的回文,如 “Sir, I demand, I am a maid named Iris.” 和 “Bob: Did Anna peep? Anna: Did Bob?” 等
中文博大精深,自然也有很多有趣的回文,如有回文对联 “上来自来水来自海上” 和 “黄山落叶松叶落黄山” 等。如果不考虑标点符号,中文还有 “我为人人,人人为我” 等经典回文
回文是一种大家喜闻乐见的文字游戏,与回文相关的非常有意思的面试题也有很多。除了本章几道典型的与回文有关的面试题,在第 13 章和第 14 章也有与回文有关的题目。下面从判断一个字符串是不是回文开始介绍
面试题 18:有效的回文
题目:给定一个字符串,请判断如果最多从字符串中删除一个字符能不能得到一个回文字符串。例如,如果输入字符串 “abca”,由于删除字符 ‘b’ 或 ‘c’ 就能得到一个回文字符串,因此输出为 true
public static 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);
}
private static boolean isPalindrome(String s, int start, int end) {
while (start < end) {
if (s.charAt(start) != s.charAt(end)) {
break;
}
start++;
end--;
}
return start >= end;
}
在函数 validPalindrome 的最后的 return 语句中,如果变量 start 等于输入字符串 s 的长度的一半,那么字符串 s 本身就是一个回文。如果变量 start 小于字符串 s 的长度的一半,那么下标为 start 和 end 的两个字符串不相同,分别跳过下标 start 和 end(相当于删除字符串中下标为 start 或 end 的字符),调用函数 isPalindrome 可以判断剩下的字符串是不是一个回文
面试题 20:回文子字符串的个数
题目:给定一个字符串,请问该字符串中有多少个回文连续子字符串?例如,字符串 “abc” 有 3 个回文字符串,分别为 “a”、“b” 和 “c”;而字符串 “aaa” 有 6 个回文子字符串,分别为 “a”、“a”、“a”、“aa”、“aa” 和 “aaa”
public static 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);
count += countPalindrome(s, i, i + 1);
}
return count;
}
private static 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;
}
字符串的下标为 i。第 i 个字符本身可以成为长度为奇数的回文子字符串的对称中心,同时第 i 个字符和第 i+1 个字符可以一起成为长度为偶数的回文子字符串的对称中心。因此,在上述代码中,for 循环通过对每个下标 i 调用两次 countPalindrome 来统计回文子字符串的个数
上述解法仍然需要两个嵌套的循环,因此时间复杂度是 O(n^2)。该解法只用到了若干变量,其空间复杂度是 O(1)
本章小结
本章详细讨论了字符串及其相关的典型面试题。字符串是编程面试中经常出现的数据类型,熟练掌握字符串常用操作对应的函数是解决字符串面试题的前提
变位词和回文是很有意思的文字游戏,在与字符串相关的算法面试题中,它们出现的频率很高。如果两个字符串包含的字符及每个字符出现的次数都相同,只是字符出现的顺序不同,那么它们就是一组变位词。通常可以用一个哈希表来统计每个字符出现的次数,有了哈希表就很容易判断两个字符串是不是一组变位词
回文是一类特殊的字符串。不管是从前往后还是从后往前读取其每一个字符,得到的内容都是一样的。通常可以用两个指针来判断一个字符串是不是回文,要么两个指针从字符串的两端开始向中间移动,要么两个指针从中间开始向两端移动