【C++】競程筆記(資料結構:linked-list)

程式碼範例參考:NTUCPC Guide,此筆記僅為個人學習用途。

鏈結串列(linked-list)

一般的陣列會是在連續的記憶體空間中儲存資料,而鏈結串列雖與陣列同為線性資料結構,但他可以將資料儲存在一個 不連續(non-contiguous) 的記憶體空間中。

linked-list 的定義為數個節點(nodes)的集合,而一個節點就由兩個成員所組成,也就是值跟前後的指標(value and previous / next pointer)。

linked-list 可以有三種型態:

  1. 單向鏈結串列(Singly Linked List)
  2. 雙向鏈結串列(Doubly Linked List)
  3. 循環鏈結串列(Circular Linked List)

那首先最簡單的當然就是單向的 linked-list 了,要如何簡單的理解 linked-list 呢?就如同上課傳紙條一樣,假設有一排座位,某個同學要傳紙條到最前面的同學,勢必要點他前面同學的肩膀叫他接力下去,就有點像下圖那樣:

image

圖:作者繪製

Head 就是這個鏈結串列的開頭,A 同學經過一連串的同學接力傳紙條後,直到傳給 F 同學為止,而最終鏈結串列結束時要指向空指標 NULL,此時我們可以假設講台是 NULL。

那在這時候(「單向」的鏈結串列)只能朝向一個方向,也就是 Next,並不能往前(Previous)。

而雙向鏈結串列就有點像是點同學肩膀然後那個同學回頭一樣,多了一個往前(Previous)的動作,像是往回看點人的同學,如下圖:

image

由於雙向鏈結串列多了 Prev 的動作,所以 A 作為開頭也能做 Prev 的動作,只不過 Prev 後就沒東西了,所以是 NULL。

最後是循環鏈結串列,其實他跟單向鏈結串列差不多,只是跑到最後一個節點會回到第一個節點去。

一樣是舉傳紙條的例子,傳到最後一個人的時候,F 同學可能看到某些勁爆的內容,很生氣地把紙條丟回去給 A 同學 XD。

image

C++ 實作鏈結串列


可用 OOP 實作。

初始化一個節點(Node),包含 data、pointer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;

class Node {
public:
int data; // 儲存節點資料
Node* next; // 指向下一節點

// 建構子:初始化資料與指標
Node(int data) {
this->data = data;
this->next = nullptr;
}
};

基本操作:

  1. 串列頭插入節點
1
2
3
4
5
void insertAtHead(int value) {
Node* newNode = new Node(value);
newNode->next = head;
head = newNode;
}

把新節點的 next 指向原本的 head,再更新 head 就行。

  1. 串列尾插入節點
1
2
3
4
5
6
7
8
9
10
11
12
void insertAtTail(int value) {
Node* newNode = new Node(value);
if (head == nullptr) {
head = newNode;
return;
}
Node* temp = head;
while (temp->next != nullptr) {
temp = temp->next;
}
temp->next = newNode;
}

要先遍歷完整個鏈結串列,直到最後一個節點,把 next 指向新節點;若串列為空,則直接更新 head 就好。

  1. 刪除串列之頭節點

直接將 head 指向 head->next,並釋放原本的頭節點記憶體。

1
2
3
4
5
6
void deleteHead() {
if (head == nullptr) return;
Node* temp = head;
head = head->next;
delete temp;
}
  1. 遍歷鏈結串列並且印出
1
2
3
4
5
6
7
8
void printList() {
Node* temp = head;
while (temp != nullptr) {
cout << temp->data << " ";
temp = temp->next;
}
cout << endl;
}

遍歷由 head 開始,依序存取每個節點的 data,直到節點指標為 nullptr 為止。

最後我們把它合起來變成一個完整的程式碼,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#include <iostream>
using namespace std;

// 節點類別
class Node {
public:
int data;
Node* next;
Node(int data) {
this->data = data;
this->next = nullptr;
}
};

// 鏈結串列類別
class LinkedList {
private:
Node* head;

public:
LinkedList() { head = nullptr; } // 建構子

// 在頭插入
void insertAtHead(int value) {
Node* newNode = new Node(value);
newNode->next = head; // 等同 (*newNode).next = head;
head = newNode;
}

// 在尾插入
void insertAtTail(int value) {
Node* newNode = new Node(value);
if (head == nullptr) {
head = newNode;
return;
}
Node* temp = head;
while (temp->next != nullptr) temp = temp->next;
temp->next = newNode;
}

// 刪除頭節點
void deleteHead() {
if (head == nullptr) return;
Node* temp = head;
head = head->next;
delete temp;
}

// 刪除指定位置節點(1-based)
void deleteAtPosition(int pos) {
if (head == nullptr || pos < 1) return;
if (pos == 1) {
deleteHead();
return;
}
Node* current = head;
for (int i = 1; i < pos - 1 && current->next != nullptr; i++) {
current = current->next;
}
if (current->next == nullptr) return; // 超出長度
Node* temp = current->next;
current->next = temp->next;
delete temp;
}

// 列印串列
void printList() {
Node* temp = head;
while (temp != nullptr) {
cout << temp->data << " ";
temp = temp->next;
}
cout << endl;
}
};

int main() {
LinkedList list;
// 範例操作
list.insertAtHead(3);
list.insertAtHead(2);
list.insertAtTail(4);
list.insertAtTail(5);
cout << "Initial list: ";
list.printList(); // 2 3 4 5

list.deleteAtPosition(2); // 移除索引為 2 的元素
cout << "After deleting position 2: ";
list.printList(); // 2 4 5

list.deleteHead();
cout << "After deleting head: ";
list.printList(); // 4 5

return 0;
}

:::info
註:此程式的鏈結串列是以 1 作為起始索引。
:::

至於在指定位置插入節點的方式,可結合前兩種方式(在串列頭插入節點、在串列尾插入節點):

先遍歷至目標的前一個位置,再調整指標串接新節點。若位置(索引)為 1 則與串列頭插入的方式相同;若超出串列長度則將插入方式改為在串列尾插入節點。


而於指定位置刪除節點的方式:

若位置為 1,與刪除串列頭節點相同;否則遍歷至目標前一個節點,調整其 next 指向目標節點的下一節點,並刪除目標節點記憶體。

C++ STL linked-list


C++ 的 STL 函式庫中也提供了 linked-list 的容器,在 STL 中是一個雙向鏈結串列的實作容器。

使用前需要引入標頭檔 #include <list>

相關操作如下:

  • push_back():將元素增加到尾端。
  • push_front():將元素增加到頭端。
  • pop_back():將元素從尾端移除。
  • pop_front():將元素從頭端移除。
  • insert():插入元素。
  • erase():移除某個位置的元素,也可以移除某段範圍的元素。
  • clear():清空容器內的所有元素。
  • empty():回傳容器是否為空。
  • size():回傳容器長度。
  • front():存取頭端元素。
  • back():存取尾端元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#include <list>
#include <iostream>

using namespace std;

class LinkedList {
private:
list<int> lst;

public:
// 插入頭節點
void insertHead(int value) {
lst.push_front(value);
}

// 插入尾節點
void insertTail(int value) {
lst.push_back(value);
}

// 插入指定位置節點
bool insertAt(int index, int value) {
if (index < 0 || index > lst.size()) {
return false;
}
auto it = lst.begin();
// advance(iterator, distance)
// 表示在迭代器 iterator 中前進 distance 個距離
advance(it, index);
lst.insert(it, value);
return true;
}

// 刪除頭節點
bool deleteHead() {
if (lst.empty()) {
return false;
}
lst.pop_front();
return true;
}

// 刪除尾節點
bool deleteTail() {
if (lst.empty()) {
return false;
}
lst.pop_back();
return true;
}

// 刪除指定位置節點
bool deleteAt(int index) {
if (index < 0 || index >= lst.size()) {
return false;
}
auto it = lst.begin();
advance(it, index);
lst.erase(it);
return true;
}

// 顯示鏈結串列內容
void display() {
if (lst.empty()) {
cout << "List is empty" << endl;
return;
}
for (const auto& val : lst) {
cout << val << " ";
}
cout << endl;
}
};

int main() {
LinkedList list;

// 插入
list.insertHead(1); // 1
list.insertTail(3); // 1 3
list.insertAt(1, 2); // 1 2 3
cout << "After insertions: ";
list.display();

// 刪除
list.deleteHead(); // 2 3
cout << "After deleting head: ";
list.display();

list.deleteTail(); // 2
cout << "After deleting tail: ";
list.display();

list.insertTail(4); // 2 4
list.insertHead(1); // 1 2 4
list.deleteAt(1); // 1 4
cout << "After deleting at index 1: ";
list.display();

return 0;
}

Linked-list 與 array 時間複雜度


操作類型Array(陣列)Linked List(鏈結串列)
存取(Access)O(1)O(n)
搜尋(Search)O(n)O(n)
插入(Insert)
- 開頭O(n)(需移動元素)O(1)
- 中間O(n)(需移動元素)O(n)(需遍歷)
- 尾端O(1)(若空間足夠)O(1)(在 tail 指標下)
刪除(Delete)
- 開頭O(n)(需移動元素)O(1)
- 中間O(n)(需移動元素)O(n)(需遍歷)
- 尾端O(1)(若空間足夠)O(n)(單向需遍歷)
大小調整O(n)(需重新配置)O(1)

若為 Doubly Linked List(雙向鏈結串列),尾端刪除可達 O(1),但需額外空間儲存前向指標。

鏈結串列習題

b938. kevin 愛殺殺:https://zerojudge.tw/ShowProblem?problemid=b938

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <bits/stdc++.h>

using namespace std;

int main() {

ios::sync_with_stdio(false);

cin.tie(nullptr);

int n, m;

cin >> n >> m;

list<int> lst;

vector<list<int>::iterator> vec(n + 1); // 記錄每個編號在 lst 中的迭代器位置

vector<bool> alive(n + 1, true); // 檢查是否有人活著

for (int i = 1; i <= n; i++) {
vec[i] = lst.insert(lst.end(), i);
}

for (int j = 0; j < m; j++) {

int k;
cin >> k;

if (!alive[k]) { // 若 k 沒有活著
cout << "0u0 ...... ?" << "\n";
} else {

auto it = vec[k];

if (next(it) == lst.end()) { // 若是最後一個人就 illegal
cout << "0u0 ...... ?" << "\n";
} else {
auto next_it = next(it);
int removed_value = *next_it;
cout << removed_value << "\n";
lst.erase(next_it);
alive[removed_value] = false;
}
}
}

return 0;
}