链表是一种线性数据结构,它包含的元素并不是物理上连续的,而是通过指针进行连接。链表中的每个元素通常由一个节点表示,每个节点包含一个数据元素和一个或多个链接(指针)。
链表的主要类型包括:
与数组相比,链表有以下特点和区别:
动态大小:链表的大小是动态的,可以根据需要添加或删除节点。相比之下,数组的大小在创建时就已经确定,并且不能改变。
效率的不同:在链表中插入和删除元素通常更有效率,因为这些操作不需要移动其他元素,只需更改相关节点的指针即可。另一方面,数组中的插入和删除操作可能需要移动大量的元素。然而,数组可以通过索引直接访问任何元素,而在链表中访问特定元素需要从头节点开始逐个遍历。
内存使用:在链表中,每个元素都需要额外的存储空间来存储指针,这导致链表的内存使用效率相对较低。另一方面,数组中的元素在内存中是连续存储的,没有额外的存储开销。
内存分配方式:数组需要连续的内存空间,而链表则可以利用内存中的任何空闲区域,只要能够容纳单个节点就可以。
这些特性使得链表和数组在不同的情况下各有优势。例如,如果您需要频繁地在序列中间插入和删除元素,链表可能是一个好的选择。然而,如果您的主要操作是随机访问元素,那么数组可能是更好的选择
。
接下来,就由我来细细的给大家讲解每种类型的链表,我们如何去自定义他们的数据结构,以及如何进行各种的节点操作。
我们都知道,单向链表这种数据结构都是由一个个节点组成的,那单向链表节点这种结构体都由什么组成呢?当然是由一个节点的值,与节点的一个next指针组成,以及构建节点的构造函数组成。其中节点的next指针,指向了该链表的下一个节点的元素位置。
所以,当我们创建单向链表节点的这个结构体的时候,需要指定以下元素:
具体代码如下:
struct ListNode {
int val; // 节点的值
ListNode *next; // 节点的next指针
ListNode(int x) : val(x), next(nullptr) {} // 初始化一个链表的构造函数
}
当我们想要构造一个链表节点,或者搭建一个全新的链表,new关键字就会发挥作用。这个操作需要我们主动去分配一块新的内存空间,以存储新的节点信息。然而,这仅仅只是创建了一个节点。如果我们想要创建一个完整的链表,那就需要逐个创建节点,然后利用next指针把这些节点逐一连接起来。这种操作在解决算法题目的过程中是常见的。
让我们以创建链表为例进行说明。首先,我们通过 new关键字创建一个节点,把需要的数据存储在这个新分配的内存空间里。然后,通过重复这个步骤,我们可以创建多个节点。最后,通过使用next指针,将这些独立的节点串联起来,形成一个完整的链表。
按照我们刚刚给出的 ListNode 结构体定义,我们可以这样创建一个简单的链表:
int main() {
ListNode* node1 = new ListNode(1);
ListNode* node2 = new ListNode(2);
ListNode* node3 = new ListNode(3);
node1->next = node2;
node2->next = node3;
// 遍历链表,打印值
ListNode* current = node1;
while (current != NULL) {
std::cout << current->val << " ";
current = current->next;
}
return 0;
}
所以接下来我们就把这两种种插入操作,来分开讲解一下。
具体代码如下:
ListNode * insertBeforeNode(ListNode * head, ListNode * target, int val){
// 如果链表为空,给定的节点也为空,则直接返回nullptr
if(head == nullptr && target == nullptr) return nullptr;
// 如果链表不为空,给定的节点为空,等于在尾后插入一个节点
if(target == nullptr){
ListNode * prev = head;
ListNode * cur = head->next;
while(cur != nullptr){
prev = cur;
cur = cur->next;
}
ListNode * node = new ListNode(val);
prev->next = node;
return head;
}
// 如果链表不为空,给定的节点也不为空
ListNode * prev = head;
ListNode * cur = prev->next;
while(cur != target && cur != nullptr){
prev = prev->next;
cur = cur->next;
}
ListNode * temp = new ListNode(val);
prev->next = temp;
temp->next = cur;
return head;
}
具体的代码如下:
// 从指定节点后插入一个节点
ListNode * insertAfterNode(ListNode * head, ListNode * target, int val){
// 如果链表为空,或者指定的节点为空,则无法在指定位置后插入这个节点,直接返回
if(head == nullptr || target == nullptr) return head;
// 如果链表不为空,指定的节点不为空,则我们在指定的位置后,插入这个节点
ListNode * cur = head;
while(cur != nullptr){
if(cur == target){
ListNode * temp = cur->next;
ListNode * node = new ListNode(val);
cur->next = node;
node->next = temp;
break;
}
cur = cur->next;
}
return head;
}
在链表中如果说要删除一个节点了话,无外乎与这么两种情况:
具体的代码如下:
ListNode * deleteNode(ListNode * head, ListNode * target){
// 如果链表为空,或者指定节点为空,则直接返回
if(head == nullptr || target == nullptr) return head;
ListNode * prev = head;
ListNode * cur = prev->next;
while(cur != nullptr){
if(cur == target){
ListNode * temp = cur->next;
prev->next = temp;
delete cur;
break;
}
prev = prev->next;
cur = cur->next;
}
return head;
}
void deleteNode(ListNode* node) {
node->val = node->next->val;
ListNode * temp = node->next;
node->next = node->next->next;
delete temp;
temp = nullptr;
}
一般的实现方式:
ListNode * findMiddleNode(ListNode * head){
// 如果链表为空,或者链表只有一个节点,则返回head本身
if(head == nullptr || head->next == nullptr) return head;
int count = 0;
ListNode * cur = head;
while(cur !=nullptr){
++count;
cur = cur->next;
}
int mid = count / 2;
cur = head;
while(mid > 0){
--mid;
cur = cur->next;
}
return cur;
}
快慢指针法:
ListNode * findMiddleNode(ListNode * head) {
if(head == nullptr || head->next == nullptr) return head;
ListNode * slow = head;
ListNode * fast = head;
while(fast->next != nullptr && fast->next->next != nullptr){
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
反转一个链表有很多种方式,这里我们主要介绍两种,分别是迭代法,与递归法
迭代法:
具体实现请看代码:
ListNode * reverseList1(ListNode * head){
// 如果链表为空,或者说是只有一个节点,就直接返回
if(head == nullptr || head->next == nullptr) return head;
ListNode * prev = nullptr;
ListNode * cur = head;
while(cur!=nullptr){
ListNode * temp = cur->next;
cur->next = prev;
prev = cur;
cur = temp;
}
return prev;
}
递归法:
具体的实现请看代码:
ListNode * reverseList2(ListNode * head){
// 如果链表为空,或者说是只有一个节点,就直接返回
if(head == nullptr || head->next == nullptr) return head;
ListNode * p = reverseList2(head->next);
head->next->next = head;
head->next = nullptr;
return p;
}
双链表与单链表的区别在与,双链表里面多了一个prev指针,指向这个节点的前一节点。其他的与单向链表一致
但由于双向链表其实我们做题的时候,很少用到自己自定义双向链表的情况,所以这里就不放细细拆解自定义双向链表里面的所有操作了。
我写了一个自定义双向链表类,大家感兴趣的话,看看即可,没有什么特别的难处
class DoublyLinkedList {
private:
struct Node {
int val;
Node* prev;
Node* next;
Node(int val) : val(val), prev(nullptr), next(nullptr) {}
};
Node* head;
Node* tail;
public:
DoublyLinkedList() {
// 创建哨兵节点
head = new Node(0); // 头部哨兵节点
tail = new Node(0); // 尾部哨兵节点
// 将哨兵节点连接起来
head->next = tail;
tail->prev = head;
}
void addNode(int val) {
// 在链表尾部添加新的节点
Node* node = new Node(val);
node->prev = tail->prev;
node->next = tail;
tail->prev->next = node;
tail->prev = node;
}
void removeNode(Node* node) {
// 从链表中移除节点
node->prev->next = node->next;
node->next->prev = node->prev;
delete node;
}
~DoublyLinkedList() {
// 从头部开始删除所有节点
Node* node = head;
while (node) {
Node* next_node = node->next;
delete node;
node = next_node;
}
}
};
使用自定义单链表和双链表时,需要注意一些常见的易错点,这些主要包括:
空指针解引用
:链表操作中的空指针解引用是很常见的错误。例如,当试图访问链表的下一个节点或者前一个节点时,一定要确保当前节点不是尾节点或头节点。
内存管理
:在创建新的节点或者删除现有的节点时,需要正确处理内存分配和释放,避免内存泄漏。尤其是在删除节点时,一定要记住先更新链表的连接关系,再释放节点内存。
链表循环引用
:特别是在双向链表中,可能会出现头尾相连形成循环引用的情况,这会导致访问链表时陷入无限循环。需要小心处理头尾节点的连接关系,避免出现循环引用。
链表断裂
:在进行节点插入和删除操作时,如果没有正确更新相邻节点的连接关系,可能会导致链表断裂。例如,在插入新节点时,不仅需要更新新节点和相邻节点的连接关系,还需要更新相邻节点和新节点的连接关系。
边界条件处理
:在进行链表操作时,常常需要处理一些边界条件,例如链表为空、只有一个节点、插入或删除的是头节点或尾节点等情况。这些边界条件如果处理不当,可能会导致程序错误。
忽视哨兵节点的影响
:如果链表使用了哨兵节点,需要在处理链表时考虑到哨兵节点的存在。特别是在遍历链表、计算链表长度等操作时,要注意不要将哨兵节点计入。
以上就是在使用自定义单链表和双链表时需要注意的一些常见易错点。只要小心处理这些问题,就能避免大部分链表操作中的错误。
错误原因:
错误解析
这是一个很常见的问题,通常是由于对变量的引用和赋值操作不够清晰所导致的。在 C++ 中,当你写 Node* node = head;
这样的语句时,你创建的 node 是原链表头节点 head 的一个引用,也就是说,他们都指向同一个物理对象。因此,对 node 的任何修改也会影响到 head。
如果你需要创建一个新的链表,而不是在原链表上进行操作,你应该为新链表的每个节点创建新的内存空间,而不是直接使用原链表的节点。这可以通过 new 关键字来实现
。
比如:Node* node = new Node(head->val);
在数据结构中,哨兵节点(也称为哑元节点、哨兵元素等)是一种特殊的节点,它主要用于简化链表操作的编程实现。
对于单向链表来说,通常只有一个哨兵节点,位与链表的头部。
对于双向链表来说,通常有两个哨兵节点,一个位于链表的头部,一个位于链表的尾部。
使用哨兵节点的好处主要有以下几点:
简化边界条件的处理:在进行链表操作时,需要考虑很多边界条件,如插入或删除元素时,元素可能在链表的头部或尾部。使用哨兵节点可以将这些特殊情况转化为一般情况,简化代码的复杂度。
提高代码的效率和可读性:由于哨兵节点消除了许多特殊情况的需要,代码通常更短,更易于阅读和理解。同时,由于边界检查的需求减少,代码的效率可能会有所提高。
方便进行循环操作:在具有头尾哨兵节点的双向链表中,可以通过从一个哨兵节点开始,绕链表进行循环操作,这在某些应用场景中可能非常有用。
保护真实数据:哨兵节点可以作为保护屏障,防止对链表进行错误的修改。
但是,也要注意哨兵节点的使用会消耗额外的存储空间,虽然这在大多数情况下并不是问题。总的来说,是否使用哨兵节点,需要根据实际情况以及特定应用的需求来决定。
这个文章讲解了C++的STL库中的双向链表这种容器的各种操作,我们不单单应该会自己自定义的链表,也要去学C++的STL容器才行。
最后的最后,如果你觉得我的这篇文章写的不错的话,请给我一个赞与收藏,关注我,我会继续给大家带来更多更优质的干货内容
。