字符串基本理论 字符串看作是所有元素都是字符的线性表。
在C++种std::string表示字符串类型。
如果库函数仅仅是 解题过程中的一小部分,并且你已经很清楚这个库函数的内部实现原理的话,可以考虑使用库函数。
反转字符串1 题目:第344题
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。
思路:采用双指针法,一个指针在前,一个指针在后,交换两个指针指向的元素。然后两个指针同时移动。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution {public : void reverseString (vector<char >& s) { int front = 0 ; int tail = s.size () - 1 ; while (front < tail) { char temp = s[front]; s[front] = s[tail]; s[tail] = temp; front++; tail--; } return ; } };
时间复杂度O(n),两个指针共同遍历一次数组;空间复杂度O(1)
反转字符串2 题目:第341
给定一个字符串 s 和一个整数 k,从字符串开头算起, 每计数至 2k 个字符,就反转这 2k 个字符中的前 k 个字符。
如果剩余字符少于 k 个,则将剩余字符全部反转。
如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。
示例:
输入: s = “abcdefg”, k = 2 输出: “bacdfeg”
思路:直接取每个区间的起点,i=0, 2k,然后在循环中判断i + k是否超过最后字符串结尾即可。
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 class Solution { public: void reverse(string &s, int front, int tail) { while (front < tail) { char temp = s[front]; s[front] = s[tail]; s[tail] = temp; // 更新两个指针 front++; tail--; } } string reverseStr(string s, int k) { int len = s.length(); for (int i = 0; i < len; i += 2 * k) { if (i + k <= len) { // 将[i, i + k)对应的字符翻转 reverse(s, i, i + k - 1); } else { // 将[i, len)对应的字符翻转 reverse(s, i, len - 1); } } return s; } };
时间复杂度O(ceil(n/2k)*k),其实就是字符串长度O(n);空间复杂度O(1)
替换空格 剑指Offer 05.替换空格
目前leetcode上没有原题,牛客网上有原题。
请实现一个函数,将一个字符串s中的每个空格替换成“%20”。
例如,当字符串为We Are Happy.则经过替换之后的字符串为We%20Are%20Happy。
数据范围:0≤len(s)≤1000 。保证字符串中的字符为大写英文字母、小写英文字母和空格中的一种。
思路:其实很多数组填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作。
这么做有两个好处:
1.不用申请新数组。
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 25 26 27 28 29 30 31 32 class Solution {public : string replaceSpace (string s) { int nums_blank = 0 ; for (char c : s) if (c == ' ' ) nums_blank++; string result (s.length() + nums_blank * 2 , 'a' ) ; int i = s.length () - 1 ; int j = result.length () - 1 ; while (i >= 0 ) { if (s[i] == ' ' ) { result[j--] = '0' ; result[j--] = '2' ; result[j--] = '%' ; } else { result[j--] = s[i]; } i--; } return result; } };
时间复杂度O(n),遍历两次数组;空间复杂度O(n)
翻转字符串里的单词 题目:151
给定一个字符串,逐个翻转字符串中的每个单词。
示例 1: 输入: “the sky is blue” 输出: “blue is sky the”
示例 2: 输入: “ hello world! “ 输出: “world! hello” 解释: 输入字符串可以在前面或者后面包含多余的空格,但是反转后的字符不能包括。
示例 3: 输入: “a good example” 输出: “example good a” 解释: 如果两个单词间有多余的空格,将反转后单词间的空格减少到只含一个。
思路1:使用栈,栈种保存的类型是string,遍历字符串,遇到空格就取出之前的单词。
保存每个字符串并存入栈结构,最后再出栈,这样存储空间是O(n)
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 class Solution {public : string reverseWords (string s) { stack<string> words; for (int i = 0 ; i < s.length (); i++) { if (s[i] != ' ' ) { int j = i + 1 ; while (j < s.length () && s[j] != ' ' ) j++; words.push (string (s.begin () + i, s.begin () + j)); i = j; } else continue ; } string res = words.top (); words.pop (); while (!words.empty ()) { res += " " + words.top (); words.pop (); } return res; } };
注意上面将单词加入栈的逻辑:当遇到非空格字符时,一直向后找到空格字符,将单词入栈。
另一种逻辑是:遇到空格再将前面的单词加入栈,这样的坏处是:最后一个单词如果没有遇到空格就无法入栈。
时间复杂度O(n);空间复杂度O(n)
思路2:去除多余的空格、翻转整个字符串、翻转单个字符串,存储空间是O(1)
删除多余的空格: 首尾的空格以及单词之间连续的空格,参考题目:数组:1.1.3 题目27:移除重复元素
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 class Solution { public: void removeBlank(string &s) { int len = s.length(); // 快慢指针 int slow, fast; slow = fast = 0; for (; fast < s.length(); fast++) { // 当快指针指向非空格字符时才添加字符 if (s[fast] != ' ') { // 如果慢指针不是首部,则说明是单词中间,可以添加一个空格 if (slow != 0) s[slow++] = ' '; // 移动快指针,将后面字符添加到这个单词上 while (fast < s.length() && s[fast] != ' ') s[slow++] = s[fast++]; } } s.resize(slow); } // 翻转[start, end]内的字符串 void reverse(string &s, int start, int end) { while (start < end) { swap(s[start], s[end]); start++; end--; } } string reverseWords(string s) { // 首先去除多余的空格字符 removeBlank(s); // 翻转整个字符串 reverse(s, 0, s.length() - 1); // 翻转每个单词 for (int i = 0; i < s.length(); i++) { if (s[i] != ' ') { // 找到下一个空格字符 int j = i + 1; while (j < s.length() && s[j] != ' ') j++; // 将[i, j-1]的之间的字串进行翻转 reverse(s, i, j - 1); i = j; // 更新i为j指向的空格字符 } else { continue; } } return s; } };
时间复杂度O(n);空间复杂度O(1)
左旋转字符串 题目:剑指Offer58-II.左旋转字符串
原leetcode上已经没有该题,牛客网上可以搜到原题
汇编语言中有一种移位指令叫做循环左移(ROL),现在有个简单的任务,就是用字符串模拟这个指令的运算结果。对于一个给定的字符序列 S ,请你把其循环左移 K 位后的序列输出。例如,字符序列 S = ”abcXYZdef” , 要求输出循环左移 3 位后的结果,即 “XYZdefabc”
思路1:需要额外空间:先保存前K个字符,然后将后面的字符移动到相应的位置。
时间复杂度O(n);空间复杂度O(k)
思路2:(原地修改)旋转整个字符串、旋转左半字符串、旋转右半字符串
需要注意的是:如果K大于总长度,应该使用K%len进行翻转。另外如果总长度就是0,应该返回空字符串。
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 class Solution { public : void ReverseStr (string& s, int start, int end) { while (start < end) { swap (s[start], s[end]); start++; end--; } } string LeftRotateString (string str, int n) { int len = str.length (); if (len == 0 ) return str; int k = n % len; ReverseStr (str, 0 , len - 1 ); ReverseStr (str, 0 , len - k - 1 ); ReverseStr (str, len - k, len - 1 ); return str; } };
时间复杂度O(n);空间复杂度O(1)
上面也可以直接使用C++ STL中的reverse函数。
旋转字符串回顾 此时我们已经反转好多次字符串了,来一起回顾一下吧。
在这篇文章344.反转字符串 ,第一次讲到反转一个字符串应该怎么做,使用了双指针法。
然后发现541. 反转字符串II ,这里开始给反转加上了一些条件,当需要固定规律一段一段去处理字符串的时候,要想想在for循环的表达式上做做文章。
后来在151.翻转字符串里的单词 中,要对一句话里的单词顺序进行反转,发现先整体反转再局部反转 是一个很妙的思路。
最后再讲到本题,本题则是先局部反转再 整体反转,与151.翻转字符串里的单词 类似,但是也是一种新的思路。
实现KMP算法 题目:28.
给你两个字符串 haystack
和 needle
,请你在 haystack
字符串中找出 needle
字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle
不是 haystack
的一部分,则返回 -1
。
传统的字符串匹配算法:对于S串和T串。假设S串的长度为n,T串的长度为m,并且n 大于等于m,那么 i 遍历[0, n-1], j 遍历[0, m - 1],分别进行比较。时间复杂度O(mn)。
KMP算法主要思想:当在T串的比较中出现不同时,j 值(指向T串的字符下标)需要从头开始吗?i 值需要不断从 i 增加到i + m进行意义比较吗?
分析1: 如果T串中全都是互不相同的字符:
当比较到不同的字符时,i 不需要进行回溯。
以T串的第一个字符为例,首字符不与后面的字符相等,即:$t_1 \ne t_2,…t_j$
而已经在本次循环中比较过S串和T串中的部分,即:$s_1=t_1,s_2=t_2,…s_j=t_j$
所以可得到:$t_1 \ne s_1,s_2,…s_j $
因此T串的首字符下一次比较不需要与$s_1…s_j$进行,直接从最后一个不相等的元素位置开始即可。
分析2: 如果在T字符中出现重复字符
因此,如果在已经比较过的 (指的是与S串中逐一比较过,即T串的左边部分)T串中有前缀和后缀相同的字串,那么由于后缀已经和S串比较过,存在相同的部分,所以只需要从S串中与前缀相同(也同样是与后缀相同)的后一位开始比较即可,即 j 是前缀的下一位,i 是上次循环比较的最后一次S串与T串中字符不相同的位置 。
由此得到当比较到 j 不同时,下一步应该从T串的哪一个位置开始进行比较的next数组:
(注意,当 j 不同时,说明前面 [0, j-1]的T串子串与S串已经匹配上了,需要关注的是这一部分中的前缀和后缀长度)
当j=0时,说明在比较时T串的第一个字符就不相等,因此下一次仍然从0开始比较,即next[0]=0;
当j=1时,前面比较过相同的字符只有1个,因此下一次比较也只能从0开始,即next[1]=0;
当j>1时,前面比较过的和S串相同的字符是[0, j-1],假设这一段的前缀和后缀的长度是K,k<j(整段的长度),即前缀不能和后缀相同,则下一次比较从0+k开始,即next[j] = k。
获取next数组的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void get_next (const std::string &T, std::vector<int > &next) { int i = 1 , j = 0 ; int len = next.size (); if (len > 0 ) next[0 ] = 0 ; if (len > 1 ) next[1 ] = 0 ; while (i < len - 1 ) { if (T[i] == T[j]) { ++i; ++j; next[i] = j; } else if (j == 0 ) { ++i; next[i] = 0 ; } else { j = next[j]; } } }
在上面的代码中,j表示相同的前缀的下一个下标,i不断递增,遍历每一个字符,比较第 i 个字符,计算的前后缀长度是放到next数组的 i+1 位置上。
求next数组的时间复杂度: 只涉及到对于串T的循环,所以时间复杂度是O(m)
KMP模式匹配: 由于 i 不会再回溯,所以时间复杂度是O(n)
因此总的时间复杂度是O(m+n),比传统的方法O((n-m+1)*m)要好一些。
需要注意的是,只有当子串中有很多重复部分,KMP才能体现出优势。
优化3: 当出现不匹配时,要根据next数组选择 j 的位置,如果当前字符 不与S串匹配,那么next指向的字符如果与当前字符相同 ,也不会与这个·S串中的字符相等,因此可以把next指向的字符对应next值给这个字符。
改进之后的求next数组:
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 void get_next (const std::string &T, std::vector<int > &next) { int i = 1 , j = 0 ; int len = next.size (); if (len > 0 ) next[0 ] = 0 ; if (len > 1 ) next[1 ] = 0 ; while (i < len - 1 ) { if (T[i] == T[j]) { ++i; ++j; if (T[i] != T[j]) next[i] = j; else next[i] = next[j]; } else if (j == 0 ) { ++i; next[i] = j; } else { j = next[j]; } } }
有了next数组之后,求第一个匹配的下标算法就很简单了:
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 int Index_KMP (const std::string &S, const std::string &T, int pos) { int s_len = S.length (); int t_len = T.length (); std::vector<int > next (t_len) ; get_next (T, next); int i = pos; int j = 0 ; while (i < s_len && j < t_len) { if (S[i] == T[j]) { ++i; ++j; } else if (j == 0 ) { ++i; } else { j = next[j]; } } if (j == t_len) return i - t_len; else return -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 46 47 class Solution {public : void GetNext (string &T, vector<int > &next) { int len = next.size (); if (len > 0 ) next[0 ] = 0 ; if (len > 1 ) next[1 ] = 0 ; int i = 1 ; int j = 0 ; while (i < len - 1 ) { if (T[i] == T[j]) { ++i; ++j; if (T[i] != T[j]) next[i] = j; else next[i] = next[j]; } else if (j == 0 ) { ++i; next[i] = 0 ; } else { j = next[j]; } } } int strStr (string haystack, string needle) { int hay_len = haystack.length (); int need_len = needle.length (); vector<int > next (need_len, 0 ) ; GetNext (needle, next); int i = 0 , j = 0 ; while (i < hay_len && j < need_len) { if (haystack[i] == needle[j]) { ++i; ++j; } else if (j == 0 ) { ++i; } else { j = next[j]; } } if (j == need_len) return i - need_len; else return -1 ; } };
重复的子字符串 题目:第459题
给定一个非空的字符串,判断它是否可以由它的一个子串重复多次构成。给定的字符串只含有小写英文字母,并且长度不超过10000。
思路1:暴力破解法:假设存在子串s,字符串的总长度一定是子串s的整数倍,并且子串s一定是字符串的前缀。
此外,假设子串长度为i,那么任意的j > i,都有s[j] == s[j - i]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution {public : bool repeatedSubstringPattern (string s) { int len = s.length (); for (int i = 1 ; i < len; i++) { if (len % i != 0 ) continue ; int j; for (j = i; j < len; j++) { if (s[j] != s[j - i]) break ; } if (j == len) return true ; } return false ; } };
时间复杂度O(n^2);空间复杂度O(1)
思路2:利用两个s拼接在一起,一定能从中间找到s,因为如果是由重复字符串构成的话,中间的一部分一定是s。但是不能两端找到字串s,因为就是由两个s组成的。
find库函数的默认实现时间复杂度为KMP算法O(n),空间复杂度为O(n)
1 2 3 4 5 6 7 8 9 10 11 class Solution {public : bool repeatedSubstringPattern (string s) { string temp = s + s; temp.erase (temp.begin ()); temp.erase (temp.end () - 1 ); if (temp.find (s) != std::string::npos) return true ; else return false ; } };
思路3:在由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串
KMP算法的next数组其实就是最长前缀和最长后缀的概念,只不过我在实现的时候是求不包括当前字符的前缀长度
前缀和后缀的概念:
前缀:是指不包含最后一个字符的所有以第一个字符开头的连续子串
后缀:是指不包含第一个字符的所有以最后一个字符结尾的连续子串
作者求的是当前字符的前后缀长度,与我不同,所以作者遇到当前字符不同时,需要查找前一个字符的next值,我要找当前字符的next值,其实是一样的.
只不过作者引入了-1,我不想看了。
总结 字符串本质上就是字符数组。
如果要使用字符串的库函数,一定要了解库函数的实现复杂度。
双指针法: 将字符串当作数组来处理,使用双指针可以一次遍历两遍数组。
反转: 在反转字符串中的单词时,采用先整体反转再局部反转的方法;在实现字符串的左旋时,先局部反转再整体反转。
KMP算法: 在字符串中寻找模式串。