字串演算法
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個步驟操作即可:
- 首先將原字串分為兩個部分,即X:abc,Y:def;
- 將X反轉,X->X^T,即得:abc->cba;將Y反轉,Y->Y^T,即得:def->fed。
- 反轉上述步驟得到的結果字串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),達到了題目的要求。
舉一反三
- 連結串列翻轉。給出一個連結串列和一個數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,用程式實現。
- 編寫程式,在原字串中把字串尾部的m個字元移動到字串的頭部,要求:長度為n的字串操作時間複雜度為O(n),空間複雜度為O(1)。 例如,原字串為”Ilovebaofeng”,m=7,輸出結果為:”baofengIlove”。
- 單詞翻轉。輸入一個英文句子,翻轉句子中單詞的順序,但單詞內字元的順序不變,句子中單詞以空格符隔開。為簡單起見,標點符號和普通字母一樣處理。例如,輸入“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<str.length;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; i<val.length();i++){
char 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。
- 首先,我們固定第一個字元a,求後面兩個字元bc的排列
- 當兩個字元bc排列求好之後,我們把第一個字元a和後面的b交換,得到bac,接著我們固定第一個字元b,求後面兩個字元ac的排列
- 現在是把c放在第一個位置的時候了,但是記住前面我們已經把原先的第一個字元a和後面的b做了交換,為了保證這次c仍是和原先處在第一個位置的a交換,我們在拿c和第一個字元交換之前,先要把b和a交換回來。在交換b和a之後,再拿c和處於第一位置的a進行交換,得到cba。我們再次固定第一個字元c,求後面兩個字元b、a的排列
- 既然我們已經知道怎麼求三個字元的排列,那麼固定第一個字元之後求後面兩個字元的排列,就是典型的遞迴思路了
解法一 遞迴實現
從集合中依次選出每一個元素,作為排列的第一個元素,然後對剩餘的元素進行全排列,如此遞迴處理,從而得到所有元素的全排列。以對字串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)