算法学习之字符串

67 阅读6分钟

本节我们来学习有关于字符串的相关题目的解题思路,首先我们要知道的一点是在java中有许多的关于字符串的库函数,那我们解题时什么时候去使用这些函数什么时候又不使用呢?这里我们提供两点标准

1、如果题目关键的部分直接用库函数就可以解决,建议不要使用库函数。2、如果库函数仅仅是 解题过程中的一小部分,并且你已经很清楚这个库函数的内部实现原理的话,可以考虑使用库函数。

还有很重要的一点是,我们最好要理解库函数中的底层实现原理和它的复杂度,不然随意调用库函数可能会导致我们的算法效率增加

字符串结合双指针

这题虽然是字符串的题目,但是这里最适合使用双指针,实际上字符串也可以转化为数组,因此这里我们当然也可以采用双指针法解题,来看看双指针的代码

class Solution {
    public void reverseString(char[] s) {
        int len = 0,right = s.length-1;
        while (len<right){
            exch(s,len++,right--);
        }
    }
​
    private void exch(char[] s, int i, int i1) {
        char temp = 'a';
        temp = s[i];
        s[i] = s[i1];
        s[i1]= temp;
    }
}

没啥值得说的,双指针就完了。不过这里值得一提的是,我们这里还有通过位运算达成目的的版本的代码,不过这个内容就只做了解,有兴趣的自己去看看吧

class Solution {
    public void reverseString(char[] s) {
        int l = 0;
        int r = s.length - 1;
        while (l < r) {
            s[l] ^= s[r];  //构造 a ^ b 的结果,并放在 a 中
            s[r] ^= s[l];  //将 a ^ b 这一结果再 ^ b ,存入b中,此时 b = a, a = a ^ b
            s[l] ^= s[r];  //a ^ b 的结果再 ^ a ,存入 a 中,此时 b = a, a = b 完成交换
            l++;
            r--;
        }
    }
}

固定规律处理字符串

上面的反转字符串的题目并不难,那么现在我们来做做进阶的反转字符串的题目

上面的题目本身并不难,只是情况比较多。对于初学者而言,其最可能的情况是会创建很多的代码去用于判断或者是特殊处理一些情况,这种做法往往缝缝补补也能过,但是代码上就会非常难看,臃肿而且不易于维护

其实,我们注意观察本道题,我们会发现这题是要用相同的规律来处理字符串,那么对于这种需要用相同规律的题目,我们可以构建一个for循环,再加入一些判断条件防止越界,就可以完美达成我们的目的,让我们的代码变得美观。请看我们最终的题解代码

class Solution {
    public String reverseStr(String s, int k) {
        char[] chars = s.toCharArray();
        for (int i = 0; i < chars.length; i+=2*k) {
            int start = i;
            int end = Math.min(i + k, chars.length);
            while (start<end){
                exch(chars,start++,end--);
            }
        }
        return new String(chars);
    }
​
    private void exch(char[] chars, int i, int i1) {
        char c = ' ';
        i1--;
        c = chars[i];
        chars[i]=chars[i1];
        chars[i1]=c;
    }
}

这里我们总结做的两点是,我们每次循环让i前进2k个位置,并且我们每次定义end时都取边界和自定义的最小值,这样就可以有效防止越界异常

我们可以总结一点,当需要固定规律一段一段去处理字符串的时候,要想想在在for循环的表达式上做做文章。

利用StringBuffer实现常数空间解题

看下图,如果这题的条件仅此为止的话,那么这题的难度就太低了,于是我们这里加上另外一个条件,那就是我们必须在常数空间内实现本题

那么我们该怎么办呢?这里我们可以创建一个StringBuffer类,该类可以拼接字符串,我们先遍历一遍字符串,然后如果遇到一个空格,我们就令其拼接两个空格。然后将这个拼接的对象加在原来的位置上,接着定义两个指针,一个指针指向原来的字符串的末位,另一个指针指向拼接后的字符串的末位,然后令两个字符串一起往左前进,如果我们遍历到空格,就让最后位的指针加上%20,否则就加上原词,这样就可以实现原地修改我们的字符串了。请看代码

class Solution {
    public String replaceSpace(String s) {
        if(s.length() == 0){
            return s;
        }
        StringBuffer stringBuffer = new StringBuffer("");
        for (int i = 0; i < s.length(); i++) {
            if(s.charAt(i)==' '){
                stringBuffer.append("  ");
            }
        }
        int first = s.length()-1;
        s+=stringBuffer.toString();
        int secend = s.length()-1;
        if(first == secend){
            return s;
        }
        char[] chars = s.toCharArray();
        while (first >= 0){
            if(chars[first]==' '){
                chars[secend--]='0';
                chars[secend--]='2';
                chars[secend--]='%';
                first--;
            }else {
                chars[secend--]=chars[first];
                first--;
            }
        }
        return new String(chars);
    }
}

本题的内容重要的在于提供一个先用StringBuffer类来创造多余空间并与原字符串拼接之后使用双指针解题的思路,这个思路本身比较重要,也提醒我们要学会StringBuffer类的用法,其往往能用于字符串内容的解题,就像之前我们说的java中提供的反转方法,那也是在StringBuffer类中才有的,String类里是没有的,因此学习StringBuffer也是十分重要的

接下来我们来看看利用StringBuffer类的进阶题目实现

其实像上面的题目可能很多同学都会处于一种有思路但是无法实现的状态,实际做题的时候会觉得这里要考虑那里要考虑,删删改改最后也做不出来,其实像这种题目,我们可以分步解题,一步步解决问题,而不要一次就要吃成个大胖子,就非要追求最高效率,想一次遍历就顺便把多余空格也一并判断完,这样是行不通的。比如说,在本题中,我们可以先将字符串的空格给处理掉,这样得到一个比较容易处理的字符串,之后再实现我们的思路,将单词置换过来。先来看看下面的代码

class Solution {
    public String reverseWords(String s) {
        List<Character> list = new LinkedList<>();
        for (int i = 0; i < s.length(); i++) {
            if(list.isEmpty() && s.charAt(i)!=' '){
                list.add(s.charAt(i));
                continue;
            }
            if(list.isEmpty() && s.charAt(i)==' '){
                continue;
            }
            if(s.charAt(i)!=' ' || list.get(list.size()-1)!=' '){
                list.add(s.charAt(i));
            }
        }
        if(list.get(list.size()-1)==' '){
            list.remove(list.size()-1);
        }
        char[] chars = new char[list.size()];
        int index = 0;
        for (Character c:list) {
            chars[index++]=c;
        }
        int start = 0,end = 1;
        List<String> stringList = new LinkedList<>();
        String s1 = new String(chars);
        while (start<chars.length){
            if(chars[start]==' '){
                start++;
                continue;
            }
            if(end<chars.length && chars[end]!=' '){
                end++;
                continue;
            }
            int lastindex = end;
            String s2 = s1.substring(start,end);
            stringList.add(s2);
            start=++lastindex;
            end=start+1;
        }
        StringBuffer stringBuffer = new StringBuffer();
        for (int i = stringList.size()-1; i >= 0; i--) {
            stringBuffer.append(stringList.get(i));
            if(i!=0){
                stringBuffer.append(" ");
            }
        }
        return stringBuffer.toString();
    }
​
    private void exch(char[] chars, int i, int i1) {
        char c = ' ';
        c = chars[i];
        chars[i]=chars[i1];
        chars[i1]=c;
    }
}
​

这份代码就是按照这种思路来解题的,先将字符串进行处理,后续将字符串存放于集合中,然后通过字符串的拼接完成解题。但是,我们这里使用集合来帮助解题,如果我们想要达成使用常数空间解题的话,我们就应该采用StringBuffer类来进行解题,请看代码

class Solution {
    /**
     * 不使用Java内置方法实现
     * <p>
     * 1.去除首尾以及中间多余空格
     * 2.反转整个字符串
     * 3.反转各个单词
     */
    public String reverseWords(String s) {
        // System.out.println("ReverseWords.reverseWords2() called with: s = [" + s + "]");
        // 1.去除首尾以及中间多余空格
        StringBuilder sb = removeSpace(s);
        // 2.反转整个字符串
        reverseString(sb, 0, sb.length() - 1);
        // 3.反转各个单词
        reverseEachWord(sb);
        return sb.toString();
    }
​
    private StringBuilder removeSpace(String s) {
        // System.out.println("ReverseWords.removeSpace() called with: s = [" + s + "]");
        int start = 0;
        int end = s.length() - 1;
        while (s.charAt(start) == ' ') start++;
        while (s.charAt(end) == ' ') end--;
        StringBuilder sb = new StringBuilder();
        while (start <= end) {
            char c = s.charAt(start);
            if (c != ' ' || sb.charAt(sb.length() - 1) != ' ') {
                sb.append(c);
            }
            start++;
        }
        // System.out.println("ReverseWords.removeSpace returned: sb = [" + sb + "]");
        return sb;
    }
​
    /**
     * 反转字符串指定区间[start, end]的字符
     */
    public void reverseString(StringBuilder sb, int start, int end) {
        // System.out.println("ReverseWords.reverseString() called with: sb = [" + sb + "], start = [" + start + "], end = [" + end + "]");
        while (start < end) {
            char temp = sb.charAt(start);
            sb.setCharAt(start, sb.charAt(end));
            sb.setCharAt(end, temp);
            start++;
            end--;
        }
        // System.out.println("ReverseWords.reverseString returned: sb = [" + sb + "]");
    }
​
    private void reverseEachWord(StringBuilder sb) {
        int start = 0;
        int end = 1;
        int n = sb.length();
        while (start < n) {
            while (end < n && sb.charAt(end) != ' ') {
                end++;
            }
            reverseString(sb, start, end - 1);
            start = end + 1;
            end = start + 1;
        }
    }
}

上面的代码的整体思路如下图所示

对我们而言,就算我们想不到连续两次双指针反转单词最终构造我们所需要的字符串,最起码也该想得到用StringBuffer类来先处理字符串吧。而且最开始的两个循环组合去除所有字符串的多余空格的方法也值得学习

本题的重要思想是分步处理一个难题,面对难题不要想着一次解决所有问题,可以采取分步处理的方式解决,先解决一个问题,再解决下一个问题

StringBuffer类的进一步使用

这题我们同样要求要在常数空间内解题,因为如果不要求的话那这题就太太太太太太简单了,没有任何意义这样

那按照我们的想法,既然要用常数空间解题,那就不得不使用StrngBuffer类了,请看代码

class Solution {
    public String reverseLeftWords(String s, int n) {
        StringBuffer stringBuffer = new StringBuffer();
        for (int i = n; i < s.length(); i++) {
            stringBuffer.append(s.charAt(i));
        }
        for (int i = 0; i < n; i++) {
            stringBuffer.append(s.charAt(i));
        }
        return stringBuffer.toString();
    }
}

这个代码似乎没有问题,但是它的效率太差了,我们应该要对其进行改良。其实我们就更加好的做法,那就是我们上一节题目里学过的整体反转+局部的反转的方式,通过两次双指针的反转,我们可以直接获得我们所需要的字符串

我们容易观察到,如果我们先将整体反转一次,然后对限定范围的两个局部字符串都反转一次,最后获得字符串就是我们所需要的字符串,那我们只要按照这个思路来解题就可以了,请看代码

class Solution {
    public String reverseLeftWords(String s, int n) {
        StringBuffer stringBuffer = new StringBuffer(s);
        reserve(stringBuffer, 0, stringBuffer.length());
        reserve(stringBuffer, 0, stringBuffer.length() - n);
        reserve(stringBuffer, stringBuffer.length() - n, s.length());
        return stringBuffer.toString();
    }
​
    private void reserve(StringBuffer substring,int len,int end) {
        end--;
        while (len<end){
            char left = substring.charAt(len);
            char right = substring.charAt(end);
            substring.setCharAt(len++, right);
            substring.setCharAt(end--, left);
        }
    }
}

当然,我们也可以先反转局部,再反转整体,也能达成同样的效果

class Solution {
    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--;
        }
    }
}

最后有个悲哀的事情,那就是后面两个双指针方法的效率还没第一个嗯拼接的方法来得效率高,well,无所谓其实,只要我们能够理解到两次双指针反转字符串解题的思路就可以了

本题重要的思想是使用两次双指针结合反转整体和反转局部的方式来解题

利用KMP算法解题

对于这一题,我们可以使用双指针法进行判断,先遍历一遍字符串,然后利用双指针法判断子串是否为目标串,请看代码

class Solution {
    public int strStr(String haystack, String needle) {
        if(needle.length()>haystack.length()){
            return -1;
        }else if(haystack.length()==0 && needle.length()==0){
            return 0;
        }else if(needle.length()==0){
            return 0;
        }
        for (int i = 0; i < haystack.length(); i++) {
            if(haystack.charAt(i)==needle.charAt(0)){
                int start1 = i;
                int end1 = start1+needle.length()-1;
                if(end1>=haystack.length()){
                    return -1;
                }
                int start2 = 0;
                int end2 = needle.length()-1;
                boolean judge = true;
                while (start2<=end2){
                    if(haystack.charAt(start1++)!=needle.charAt(start2++)){
                        judge = false;
                        break;
                    }
                    if(haystack.charAt(end1--)!=needle.charAt(end2--)){
                        judge = false;
                        break;
                    }
                }
                if(judge){
                    return i;
                }
            }
        }
        return -1;
    }
}

效率上也还过得去,但本题其实是一个经典的KMP算法题,我们应该要使用KMP算法解开这题,那么当然,在此之前我们得先学习下什么是KMP算法,关于KMP算法,我们另外创建了一个文档用于说明,还没学习的快去看吧,这里我们就权当各位已经会KMP算法了

所以接下来我们使用KMP算法解开本题,请看代码

class Solution {
    public int strStr(String haystack, String needle) {
        if(needle.length()>haystack.length()){
            return -1;
        }
        if(needle.length()==0){
            return 0;
        }
        int[] prefix = new int[needle.length()];
        int j = 0;
        for (int i = 1; i < prefix.length; i++) {
            while (j>0 && needle.charAt(i)!=needle.charAt(j)){
                j=prefix[j-1];
            }
            if(needle.charAt(i)==needle.charAt(j)){
                j++;
            }
            prefix[i]=j;
        }
        int k = 0;
        for (int i = 0; i < haystack.length(); i++) {
            while (k>0 && haystack.charAt(i)!=needle.charAt(k)){
                k=prefix[k-1];
            }
            if(haystack.charAt(i)==needle.charAt(k)){
                k++;
            }
            if(k== prefix.length){
                return i- prefix.length+1;
            }
        }
        return -1;
    }
}

我们这里首先构造不含任何移位的前缀表,然后我们在一次遍历中采取类似于我们构建前缀表时的方式来遍历我们的目标字符串,每次遍历如果指定的字符与我们的模式串中的字符不相等的话我们就让我们的k不断往回走,直到其相等或者k为0,然后如果想着相等我们就让i和k一起前进,如果k能前进到与模式串相等的长度,就说明我们已经遍历完了,此时我们做对应的计算就可以返回模式串的起始下标

KMP算法的进阶使用

接下来我们来学习进一步使用KMP算法的题目,这题要稍微结合一些数学上的思想来做一个精妙的判断,请看题目

请看代码

class Solution {
    public boolean repeatedSubstringPattern(String s) {
        int[] next = new int[s.length()];
        int length = s.length();
        for (int i = 1,j = 0; i < next.length; i++) {
            while (j>0 && s.charAt(i)!=s.charAt(j)){
                j=next[j-1];
            }
            if(s.charAt(i)==s.charAt(j)){
                j++;
            }
            next[i]=j;
        }
        if(next[length-1]>0 && length % (length-next[length-1])==0){
            return true;
        }
        return false;
    }
}

构建前缀表的代码是没有什么问题了,但是我觉得各位同学们比较难理解的位置应该是在第14行到第16行,不理解为什么这样判断就可以确定其有能够构成的字符串,其实原理是因为数组长度减去最长相同前后缀的长度相当于是第一个周期的长度,也就是一个周期的长度,如果这个周期可以被整除,就说明整个数组就是这个周期的循环。所以我们可以用这个进行判断,当然,如果这个判断不成立,这么就说明不存在了

那么到此为止,我们字符串的例题就算是学完了,这里面值得学习的思想有很多,比如最简单的整体反转再加上局部反转,又或者是我们将困难题目先将其例子化为简单题目的思想,还或者是我们的用字符串倒序添加我们的对应字符来实现常数空间解题的方法,这里需要用到StringBuffer类,当然,这一节最有挑战性的事情当然是KMP算法了,前缀表的构建是必须要掌握的,实在理解不了,最起码也要先记住,后续我们还会回来继续学习它的。当然,前缀表如何使用也是很重要的一点