1. 字符串反转专题
我们知道反转是链表的一个重要考点,反转同样是字符串的重要问题。常见问题也就是在LeetCode中列举的相关题目:
【1】LeetCode344. 反转字符串:编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。
【2】LeetCode541. K个一组反转:给定一个字符串 s 和一个整数 k ,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 字符中的前 k 个字符。
【3】LeetCode.917. 仅仅反转字母:给定一个字符串 S ,返回 “反转后的” 字符串,其中不是字母的字符都保留在原地,而所有字母的位置发生反转。
【4】LeetCode151. 反转字符串里的单词:给你一个字符串 s ,逐个反转字符串中的所有单词。
【5】LeetCode.557. 反转字符串中的单词 III:给定一个字符串,你需要反转字符串中每个单词的字符顺序,同时仍保留空格和单词的初始顺序。
这几个题目你是否发现前三道就是要么反转字符,要么反转里面的单词。针对字符的反转又可以变换条件造出多问题。我们就从基本问题出发,各个击破。
1.1 LeetCode344. 反转字符串
题目要求
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
示例1:
输入:s = [“h”,“e”,“l”,“l”,“o”]
输出: [“o”,“l”,“l”,“e”,“h”]
示例2:
输入:s = [“H”,“a”,“n”,“n”,“a”,“h”]
输出: [“h”,“a”,“n”,“n”,“a”,“H”]
这是最基本的反转题,也是最简单的问题,使用双指针方法最直接。具体做法是:
对于长度为 N 的待被反转的字符数组,我们可以观察反转前后下标的变化,假设反转前字符数组为 s[0] s[1] s[2] … s[N - 1],那么反转后字符数组为 s[N - 1] s[N - 2] … s[0]。比较反转前后下标变化很容易得出 s[i] 的字符与 s[N - 1 - i] 的字符发生了交换的规律,因此我们可以得出如下双指针的解法:
- 将 left 指向字符数组首元素, right 指向字符数组尾元素。
- 当 left < right:
- 交换 s[left] 和 s[right];
- left 指针右移一位,即 left = left + 1;
- right 指针左移一位,即 right = right - 1 。
- 当 left >= right ,反转结束,返回字符数组即可。
class Solution {
public void reverseString(char[] s) {
if (s == null || s.length() == 0) {
return s;
}
int n = s.length;
for (int left = 0, right = n - 1; left < right; ++left, --right) {
char tmp = s[left];
s[left] = s[right];
s[right] = tmp;
}
}
}
这里使用for循环貌似条件过于复杂了,我们使用while也可以:
class Solution {
public void reverseString(char[] s) {
//采用双指针反转即可,左右指针元素互换,然后慢慢靠近
int left=0,right=s.length-1;
while(left<=right){
char tmp=s[left];
s[left]=s[right];
s[right]=tmp;
left++;
right--;
}
}
}
1.2 LeetCode541. K个一组反转
这个题,我感觉有点没事找事,先看一下要求:
给定一个字符串 s 和一个整数 k,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 字符中的前 k 个字符。
- 如果剩余字符少于 k 个,则将剩余字符全部反转。
- 如果剩余字符小于 2k 但大于或等于 k 个,则反转前k 个字符,其余字符保持原样。
示例1:
输入:s = “abcdefg”, k = 2
输出: “bacdfeg”
示例2:
输入:s = “abcd”, k = 2
输出: “bacd”
我们直接按题意进行模拟就可以:反转每个下标从 2k的倍数开始的,长度为 k的子串。若该子串长度不足 k,则反转整个子串。
class Solution {
public String reverseStr(String s, int k) {
if (s == null || s.length() == 0) {
return s;
}
int n = s.length();
char[] arr = s.toCharArray();
for (int i = 0; i < n; i += 2 * k) {
reverse(arr, i, Math.min(i + k, n) - 1);
}
return new String(arr);
}
public void reverse(char[] arr, int left, int right) {
while (left < right) {
char temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
left++;
right--;
}
}
}
1.3 LeetCode.917. 仅仅反转字母
这个题有点难度,我们来看一下:
给定一个字符串 S ,返回 “反转后的” 字符串,其中不是字母的字符都保留在原地,而所有字母的位置发生反转。
示例 1:
输入:s = “ab-cd”
输出:“dc-ba”
示例 2:
输入:s = “a-bC-dEf-ghIj”
输出:“j-Ih-gfE-dCba”
示例 3:
输入:s = “Test1ng-Leet=code-Q!”
输出:“Qedo1ct-eeLg=ntse-T!”
这里第一眼感觉不是特别复杂,同样从两头向中间即可,但问题是"-"不是均匀的有些划分的段长,有的短,这就增加了处理的难度。
方法1:使用栈
将 s 中的所有字母单独存入栈中,所以出栈等价于对字母反序操作。(或者,可以用数组存储字母并反序数组。) 然后,遍历 s 的所有字符,如果是字母我们就选择栈顶元素输出。
class Solution {
public String reverseOnlyLetters(String S) {
Stack<Character> letters = new Stack();
for (char c: S.toCharArray())
if (Character.isLetter(c))
letters.push(c);
StringBuilder ans = new StringBuilder();
for (char c: S.toCharArray()) {
if (Character.isLetter(c))
ans.append(letters.pop());
else
ans.append(c);
}
return ans.toString();
}
}
方法2:拓展 双转指针
一个接一个输出 s 的所有字符。当遇到一个字母时,我们希望找到逆序遍历字符串的下一个字母。 所以我们这么做:维护一个指针 j从后往前遍历字符串,当需要字母时就使用它。
class Solution {
public String reverseOnlyLetters(String S) {
if (S == null || S.length() == 0) {
return S;
}
StringBuilder ans = new StringBuilder();
int j = S.length() - 1;
for (int i = 0; i < S.length(); ++i) {
if (Character.isLetter(S.charAt(i))) {
while (!Character.isLetter(S.charAt(j)))
j--;
ans.append(S.charAt(j--));
} else {
ans.append(S.charAt(i));
}
}
return ans.toString();
}
}
1.4 LeetCode151. 反转字符串里的单词
题目要求
给你一个字符串 s ,逐个反转字符串中的所有单词 。
单词是由非空格字符组成的字符串。 s 中使用至少一个空格将字符串中的单词分隔开。
请你返回一个反转 s 中单词顺序并用单个空格相连的字符串。
说明:
- 输入字符串 s 可以在前面、后面或者单词间包含多余的空格。
- 反转后单词间应当仅用一个空格分隔。
- 反转后的字符串中不应包含额外的空格。
示例 1:
输入:s = “the sky is blue”
输出:“blue is sky the”
示例 2:
输入:s = " hello world "
输出:“world hello”
解释:反转后的字符串中不能存在前导空格和尾随空格。
示例 3:
输入:s = “a good example”
输出:“example good a”
解释:如果两个单词间有多余的空格,反转后的字符串需要将单词间的空格减少到仅有一个。
这个题也经常出现在很多面试题中,我记得曾经见过有个题是这样出的,要你按照同样的方式反转“ I love youzan”。
这个题的关键在于如何处理单词。很多语言提供了相关的特性,因此我们可以首先使用语言的特性来实现:
很多语言对字符串提供了split(拆分),reverse(反转)和 join(连接)等方法,因此我们可以简单的调用内置的API 完成操作:
- 使用 split 将字符串按空格分割成字符串数组;
- 使用 reverse 将字符串数组进行反转;
- 使用 join 方法将字符串数组拼成一个字符串。
如图:
class Solution {
public String reverseWords(String s) {
if (s == null || s.length() == 0) {
return s;
}
// 除去开头和末尾的空白字符
s = s.trim();
// 正则匹配连续的空白字符作为分隔符分割
List<String> wordList = Arrays.asList(s.split("\\s+"));
Collections.reverse(wordList);
return String.join(" ", wordList);
}
}
如果我们要自行编写实现函数,对于字符串不可变的语言,例如java中的String,首先得把字符串转化成其他可变的数据结构,同时还需要在转化的过程中去除空格。
第二种解法
对于字符串可变的语言,就不需要再额外开辟空间了,直接在字符串上原地实现。在这种情况下,反转字符和去除空格可以一起完成。
实现方法:
class Solution {
public String reverseWords(String s) {
if (s == null || s.length() == 0) {
return s;
}
StringBuilder sb = trimSpaces(s);
// 反转字符串
reverse(sb, 0, sb.length() - 1);
// 反转每个单词
reverseEachWord(sb);
return sb.toString();
}
public StringBuilder trimSpaces(String s) {
int left = 0, right = s.length() - 1;
// 去掉字符串开头的空白字符
while (left <= right && s.charAt(left) == ' ') {
++left;
}
// 去掉字符串末尾的空白字符
while (left <= right && s.charAt(right) == ' ') {
--right;
}
// 将字符串间多余的空白字符去除
StringBuilder sb = new StringBuilder();
while (left <= right) {
char c = s.charAt(left);
if (c != ' ') {
sb.append(c);
} else if (sb.charAt(sb.length() - 1) != ' ') {
sb.append(c);
}
++left;
}
return sb;
}
public void reverse(StringBuilder sb, int left, int right) {
while (left < right) {
char tmp = sb.charAt(left);
sb.setCharAt(left++, sb.charAt(right));
sb.setCharAt(right--, tmp);
}
}
public void reverseEachWord(StringBuilder sb) {
int n = sb.length();
int start = 0, end = 0;
while (start < n) {
// 循环至单词的末尾
while (end < n && sb.charAt(end) != ' ') {
++end;
}
// 反转单词
reverse(sb, start, end - 1);
// 更新start,去找下一个单词
start = end + 1;
++end;
}
}
}
另外本题还可以使用双端队列来解决。由于双端队列支持从队列头部插入的方法,因此我们可以沿着字符串一个一个单词处理,然后将单词压入队列的头部,再将队列转成字符串即可。
class Solution {
public String reverseWords(String s) {
int left = 0, right = s.length() - 1;
// 去掉字符串开头的空白字符
while (left <= right && s.charAt(left) == ' ') {
++left;
}
// 去掉字符串末尾的空白字符
while (left <= right && s.charAt(right) == ' ') {
--right;
}
Deque<String> d = new ArrayDeque<String>();
StringBuilder word = new StringBuilder();
while (left <= right) {
char c = s.charAt(left);
if ((word.length() != 0) && (c == ' ')) {
// 将单词 push 到队列的头部
d.offerFirst(word.toString());
word.setLength(0);
} else if (c != ' ') {
word.append(c);
}
++left;
}
d.offerFirst(word.toString());
return String.join(" ", d);
}
}
1.5 LeetCode557. 反转字符串中的单词 III
给定一个字符串,你需要反转字符串中每个单词的字符顺序,同时仍保留空格和单词的初始顺序。
示例 1:
输入:s = “Let’s take LeetCode contest”
输出:“s’teL ekat edoCteeL tsetnoc”
示例 2:
输入: s = “Mr Ding”
输出:“rM gniD”
提示
- 在字符串中,每个单词由单个空格分隔,并且字符串中不会有任何额外的空格。
上一个题是将单词本身不变,单词之间的关系反转。而本题就是单词反转,单词之间的关系不变。
分析
我们可以使用额外的空间来执行,开辟一个新字符串。然后从头到尾遍历原字符串,直到找到空格为止,此时找到了一个单词,并能得到单词的起止位置。随后,根据单词的起止位置,可以将该单词逆序放到新字符串当中。如此循环多次,直到遍历完原字符串,就能得到反转后的结果。
class Solution {
public String reverseWords(String s) {
if (s == null || s.length() == 0) {
return s;
}
StringBuffer ret = new StringBuffer();
int length = s.length();
int i = 0;
while (i < length) {
int start = i;
while (i < length && s.charAt(i) != ' ') {
i++;
}
for (int p = start; p < i; p++) {
ret.append(s.charAt(start + i - 1 - p));
}
while (i < length && s.charAt(i) == ' ') {
i++;
ret.append(' ');
}
}
return ret.toString();
}
}
此题也可以直接在原字符串上进行操作,避免额外的空间开销。当找到一个单词的时候,我们交换字符串第一个字符与倒数第一个字符,随后交换第二个字符与倒数第二个字符……如此反复,就可以在原空间上反转单词。
需要注意的是,原地解法在某些语言(比如 Java,JavaScript)中不适用,因为在这些语言中 String 类型是一个不可变的类型,需要先转换。在写转换的时候有一个更大的问题经常会被忽略,下面这段代码是有问题的,执行之后会发现无法完成反转,你能找到问题在哪里吗?
class Solution {
public String reverseWordsError(String s) {
int length = s.length();
char[] charArray = s.toCharArray();
int i = 0;
while (i < length) {
int start = i;
while (i < length && charArray[i] != ' ') {
i++;
}
int left = start, right = i - 1;
while (left < right) {
swap(charArray[left], charArray[right]);
left++;
right--;
}
while (i < length && charArray[i] == ' ') {
i++;
}
}
return String.valueOf(charArray);
}
public void swap(char a ,char b){
char c=a;
a=b;
b=c;
}
}
这里的问题在于swap方法只是在局部反转了a和b,而并没有调整数组charArray中的元素,那该怎么写呢?这个留给读者好好思考。