那些年,面試中常見的資料結構基礎和演算法題(上) | 掘金技術徵文

ssjhust發表於2018-10-09

前言

作為一個多年的老菜鳥,有感於大部分的公司面試 “面試造航母,工作螺絲釘” 的作風,特整理了這個資料結構和演算法面試題系列。對於校招而言,如果沒有太多實踐/實習經驗,大公司往往喜歡考察資料結構和演算法,如微軟就特別喜歡在校招時手寫演算法題,而且難度還不小,當年我畢業找工作時也是頗受折磨。

從第一篇文章到現在完成已然一個多月了,經 @掘金-yuzu柚子茶 的殷勤的催稿,終於在今天基本完成了。近一個月的業餘時間全在這上面了,除了要將博文整合,還要將程式碼重新錄入和測試,耗費不少精力。本系列的主要資料來源包括:《演算法導論》、《程式設計珠璣》、《資料結構與演算法-C語言實現》,面試題則多來自 leetcode、geeksforgeeks、程式設計之美等。

整理的博文系列名為 資料結構和演算法面試題系列 ,是我6年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在 CSDN 。由於之前的博文比較雜亂,且沒有將實現程式碼統一整理,看起來會有諸多不便。現整理為一個系列給需要的朋友參考。本系列完整程式碼在 github 建了個倉庫,所有程式碼都重新整理和做了一些基本的測試,程式碼倉庫地址在這裡: shishujuan/dsalg: 資料結構與演算法系列彙總,如有錯誤,請在文章下面評論指出或者在 github 給我留言,我好及時改正以免誤導其他朋友。

文章末尾有系列目錄,可以按需取閱,如果需要測試,亦可以將倉庫程式碼 clone 下來進行各種測試。如有錯誤或者引用不全、有侵權的地方,請大家給我指出,我好及時調整改正。系列文章總字數多達4萬字,因此也整合了上下兩篇文章給各位有需要的小夥伴,如果本系列有幫助到你,也歡迎點贊或者在 github 上 star✨✨,十分感謝。

資料結構和演算法面試題系列—C指標、陣列和結構體

0.概述

在用C語言實現一些常見的資料結構和演算法時,C語言的基礎不能少,特別是指標和結構體等知識。

1.關於 ELF 檔案

linux 中的 C 編譯得到的目標檔案和可執行檔案都是 ELF 格式的,可執行檔案中以 segment 來劃分,目標檔案中,我們是以 section 劃分。一個 segment 包含一個或多個 section,通過 readelf 命令可以看到完整的 section 和 segment 資訊。看一個栗子?:

char pear[40];
static double peach;
int mango = 13;
char *str = "hello";

static long melon = 2001;

int main()
{
    int i = 3, j;
    pear[5] = i;
    peach = 2.0 * mango;
    return 0;
}
複製程式碼

這是個簡單的 C 語言程式碼,現在分析下各個變數儲存的位置。其中 mango,melon 屬於 data section,pear 和 peach 屬於 common section 中,而且 peach 和 melon 加了 static,說明只能本檔案使用。而 str 對應的字串 "helloworld" 儲存在 rodata section 中。

main 函式歸屬於 text section,函式中的區域性變數 i,j 在執行時在棧中分配空間。注意到前面說的全域性未初始化變數 peach 和 pear 是在 common section 中,這是為了強弱符號而設定的。那其實最終連結成為可執行檔案後,會歸於 BSS segment。同樣的,text section 和 rodata section 在可執行檔案中都屬於同一個 segment。

更多 ELF 內容參見《程式猿的自我修養》一書。

2.指標

想當年學習 C 語言最怕的就是指標了,當然《c與指標》和《c專家程式設計》以及《高質量C程式設計》裡面對指標都有很好的講解,系統回顧還是看書吧,這裡我總結了一些基礎和易錯的點。環境是 ubuntu14.10 的 32 位系統,編譯工具 GCC。

2.1 指標易錯點

/***
指標易錯示例1 demo1.c
***/

int main()
{
    char *str = "helloworld"; //[1]
    str[1] = 'M'; //[2] 會報錯
    char arr[] = "hello"; //[3]
    arr[1] = 'M';
    return 0;
}
複製程式碼

demo1.c 中,我們定義了一個指標和陣列分別指向了一個字串,然後修改字串中某個字元的值。編譯後執行會發現[2]處會報錯,這是為什麼呢?用命令gcc -S demo1.c 生成彙編程式碼就會發現[1]處的 helloworld 是儲存在 rodata section 的,是隻讀的,而[3]處的是儲存在棧中的。所以[2]報錯而[3]正常。在 C 中,用[1]中的方式建立字串常量並賦值給指標,則字串常量儲存在 rodata section。而如果是賦值給陣列,則儲存在棧中或者 data section 中(如[3]就是儲存在棧中)。示例 2 給出了更多容易出錯的點,可以看看。

/***
指標易錯示例2 demo2.c
***/
char *GetMemory(int num) {
    char *p = (char *)malloc(sizeof(char) * num);
    return p;
}

char *GetMemory2(char *p) {
    p = (char *)malloc(sizeof(char) * 100);
}

char *GetString(){
    char *string = "helloworld";
    return string;
}

char *GetString2(){
    char string[] = "helloworld";
    return string;
}

void ParamArray(char a[])
{
    printf("sizeof(a)=%d\n", sizeof(a)); // sizeof(a)=4,引數以指標方式傳遞
}

int main()
{
    int a[] = {1, 2, 3, 4};
    int *b = a + 1;
    printf("delta=%d\n", b-a); // delta=4,注意int陣列步長為4
    printf("sizeof(a)=%d, sizeof(b)=%d\n", sizeof(a), sizeof(b)); //sizeof(a)=16, sizeof(b)=4
    ParamArray(a); 
        
        
    //引用了不屬於程式地址空間的地址,導致段錯誤
    /*
    int *p = 0;
    *p = 17;         
    */
        
    char *str = NULL;
    str = GetMemory(100);
    strcpy(str, "hello");
    free(str); //釋放記憶體
    str = NULL; //避免野指標

	//錯誤版本,這是因為函式引數傳遞的是副本。
	/*
    char *str2 = NULL;
    GetMemory2(str2);
    strcpy(str2, "hello");
    */

    char *str3 = GetString();
    printf("%s\n", str3);

    //錯誤版本,返回了棧指標,編譯器會有警告。
    /*
    char *str4 = GetString2();
    */
    return 0;
}
複製程式碼

2.2 指標和陣列

在2.1中也提到了部分指標和陣列內容,在C中指標和陣列在某些情況下可以相互轉換來使用,比如char *str="helloworld"可以通過str[1]來訪問第二個字元,也可以通過*(str+1)來訪問。 此外,在函式引數中,使用陣列和指標也是等同的。但是指標和陣列在有些地方並不等同,需要特別注意。

比如我定義一個陣列char a[9] = "abcdefgh";(注意字串後面自動補\0),那麼用 a[1]讀取字元 'b' 的流程是這樣的:

  • 首先,陣列a有個地址,我們假設是 9980。
  • 然後取偏移值,偏移值為索引值*元素大小,這裡索引是 1,char 大小也為1,因此加上 9980 為 9981,得到陣列 a 第 1 個元素的地址。(如果是int型別陣列,那麼這裡偏移就是1 * 4 = 4)
  • 取地址 9981 處的值,就是'b'。

那如果定義一個指標char *a = "abcdefgh";,我們通過 a[1]來取第一個元素的值。跟陣列流程不同的是:

  • 首先,指標 a 自己有個地址,假設是 4541.
  • 然後,從 4541 取 a 的值,也就是字串 “abcdefgh” 的地址,假定是 5081。
  • 接著就是跟之前一樣的步驟了,5081 加上偏移 1 ,取 5082 地址處的值,這裡就是'b'了。

通過上面的說明可以發現,指標比陣列多了一個步驟,雖然看起來結果是一致的。因此,下面這個錯誤就比較好理解了。在 demo3.c 中定義了一個陣列,然後在 demo4.c 中通過指標來宣告並引用它,顯然是會報錯的。如果改成extern char p[];就正確了(當然宣告你也可以寫成 extern char p[3],宣告裡面的陣列大小跟實際大小不一致是沒有關係的),一定要保證定義和宣告匹配。

/***
demo3.c
***/
char p[] = "helloworld";

/***
demo4.c
***/
extern char *p;
int main()
{
    printf("%c\n", p[1]);
    return 0;
}
複製程式碼

3.typedef 和 #define

typedef 和 #define 都是經常用的,但是它們是不一樣的。一個 typedef 可以塞入多個宣告器,而 #define 一般只能有一個定義。在連續宣告中,typedef 定義的型別可以保證宣告的變數都是同一種型別,而 #define 不行。此外,typedef 是一種徹底的封裝型別,在宣告之後不能再新增其他的型別。如程式碼中所示。

#define int_ptr int *
int_ptr i, j; //i是int *型別,而j是int型別。

typedef char * char_ptr;
char_ptr c1, c2; //c1, c2都是char *型別。

#define peach int
unsigned peach i; //正確

typdef int banana;
unsigned banana j; //錯誤,typedef宣告的型別不能擴充套件其他型別。
複製程式碼

另外,typedef 在結構體定義中也很常見,比如下面程式碼中的定義。需要注意的是,[1]和[2]是很不同的。當你如[1]中那樣用 typedef 定義了 struct foo,那麼其實除了本身的 foo 結構標籤,你還定義了 foo 這種結構型別,所以可以直接用 foo 來宣告變數。而如[2]中的定義是不能用 bar 來宣告變數的,因為它只是一個結構變數,並不是結構型別。

還有一點需要說明的是,結構體是有自己名字空間的,所以結構體中的欄位可以跟結構體名字相同,比如[3]中那樣也是合法的,當然儘量不要這樣用。後面一節還會更詳細探討結構體,因為在 Python 原始碼中也有用到很多結構體。

typedef struct foo {int i;} foo; //[1]
struct bar {int i;} bar; //[2]

struct foo f; //正確,使用結構標籤foo
foo f; //正確,使用結構型別foo

struct bar b; //正確,使用結構標籤bar
bar b; // 錯誤,使用了結構變數bar,bar已經是個結構體變數了,可以直接初始化,比如bar.i = 4;

struct foobar {int foorbar;}; //[3]合法的定義
複製程式碼

4.結構體

在學習資料結構的時候,定義連結串列和樹結構會經常用到結構體。比如下面這個:

struct node {
    int data;
    struct node* next;
};
複製程式碼

在定義連結串列的時候可能就有點奇怪了,為什麼可以這樣定義,貌似這個時候 struct node 還沒有定義好為什麼就可以用 next 指標指向用這個結構體定義了呢?

4.1 不完全型別

這裡要說下 C 語言裡面的不完全型別。C 語言可以分為函式型別,物件型別以及不完全型別。而物件型別還可以分為標量型別和非標量型別。算術型別(如 int,float,char 等)和指標型別屬於標量型別,而定義完整的結構體,聯合體,陣列等都是非標量型別。而不完全型別是指沒有定義完整的型別,比如下面這樣的:

struct s;
union u;
char str[];
複製程式碼

具有不完全型別的變數可以通過多次宣告組合成一個完全型別。比如下面 2 詞宣告 str 陣列是合法的:

char str[];
char str[10];
複製程式碼

此外,如果兩個原始檔定義了同一個變數,只要它們不全部是強型別的,那麼也是可以編譯通過的。比如下面這樣是合法的,但是如果將 file1.c 中的int i;改成強定義如int i = 5;那麼就會出錯了。

//file1.c
int i;

//file2.c
int i = 4;
複製程式碼

4.2 不完全型別結構體

不完全型別的結構體十分重要,比如我們最開始提到的 struct node 的定義,編譯器從前往後處理,發現struct node *next時,認為 struct node 是一個不完全型別,next 是一個指向不完全型別的指標,儘管如此,指標本身是完全型別,因為不管什麼指標在 32 位系統都是佔用 4 個位元組。而到後面定義結束,struct node 成了一個完全型別,從而 next 就是一個指向完全型別的指標了。

4.3 結構體初始化和大小

結構體初始化比較簡單,需要注意的是結構體中包含有指標的時候,如果要進行字串拷貝之類的操作,對指標需要額外分配記憶體空間。如下面定義了一個結構體 student 的變數 stu 和指向結構體的指標 pstu,雖然 stu 定義的時候已經隱式分配了結構體記憶體,但是你要拷貝字串到它指向的記憶體的話,需要顯示分配記憶體。

struct student {
    char *name;
    int age;
} stu, *pstu;

int main()
{
    stu.age = 13; //正確
    // strcpy(stu.name,"hello"); //錯誤,name還沒有分配記憶體空間
        
    stu.name = (char *)malloc(6);
    strcpy(stu.name, "hello"); //正確
        
    return 0;
}
複製程式碼

結構體大小涉及一個對齊的問題,對齊規則為:

  • 結構體變數首地址為最寬成員長度(如果有#pragma pack(n),則取最寬成員長度和n的較小值,預設pragma的n=8)的整數倍
  • 結構體大小為最寬成員長度的整數倍
  • 結構體每個成員相對結構體首地址的偏移量都是每個成員本身大小(如果有pragma pack(n),則是n與成員大小的較小值)的整數倍 因此,下面結構體S1和S2雖然內容一樣,但是欄位順序不同,大小也不同,sizeof(S1) = 8, 而sizeof(S2) = 12. 如果定義了#pragma pack(2),則sizeof(S1)=8;sizeof(S2)=8
typedef struct node1
{
    int a;
    char b;
    short c;
}S1;

typedef struct node2
{
    char b;
    int a;
    short c;
}S2;
複製程式碼

4.4 柔性陣列

柔性陣列是指結構體的最後面一個成員可以是一個大小未知的陣列,這樣可以在結構體中存放變長的字串。如程式碼中所示。**注意,柔性陣列必須是結構體最後一個成員,柔性陣列不佔用結構體大小.**當然,你也可以將陣列寫成char str[0],含義相同。

注:在學習 Python 原始碼過程中,發現其柔性陣列宣告並不是用一個空陣列或者 char str[0],而是用的char str[1],即陣列大小為 1。這是因為 ISO C標準不允許宣告大小為 0 的陣列( gcc -pedanti 引數可以檢查是否符合 ISO C 標準),為了可移植性,所以常常看到的是宣告陣列大小為1。當然,很多編譯器比如 GCC 等把陣列大小為 0 作為了一個非標準的擴充套件,所以宣告空的或者大小為 0 的柔性陣列在 GCC 中是可以正常編譯的。

struct flexarray {
    int len;
    char str[];
} *pfarr;

int main()
{
    char s1[] = "hello, world";
    pfarr = malloc(sizeof(struct flexarray) + strlen(s1) + 1);
    pfarr->len = strlen(s1);
    strcpy(pfarr->str, s1);
    printf("%d\n", sizeof(struct flexarray)); // 4
    printf("%d\n", pfarr->len); // 12
    printf("%s\n", pfarr->str); // hello, world
    return 0;
}
複製程式碼

5.總結

  • 關於 const,c 語言中的 const 不是常量,所以不能用 const 變數來定義陣列,如 const int N = 3; int a[N];這是錯誤的。
  • 注意記憶體分配和釋放,杜絕野指標。
  • C 語言中弱符號和強符號一起連結是合法的。
  • 注意指標和陣列的區別。
  • typedef 和 #define 是不同的。
  • 注意包含指標的結構體的初始化和柔性陣列的使用。

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

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演算法

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

0.概述

連結串列作為一種基礎的資料結構,在很多地方會用到。如在 Linux 核心程式碼,redis 原始碼,python 原始碼中都有使用。除了單向連結串列,還有雙向連結串列,本文主要關注單向連結串列(含部分迴圈連結串列題目,會在題目中註明,其他情況都是討論簡單的單向連結串列)。雙向連結串列在redis中有很好的實現,也在我的倉庫中拷貝了一份用於測試用,本文的相關程式碼在 這裡

1.定義

先定義一個單向連結串列結構,如下,定義了連結串列結點和連結串列兩個結構體。這裡我沒有多定義一個連結串列的結構體,儲存頭指標,尾指標,連結串列長度等資訊,目的也是為了多練習下指標的操作。

// aslist.h

// 連結串列結點定義
typedef struct ListNode {
    struct ListNode *next;
    int value;
} listNode;
複製程式碼

連結串列結構

2.基本操作

在上一節的連結串列定義基礎上,我們完成幾個基本操作函式,包括連結串列初始化,連結串列中新增結點,連結串列中刪除結點等。

/**
 * 建立連結串列結點
 */
ListNode *listNewNode(int value)
{
    ListNode *node;
    if (!(node = malloc(sizeof(ListNode))))
        return NULL;

    node->value = value;
    node->next = NULL;
    return node;
}

/**
 * 頭插法插入結點。
 */
ListNode *listAddNodeHead(ListNode *head, int value)
{
    ListNode *node;
    if (!(node = listNewNode(value)))
        return NULL;

    if (head) 
        node->next = head;

    head = node;
    return head;
}

/**
 * 尾插法插入值為value的結點。
 */
ListNode *listAddNodeTail(ListNode *head, int value)
{
    ListNode *node;
    if (!(node = listNewNode(value)))
        return NULL;

    return listAddNodeTailWithNode(head, node);
}

/**
 * 尾插法插入結點。
 */
ListNode *listAddNodeTailWithNode(ListNode *head, ListNode *node)
{
    if (!head) {
        head = node;
    } else {
        ListNode *current = head;
        while (current->next) {
            current = current->next;
        } 
        current->next = node;
    }
    return head;
}

/**
 * 從連結串列刪除值為value的結點。
 */
ListNode *listDelNode(ListNode *head, int value)
{
    ListNode *current=head, *prev=NULL;

    while (current) {
        if (current->value == value) {
            if (current == head)
                head = head->next;

            if (prev)
                prev->next = current->next;

            free(current);
            break;
        }

        prev = current;
        current = current->next;
    }
    return head;
}

/**
 * 連結串列遍歷。
 */
void listTraverse(ListNode *head)
{
    ListNode *current = head;
    while (current) {
        printf("%d", current->value);
        printf("->");
        current = current->next;
        if (current == head) // 處理首尾迴圈連結串列情況
            break;
    }

    printf("NULL\n");
}

/**
 * 使用陣列初始化一個連結串列,共len個元素。
 */
ListNode *listCreate(int a[], int len)
{
    ListNode *head = NULL;
    int i;
    for (i = 0; i < len; i++) {
        if (!(head = listAddNodeTail(head, a[i])))
            return NULL;
    }
    return head;
}

/**
* 連結串列長度函式
*/
int listLength(ListNode *head)
{
    int len = 0;
    while (head) {
        len++;
        head = head->next;
    }
    return len;
}
複製程式碼

3.連結串列相關面試題

3.1 連結串列逆序

題: 給定一個單向連結串列 1->2->3->NULL,逆序後變成 3->2->1->NULL

解: 常見的是用的迴圈方式對各個結點逆序連線,如下:

/**
 * 連結串列逆序,非遞迴實現。
*/
ListNode *listReverse(ListNode *head)
{
    ListNode *newHead = NULL, *current = head;
    while (current) {
        ListNode *next = current->next;
        current->next = newHead;
        newHead = current;
        current = next;
    }

    return newHead;
}
複製程式碼

如果帶點炫技性質的,那就來個遞迴的解法,如下:

/**
 * 連結串列逆序,遞迴實現。
 */
ListNode *listReverseRecursive(ListNode *head)
{
    if (!head || !head->next) {
        return head;
    }

    ListNode *reversedHead = listReverseRecursive(head->next);
    head->next->next = head;
    head->next = NULL;
    return reversedHead;
}
複製程式碼

3.2 連結串列複製

題: 給定一個單向連結串列,複製並返回新的連結串列頭結點。

解: 同樣可以有兩種解法,非遞迴和遞迴的,如下:

/**
 * 連結串列複製-非遞迴
 */
ListNode *listCopy(ListNode *head) 
{
    ListNode *current = head, *newHead = NULL, *newTail = NULL; 
    while (current) {
        ListNode *node = listNewNode(current->value);
        if (!newHead) { // 第一個結點
            newHead = newTail = node;
        } else {
            newTail->next = node;
            newTail = node;
        }
        current = current->next;
    }
    return newHead;
}
	
/**
 * 連結串列複製-遞迴
 */
ListNode *listCopyRecursive(ListNode *head)
{
    if (!head) 
        return NULL;
	
    ListNode *newHead = listNewNode(head->value);
    newHead->next = listCopyRecursive(head->next);
    return newHead;
}
複製程式碼

3.3 連結串列合併

題: 已知兩個有序單向連結串列,請合併這兩個連結串列,使得合併後的連結串列仍然有序(注:這兩個連結串列沒有公共結點,即不交叉)。如連結串列1是 1->3->4->NULL,連結串列2是 2->5->6->7->8->NULL,則合併後的連結串列為 1->2->3->4->5->6->7->8->NULL

解: 這個很類似歸併排序的最後一步,將兩個有序連結串列合併到一起即可。使用2個指標分別遍歷兩個連結串列,將較小值結點歸併到結果連結串列中。如果一個連結串列歸併結束後另一個連結串列還有結點,則把另一個連結串列剩下部分加入到結果連結串列的尾部。程式碼如下所示:

/**
 * 連結串列合併-非遞迴
 */
ListNode *listMerge(ListNode *list1, ListNode *list2)
{
    ListNode dummy; // 使用空結點儲存合併連結串列
    ListNode *tail = &dummy;

    if (!list1)
        return list2;

    if (!list2)
        return list1;

    while (list1 && list2) {
        if (list1->value <= list2->value) {
            tail->next = list1;
            tail = list1;
            list1 = list1->next;
        } else {
            tail->next = list2;
            tail = list2;
            list2 = list2->next;
        }
    }

    if (list1) {
        tail->next = list1;
    } else if (list2) {
        tail->next = list2;
    }

    return dummy.next;
}
複製程式碼

當然,要實現一個遞迴的也不難,程式碼如下:

ListNode *listMergeRecursive(ListNode *list1, ListNode *list2)
{
    ListNode *result = NULL;

    if (!list1)
        return list2;

    if (!list2)
        return list1;

    if (list1->value <= list2->value) {
        result = list1;
        result->next = listMergeRecursive(list1->next, list2);
    } else {
        result = list2;
        result->next = listMergeRecursive(list1, list2->next);
    }

    return result;
}
複製程式碼

3.4 連結串列相交判斷

題: 已知兩個單向連結串列list1,list2,判斷兩個連結串列是否相交。如果相交,請找出相交的結點。

解1: 可以直接遍歷list1,然後依次判斷list1每個結點是否在list2中,但是這個解法的複雜度為 O(length(list1) * length(list2))。當然我們可以遍歷list1時,使用雜湊表儲存list1的結點,這樣再遍歷list2即可判斷了,時間複雜度為O(length(list1) + length(list2)),空間複雜度為 O(length(list1)),這樣相交的結點自然也就找出來了。當然,找相交結點還有更好的方法。

解2: 兩個連結串列如果相交,那麼它們從相交後的節點一定都是相同的。假定list1長度為len1,list2長度為len2,且 len1 > len2,則我們只需要將 list1 先遍歷 len1-len2個結點,然後兩個結點一起遍歷,如果遇到相等結點,則該結點就是第一個相交結點。

/**
 * 連結串列相交判斷,如果相交返回相交的結點,否則返回NULL。
 */
ListNode *listIntersect(ListNode *list1, ListNode *list2)
{
    int len1 = listLength(list1);
    int len2 = listLength(list2);
    int delta = abs(len1 - len2);

    ListNode *longList = list1, *shortList = list2;

    if (len1 < len2) {
        longList = list2;
        shortList = list1;
    }

    int i;
    for (i = 0; i < delta; i++) {
        longList = longList->next;
    }

    while (longList && shortList) {
        if (longList == shortList)
            return longList;

        longList = longList->next;
        shortList = shortList->next;
    }

    return NULL;
}
複製程式碼

3.5 判斷連結串列是否存在環

題: 給定一個連結串列,判斷連結串列中是否存在環。

判斷連結串列環

解1: 容易想到的方法就是使用一個雜湊表記錄出現過的結點,遍歷連結串列,如果一個結點重複出現,則表示該連結串列存在環。如果不用雜湊表,也可以在連結串列結點 ListNode 結構體中加入一個 visited 欄位做標記,訪問過標記為 1,也一樣可以檢測。由於目前我們還沒有實現一個雜湊表,這個方法程式碼後面再加。

解2: 更好的一種方法是 Floyd判圈演算法,該演算法最早由羅伯特.弗洛伊德發明。通過使用兩個指標 fast 和 slow 遍歷連結串列,fast 指標每次走兩步,slow 指標每次走一步,如果 fast 和 slow 相遇,則表示存在環,否則不存在環。(注意,如果連結串列只有一個節點且沒有環,不會進入 while 迴圈)

/**
 * 檢測連結串列是否有環-Flod判圈演算法
 * 若存在環,返回相遇結點,否則返回NULL
 */
ListNode *listDetectLoop(ListNode *head)
{
    ListNode *slow, *fast;
    slow = fast = head;

    while (slow && fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) {
            printf("Found Loop\n");
            return slow;
        }
    }

    printf("No Loop\n");
    return NULL;
}

void testListDetectLoop()
{
    printf("\nTestListDetectLoop\n");
    int a[] = {1, 2, 3, 4};
    ListNode *head = listCreate(a, ALEN(a));
    listDetectLoop(head);

    // 構造一個環
    head->next->next->next = head;
    listDetectLoop(head);
}
複製程式碼

擴充套件: 檢測到有環的話,那要如何找連結串列的環的入口點呢?

首先,我們來證明一下為什麼上面的解 2 提到的演算法是正確的。如果連結串列不存在環,因為快指標每次走 2 步,必然會比慢指標先到達連結串列尾部,不會相遇。

如果存在環,假定快慢指標經過s次迴圈後相遇,則此時快指標走的距離為 2s,慢指標走的距離為 s,假定環內結點數為 r,則要相遇則必須滿足下面條件,即相遇時次數滿足 s = nr。即從起點之後下一次相遇需要迴圈 r 次。

2s - s = nr => s = nr
複製程式碼

如下圖所示,環長度 r=4,則從起點後下一次相遇需要經過 4 次迴圈。

那麼環的入口點怎麼找呢?前面已經可知道第一次相遇要迴圈 r 次,而相遇時慢指標走的距離為 s = r,設連結串列總長度為 L,連結串列頭到環入口的距離為 a,環入口到相遇點的距離為 x,則 L = a + r,可以推匯出 a = (L-x-a),其中 L-x-a 為相遇點到環入口點的距離,即連結串列頭到環入口的距離a等於相遇點到環入口距離

s = r = a + x => a + x = (L-a) => a = L-x-a
複製程式碼

於是,在判斷連結串列存在環後,從相遇點和頭結點分別開始遍歷,兩個指標每次都走一步,當兩個指標相等時,就是環的入口點。

/**
 * 查詢連結串列中環入口
 */
ListNode *findLoopNode(ListNode *head)
{
    ListNode *meetNode = listDetectLoop(head);
    if (!meetNode)
        return NULL;

    ListNode *headNode = head;
    while (meetNode != headNode) {
        meetNode = meetNode->next;
        headNode = headNode->next;
    }
    return meetNode;
}
複製程式碼

3.6 連結串列模擬加法

題: 給定兩個連結串列,每個連結串列的結點值為數字的各位上的數字,試求出兩個連結串列所表示數字的和,並將結果以連結串列形式返回。假定兩個連結串列分別為 list1 和 list2,list1 各個結點值分別為數字 513 的個位、十位和百位上的數字,同理 list2 的各個結點值為數字 295 的各位上的數字。則這兩個數相加為 808,所以輸出按照從個位到百位順序輸出,返回的結果連結串列如下。

list1:  (3 -> 1 -> 5 -> NULL)

list2:  (5 -> 9 -> 2 -> NULL)

result: (8 -> 0 -> 8 -> NULL)
複製程式碼

解: 這個題目比較有意思,需要對連結串列操作比較熟練。我們考慮兩個數字相加過程,從低位到高位依次相加,如果有進位則標記進位標誌,直到最高位才終止。設當前位的結點為 current,則有:

current -> data = list1 -> data + list2 -> data + carry
(其中 carry 為低位的進位,如果有進位為 1,否則為 0)
複製程式碼

非遞迴程式碼如下:

/**
 * 連結串列模擬加法-非遞迴解法
 */
ListNode *listEnumarateAdd(ListNode *list1, ListNode *list2)
{
    int carry = 0;
    ListNode *result = NULL;

    while (list1 || list2 || carry) {
        int value = carry;
        if (list1) {
            value += list1->value;
            list1 = list1->next;
        }

        if (list2) {
            value += list2->value;
            list2 = list2->next;
        }

        result = listAddNodeTail(result, value % 10);
        carry = ( value >= 10 ? 1: 0);
    }

    return result;
}
複製程式碼

非遞迴實現如下:

/**
 * 連結串列模擬加法-遞迴解法
 */
ListNode *listEnumarateAddRecursive(ListNode *list1, ListNode *list2, int carry)
{
    if (!list1 && !list2 && carry==0)
        return NULL;

    int value = carry;
    if (list1)
        value += list1->value;

    if (list2)
        value += list2->value;

    ListNode *next1 = list1 ? list1->next : NULL;
    ListNode *next2 = list2 ? list2->next : NULL;
    ListNode *more = listEnumarateAddRecursive(next1, next2, (value >= 10 ? 1 : 0));
    ListNode *result = listNewNode(carry);
    result->value = value % 10;
    result->next = more;

    return result;
}
複製程式碼

3.7 有序單向迴圈連結串列插入結點

題: 已知一個有序的單向迴圈連結串列,插入一個結點,仍保持連結串列有序,如下圖所示。

迴圈連結串列

解: 在解決這個問題前,我們先看一個簡化版本,就是在一個有序無迴圈的單向連結串列中插入結點,仍然保證其有序。這個問題的程式碼相信多數人都很熟悉,一般都是分兩種情況考慮:

  • 1)如果原來連結串列為空或者插入的結點值最小,則直接插入該結點並設定為頭結點。
  • 2)如果原來連結串列非空,則找到第一個大於該結點值的結點,並插入到該結點的前面。如果插入的結點值最大,則插入在尾部。

實現程式碼如下:

/**
 * 簡化版-有序無迴圈連結串列插入結點
 */
ListNode *sortedListAddNode(ListNode *head, int value)
{
    ListNode *node = listNewNode(value);
    if (!head || head->value >= value) { //情況1
        node->next = head;
        head = node;
    } else {  //情況2
        ListNode *current = head;
        while (current->next != NULL && current->next->value < value)
            current = current->next;
        node->next = current->next;
        current->next = node;
    }
    return head;
}
複製程式碼

當然這兩種情況也可以一起處理,使用二級指標。如下:


/**
 * 簡化版-有序無迴圈連結串列插入結點(兩種情況一起處理)
 */
void sortedListAddNodeUnify(ListNode **head, int value)
{
    ListNode *node = listNewNode(value);
    ListNode **current = head;
    while ((*current) && (*current)->value < value) {
        current = &((*current)->next);
    }
    node->next = *current;
    *current = node;
}
複製程式碼

接下來看迴圈連結串列的情況,其實也就是需要考慮下面2點:

  • 1) prev->value ≤ value ≤ current->value: 插入到prev和current之間。
  • 2) value為最大值或者最小值: 插入到首尾交接處,如果是最小值重新設定head值。

程式碼如下:

/**
 * 有序迴圈連結串列插入結點
 */
ListNode *sortedLoopListAddNode(ListNode *head, int value)
{
    ListNode *node = listNewNode(value);
    ListNode *current = head, *prev = NULL;
    do {
        prev = current;
        current = current->next;
        if (value >= prev->value && value <= current->value)
            break;
    } while (current != head);

    prev->next = node;
    node->next = current;

    if (current == head && value < current->value) // 判斷是否要設定連結串列頭
        head = node;

    return head;
}
複製程式碼

3.8 輸出連結串列倒數第K個結點

題: 給定一個簡單的單向連結串列,輸出連結串列的倒數第K個結點。

解1: 如果是順數第 K 個結點,不用多思考,直接遍歷即可。這個題目的新意在於它是要輸出倒數第 K 個結點。一個直觀的想法是,假定連結串列長度為 L,則倒數第 K 個結點就是順數的 L-K+1 個結點。如連結串列長度為 3,倒數第 2 個,就是順數的第 2 個結點。這樣需要遍歷連結串列 2 次,一次求長度,一次找結點。

/**
* 連結串列倒數第K個結點-遍歷兩次演算法
*/
ListNode *getLastKthNodeTwice(ListNode *head, int k)
{
    int len = listLength(head);     
    if (k > len)
        return NULL;

    ListNode *current = head; 
    int i;
    for (i = 0; i < len-k; i++)  //遍歷連結串列,找出第N-K+1個結點
        current = current->next;

    return current;
}
複製程式碼

解2: 當然更好的一種方法是遍歷一次,設定兩個指標p1,p2,首先 p1 和 p2 都指向 head,然後 p2 向前走 k 步,這樣 p1 和 p2 之間就間隔 k 個節點。最後 p1 和 p2 同時向前移動,p2 走到連結串列末尾的時候 p1 剛好指向倒數第 K 個結點。程式碼如下:

/**
* 連結串列倒數第K個結點-遍歷一次演算法
*/
ListNode *getLastKthNodeOnce(ListNode *head, int k)
{
    ListNode *p1, *p2;
    p1 = p2 = head;

    for(; k > 0; k--) {
        if (!p2) // 連結串列長度不夠K
            return NULL;
        p2 = p2->next;
    }

    while (p2) {
        p1 = p1->next;
        p2 = p2->next;
    }
    return p1;
}
複製程式碼

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

這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在CSDN。現整理為一個系列給需要的朋友參考,如有錯誤,歡迎指正。本系列完整程式碼地址在 這裡

0.概述

棧作為一種基本的資料結構,在很多地方有運用,比如函式遞迴,前字尾表示式轉換等。本文會用 C 陣列來實現棧結構(使用連結串列實現可以參見連結串列那一節,使用頭插法構建連結串列即可),並對常見的幾個跟棧相關的面試題進行分析,本文程式碼在 這裡

1.定義

我們使用結構體來定義棧,使用柔性陣列來儲存元素。幾個巨集定義用於計算棧的元素數目及棧是否為空和滿。

typedef struct Stack {
    int capacity;
    int top;
    int items[];
} Stack;

#define SIZE(stack) (stack->top + 1)
#define IS_EMPTY(stack) (stack->top == -1)
#define IS_FULL(stack) (stack->top == stack->capacity - 1)
複製程式碼

2.基本操作

棧主要有三種基本操作:

  • push:壓入一個元素到棧中。
  • pop:彈出棧頂元素並返回。
  • peek:取棧頂元素,但是不修改棧。

如圖所示:

棧示意圖

程式碼如下:

Stack *stackNew(int capacity)
{
    Stack *stack = (Stack *)malloc(sizeof(*stack) + sizeof(int) * capacity);
    if (!stack) {
        printf("Stack new failed\n");
        exit(E_NOMEM);
    }

    stack->capacity = capacity;
    stack->top = -1;
    return stack;
}

void push(Stack *stack, int v)
{
    if (IS_FULL(stack)) {
        printf("Stack Overflow\n");
        exit(E_FULL);
    }
    stack->items[++stack->top] = v;
}

int pop(Stack *stack)
{
    if (IS_EMPTY(stack)) {
        printf("Stack Empty\n");
        exit(E_EMPTY);
    }

    return stack->items[stack->top--];
}

int peek(Stack *stack)
{
    if (IS_EMPTY(stack)) {
        printf("Stack Empty\n");
        exit(E_EMPTY);
    }
    return stack->items[stack->top];
}

複製程式碼

3.棧相關面試題

3.1 字尾表示式求

題: 已知一個字尾表示式 6 5 2 3 + 8 * + 3 + *,求該字尾表示式的值。

解: 字尾表示式也叫逆波蘭表示式,其求值過程可以用到棧來輔助儲存。則其求值過程如下:

  • 1)遍歷表示式,遇到的數字首先放入棧中,此時棧為 [6 5 2 3]
  • 2)接著讀到 +,則彈出3和2,計算 3 + 2,計算結果等於 5,並將 5 壓入到棧中,棧為 [6 5 5]
  • 3)讀到 8 ,將其直接放入棧中,[6 5 5 8]
  • 4)讀到 *,彈出 85 ,計算 8 * 5,並將結果 40 壓入棧中,棧為 [6 5 40]。而後過程類似,讀到 +,將 405 彈出,將 40 + 5 的結果 45 壓入棧,棧變成[6 45],讀到3,放入棧 [6 45 3]...以此類推,最後結果為 288

程式碼:

int evaluatePostfix(char *exp)
{
    Stack* stack = stackNew(strlen(exp));
    int i;
 
    if (!stack) {
        printf("New stack failed\n");
        exit(E_NOMEM);
    }
 
    for (i = 0; exp[i]; ++i) {
        // 如果是數字,直接壓棧
        if (isdigit(exp[i])) {
            push(stack, exp[i] - '0');
        } else {// 如果遇到符號,則彈出棧頂兩個元素計算,並將結果壓棧
            int val1 = pop(stack);
            int val2 = pop(stack);
            switch (exp[i])
            {
                case '+': push(stack, val2 + val1); break;
                case '-': push(stack, val2 - val1); break;
                case '*': push(stack, val2 * val1); break;
                case '/': push(stack, val2/val1);   break;
            }
        }
    }

    return pop(stack); 
}
複製程式碼

3.2 棧逆序

題: 給定一個棧,請將其逆序。

解1: 如果不考慮空間複雜度,完全可以另外弄個輔助棧,將原棧資料全部 pop 出來並 push 到輔助棧即可。

解2: 如果在面試中遇到這個題目,那肯定是希望你用更好的方式實現。可以先實現一個在棧底插入元素的函式,然後便可以遞迴實現棧逆序了,不需要用輔助棧。

 * 在棧底插入一個元素
 */
void insertAtBottom(Stack *stack, int v)
{
    if (IS_EMPTY(stack)) {
        push(stack, v);
    } else {
        int x = pop(stack);
        insertAtBottom(stack, v);
        push(stack, x);
    }
}

/**
 * 棧逆序
 */
void stackReverse(Stack *stack)
{
    if (IS_EMPTY(stack))
        return;

    int top = pop(stack);
    stackReverse(stack);
    insertAtBottom(stack, top);
}
複製程式碼

3.3 設計包含 min 函式的棧

題: 設計一個棧,使得push、pop以及min(獲取棧中最小元素)能夠在常數時間內完成。

分析: 剛開始很容易想到一個方法,那就是額外建立一個最小二叉堆儲存所有元素,這樣每次獲取最小元素只需要 O(1) 的時間。但是這樣的話,為了建最小堆 pushpop 操作就需要 O(lgn) 的時間了(假定棧中元素個數為n),不符合題目的要求。

解1:輔助棧方法

那為了實現該功能,可以使用輔助棧使用一個輔助棧來儲存最小元素,這個解法簡單不失優雅。設該輔助棧名字為 minStack,其棧頂元素為當前棧中的最小元素。這意味著

  • 1)要獲取當前棧中最小元素,只需要返回 minStack 的棧頂元素即可。
  • 2)每次執行 push 操作時,檢查 push 的元素是否小於或等於 minStack 棧頂元素。如果是,則也push 該元素到 minStack 中。
  • 3)當執行 pop 操作的時候,檢查 pop 的元素是否與當前最小值相等。如果相等,則需要將該元素從minStack 中 pop 出去。

程式碼:

void minStackPush(Stack *orgStack, Stack *minStack, int v)
{
    if (IS_FULL(orgStack)) {
        printf("Stack Full\n");
        exit(E_FULL);
    }

    push(orgStack, v);
    if (IS_EMPTY(minStack) || v < peek(minStack)) {
        push(minStack, v);
    }
}

int minStackPop(Stack *orgStack, Stack *minStack)
{
    if (IS_EMPTY(orgStack)) {
        printf("Stack Empty\n");
        exit(E_EMPTY);
    }

    if (peek(orgStack) == peek(minStack)) {
        pop(minStack);
    }
    return pop(orgStack);
}

int minStackMin(Stack *minStack)
{
    return peek(minStack);
}
複製程式碼

示例:

另外一種解法利用儲存差值而不需要輔助棧,方法比較巧妙:

  • 棧頂多出一個空間用於儲存棧最小值。
  • push 時壓入的是當前元素與壓入該元素前的棧中最小元素(棧頂的元素)的差值,然後通過比較當前元素與當前棧中最小元素大小,並將它們中的較小值作為新的最小值壓入棧頂。
  • pop 函式執行的時候,先 pop 出棧頂的兩個值,這兩個值分別是當前棧中最小值 min 和最後壓入的元素與之前棧中最小值的差值 delta。根據 delta < 0 或者 delta >= 0 來獲得之前壓入棧的元素的值和該元素出棧後的新的最小值。
  • min 函式則是取棧頂元素即可。

程式碼:

void minStackPushUseDelta(Stack *stack, int v)
{
    if (IS_EMPTY(stack)) { // 空棧,直接壓入v兩次
        push(stack, v);
        push(stack, v);
    } else { 
       int oldMin = pop(stack); // 棧頂儲存的是壓入v之前的棧中最小值
       int delta = v - oldMin; 
       int newMin = delta < 0 ? v : oldMin;
       push(stack, delta); // 壓入 v 與之前棧中的最小值之差
       push(stack, newMin); // 最後壓入當前棧中最小值
   }
   int minStackPopUseDelta(Stack *stack)
{
    int min = pop(stack);
    int delta = pop(stack);
    int v, oldMin;

    if (delta < 0) { // 最後壓入的元素比min小,則min就是最後壓入的元素
        v = min;
        oldMin = v - delta;
    } else { // 最後壓入的值不是最小值,則min為oldMin。
        oldMin = min;
        v = oldMin + delta;
    }

    if (!IS_EMPTY(stack)) { // 如果棧不為空,則壓入oldMin
        push(stack, oldMin);
    }
    return v;
}

int minStackMinUseDelta(Stack *stack)
{
    return peek(stack);
}
複製程式碼

示例:

push(3): [3 3] 
push(4): [3 1 3] 
push(2): [3 1 -1 2] 
push(5): [3 1 -1 3 2] 
push(1): [3 1 -1 3 -1 1] 

min(): 1,pop(): 1,[3 1 -1 3 2]
min(): 2,pop(): 5,[3 1 -1 2] 
min(): 2,pop(): 2,[3 1 3] 
min(): 3,pop(): 4,[3 3] 
min(): 3,pop(): 3,[ ]
複製程式碼

3.4 求出棧數目和出棧序列

求出棧數目

題: 已知一個入棧序列,試求出所有可能的出棧序列數目。例如入棧序列為 1,2,3,則可能的出棧序列有5種:1 2 3,1 3 2 ,2 1 3,2 3 1,3 2 1

解: 要求解出棧序列的數目,還算比較容易的。已經有很多文章分析過這個問題,最終答案就是卡特蘭數,也就是說 n 個元素的出棧序列的總數目等於 C(2n, n) - C(2n, n-1) = C(2n, n) / (n+1) ,如 3 個元素的總的出棧數目就是 C(6, 3) / 4 = 5

如果不分析求解的通項公式,是否可以寫程式求出出棧的序列數目呢?答案是肯定的,我們根據當前棧狀態可以將 出棧一個元素入棧一個元素 兩種情況的總的數目相加即可得到總的出棧數目。

/**
 * 計算出棧數目
 * - in:目前棧中的元素數目
 * - out:目前已經出棧的元素數目
 * - wait:目前還未進棧的元素數目
 */
int sumOfStackPopSequence(Stack *stack, int in, int out, int wait)
{
    if (out == stack->capacity) { // 元素全部出棧了,返回1
        return 1;
    } 

    int sum = 0;

    if (wait > 0) // 進棧一個元素
        sum += sumOfStackPopSequence(stack, in + 1, out, wait - 1);

    if (in > 0) // 出棧一個元素
        sum += sumOfStackPopSequence(stack, in - 1, out + 1, wait);

    return sum;
}
複製程式碼

求所有出棧序列

題: 給定一個輸入序列 input[] = {1, 2, 3},列印所有可能的出棧序列。

解: 這個有點難,不只是出棧數目,需要列印所有出棧序列,需要用到回溯法,回溯法比簡單的遞迴要難不少,後面有時間再單獨整理一篇回溯法的文章。出棧序列跟入棧出棧的順序有關,對於每個輸入,都會面對兩種情況: 是先將原棧中元素出棧還是先入棧 ,這裡用到兩個棧來實現,其中棧 stk 用於模擬入棧出棧,而棧 output 用於儲存出棧的值。注意退出條件是當遍歷完所有輸入的元素,此時棧 stk 和 output 中都可能有元素,需要先將棧 output 從棧底開始列印完,然後將棧 stk 從棧頂開始列印即可。 另外一點就是,當我們使用的模擬棧 stk 為空時,則這個分支結束。程式碼如下:

void printStackPopSequence(int input[], int i, int n, Stack *stk, Stack *output)
{
    if (i >= n) {
        stackTraverseBottom(output); // output 從棧底開始列印
        stackTraverseTop(stk); // stk 從棧頂開始列印
        printf("\n");
        return;
    }   

    push(stk, input[i]);
    printStackPopSequence(input, i+1, n, stk, output);
    pop(stk);

    if (IS_EMPTY(stk))
        return;

    int v = pop(stk);
    push(output, v); 
    printStackPopSequence(input, i, n, stk, output);
    push(stk, v); 
    pop(output);
}
複製程式碼

資料結構和演算法面試題系列—二叉堆

0.概述

本文要描述的堆是二叉堆。二叉堆是一種陣列物件,可以被視為一棵完全二叉樹,樹中每個結點和陣列中存放該結點值的那個元素對應。樹的每一層都是填滿的,最後一層除外。二叉堆可以用於實現堆排序,優先順序佇列等。本文程式碼地址在 這裡

1.二叉堆定義

使用陣列來實現二叉堆,二叉堆兩個屬性,其中 LENGTH(A) 表示陣列 A 的長度,而 HEAP_SIZE(A) 則表示存放在A中的堆的元素個數,其中 LENGTH(A) <= HEAP_SIZE(A),也就是說雖然 A[0,1,...N-1] 都可以包含有效值,但是 A[HEAP_SIZE(A)-1] 之後的元素不屬於相應的堆。

二叉堆對應的樹的根為 A[0],給定某個結點的下標 i ,可以很容易計算它的父親結點和兒子結點。注意在後面的示例圖中我們標註元素是從1開始計數的,而實現程式碼中是從0開始計數。

#define PARENT(i) ( i > 0 ? (i-1)/2 : 0)
#define LEFT(i) (2 * i + 1)
#define RIGHT(i) (2 * i + 2)
複製程式碼

注:堆對應的樹每一層都是滿的,所以一個高度為 h 的堆中,元素數目最多為 1+2+2^2+...2^h = 2^(h+1) - 1(滿二叉樹),元素數目最少為 1+2+...+2^(h-1) + 1 = 2^h。 由於元素數目 2^h <= n <= 2^(h+1) -1,所以 h <= lgn < h+1,因此 h = lgn 。即一個包含n個元素的二叉堆高度為 lgn

2.保持堆的性質

本文主要建立一個最大堆,最小堆原理類似。為了保持堆的性質,maxHeapify(int A[], int i) 函式讓堆陣列 A 在最大堆中下降,使得以 i 為根的子樹成為最大堆。

void maxHeapify(int A[], int i, int heapSize)
{
    int l = LEFT(i);
    int r = RIGHT(i);

    int largest = i;

    if (l <= heapSize-1 && A[l] > A[i]) {
        largest = l;
    }

    if (r <= heapSize-1 && A[r] > A[largest]) {
        largest = r;
    }

    if (largest != i) { // 最大值不是i,則需要交換i和largest的元素,並遞迴呼叫maxHeapify。
        swapInt(A, i, largest);
        maxHeapify(A, largest, heapSize);
    }
}
複製程式碼
  • 在演算法每一步裡,從元素 A[i]A[left] 以及 A[right] 中選出最大的,將其下標存在 largest 中。如果 A[i] 最大,則以 i 為根的子樹已經是最大堆,程式結束。

  • 否則,i 的某個子結點有最大元素,將 A[i]A[largest] 交換,從而使i及其子女滿足最大堆性質。此外,下標為 largest 的結點在交換後值變為 A[i],以該結點為根的子樹又有可能違反最大堆的性質,所以要對該子樹遞迴呼叫 maxHeapify()函式。

maxHeapify() 函式作用在一棵以 i 為根結點的、大小為 n 的子樹上時,執行時間為調整 A[i]A[left]A[right] 的時間 O(1),加上對以 i 為某個子結點為根的子樹遞迴呼叫 maxHeapify 的時間。i 結點為根的子樹大小最多為 2n/3(最底層剛好半滿的時候),所以可以推得 T(N) <= T(2N/3) + O(1),所以 T(N)=O(lgN)

下圖是一個執行 maxHeapify(heap, 2) 的例子。A[] = {16, 4, 10, 14, 7, 9, 3, 2, 8, 1},堆大小為 10

保持最大堆性質

3.建立最大堆

我們可以知道,陣列 A[0, 1, ..., N-1] 中,A[N/2, ..., N-1] 的元素都是樹的葉結點。如上面圖中的 6-10 的結點都是葉結點。每個葉子結點可以看作是隻含一個元素的最大堆,因此我們只需要對其他的結點呼叫 maxHeapify() 函式即可。

void buildMaxHeap(int A[], int n)
{
    int i;
    for (i = n/2-1; i >= 0; i--) {
        maxHeapify(A, i, n);
    }
}
複製程式碼

之所以這個函式是正確的,我們需要來證明一下,可以使用迴圈不變式來證明。

迴圈不變式:在for迴圈開始前,結點 i+1、i+2...N-1 都是一個最大堆的根。

初始化:for迴圈開始迭代前,i = N/2-1, 結點 N/2, N/2+1, ..., N-1都是葉結點,也都是最大堆的根。

保持:因為結點 i 的子結點標號都比 i 大,根據迴圈不變式的定義,這些子結點都是最大堆的根,所以呼叫 maxHeapify() 後,i 成為了最大堆的根,而 i+1, i+2, ..., N-1仍然保持最大堆的性質。

終止:過程終止時,i=0,因此結點 0, 1, 2, ..., N-1都是最大堆的根,特別的,結點0就是一個最大堆的根。

建立最大堆

雖然每次呼叫 maxHeapify() 時間為 O(lgN),共有 O(N) 次呼叫,但是說執行時間是 O(NlgN) 是不確切的,準確的來說,執行時間為 O(N),這裡就不證明了,具體證明過程參見《演算法導論》。

4.堆排序

開始用 buildMaxHeap() 函式建立一個最大堆,因為陣列最大元素在 A[0],通過直接將它與 A[N-1] 互換來達到最終正確位置。去掉 A[N-1],堆的大小 heapSize 減 1,呼叫 maxHeapify(heap, 0, --heapSize) 保持最大堆的性質,直到堆的大小由 N 減到 1。

void heapSort(int A[], int n)
{
    buildMaxHeap(A, n);
    int heapSize = n;
    int i;
    for (i = n-1; i >= 1; i--) {
        swapInt(A, 0, i);
        maxHeapify(A, 0, --heapSize);
    }
}
複製程式碼

5.優先順序佇列

最後實現一個最大優先順序佇列,主要有四種操作,分別如下所示:

  • insert(PQ, key):將 key 插入到佇列中。
  • maximum(PQ): 返回佇列中最大關鍵字的元素
  • extractMax(PQ):去掉並返回佇列中最大關鍵字的元素
  • increaseKey(PQ, i, key):將佇列 i 處的關鍵字的值增加到 key

這裡定義一個結構體 PriorityQueue 便於操作。

typedef struct PriorityQueue {
    int capacity;
    int size;
    int elems[];
} PQ;
複製程式碼

最終優先順序佇列的操作實現程式碼如下:

/**
 * 從陣列建立優先順序佇列
 */
PQ *newPQ(int A[], int n)
{
    PQ *pq = (PQ *)malloc(sizeof(PQ) + sizeof(int) * n);
    pq->size = 0;
    pq->capacity = n;

    int i;
    for (i = 0; i < pq->capacity; i++) {
        pq->elems[i] = A[i];
        pq->size++;
    }
    buildMaxHeap(pq->elems, pq->size);

    return pq;
}

int maximum(PQ *pq)
{
    return pq->elems[0];
}

int extractMax(PQ *pq)
{
    int max = pq->elems[0];
    pq->elems[0] = pq->elems[--pq->size];
    maxHeapify(pq->elems, 0, pq->size);
    return max;
}

PQ *insert(PQ *pq, int key)
{
    int newSize = ++pq->size;
    if (newSize > pq->capacity) {
        pq->capacity = newSize * 2;
        pq = (PQ *)realloc(pq, sizeof(PQ) + sizeof(int) * pq->capacity);
    }
    pq->elems[newSize-1] = INT_MIN;
    increaseKey(pq, newSize-1, key);
    return pq;
}

void increaseKey(PQ *pq, int i, int key)
{
    int *elems = pq->elems;
    elems[i] = key;

    while (i > 0 && elems[PARENT(i)] < elems[i]) {
        swapInt(elems, PARENT(i), i);
        i = PARENT(i);
    }
}
複製程式碼

資料結構和演算法面試題系列—二叉樹基礎

0.概述

在說二叉樹前,先來看看什麼是樹。樹中基本單位是結點,結點之間的連結,稱為分支。一棵樹最上面的結點稱之為根節點,而下面的結點為子結點。一個結點可以有 0 個或多個子結點,沒有子結點的結點我們稱之為葉結點。

二叉樹是指子結點數目不超過 2 個的樹,它是一種很經典的資料結構。而二叉搜尋樹(BST)是有序的二叉樹,BST 需要滿足如下條件:

  • 若任意結點的左子樹不空,則左子樹上所有節點的值均小於它的根節點的值;
  • 若任意結點的右子樹不空,則右子樹上所有節點的值均大於或等於它的根節點的值;(有些書裡面定義為 BST 不能有相同值結點,本文將相同值結點插入到右子樹)
  • 任意結點的左、右子樹也分別為二叉查詢樹;

本文接下來會從定義,二叉搜尋樹的增刪查以及二叉樹的遞迴和非遞迴遍歷進行整理。 下一篇文章會對二叉樹相關的經典面試題進行全面解析,本文程式碼在 這裡

1.定義

我們先定義一個二叉樹的結點,如下:

typedef struct BTNode {
    int value;
    struct BTNode *left;
    struct BTNode *right;
} BTNode;
複製程式碼

其中 value 儲存值,leftright 指標分別指向左右子結點。二叉搜尋樹跟二叉樹可以使用同一個結構,只是在插入或者查詢時會有不同。

2.基本操作

接下來看看二叉樹和二叉查詢樹的一些基本操作,包括 BST 插入結點,BST 查詢結點,BST 最大值和最小值,二叉樹結點數目和高度等。二叉查詢樹( BST )特有的操作都在函式前加了 bst 字首區分,其他函式則是二叉樹通用的。

1) 建立結點

分配記憶體,初始化值即可


/**
 * 建立BTNode
 */
BTNode *newNode(int value)
{
    BTNode *node = (BTNode *)malloc(sizeof(BTNode));
    node->value = value;
    node->left = node->right = NULL;
    return node;
}
複製程式碼

2) BST 插入結點

插入結點可以用遞迴或者非遞迴實現,如果待插入值比根節點值大,則插入到右子樹中,否則插入到左子樹中。如下圖所示(圖來自參考資料1,2,3):

BST插入結點

/**
 * BST中插入值,遞迴方法
 */
/**
 * BST中插入結點,遞迴方法
 */
BTNode *bstInsert(BTNode *root, int value)
{
    if (!root)
        return newNode(value);

    if (root->value > value) {
        root->left = bstInsert(root->left, value);
    } else {
        root->right = bstInsert(root->right, value);
    }
    return root;
}

/**
 * BST中插入結點,非遞迴方法
 */
BTNode *bstInsertIter(BTNode *root, int value)
{
    BTNode *node = newNode(value);

    if (!root)
        return node;

    BTNode *current = root, *parent = NULL;

    while (current) {
        parent = current;
        if (current->value > value)
            current = current->left;
        else
            current = current->right;
    }

    if (parent->value >= value)
        parent->left = node;
    else
        parent->right = node;

    return root;
}
複製程式碼

3) BST 刪除結點

刪除結點稍微複雜一點,要考慮3種情況:

  • 刪除的是葉子結點,好辦,移除該結點並將該葉子結點的父結點的 left 或者 right 指標置空即可。

BST刪除結點-葉子結點

  • 刪除的結點有兩個子結點,則需要找到該結點左子樹的最大結點(使用後面的 bstSearchIter 函式),並將其值替換到待刪除結點中,然後遞迴呼叫刪除函式刪除該結點左子樹最大結點即可。

BST刪除結點-有兩個子結點

  • 刪除的結點只有一個子結點,則移除該結點並將其子結點的值填充到該刪除結點即可(需要判斷是左孩子還是右孩子結點)。

BST刪除結點-一個子結點

/**
 * BST中刪除結點
 */
BTNode *bstDelete(BTNode *root, int value)
{
    BTNode *parent = NULL, *current = root;
    BTNode *node = bstSearchIter(root, &parent, value);
    if (!node) {
        printf("Value not found\n");
        return root;
    }

    if (!node->left && !node->right) {
        // 情況1:待刪除結點是葉子結點
        if (node != root) {
            if (parent->left == node) {
                parent->left = NULL;
            } else {
                parent->right = NULL;
            }
        } else {
            root = NULL;
        }
        free(node);
    } else if (node->left && node->right) {
        // 情況2:待刪除結點有兩個子結點
        BTNode *predecessor = bstMax(node->left);
        bstDelete(root, predecessor->value);
        node->value = predecessor->value;
    } else {
        // 情況3:待刪除結點只有一個子結點
        BTNode *child = (node->left) ? node->left : node->right;
        if (node != root) {
            if (node == parent->left)
                parent->left = child;
            else
                parent->right = child;
        } else {
            root = child;
        }
        free(node);
    }
    return root;
}
複製程式碼

4) BST 查詢結點

注意在非遞迴查詢中會將父結點也記錄下來。

BST查詢結點

/**
 * BST查詢結點-遞迴
 */
BTNode *bstSearch(BTNode *root, int value)
{
    if (!root) return NULL; 

    if (root->value == value) {
        return root;
    } else if (root->value > value) {
        return bstSearch(root->left, value);
    } else {
        return bstSearch(root->left, value);
    }
}

/**
 * BST查詢結點-非遞迴
 */
BTNode *bstSearchIter(BTNode *root, BTNode **parent, int value)
{
    if (!root) return NULL;

    BTNode *current = root;

    while (current && current->value != value) {
        *parent = current;
        if (current->value > value)
            current = current->left;
        else
            current = current->right;
    }

    return current;
}
複製程式碼

5)BST 最小值結點和最大值結點

最小值結點從左子樹遞迴查詢,最大值結點從右子樹遞迴找。

/**
 * BST最小值結點
 */
BTNode *bstMin(BTNode *root)
{
    if (!root->left)
        return root;

    return bstMin(root->left);
}

/**
 * BST最大值結點
 */
BTNode *bstMax(BTNode *root)
{
    if (!root->right)
        return root;

    return bstMax(root->right);
}

複製程式碼

6)二叉樹結點數目和高度

/**
 * 二叉樹結點數目
 */
int btSize(BTNode *root)
{
    if (!root) return 0;
    
    return btSize(root->left) + btSize(root->right) + 1;
}

/**
 * 二叉樹高度
 */
int btHeight(BTNode *root)
{
    if (!root) return 0;

    int leftHeight = btHeight(root->left);
    int rightHeight = btHeight(root->right);
    int maxHeight = leftHeight > rightHeight ? leftHeight+1 : rightHeight+1;
    return maxHeight;
}
複製程式碼

3.二叉樹遍歷

遞迴遍歷-先序、中序、後序、層

二叉樹遍歷的遞迴實現比較簡單,直接給出程式碼。這裡值得一提的是層序遍歷,先是計算了二叉樹的高度,然後呼叫的輔助函式依次遍歷每一層的結點,這種方式比較容易理解,雖然在時間複雜度上會高一些。

/**
 * 二叉樹先序遍歷
 */
void preOrder(BTNode *root)
{
    if (!root) return;

    printf("%d ", root->value);
    preOrder(root->left);
    preOrder(root->right);
}

/**
 * 二叉樹中序遍歷
 */
void inOrder(BTNode *root)
{
    if (!root) return;

    inOrder(root->left);
    printf("%d ", root->value);
    inOrder(root->right);
}

/**
 * 二叉樹後序遍歷
 */
void postOrder(BTNode *root)
{
    if (!root) return;

    postOrder(root->left);
    postOrder(root->right);
    printf("%d ", root->value);
}

/**
 * 二叉樹層序遍歷
 */
void levelOrder(BTNode *root)
{
    int btHeight = height(root);    
    int level;
    for (level = 1; level <= btHeight; level++) {
        levelOrderInLevel(root, level);
    }
}

/**
 * 二叉樹層序遍歷輔助函式-列印第level層的結點
 */
void levelOrderInLevel(BTNode *root, int level)
{
    if (!root) return;

    if (level == 1) {
        printf("%d ", root->value);
        return;
    }
    levelOrderInLevel(root->left, level-1);
    levelOrderInLevel(root->right, level-1);
}
複製程式碼

非遞迴遍歷-先序、中序、後序、層序

  • 非遞迴遍歷裡面先序遍歷最簡單,使用一個棧來儲存結點,先訪問根結點,然後將右孩子和左孩子依次壓棧,然後迴圈這個過程。中序遍歷稍微複雜一點,需要先遍歷完左子樹,然後才是根結點,最後才是右子樹。
  • 後序遍歷使用一個棧的方法postOrderIter()會有點繞,也易錯。所以在面試時推薦用兩個棧的版本postOrderIterWith2Stack(),容易理解,也比較好寫。
  • 層序遍歷用了佇列來輔助儲存結點,還算簡單。
  • 這裡我另外實現了一個佇列 BTNodeQueue 和棧 BTNodeStack,用於二叉樹非遞迴遍歷。

/*********************/
/** 二叉樹遍歷-非遞迴 **/
/*********************/
/**
 * 先序遍歷-非遞迴
 */
void preOrderIter(BTNode *root)
{
    if (!root) return;

    int size = btSize(root);
    BTNodeStack *stack = stackNew(size);

    push(stack, root);
    while (!IS_EMPTY(stack)) {
        BTNode *node = pop(stack);
        printf("%d ", node->value);

        if (node->right)
            push(stack, node->right);

        if (node->left)
            push(stack, node->left);
    }
    free(stack);
}

/**
 * 中序遍歷-非遞迴
 */
void inOrderIter(BTNode *root)
{
    if (!root) return;

    BTNodeStack *stack = stackNew(btSize(root));

    BTNode *current = root;
    while (current || !IS_EMPTY(stack)) {
        if (current) {
            push(stack, current);
            current = current->left;
        } else {
            BTNode *node = pop(stack);
            printf("%d ", node->value);
            current = node->right;
        }
    }
    free(stack);
}

/**
 * 後續遍歷-使用一個棧非遞迴
 */
void postOrderIter(BTNode *root)
{
    BTNodeStack *stack = stackNew(btSize(root));
    BTNode *current = root;
    do { 
        // 移動至最左邊結點
        while (current) { 
            // 將該結點右孩子和自己入棧
            if (current->right) 
                push(stack, current->right); 
            push(stack, current); 
  
            // 往左子樹遍歷
            current = current->left; 
        } 
  
        current = pop(stack); 
  
        if (current->right && peek(stack) == current->right) { 
            pop(stack);
            push(stack, current);
            current = current->right;
        } else { 
            printf("%d ", current->value); 
            current = NULL; 
        } 
    } while (!IS_EMPTY(stack)); 
}

/**
 * 後續遍歷-使用兩個棧,更好理解一點。
 */
void postOrderIterWith2Stack(BTNode *root)
{
    if (!root) return;

    BTNodeStack *stack = stackNew(btSize(root));
    BTNodeStack *output = stackNew(btSize(root));

    push(stack, root);
    BTNode *node;

    while (!IS_EMPTY(stack)) {
        node = pop(stack);
        push(output, node);

        if (node->left)
            push(stack, node->left);

        if (node->right)
            push(stack, node->right);
    }

    while (!IS_EMPTY(output)) {
        node = pop(output);
        printf("%d ", node->value);
    }
}

/**
 * 層序遍歷-非遞迴
 */
void levelOrderIter(BTNode *root)
{
    if (!root) return;

    BTNodeQueue *queue = queueNew(btSize(root));
    enqueue(queue, root);

    while (1) {
        int nodeCount = QUEUE_SIZE(queue);
        if (nodeCount == 0)
            break;
btHeight
        while (nodeCount > 0) {
            BTNode *node = dequeue(queue);
            printf("%d ", node->value);

            if (node->left)
                enqueue(queue, node->left);

            if (node->right)
                enqueue(queue, node->right);

            nodeCount--;
        }
        printf("\n");
    }
}
複製程式碼

資料結構和演算法面試題系列—二叉樹面試題彙總

0.概述

繼上一篇總結了二叉樹的基礎操作後,這一篇文章彙總下常見的二叉樹相關面試題,主要分為判斷類、構建類、儲存類、查詢類、距離類、混合類這六類大問題。本文所有程式碼在 這裡

1.判斷類問題

判斷類問題主要分下下判斷二叉樹是否是二叉搜尋樹、二叉完全樹,以及兩棵二叉樹是否同構這三個問題。

1.1 判斷一棵二叉樹是否是二叉搜尋樹(BST)

題: 給定一棵二叉樹,判斷該二叉樹是否是二叉搜尋樹。

二叉搜尋樹是一種二叉樹,但是它有附加的一些約束條件,這些約束條件必須對每個結點都成立:

  • 結點的左子樹所有結點的值都小於等於該結點的值。
  • 結點的右子樹所有結點的值都大於該結點的值。
  • 結點的左右子樹同樣都必須是二叉搜尋樹。

一種錯誤解法

初看這個問題,容易這麼實現:假定當前結點值為 k,對於二叉樹中每個結點,判斷其左孩子的值是否小於 k,其右孩子的值是否大於 k。如果所有結點都滿足該條件,則該二叉樹是一棵二叉搜尋樹。實現程式碼如下:

int isBSTError(BTNode *root)
{
    if (!root) return 1;  
      
    if (root->left && root->left->value >= root->value)  
        return 0;  
      
    if (root->right && root->right->value < root->value)  
        return 0;  
    
    if (!isBSTError(root->left) || !isBSTError(root->right))  
        return 0;  
      
    return 1;  
}
複製程式碼

很不幸,這種做法是錯誤的,如下面這棵二叉樹滿足上面的條件,但是它並不是二叉搜尋樹。

    10
   /  \
  5    15     -------- binary tree(1) 符合上述條件的二叉樹,但是並不是二叉搜尋樹。
      /  \
     6    20

複製程式碼

解1:蠻力法

上面的錯誤解法是因為判斷不完整導致,可以這樣來判斷:

  • 判斷結點左子樹最大值是否大於等於結點的值,如果是,則該二叉樹不是二叉搜尋樹,否則繼續下一步判斷。。
  • 判斷右子樹最小值是否小於或等於結點的值,如果是,則不是二叉搜尋樹,否則繼續下一步判斷。
  • 遞迴判斷左右子樹是否是二叉搜尋樹。(程式碼中的 bstMaxbstMin 函式功能分別是返回二叉樹中的最大值和最小值結點,這裡假定二叉樹為二叉搜尋樹,實際返回的不一定是最大值和最小值結點)
int isBSTUnefficient(BTNode *root)
{
    if (!root) return 1;
    
    if (root->left && bstMax(root->left)->value >= root->value)
        return 0;

    if (root->right && bstMin(root->right)->value < root->value)
        return 0;

    if (!isBSTUnefficient(root->left) || !isBSTUnefficient(root->right))
        return 0;

    return 1;
}
複製程式碼

解2:一次遍歷法

以前面提到的 binary tree(1) 為例,當我們遍歷到結點 15 時,我們知道右子樹結點值肯定都 >=10。當我們遍歷到結點 15 的左孩子結點 6 時,我們知道結點 15 的左子樹結點值都必須在 1015 之間。顯然,結點 6 不符合條件,因此它不是一棵二叉搜尋樹。

int isBSTEfficient(BTNode* root, BTNode *left, BTNode *right) 
{
   if (!root) return 1;

   if (left && root->value <= left->value)
       return 0;

   if (right && root->value > right->value)
       return 0;

   return isBSTEfficient(root->left, left, root) && isBSTEfficient(root->right, root, right);
}
複製程式碼

解3:中序遍歷解法

還可以模擬樹的中序遍歷來判斷BST,可以直接將中序遍歷的結果存到一個輔助陣列,然後判斷陣列是否有序即可判斷是否是BST。當然,我們可以不用輔助陣列,在遍歷時通過保留前一個指標 prev,據此來實現判斷BST的解法,初始時 prev = NULL

int isBSTInOrder(BTNode *root, BTNode *prev) 
{ 
    if (!root) return 1; 
      
    if (!isBSTInOrder(root->left, prev)) 
        return 0; 
  
    if (prev && root->value < prev->value) 
        return 0; 
  
    return isBSTInOrder(root->right, root); 
} 
複製程式碼

1.2 判斷二叉樹是否是完全二叉樹

題: 給定一棵二叉樹,判斷該二叉樹是否是完全二叉樹(完全二叉樹定義:若設二叉樹的深度為 h,除第 h 層外,其它各層 (1~h-1) 的結點數都達到最大個數,第 h 層所有的結點都連續集中在最左邊,這就是完全二叉樹,如下圖所示)。

解1:常規解法-中序遍歷

先定義一個 滿結點 的概念:即一個結點存在左右孩子結點,則該結點為滿結點。在程式碼中定義變數 flag 來標識是否發現非滿結點,為1表示該二叉樹存在非滿結點。完全二叉樹如果存在非滿結點,則根據層序遍歷佇列中剩下結點必須是葉子結點,且如果一個結點的左孩子為空,則右孩子結點也必須為空。

int isCompleteBTLevelOrder(BTNode *root)
{
    if (!root) return 1;

    BTNodeQueue *queue = queueNew(btSize(root));
    enqueue(queue, root);

    int flag = 0;
    while (QUEUE_SIZE(queue) > 0) {
        BTNode *node = dequeue(queue);
        if (node->left) {
            if (flag) return 0;
            enqueue(queue, node->left);
        } else {
            flag = 1;
        }

        if (node->right) {
            if (flag) return 0;
            enqueue(queue, node->right);
        } else {
            flag = 1;
        }
    }
    return 1;
}
複製程式碼

解2:更簡單的方法-判斷結點序號法

更簡單的方法是判斷結點序號法,因為完全二叉樹的結點序號都是有規律的,如結點 i 的左右子結點序號為 2i+12i+2,如根結點序號是 0,它的左右子結點序號是 12 (如果都存在的話)。我們可以計算二叉樹的結點數目,然後依次判斷所有結點的序號,如果不是完全二叉樹,那肯定會存在結點它的序號大於等於結點數目的。如前面提到的 binary tree(1) 就不是完全二叉樹。

    10(0)
   /  \
  5(1) 15(2)    - 結點數目為5,如果是完全二叉樹結點最大的序號應該是4,而它的是6,所以不是。
      /  \
     6(5) 20(6)
複製程式碼

實現程式碼如下:

int isCompleteBTIndexMethod(BTNode *root, int index, int nodeCount)
{
    if (!root) return 1;

    if (index >= nodeCount)
        return 0;

    return (isCompleteBTIndexMethod(root->left, 2*index+1, nodeCount) &&
            isCompleteBTIndexMethod(root->right, 2*index+2, nodeCount));
}
複製程式碼

1.3 判斷平衡二叉樹

題: 判斷一棵二叉樹是否是平衡二叉樹。所謂平衡二叉樹,指的是其任意結點的左右子樹高度之差不大於1。

     __2__
    /     \
   1       4       ---- 平衡二叉樹示例
    \     / \
     3   5   6
複製程式碼

解1:自頂向下方法

判斷一棵二叉樹是否是平衡的,對每個結點計算左右子樹高度差是否大於1即可,時間複雜度為O(N^2)

int isBalanceBTTop2Down(BTNode *root)
{
    if (!root) return 1;

    int leftHeight = btHeight(root->left);
    int rightHeight = btHeight(root->right);
    int hDiff = abs(leftHeight - rightHeight);

    if (hDiff > 1) return 0;

    return isBalanceBTTop2Down(root->left) && isBalanceBTTop2Down(root->right);
}
複製程式碼

解2:自底向上方法

因為解1會重複的遍歷很多結點,為此我們可以採用類似後序遍歷的方式,自底向上來判斷左右子樹的高度差,這樣時間複雜度為 O(N)

int isBalanceBTDown2Top(BTNode *root, int *height)
{
    if (!root) {
        *height = 0;
        return 1;
    }

    int leftHeight, rightHeight;
    if (isBalanceBTDown2Top(root->left, &leftHeight) &&
        isBalanceBTDown2Top(root->right, &rightHeight)) {
        int diff = abs(leftHeight - rightHeight);
        return diff > 1 ? 0 : 1;
    }
    return 0;
}
複製程式碼

1.4 判斷兩棵二叉樹是否同構

題: 給定兩棵二叉樹,根結點分別為 t1t2,判定這兩棵二叉樹是否同構。所謂二叉樹同構就是指它們的結構相同,如下二叉樹 (1) 和 (2) 是同構的,而它們和 (3) 是不同結構的:

    5               9             6
   / \             / \           / \   
  1   2           7   12        5   9             
 / \             / \                 \
4   3           5   8                10

  二叉樹(1)        二叉樹(2)      二叉樹(3)

複製程式碼

解: 二叉樹結構是否相同,還是遞迴實現,先判斷根結點是否同構,然後再判斷左右子樹。

int isOmorphism(BTNode *t1, BTNode *t2)
{
    if (!t1 || !t2)
        return (!t1) && (!t2);

    return isOmorphism(t1->left, t2->left) && isOmorphism(t1->right, t2->right);
}
複製程式碼

2.構建類問題

構建類問題主要是使用二叉樹的兩種遍歷順序來確定二叉樹的另外一種遍歷順序問題。在上一篇文章中我們分析過二叉樹的先序、中序、後序遍歷的遞迴和非遞迴實現。那麼,是否可以根據先序、中序或者先序、後序或者中序、後序唯一確定一棵二叉樹呢?

答案是 在沒有重複值的二叉樹中, 根據先序遍歷和後序遍歷無法唯一確定一棵二叉樹,而根據先序、中序或者中序、後序遍歷是可以唯一確定一棵二叉樹的

1)先序和後序遍歷無法唯一確定一棵二叉樹

一個簡單的例子如下,這兩棵二叉樹的先序遍歷和後序遍歷相同,由此可以證明先序遍歷和後序遍歷無法唯一確定一棵二叉樹。

  1           1
 /           /
2           2
\          /
 3        3
 
先序遍歷: 1 2 3
後序遍歷: 3 2 1
複製程式碼

2)先序和中序遍歷可以唯一確定二叉樹

簡單證明:因為先序遍歷的第一個元素是根結點,該元素將二叉樹中序遍歷序列分成兩部分,左邊(假設有 L 個元素)表示左子樹,若左邊無元素,則說明左子樹為空;右邊(假設有R個元素)是右子樹,若為空,則右子樹為空。根據前序遍歷中"根-左子樹-右子樹"的順序,則由從先序序列的第二元素開始的 L 個結點序列和中序序列根左邊的 L 個結點序列構造左子樹,由先序序列最後 R 個元素序列與中序序列根右邊的 R 個元素序列構造右子樹。

3)中序和後序遍歷可以唯一確定二叉樹

簡單證明: 假定二叉樹結點數為 n,假定中序遍歷為 S1, S2, ..., Sn,而後序遍歷為 P1, P2, ..., Pn,因為後序遍歷最後一個結點 Pn 是根結點,則可以根據 Pn 將中序遍歷分為兩部分,則其中左邊 L 個結點是左子樹結點,右邊 R 個結點是右子樹結點,則後序遍歷中的 1~L 個結點是左子樹的後序遍歷,由此 PL 是左子樹的根,與前面同理可以將中序遍歷分成兩部分,直到最終確定該二叉樹。

2.1 根據先序、中序遍歷構建二叉樹

題: 給定一棵二叉樹的先序和中序遍歷序列,請構建該二叉樹(注:二叉樹沒有重複的值)。

先序遍歷: 7 10 4 3 1 2 8 11
中序遍歷: 4 10 3 1 7 11 8 2
二叉樹如下:
          7
        /    \
     10        2
    /   \      /
   4    3      8
         \    /
          1  11

複製程式碼

解: 根據前面的分析來解這個問題。

  • 先序遍歷的第一個結點總是根結點。如上圖中的二叉樹,根結點為 7 。
  • 可以觀察到在中序遍歷中,根結點 7 是第 4 個值(從 0 開始算起)。由於中序遍歷順序為:左子樹,根結點,右子樹。所以根結點7左邊的 {4,10,3,1} 這四個結點屬於左子樹,而根結點7右邊的 {11,8,2} 屬於右子樹。
  • 據此可以寫出遞迴式了。注意關於如何得到根結點在中序遍歷中的位置程式碼中使用線性掃描查詢位置,每次查詢需要 O(N) 的時間,整個演算法需要 O(N^2) 的時間。如果要提高效率,也可以雜湊表來儲存與查詢根結點在中序遍歷中的位置,每次查詢只需要 O(1) 的時間,這樣構建整棵樹只需要 O(N)的時間。
  • 呼叫方法為 buildBTFromPreInOrder(preorder, inorder, n, 0, n);,其中 preorderinorder 分別為先序中序遍歷陣列,n 為陣列大小。
/**
 * 輔助函式,查詢根結點在中序遍歷中的位置。
 */
int findBTRootIndex(int inorder[], int count, int rootVal)
{
    int i;
    for (i = 0; i < count; i++) {
        if (inorder[i] == rootVal)
            return i;
    }
    return -1;
}

/**
/**
 * 根據先序和中序遍歷構建二叉樹
 */
BTNode *buildBTFromPreInOrder(int preorder[], int inorder[], int n, int offset, int count)
{
    if (n == 0) return NULL;

    int rootVal = preorder[0];
    int rootIndex = findBTRootIndex(inorder, count, rootVal);
    int leftCount = rootIndex - offset; // 左子樹結點數目
    int rightCount = n - leftCount - 1; // 右子樹結點數目

    BTNode *root = btNewNode(rootVal);
    root->left = buildBTFromPreInOrder(preorder+1, inorder, leftCount, offset, count);
    root->right = buildBTFromPreInOrder(preorder+leftCount+1, inorder, rightCount, offset+leftCount+1, count);
    return root;
}
複製程式碼

根據中序、後序遍歷構建二叉樹

題: 給定一棵二叉樹的中序和後序遍歷序列,請構建該二叉樹(注:二叉樹沒有重複的值)。

中序遍歷: 4 10 3 1 7 11 8 2
後序遍歷: 4 1 3 10 11 8 2 7
二叉樹如下:
          7
        /    \
     10        2
    /   \      /
   4    3      8
         \    /
          1  11
複製程式碼

解: 跟前面一題類似,只是這裡根結點是從後序遍歷陣列的最後一個元素取。


/**
 * 根據中序和後序遍歷構建二叉樹
 */
BTNode *buildBTFromInPostOrder(int postorder[], int inorder[], int n, int offset, int count)
{
    if (n == 0) return NULL;

    int rootVal = postorder[n-1];
    int rootIndex = findBTRootIndex(inorder, count, rootVal);
    int leftCount = rootIndex - offset; // 左子樹結點數目
    int rightCount = n - leftCount - 1; // 右子樹結點數目

    BTNode *root = btNewNode(rootVal);
    root->left = buildBTFromInPostOrder(postorder, inorder, leftCount, offset, count);
    root->right = buildBTFromInPostOrder(postorder+leftCount, inorder, rightCount, offset+leftCount+1, count);
    return root;
}
複製程式碼

3.儲存類問題

3.1 二叉搜尋樹儲存和恢復

題: 設計一個演算法,將一棵二叉搜尋樹(BST)儲存到檔案中,需要能夠從檔案中恢復原來的二叉搜尋樹,注意演算法的時空複雜度。

      30
     /   \   
   20    40
  /      / \
 10    35  50
複製程式碼

思路

二叉樹遍歷演算法有先序遍歷、中序遍歷、後序遍歷演算法等。但是它們中間哪一種能夠用於儲存BST到檔案中並從檔案中恢復原來的BST,這是個要考慮的問題。

假定用中序遍歷,因為這棵BST的中序遍歷為 10 20 30 35 40 50,可能的結構是下面這樣,因此 中序遍歷不符合要求

         50
         /      
        40 
       /   
      35
     /
    30
   /
  20
 /
10
複製程式碼

既然中序遍歷不行,後序遍歷如何?後序遍歷該BST可以得到:10 20 35 50 40 30 。讀取這些結點並構造出原來的BST是個難題,因為在構造二叉樹時是先構造父結點再插入孩子結點,而後序遍歷序列是先讀取到孩子結點然後才是父結點,所以 後續遍歷也不符合條件

綜合看來,只有先序遍歷滿足條件 。該BST的先序遍歷是 30 20 10 40 35 50 。我們觀察到重要的一點就是:一個結點的父親結點總是在該結點之前輸出 。有了這個觀察,我們從檔案中讀取BST結點序列後,總是可以在構造孩子結點之前構造它們的父結點。將BST寫入到檔案的程式碼跟先序遍歷一樣。

那麼讀取恢復怎麼做呢?使用二叉搜尋樹 bstInsert() 方法執行 N 次插入操作即可,如果二叉搜尋樹平衡的話每次插入需要時間 O(lgN),共需要 O(NlgN) 的時間,而最壞情況下為 O(N^2)

/**
 * 儲存二叉樹到檔案中-使用先序遍歷
 */
void bstSave(BTNode *root, FILE *fp)
{
    if (!root) return;

    char temp[30];
    sprintf(temp, "%d\n", root->value);
    fputs(temp, fp);
    bstSave(root->left, fp);
    bstSave(root->right, fp);
}

/**
 * 從檔案中恢復二叉樹
 */
BTNode *bstRestore(FILE *fp)
{
    BTNode *root = NULL;
    char *s;
    char buf[30];
    while ((s = fgets(buf, 30, fp))) {
        int nodeValue = atoi(s);
        root = bstInsert(root, nodeValue);
    }
    return root;
}
複製程式碼

3.2 二叉樹儲存和恢復

題: 設計一個演算法能夠實現二叉樹(注意,不是二叉搜尋樹BST)儲存和恢復。

解: 3.1節提到過使用先序遍歷可以儲存和恢復二叉搜尋樹,而這個題目是針對二叉樹,並不是BST,所以不能用前面的方式。不過,我們可以採用先序遍歷的思想,只是在這裡需要改動。為了能夠在重構二叉樹時結點能夠插入到正確的位置,在使用先序遍歷儲存二叉樹到檔案中的時候需要把 NULL 結點也儲存起來(可以使用特殊符號如 # 來標識 NULL 結點)。

注意: 本題採用 # 儲存 NULL 結點的方法存在缺陷,如本方法中二叉樹結點值就不能是 #。如果要能儲存各種字元,則需要採用其他方法來實現了。

     30
   /    \   
  10    20
 /     /  \
50    45  35
複製程式碼

如上面這棵二叉樹,儲存到檔案中則為 30 10 50 # # # 20 45 # # 35 # #。於是,儲存和恢復實現的程式碼如下:

/**
 * 儲存二叉樹到檔案中
 */
void btSave(BTNode *root, FILE *fp)
{
    if (!root) {
        fputs("#\n", fp);
    } else {
        char temp[30];
        sprintf(temp, "%d\n", root->value);
        fputs(temp, fp);
        btSave(root->left, fp);
        btSave(root->right, fp);
    }
}

/**
 * 從檔案恢復二叉樹
 */
BTNode *btRestore(BTNode *root, FILE *fp)
{
    char buf[30];
    char *s = fgets(buf, 30, fp);
    if (!s || strcmp(s, "#\n") == 0)
        return NULL; 

    int nodeValue = atoi(s);
    root = btNewNode(nodeValue);
    root->left = btRestore(root->left, fp);
    root->right = btRestore(root->right, fp);
    return root;
}
複製程式碼

4.查詢類問題

查詢類問題主要包括:查詢二叉樹/二叉搜尋樹的最低公共祖先結點,或者是二叉樹中的最大的子樹且該子樹為二叉搜尋樹等。

4.1 二叉搜尋樹最低公共祖先結點

題: 給定一棵二叉搜尋樹( BST ),找出樹中兩個結點的最低公共祖先結點( LCA )。如下面這棵二叉樹結點 2 和 結點 8 的 LCA 是 6,而結點 4 和 結點 2 的 LCA 是 2。

        ______6______
       /              \
    __2__            __8__
   /     \          /      \
   0      4         7       9
         /  \
         3   5

複製程式碼

解: 我們從頂往下遍歷二叉搜尋樹時,對每個遍歷到的結點,待求 LCA 的兩個結點可能有如下四種分佈情況:

  • 1)兩個結點都在樹的左子樹中: LCA一定在當前遍歷結點的左子樹中。
  • 2)兩個結點都在樹的右子樹中: LCA一定在當前遍歷結點右子樹中。
  • 3)一個結點在樹的左邊,一個結點在樹的右邊: LCA就是當前遍歷的結點。
  • 4)當前結點等於這兩個結點中的一個: LCA也是當前遍歷的結點。
BTNode *bstLCA(BTNode *root, BTNode *p, BTNode *q)
{
    if (!root || !p || !q) return NULL;

    int maxValue = p->value >= q->value ? p->value : q->value;
    int minValue = p->value < q->value ? p->value : q->value;

    if (maxValue < root->value) {
        return bstLCA(root->left, p, q);
    } else if (minValue > root->value) {
        return bstLCA(root->right, p, q);
    } else {
        return root;
    }
}
複製程式碼

4.2 二叉樹(不一定是 BST )最低公共祖先結點

題: 給定二叉樹中的兩個結點,輸出這兩個結點的最低公共祖先結點(LCA)。注意,該二叉樹不一定是二叉搜尋樹。

        _______3______
       /              \
    ___5__          ___1__
   /      \        /      \
   6       2       0       8
         /  \
         7   4
複製程式碼

解1:自頂向下方法

因為不一定是BST,所以不能根據值大小來判斷,不過總體思路是一樣的:我們可以從根結點出發,判斷當前結點的左右子樹是否包含這兩個結點。

  • 如果左子樹包含兩個結點,則它們的最低公共祖先結點也一定在左子樹中。
  • 如果右子樹包含兩個結點,則它們的最低公共祖先結點也一定在右子樹中。
  • 如果一個結點在左子樹,而另一個結點在右子樹中,則當前結點就是它們的最低公共祖先結點。

因為對每個結點都要重複判斷結點 pq 的位置,總的時間複雜度為 O(N^2),為此,我們可以考慮找一個效率更高的方法。

/**
 * 二叉樹最低公共祖先結點-自頂向下解法 O(N^2)
 */
BTNode *btLCATop2Down(BTNode *root, BTNode *p, BTNode *q)
{
    if (!root || !p || !q) return NULL;

    if (btExist(root->left, p) && btExist(root->left, q)) {
        return btLCATop2Down(root->left, p, q);
    } else if (btExist(root->right, p) && btExist(root->right, q)) {
        return btLCATop2Down(root->right, p, q);
    } else {
        return root;
    }
}

/**
 * 二叉樹結點存在性判斷
 */
int btExist(BTNode *root, BTNode *node)
{
    if (!root) return 0;

    if (root == node) return 1;

    return btExist(root->left, node) || btExist(root->right, node);
}
複製程式碼

解2:自底向上方法

因為自頂向下方法有很多重複的判斷,於是有了這個自底向上的方法。自底向上遍歷結點,一旦遇到結點等於 p 或者 q,則將其向上傳遞給它的父結點。父結點會判斷它的左右子樹是否都包含其中一個結點,如果是,則父結點一定是這兩個結點 pq 的 LCA。如果不是,我們向上傳遞其中的包含結點 p 或者 q 的子結點,或者 NULL (如果左右子樹都沒有結點 p 或 q )。該方法時間複雜度為 O(N)。

/**
 * 二叉樹最低公共祖先結點-自底向上解法 O(N)
 */
BTNode *btLCADown2Top(BTNode *root, BTNode *p, BTNode *q)
{
    if (!root) return NULL;

    if (root == p || root == q) return root;

    BTNode *left = btLCADown2Top(root->left, p, q);
    BTNode *right = btLCADown2Top(root->right, p, q);
    if (left && right)
        return root;  // 如果p和q位於不同的子樹  

    return left ? left: right;  //p和q在相同的子樹,或者p和q不在子樹中
}
複製程式碼

4.3 二叉樹的最大二叉搜尋子樹

題: 找出二叉樹中最大的子樹,該子樹為二叉搜尋樹。所謂最大的子樹就是指結點數目最多的子樹。

         ___10___
        /         \
      _5_         15
     /   \          \
     1    8          7


		  ___10____
        /         \
      _5_         15     -------- subtree (1)
     /    \
     1     8 
     
      _5_
     /   \               -------- subtree (2)
    1     8 
複製程式碼

根據維基百科對 子樹 的定義,一棵二叉樹T的子樹由T的某個結點和該結點所有的後代構成。也就是說,該題目中,subtree(2) 才是正確的答案,因為 subtree(1) 不包含結點7,不滿足子樹的定義。

解1:自頂向下解法

最自然的解法是以根結點開始遍歷二叉樹所有的結點,判定以當前結點為根的子樹是否是BST,如果是,則該結點為根的BST就是最大的BST。如果不是,遞迴呼叫左右子樹,返回其中包含較多結點的子樹。

/**
 * 查詢二叉樹最大的二叉搜尋子樹-自頂向下方法
 */
BTNode *largestSubBSTTop2Down(BTNode *root, int *bstSize)
{
    if (!root) {
        *bstSize = 0;
        return NULL;
    }

    if (isBSTEfficient(root, NULL, NULL)) { //以root為根結點的樹為BST,則設定結果為root並返回。
        *bstSize = btSize(root);
        return root;
    }

    int lmax, rmax;
    BTNode *leftBST = largestSubBSTTop2Down(root->left, &lmax);   //找出左子樹中為BST的最大的子樹
    BTNode *rightBST = largestSubBSTTop2Down(root->right, &rmax);  //找出右子樹中為BST的最大的子樹
    *bstSize = lmax > rmax ? lmax : rmax;      //設定結點最大數目
    BTNode *result = lmax > rmax ? leftBST : rightBST;
    return result;
}
複製程式碼

解2:自底向上解法

自頂向下的解法時間複雜度為 O(N^2),每個結點都要判斷是否滿足BST的條件,可以用從底向上方法優化。我們在判斷上面結點為根的子樹是否是BST之前已經知道底部結點為根的子樹是否是BST,因此只要以底部結點為根的子樹不是BST,則以它上面結點為根的子樹一定不是BST。我們可以記錄子樹包含的結點數目,然後跟父結點所在的二叉樹比較,來求得最大BST子樹。

/**
 * 查詢二叉樹最大的二叉搜尋子樹-自底向上方法
 */
BTNode *largestSubBSTDown2Top(BTNode *root, int *bstSize)
{
    BTNode *largestBST = NULL;
    int min, max, maxNodes=0;
    findLargestSubBST(root, &min, &max, &maxNodes, &largestBST);
    *bstSize = maxNodes;
    return largestBST;
}

/**
 * 查詢最大二叉搜尋子樹自底向上方法主體函式
 * 如果是BST,則返回BST的結點數目,否則返回-1
 */
int findLargestSubBST(BTNode *root, int *min, int *max, int *maxNodes, BTNode **largestSubBST)
{
	if (!root) return 0;

    int isBST = 1;

    int leftNodes = findLargestSubBST(root->left, min, max, maxNodes, largestSubBST);
    int currMin = (leftNodes == 0) ? root->value : *min;

    if (leftNodes == -1 || (leftNodes != 0 && root->value <= *max))
        isBST = 0;

    int rightNodes = findLargestSubBST(root->right, min, max, maxNodes, largestSubBST);
    int currMax = (rightNodes == 0) ? root->value : *max;

    if (rightNodes == -1 || (rightNodes != 0 && root->value > *min))
        isBST = 0;

    if (!isBST)
        return -1;

    *min = currMin;
    *max = currMax;
    int totalNodes = leftNodes + rightNodes + 1;
    if (totalNodes > *maxNodes) {
        *maxNodes = totalNodes;
        *largestSubBST = root;
    }

    return totalNodes;
}
複製程式碼

5.距離類問題

5.1 二叉樹兩個結點之間的最短距離

題: 已知二叉樹中兩個結點,求這兩個結點之間的最短距離(注:最短距離是指從一個結點到另一個結點需要經過的邊的條數)。

         ___1___
        /        \
       2          3
     /   \       /  \
    4     5     6    7
                 \
                  8
Distance(4, 5) = 2
Distance(4, 6) = 4
Distance(3, 4) = 3
Distance(2, 4) = 1
Distance(8, 5) = 5
複製程式碼

解: 兩個結點的距離比較好辦,先求出兩個結點的最低公共祖先結點(LCA),然後計算 LCA 到兩個結點的距離之和即可,時間複雜度 O(N)

/**
 * 計算二叉樹兩個結點最短距離
 */
int distanceOf2BTNodes(BTNode *root, BTNode *p, BTNode *q)
{
    if (!root) return 0;

    BTNode *lca = btLCADown2Top(root, p, q);
    int d1 = btDistanceFromRoot(lca, p, 0);
    int d2 = btDistanceFromRoot(lca, q, 0);
    return d1+d2;
}

/**
 * 計算二叉樹結點node和root的距離
 */
int btDistanceFromRoot(BTNode *root, BTNode *node, int level)
{
    if (!root) return -1;
    
    if (root == node) return level;

    int left = btDistanceFromRoot(root->left, node, level+1);
    if (left == -1)
        return btDistanceFromRoot(root->right, node, level+1);
    return left;
}
複製程式碼

5.2 二叉搜尋樹兩個結點的最短距離

題: 求一棵二叉搜尋樹中的兩個結點的最短距離。

解: 與前面不同的是,這是一棵 BST,那麼我們可以使用二叉搜尋樹的特點來簡化距離計算流程,當然直接用 5.1 的方法是完全 OK 的,因為它是通用的計算方法。

/**
 * 計算BST兩個結點最短距離。
 */
int distanceOf2BSTNodes(BTNode *root, BTNode *p, BTNode *q)
{
    if (!root) return 0;

    if (root->value > p->value && root->value > q->value) {
        return distanceOf2BSTNodes(root->left, p, q);
    } else if(root->value <= p->value && root->value <= q->value){
        return distanceOf2BSTNodes(root->right, p, q);
    } else {
        return bstDistanceFromRoot(root, p) + bstDistanceFromRoot(root, q);
    }
}

/**
 * 計算BST結點node和root的距離
 */
int bstDistanceFromRoot(BTNode *root, BTNode *node)
{
    if (root->value == node->value)
        return 0;
    else if (root->value > node->value)
        return 1 + bstDistanceFromRoot(root->left, node);
    else
        return 1 + bstDistanceFromRoot(root->right, node);
}
複製程式碼

5.3 二叉樹中結點的最大距離

題: 寫一個程式求一棵二叉樹中相距最遠的兩個結點之間的距離。

解: 《程式設計之美》上有這道題,這題跟前面不同,要求相距最遠的兩個結點的距離,而且並沒有指定兩個結點位置。計算一個二叉樹的最大距離有兩個情況:

  • 1)路徑為 左子樹的最深節點 -> 根節點 -> 右子樹的最深節點。
  • 2)路徑不穿過根節點,而是左子樹或右子樹的最大距離路徑,取其大者。
         ___10___
        /         \
      _5_         15      ------ 第1種情況
     /   \          \
    1     8          7
     
         10
        /         
       5        
     /   \                ------ 第2種情況
    1     8    
   /       \
  2         3
複製程式碼

我們定義函式 maxDistanceOfBT(BTNode *root) 用於計算二叉樹相距最遠的兩個結點的距離,可以遞迴的先計算左右子樹的最遠結點距離,然後比較左子樹最遠距離、右子樹最遠距離以及左右子樹最大深度之和,從而求出整個二叉樹的相距最遠的兩個結點的距離。

int btMaxDistance(BTNode *root, int *maxDepth)
{
    if (!root) {
        *maxDepth = 0;
        return 0;
    }

    int leftMaxDepth, rightMaxDepth;
    int leftMaxDistance = btMaxDistance(root->left, &leftMaxDepth);
    int rightMaxDistance = btMaxDistance(root->right, &rightMaxDepth);

    *maxDepth = max(leftMaxDepth+1, rightMaxDepth+1);
    int maxDistance = max3(leftMaxDistance, rightMaxDistance, leftMaxDepth+rightMaxDepth); // max求兩個數最大值,max3求三個數最大值,詳見程式碼

    return maxDistance;
}
複製程式碼

5.4 二叉樹最大寬度

題: 給定一棵二叉樹,求該二叉樹的最大寬度。二叉樹的寬度指的是每一層的結點數目。如下面這棵二叉樹,從上往下 1 - 4 層的寬度分別是 1,2,3,2,於是它的最大寬度為 3。

         1
        /  \
       2    3
     /  \     \
    4    5     8 
              /  \
             6    7
複製程式碼

解1:層序遍歷法

最容易想到的方法就是使用層序遍歷,然後計算每一層的結點數,然後得出最大結點數。該方法時間複雜度為 O(N^2)。當然如果優化為使用佇列來實現層序遍歷,可以得到 O(N) 的時間複雜度。

/**
 * 二叉樹最大寬度
 */
int btMaxWidth(BTNode *root)
{
    int h = btHeight(root);
    int level, width;
    int maxWidth = 0;

    for (level = 1; level <= h; level++) {
        width = btLevelWidth(root, level);
        if (width > maxWidth)
            maxWidth = width;
    }
    return maxWidth;
}

/**
 * 二叉樹第level層的寬度
 */
int btLevelWidth(BTNode *root, int level)
{
    if (!root) return 0;
    if (level == 1) return 1;

    return btLevelWidth(root->left, level-1) + btLevelWidth(root->right, level-1);
}
複製程式碼

解2:先序遍歷法

我們可以先建立一個大小為二叉樹高度 h 的輔助陣列來儲存每一層的寬度,初始化為 0。通過先序遍歷的方式來遍歷二叉樹,並設定好每層的寬度。最後,從這個輔助陣列中求最大值即是二叉樹最大寬度。

/**
 * 二叉樹最大寬度-先序遍歷法
 */
int btMaxWidthPreOrder(BTNode *root)
{
    int h = btHeight(root);
    int *count = (int *)calloc(sizeof(int), h);
    btLevelWidthCount(root, 0, count);

    int i, maxWidth = 0;
    for (i = 0; i < h; i++) {
        if (count[i] > maxWidth)
            maxWidth = count[i];
    }
    return maxWidth;
}

/**
 * 計算二叉樹從 level 開始的每層寬度,並儲存到陣列 count 中。
 */
void btLevelWidthCount(BTNode *root, int level, int count[])
{
    if (!root) return;

    count[level]++;
    btLevelWidthCount(root->left, level+1, count);
    btLevelWidthCount(root->right, level+1, count);
}
複製程式碼

6.混合類問題

此類問題主要考察二叉樹和連結串列/陣列等結合,形式偏新穎。

6.1 根據有序陣列構建平衡二叉搜尋樹

題: 給定一個有序陣列,陣列元素升序排列,試根據該陣列元素構建一棵平衡二叉搜尋樹(Balanced Binary Search Tree)。所謂平衡的定義,就是指二叉樹的子樹高度之差不能超過1。

         __3__
        /     \
       1       5       ---- 平衡二叉搜尋樹示例
        \     / \
         2   4   6
             
複製程式碼

解: 如果要從一個有序陣列中選擇一個元素作為根結點,應該選擇哪個元素呢?我們應該選擇有序陣列的中間元素作為根結點。選擇了中間元素作為根結點並建立後,剩下的元素分為兩部分,可以看作是兩個陣列。這樣剩下的元素在根結點左邊的作為左子樹,右邊的作為右子樹。

BTNode *sortedArray2BST(int a[], int start, int end)
{
    if (start > end) return NULL;

    int mid = start + (end-start)/2;
    BTNode *root = btNewNode(a[mid]);
    root->left = sortedArray2BST(a, start, mid-1);
    root->right = sortedArray2BST(a, mid+1, end);
    return root;
}
複製程式碼

6.2 有序單向連結串列構建平衡二叉搜尋樹

題: 給定一個有序的單向連結串列,構建一棵平衡二叉搜尋樹。

解: 最自然的想法是先將連結串列中的結點的值儲存在陣列中,然後採用 6.1 中方法實現,時間複雜度為 O(N)。我們還可以採用自底向上的方法,在這裡我們不再需要每次查詢中間元素。

下面程式碼依舊需要連結串列長度作為引數,計算連結串列長度時間複雜度為 O(N),演算法時間複雜度也為 O(N),所以總的時間複雜度為 O(N)。程式碼中需要注意的是每次呼叫 sortedList2BST 函式時,list 位置都會變化,呼叫完函式後 list 總是指向 mid+1 的位置 (如果滿足返回條件,則 list 位置不變)。

BTNode *sortedList2BST(ListNode **pList, int start, int end)
{
    if (start > end) return NULL;

    int mid = start + (end-start)/2;
    BTNode *left = sortedList2BST(pList, start, mid-1);
    BTNode *parent = btNewNode((*pList)->value);
    parent->left = left;
    *pList = (*pList)->next;
    parent->right = sortedList2BST(pList, mid+1, end);
    return parent;
}
複製程式碼

例如連結串列只有 2 個節點 3->5->NULL,則初始 start=0, end=1, mid=0,繼而遞迴呼叫 sortedList2BST(pList, start,mid-1),此時直接返回 NULL。即左孩子為NULL, 根結點為 3,而後連結串列指向 5,再呼叫 sortedList2BST(pList, mid+1, end),而這次呼叫返回結點 5,將其賦給根結點 3 的右孩子。這次呼叫的 mid=1,呼叫完成後 list 已經指向連結串列末尾。

6.3 二叉搜尋樹轉換為有序迴圈連結串列

題: 給定一棵二叉搜尋樹( BST ),將其轉換為雙向的有序迴圈連結串列。

轉換示意圖

解: 如圖所示,需要將 BST 的左右孩子指標替換成連結串列的 prevnext 指標,分別指向雙向連結串列的前一個和後一個結點。相信大多數人第一反應就是中序遍歷這棵二叉樹,同時改變樹中結點的 leftright 指標。這裡需要額外考慮的是如何將最後一個結點的right 指標指向第一個結點,如下圖所展示的那樣。

雙向連結串列

以中序遍歷遍歷一棵二叉樹的時候,每遍歷到一個結點,我們就可以修改該結點的 left 指標指向前一個遍歷到的結點,因為在後續操作中我們不會再用到 left 指標;與此同時,我們還需要修改前一個遍歷結點的 right 指標,讓前一個遍歷結點的 right 指標指向當前結點。比如我們遍歷到結點 2,則我們修改結點2的 left 指標指向結點 1,同時需要修改結點 1 的 right 指標指向結點 2。需要注意一點,這裡的前一個遍歷結點不是當前結點的父結點,而是當前結點的前一個比它小的結點。

看似問題已經解決,慢著,我們其實落下了重要的兩步。1)我們沒有對頭結點head賦值。 2)最後一個結點的right指標沒有指向第一個結點。

解決這兩個問題的方案非常簡單:在每次遞迴呼叫的時候,更新當前遍歷結點的 right 指標讓其指向頭結點 head,同時更新頭結點 headleft 指標讓其指向當前遍歷結點。當遞迴呼叫結束的時候,連結串列的頭尾結點會指向正確的位置。不要忘記只有一個結點的特殊情況,它的 leftright 指標都是指向自己。

只有一個結點情況

void bt2DoublyList(BTNode *node, BTNode **pPrev, BTNode **pHead)
{
	
    if (!node) return;

    bt2DoublyList(node->left, pPrev, pHead);

    // 當前結點的left指向前一個結點pPrev
    node->left = *pPrev;
    if (*pPrev)
        (*pPrev)->right = node;  // 前一個結點的right指向當前結點
    else
        *pHead = node; // 如果前面沒有結點,則設定head為當前結點(當前結點為最小的結點)。

    // 遞迴結束後,head的left指標指向最後一個結點,最後一個結點的右指標指向head結點。
    // 注意儲存當前結點的right指標,因為在後面程式碼中會修改該指標。
    BTNode *right = node->right;
    (*pHead)->left = node;
    node->right = (*pHead);
    *pPrev = node;//更新前一個結點
    bt2DoublyList(right, pPrev, pHead); 
}
複製程式碼

這個解法非常的精巧,因為該演算法是對中序遍歷的一個改進,因此它的時間複雜度為 O(N),N 為結點數目。當然,相比中序遍歷,我們在每次遞迴呼叫過程中增加了額外的賦值操作。

系列文章目錄

其他

此外,在我 簡書的部落格 上還整理有《docker相關技術筆記》、《MIT6.828作業系統學習筆記》、《python原始碼剖析筆記》等文章,請大家指正。

參考資料

我在參加掘金技術徵文 活動詳情 秋招求職時,寫文就有好禮相送 | 掘金技術徵文

那些年,面試中常見的資料結構基礎和演算法題(上) | 掘金技術徵文

相關文章