目录
字符串基础
字符串基本操作
字符串匹配算法
字符串异位词问题
分组分类问题和快速查找数据结构之间存在一定的关系。
字符串回文串问题
留下悬念:高级字符串算法题目(字符串 + dp)
字符串基础
字符串定义:n个字符顺次排列而成的序列.
子串:按照字符串原来的顺次排列, 截取出来的串叫做子串。
eg:"abcabcd" 所有的子串就是 {"", "a", "ab", "abc", "abca", "abcab", "abcabc", "abcabcd", "bc", "bca", "bcab"... } "" 就叫做空串.
子序列:
字符串 S 的 子序列 是从 S 中将若干元素提取出来并不改变相对位置形成的序列
子序列是原序列中去掉部分元素但不改变其相对顺序而得到的新序列.
说白了,就是相对顺序不变,但是中间可以抠掉几个字符。就叫做子序列. 对比子串理解
eg:"abcde" 的子序列: ac, acd, ae 这些都是子序列,但是绝对不是子串。abc既可以是子串也是子序列. 子串相较于子序列要求更多,是子串一定是子序列,但是是子序列却不一定是子串. 因为子串要求严格的按照原串连续切割,但是子序列可以按照需求干掉一两个字符。中间可以间隔。
子序列:如此特质,条件性没有子串那么严格严厉,所以子序列这样的弱条件性,存在更多的可叠加性,自然,可叠加,可衍生,我们就可以想到子问题---》最终问题解,递归树,动态规划,一个一个状态的不断向后积累,叠加。 多个最优子问题解,选择转移到新的多个更进一步的最优子问题解,一直持续到最终解。(明显的dp特性.)
以下是几个常见的子序列问题:
-
最长公共子序列(Longest Common Subsequence,LCS):给定两个序列,求它们的最长公共子序列。
-
最长递增子序列(Longest Increasing Subsequence,LIS):给定一个序列,求它的最长递增子序列。
-
最长回文子序列(Longest Palindromic Subsequence,LPS):给定一个序列,求它的最长回文子序列。
-
最长重复子串(Longest Repeated Substring,LRS):给定一个字符串,求它的最长重复子串。
-
最长不下降子序列(Longest Non-Decreasing Subsequence,LNDS):给定一个序列,求它的最长不下降子序列。
这些问题都可以使用动态规划算法来解决。
简要的介绍什么是动态规划,一种聪明的具备叠加性的状态转移方式,要求没有后效性,说白了叠加出来的解对之前没有影响。 不断的叠加出来一些优秀的子问题解,这些子问题的解决路径都是可能成为最终的解,随着不断的叠加,状态的迁移,剩下的优秀状态越来越少,直到最终只有一个最终解状态。 有一点金字塔的叠加感觉。
后缀:是指从某个位置 开始到整个串末尾结束的一个特殊子串 (包含尾巴字符的子串)
真后缀:不能是包含整个串,一定要少字符。
eg:abcde的后缀: {e, de, cde, bcde, abcde} 真后缀:{e, de, cde, bcde}
前缀:和后缀对称, 从串开始到 某个位置 位置的 一个特殊子串 (包含开头字符的子串)
真前缀:不能是包含整个串,一定要少字符。
eg:abcde的前缀:{a, ab, abc, abcd, abcde} 真前缀:{a, ab, abc, abcd}
字典序:查过字典的应该能懂,按照每个字符的大小对比.
对于两个字符串,可以按照它们第一个不同的字符的 ASCII 码值的大小来比较它们的字典序。
-
"apple" 和 "banana",它们第一个不同的字符是 "p" 和 "b","p" 的 ASCII 码值比 "b" 大,所以 "apple" 的字典序比 "banana" 小。
-
"cat" 和 "car",它们第一个不同的字符是 "t" 和 "r","t" 的 ASCII 码值比 "r" 大,所以 "cat" 的字典序比 "car" 大。
回文串:是正着写和倒着写相同的字符串, 对称串
字符串基本操作
最常用的一个api:substr(begin, length). 功能, 从原串中的begin位置开始截取length长度的一个子串出来。
基本操作的题目:各种反转字符串,反转单词,向左旋转字符串... 本质全部都是反转字符串的叠加应用。(基本的代码code控制力必须练熟悉)
1. 反转代码:
void reverse(std::string& s, int l, int r) {
for (; l < r; l ++, r --) {
swap(s[l], s[r]);
}
}
力扣
解题思路:
class Solution {
void reverse(std::string& s, int l, int r) {
for (; l < r; l ++, r --) {
swap(s[l], s[r]);
}
}
public:
string reverseStr(string s, int k) {
int l = 0, r = 0, e = 0;
//解决满2k的反转
for (; r < s.size(); r ++) {
if (r-l+1 == k) {
e = r;
continue;
}
if (r-l+1 == k << 1) {
reverse(s, l, e);
l = r+1;//从新开始
}
}
//最后一个不满2k的反转处理
if (e-l+1 == k) {//>= k < 2k
reverse(s, l, e);
} else { //< k
reverse(s, l, r - 1);
}
return s;
}
};
557. 反转字符串中的单词 III
解题思路:
class Solution {
void reverse(string& s, int l, int r) {
while (l < r) {
swap (s[l], s[r]);
l ++; r --;
}
}
public:
string reverseWords(string s) {
int l = 0;
for (int i = 0; i < s.size(); i ++) {
if (s[i] == ' ') {
reverse(s, l, i - 1);
l = i + 1;
}
}
reverse(s, l, s.size() - 1);
return s;
}
};
力扣
解题思路:
class Solution {
private:
void reverse(string& s, int l, int r) {
while (l < r) {
swap(s[l], s[r]);
l ++; r--;
}
}
public:
string reverseWords(string s) {
string tmp = "";
int start = 0, end = s.size() - 1;
while (s[start] == ' ') start++;
while (s[end] == ' ') end--;//走过前后空格
for (int i = start; i <= end; i++) {
if (s[i] == ' ') {
tmp += ' ';
while (s[i] == ' ') i++;
i --;
continue;
}
tmp += s[i];
}
reverse(tmp, 0, tmp.size() - 1);
int l = 0;
for (int i = 0; i < tmp.size(); i ++) {
if (tmp[i] == ' ') {
reverse(tmp, l, i - 1);
l = i + 1;
}
}
reverse(tmp, l, tmp.size() - 1);//最后一个单词再反转.
return tmp;
}
};
力扣
class Solution {
public:
bool rotateString(string s, string goal) {
if (s.size() != goal.size()) return false;
int fast = 0, slow = 0;
for (; fast < s.size(); fast ++) {
if (s[fast] == goal[slow]) slow ++;
}
if (slow == goal.size()) return true;//没有旋转.
//至此, goal前半段已经走完
for (fast = 0; fast < s.size(); fast ++) {
if (s[fast] == goal[slow]) slow ++;
else {
return false;
}
if (slow == goal.size()) return true;
}
return 0;
}
};
几乎所有的反转操作,本质上都是反复使用reverse函数而已。
比如我们仅仅需要局部整体反转,但是局部内部顺序不变。就比如说是旋转几个字符到右边,或者是按照单词反转,而非是单词内部反转。我们可以将整体线进行一次反转,诚然,前面的单词整体就跑到后面去了,后面的单词整体就跑到前面去了,但是单词内部的顺序也被破坏掉了。我们还需要将单词内部再进行一次反转,让单词整体反转的同时,单词内部仍然保持原来的顺序。
字符串匹配算法
朴素暴力BF匹配算法:
算法原理:特别朴实,从主串和待匹配串第一个字符依次向后匹配,如果一直匹匹配到待匹配串遍历完,则说明完成了匹配。否则,说明主串第一个字符开始无法完成匹配,所以主串就向后移动一位从第二位继续从新匹配,待匹配串则从头从新匹配。一直重复直到完成匹配。
妈呀,主串一旦回退,则一轮走一位,待匹配串每次都需要从头再来。复杂度,明显最差情况下是O(n*m).
int find(std::string s, std::string needle) {
for (int i = 0, j; i < s.size() - needle.size() + 1; i ++) {
for (j = 0; j < needle.size(); j ++) {
if (s[i + j] != needle[j]) break;
}
if (j == needle.size()) {
return i;//找到了位置
}
}
return -1;//没找到.
}
KMP匹配算法:
难点来了,几乎所有人郁闷都在这里。核心思想:主串绝不后退,如果出现了不匹配,主串不动,让子串回退到一个最可能的位置,再次尝试匹配。
那最可能的位置是什么位置呢? 最长公共真前后缀位置
为啥子串回退,我们要回退到最长公共前后缀位置继续 ? 因为 比如说 子串中前后 分别是 abc abd abe abf abg. 很明显呀,当我们匹配到 abe的时候,失去匹配了。很明显目前主串上面也是和子串同样情况,前面的abc abd ab均可以匹配符合。观察,发现前面的真前缀abc 也是以ab开头的,末尾失配的位置也是以ab开头的,所以直接子串回退到前面的abc继续尝试向下匹配。
eg:
主串: abcabc abd
待匹配串: abcabd
细细评鉴上述案例,这个案例可不是随便乱写的,是有一定的规律的。
这个实在很抽象,所以要多感受,多画图,去理解最长公共前后缀,核心主串不退,子串退到一个比较优秀的位置继续匹配。这个优秀的位置应该是和尾巴上失配位置的前一个位置的末尾和开头前缀子串的最长公共匹配串。 说白了,我两长得相似,你不行我来好吧。
OK: 一难过去还有一难。如何获取这个最长公共前后缀长度的数组,也就是知名的next数组也是一个小小的难点。思路:本质上和公共前后缀还是一致的。能继续匹配,就继续匹配,++下标,作为该位置的最长公共前后缀。失配了嘛,你不行,让前面的公共前缀继续尝试向后好吧。
熬过去了,幸福了,思路化作代码还是很短的。
力扣
class Solution {
void getNext(std::vector<int>& next, string& needle) {
next[0] = 0;
for (int ind = 1, pre = 0; ind < needle.size(); ind ++) {
while (pre > 0 && needle[pre] != needle[ind]) {
pre = next[pre - 1];//尝试使用前缀继续前进
}
if (needle[ind] == needle[pre]) pre ++;
next[ind] = pre;
}
}
public:
int strStr(string haystack, string needle) {
std::vector<int> next(needle.size() + 1);
getNext(next, needle);
for (int i = 0, j = 0; i < haystack.size(); i++) {
while (j > 0 && haystack[i] != needle[j])
j = next[j - 1];//回退到最长公共前后缀.
if (haystack[i] == needle[j]) {
j ++;
}
if (j == needle.size()) return i - j + 1;
}
return -1;
}
};
字符串异位词问题
什么叫做异位词: 相同的字母组成,但排列顺序不同的两个单词或短语
核心:异位,顺序性可变。与顺序完全无关了。仅仅需要满足每个字符出现的次数是保持一致的就OK了。所以针对这种题目,我们往往可以采用映射来计数每个字符出现的次数。当两个字符串,每一个出现的字符对应的出现次数均保持一致的时候,说明两个字符串就是异位串.
力扣
解题思路:因为异位词本质就是出现的字符计数相同。所以,如果判断两个字符串是否互为异位词,仅仅需要判断每一个出现的字符出现次数是否保持一致即可。
/*
1. dict: 存储所需字符
2. count: 统计路径字符
3. needcnt: 需要满足的出现字符的个数
4. cnt: 已经满足的出现字符的个数
所谓满足,就是字符的出现次数相等。
每一个字符都满足出现次数同次数,两个串也就满足异位.
*/
class Solution {
public:
bool isAnagram(string s, string t) {
if (s.size() != t.size()) return false;
int cnt = 0, needcnt = 0;
std::vector<int> dict(26), count(26);
for (auto e : s) {
needcnt += dict[e-'a']==0;
dict[e-'a'] ++;
}
for (auto e : t) {
if (!dict[e-'a']) return false;
count[e-'a'] ++;
if (count[e-'a'] > dict[e-'a']) return false;
if (count[e-'a'] == dict[e-'a']) cnt ++;//e字符满足异位.
}
return cnt == needcnt;
}
};
力扣
解题思路:还是特别的巧妙的。思路的跨度。本来,我还是试图利用上述的方式来判断,两个串是否满足互相的异位。两个词异位,则说明两个串的字符计数数组应该是满足相同的,所以就需要使用一个数组作为映射。最初一开始,我没敢想一个数组可以作为key值,也是因为自己的经验不足。所以就老实的用判断相互异位的上一个题目的功能函数来作为功能函数实现,思路可行,但是映射方向错误,而且我采取了记录首次串的方式来作为路径记录,映射下标,来将剩余的后来的同位串归为一起。弊端:需要遍历前面的首次出现串,用的数组存储串,但是没有想到用计数数组来作为key来hash映射进行分组。
分组问题:按照一定的分组规则进行划分,存储分组的数据结构必须要可以快速映射分组,因为判断异位的操作跑不掉,我们可以思考降低复杂度的方式:就在于快速分类上面。如此:分类问题的开放性思路也就出来了:快速分组,分类可能会用到hash,或者红黑树这种快速查找数据结构来达到快速寻找分组的目的。
//异位词分组. return
//so so
/*
分析:
路径上面的每一个词既可能是单独的单词,也可能是异位的词...
如果每一个走过的单词的异位路径都需要进行记录.
需要写一个判断是否是异位词的辅助函数
*/
//code version1, 接近最后10个案例过不了, 时间复杂度o(m*n). 确实高了.
/*
class Solution {
bool isAnagram(string s, string t) {
if (s.size() != t.size()) return false;
int cnt = 0, needcnt = 0;
std::vector<int> dict(26), count(26);
for (auto e : s) {
needcnt += dict[e-'a']==0;
dict[e-'a'] ++;
}
for (auto e : t) {
if (!dict[e-'a']) return false;
count[e-'a'] ++;
if (count[e-'a'] > dict[e-'a']) return false;
if (count[e-'a'] == dict[e-'a']) cnt ++;
}
return cnt == needcnt;
}
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
std::vector<vector<string>> ans;
std::unordered_map<string, int> inds;//映射下表,结果分组
std::vector<string> paths;//路径上已经走过的字符串
int i;
for (auto e : strs) {
i = 0;
for (; i < paths.size(); i ++) {
if (isAnagram(e, paths[i])) {//是同位的
int ind = inds[paths[i]];
ans[ind].push_back(e);
break;
}
}
if (i == paths.size()) { //还没有他的同位
ans.push_back(vector<string>());
int ind = ans.size() - 1;
inds[e] = ind;
ans[ind].push_back(e);
paths.push_back(e);
}
}
return ans;
}
};
*/
class Solution {
std::string encode(std::string& str) {
vector<int > count(26) ;//用于计数str的路径并且结合hash映射
for (char ch : str) {
count[ch-'a'] ++;
}
return std::string(count.begin(), count.end());
}
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
std::vector<vector<string>> ans;
std::unordered_map<std::string, vector<string>> mp;
for (auto& e : strs) {
std::string code = encode(e);
mp[code].push_back(e);
}
for (auto& e : mp) {
ans.push_back(e.second);
}
return ans;
}
};
分组分类问题和快速查找数据结构之间存在一定的关系。
分组分类问题是将元素按照某种规则划分成若干个组别,通常需要对每个组别进行统计、查询或聚合操作。这种问题在实际应用中非常普遍,比如根据用户属性将用户分组、根据销售渠道将产品分组等。
而快速查找数据结构则是为了快速地查找特定元素而设计的数据结构,通常包括哈希表、二叉搜索树、红黑树、堆等。这些数据结构可以快速地进行查找、插入、删除等操作,并且具有较好的时间复杂度。
在解决分组分类问题时,我们通常需要对元素进行预处理,将其按照某种方式存储起来,以便后续的查询或统计操作。这时,就可以使用快速查找数据结构来实现快速的查询操作,比如使用哈希表或者二叉搜索树来实现。通过将元素按照一定的方式进行转换和存储,再利用快速查找数据结构进行查询和统计,可以大大提高算法的效率。
因此,分组分类问题和快速查找数据结构之间存在密切的联系,可以相互配合来解决很多实际问题。
字符串回文串问题
解题思路:回文串问题,分为回文串判断和寻找最长回文串两类。如果是回文串判断,很简单,就是左右往中间走,如果一直都是对称的,则为回文串。还有另外一类题目:最长回文子串,寻找回文串,为了最快,我们肯定不能使用回文串判断的方式来了,那样不好操作,而且复杂度高。我们需要从回文串的产生角度入手,回文串的对称性,从中间向两边扩散,寻找最长回文串。
力扣
力扣
/*
*
1. 回文可能在左边或者右边
2. 删除的这个字符可能是删除左边的,也可能是删除右边的.
*/
class Solution {
bool isPalindrome(std::string& s) {
for (int l = 0, r = s.size() - 1; l < r; l ++, r --) {
if (s[l] != s[r]) return false;
}
return true;
}
public:
bool validPalindrome(string s) {
int l, r ;
for (l = 0, r = s.size()-1; l < r; l ++, r --) {
if (s[r] != s[l]) {
break;
}
}
if (r <= l) return true;//不用删除
//删左边
std::string s_right = s.substr(l+1, r-l);
//删右边
std::string s_left = s.substr(l, r-l);
return isPalindrome(s_left) || isPalindrome(s_right);
}
};
寻找最长回文子串.
力扣
/最长回文子串. 因为是子串问题, 所以很自然的想到需要遍历所有可能的子串
//自然扫描一道整个串是必然的. 扫描中获取最长回文子串.
//判断回文串和寻找回文串的思考角度不一样.
//判断回文串从整体的结构入手, 已知串判断回文.
//寻找回文串从产生入手, 如何产生的 ? 从中间向两边辐射产生, 对称结构.
//边界问题. 完全未知. 谁都可能成为边界.
//make_pair不需要显式调用
class Solution {
std::pair<int, int> search(std::string& s, int l, int r) {
for (; l >= 0 && r < s.size(); l --, r ++) {
if (s[l] != s[r]) break;
}
//走到不可能成为ans的位置, 也就是
return std::make_pair(l + 1, r - 1);
}
public:
string longestPalindrome(string s) {
string ans = "";
for (int i = 0; i < s.size(); i ++) {
std::pair<int, int> pair1 = search(s, i, i + 1);
string s1 = s.substr(pair1.first, pair1.second - pair1.first + 1) ;
std::pair<int, int> pair2 = search(s, i, i);
string s2 = s.substr(pair2.first, pair2.second - pair2.first + 1) ;
ans = ans.size() > s1.size() ? ans : s1;
ans = ans.size() > s2.size() ? ans : s2;
}
return ans;
}
};
留下悬念:高级字符串算法题目(字符串 + dp)
dp有点难以理解。留着刷到dp算法题目的时候拔高。留下一点悬念,依稀记得大二刚学习dp的时候写过一篇dp入门算法,写的感觉还可以,跟大家分享.
动态规划真的有那么抽象吗?(递推算法还是动态规划别傻傻分不清了) 以正确的姿势学习动态规划 (入门篇)_动态规划和递推_小杰312的博客-CSDN博客