資料結構和演算法面試題系列—字串

ssjhust發表於2018-09-09

這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在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 最長迴文子串

題: 給定一個字串,找出該字串的最長迴文子串。迴文字串指的就是從左右兩邊看都一樣的字串,如abacddc都是迴文字串。字串 abbacdc 存在的迴文子串有 abbacdc,因此它的最長迴文子串為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。

    1. 如果 xm = yn,那麼 zk = xm = yn,且 Zk-1 是 Xm-1 和 Yn-1 的一個LCS。
    1. 如果 xm != yn,那麼 zk != xm,且 Z 是 Xm-1 和 Y 的一個LCS。
    1. 如果 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的流程如下圖所示(圖取自演算法導論):

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 為排列第一個值的排列,而後是 bc 為第一個值的排列。

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能匹配 xayx2y 等等,但它不能匹配 xyxaby。顯然 ^.$ 能夠與任何單個字元的串匹配。寫在方括號 [] 裡的一組字元能與這組字元中的任一個相匹配。如 [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演算法

參考資料

相關文章