0x00000005 3.数据结构和算法 基础数据结构 字符串(上)

文章目录

  • 基本知识简单总结
    • 模式匹配
    • 最长回文子串
    • 前缀匹配
    • 扩展和补充*
  • C++11常见API
  • References:


字符串也是一个高频考察点。
虽然可以和数组考点合并,但由于该场景许多优化空间大。问题典型:如子序列和子数组问题。
容易和比较重要的算法思想:如单调栈,滑动窗口,动态规划结合。
并且有些题目的编码细节比较多。
经常面试和笔试题都喜欢问及。

这里先总结基础知识(这里主要针对字符串数组查找算法,并给出源代码)和常用API,具体试题留在下周进行总结。
如字符串匹配,字典树,最长回文子串问题

基本知识简单总结

字符串可以看做一个数组,内部存储的元素为char类型。
所以数组的基本操作字符串都能够进行处理。
由于字符串独特的一些场景。所以关于字符串的特殊的优化算法,相关操作有很多。这里主要展开以下几个场景进行介绍:

  1. 模式匹配(KMP算法)
  2. 最长回文子串(Manacher算法)
  3. 前缀匹配(Trie结构/字典树/前缀树)

模式匹配

  1. 场景描述
    求解串A与子串B的单一匹配问题。
    换句话说,如果B串在A串中存在子串,就返回B串在A串出现的第一个位置(不存在返回-1)=>
    leetcode原题:实现Strstr()
    一个直观的解题想法如下(暴力匹配)

    class Solution {
    public:
        int strStr(string haystack, string needle) {
            for (int i = 0; i + needle.size() <= haystack.size(); ++i) { //遍历的起始位置点,注意细节,不要用-;而是+(unsigned int!!)
                bool flag = true; //是否完美匹配
                for (int j = 0; j < needle.size(); ++j) { //每个位置点进行遍历,查找是否与needle匹配
                    if (haystack[i + j] != needle[j]) {
                        flag = false;
                        break;
                    }
                }
                if (flag) {
                    return i;
                }
            }
            return -1;
        }
    };
    

    由于每次匹配,失败从头匹配。导致回溯,时间复杂度为O(n*m),空间复杂度为O(1)。

  2. 算法描述
    KMP算法=> 主要利用空间换时间的方法。(考虑到很多博客已经详细讲解这一过程,我们此处只是将该过程关键点简单梳理一次
    1.由于每次回溯匹配是失败的,那么如果我们直接跳转到重新匹配的新位置,我们就能够继续匹配,减少从头跳转的时间开销。
    2.跳转到对应位置,我们仅需要通过模式串自身就能够构造->Next数组。
    3.Next的数组含义是指匹配失败时候,模式串指向j应该跳转到对应模式串哪一个位置。
    4.这个关键点在于找到该位置前面的字符串,后缀和前缀最长匹配长度即可。
    =>所以整体空间复杂度为O(m)。
    时间复杂度考虑到主串的指针没有进行任何回溯,模式串能够在有限次数内完成回溯,所以匹配过程中总的时间复杂度为O(n)。对于整体算法:时间复杂度为O(n+m)

  3. 核心代码示例

    class Solution {
    public:
        int strStr(string haystack, string needle) {
            if (needle.size() == 0) {
                return 0;
            }
            vector<int> next(needle.size());  //首先构建next数组
            // 表示失败匹配后,模式串指针j指向的位置
            // 这个指向位置表示当前匹配失败位置前面字符串,它的前缀与后缀最长匹配长度
            // 
    
            for (int i = 1, j = 0; i < needle.size(); ++i) {
                // i表示匹配失败的位置,j表示i位置下前缀与后缀最长匹配长度;
                // 如果移动到i位置,前缀失效;
                // 这里有一个小优化:由于之前的最长匹配前缀已经保存在next,可以考虑直接跳转判断
                while (needle[i] != needle[j] && j > 0) {
                    j = next[j - 1]; //最难理解就是这一句话-> 等价与 j = next[next[i - 1]-1];
                   // 找到 i - 1 前位置最长前缀的前一个位置重新尝试匹配。
                }
    
                // 否则一直匹配计算
               if (needle[i] == needle[j]) {
                   ++j;
               }
               next[i] = j;
            }
    
            // 开始匹配
            for (int i = 0, j = 0; i < haystack.size(); ++i) {
                while (haystack[i] != needle[j] && j > 0) {
                    j = next[j - 1];
                }
    
                if (haystack[i] == needle[j]) {
                    ++j;
                }
                if (j == needle.size()) {
                    return i - needle.size() + 1;
                }
            }
            return -1;
        }
    };
    
  4. 总结和补充
    这个是一个经典的算法(数据结构都会讲解)。
    难度有一定难,代码却十分简单。
    关键为了减少从头回溯,构造next数组。
    构造next数组关键在前缀和后缀最长匹配。
    代码中求解前缀和后缀最长匹配失效,使用一个小的优化策略:不停寻找i-1最长前缀的下标的前一个位置。

最长回文子串

  1. 场景描述
    最长回文子串
    找出一个字符串的其中一个最长回文子串。
    一个简单的想法:枚举所有子串,检查每一个子串是否为字符串的回文子串,然后求解最长回文子串。
    时间复杂度 O ( n 3 ) O(n^3) O(n3),空间复杂度O(1)。
    下面对其进行优化(主要针对时间复杂度)
  2. 算法描述和代码示例
    1)动态规划(对左右端点进行枚举)
    class Solution {
    public:
        string longestPalindrome(string s) {
            int len = s.size();
            if (len < 2) {
                return s;
            }
            int max_len = 1, beg = 0;
            vector<vector<bool>> dp(len, vector<bool>(len, true));
    
            for (int sub_len = 2; sub_len <= len; ++sub_len) { // 由于状态转移方程的递推方式。
            // 必须先枚举子串长度,然后枚举右边端点
                for (int left = 0; left < len; ++left)  { // 枚举子串长度
                    int right = left + sub_len - 1;
                    if (right >= len) {
                        break; // 停止改点的枚举
                    }
    
                    if (s[left] != s[right]) { //判断端点
                        dp[left][right] = false;
                    } else {
                        if (right - left < 3) { // 长度为1或者2,直接跳过
                            dp[left][right] = true;
                        } else {
                            dp[left][right] = dp[left + 1][right - 1];
                        }
                    }
    
                    if (dp[left][right] && sub_len > max_len) {
                        max_len = sub_len;
                        beg = left;
                    }
                }
            }
            return s.substr(beg, max_len);
        }
    };
    
    2)中心扩展(对回文串中心点进行枚举)
    class Solution {
    public:
        string longestPalindrome(string s) {
            int start = 0, end = 0;
            for (int left = 0; left < s.size(); ++left) { // 以left进行枚举
                auto[left1, right1] = palindrome(s, left, left); // 以left为中点扩展
                auto[left2, right2] = palindrome(s, left, left + 1); // 以left,left + 1为中点扩展
                if (right1 - left1 > end - start) {
                    start = left1;
                    end = right1;
                }
    
                if (right2 - left2 > end - start) {
                    start = left2;
                    end = right2;
                } // 判断两个情况哪一种较长
            }
            return s.substr(start, end - start + 1);
        }
    
        pair<int, int> palindrome(string s, int left, int right) {
            while (left >= 0 && right < s.size() && s[left] == s[right]) {
                --left;
                ++right;
            }
            return {left + 1, right - 1};
        }
    };
    
    3)Manacher算法(内涵详细注释)
    (代码参考该视频:https://www.bilibili.com/video/BV13g41157hK?p=14)
    class Solution {
    
        // 预处理步骤,可以不考虑两种回文子串的情况。
        void predeal(string& s) {
            string ans = "#";
            for (char ch : s) {
                ans += ch;
                ans += '#';
            }
            ans += '#';
            s = ans;
        }
    
      
        // 后处理步骤,解码回需要的子串
        string postdeal(int max_left, int maxlen, string& s) {
            string ans = "";
    
            for (int offset = 0; offset  < maxlen; ++offset) {
                if (s[max_left + offset] != '#') {
                    ans += s[max_left + offset];
                } 
            }
            return ans;
        }
    public:
        string longestPalindrome(string s) {
           predeal(s);
    
            // 这里的优化主要利用回文字符串对称性,从左往右枚举中心点遍历时候必然有一些区间字符对应相等。
            // 保存这些信息,就可以减少查找的过程,直接跳转。
            // 类似KMP算法匹配字符串,利用匹配失效后。由于失效匹配前缀必然向前有一段可能与模式串由前向后某一段完全匹配。
            // 保存这些重复段信息,直接查找,减少回溯,提出:最长匹配前缀和最长匹配后缀概念=> Next数组保存当前最长匹配前缀长度信息。
    
            // 这些信息全部包含在以下两个概念中:
            // 1. 每一个字符的回文半径C(需要保存每一个,所以需要创建一个数组)
            // 2. 从左往右遍历能够抵达的最长回文区域点Right
            vector<int> Radius(s.size()); //回文半径数组
            int Center = -1; // 到达最右右边界的回文串中心
            int Right = -1; // 回文右边界终止位置  ... Right -1]  Right ...
            int max_left = 0, maxlen = 1;
    
            // 1) pos 在 R 外, 无优化,继续遍历
            // 2) pos 在 R 内 
            // 设R回文串中心为C
            // pos': pos关于C的回文点,其回文到达左边界为left = pos' - Radius[pos'];
            // a. left 在 C - R 右边(包含在当前最右的回文串内) 该点回文半径为Radius[pos']
            // b. left 在 C - R 左边(不包含在当前最右回文串内) 该点回文半径为R - pos;
            // c. left 落在 C - R 上 , 不需要验证的点为 R - pos ,从该点开始继续遍历
    
            for (int pos = 0; pos < s.size(); ++pos) {
                // 上述情况中,至少不用检验的区域
                Radius[pos] = Right > pos ?  min(Radius[2 * Center - pos], Right - pos) : 1;
    
                // 不管哪种情况,都尝试往外扩展试试
                while (pos + Radius[pos] < s.size() && pos - Radius[pos] > -1) {
                    if (s[pos + Radius[pos]] == s[pos - Radius[pos]]) {
                        ++Radius[pos];
                    } else {
                        break;
                    }
                }
    
                // 更新Right 和 Center
                if (pos + Radius[pos] > Right) {
                    Right = pos + Radius[pos];
                    Center = pos;
                }
    
                // 记录最长回文数组左端点和长度
                if (maxlen < Radius[pos] * 2 - 1) {
                    maxlen = Radius[pos] * 2 - 1;
                    max_left = pos - Radius[pos] + 1;
                }
            }
    
            return postdeal(max_left, maxlen, s);
        }
    };
    
  3. 总结和补充
    一般字符串的问题可以考虑从以某一个端点(i为尾的子串)的方式进行枚举。

前缀匹配

  1. 场景描述
    实现前缀树
    字典序的第k小数字

  2. 核心代码示例
    实现一个前缀树,本质上是一个简单的n叉树。核心代码如下所示:

    class Trie {
        map<char, Trie*> children;
        bool isend;
    
        Trie* SearchPrefix(string word) {
            Trie* node = this;
            for (char ch : word) {
                if (node->children.count(ch) == 0) {
                    return nullptr;
                }
                node = node->children[ch];
            }
            return node;
        }
    	public:
    	    /** Initialize your data structure here. */
    	    Trie(): isend(false) {
    	
    	    }
    	    
    	    /** Inserts a word into the trie. */
    	    void insert(string word) {
    	        Trie* node = this;
    	        for (char ch : word) {
    	            if (node->children.count(ch) == 0) {
    	                node->children[ch] = new Trie();
    	            }
    	            node = node->children[ch];
    	        }
    	        node->isend = true;
    	    }
    	    
    	    /** Returns if the word is in the trie. */
    	    bool search(string word) {
    	        Trie* node = this->SearchPrefix(word);
    	        return node != nullptr && node->isend;
    	    }
    	    
    	    /** Returns if there is any word in the trie that starts with the given prefix. */
    	    bool startsWith(string prefix) {
    	        return this->SearchPrefix(prefix) != nullptr;
    	    }
    	};
    	/**
    	 * Your Trie object will be instantiated and called as such:
    	 * Trie* obj = new Trie();
    	 * obj->insert(word);
    	 * bool param_2 = obj->search(word);
    	 * bool param_3 = obj->startsWith(prefix);
    	 */
    
  3. 总结和补充
    可以考虑完成一下题目:
    a. 添加与搜索单词 - 数据结构设计
    b. 单词搜索 II
    c. 数组中两个数的最大异或值
    d. 与数组中元素的最大异或值

扩展和补充*

这里只补充一个,其他有时间可以看看下面参考link(如: AC自动机, Z数组)

  1. 字符串哈希 + 滚动哈希
    关键把字符串映射为一个值,这个值和字符串一一对应!

    一个简单的想法是将字符串使用M进制表示,对于str=“abcd”,其hash值为:
    h a s h [ s t r ] = ( ( a ∗ M + b ) ∗ M + c ) ∗ M + d hash[str] = ((a * M + b) * M + c)* M + d hash[str]=((aM+b)M+c)M+d

    为了避免冲突,M需要取很大的质数。而计算机表示数字优先。这里就需要取一个模(大小为P)。
    (思路大致如以上所示,但是具体实践中需要多次尝试,给出一个代码模板:)

    using ULL = unsigned long long;
    
    const ULL BASE = 13331; // Base, 可以考虑其他素数:31,
    const ULL MOD = 10e9 + 7; // MOD 也是数组最大长度
    
    // 进行字符串编码
    UUL encode(const string& str) {
        UUL ans = 0;
        for (auto ch : str) {
            ans *= BASE;
            ans += ch;
            ans %= MOD;
            // ans = (ans * BASE + ch) % MOD;
        }
        return ans;
    }
    

    当然,如果单一hash值感觉还会导致冲突,可以考虑使用两个BASE 和 MOD。最后存储为一个pair即可。
    该方法可以解决一下问题:(此处参考这个link)
    这里只举出一个:KMP算法中模式匹配问题:

    class Solution {
        using ULL = unsigned long long;
        const ULL Base = 29;
        const ULL MOD = 1e9 + 7;
    
        ULL encode(const string& str) {
            ULL ans = 0;
            for (auto ch : str) {
                ans *= Base;
                ans += ch;
                ans % MOD;
            }
            return ans;
        }
    public:
        int strStr(string haystack, string needle) {
            if (haystack.size() == 0) {
                return 0;
            }
            if (haystack.size() < needle.size()) {
                return -1;
            }
    
            ULL temp_str = encode(needle);
    
            vector<ULL> my_str(haystack.size() - needle.size() + 1); 
    
            for (int i = 0; i + needle.size() <= haystack.size(); ++i) {
                my_str[i] = encode(haystack.substr(i, needle.size()));
            }
    
            for (int i = 0; i < my_str.size(); ++i) {
                if (my_str[i] == temp_str) {
                    return i;
                }
            }
            return -1;
        }
    };
    

    严格优化后如下

    class Solution {
        using ULL = unsigned long long;
        const ULL Base = 29;
        const ULL MOD = 1e9 + 7;
    
    public:
        int strStr(string haystack, string needle) {
            if (haystack.size() == 0) {
                return 0;
            }
            if (haystack.size() < needle.size()) {
                return -1;
            }
    
            ULL temp_str = 0; //模式串对应hash值
            ULL ans_str = 0; // 答案串对应hash值
            ULL mul = 1; // 滚动哈希最后一位乘数值 , 注意为 needle.size() - 1 的幂次
    
            for (int i = 0; i < needle.size(); ++i) {
                temp_str = (temp_str * Base + needle[i] - 'a') % MOD;
                ans_str = (ans_str * Base + haystack[i] - 'a') % MOD;
                if (i + 1 < needle.size()) {
                    mul = (mul * Base) % MOD;
                }
            } 
    
            if (temp_str == ans_str) { // 第一次就找到了
                return 0; 
            }
    
            for (int i = 1; i + needle.size() <= haystack.size(); ++i) {
                ans_str = (ans_str - mul * (haystack[i - 1]  - 'a') + MOD ) * Base % MOD; //为了防止滚动到之前位置,我们加上一个MOD
                ans_str = (ans_str + (haystack[i + needle.size() - 1] - 'a')) % MOD;
    
                if (ans_str == temp_str) {
                    return i;
                }
            }
    
            return -1;
        }
    };
    
  2. 其他参考link


C++11常见API

这里主要是对string类型的介绍:

  1. 创建,增删改查(基本与vector类似)

    基本参考vector,这里省略。

    string str("acds");
    string str1{"abcd"};
    string str2{"ddef"};
    //比较特殊变量:
    string::npos; //通常表示索引函数find没有查找成功,类型为size_type, 值为-1.
    
    // 一些特殊的函数
    str.data() //返回存储数据的指针
    str.c_str() //返回一个C类型指针
    // 注意以上两个指针类型都是 const char *
    
    // 备注:+ 同时可以拼接两个字符串,要求第一个一定为String类型,+后面可以为字符串常量。
    // append() 表示在后面追加,一般用法如下
    str.append(5, 'a');
    str1.append(str2);
    str1.append(str2, 1, 3); //从pos = 1位置,添加长度len = 3,不写默认添加到最后
    str.append("cdd", 5); // 从头到 len = min(添加字符串长度, 5)部分加入str尾部
    
    // 替换部分
    str.replace(1, 2, "heat"); // 把str 1位置后2个字符换成"heat"
    
    str.replace(0, 2, str2, 1, 2); //表示str pos = 0开始后面2个位置用str2 pos = 1后面2个位置替换。
    // 1, 2 不写默认替换为str2全部
    // 不写2,表示从0开始替换1个字符
    
    str.replace(str.begin(), str.begin() + 2, 3, 'A'); //把两个迭代器区域(前闭后开),换成3个'A'
    str.replace(str.begin(), str.begin() + 2 , str2); //str2 替换掉部分
    str.replace(str.begin(), str.begin() + 2 , "heat", 2); // str 0 - 1 部分替换为"heat"前面两个字符
    
    str.replace(0 , 2, 10 , 'a');// 0 -> 1区域用10个'a'替换
    str.replace(str.begin(), str.begin() + 1, 10 , 'a');// 上述迭代器版本
    
    
  2. 获取子串

    str.sub_str(0, 2);// 获取0位置开始,长度为2的子串
    //不写长度为2,默认到最后一个字符
    
  3. 查找子串

    // 前缀和后缀=>返回true/ false;
    str.starts_with(prefix); 
    str.ends_with(suffix);
    
    // 查找(都只查找第一个)
    str.find(str1, 2);// 从pos = 2时开始找str1,默认pos = 0;
    str.find("ab", 1);// 从pos = 1开始查找“ab”, 默认pos = 0;
    str.find('a', 2);// 从pos = 2时开始找'a',默认pos = 0; 
    
    // 其余类似:
    // rfind()
    // find_first_of(), find_first_not_of
    // find_last_of()
    // find_last_not_of()
    
    
  4. 与其他类型相互转换

    stoi(str, nullptr, 10);
    stol(str, nullptr, 10);
    stoll(str, nullptr, 10);
    // 从第一个位置开始,找到第一个非空格字符,尽可能多的使用字符形成整数,其中base = 10;
    // 在nullptr处传入变量地址可以返回获得数字长度(size_t)
    // 如果第一个非空字符不是合法符号,抛出std::invalid_argument异常。
    
    
    // 类似的有stof,stod,stold
    // stoul, stoull
    
    // 其他类型转字符串
    to_string(123);
    // 转为宽字符串
    to_wstring(1);
    

References:

  1. String API部分: https://en.cppreference.com/w/cpp/string/basic_string
  2. Manacher算法代码参考:https://www.bilibili.com/video/BV13g41157hK?p=14
  3. Trie部分参考:https://www.bilibili.com/video/BV1zz4y1S7Je
  4. 其他字符串算法技巧解析参考:https://oi-wiki.org/string/
欢迎评论指正和补充

你可能感兴趣的:(基础数据结构,数据结构和算法,算法,数据结构,c++)