一、链表的基本结构
1.链表是一种通过指针串联各节点形成的线性表,其中各个节点在内存中散乱分布而不需要预先占用一整块内存。相邻元素之间通常也没有物理层面上的相邻。
2.链表中一个节点称为一个元素,一个节点由数据域和指针域构成。
3.除了数据节点,链表中还经常包含一些其他的数据项:
头结点 | 是一个特殊的节点,位于链表的起始位置,不包含实际的数据,不计入链表长度,它的下一个节点为链表的第一个节点 |
头指针 | 指向链表第一个节点的指针 |
尾节点 | 尾节点是链表中的最后一个数据节点,包含数据 |
尾指针 | 指向链表中最后一个节点的指针 |
二、链表的优缺点
1.优点
(1)动态内存分配
数组必须在创建时声明最大长度,而链表可以实现动态内存分配,根据需要不断生成新的节点。由于链表不需要整块的内存地址,所以它可以不断创建新节点直到内存溢出,几乎不必考虑容量问题。由于不需要预先知道大小,在许多情景下可以有效节约内存空间。
(2)插入删除操作效率高
为了保持元素在物理层面的相邻,数组中每插入或删除一个元素都必须调整对该节点之后的所有元素,时间复杂度为O(n)。链表中插入或删除节点只需要调整该节点前后节点指针即可,时间复杂度为O(1)。
2.缺点
(1)访问低效
链表的访问是基于指针的,如果要访问某个指定序号的节点,需要从头节点(尾节点)逐个遍历至该节点,时间复杂度为O(n),而数组访问任意元素的时间复杂度均为O(1)。
(2)内存开销大
数组只需要存储元素数据,而链表节点除了数据域还有指针域,需要更多空间,存储效率较低。
3.总结
适合使用链表的情况:
- 预先不知道线性表需要多大的容量
- 经常对线性表中的元素进行增删操作
- 不经常寻找指定位置的节点
三、链表的种类和结构
1.单链表(Singly Linked List)
单链表中每一个节点的指针域仅存储下一个节点的内存地址,在查找时必须按照从头结点到尾节点的顺序遍历。
// 定义单链表节点结构
struct Node {
int data; // 数据域
struct Node* next; // 指针域
};
// 定义单链表结构
struct LinkedList {
struct Node* head; // 链表头指针
};
2.双向链表(Doubly Linked List)
双向链表中每一个节点的指针域存储下一个节点的内存地址(next)和上一个节点的内存地址(prior)。在遍历时可以按照从前往后或从后往前的顺序查找。
// 定义双向链表节点结构
struct Node {
int data; // 数据域
struct Node* prior; // 指向前一个节点的指针
struct Node* next; // 指向后一个节点的指针
};
// 定义双向链表结构
struct DoublyLinkedList {
struct Node* head; // 链表头指针
struct Node* rear; // 链表尾指针
};
3.循环链表(Circular Linked List)
循环链表中每一个节点的指针域仅存储下一个节点的内存地址,在查找时必须按照从头结点到尾节点的顺序遍历。其与单链表最大的不同点在于循环链表的最后一个元素指向第一个元素,常用于需要循环遍历数组的场景。
// 定义循环链表节点结构
struct Node {
int data; // 数据域
struct Node* next; // 指向下一个节点的指针
};
// 定义循环链表结构
struct CircularLinkedList {
struct Node* head; // 链表头指针
};
//循环链表在定义结构时与单链表相同,在创建实例时有所不同
4.双向循环链表(Doubly Circular Linked List)
双向循环链表中每一个节点的指针域存储下一个节点的内存地址(next)和上一个节点的内存地址(prior)。在遍历时可以按照从前往后或从后往前的顺序查找。最后一个元素指向下一个结点的指针(next)指向首个节点,首个节点指向上一个元素的指针(prior)指向尾节点。
// 定义双向循环链表节点结构
struct Node {
int data; // 数据域
struct Node* prior; // 指向前一个节点的指针
struct Node* next; // 指向后一个节点的指针
};
// 定义双向链表结构
struct DoublyLinkedList {
struct Node* head; // 链表头指针
struct Node* rear; // 链表尾指针
};
//双向循环链表在定义结构时与双向链表相同,创建时需将尾节点与第一个结点相互连接
四、链表的创建
单链表的创建:
struct LinkedList* createList() {
struct LinkedList newList = (struct LinkedList*)malloc(sizeof(struct LinkList));
if(newList) {
newList->head = NULL; //若链表为空,则将头结点指向NULL
}
}
双向链表的创建:
struct DoublyLinkedList* createDoublyLinkedList() {
struct DoublyLinkedList* newList = (struct DoublyLinkedList*)malloc(sizeof(struct DoublyLinkedList));
if (newList) {
newList->head = NULL; // 初始时链表为空
newList->rear = NULL;
}
return newList;
}
循环链表的创建:
struct CircularLinkedList* createCircularLinkedList() {
struct CircularLinkedList* newList = (struct CircularLinkedList*)malloc(sizeof(struct CircularLinkedList));
if (newList) {
newList->head = NULL; // 初始时链表为空
}
return newList;
}
五、元素的插入操作
由于链表的特殊性,在插入和删除操作中,必须优先处理p->next->prior等间接调用的指针,否则在修改后将会出现无法找到下一个(上一个)节点的情况。
1.头插法
在单链表的头部插入新节点,新节点将成为链表的新头节点,而原来的头节点将变为新节点的下一个节点。链表头指针将指向新节点。
// 在链表头部插入新节点
void prepend(struct LinkedList* list, int data) {
// 创建新节点
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
if (newNode) {
newNode->data = data;
// 将新节点的next指向当前头节点
newNode->next = list->head;
// 更新链表的头节点指针
list->head = newNode;
} else {
printf("内存分配失败,无法插入新节点。\n");
}
}
2.尾插法
在单链表的尾部插入新节点,新节点将成为链表的最后一个节点,而原来的尾节点将变为新节点的前一个节点。链表尾指针将指向新节点。注意讨论链表为空时的情况。
void append(struct LinkedList* list, int data) {
// 创建新节点
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
if (newNode) {
newNode->data = data;
newNode->next = NULL;
// 如果链表为空,将新节点设置为头节点
if (list->head == NULL) {
list->head = newNode;
} else {
// 否则,找到链表末尾的节点并将新节点连接到末尾
struct Node* current = list->head;
while (current->next != NULL) {
current = current->next;
}
current->next = newNode;
}
list->rear = newNode;
} else {
printf("内存分配失败,无法插入新节点。\n");
}
}
3.在链表中插入
// 在双向链表指定节点之后插入新节点
void insertAfter(struct Node* prevNode, int data) {
if (prevNode == NULL) {
printf("无法插入新节点,前一个节点为空。\n");
return;
}
// 创建新节点
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
if (newNode) {
newNode->data = data;
// 更新前一个节点的next指针
newNode->next = prevNode->next;
prevNode->next = newNode;
// 更新新节点的prior指针和后一个节点的prior指针
newNode->prior = prevNode;
if (newNode->next != NULL) {
newNode->next->prior = newNode;
}
} else {
printf("内存分配失败,无法插入新节点。\n");
}
}
六、链表的删除操作
1.删除某一元素
先将前后节点用指针相连,再释放该节点所占内存。以双向链表为例:
// 删除双向链表中指定值的节点
void deleteNode(struct Node** head, int target) {
struct Node* current = *head;
// 遍历链表查找要删除的节点
while (current != NULL) {
if (current->data == target) {
// 调整前一个节点的next指针
if (current->prev != NULL) {
current->prev->next = current->next;
} else {
// 如果要删除的节点是头节点,更新头指针
*head = current->next;
}
// 调整后一个节点的prior指针
if (current->next != NULL) {
current->next->prior = current->prior;
}
// 释放要删除的节点的内存
free(current);
return;
}
}
// 如果未找到要删除的节点
printf("节点 %d 未找到,无法删除。\n", target);
}
2.删除链表
设置两个指针,一前一后不断向末尾移动并逐个删除节点,因为只用一个指针将出现删除后下一个节点无法找到的问题。
void deleteLinkedList(struct Node** head) {
// 定义一前一后两个指针
struct Node* current = head;
struct Node* next;
while (current != NULL) {
next = current->next;
free(current); // 释放当前节点的内存
current = next;
}
head = NULL; // 确保头指针指向NULL
}
七、习题演练
题目描述:
你可以选择使用单链表或者双链表,设计并实现自己的链表。
单链表中的节点应该具备两个属性:
val
和next
。val
是当前节点的值,next
是指向下一个节点的指针/引用。如果是双向链表,则还需要属性
prev
以指示链表中的上一个节点。假设链表中的所有节点下标从 0 开始。实现
MyLinkedList
类:
MyLinkedList()
初始化MyLinkedList
对象。int get(int index)
获取链表中下标为index
的节点的值。如果下标无效,则返回-1
。void addAtHead(int val)
将一个值为val
的节点插入到链表中第一个元素之前。在插入完成后,新节点会成为链表的第一个节点。void addAtTail(int val)
将一个值为val
的节点追加到链表中作为链表的最后一个元素。void addAtIndex(int index, int val)
将一个值为val
的节点插入到链表中下标为index
的节点之前。如果index
等于链表的长度,那么该节点会被追加到链表的末尾。如果index
比长度更大,该节点将 不会插入 到链表中。void deleteAtIndex(int index)
如果下标有效,则删除链表中下标为index
的节点。示例:
输入 ["MyLinkedList", "addAtHead", "addAtTail", "addAtIndex", "get", "deleteAtIndex", "get"] [[], [1], [3], [1, 2], [1], [1], [1]] 输出 [null, null, null, null, 2, null, 3] 解释 MyLinkedList myLinkedList = new MyLinkedList(); myLinkedList.addAtHead(1); myLinkedList.addAtTail(3); myLinkedList.addAtIndex(1, 2); // 链表变为 1->2->3 myLinkedList.get(1); // 返回 2 myLinkedList.deleteAtIndex(1); // 现在,链表变为 1->3 myLinkedList.get(1); // 返回 3提示:
0 <= index, val <= 1000
- 请不要使用内置的 LinkedList 库。
- 调用
get
、addAtHead
、addAtTail
、addAtIndex
和deleteAtIndex
的次数不超过2000
。
本题看起来很基础,但十分考验对链表、C++类和C语言struct结构体的理解和掌握,代码示例中的代码思路来自于代码随想录 (programmercarl.com),笔者在此处将其从单链表转换为双向链表,对部分代码进行优化并增添注释以增进对链表结构的理解。
需要注意的是,当创建双向链表时,有时会遇到 temp->next->prev = cur;等需要对间接节点的指针进行操作的情况,此时一定要判断间接节点是否为空指针,否则即使在IDE中运行成功,OJ也将判定出错,错误信息如下:
runtime error: member access within null pointer of type 'struct ListNode'
代码示例:
using namespace std;
class MyLinkedList {
public:
// 注意本题需要自行定义链表节点结构体
// 一个MyLinkedList的实例代表一个链表
struct Node {
int val;
Node* next;
Node* prev;
Node(int n = 0, Node* next1 = nullptr, Node* prev1 = nullptr):val(n), next(next1), prev(prev1) {}
};
MyLinkedList() {
vhead = new Node(0); // 创建虚拟头节点,该节点不存储实际数据元素,指向第一个元素
size = 0; // 记录链表长度
}
int get(int index) {
if(index > (size - 1) || index < 0) return -1;
Node* ptr = vhead->next; // 遍历链表的指针
while(index--) { // 注意终止条件,需要循环index次
ptr = ptr->next;
}
return ptr->val;
}
void addAtHead(int val) {
// 头插法
Node* p = new Node(val, vhead->next, vhead);
vhead->next = p; // 顺序不可颠倒,否则将无法找到第一个节点
size++;
return;
}
void addAtTail(int val) {
// 尾插法
Node* cur = vhead; // 设置指针遍历数组找出尾节点
while(cur->next != nullptr){
cur = cur->next;
}
Node* p = new Node(val, nullptr, cur); // 创建新节点
cur->next = p;
size++;
return;
}
void addAtIndex(int index, int val) {
// 在链表指定位置插入
// 在第index个节点之前插入一个新节点,例如index为0,那么新插入的节点为链表的新头节点。
// 如果index 等于链表的长度,则说明是新插入的节点为链表的尾结点
// 如果index大于链表的长度,则返回空
// 如果index小于0,则在头部插入节点
if(index > size) return;
if(index < 0) index = 0;
Node* newNode = new Node(val);
Node* cur = vhead;
while(index--) {
cur = cur->next;
}
newNode->next = cur->next;
if(cur->next != nullptr)cur->next->prev = newNode;
newNode->prev = cur;
cur->next = newNode;
size++;
}
void deleteAtIndex(int index) {
// 删除第index个节点,如果index 大于等于链表的长度或小于0,直接return,注意index是从0开始的
if(index >= size || index < 0){
return;
}else {
Node* cur = vhead;
while(index--) {
// cur将指向第(index-1)个节点
cur = cur->next;
}
Node* temp = cur->next;
if(temp->next != nullptr) temp->next->prev = cur;
cur->next = temp->next;
delete(temp);
//delete命令指示释放了tmp指针原本所指的那部分内存,
//被delete后的指针tmp的值(地址)并非就是NULL,而是随机值。也就是被delete后,
//如果不再加上一句tmp=nullptr,tmp会成为乱指的野指针
//如果之后的程序不小心使用了tmp,会指向难以预想的内存空间
temp = nullptr;
size--;
}
}
// 打印链表
void printLinkedList() {
Node* cur = vhead;
while (cur->next != nullptr) {
cout << cur->next->val << " ";
cur = cur->next;
}
cout << endl;
}
private:
int size;
Node* vhead; //虚拟头结点
};
/**
* Your MyLinkedList object will be instantiated and called as such:
* MyLinkedList* obj = new MyLinkedList();
* int param_1 = obj->get(index);
* obj->addAtHead(val);
* obj->addAtTail(val);
* obj->addAtIndex(index,val);
* obj->deleteAtIndex(index);
*/