這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在CSDN。現整理為一個系列給需要的朋友參考,如有錯誤,歡迎指正。本系列完整程式碼地址在 這裡。
0 概述
字串作為資料結構中的基礎內容,也是面試中經常會考察的基本功之一,比如實現 strcpy,strcmp等基本函式等,迴文字串,字串搜尋,正規表示式等。本文相關程式碼見 這裡。
1 基本操作
首先來看一些字串的基本函式的實現,以下程式碼取自MIT6.828課程。
// 字串長度
int strlen(const char *s)
{
int n;
for (n = 0; *s != '\0'; s++)
n++;
return n;
}
// 字串複製
char *strcpy(char *dst, const char *src)
{
char *ret;
ret = dst;
while ((*dst++ = *src++) != '\0')
/* do nothing */;
return ret;
}
// 字串拼接
char *strcat(char *dst, const char *src)
{
int len = strlen(dst);
strcpy(dst + len, src);
return dst;
}
// 字串比較
int strcmp(const char *p, const char *q)
{
while (*p && *p == *q)
p++, q++;
return (int) ((unsigned char) *p - (unsigned char) *q);
}
// 返回字串s中第一次出現c的位置
char *strchr(const char *s, char c)
{
for (; *s; s++)
if (*s == c)
return (char *) s;
return 0;
}
// 設定記憶體位置v開始的n個元素值為c
void *memset(void *v, int c, size_t n)
{
char *p;
int m;
p = v;
m = n;
while (--m >= 0)
*p++ = c;
return v;
}
// 記憶體拷貝,注意覆蓋情況
void *memmove(void *dst, const void *src, size_t n)
{
const char *s;
char *d;
s = src;
d = dst;
if (s < d && s + n > d) {
s += n;
d += n;
while (n-- > 0)
*--d = *--s;
} else
while (n-- > 0)
*d++ = *s++;
return dst;
}
複製程式碼
2 字串相關面試題
2.1 最長迴文子串
題: 給定一個字串,找出該字串的最長迴文子串。迴文字串指的就是從左右兩邊看都一樣的字串,如aba
,cddc
都是迴文字串。字串 abbacdc
存在的迴文子串有 abba
和 cdc
,因此它的最長迴文子串為abba。
一個容易犯的錯誤
初看這個問題可能想到這樣的方法:對字串S逆序得到新的字串S',再求S和S'的最長公共子串,這樣求出的就是最長迴文子串。
- 如
S = caba
,S' = abac
,則S和S'的最長公共子串為aba
,這個是正確的。 - 但是如果
S = abacdfgdcaba
,S’ = abacdgfdcaba
,則S和S'的最長公共子串為abacd
,顯然這不是迴文字串。因此這種方法是錯誤的。
判定一個字串是否是迴文字串
要找出最長迴文子串,首先要解決判斷一個字串是否是迴文字串的問題。最顯而易見的方法是設定兩個變數i和j,分別指向字串首部和尾部,比較是否相等,然後 i++,j--
,直到 i >= j
為止。下面的程式碼是判斷字串 str[i, j]
是不是迴文字串,即字串str從i到j的這一段子串是否是迴文字串,在後面會用到這個方法。
/**
* 判斷字串s[start:end]是否是迴文字串
*/
int isPalindrome(string s, int start, int end)
{
for (; start < end; ++start,--end) {
if (s[start] != s[end])
return 0;
}
return 1;
}
複製程式碼
解1:蠻力法求最長子串
蠻力法通過對字串所有子串進行判斷,如果是迴文字串,則更新最長迴文的長度。因為長度為N的字串的子串一共可能有 (1+N)*N/2
個,每次判斷子串需要 O(N)
的時間,所以一共需要 O(N^3)
時間求最長迴文子串。
/**
* 最長迴文子串-蠻力法 O(N^3)
*/
string longestPalindrome(string s)
{
int len = s.length(), maxLen = 1;
int start=0, i, j;
/*遍歷字串所有的子串,若子串為迴文字串則更新最長迴文的長度*/
for (i = 0; i < len - 1; i++) {
for (j = i + 1; j < len; j++) {
if (isPalindrome(s, i, j)) { //如果str[i,j]是迴文,則判斷其長度是否大於最大值,大於則更新長度和位置
int pLen = j - i + 1;
if (pLen > maxLen) {
start = i; //更新最長迴文起始位置
maxLen = pLen; //更新最長迴文的長度
}
}
}
}
return s.substr(start, maxLen);
}
複製程式碼
解2:動態規劃法
因為蠻力法判定迴文的時候需要很多重複的計算,所以可以通過動態規劃法來改進該演算法。假定我們知道“bab”是迴文,則“ababa”也一定是迴文。
定義P[i, j] = true 如果子串P[i, j]是迴文字串。
則 P[i, j] <- (P[i+1, j-1] && s[i] = s[j])。
Base Case:
P[i, i ] = true
P[i, i+1 ] = true <- s[i] = s[i+1]
複製程式碼
據此,實現程式碼如下:
/**
* 最長迴文子串-動態規劃法,該方法的時間複雜度為O(N^2),空間複雜度為O(N^2)。
*/
/**
* 最長迴文子串-動態規劃法,該方法的時間複雜度為O(N^2),空間複雜度為O(N^2)。
*
* 思想:定義P[i, j] = 1 如果子串P[i, j]是迴文字串。
* 則 P[i, j] <- (P[i+1, j-1] && s[i] == s[j])。
*
* Base Case:
* P[ i, i ] <- 1
* P[ i, i+1 ] <- s[i] == s[i+1]
*/
string longestPalindromeDP(string s)
{
int n = s.length();
int longestBegin = 0, maxLen = 1;
int **P;
int i;
/*構造二維陣列P*/
P = (int **)calloc(n, sizeof(int *));
for (i = 0; i < n; i++) {
P[i] = (int *)calloc(n, sizeof(int));
}
for (i = 0; i < n; i++) {
P[i][i] = 1;
}
for (int i=0; i<n-1; i++) {
if (s[i] == s[i+1]) {
P[i][i+1] = 1;
longestBegin = i;
maxLen = 2;
}
}
/*依次求P[i][i+2]...P[i][i+n-1]等*/
int len = 3;
for (; len <= n; ++len) {
for (i = 0; i < n-len+1; ++i) {
int j = i + len - 1;
if (s[i] == s[j] && P[i+1][j-1]) {
P[i][j] = 1;
longestBegin = i;
maxLen = len;
}
}
}
/*釋放記憶體*/
for (i = 0; i< n; i++)
free(P[i]);
free(P);
return s.substr(longestBegin, maxLen);
}
複製程式碼
解3:中心法
還有一個更簡單的方法可以使用 O(N^2)
時間、不需要額外的空間求最長迴文子串。我們知道迴文字串是以字串中心對稱的,如 abba
以及 aba
等。一個更好的辦法是從中間開始判斷,因為迴文字串以字串中心對稱。一個長度為N的字串可能的對稱中心有2N-1個,至於這裡為什麼是2N-1而不是N個,是因為可能對稱的點可能是兩個字元之間,比如abba的對稱點就是第一個字母b和第二個字母b的中間。據此實現程式碼如下:
/**
* 求位置l為中心的最長迴文子串的開始位置和長度
*/
void expandAroundCenter(string s, int l, int r, int *longestBegin, int *longestLen)
{
int n = s.length();
while (l>=0 && r<=n-1 && s[l]==s[r]) {
l--, r++;
}
*longestBegin = l + 1;
*longestLen = r - l - 1;
}
/**
* 最長迴文子串-中心法,時間O(N^2)。
*/
string longestPalindromeCenter(string s)
{
int n = s.length();
if (n == 0)
return s;
char longestBegin = 0;
int longestLen = 1;
for (int i = 0; i < n; i++) {
int iLongestBegin, iLongestLen;
expandAroundCenter(s, i, i, &iLongestBegin, &iLongestLen); //以位置i為中心的最長迴文字串
if (iLongestLen > longestLen) {
longestLen = iLongestLen;
longestBegin = iLongestBegin;
}
expandAroundCenter(s, i, i+1, &iLongestBegin, &iLongestLen); //以i和i+1之間的位置為中心的最長迴文字串
if (iLongestLen > longestLen) {
longestLen = iLongestLen;
longestBegin = iLongestBegin;
}
}
return s.substr(longestBegin, longestLen);
}
複製程式碼
2.2 交換排序
題: 已知一個字元陣列,其中儲存有R、G、B
字元,要求將所有的字元按照 RGB
的順序進行排序。比如給定一個陣列為 char s[] = "RGBBRGGBGB"
,則排序後應該為 RRGGGGBBBB
。
解1: 這個題目有點類似於快速排序中用到的劃分陣列的方法,但是這裡有三個字元,因此需要呼叫劃分方法兩次,第一次以 B
劃分,第二次以 G
劃分,這樣兩次劃分後就可以將原來的字元陣列劃分成RGB
順序。這個方法比較自然,容易想到,程式碼如下。這個方法的缺點是需要遍歷兩遍陣列。
void swapChar(char *s, int i, int j)
{
char temp = s[i];
s[i] = s[j];
s[j] = temp;
}
/**
* 劃分函式
*/
void partition(char *s, int lo, int hi, char t)
{
int m = lo-1, i;
for (i = lo; i <= hi; i++) {
if (s[i] != t) {
swapChar(s, ++m ,i);
}
}
}
/**
* RGB排序-遍歷兩次
*/
void rgbSortTwice(char *s)
{
int len = strlen(s);
partition(s, 0, len-1, 'G'); // 以G劃分,劃分完為 RBBRBBGGGG
partition(s, 0, len-1, 'B'); // 再以B劃分,劃分完為 RRGGGGBBBB
}
複製程式碼
解2: 其實還有一個只需要遍歷一遍陣列的方法,當然該方法雖然只遍歷一遍陣列,但是需要交換的次數並未減少。主要是設定兩個變數r和g分別指示當前R和G字元所在的位置,遍歷陣列。
-
1)如果第i個位置為字元R,則與前面的指示變數r的後一個字元也就是++r處的字元交換,並++g,此時還需要判斷交換後的i裡面儲存的字元是否是G,如果是G,則需要將其與g處的字元交換;
-
2)如果第i個位置為字元G,則將其與++g處的字元交換即可。++g指向的總是下一個應該交換G的位置,++r指向的是下一個需要交換R的位置。
-
3)如果第i個位置為字元B,則什麼都不做,繼續遍歷。
/**
* RGB排序-遍歷一次
*/
void rgbSortOnce(char *s)
{
int len = strlen(s);
int lo = 0, hi = len - 1;
int r, g, i; //++r和++g分別指向R和G交換的位置
r = g = lo - 1;
for (i = lo; i <= hi; i++) {
if (s[i] == 'R') { // 遇到R
swapChar(s, ++r, i);
++g;
if (s[i] == 'G') // 交換後的值是G,繼續交換
swapChar(s, g, i);
} else if (s[i] == 'G') { // 遇到G
swapChar(s, ++g, i);
} else { // 遇到B,什麼都不做
}
}
}
複製程式碼
解3: 如果不考慮用交換的思想,可以直接統計RGB各個字元的個數,然後從頭開始對陣列重新賦值為RGB即可。那樣簡單多了,哈哈。但是如果換一個題,要求是對正數、負數、0按照一定順序排列,那就必須用交換了。
2.3 最大滑動視窗
題: 給定一個陣列A,有一個大小為w的滑動視窗,該滑動視窗從最左邊滑到最後邊。在該視窗中你只能看到w個數字,每次只能移動一個位置。我們的目的是找到每個視窗w個數字中的最大值,並將這些最大值儲存在陣列B中。
例如陣列 A = [1 3 -1 -3 5 3 6 7]
, 視窗大小 w = 3
。則視窗滑動過程如下所示:
Window position Max
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
輸入: 陣列A和w大小
輸出: 陣列B,其中B[i]儲存了A[i]到A[i+w-1]中w個數字的最大值。
複製程式碼
解1:簡單實現
一個最簡單的想法就是每次移動都計算 w 個數字的最大值並儲存起來,每次計算 w 個數字的最大值需要 O(w)
的時間,而滑動過程需要滑動 n-w+1
次,n為陣列大小,因此總共的時間為 O(nw)
。
/*
* 求陣列最大值
*/
int maxInArray(int A[], int n)
{
int max = A[0], i;
for (i = 1; i < n; i++) {
if (A[i] > max) {
max = A[i];
}
}
return max;
}
/*
* 最大滑動視窗-簡單實現
*/
void maxSlidingWindowSimple(int A[], int n, int w, int B[])
{
int i;
for (i = 0; i <= n-w; i++)
B[i] = maxInArray(A + i, w);
}
複製程式碼
解2:最大堆解法
第1個方法思路簡單,但是時間複雜度過高,因此需要改進。可以使用一個最大堆來儲存w個數字,每次插入數字時只需要 O(lgw)
的時間,從堆中取最大值只需要 O(1)
的時間(堆的平均大小約為 w)。隨著視窗由左向右滑動,因此堆中有些數字會失效(因為它們不再包含在視窗中)。如果陣列本身有序,則堆大小會增大到n。因為堆大小並不保持在w不變,因此該演算法時間複雜度為 O(nlgn)
。
/**
* 最大滑動視窗-最大堆解法
*/
void maxSlidingWindowPQ(int A[], int n, int w, int B[])
{
typedef pair<int, int> Pair;
priority_queue<Pair> Q; //優先順序佇列儲存視窗裡面的值
for (int i = 0; i < w; i++)
Q.push(Pair(A[i], i)); //構建w個元素的最大堆
for (int i = w; i < n; i++) {
Pair p = Q.top();
B[i-w] = p.first;
while (p.second <= i-w) {
Q.pop();
p = Q.top();
}
Q.push(Pair(A[i], i));
}
B[n-w] = Q.top().first;
}
複製程式碼
解3:雙向佇列解法
最大堆解法在堆中儲存有冗餘的元素,比如原來堆中元素為 [10 5 3]
,新的元素為11,則此時堆中會儲存有 [11 5 3]
。其實此時我們可以清空整個佇列,然後再將11加入到佇列即可,即只在佇列中保持 [11]
。使用雙向佇列可以滿足要求,滑動視窗的最大值總是儲存在佇列首部,佇列裡面的資料總是從大到小排列。當遇到比當前滑動視窗最大值更大的值時,則將佇列清空,並將新的最大值插入到佇列中。如果遇到的值比當前最大值小,則直接插入到佇列尾部。每次移動的時候需要判斷當前的最大值是否在有效範圍,如果不在,則需要將其從佇列中刪除。由於每個元素最多進隊和出隊各一次,因此該演算法時間複雜度為O(N)。
/**
* 最大滑動視窗-雙向佇列解法
*/
void maxSlidingWindowDQ(int A[], int n, int w, int B[])
{
deque<int> Q;
for (int i = 0; i < w; i++) {
while (!Q.empty() && A[i] >= A[Q.back()])
Q.pop_back();
Q.push_back(i);
}
for (int i = w; i < n; i++) {
B[i-w] = A[Q.front()];
while (!Q.empty() && A[i] >= A[Q.back()])
Q.pop_back();
while (!Q.empty() && Q.front() <= i-w)
Q.pop_front();
Q.push_back(i);
}
B[n-w] = A[Q.front()];
}
複製程式碼
2.4 最長公共子序列
題: 給定兩個序列 X = < x1, x2, ..., xm > 和 Y = < y1, y2, ..., ym >,希望找出X和Y最大長度的公共子序列(LCS)。
分析: 解決LCS的最簡單的是使用蠻力法,窮舉 X
的所有子序列,然後逐一檢查是否是 Y
的子序列,並記錄發現的最長子序列,最終取最大的子序列即可。但是 X
所有子序列有 2^m
,該方法需要指數級時間,不太切實際,然而LCS問題其實具有最優子結構性質。
LCS最優子結構:
如 X = <A, B, C, B, D, A, B>
, Y = <B, D, C, A, B, A>
,則 X 和 Y 的最長公共子序列為 <B, C, B, A>
或者 <B, D, A, B>
。也就是說,LCS可能存在多個。
設 X = < x1, x2, ..., xm > 和 Y = < y1, y2, ..., yn > 為兩個序列,並設 Z = < z1, z2, ..., zk > 為 X 和 Y 的任意一個LCS。
-
- 如果 xm = yn,那麼 zk = xm = yn,且 Zk-1 是 Xm-1 和 Yn-1 的一個LCS。
-
- 如果 xm != yn,那麼 zk != xm,且 Z 是 Xm-1 和 Y 的一個LCS。
-
- 如果 xm != yn,那麼 zk != yn,且 Z 是 X 和 Yn-1 的一個LCS。
因此,我們可以定義 c[i, j]
為序列 Xi 和 Yj 的一個LCS的長度,則可以得到下面的遞迴式:
c[i, j] = 0 // i = 0 或者 j = 0
c[i, j] = c[i-1, j-1] + 1 // i,j > 0,且 Xi = Yj
c[i, j] = max(c[i-1, j], c[i][j-1]) // i, j > 0,且 Xi != Yj
複製程式碼
據此可以寫出如下程式碼求LCS的長度及LCS,使用一個輔助陣列 b 儲存 LCS 路徑。這裡給出遞迴演算法求LCS長度,使用動態規劃演算法的程式碼見本文原始碼。
/**
* LCS-遞迴演算法
*/
#define UP 1
#define LEFT 2
#define UPLEFT 3
int lcsLengthRecur(char *X, int m, char *Y, int n, int **b)
{
if (m == 0 || n == 0)
return 0;
if (X[m-1] == Y[n-1]) {
b[m][n] = UPLEFT;
return lcsLengthRecur(X, m-1, Y, n-1, b) + 1;
}
int len1 = lcsLengthRecur(X, m-1, Y, n, b);
int len2 = lcsLengthRecur(X, m, Y, n-1, b);
int maxLen;
if (len1 >= len2) {
maxLen = len1;
b[m][n] = UP;
} else {
maxLen = len2;
b[m][n] = LEFT;
}
return maxLen;
}
/**
* 列印LCS,用到輔助陣列b
*/
void printLCS(int **b, char *X, int i, int j)
{
if (i == 0 || j == 0)
return;
if (b[i][j] == UPLEFT) {
printLCS(b, X, i-1, j-1);
printf("%c ", X[i-1]);
} else if (b[i][j] == UP) {
printLCS(b, X, i-1, j);
} else {
printLCS(b, X, i, j-1);
}
}
複製程式碼
列印LCS的流程如下圖所示(圖取自演算法導論):
2.5 字串全排列
題: 給一個字元陣列 char arr[] = "abc"
,輸出該陣列中字元的全排列。
解: 使用遞迴來輸出全排列。首先明確的是 perm(arr, k, len)
函式的功能:輸出字元陣列 arr
從位置 k
開始的所有排列,陣列長度為 len
。基礎條件是 k == len-1
,此時已經到達最後一個元素,一次排列已經完成,直接輸出。否則,從位置k開始的每個元素都與位置k的值交換(包括自己與自己交換),然後進行下一次排列,排列完成後記得恢復原來的序列。
假定陣列 arr
大小 len=3
,則程式呼叫 perm(arr, 0, 3)
可以如下理解:
第一次交換 0,0
,並執行 perm(arr, 1, 3)
,執行完再次交換0,0,陣列此時又恢復成初始值。
第二次交換 1,0
(注意陣列此時是初始值),並執行 perm(arr, 1, 3)
, 執行完再次交換 1,0
,陣列此時又恢復成初始值。
第三次交換 2,0
,並執行 perm(arr, 1, 3)
,執行完成後交換2,0
,陣列恢復成初始值。
程式執行輸出結果為:abc acb bac bca cba cab
。即先輸出以 a
為排列第一個值的排列,而後是 b
和 c
為第一個值的排列。
void perm(char *arr, int k, int len) { //k為起始位置,len為陣列大小
if (k == len-1) {
printf("%s\n", arr);
return;
}
for (int i = k; i < len; i++) {
swapChar(arr, i, k); //交換
perm(arr, k+1, len); //下一次排列
swapChar(arr, i, k); //恢復原來的序列
}
}
複製程式碼
2.6 正規表示式
題: 實現一個簡易版的正規表示式,支援 ^、$、.
等特性。
正規表示式基礎:一個正規表示式本身也是一個字元序列,它定義了能與之匹配的字串集合。在 Unix/Linux 通用的正規表示式中,字元 ^
表示字串開始, $
表示字串結束。這樣,^x
只能與位於字串開始處的 x匹配, x$
只能匹配結尾的 x,^x$
只能匹配單個字元的串裡的 x,而^$
只能匹配空串。字元 .
能與任意字元匹配。所以,模式 x.y
能匹配 xay
、x2y
等等,但它不能匹配 xy
或 xaby
。顯然 ^.$
能夠與任何單個字元的串匹配。寫在方括號 []
裡的一組字元能與這組字元中的任一個相匹配。如 [0123456789]
能與任何數字匹配。這個模式也可以簡寫為 [0-9]
。
解: 下面是正規表示式匹配的主函式match,接收引數為匹配模式regexp和文字text。 如果正規表示式的開頭是 ^
,那麼正文必須從起始處與表示式的其餘部分匹配。否則,我們就沿著串走下去,用 matchhere()
看正文是否能在某個位置上匹配。一旦發現了匹配,工作就完成了。注意這裡 do-while
的使用,有些表示式能與空字串匹配 (例如: $
能夠在字串的末尾與空字串匹配,*
能匹配任意個數的字元,包括 0 個)。所以,即使遇到了空字串,我們也還需要呼叫 matchhere()
。
int match(const char *regexp, const char *text)
{
if (regexp[0] == '^')
return matchhere(regexp+1, text);
do {
if (matchhere(regexp, text))
return 1;
} while (*text++ != '\0');
return 0;
}
複製程式碼
遞迴函式 matchhere()
完成大部分的匹配工作:
- 如果
regexp[0]=='\0'
,表示已經匹配到末尾,則匹配成功,返回1。 - 如果表示式的最後是
$
,匹配成功的條件是正文也到達了末尾,即判斷*text=='\0'
。如果正文text也到了末尾,則匹配成功,否則失敗。 - 如果正文沒有到末尾,且
regexp[0] == *text
或者regexp=='.'
(.
表示匹配任意字元),則遞迴呼叫matchhere繼續下一次匹配。 - 如果
regexp[1]=='*'
,則過程稍顯複雜,例如x*
。這時我們呼叫matchstar
來處理,其第一個引數是星號的引數 (x*
中的x
),隨後的引數是位於星號之後的模式,以及對應的正文串。
int matchhere(const char *regexp, const char *text)
{
if (regexp[0] == '\0')
return 1;
if (regexp[0]=='$' && regexp[1]=='\0')
return *text == '\0';
if (regexp[1] == '*')
return matchstar(regexp[0], regexp+2, text);
if (*text != '\0' && (regexp[0] == '.' || regexp[0] == *text))
return matchhere(regexp+1, text+1);
return 0;
}
int matchstar(int c, const char *regexp, const char *text)
{
do {
if (matchhere(regexp, text))
return 1;
} while (*text != '\0' && (*text++ == c || c == '.'));
return 0;
}
複製程式碼
示例:
char *regexp="abc", text="dagabcdefg"
,匹配成功。char *regexp="^abc", *text="abcdefg"
,匹配成功。char *regexp="^abc", *text="bcdefgabc"
,匹配失敗。char *regexp="abc$", *text="defghabc"
,匹配成功。
2.7 KMP演算法和BM演算法
字串匹配的大名鼎鼎的有KMP演算法和BM演算法,網上資料比較多,可以參見 grep之字串搜尋演算法Boyer-Moore由淺入深(比KMP快3-5倍) 和 字串匹配的KMP演算法 。
參考資料
- articles.leetcode.com/sliding-win…
- leetcode.com/problems/lo…
- 程式設計珠璣
- 演算法導論
- MIT6.828程式碼