這個系列是我多年前找工做時對數據結構和算法總結,其中有基礎部分,也有各大公司的經典的面試題,最先發布在CSDN。現整理爲一個系列給須要的朋友參考,若有錯誤,歡迎指正。本系列完整代碼地址在 這裏。html
字符串做爲數據結構中的基礎內容,也是面試中常常會考察的基本功之一,好比實現 strcpy,strcmp等基本函數等,迴文字符串,字符串搜索,正則表達式等。本文相關代碼見 這裏。c++
首先來看一些字符串的基本函數的實現,如下代碼取自MIT6.828課程。git
// 字符串長度
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;
}
複製代碼
題: 給定一個字符串,找出該字符串的最長迴文子串。迴文字符串指的就是從左右兩邊看都同樣的字符串,如aba
,cddc
都是迴文字符串。字符串 abbacdc
存在的迴文子串有 abba
和 cdc
,所以它的最長迴文子串爲abba。github
一個容易犯的錯誤面試
初看這個問題可能想到這樣的方法:對字符串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)
時間求最長迴文子串。bash
/**
* 最長迴文子串-蠻力法 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);
}
複製代碼
題: 已知一個字符數組,其中存儲有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按照必定順序排列,那就必須用交換了。
題: 給定一個數組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()];
}
複製代碼
題: 給定兩個序列 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。
所以,咱們能夠定義 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的流程以下圖所示(圖取自算法導論):
題: 給一個字符數組 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); //恢復原來的序列
}
}
複製代碼
題: 實現一個簡易版的正則表達式,支持 ^、$、.
等特性。
正則表達式基礎:一個正則表達式自己也是一個字符序列,它定義了能與之匹配的字符串集合。在 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"
,匹配成功。字符串匹配的大名鼎鼎的有KMP算法和BM算法,網上資料比較多,能夠參見 grep之字符串搜索算法Boyer-Moore由淺入深(比KMP快3-5倍) 和 字符串匹配的KMP算法 。