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