字符串算法

1.1 旋转字符串

题目描述

给定一个字符串,要求把字符串前面的若干个字符移动到字符串的尾部,如把字符串“abcdef”前面的2个字符’a’和’b’移动到字符串的尾部,使得原字符串变成字符串“cdefab”。请写一个函数完成此功能,要求对长度为n的字符串操作的时间复杂度为 O(n),空间复杂度为 O(1)。

分析与解法

解法一:暴力移位法

初看此题,可能最先想到的方法是按照题目所要求的,把需要移动的字符一个一个地移动到字符串的尾部,如此我们可以实现一个函数LeftShiftOne(char* s, int n) ,以完成移动一个字符到字符串尾部的功能,代码如下所示:

/**
 * 将长度为 n 的数组 arr 中的首个字母放到最后
 */
public static void leftShiftOne(char[] arr, int n){
        char t = arr[0];
        for(int i = 1; i < n; i++){
            arr[i - 1] = arr[i];
        }
        arr[n - 1] = t;
}

因此,若要把字符串开头的m个字符移动到字符串的尾部,则可以如下操作:

/**
 * 将长度为 n 的数组 arr 中前 m 个元素放到尾部
 * abcde -> bcdeab
 */
public static void lefyShift(char[] arr, int n, int m){
    while (m-- != 0){
        leftShiftOne(arr, n);
    }
}
/*
abcde
cdeab
*/

下面,我们来分析一下这种方法的时间复杂度和空间复杂度。

针对长度为n的字符串来说,假设需要移动m个字符到字符串的尾部,那么总共需要 mn 次操作,同时设立一个变量保存第一个字符,如此,时间复杂度为O(m n),空间复杂度为O(1),空间复杂度符合题目要求,但时间复杂度不符合,所以,我们得需要寻找其他更好的办法来降低时间复杂度。

解法二:三步反转法

对于这个问题,换一个角度思考一下。

将一个字符串分成X和Y两个部分,在每部分字符串上定义反转操作,如X^T,即把X的所有字符反转(如,X=”abc”,那么X^T=”cba”),那么就得到下面的结论:(X^TY^T)^T=YX,显然就解决了字符串的反转问题。

例如,字符串 abcdef ,若要让def翻转到abc的前头,只要按照下述3个步骤操作即可:

  1. 首先将原字符串分为两个部分,即X:abc,Y:def;
  2. 将X反转,X->X^T,即得:abc->cba;将Y反转,Y->Y^T,即得:def->fed。
  3. 反转上述步骤得到的结果字符串X^TY^T,即反转字符串cbafed的两部分(cba和fed)给予反转,cbafed得到defabc,形式化表示为(X^TY^T)^T=YX,这就实现了整个反转。

代码则可以这么写:

public static void ReverseString(char[] s,int from,int to)
 {
     while (from < to)
     {
         char t = s[from];
         s[from++] = s[to];
         s[to--] = t;
     }
 }
public static void LeftRotateString(char[]s,int n,int m)
 {
     m %= n;               //若要左移动大于n位,那么和%n 是等价的
     ReverseString(s, 0, m - 1); //反转[0..m - 1],套用到上面举的例子中,就是X->X^T,即 abc->cba
     ReverseString(s, m, n - 1); //反转[m..n - 1],例如Y->Y^T,即 def->fed
     ReverseString(s, 0, n - 1); //反转[0..n - 1],即如整个反转,(X^TY^T)^T=YX,即 cbafed->defabc。
 }
public static void main(String[] args) {
    char[] arr = {'a','b','c','d','e'};
    System.out.println(arr);
    LeftRotateString(arr,5, 2);
    System.out.println(arr);
}
/*
abcde
cdeab
*/

这就是把字符串分为两个部分,先各自反转再整体反转的方法,时间复杂度为O(n),空间复杂度为O(1),达到了题目的要求。

举一反三

  1. 链表翻转。给出一个链表和一个数k,比如,链表为1→2→3→4→5→6,k=2,则翻转后2→1→6→5→4→3,若k=3,翻转后3→2→1→6→5→4,若k=4,翻转后4→3→2→1→6→5,用程序实现。
  2. 编写程序,在原字符串中把字符串尾部的m个字符移动到字符串的头部,要求:长度为n的字符串操作时间复杂度为O(n),空间复杂度为O(1)。 例如,原字符串为”Ilovebaofeng”,m=7,输出结果为:”baofengIlove”。
  3. 单词翻转。输入一个英文句子,翻转句子中单词的顺序,但单词内字符的顺序不变,句子中单词以空格符隔开。为简单起见,标点符号和普通字母一样处理。例如,输入“I am a student.”,则输出“student. a am I”。

1.2字符串包含

题目描述

给定两个分别由字母组成的字符串A和字符串B,字符串B的长度比字符串A短。请问,如何最快地判断字符串B中所有字母是否都在字符串A里?

为了简单起见,我们规定输入的字符串只包含大写英文字母,请实现函数bool StringContains(string &A, string &B)

比如,如果是下面两个字符串:
String 1:ABCD
String 2:BAD
答案是true,即String2里的字母在String1里也都有,或者说String2是String1的真子集。
如果是下面两个字符串:
String 1:ABCD
String 2:BCE
答案是false,因为字符串String2里的E字母不在字符串String1里。
同时,如果string1:ABCD,string 2:AA,同样返回true。

分析与解法

题目描述虽长,但题意很明了,就是给定一长一短的两个字符串A,B,假设A长B短,要求判断B是否包含在字符串A中。

初看似乎简单,但实现起来并不轻松,且如果面试官步步紧逼,一个一个否决你能想到的方法,要你给出更好、最好的方案时,恐怕就要伤不少脑筋了。

解法一

判断string2中的字符是否在string1中?最直观也是最简单的思路是,针对string2中每一个字符,逐个与string1中每个字符比较,看它是否在String1中。

代码可如下编写:

public static boolean stringContain(String s1, String s2){
    boolean flag = false;
    for (int i = 0; i < s2.length(); i++){
        char c = s2.charAt(i);
        for (int j = 0; j < s1.length(); j++){
            if (s1.charAt(j) == c)
                flag = true;
        }
        if (flag && i < s2.length()-1)
            flag = false;
    }
    return flag;
}

假设n是字符串String1的长度,m是字符串String2的长度,那么此算法,需要O(n*m)次操作。显然,时间开销太大,应该找到一种更好的办法。

解法二

如果允许排序的话,我们可以考虑下排序。比如可先对这两个字符串的字母进行排序,然后再同时对两个字串依次轮询。两个字串的排序需要(常规情况)O(m log m) + O(n log n)次操作,之后的线性扫描需要O(m+n)次操作。

关于排序方法,可采用最常用的快速排序,参考代码如下:

//注意A B中可能包含重复字符,所以注意A下标不要轻易移动。这种方法改变了字符串。如不想改变请自己复制
boolean StringContain(string a,string b)
{
    sort(a.begin(),a.end());
    sort(b.begin(),b.end());
    for (int pa = 0, pb = 0; pb < b.length();)
    {
        while ((pa < a.length()) && (a.charAt(pa) < b.charAt(pb)))
        {
            ++pa;
        }
        if ((pa >= a.length()) || (a.charAt(pa) > b.charAt(pb)))
        {
            return false;
        }
        ++pb;
    }
    return true;
}

解法三

事实上,可以先把长字符串a中的所有字符都放入一个Hashtable里,然后轮询短字符串b,看短字符串b的每个字符是否都在Hashtable里,如果都存在,说明长字符串a包含短字符串b,否则,说明不包含。

再进一步,我们可以对字符串A,用位运算(26bit整数表示)计算出一个“签名”,再用B中的字符到A里面进行查找。

// “最好的方法”,时间复杂度O(n + m),空间复杂度O(1)
boolean StringContain(string a,string b)
{
    int hash = 0;
    for (int i = 0; i < a.length(); i++)
    {
        hash |= (1 << (a.charAt(i) - 'A'));
    }
    for (int i = 0; i < b.length(); ++i)
    {
        if ((hash & (1 << (b.charAt(j) - 'A'))) == 0)
        {
            return false;
        }
    }
    return true;
}

这个方法的实质是用一个整数代替了hashtable,空间复杂度为O(1),时间复杂度还是O(n + m)。

1.3 字符串转换成整数

题目描述

输入一个由数字组成的字符串,把它转换成整数并输出。例如:输入字符串”123”,输出整数123。
给定函数原型int StrToInt(String str) ,实现字符串转换成整数的功能,不能使用库函数 Integer.parseInt(str);

分析与解法

本题考查的实际上就是字符串转换成整数的问题,或者说是要你自行实现atoi函数。那如何实现把表示整数的字符串正确地转换成整数呢?以”123”作为例子:

  • 当我们扫描到字符串的第一个字符’1’时,由于我们知道这是第一位,所以得到数字1。
  • 当扫描到第二个数字’2’时,而之前我们知道前面有一个1,所以便在后面加上一个数字2,那前面的1相当于10,因此得到数字:1*10+2=12。
  • 继续扫描到字符’3’,’3’的前面已经有了12,由于前面的12相当于120,加上后面扫描到的3,最终得到的数是:12*10+3=123。

因此,此题的基本思路便是:从左至右扫描字符串,把之前得到的数字乘以10,再加上当前字符表示的数字。

思路有了,你可能不假思索,写下如下代码:

int StrToInt(String str)
{
    int n = 0;
    int m = str.length();
    int i = 0;
    while (i != m)
    {
        int c = str.charAt(i) - '0';
        n = n * 10 + c;
        i++;
    }
    return n;
}

显然,上述代码忽略了以下细节:

  • 空指针输入:输入的是指针,在访问空指针时程序会崩溃,因此在使用指针之前需要先判断指针是否为空。
  • 正负符号:整数不仅包含数字,还有可能是以’+’或’-‘开头表示正负整数,因此如果第一个字符是’-‘号,则要把得到的整数转换成负整数。
  • 非法字符:输入的字符串中可能含有不是数字的字符。因此,每当碰到这些非法的字符,程序应停止转换。
  • 整型溢出:输入的数字是以字符串的形式输入,因此输入一个很长的字符串将可能导致溢出。

上述其它问题比较好处理,但溢出问题比较麻烦,所以咱们来重点看下溢出问题。

一般说来,当发生溢出时,取最大或最小的int值。即大于正整数能表示的范围时返回MAX_INT:2147483647 小于负整数能表示的范围时返回MIN_INT:-2147483648。

我们先设置一些变量:

  • sign用来处理数字的正负,当为正时sign > 0,当为负时sign < 0
  • n存放最终转换后的结果
  • c表示当前数字

而后,你可能会编写如下代码段处理溢出问题:

//当发生正溢出时,返回INT_MAX
if ((sign == '+') && (c > MAX_INT - n * 10))
{
    n = MAX_INT;
    break;
}
//发生负溢出时,返回INT_MIN
else if ((sign == '-') && (c - 1 > MAX_INT - n * 10))
{
    n = MIN_INT;
    break;
}

但当上述代码转换” 10522545459”会出错,因为正常的话理应得到MAX_INT:2147483647,但程序运行结果将会是:1932610867。

为什么呢?因为当给定字符串” 10522545459”时,而MAX_INT是2147483647,即MAX_INT(2147483647) < n10(1052254545\10),所以当扫描到最后一个字符‘9’的时候,执行上面的这行代码:

c > MAX_INT - n * 10
//*10以后 n 已经放不下这个数字了

已无意义,因为此时(MAX_INT - n * 10)已经小于0,程序已经出错。

针对这种由于输入了一个很大的数字转换之后会超过能够表示的最大的整数而导致的溢出情况,我们有两种处理方式可以选择:

  • 一个取巧的方式是把转换后返回的值n定义成long long,即long long n;
  • 另外一种则是只比较n和MAX_INT / 10的大小,即:
    • 若n > MAX_INT / 10,那么说明最后一步转换时,n*10必定大于MAX_INT,所以在得知n > MAX_INT / 10时,当即返回MAX_INT。
    • 若n == MAX_INT / 10时,那么比较最后一个数字c跟MAX_INT % 10的大小,即如果n == MAX_INT /10且c > MAX_INT % 10,则照样返回MAX_INT。

一直以来,我们努力的目的归根结底是为了更好的处理溢出,但上述第二种处理方式考虑到直接计算n*10 + c 可能会大于MAX_INT导致溢出,那么便两边同时除以10,只比较n和MAX_INT / 10的大小,从而巧妙的规避了计算n*10这一乘法步骤,转换成计算除法MAX_INT/10代替,不能不说此法颇妙。

如此我们可以写出正确的处理溢出的代码:

c = str.charAt(i) - '0';
if (sign > 0 && (n > MAX_INT / 10 || (n == MAX_INT / 10 && c > MAX_INT % 10)))
{
    n = MAX_INT;
    break;
}
else if (sign < 0 && (n > Math.abs(MIN_INT) / 10 || (n == Math.abs(MIN_INT) / 10 && c > Math.abs(MIN_INT) % 10)))
{
    n = MIN_INT;
    break;
}

从而,字符串转换成整数,完整的参考代码为:

int StrToInt(String str)
{
    static final int MAX_INT = Integer.MAX_VALUE;
    static final int MIN_INT = Integer.MIN_VALUE;
    int n = 0;

    //判断是否输入为空
    if (str.isEmpty())
    {
        return 0;
    }

    //处理空格
    str = trimStr(str);

    //处理正负
    int sign = 1;
    if (str.charAt(i) == '+' || str.charAt(i) == '-')
    {
        if (str.charAt(i) == '-')
            sign = -1;
        str = str.subString(1);//去除符号位,心里有B数
    }

    //确定是数字后才执行循环
    for(int i=0;i//处理溢出
        int c = str。charAt(i) - '0';
        if (sign > 0 && (n > MAX_INT / 10 || (n == MAX_INT / 10 && c > MAX_INT % 10)))
        {
            n = MAX_INT;
            break;
        }
        else if (sign < 0 && (n >(unsigned)MIN_INT / 10 || (n == (unsigned)MIN_INT / 10 && c > (unsigned)MIN_INT % 10)))
        {
            n = MIN_INT;
            break;
        }

        //把之前得到的数字乘以10,再加上当前字符表示的数字。
        n = n * 10 + c;
        i++;
    }
    return sign > 0 ? n : -n;
}

 public String trimStr(String str){
     String val = str.trim();
     StringBuilder sb = new StringBuilder();
     String s2="";
     for (int i =0; ichar t = val.charAt(i);
         if(t != ' ')
         sb.append(t);
     }
     return s2;
 }

1.4 回文判断

题目描述

回文,英文palindrome,指一个顺着读和反过来读都一样的字符串,比如madam、我爱我,这样的短句在智力性、趣味性和艺术性上都颇有特色,中国历史上还有很多有趣的回文诗。
那么,我们的第一个问题就是:判断一个字串是否是回文?

分析与解法

回文判断是一类典型的问题,尤其是与字符串结合后呈现出多姿多彩,在实际中使用也比较广泛,而且也是面试题中的常客,所以本节就结合几个典型的例子来体味下回文之趣。

解法一

同时从字符串头尾开始向中间扫描字串,如果所有字符都一样,那么这个字串就是一个回文。采用这种方法的话,我们只需要维护头部和尾部两个扫描指针即可。

代码如下:

public static boolean IsPalindrome(String str)
{
    //n 为字符串长度
    int n = str.length();
    // 非法输入
    if (str == null || n < 1)
    {
        return false;
    }
    int front,back;

    char[] s = str.toCharArray();

    // 初始化头指针和尾指针
    front = 0;
    back = n-1;

    while (front < back)
    {
        if (s[front] != s[back])
        {
            return false;
        }
        ++front;
        --back;
    }
    return true;
}

这是一个直白且效率不错的实现,时间复杂度:O(n),空间复杂度:O(1)。

解法二

上述解法一从两头向中间扫描,那么是否还有其它办法呢?我们可以先从中间开始、然后向两边扩展查看字符是否相等。参考代码如下:

public static boolean IsPalindrome(String str)
{
    //n 为字符串长度
    int n = str.length();
    // 非法输入
    if (str == null || n < 1)
    {
        return false;
    }
    int front,back;

    char[] s = str.toCharArray();

    // 初始化头指针和尾指针 定位到中间
    front = n / 2;
    back = (n%2==0)?n/2+1:n/2+2;

    while (front < back)
    {
        if (s[front] != s[back])
        {
            return false;
        }
        ++front;
        --back;
    }
    return true;
}

时间复杂度:O(n),空间复杂度:O(1)。

虽然本解法二的时空复杂度和解法一是一样的,但很快我们会看到,在某些回文问题里面,这个方法有着自己的独到之处,可以方便的解决一类问题。

举一反三

1、判断一条单向链表是不是“回文”
分析:对于单链表结构,可以用两个指针从两端或者中间遍历并判断对应字符是否相等。但这里的关键就是如何朝两个方向遍历。由于单链表是单向的,所以要向两个方向遍历的话,可以采取经典的快慢指针的方法,即先定位到链表的中间位置,再将链表的后半逆置,最后用两个指针同时从链表头部和中间开始同时遍历并比较即可。
2、判断一个栈是不是“回文”
分析:对于栈的话,只需要将字符串全部压入栈,然后依次将各字符出栈,这样得到的就是原字符串的逆置串,分别和原字符串各个字符比较,就可以判断了。

1.5 最长回文子串

题目描述

给定一个字符串,求它的最长回文子串的长度。

分析与解法

最容易想到的办法是枚举所有的子串,分别判断其是否为回文。这个思路初看起来是正确的,但却做了很多无用功,如果一个长的子串包含另一个短一些的子串,那么对子串的回文判断其实是不需要的。

解法一

那么如何高效的进行判断呢?我们想想,如果一段字符串是回文,那么以某个字符为中心的前缀和后缀都是相同的,例如以一段回文串“aba”为例,以b为中心,它的前缀和后缀都是相同的,都是a。

那么,我们是否可以可以枚举中心位置,然后再在该位置上用扩展法,记录并更新得到的最长的回文长度呢?答案是肯定的,参考代码如下:

public class Str {
    public static void main(String[] args) {
        System.out.println(LongestPalindrome("ccabcdedcbas"));
        //9
    }
    public static int LongestPalindrome(String str)
    {
        int n = str.length();
        if (str == null || n < 1)
            return 0;

        char[] s = str.toCharArray();
        int i, j, max,c;
        c = 0;
        max = 0;

        for (i = 0; i < n; ++i) { // i is the middle point of the palindrome
            for (j = 0; (i - j >= 0) && (i + j < n); ++j){ // if the length of the palindrome is odd
                if (s[i - j] != s[i + j])
                    break;
                c = j * 2 + 1;
            }
            if (c > max)
                max = c;

            for (j = 0; (i - j >= 0) && (i + j + 1 < n); ++j){ // for the even0 case
                if (s[i - j] != s[i + j + 1])
                    break;
                c = j * 2 + 2;
            }
            if (c > max)
                max = c;
        }
        return max;
    }
}

代码稍微难懂一点的地方就是内层的两个 for 循环,它们分别对于以 i 为中心的,长度为奇数和偶数的两种情况,整个代码遍历中心位置 i 并以之扩展,找出最长的回文。

1.6 字符串的全排列

题目描述

输入一个字符串,打印出该字符串中字符的所有排列。
例如输入字符串abc,则输出由字符a、b、c 所能排列出来的所有字符串
abc、acb、bac、bca、cab 和 cba。

分析与解法

输入一个字符串,打印出该字符串中字符的所有排列。
例如输入字符串abc,则输出由字符a、b、c 所能排列出来的所有字符串
abc、acb、bac、bca、cab 和 cba。

  1. 首先,我们固定第一个字符a,求后面两个字符bc的排列
  2. 当两个字符bc排列求好之后,我们把第一个字符a和后面的b交换,得到bac,接着我们固定第一个字符b,求后面两个字符ac的排列
  3. 现在是把c放在第一个位置的时候了,但是记住前面我们已经把原先的第一个字符a和后面的b做了交换,为了保证这次c仍是和原先处在第一个位置的a交换,我们在拿c和第一个字符交换之前,先要把b和a交换回来。在交换b和a之后,再拿c和处于第一位置的a进行交换,得到cba。我们再次固定第一个字符c,求后面两个字符b、a的排列
  4. 既然我们已经知道怎么求三个字符的排列,那么固定第一个字符之后求后面两个字符的排列,就是典型的递归思路了

解法一 递归实现

从集合中依次选出每一个元素,作为排列的第一个元素,然后对剩余的元素进行全排列,如此递归处理,从而得到所有元素的全排列。以对字符串abc进行全排列为例,我们可以这么做:以abc为例

  • 固定a,求后面bc的排列:abc,acb,求好后,a和b交换,得到bac
  • 固定b,求后面ac的排列:bac,bca,求好后,c放到第一位置,得到cba
  • 固定c,求后面ba的排列:cba,cab。

代码可如下编写所示:

public static void CalcAllPermutation(char[] perm, int from, int to){
    if (to <= 1)
        return;

    if (from == to)
        for (int i = 0; i <= to; i++)
            System.out.println(perm[i]);

    else{
        for (int j = from; j <= to; j++) {
            swap(perm, j, from);
            CalcAllPermutation(perm, from + 1, to);
            swap(perm, j, from);
        }
    }
}

public static void swap(char c[], int a, int b) {
    char t = c[a];
    c[a] = c[b];
    c[b] = t;
}
public static void main(String[] args) {
    CalcAllPermutation("abc".toCharArray(),0,2);
}

解法总结

由于全排列总共有n!种排列情况,解法一中的递归方法,复杂度都为O(n!)。

类似问题

1、已知字符串里的字符是互不相同的,现在任意组合,比如ab,则输出aa,ab,ba,bb,编程按照字典序输出所有的组合。
分析:非简单的全排列问题(跟全排列的形式不同,abc全排列的话,只有6个不同的输出)。 本题可用递归的思想,设置一个变量表示已输出的个数,然后当个数达到字符串长度时,就输出。

public static void perm(char[] result, char []str, int size, int resPos){
    if(resPos == size)
      System.out.println(result);
    else{
      for(int i = 0; i < size; ++i){
          result[resPos] = str[i];
          perm(result, str, size, resPos + 1);
        }
    }
}

2、如果不是求字符的所有排列,而是求字符的所有组合,应该怎么办呢?当输入的字符串中含有相同的字符串时,相同的字符交换位置是不同的排列,但是同一个组合。举个例子,如果输入abc,它的组合有a、b、c、ab、ac、bc、abc。
3、写一个程序,打印出以下的序列。
(a),(b),(c),(d),(e)……..(z)
(a,b),(a,c),(a,d),(a,e)……(a,z),(b,c),(b,d)…..(b,z),(c,d)…..(y,z)
(a,b,c),(a,b,d)….(a,b,z),(a,c,d)….(x,y,z)
….

(a,b,c,d,…..x,y,z)

你可能感兴趣的:(算法)