实例分析: 单向链表 (forward list)#
前置内容
关注逻辑、忽略数据#
由于教材原因, 新手可能在最开始接触到这样的链表结点:
1struct Student {
2 std::string id;
3 std::string name;
4 int grade;
5 Student* link; // 到下一个学生的连接
6};
这给对链表的理解带来了完全不必要的麻烦: 从学生这个词语, 我们完全看不出它会连接下一个学生.
所以我们首先对数据的存储进行抽象, 用类型别名来替代存储的数据, 将链表结点确确实实地命名为 Node
(结点):
1using value_type = int; // value_type 是 int 的别名
2
3value_type value = 0; // 相当于 int value = 0;
1using value_type = int;
2
3struct Node {
4 value_type value;
5 Node* next; // 既然是到下一个结点, 那直接命名为 next 多好!
6};
建立结点间的连接关系#
结点之间的连接是通过 next
成员完成的, 跟存储的数据没有关系. 例如在 main
函数中有三个结点, 以下代码则建立起它们之间的连接:
1int main() {
2 // 初始化时, 下一个结点是 nullptr (空结点), 则三个结点之间没有连接
3 Node node0 = {0, nullptr}; // value = 0, next = nullptr
4 Node node1 = {1, nullptr};
5 Node node2 = {2, nullptr};
6
7 node0.next = &node1; // 建立 node0 -> node1
8 node1.next = &node2; // 建立 node1 -> node2
9
10 // 此时 node0 -> node1 -> node2 -> nullptr
11}
可见结点间的连接完全基于 next
连接的结点是什么, 我们通过 node.next
即能得到下一个结点.
由此可以编写打印函数: [在线代码 41TYTzPP5]
1void print(Node const* begin) {
2 for (Node const* node = begin; // 从 begin 开始遍历;
3 node != nullptr; // 遍历直到结点是空结点;
4 node = node->next) { // 获取下一个结点
5 std::cout << node->value << " -> ";
6 }
7 std::cout << "nullptr\n";
8}
提示
你当然可以让两个结点相互指向, 即:
1Node left = {0, nullptr};
2Node right = {1, nullptr};
3left.next = &right;
4right.next = &left;
5// left ⇄ right
但我们在本文中不把这称为单向链表, 你也许可以称它为环状链表或其他东西.
数据结构都具有 不变式 即逻辑关系, 对于本文中的单向链表而言就是:
链表中上一个结点都指向下一个结点, 而不会反过来或指向其他结点;
链表中最后一个结点指向空结点.
向后插入结点#
我们已经知道了 如何连接结点从而建立链表 和 如何在链表中进行遍历, 接下来考虑如何插入结点. 此处以向已有结点 position
之后插入结点 new_node
为例, 这是单向链表最自然的情况.
已知结点 position
, 则可以得到它的后续结点 position.next
. 而所谓在 position
之后插入结点, 就是把结点插入到 position
和 position.next
之间, 且仍然保持单向链表的逻辑关系, 因而插入后应该有:
1... -> position -> new_node -> position.next -> ...
注意到, 我们完全不在乎 position
之前连接了什么结点, position.next
之后连接了什么结点.
由此有: [在线代码 ve73181v8]
1// 在 position 之后插入 new_node
2// 前置条件::
3// - position != nullptr, 即 position 是确实存在的结点.
4// - new_node != nullptr, 即 new_node 是确实存在的结点.
5//
6// 返回值::
7// 返回指向新插入结点的指针
8Node* insert_after(Node* position, Node* new_node) {
9 new_node->next = position->next;
10 position->next = new_node;
11 return new_node;
12}
13
14int main() {
15 Node node0 = {0, nullptr};
16 Node node2 = {2, nullptr};
17 insert_after(&node0, &node2);
18 // node0 -> node2 -> nullptr
19
20 Node node1 = {1, nullptr};
21 insert_after(&node0, &node1);
22 // node0 -> node1 -> node2 -> nullptr
23}
为什么要返回新插入结点的指针? 这是为了方便连续插入: [在线代码 dqjb31zv6]
1int main() {
2 Node node0 = {0, nullptr};
3 Node node1 = {1, nullptr};
4 Node node2 = {2, nullptr};
5
6 Node* node = &node0;
7 node = insert_after(node, &node1);
8 node = insert_after(node, &node2);
9 // node0 -> node1 -> node2 -> nullptr
10}
移除后一个结点#
与插入相对的, 是如何移除结点. 同样地, 对于单向链表, 移除已有结点 position
之后一个结点是最自然的情况.
已知结点 position
, 则可以得到它的后续结点 position.next
. 而所谓移除 position
之后一个结点, 就是将 position
与 position.next.next
直接连接, 并让 position.next
连接到空结点, 从而将 position.next
排除在链表的连接关系之外: [在线代码 eGfzddj4h]
1// 移除 position 的下一个结点
2// 前置条件::
3// - position != nullptr, 即 position 是确实存在的结点.
4// - position->next != nullptr, 即 position->next 是确实存在的结点.
5//
6// 返回值::
7// 返回移除的结点.
8Node* remove_after(Node* position) {
9 Node* to_be_removed = position->next;
10 position->next = to_be_removed->next;
11 to_be_removed->next = nullptr;
12 return to_be_removed;
13}
14
15int main() {
16 Node node0 = {0, nullptr};
17 Node node1 = {1, nullptr};
18 insert_after(&node0, &node1);
19 // node0 -> node1 -> nullptr
20
21 remove_after(&node0);
22 // node0 -> nullptr
23}
统一管理结点资源#
但我们的链表还有一个可能致命的问题: 我们的链表结点是单独存储的, 且都是局部变量.
- 单独存储意味着要人脑记录连接情况.
1int main() { 2 Node node0 = {0, nullptr}; 3 Node node1 = {1, nullptr}; 4 Node node2 = {2, nullptr}; 5 Node node3 = {3, nullptr}; 6 insert_after(&node0, &node1); 7 insert_after(&node1, &node2); 8 9 /* 几十行代码 */ 10 insert_after(&node3, &node0); 11 /* 几十行代码 */ 12}
在几十行代码之后, 你是否知道链表的首结点已经变成了
node3
而不是node0
呢?- 局部变量意味着整体拷贝后需要重新建立连接.
类和结构体默认的拷贝行为是对成员进行逐一拷贝, 而指针拷贝后, 两个指针存储相同的地址值、指向同样的对象. 因此, 由于
Node* next
是指针, 拷贝得到的结点仍指向原结点的后续结点.1int main() { 2 Node node0 = {0, nullptr}; 3 Node node1 = {1, nullptr}; 4 insert_after(&node0, &node1); 5 // node0 -> node1 -> nullptr 6 7 Node copy0 = node0; // copy0 -> node1 -> nullptr; 8 Node copy1 = node1; // copy1 -> nullptr 9}
我们需要 统一管理链表结点的存储 并且 不存储为局部变量, 因此我们使用 new
将结点分配在堆空间, 通过 new
所返回的指针访问结点, 使用完成后用 delete
释放结点:
1int main() {
2 // new 会在堆空间分配结点, 并返回得到指向该结点的指针
3 Node* node0 = new Node{0, nullptr};
4 Node* node1 = new Node{1, nullptr};
5
6 insert_after(&node0, &node1);
7 // node0 -> node1 -> nullptr;
8
9 // 用 delete 释放结点
10 delete node0;
11 delete node1;
12}
要释放结点, 我们只需要有指向该结点的指针, 而单向链表恰能通过 Node* next
得到后续结点的指针, 所以我们只要知道首结点就能通过遍历释放所有结点: [在线代码 xGqdacdnq]
1void delete_list(Node* node) {
2 while (node != nullptr) {
3 Node* to_delete = node;
4 node = node->next;
5 delete to_delete;
6 }
7}
8
9int main() {
10 Node* head = new Node{0, nullptr};
11
12 Node* node = head;
13 node = insert_after(node, new Node{1, nullptr});
14 node = insert_after(node, new Node{2, nullptr});
15 // 0 -> 1 -> 2 -> nullptr
16
17 delete remove_after(head);
18 // 0 -> 2 -> nullptr
19
20 delete_list(head); // 释放
21}
通过用首结点来表达整个链表的所有权, 我们有了链表从哪里开始的概念: 整个链表被首结点所有, 从首结点开始, 通过对首结点调用 delete_list
进行释放.
为了更明确地表示首结点所具有的所有权, 我们实际地定义单向链表类: [在线代码 MW5Tfrczc]
1struct Forward_list {
2 public:
3 Node* head;
4};
5
6int main() {
7 Forward_list list = {new Node{0, nullptr}};
8
9 Node* node = list.head;
10 node = insert_after(node, new Node{1, nullptr});
11 node = insert_after(node, new Node{2, nullptr});
12 // 0 -> 1 -> 2 -> nullptr
13
14 delete remove_after(list.head);
15 // 0 -> 2 -> nullptr
16
17 delete_list(list.head); // 释放
18}
让链表能包含其他类型的数据#
我们之前使用 using value_type = int;
, 从而淡化了链表存储的数据而专注于链表连接的建立. 其实这样做还带来另一个好处: 我们只需更改 using value_type = ...;
为其他类型, 就能让链表包含其他类型的数据!
1using value_type = double; // 现在, 结点存储的是 double 类型数据
2// ...
那么如果我想让链表存储学生类呢?
1struct Student {
2 std::string id;
3 std::string name;
4};
5
6using value_type = Student;
但当你编译生成会发现, 这样修改后会报错, 为什么呢? 因为我们在 print()
函数中使用 std::cout << node->value
进行输出, 而我们并没有对 Student
定义这样的操作. 为此你可以:
为
Studnet
定义一个输出函数, 将std::cout << node->value
替换为这个输出函数.或学习 运算符重载 并重载
std::cout << node->value
.
这当然还不够好, 你完全可以让链表用起来像 int 一样简单, 再坚持往下读!
扩展: 在任意位置插入结点#
由于单向链表天然只能通过 next
访问下一个结点, 而不能直接访问上一个结点, 向后插入显然是单向链表插入中最自然的情况. 因此, 回顾一下我们目前有的向后插入函数会为我们带来不少灵感:
1// 在 position 之后插入 new_node
2// 前置条件::
3// - position != nullptr, 即 position 是确实存在的结点.
4// - new_node != nullptr, 即 new_node 是确实存在的结点.
5//
6// 返回值::
7// 返回指向新插入结点的指针.
8Node* insert_after(Node* position, Node* new_node) {
9 new_node->next = position->next;
10 position->next = new_node;
11 return new_node;
12}
注意到, 由于对 position
进行了解引用, 该插入函数前置要求 position
结点非空, 因而要求链表中至少有一个结点. 这给我们带来了第一个可供分类讨论的地方.
非空链表的插入#
对于含有 n 个结点的链表, 一共有 n + 1 个插入位置. 其中有 n 个位置分别对应于向某结点之后插入, 还有 1 个位置是插入到首结点之前.

存在 n + 1 个插入位置#
插入到结点之后#
针对插入到结点之后, 我们已经定义了如何插入 (即函数 insert_after(position, new_node)
), 唯一要做的是确定在哪个结点之后插入.
确定在哪个结点之后插入取决于具体的应用, 你可以选择:
1// 获取从 node 起链表的尾结点
2// 前置条件::
3// node != nullptr, 即 node 是确实存在的结点.
4Node* tail(Node* node) {
5 for (; node->next != nullptr; node = node->next) {
6 }
7 return node;
8}
1// 获取 node 之后第 step 个结点
2// 前置条件::
3// - node 及之后 step - 1 个结点都确实存在.
4// - step >= 0.
5Node* next(Node* node, int step = 1) {
6 for (int i = 0; i < step; ++i) {
7 node = node->next;
8 }
9 return node;
10}
1// 获取 node 之后第 step 个结点, 如果不足 step 个, 则获取最后一个非空结点
2// 前置条件::
3// - node != nullptr, 即 node 是确实存在的结点.
4// - step >= 0.
5Node* sentineled_next(Node* node, int step = 1) {
6 for (int i = 0; i < step; ++i) {
7 if (node->next == nullptr) {
8 return node;
9 }
10 node = node->next;
11 }
12 return node;
13}
1// 在从 node 起的链表中查找值等于 value 的结点
2// 前置条件::
3// node != nullptr, 即 node 是确实存在的结点.
4//
5// 返回值::
6// - 如果链表中有匹配的结点, 返回首个匹配的结点.
7// - 否则返回 nullptr.
8Node* find(Node* node, value_type value) {
9 for (; node != nullptr; node = node->next) {
10 if (node->value == value) {
11 return node;
12 }
13 }
14 return nullptr;
15}
插入到首结点之前#
我们只需要: [在线代码 8M6acxejY]
用新结点连接原来的首结点:
new_node->next = list.head
.将该新结点作为新的首结点:
list.head = new_node
.
1// 将 new_node 插入到 list 首结点之前
2// 前置条件::
3// - list.head != nullptr, 即链表不为空.
4// - new_node != nullptr, 即 new_node 是确实存在的结点.
5void prepend(Forward_list& list, Node* new_node) {
6 new_node->next = list.head;
7 list.head = new_node;
8}
9
10int main() {
11 Forward_list list = {new Node{1, nullptr}};
12
13 prepend(list, new Node{0, nullptr});
14 // 0 -> 1 -> nullptr
15
16 delete_list(list.head);
17}
空链表的插入#
实际上, 我们的 prepend(list, new_node)
写错了:
1// 将 new_node 插入到 list 首结点之前
2// 前置条件::
3// - list.head != nullptr, 即链表不为空.
4// - new_node != nullptr, 即 new_node 是确实存在的结点.
5void prepend(Forward_list& list, Node* new_node) {
6 new_node->next = list.head;
7 list.head = new_node;
8}
仔细观察函数代码可以发现, 该函数其实是允许 list.head
为空结点的——list.head
在函数内并没有被解引用.
放宽函数的前置条件后, prepend(list, new_node)
能用于向空结点前插入结点, 它就足以处理对空链表的插入: [在线代码 61T3Esrbh]
1// 将 new_node 插入到 list 首结点之前
2// 前置条件::
3// - new_node != nullptr, 即 new_node 是确实存在的结点.
4void prepend(Forward_list& list, Node* new_node) {
5 new_node->next = list.head;
6 list.head = new_node;
7}
8
9int main() {
10 Forward_list list = {nullptr};
11
12 prepend(list, new Node{0, nullptr});
13 // 0 -> nullptr
14
15 delete_list(list.head);
16}
提示
其他方案之一是, 直接将首结点作为占位结点, 不实际存储任何数据.
这样一来, 链表结构上必然有一个结点, 且所有插入都是向已有结点之后插入: [在线代码 e6rd4YYfh]

1struct Forward_list {
2 public:
3 Node* dummy;
4};
5
6int main() {
7 Forward_list list = {new Node{0, nullptr}}; // 只占位, 存储的 0 不实际使用
8
9 Node* node = list.dummy;
10 node = insert_after(node, new Node{0, nullptr});
11 node = insert_after(node, new Node{1, nullptr});
12 node = insert_after(node, new Node{2, nullptr});
13 // 0 -> 1 -> 2 -> nullptr
14
15 print(list.dummy->next); // 之后一个结点才是实际存储内容的结点
16
17 delete_list(list.dummy);
18}
进行任意插入#
为什么要大费周章分析各种情况? 我们插入情况拆分为了一个个小函数, 接口更宽松的插入则只需复用组合这些函数: [在线代码 96Yz4f1sY]
1// 在链表第 n 个结点之后插入新结点 new_node,
2// - 如果不足 n 个结点, 则在尾结点之后插入
3// - 如果链表为空, 则插入为首结点
4//
5// 返回值::
6// 返回新插入的结点.
7Node* insert_after_sentineled_n(Forward_list& list, int n, Node* new_node) {
8 if (list.head == nullptr) {
9 prepend(list, new_node);
10 return list.head;
11 }
12 return insert_after(sentineled_next(list.head, n), new_node);
13}
扩展: 移除任意位置的结点#
由于单向链表天然只能通过 next
访问下一个结点, 而不能直接访问上一个结点, 移除后一个结点显然是单向链表移除中最自然的情况. 因此, 回顾一下我们目前有的移除后一个结点函数会为我们带来不少灵感:
1// 移除 position 的下一个结点
2// 前置条件::
3// - position != nullptr, 即 position 是确实存在的结点.
4// - position->next != nullptr, 即 position->next 是确实存在的结点.
5//
6// 返回值::
7// 返回移除的结点.
8Node* remove_after(Node* position) {
9 Node* to_be_removed = position->next;
10 position->next = to_be_removed->next;
11 to_be_removed = nullptr;
12 return to_be_removed;
13}
该函数存在两个问题:
不能以尾结点为参数, 因为尾结点的下一个结点为空结点, 不满足
position->next != nullptr
.不能移除首结点, 因为首结点不处于任何结点之后.
这两个问题都是可以接受的: 很多时候, 调用者就是在已知结点存在的情况下去移除那个结点. 所以与其修改 remove_after
函数的实现, 我们可以定义其它函数来满足不同人群的需要.
允许以尾结点为参数#
1// 如果存在, 移除 position 的下一个结点
2// 前置条件::
3// position != nullptr, 即 position 是确实存在的结点.
4//
5// 返回值::
6// - 如果存在, 返回移除的结点.
7// - 否则返回 nullptr.
8Node* remove_after_if_exists(Node* position) {
9 if (position->next == nullptr) {
10 return nullptr;
11 }
12 return remove_after(position);
13}
移除首结点#
1// 移除 list 的首结点
2// 前置条件::
3// list.head != nullptr, 即 list.head 是确实存在的结点.
4//
5// 返回值::
6// 返回移除的首结点.
7Node* remove_head(Forward_list& list) {
8 Node* to_be_removed = list.head;
9 list.head = to_be_removed->next;
10 to_be_removed->next = nullptr;
11 return to_be_removed;
12}
1// 如果链表不为空, 移除 list 的首结点
2//
3// 返回值::
4// - 如果链表不为空, 返回移除的首结点.
5// - 否则返回 nullptr.
6Node* remove_head_if_exists(Forward_list& list) {
7 if (list.head == nullptr) {
8 return nullptr;
9 }
10 return remove_head(list);
11}
提示
其他方案之一是, 直接将首结点作为占位结点, 不实际存储任何数据.
这样一来, 链表结构上必然有一个结点, 且所有移除都是移除已有结点之后的结点.
扩展: swap
和 exchange
#
标准库中有很多被教学忽略了的算法基础组件, 实际上很多代码是没必要自己编写的, 自行在算法内编写不仅低效而且影响阅读.
例如 std::swap(lhs, rhs)
会交换两个对象的内容:
1#include <utility> // for std::swap
2
3int lhs = 2;
4int rhs = 3;
5std::swap(lhs, rhs);
6std::cout << lhs; // 输出 3
7std::cout << rhs; // 输出 2
而对于链表, 使用算法组件 std::exchange(object, new_value)
将会非常方便, 它将 new_value
赋给 object
, 并返回 object
的旧值:
1#include <utility> // for std::exchange
2
3int value = 5;
4std::cout << std::exchange(value, 3); // 输出 5
5std::cout << value; // 输出 3
这让数据像是水流一样, 从右边流向左边:
1#include <utility> // for std::exchange
2
3// value1 == 0, value2 == 1
4int value1 = 0;
5int value2 = 1;
6
7// 返回 0, value1 == 1, value2 == 2
8std::exchange(value1, std::exchange(value2, 2));
则链表可以写为:
1#include <utility> // for std::exchange
2
3void insert_after(Node* position, Node* new_node) {
4 new_node->next = std::exchange(position->next, new_node);
5}
1#include <utility> // for std::exchange
2
3Node* remove_after(Node* node) {
4 Node* to_be_removed = std::exchange(node->next, node->next->next);
5 to_be_removed->next = nullptr;
6 return to_be_removed;
7}
1#include <utility> // for std::exchange
2
3void delete_list(Node* node) {
4 while (node != nullptr) {
5 delete std::exchange(node, node->next);
6 }
7}
扩展: 让链表像 int
一样#
目前我们已经建立了单向链表, 但这真的好用吗?
- 我们需要手动调用
delete_list
来释放资源. 1int main() { 2 Node* head = new Node{0, nullptr}; 3 4 Node* node = head; 5 node = insert_after(node, new Node{1, nullptr}); 6 node = insert_after(node, new Node{2, nullptr}); 7} // 没有 delete 而内存泄露, 你可能导致发动机停止, 引发了一场空难!
- 我们拷贝
Node*
所得新指针仍然指向同一链表. 1Node* get_list(); 2 3int main() { 4 Node* list0 = get_list(); 5 Node* list1 = get_list(); 6 // list0 和 list1 是不同的链表吗? 还是同一个链表? 7}
通过使用类来进行资源管理, 我们可以让单向链表的使用跟 int
差不多:
析构时就释放资源, 不需要手动调用
delete_list
函数.拷贝得到的新对象与原对象相互独立, 对一个对象操作不会影响另一个对象.
为了简化实现, 我选择将第一个结点作为占位结点而把第二个结点作为真正的首结点, 这样一来所有插入都能用向后插入实现: [在线代码 xTfGcjjMT]
参见
以下代码的具体思路请参考 实例分析: 动态数组 (dynamic array)
1class Forward_list {
2 public:
3 Forward_list() : dummy_(new Node{0, nullptr}), tail_(dummy_) {}
4 Forward_list(Forward_list const& other)
5 : Forward_list() // 委托默认构造函数构造好占位结点
6 {
7 // 插入占位结点之后所有结点的值, 即链表中所有实际的结点
8 for (Node* node = other.dummy_->next; node != nullptr; node = node->next) {
9 push_back(node->value);
10 }
11 }
12
13 // 用定义好的拷贝构造函数、析构函数来实现拷贝赋值函数
14 Forward_list& operator=(Forward_list const& other) {
15 Forward_list temp(other); // 用 other 拷贝一个新对象
16 swap(*this, temp); // 交换 *this 和 temp 的内容
17 return *this;
18 } // temp 的析构函数将会清理交换来的内容
19
20 ~Forward_list() {
21 while (dummy_ != nullptr) {
22 delete std::exchange(dummy_, dummy_->next);
23 }
24 }
25
26 friend void swap(Forward_list& lhs, Forward_list& rhs) {
27 using std::swap; // 先 using std::swap
28 swap(lhs.dummy_, rhs.dummy_); // 再用没有任何限定的 swap
29 }
30
31 void push_front(value_type value) {
32 insert_after(dummy_, new Node{value, nullptr});
33 }
34
35 void push_back(value_type value) {
36 tail_ = insert_after(tail_, new Node{value, nullptr});
37 }
38
39 private:
40 Node* dummy_;
41 Node* tail_;
42};
别看: 让链表支持高效的移动
这样定义拷贝函数后, 我们不再能高效地返回本地对象:
1Node* make_list() {
2 Node* head = new Node{0, nullptr};
3 /* ... */
4 return head; // 返回只需要拷贝指向头结点的指针
5} // 局部变量 head 被析构, 这没什么, 它只是指向链表头结点的指针
1Forward_list make_list() {
2 Forward_list list;
3 list.push_front(0);
4 /* ... */
5 return list; // 返回时拷贝整个链表
6} // 局部变量 list 被析构, 我们平白无故拷贝了它一份作为返回, 又析构它本身
发现了吗? 对于有的情况 (尤其是当我们在函数内构造了链表, 且想要返回这个链表时), 我们只想拷贝指针, 从而复用这个链表.
C++11 为此添加了移动语义, 它表达对资源的转移而非对资源进行拷贝. 我们可以这样定义移动构造函数和移动赋值函数:
1#include <utility> // for std::move
2
3class Forward_list {
4 public:
5 // 移动构造函数将资源从 other 转移到本对象中
6 // 因此将 other.dummy_ 的值赋给 dummy_, 并将 other.dummy_ 设为 nullptr
7 // 而 other.tail_ 同理
8 Forward_list(Forward_list&& other)
9 : dummy_(std::exchange(other.dummy_, nullptr)),
10 tail_(std::exchange(other.tail_, nullptr)) {}
11
12 // 用定义好的移动构造函数、析构函数来实现移动赋值函数
13 Forward_list& operator=(Forward_list&& other) {
14 Forward_list temp(std::move(other)); // 移动构造
15 swap(*this, temp);
16 return *this;
17 }
18
19 private:
20 Node* dummy_;
21 Node* tail_;
22};
此后, 当我们将局部链表对象作为返回值时, 它将调用移动函数而非拷贝函数:
1Forward_list make_list() {
2 Forward_list list;
3 list.push_front(0);
4 /* ... */
5 return list; // 返回时移动整个链表
6} // 局部变量 list 被析构, 没事, 它的 list.dummy_ 已经是 nullptr 了
扩展: 让链表能包含任何类型的数据#
前置内容
那有没有一种方法, 让链表能包含任意类型的数据呢? 我们代码里也许同时需要 int
类型的链表和 double
类型的链表.
为此我们使用 模板, 它将类型作为参数, 基于提供的参数生成对应的代码.
1template <typename T>
2struct Node {
3 T value;
4 Node* next;
5};
6
7Node<int> node0; // 这是存储 int 类型数据的结点
8Node<double> node1; // 这是存储 double 类型数据的结点
扩展: 让链表支持上百种算法#
在 C 风格数组: T array[size] (C-style array) 中, 我给出了 (int* begin, int* end)
这样的数组传参方法, 将数组作为半开范围 [begin, end)
传递:

而单向链表天然就具有半开范围结构:

它们的 print()
函数又是那么的相似:
1void print(int const* begin, int const* end) {
2 for (int const* iter = begin; iter != end; ++iter) {
3 std::cout << *iter << ' ';
4 }
5 std::cout << '\n';
6}
1// 我们这次不输出直到空指针, 而是输出直到 end 结点
2// 空指针不过是 end == nullptr 的特例
3void print(Node const* begin, Node const* end) {
4 for (Node const* node = begin; node != end; node = node->next) {
5 std::cout << node->value << ' ';
6 }
7 std::cout << '\n';
8}
注意到了吗? 仅有的区别在于:
++iter
变成了node = node->next
;*iter
变成了node->value
;
通过 模板, 我们可以将数组打印函数泛化, 让所有支持相应操作的类型均能使用:
1template <typename Iter>
2void print(Iter begin, Iter end) {
3 for (Iter iter = begin; iter != end; ++iter) {
4 std::cout << *iter << ' ';
5 }
6 std::cout << '\n';
7}
8
9int array0[] = {0, 1, 2, 3};
10print(&array0[0], &array0[4]); // 支持 int[]
11
12char array1[] = "hello";
13print(&array1[0], &array1[5]); // 支持 char[]
所以我们唯一需要解决的, 就是要让链表结点也支持 ++iter
和 *iter
, 而 运算符重载 恰好能帮助我们:
1class Node_iterator {
2 public:
3 Node_iterator() : node_(nullptr) {}
4 Node_iterator(Node* node) : node_(node) {}
5
6 value_type& operator*() const {
7 return *node;
8 }
9
10 Node_iterator& operator++() {
11 node_ = node_->next;
12 return *this;
13 }
14
15 friend bool operator==(Node_iterator const& lhs, Node_iterator const& rhs) {
16 return lhs.node_ == rhs.node_;
17 }
18 friend bool operator!=(Node_iterator const& lhs, Node_iterator const& rhs) {
19 return lhs.node_ == rhs.node_;
20 }
21
22 private:
23 Node* node_;
24};
于是, 我们的链表将能使用数组的打印函数, 具体地, 将能使用以半开范围为接口的很多算法.
1Node node0 = {0, nullptr};
2Node node1 = {1, nullptr};
3Node node2 = {2, nullptr};
4node0.next = &node1;
5node1.next = &node2;
6
7print(Node_iterator{&node0}, Node_iterator{nullptr});
8// 输出 0 1 2
参见
但为什么说支持上百种呢? 请阅读 范围、迭代器和算法 (range, iterator and algorithm).