02 字符串相关问题

182 阅读13分钟

02 字符串相关问题

1、反转字符串

题目简介:

编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。

不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。

示例:
输入: s = ["h","e","l","l","o"]
输出: ["o","l","l","e","h"]

题解:

反转字符串依然是使用双指针的方法,只不过对于字符串的反转,其实要比链表简单一些。

因为字符串也是一种数组,所以元素在内存中是连续分布,这就决定了反转链表和反转字符串方式上还是有所差异的。

对于字符串,我们定义两个指针(也可以说是索引下标),一个从字符串前面,一个从字符串后面,两个指针同时向中间移动,并交换元素。

    public void reverseString(char[] s) {
        int l = 0;
        int r = s.length - 1;
        while(l < r){
            char temp = s[l];
            s[l] = s[r];
            s[r] = temp;
            l++;
            r--;
        }
    }

2、反转字符串II

题目简介:

给定一个字符串 s 和一个整数 k,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 字符中的前 k 个字符。

  • 如果剩余字符少于 k 个,则将剩余字符全部反转。
  • 如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。
示例:
输入: s = "abcdefg", k = 2
输出: "bacdfeg"

题解:

在遍历字符串的过程中,只要让 i += (2 * k),i 每次移动 2 * k 就可以了,然后判断是否需要有反转的区间。

因为要找的也就是每2 * k 区间的起点,这样写,程序会高效很多。

    public String reverseStr(String s, int k) {
        char[] ch = s.toCharArray();
        for(int i = 0;i < ch.length;i += 2 * k){
            int start = i;
            // 判断尾数够不够k个来取决end指针的位置
            int end = Math.min(ch.length - 1,start + k - 1);
            while(start < end){
                
                char temp = ch[start];
                ch[start] = ch[end];
                ch[end] = temp;

                start++;
                end--;
            }
        }
        return new String(ch);
    }

3、翻转字符串里的单词

题目简介:

给你一个字符串 s ,请你反转字符串中 单词 的顺序。

单词 是由非空格字符组成的字符串。s 中使用至少一个空格将字符串中的 单词 分隔开。

返回 单词 顺序颠倒且 单词 之间用单个空格连接的结果字符串。

注意: 输入字符串 s中可能会存在前导空格、尾随空格或者单词间的多个空格。返回的结果字符串中,单词间应当仅用单个空格分隔,且不包含任何额外的空格。

示例:
输入: s = "the sky is blue"
输出: "blue is sky the"
输入:s = "a good   example"
输出:"example good a"
解释:如果两个单词间有多余的空格,反转后的字符串需要将单词间的空格减少到仅有一个。

题解:

解题思路:

  • 移除多余空格
  • 将整个字符串反转
  • 将每个单词反转
    public String reverseWords(String s) {
        //源字符数组
        char[] initialArr = s.toCharArray();
        //新字符数组
        char[] newArr = new char[initialArr.length+1];//下面循环添加"单词 ",最终末尾的空格不会返回
        int newArrPos = 0;
        //i来进行整体对源字符数组从后往前遍历
        int i = initialArr.length-1;
        while(i>=0){
            while(i>=0 && initialArr[i] == ' '){i--;}  //跳过空格
            //此时i位置是边界或!=空格,先记录当前索引,之后的while用来确定单词的首字母的位置
            int right = i;
            while(i>=0 && initialArr[i] != ' '){i--;} 
            //指定区间单词取出(由于i为首字母的前一位,所以这里+1,),取出的每组末尾都带有一个空格
            for (int j = i+1; j <= right; j++) {
                newArr[newArrPos++] = initialArr[j];
                if(j == right){
                    newArr[newArrPos++] = ' ';//空格
                }
            }
        }
        //若是原始字符串没有单词,直接返回空字符串;若是有单词,返回0-末尾空格索引前范围的字符数组(转成String返回)
        if(newArrPos == 0){
            return "";
        }else{
            return new String(newArr,0,newArrPos-1);
        }
    }

4、左旋转字符串

题目简介:

字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。

请定义一个函数实现字符串左旋转操作的功能。比如,输入字符串"abcdefg"和数字2,该函数将返回左旋转两位得到的结果"cdefgab"。

示例:
输入: s = "abcdefg", k = 2  
输出: "cdefgab"

题解:

解题步骤:

  • 反转区间为前n的子串
  • 反转区间为n到末尾的子串
  • 反转整个字符串
    public String reverseLeftWords(String s, int n) {
        int len=s.length();
        StringBuilder sb=new StringBuilder(s);
        reverseString(sb,0,n-1);
        reverseString(sb,n,len-1);
        return sb.reverse().toString();
    }
     public void reverseString(StringBuilder sb, int start, int end) {
        while (start < end) {
            char temp = sb.charAt(start);
            sb.setCharAt(start, sb.charAt(end));
            sb.setCharAt(end, temp);
            start++;
            end--;
        }
    }

5、找出字符串中第一个匹配项的下标

题目简介:

给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。

如果 needle 不是 haystack 的一部分,则返回  -1 。

示例:
输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 0 和 6 处匹配。
第一个匹配项的下标是 0 ,所以返回 0 。
输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 。

KMP讲解:

1、KMP的经典思想就是:

当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。

2、KMP有什么用:

KMP主要应用在字符串匹配上。所以如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。

3、什么是前缀表:

next数组就是一个前缀表(prefix table)。

前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。

4、前缀表是如何记录的呢:

前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。

那么什么是前缀表:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。

5、最长公共前后缀:

字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串

后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串

因为前缀表要求的就是相同前后缀的长度。

所以字符串a的最长相等前后缀为0。 字符串aa的最长相等前后缀为1。 字符串aaa的最长相等前后缀为2。 等等.....。

6、为什么一定要使用前缀表:

举例:aabaabaafa 中查找是否出现过一个模式串:aabaaf。

下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。

所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。

7、如何计算前缀表:

就是观察一段字符串的最后一个字符和最前一个字符是否一样,如果一样就+1,然后继续比较前面第二位和倒数第二位;如果不一样则为0。

然后就创建一个前缀表,按照下标依次填入刚才计算出的不同长度前缀字符串时的最长公共子串长度值。

找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。

前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续比配。

最后就在文本串中找到了和模式串匹配的子串了。

8、前缀表与next数组:

next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。

其实这并不涉及到KMP的原理,而是具体实现,next数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为-1)。

9、使用next数组来匹配:

以下我们以前缀表统一减一之后的next数组来做演示

有了next数组,就可以根据next数组来 匹配文本串s,和模式串t了。

10、时间复杂度分析:

其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n)

之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。

使用KMP算法,一定要构造next数组。

11、构造next数组:

void getNext(int* next, const string& s)    //函数参数为指向next数组的指针,和一个字符串

构造next数组其实就是计算模式串s,前缀表的过程。  主要有如下三步:

  • 初始化
  • 处理前后缀不相同的情况
  • 处理前后缀相同的情况

(1)初始化:

定义两个指针i和j,j指向前缀末尾位置,i指向后缀末尾位置。

然后还要对next数组进行初始化赋值:

int j = -1;
next[0] = j;

j 为什么要初始化为 -1呢,因为之前说过 前缀表要统一减一的操作仅仅是其中的一种实现,我们这里选择j初始化为-1,下文我还会给出j不初始化为-1的实现代码。

next[i] 表示 i(包括i)之前最长相等的前后缀长度(其实就是j),所以初始化next[0] = j 。

(2)处理前后缀不相同的情况:

因为j初始化为-1,那么i就从1开始,进行s[i] 与 s[j+1]的比较。

所以遍历模式串s的循环下标i 要从 1开始,代码如下:

for (int i = 1; i < s.size(); i++) {

如果 s[i] 与 s[j+1]不相同,也就是遇到 前后缀末尾不相同的情况,就要向前回退。

next[j]就是记录着j(包括j)之前的子串的相同前后缀的长度。

那么 s[i] 与 s[j+1] 不相同,就要找 j+1前一个元素在next数组里的值(就是next[j])。

所以,处理前后缀不相同的情况代码如下:

while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
   j = next[j]; // 向前回退
}

(3)处理前后缀相同的情况:

如果 s[i] 与 s[j + 1] 相同,那么就同时向后移动i 和j 说明找到了相同的前后缀,同时还要将j(前缀的长度)赋给next[i], 因为next[i]要记录相同前后缀的长度。

if (s[i] == s[j + 1]) { // 找到相同的前后缀
    j++;
}
next[i] = j;

最后整体构建next数组的函数代码:

void getNext(int* next, const string& s){
   int j = -1;
   next[0] = j;
   for(int i = 1; i < s.size(); i++) { // 注意i从1开始
       while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
           j = next[j]; // 向前回退
      }
       if (s[i] == s[j + 1]) { // 找到相同的前后缀
           j++;
       }
       next[i] = j; // 将j(前缀的长度)赋给next[i]
   }
}

12、使用next数组来做匹配:

在文本串s里 找是否出现过模式串t。

定义两个下标j 指向模式串起始位置,i指向文本串起始位置。

那么j初始值依然为-1,为什么呢? 依然因为next数组里记录的起始位置为-1。

i就从0开始,遍历文本串,代码如下:

for (int i = 0; i < s.size(); i++) 

接下来就是 s[i] 与 t[j + 1] (因为j从-1开始的) 进行比较。

如果 s[i] 与 t[j + 1] 不相同,j就要从next数组里寻找下一个匹配的位置。

while(j >= 0 && s[i] != t[j + 1]) {
   j = next[j];
}

如果 s[i] 与 t[j + 1] 相同,那么i 和 j 同时向后移动, 代码如下:

if (s[i] == t[j + 1]) {
   j++; // i的增加在for循环里
}

如何判断在文本串s里出现了模式串t呢,如果j指向了模式串t的末尾,那么就说明模式串t完全匹配文本串s里的某个子串了。

本题要在文本串字符串中找出模式串出现的第一个位置 (从0开始),所以返回当前在文本串匹配模式串的位置i 减去 模式串的长度,就是文本串字符串中出现模式串的第一个位置。

if (j == (t.size() - 1) ) {
   return (i - t.size() + 1);
}

那么使用next数组,用模式串匹配文本串的整体代码如下:

int j = -1; // 因为next数组里记录的起始位置为-1
for (int i = 0; i < s.size(); i++) { // 注意i就从0开始
   while(j >= 0 && s[i] != t[j + 1]) { // 不匹配
       j = next[j]; // j 寻找之前匹配的位置
   }
   if (s[i] == t[j + 1]) { // 匹配,j和i同时向后移动
       j++; // i的增加在for循环里
   }
   if (j == (t.size() - 1) ) { // 文本串s里出现了模式串t
       return (i - t.size() + 1);
   }
}

13、前缀表统一减一 C++代码实现:

   void getNext(int* next, const string& s) {
       int j = -1;
       next[0] = j;
       for(int i = 1; i < s.size(); i++) { // 注意i从1开始
           while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
               j = next[j]; // 向前回退
           }
           if (s[i] == s[j + 1]) { // 找到相同的前后缀
               j++;
           }
           next[i] = j; // 将j(前缀的长度)赋给next[i]
       }
   }
   int strStr(string haystack, string needle) {
       if (needle.size() == 0) {
           return 0;
       }
       int next[needle.size()];
       getNext(next, needle);
       int j = -1; // // 因为next数组里记录的起始位置为-1
       for (int i = 0; i < haystack.size(); i++) { // 注意就从0开始
           while(j >= 0 && haystack[i] != needle[j + 1]) { // 不匹配
               j = next[j]; // j 寻找之前匹配的位置
           }
           if (haystack[i] == needle[j + 1]) { // 匹配,j和i同时向后移动
               j++; // i的增加在for循环里
           }
           if (j == (needle.size() - 1) ) { // 文本串s里出现了模式串t
               return (i - needle.size() + 1);
           }
       }
       return -1;
   }

14、前缀表(不减一)C++实现:

   void getNext(int* next, const string& s) {
       int j = 0;
       next[0] = 0;
       for(int i = 1; i < s.size(); i++) {
           while (j > 0 && s[i] != s[j]) {
               j = next[j - 1];
           }
           if (s[i] == s[j]) {
               j++;
           }
           next[i] = j;
       }
   }
   int strStr(string haystack, string needle) {
       if (needle.size() == 0) {
           return 0;
       }
       int next[needle.size()];
       getNext(next, needle);
       int j = 0;
       for (int i = 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 - needle.size() + 1);
           }
       }
       return -1;
   }
//前缀表(不减一)Java实现
    public int strStr(String haystack, String needle) {
        if (needle.length() == 0) return 0;
        int[] next = new int[needle.length()];
        getNext(next, needle);

        int j = 0;
        for (int i = 0; i < haystack.length(); i++) {
            while (j > 0 && needle.charAt(j) != haystack.charAt(i)) 
                j = next[j - 1];
            if (needle.charAt(j) == haystack.charAt(i)) 
                j++;
            if (j == needle.length()) 
                return i - needle.length() + 1;
        }
        return -1;

    }
    
    private void getNext(int[] next, String s) {
        int j = 0;
        next[0] = 0;
        for (int i = 1; i < s.length(); i++) {
            while (j > 0 && s.charAt(j) != s.charAt(i)) 
                j = next[j - 1];
            if (s.charAt(j) == s.charAt(i)) 
                j++;
            next[i] = j; 
        }
    }

6、重复的子字符串

题目简介:

给定一个非空的字符串 s ,检查是否可以通过由它的一个子串重复多次构成。

示例:
输入: s = "abab"
输出: true
解释: 可由子串 "ab" 重复两次构成。

题解:

在由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串。

    public boolean repeatedSubstringPattern(String s) {
        if (s.equals("")) return false;

        int len = s.length();
        // 原串加个空格(哨兵),使下标从1开始,这样j从0开始,也不用初始化了
        s = " " + s;
        char[] chars = s.toCharArray();
        int[] next = new int[len + 1];

        // 构造 next 数组过程,j从0开始(空格),i从2开始
        for (int i = 2, j = 0; i <= len; i++) {
            // 匹配不成功,j回到前一位置 next 数组所对应的值
            while (j > 0 && chars[i] != chars[j + 1]) j = next[j];
            // 匹配成功,j往后移
            if (chars[i] == chars[j + 1]) j++;
            // 更新 next 数组的值
            next[i] = j;
        }

        // 最后判断是否是重复的子字符串,这里 next[len] 即代表next数组末尾的值
        if (next[len] > 0 && len % (len - next[len]) == 0) {
            return true;
        }
        return false;
    }