做爲一個多年的老菜鳥,有感於大部分的公司面試 「面試造航母,工做螺絲釘」 的做風,特整理了這個數據結構和算法面試題系列。對於校招而言,若是沒有太多實踐/實習經驗,大公司每每喜歡考察數據結構和算法,如微軟就特別喜歡在校招時手寫算法題,並且難度還不小,當年我畢業找工做時也是頗受折磨。html
從第一篇文章到如今完成已然一個多月了,經 @掘金-yuzu柚子茶 的殷勤的催稿,終於在今天基本完成了。近一個月的業餘時間全在這上面了,除了要將博文整合,還要將代碼從新錄入和測試,耗費很多精力。本系列的主要資料來源包括:《算法導論》、《編程珠璣》、《數據結構與算法-C語言實現》,面試題則多來自 leetcode、geeksforgeeks、編程之美等。node
整理的博文系列名爲 數據結構和算法面試題系列 ,是我6年前找工做時對數據結構和算法總結,其中有基礎部分,也有各大公司的經典的面試題,最先發布在 CSDN 。因爲以前的博文比較雜亂,且沒有將實現代碼統一整理,看起來會有諸多不便。現整理爲一個系列給須要的朋友參考。本系列完整代碼在 github 建了個倉庫,全部代碼都從新整理和作了一些基本的測試,代碼倉庫地址在這裏: shishujuan/dsalg: 數據結構與算法系列彙總,若有錯誤,請在文章下面評論指出或者在 github 給我留言,我好及時改正以避免誤導其餘朋友。python
文章末尾有系列目錄,能夠按需取閱,若是須要測試,亦能夠將倉庫代碼 clone 下來進行各類測試。若有錯誤或者引用不全、有侵權的地方,請你們給我指出,我好及時調整改正。系列文章總字數多達4萬字,所以也整合了上下兩篇文章給各位有須要的小夥伴,若是本系列有幫助到你,也歡迎點贊或者在 github 上 star✨✨,十分感謝。linux
在用C語言實現一些常見的數據結構和算法時,C語言的基礎不能少,特別是指針和結構體等知識。c++
linux 中的 C 編譯獲得的目標文件和可執行文件都是 ELF 格式的,可執行文件中以 segment 來劃分,目標文件中,咱們是以 section 劃分。一個 segment 包含一個或多個 section,經過 readelf 命令能夠看到完整的 section 和 segment 信息。看一個栗子🌰:git
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。github
更多 ELF 內容參見《程序猿的自我修養》一書。面試
想當年學習 C 語言最怕的就是指針了,固然《c與指針》和《c專家編程》以及《高質量C編程》裏面對指針都有很好的講解,系統回顧仍是看書吧,這裏我總結了一些基礎和易錯的點。環境是 ubuntu14.10 的 32 位系統,編譯工具 GCC。正則表達式
/***
指針易錯示例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.1中也提到了部分指針和數組內容,在C中指針和數組在某些狀況下能夠相互轉換來使用,好比char *str="helloworld"
能夠經過str[1]
來訪問第二個字符,也能夠經過*(str+1)
來訪問。 此外,在函數參數中,使用數組和指針也是等同的。可是指針和數組在有些地方並不等同,須要特別注意。
好比我定義一個數組char a[9] = "abcdefgh";
(注意字符串後面自動補\0),那麼用 a[1]讀取字符 'b' 的流程是這樣的:
那若是定義一個指針char *a = "abcdefgh";
,咱們經過 a[1]來取第一個元素的值。跟數組流程不一樣的是:
經過上面的說明能夠發現,指針比數組多了一個步驟,雖然看起來結果是一致的。所以,下面這個錯誤就比較好理解了。在 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;
}
複製代碼
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]合法的定義
複製代碼
在學習數據結構的時候,定義鏈表和樹結構會常常用到結構體。好比下面這個:
struct node {
int data;
struct node* next;
};
複製代碼
在定義鏈表的時候可能就有點奇怪了,爲何能夠這樣定義,貌似這個時候 struct node 尚未定義好爲何就能夠用 next 指針指向用這個結構體定義了呢?
這裏要說下 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;
複製代碼
不徹底類型的結構體十分重要,好比咱們最開始提到的 struct node 的定義,編譯器從前日後處理,發現struct node *next
時,認爲 struct node 是一個不徹底類型,next 是一個指向不徹底類型的指針,儘管如此,指針自己是徹底類型,由於無論什麼指針在 32 位系統都是佔用 4 個字節。而到後面定義結束,struct node 成了一個徹底類型,從而 next 就是一個指向徹底類型的指針了。
結構體初始化比較簡單,須要注意的是結構體中包含有指針的時候,若是要進行字符串拷貝之類的操做,對指針須要額外分配內存空間。以下面定義了一個結構體 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)的整數倍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;
複製代碼
柔性數組是指結構體的最後面一個成員能夠是一個大小未知的數組,這樣能夠在結構體中存放變長的字符串。如代碼中所示。**注意,柔性數組必須是結構體最後一個成員,柔性數組不佔用結構體大小.**固然,你也能夠將數組寫成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;
}
複製代碼
const int N = 3; int a[N];
這是錯誤的。字符串做爲數據結構中的基礎內容,也是面試中常常會考察的基本功之一,好比實現 strcpy,strcmp 等基本函數等,迴文字符串,字符串搜索,正則表達式等。本文相關代碼見 這裏。
首先來看一些字符串的基本函數的實現,如下代碼取自 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;
}
複製代碼
題: 給定一個字符串,找出該字符串的最長迴文子串。迴文字符串指的就是從左右兩邊看都同樣的字符串,如 aba
,cddc
都是迴文字符串。字符串 abbacdc
存在的迴文子串有 abba
和 cdc
,所以它的最長迴文子串爲 abba
。
一個容易犯的錯誤
初看這個問題可能想到這樣的方法:對字符串 S 逆序獲得新的字符串 S',再求 S 和 S' 的最長公共子串,這樣求出的就是最長迴文子串。
S = caba
, S' = abac
,則 S 和 S' 的最長公共子串爲 aba
,這個是正確的。S = abacdfgdcaba
, S’ = abacdgfdcaba
,則 S 和 S' 的最長公共子串爲 abacd
,顯然這不是迴文字符串。所以這種方法是錯誤的。斷定一個字符串是不是迴文字符串
要找出最長迴文子串,首先要解決判斷一個字符串是不是迴文字符串的問題。最顯而易見的方法是設定兩個變量 i 和 j,分別指向字符串首部和尾部,比較是否相等,而後 i++,j--
,直到 i >= j
爲止。下面的代碼是判斷字符串 str[i, j]
是否是迴文字符串,即字符串 str 從 i 到 j 的這一段子串是不是迴文字符串,在後面會用到這個方法。
/**
* 判斷字符串s[start:end]是不是迴文字符串
*/
int isPalindrome(string s, int start, int end)
{
for (; start < end; ++start,--end) {
if (s[start] != s[end])
return 0;
}
return 1;
}
複製代碼
解1:蠻力法求最長子串
蠻力法經過對字符串全部子串進行判斷,若是是迴文字符串,則更新最長迴文的長度。由於長度爲 N 的字符串的子串一共可能有 (1+N)*N/2
個,每次判斷子串須要 O(N)
的時間,因此一共須要 O(N^3)
時間求最長迴文子串。
/**
* 最長迴文子串-蠻力法 O(N^3)
*/
string longestPalindrome(string s)
{
int len = s.length(), maxLen = 1;
int start=0, i, j;
/*遍歷字符串全部的子串,若子串爲迴文字符串則更新最長迴文的長度*/
for (i = 0; i < len - 1; i++) {
for (j = i + 1; j < len; j++) {
if (isPalindrome(s, i, j)) { //若是str[i,j]是迴文,則判斷其長度是否大於最大值,大於則更新長度和位置
int pLen = j - i + 1;
if (pLen > maxLen) {
start = i; //更新最長迴文起始位置
maxLen = pLen; //更新最長迴文的長度
}
}
}
}
return s.substr(start, maxLen);
}
複製代碼
解2:動態規劃法
由於蠻力法斷定迴文的時候須要不少重複的計算,因此能夠經過動態規劃法來改進該算法。假定咱們知道「bab」是迴文,則「ababa」也必定是迴文。
定義P[i, j] = true 若是子串P[i, j]是迴文字符串。
則 P[i, j] <- (P[i+1, j-1] && s[i] = s[j])。
Base Case:
P[i, i ] = true
P[i, i+1 ] = true <- s[i] = s[i+1]
複製代碼
據此,實現代碼以下:
/**
* 最長迴文子串-動態規劃法,該方法的時間複雜度爲O(N^2),空間複雜度爲O(N^2)。
*/
/**
* 最長迴文子串-動態規劃法,該方法的時間複雜度爲O(N^2),空間複雜度爲O(N^2)。
*
* 思想:定義P[i, j] = 1 若是子串P[i, j]是迴文字符串。
* 則 P[i, j] <- (P[i+1, j-1] && s[i] == s[j])。
*
* Base Case:
* P[ i, i ] <- 1
* P[ i, i+1 ] <- s[i] == s[i+1]
*/
string longestPalindromeDP(string s)
{
int n = s.length();
int longestBegin = 0, maxLen = 1;
int **P;
int i;
/*構造二維數組P*/
P = (int **)calloc(n, sizeof(int *));
for (i = 0; i < n; i++) {
P[i] = (int *)calloc(n, sizeof(int));
}
for (i = 0; i < n; i++) {
P[i][i] = 1;
}
for (int i=0; i<n-1; i++) {
if (s[i] == s[i+1]) {
P[i][i+1] = 1;
longestBegin = i;
maxLen = 2;
}
}
/*依次求P[i][i+2]...P[i][i+n-1]等*/
int len = 3;
for (; len <= n; ++len) {
for (i = 0; i < n-len+1; ++i) {
int j = i + len - 1;
if (s[i] == s[j] && P[i+1][j-1]) {
P[i][j] = 1;
longestBegin = i;
maxLen = len;
}
}
}
/*釋放內存*/
for (i = 0; i< n; i++)
free(P[i]);
free(P);
return s.substr(longestBegin, maxLen);
}
複製代碼
解3:中心法
還有一個更簡單的方法可使用 O(N^2)
時間、不須要額外的空間求最長迴文子串。咱們知道迴文字符串是以字符串中心對稱的,如 abba
以及 aba
等。一個更好的辦法是從中間開始判斷,由於迴文字符串以字符串中心對稱。一個長度爲 N 的字符串可能的對稱中心有 2N-1 個,至於這裏爲何是 2N-1 而不是 N 個,是由於可能對稱的點多是兩個字符之間,好比 abba 的對稱點就是第一個字母 b 和第二個字母 b 的中間。據此實現代碼以下:
/**
* 求位置l爲中心的最長迴文子串的開始位置和長度
*/
void expandAroundCenter(string s, int l, int r, int *longestBegin, int *longestLen)
{
int n = s.length();
while (l>=0 && r<=n-1 && s[l]==s[r]) {
l--, r++;
}
*longestBegin = l + 1;
*longestLen = r - l - 1;
}
/**
* 最長迴文子串-中心法,時間O(N^2)。
*/
string longestPalindromeCenter(string s)
{
int n = s.length();
if (n == 0)
return s;
char longestBegin = 0;
int longestLen = 1;
for (int i = 0; i < n; i++) {
int iLongestBegin, iLongestLen;
expandAroundCenter(s, i, i, &iLongestBegin, &iLongestLen); //以位置i爲中心的最長迴文字符串
if (iLongestLen > longestLen) {
longestLen = iLongestLen;
longestBegin = iLongestBegin;
}
expandAroundCenter(s, i, i+1, &iLongestBegin, &iLongestLen); //以i和i+1之間的位置爲中心的最長迴文字符串
if (iLongestLen > longestLen) {
longestLen = iLongestLen;
longestBegin = iLongestBegin;
}
}
return s.substr(longestBegin, longestLen);
}
複製代碼
題: 已知一個字符數組,其中存儲有 R、G、B
字符,要求將全部的字符按照 RGB
的順序進行排序。好比給定一個數組爲 char s[] = "RGBBRGGBGB"
,則排序後應該爲 RRGGGGBBBB
。
解1: 這個題目有點相似於快速排序中用到的劃分數組的方法,可是這裏有三個字符,所以須要調用劃分方法兩次,第一次以 B
劃分,第二次以 G
劃分,這樣兩次劃分後就能夠將原來的字符數組劃分紅 RGB
順序。這個方法比較天然,容易想到,代碼以下。這個方法的缺點是須要遍歷兩遍數組。
void swapChar(char *s, int i, int j)
{
char temp = s[i];
s[i] = s[j];
s[j] = temp;
}
/**
* 劃分函數
*/
void partition(char *s, int lo, int hi, char t)
{
int m = lo-1, i;
for (i = lo; i <= hi; i++) {
if (s[i] != t) {
swapChar(s, ++m ,i);
}
}
}
/**
* RGB排序-遍歷兩次
*/
void rgbSortTwice(char *s)
{
int len = strlen(s);
partition(s, 0, len-1, 'G'); // 以G劃分,劃分完爲 RBBRBBGGGG
partition(s, 0, len-1, 'B'); // 再以B劃分,劃分完爲 RRGGGGBBBB
}
複製代碼
解2: 其實還有一個只須要遍歷一遍數組的方法,固然該方法雖然只遍歷一遍數組,可是須要交換的次數並未減小。主要是設置兩個變量 r 和 g 分別指示當前 R 和 G 字符所在的位置,遍歷數組。
1)若是第 i 個位置爲字符 R,則與前面的指示變量 r 的後一個字符也就是 ++r 處的字符交換,並 ++g,此時還須要判斷交換後的 i 裏面存儲的字符是不是 G,若是是 G,則須要將其與 g 處的字符交換;
2)若是第 i 個位置爲字符 G,則將其與 ++g 處的字符交換便可。++g 指向的老是下一個應該交換 G 的位置,++r 指向的是下一個須要交換 R 的位置。
3)若是第 i 個位置爲字符B,則什麼都不作,繼續遍歷。
/**
* RGB排序-遍歷一次
*/
void rgbSortOnce(char *s)
{
int len = strlen(s);
int lo = 0, hi = len - 1;
int r, g, i; //++r和++g分別指向R和G交換的位置
r = g = lo - 1;
for (i = lo; i <= hi; i++) {
if (s[i] == 'R') { // 遇到R
swapChar(s, ++r, i);
++g;
if (s[i] == 'G') // 交換後的值是G,繼續交換
swapChar(s, g, i);
} else if (s[i] == 'G') { // 遇到G
swapChar(s, ++g, i);
} else { // 遇到B,什麼都不作
}
}
}
複製代碼
解3: 若是不考慮用交換的思想,能夠直接統計 RGB 各個字符的個數,而後從頭開始對數組從新賦值爲 RGB 便可。那樣簡單多了,哈哈。可是若是換一個題,要求是對正數、負數、0 按照必定順序排列,那就必須用交換了。
題: 給定一個數組 A,有一個大小爲 w 的滑動窗口,該滑動窗口從最左邊滑到最後邊。在該窗口中你只能看到 w 個數字,每次只能移動一個位置。咱們的目的是找到每一個窗口 w 個數字中的最大值,並將這些最大值存儲在數組 B 中。
例如數組 A = [1 3 -1 -3 5 3 6 7]
, 窗口大小 w = 3
。則窗口滑動過程以下所示:
Window position Max
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
輸入: 數組A和w大小
輸出: 數組B,其中B[i]存儲了A[i]到A[i+w-1]中w個數字的最大值。
複製代碼
解1:簡單實現
一個最簡單的想法就是每次移動都計算 w 個數字的最大值並保存起來,每次計算 w 個數字的最大值須要 O(w)
的時間,而滑動過程須要滑動 n-w+1
次,n 爲數組大小,所以總共的時間爲 O(nw)
。
/*
* 求數組最大值
*/
int maxInArray(int A[], int n)
{
int max = A[0], i;
for (i = 1; i < n; i++) {
if (A[i] > max) {
max = A[i];
}
}
return max;
}
/*
* 最大滑動窗口-簡單實現
*/
void maxSlidingWindowSimple(int A[], int n, int w, int B[])
{
int i;
for (i = 0; i <= n-w; i++)
B[i] = maxInArray(A + i, w);
}
複製代碼
解2:最大堆解法
第1個方法思路簡單,可是時間複雜度太高,所以須要改進。可使用一個最大堆來保存 w 個數字,每次插入數字時只須要 O(lgw)
的時間,從堆中取最大值只須要 O(1)
的時間(堆的平均大小約爲 w )。隨着窗口由左向右滑動,所以堆中有些數字會失效(由於它們再也不包含在窗口中)。若是數組自己有序,則堆大小會增大到 n。由於堆大小並不保持在 w 不變,所以該算法時間複雜度爲 O(nlgn)
。
/**
* 最大滑動窗口-最大堆解法
*/
void maxSlidingWindowPQ(int A[], int n, int w, int B[])
{
typedef pair<int, int> Pair;
priority_queue<Pair> Q; //優先級隊列保存窗口裏面的值
for (int i = 0; i < w; i++)
Q.push(Pair(A[i], i)); //構建w個元素的最大堆
for (int i = w; i < n; i++) {
Pair p = Q.top();
B[i-w] = p.first;
while (p.second <= i-w) {
Q.pop();
p = Q.top();
}
Q.push(Pair(A[i], i));
}
B[n-w] = Q.top().first;
}
複製代碼
解3:雙向隊列解法
最大堆解法在堆中保存有冗餘的元素,好比原來堆中元素爲 [10 5 3]
,新的元素爲 11,則此時堆中會保存有 [11 5 3]
。其實此時咱們能夠清空整個隊列,而後再將 11 加入到隊列便可,即只在隊列中保持 [11]
。使用雙向隊列能夠知足要求,滑動窗口的最大值老是保存在隊列首部,隊列裏面的數據老是從大到小排列。當遇到比當前滑動窗口最大值更大的值時,則將隊列清空,並將新的最大值插入到隊列中。若是遇到的值比當前最大值小,則直接插入到隊列尾部。每次移動的時候須要判斷當前的最大值是否在有效範圍,若是不在,則須要將其從隊列中刪除。因爲每一個元素最多進隊和出隊各一次,所以該算法時間複雜度爲O(N)。
/**
* 最大滑動窗口-雙向隊列解法
*/
void maxSlidingWindowDQ(int A[], int n, int w, int B[])
{
deque<int> Q;
for (int i = 0; i < w; i++) {
while (!Q.empty() && A[i] >= A[Q.back()])
Q.pop_back();
Q.push_back(i);
}
for (int i = w; i < n; i++) {
B[i-w] = A[Q.front()];
while (!Q.empty() && A[i] >= A[Q.back()])
Q.pop_back();
while (!Q.empty() && Q.front() <= i-w)
Q.pop_front();
Q.push_back(i);
}
B[n-w] = A[Q.front()];
}
複製代碼
題: 給定兩個序列 X = < x1, x2, ..., xm > 和 Y = < y1, y2, ..., ym >,但願找出X和Y最大長度的公共子序列(LCS)。
分析: 解決LCS的最簡單的是使用蠻力法,窮舉 X
的全部子序列,而後逐一檢查是不是 Y
的子序列,並記錄發現的最長子序列,最終取最大的子序列便可。可是 X
全部子序列有 2^m
,該方法須要指數級時間,不太切實際,然而LCS問題其實具備最優子結構性質。
LCS最優子結構:
如 X = <A, B, C, B, D, A, B>
, Y = <B, D, C, A, B, A>
,則 X 和 Y 的最長公共子序列爲 <B, C, B, A>
或者 <B, D, A, B>
。也就是說,LCS可能存在多個。
設 X = < x1, x2, ..., xm > 和 Y = < y1, y2, ..., yn > 爲兩個序列,並設 Z = < z1, z2, ..., zk > 爲 X 和 Y 的任意一個LCS。
所以,咱們能夠定義 c[i, j]
爲序列 Xi 和 Yj 的一個LCS的長度,則能夠獲得下面的遞歸式:
c[i, j] = 0 // i = 0 或者 j = 0
c[i, j] = c[i-1, j-1] + 1 // i,j > 0,且 Xi = Yj
c[i, j] = max(c[i-1, j], c[i][j-1]) // i, j > 0,且 Xi != Yj
複製代碼
據此能夠寫出以下代碼求 LCS 的長度及 LCS,使用一個輔助數組 b 存儲 LCS 路徑。這裏給出遞歸算法求 LCS 長度,使用動態規劃算法的代碼見本文源碼。
/**
* LCS-遞歸算法
*/
#define UP 1
#define LEFT 2
#define UPLEFT 3
int lcsLengthRecur(char *X, int m, char *Y, int n, int **b)
{
if (m == 0 || n == 0)
return 0;
if (X[m-1] == Y[n-1]) {
b[m][n] = UPLEFT;
return lcsLengthRecur(X, m-1, Y, n-1, b) + 1;
}
int len1 = lcsLengthRecur(X, m-1, Y, n, b);
int len2 = lcsLengthRecur(X, m, Y, n-1, b);
int maxLen;
if (len1 >= len2) {
maxLen = len1;
b[m][n] = UP;
} else {
maxLen = len2;
b[m][n] = LEFT;
}
return maxLen;
}
/**
* 打印LCS,用到輔助數組b
*/
void printLCS(int **b, char *X, int i, int j)
{
if (i == 0 || j == 0)
return;
if (b[i][j] == UPLEFT) {
printLCS(b, X, i-1, j-1);
printf("%c ", X[i-1]);
} else if (b[i][j] == UP) {
printLCS(b, X, i-1, j);
} else {
printLCS(b, X, i, j-1);
}
}
複製代碼
打印LCS的流程以下圖所示(圖取自算法導論):
題: 給一個字符數組 char arr[] = "abc"
,輸出該數組中字符的全排列。
解: 使用遞歸來輸出全排列。首先明確的是 perm(arr, k, len)
函數的功能:輸出字符數組 arr
從位置 k
開始的全部排列,數組長度爲 len
。基礎條件是 k == len-1
,此時已經到達最後一個元素,一次排列已經完成,直接輸出。不然,從位置k開始的每一個元素都與位置k的值交換(包括本身與本身交換),而後進行下一次排列,排列完成後記得恢復原來的序列。
假定數組 arr
大小 len=3
,則程序調用 perm(arr, 0, 3)
能夠以下理解: 第一次交換 0,0
,並執行 perm(arr, 1, 3)
,執行完再次交換0,0,數組此時又恢復成初始值。 第二次交換 1,0
(注意數組此時是初始值),並執行 perm(arr, 1, 3)
, 執行完再次交換 1,0
,數組此時又恢復成初始值。 第三次交換 2,0
,並執行 perm(arr, 1, 3)
,執行完成後交換2,0
,數組恢復成初始值。
程序運行輸出結果爲:abc acb bac bca cba cab
。即先輸出以 a
爲排列第一個值的排列,然後是 b
和 c
爲第一個值的排列。
void perm(char *arr, int k, int len) { //k爲起始位置,len爲數組大小
if (k == len-1) {
printf("%s\n", arr);
return;
}
for (int i = k; i < len; i++) {
swapChar(arr, i, k); //交換
perm(arr, k+1, len); //下一次排列
swapChar(arr, i, k); //恢復原來的序列
}
}
複製代碼
題: 實現一個簡易版的正則表達式,支持 ^、$、.
等特性。
正則表達式基礎:一個正則表達式自己也是一個字符序列,它定義了能與之匹配的字符串集合。在 Unix/Linux 通用的正則表達式中,字符 ^
表示字符串開始, $
表示字符串結束。這樣,^x
只能與位於字符串開始處的 x匹配, x$
只能匹配結尾的 x,^x$
只能匹配單個字符的串裏的 x,而^$
只能匹配空串。字符 .
能與任意字符匹配。因此,模式 x.y
能匹配 xay
、x2y
等等,但它不能匹配 xy
或 xaby
。顯然 ^.$
可以與任何單個字符的串匹配。寫在方括號 []
裏的一組字符能與這組字符中的任一個相匹配。如 [0123456789]
能與任何數字匹配。這個模式也能夠簡寫爲 [0-9]
。
解: 下面是正則表達式匹配的主函數 match,接收參數爲匹配模式 regexp 和文本 text。 若是正則表達式的開頭是 ^
,那麼正文必須從起始處與表達式的其他部分匹配。不然,咱們就沿着串走下去,用 matchhere()
看正文是否能在某個位置上匹配。一旦發現了匹配,工做就完成了。注意這裏 do-while
的使用,有些表達式能與空字符串匹配 (例如: $
可以在字符串的末尾與空字符串匹配,*
能匹配任意個數的字符,包括 0 個)。因此,即便遇到了空字符串,咱們也還須要調用 matchhere()
。
int match(const char *regexp, const char *text)
{
if (regexp[0] == '^')
return matchhere(regexp+1, text);
do {
if (matchhere(regexp, text))
return 1;
} while (*text++ != '\0');
return 0;
}
複製代碼
遞歸函數 matchhere()
完成大部分的匹配工做:
regexp[0]=='\0'
,表示已經匹配到末尾,則匹配成功,返回1。$
,匹配成功的條件是正文也到達了末尾,即判斷 *text=='\0'
。若是正文text也到了末尾,則匹配成功,不然失敗。regexp[0] == *text
或者 regexp=='.'
(.
表示匹配任意字符),則遞歸調用matchhere繼續下一次匹配。regexp[1]=='*'
,則過程稍顯複雜,例如 x*
。這時咱們調用 matchstar
來處理,其第一個參數是星號的參數 (x*
中的 x
),隨後的參數是位於星號以後的模式,以及對應的正文串。int matchhere(const char *regexp, const char *text)
{
if (regexp[0] == '\0')
return 1;
if (regexp[0]=='$' && regexp[1]=='\0')
return *text == '\0';
if (regexp[1] == '*')
return matchstar(regexp[0], regexp+2, text);
if (*text != '\0' && (regexp[0] == '.' || regexp[0] == *text))
return matchhere(regexp+1, text+1);
return 0;
}
int matchstar(int c, const char *regexp, const char *text)
{
do {
if (matchhere(regexp, text))
return 1;
} while (*text != '\0' && (*text++ == c || c == '.'));
return 0;
}
複製代碼
示例:
char *regexp="abc", text="dagabcdefg"
,匹配成功。char *regexp="^abc", *text="abcdefg"
,匹配成功。char *regexp="^abc", *text="bcdefgabc"
,匹配失敗。char *regexp="abc$", *text="defghabc"
,匹配成功。字符串匹配的大名鼎鼎的有KMP算法和BM算法,網上資料比較多,能夠參見 grep之字符串搜索算法Boyer-Moore由淺入深(比KMP快3-5倍) 和 字符串匹配的KMP算法 。
鏈表做爲一種基礎的數據結構,在不少地方會用到。如在 Linux 內核代碼,redis 源碼,python 源碼中都有使用。除了單向鏈表,還有雙向鏈表,本文主要關注單向鏈表(含部分循環鏈表題目,會在題目中註明,其餘狀況都是討論簡單的單向鏈表)。雙向鏈表在redis中有很好的實現,也在個人倉庫中拷貝了一份用於測試用,本文的相關代碼在 這裏。
先定義一個單向鏈表結構,以下,定義了鏈表結點和鏈表兩個結構體。這裏我沒有多定義一個鏈表的結構體,保存頭指針,尾指針,鏈表長度等信息,目的也是爲了多練習下指針的操做。
// aslist.h
// 鏈表結點定義
typedef struct ListNode {
struct ListNode *next;
int value;
} listNode;
複製代碼
在上一節的鏈表定義基礎上,咱們完成幾個基本操做函數,包括鏈表初始化,鏈表中添加結點,鏈表中刪除結點等。
/**
* 建立鏈表結點
*/
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;
}
複製代碼
題: 給定一個單向鏈表 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;
}
複製代碼
題: 給定一個單向鏈表,複製並返回新的鏈表頭結點。
解: 一樣能夠有兩種解法,非遞歸和遞歸的,以下:
/**
* 鏈表複製-非遞歸
*/
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;
}
複製代碼
題: 已知兩個有序單向鏈表,請合併這兩個鏈表,使得合併後的鏈表仍然有序(注:這兩個鏈表沒有公共結點,即不交叉)。如鏈表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;
}
複製代碼
題: 已知兩個單向鏈表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;
}
複製代碼
題: 給定一個鏈表,判斷鏈表中是否存在環。
解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;
}
複製代碼
題: 給定兩個鏈表,每一個鏈表的結點值爲數字的各位上的數字,試求出兩個鏈表所表示數字的和,並將結果以鏈表形式返回。假定兩個鏈表分別爲 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;
}
複製代碼
題: 已知一個有序的單向循環鏈表,插入一個結點,仍保持鏈表有序,以下圖所示。
解: 在解決這個問題前,咱們先看一個簡化版本,就是在一個有序無循環的單向鏈表中插入結點,仍然保證其有序。這個問題的代碼相信多數人都很熟悉,通常都是分兩種狀況考慮:
實現代碼以下:
/**
* 簡化版-有序無循環鏈表插入結點
*/
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點:
代碼以下:
/**
* 有序循環鏈表插入結點
*/
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;
}
複製代碼
題: 給定一個簡單的單向鏈表,輸出鏈表的倒數第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。現整理爲一個系列給須要的朋友參考,若有錯誤,歡迎指正。本系列完整代碼地址在 這裏。
棧做爲一種基本的數據結構,在不少地方有運用,好比函數遞歸,先後綴表達式轉換等。本文會用 C 數組來實現棧結構(使用鏈表實現能夠參見鏈表那一節,使用頭插法構建鏈表便可),並對常見的幾個跟棧相關的面試題進行分析,本文代碼在 這裏。
咱們使用結構體來定義棧,使用柔性數組來存儲元素。幾個宏定義用於計算棧的元素數目及棧是否爲空和滿。
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)
複製代碼
棧主要有三種基本操做:
如圖所示:
代碼以下:
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];
}
複製代碼
題: 已知一個後綴表達式 6 5 2 3 + 8 * + 3 + *
,求該後綴表達式的值。
解: 後綴表達式也叫逆波蘭表達式,其求值過程能夠用到棧來輔助存儲。則其求值過程以下:
[6 5 2 3]
。+
,則彈出3和2,計算 3 + 2
,計算結果等於 5
,並將 5
壓入到棧中,棧爲 [6 5 5]
。8
,將其直接放入棧中,[6 5 5 8]
。*
,彈出 8
和 5
,計算 8 * 5
,並將結果 40
壓入棧中,棧爲 [6 5 40]
。然後過程相似,讀到 +
,將 40
和 5
彈出,將 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);
}
複製代碼
題: 給定一個棧,請將其逆序。
解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);
}
複製代碼
題: 設計一個棧,使得push、pop以及min(獲取棧中最小元素)可以在常數時間內完成。
分析: 剛開始很容易想到一個方法,那就是額外創建一個最小二叉堆保存全部元素,這樣每次獲取最小元素只須要 O(1)
的時間。可是這樣的話,爲了建最小堆 push
和 pop
操做就須要 O(lgn)
的時間了(假定棧中元素個數爲n),不符合題目的要求。
解1:輔助棧方法
那爲了實現該功能,可使用輔助棧使用一個輔助棧來保存最小元素,這個解法簡單不失優雅。設該輔助棧名字爲 minStack
,其棧頂元素爲當前棧中的最小元素。這意味着
代碼:
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,[ ]
複製代碼
求出棧數目
題: 已知一個入棧序列,試求出全部可能的出棧序列數目。例如入棧序列爲 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);
}
複製代碼
本文要描述的堆是二叉堆。二叉堆是一種數組對象,能夠被視爲一棵徹底二叉樹,樹中每一個結點和數組中存放該結點值的那個元素對應。樹的每一層都是填滿的,最後一層除外。二叉堆能夠用於實現堆排序,優先級隊列等。本文代碼地址在 這裏。
使用數組來實現二叉堆,二叉堆兩個屬性,其中 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
。
本文主要創建一個最大堆,最小堆原理相似。爲了保持堆的性質,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
。
咱們能夠知道,數組 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+一、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)
,這裏就不證實了,具體證實過程參見《算法導論》。
開始用 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);
}
}
複製代碼
最後實現一個最大優先級隊列,主要有四種操做,分別以下所示:
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 個或多個子結點,沒有子結點的結點咱們稱之爲葉結點。
二叉樹是指子結點數目不超過 2 個的樹,它是一種很經典的數據結構。而二叉搜索樹(BST)是有序的二叉樹,BST 須要知足以下條件:
本文接下來會從定義,二叉搜索樹的增刪查以及二叉樹的遞歸和非遞歸遍歷進行整理。 下一篇文章會對二叉樹相關的經典面試題進行全面解析,本文代碼在 這裏。
咱們先定義一個二叉樹的結點,以下:
typedef struct BTNode {
int value;
struct BTNode *left;
struct BTNode *right;
} BTNode;
複製代碼
其中 value
存儲值,left
和 right
指針分別指向左右子結點。二叉搜索樹跟二叉樹可使用同一個結構,只是在插入或者查找時會有不一樣。
接下來看看二叉樹和二叉查找樹的一些基本操做,包括 BST 插入結點,BST 查找結點,BST 最大值和最小值,二叉樹結點數目和高度等。二叉查找樹( BST )特有的操做都在函數前加了 bst
前綴區分,其餘函數則是二叉樹通用的。
分配內存,初始化值便可
/**
* 建立BTNode
*/
BTNode *newNode(int value)
{
BTNode *node = (BTNode *)malloc(sizeof(BTNode));
node->value = value;
node->left = node->right = NULL;
return node;
}
複製代碼
插入結點能夠用遞歸或者非遞歸實現,若是待插入值比根節點值大,則插入到右子樹中,不然插入到左子樹中。以下圖所示(圖來自參考資料1,2,3):
/**
* 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種狀況:
left
或者 right
指針置空便可。bstSearchIter
函數),並將其值替換到待刪除結點中,而後遞歸調用刪除函數刪除該結點左子樹最大結點便可。/**
* 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;
}
複製代碼
注意在非遞歸查找中會將父結點也記錄下來。
/**
* 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;
}
複製代碼
最小值結點從左子樹遞歸查找,最大值結點從右子樹遞歸找。
/**
* 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);
}
複製代碼
/**
* 二叉樹結點數目
*/
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;
}
複製代碼
二叉樹遍歷的遞歸實現比較簡單,直接給出代碼。這裏值得一提的是層序遍歷,先是計算了二叉樹的高度,而後調用的輔助函數依次遍歷每一層的結點,這種方式比較容易理解,雖然在時間複雜度上會高一些。
/**
* 二叉樹先序遍歷
*/
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");
}
}
複製代碼
繼上一篇總結了二叉樹的基礎操做後,這一篇文章彙總下常見的二叉樹相關面試題,主要分爲判斷類、構建類、存儲類、查找類、距離類、混合類這六類大問題。本文全部代碼在 這裏 。
判斷類問題主要分下下判斷二叉樹是不是二叉搜索樹、二叉徹底樹,以及兩棵二叉樹是否同構這三個問題。
題: 給定一棵二叉樹,判斷該二叉樹是不是二叉搜索樹。
二叉搜索樹是一種二叉樹,可是它有附加的一些約束條件,這些約束條件必須對每一個結點都成立:
一種錯誤解法
初看這個問題,容易這麼實現:假定當前結點值爲 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:蠻力法
上面的錯誤解法是由於判斷不完整致使,能夠這樣來判斷:
bstMax
和 bstMin
函數功能分別是返回二叉樹中的最大值和最小值結點,這裏假定二叉樹爲二叉搜索樹,實際返回的不必定是最大值和最小值結點)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
的左子樹結點值都必須在 10
到 15
之間。顯然,結點 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);
}
複製代碼
題: 給定一棵二叉樹,判斷該二叉樹是不是徹底二叉樹(徹底二叉樹定義:若設二叉樹的深度爲 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+1
和 2i+2
,如根結點序號是 0
,它的左右子結點序號是 1
和 2
(若是都存在的話)。咱們能夠計算二叉樹的結點數目,而後依次判斷全部結點的序號,若是不是徹底二叉樹,那確定會存在結點它的序號大於等於結點數目的。如前面提到的 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。
__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;
}
複製代碼
題: 給定兩棵二叉樹,根結點分別爲 t1
和 t2
,斷定這兩棵二叉樹是否同構。所謂二叉樹同構就是指它們的結構相同,以下二叉樹 (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);
}
複製代碼
構建類問題主要是使用二叉樹的兩種遍歷順序來肯定二叉樹的另一種遍歷順序問題。在上一篇文章中咱們分析過二叉樹的先序、中序、後序遍歷的遞歸和非遞歸實現。那麼,是否能夠根據先序、中序或者先序、後序或者中序、後序惟一肯定一棵二叉樹呢?
答案是 在沒有重複值的二叉樹中, 根據先序遍歷和後序遍歷沒法惟一肯定一棵二叉樹,而根據先序、中序或者中序、後序遍歷是能夠惟一肯定一棵二叉樹的。
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 是左子樹的根,與前面同理能夠將中序遍歷分紅兩部分,直到最終肯定該二叉樹。
題: 給定一棵二叉樹的先序和中序遍歷序列,請構建該二叉樹(注:二叉樹沒有重複的值)。
先序遍歷: 7 10 4 3 1 2 8 11
中序遍歷: 4 10 3 1 7 11 8 2
二叉樹以下:
7
/ \
10 2
/ \ /
4 3 8
\ /
1 11
複製代碼
解: 根據前面的分析來解這個問題。
{4,10,3,1}
這四個結點屬於左子樹,而根結點7右邊的 {11,8,2}
屬於右子樹。O(N)
的時間,整個算法須要 O(N^2)
的時間。若是要提升效率,也能夠哈希表來存儲與查找根結點在中序遍歷中的位置,每次查找只須要 O(1)
的時間,這樣構建整棵樹只須要 O(N)
的時間。buildBTFromPreInOrder(preorder, inorder, n, 0, n);
,其中 preorder
和 inorder
分別爲先序中序遍歷數組,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;
}
複製代碼
題: 設計一個算法,將一棵二叉搜索樹(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;
}
複製代碼
題: 設計一個算法可以實現二叉樹(注意,不是二叉搜索樹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;
}
複製代碼
查找類問題主要包括:查找二叉樹/二叉搜索樹的最低公共祖先結點,或者是二叉樹中的最大的子樹且該子樹爲二叉搜索樹等。
題: 給定一棵二叉搜索樹( BST ),找出樹中兩個結點的最低公共祖先結點( LCA )。以下面這棵二叉樹結點 2 和 結點 8 的 LCA 是 6,而結點 4 和 結點 2 的 LCA 是 2。
______6______
/ \
__2__ __8__
/ \ / \
0 4 7 9
/ \
3 5
複製代碼
解: 咱們從頂往下遍歷二叉搜索樹時,對每一個遍歷到的結點,待求 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;
}
}
複製代碼
題: 給定二叉樹中的兩個結點,輸出這兩個結點的最低公共祖先結點(LCA)。注意,該二叉樹不必定是二叉搜索樹。
_______3______
/ \
___5__ ___1__
/ \ / \
6 2 0 8
/ \
7 4
複製代碼
解1:自頂向下方法
由於不必定是BST,因此不能根據值大小來判斷,不過整體思路是同樣的:咱們能夠從根結點出發,判斷當前結點的左右子樹是否包含這兩個結點。
由於對每一個結點都要重複判斷結點 p
和 q
的位置,總的時間複雜度爲 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
,則將其向上傳遞給它的父結點。父結點會判斷它的左右子樹是否都包含其中一個結點,若是是,則父結點必定是這兩個結點 p
和 q
的 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不在子樹中
}
複製代碼
題: 找出二叉樹中最大的子樹,該子樹爲二叉搜索樹。所謂最大的子樹就是指結點數目最多的子樹。
___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;
}
複製代碼
題: 已知二叉樹中兩個結點,求這兩個結點之間的最短距離(注:最短距離是指從一個結點到另外一個結點須要通過的邊的條數)。
___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;
}
複製代碼
題: 求一棵二叉搜索樹中的兩個結點的最短距離。
解: 與前面不一樣的是,這是一棵 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);
}
複製代碼
題: 寫一個程序求一棵二叉樹中相距最遠的兩個結點之間的距離。
解: 《編程之美》上有這道題,這題跟前面不一樣,要求相距最遠的兩個結點的距離,並且並無指定兩個結點位置。計算一個二叉樹的最大距離有兩個狀況:
___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;
}
複製代碼
題: 給定一棵二叉樹,求該二叉樹的最大寬度。二叉樹的寬度指的是每一層的結點數目。以下面這棵二叉樹,從上往下 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);
}
複製代碼
此類問題主要考察二叉樹和鏈表/數組等結合,形式偏新穎。
題: 給定一個有序數組,數組元素升序排列,試根據該數組元素構建一棵平衡二叉搜索樹(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.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
已經指向鏈表末尾。
題: 給定一棵二叉搜索樹( BST ),將其轉換爲雙向的有序循環鏈表。
解: 如圖所示,須要將 BST 的左右孩子指針替換成鏈表的 prev
和 next
指針,分別指向雙向鏈表的前一個和後一個結點。相信大多數人第一反應就是中序遍歷這棵二叉樹,同時改變樹中結點的 left
和 right
指針。這裏須要額外考慮的是如何將最後一個結點的right
指針指向第一個結點,以下圖所展現的那樣。
以中序遍歷遍歷一棵二叉樹的時候,每遍歷到一個結點,咱們就能夠修改該結點的 left 指針指向前一個遍歷到的結點,由於在後續操做中咱們不會再用到 left
指針;與此同時,咱們還須要修改前一個遍歷結點的 right
指針,讓前一個遍歷結點的 right
指針指向當前結點。好比咱們遍歷到結點 2,則咱們修改結點2的 left
指針指向結點 1,同時須要修改結點 1 的 right
指針指向結點 2。須要注意一點,這裏的前一個遍歷結點不是當前結點的父結點,而是當前結點的前一個比它小的結點。
看似問題已經解決,慢着,咱們其實落下了重要的兩步。1)咱們沒有對頭結點head賦值。 2)最後一個結點的right指針沒有指向第一個結點。
解決這兩個問題的方案很是簡單:在每次遞歸調用的時候,更新當前遍歷結點的 right
指針讓其指向頭結點 head
,同時更新頭結點 head
的 left
指針讓其指向當前遍歷結點。當遞歸調用結束的時候,鏈表的頭尾結點會指向正確的位置。不要忘記只有一個結點的特殊狀況,它的 left
和 right
指針都是指向本身。
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源碼剖析筆記》等文章,請你們指正。
我在參加掘金技術徵文 活動詳情 秋招求職時,寫文就有好禮相送 | 掘金技術徵文