什么是链表
链表是一种线性数据结构,它包含的元素并不是物理上连续的,而是通过指针进行连接。链表中的每个元素通常由一个节点表示,每个节点包含一个数据元素和一个或多个链接(指针)。
链表的主要类型包括:
- 单向链表(Singly Linked List):每个节点包含一个指向下一个节点的指针。链表的遍历从头节点开始,沿着每个节点的指针,直到遇到一个指向null的指针(这是链表的尾部)。
- 双向链表(Doubly Linked List):每个节点有两个指针,一个指向前一个节点,另一个指向后一个节点。这允许从两个方向遍历链表。
- 循环链表(Circular Linked List):它可以是单向的也可以是双向的,但其特点是链表的尾节点指向链表的头节点,形成一个循环。
与数组相比,链表有以下特点和区别:
-
动态大小:链表的大小是动态的,可以根据需要添加或删除节点。相比之下,数组的大小在创建时就已经确定,并且不能改变。
-
效率的不同:在链表中插入和删除元素通常更有效率,因为这些操作不需要移动其他元素,只需更改相关节点的指针即可。另一方面,数组中的插入和删除操作可能需要移动大量的元素。然而,数组可以通过索引直接访问任何元素,而在链表中访问特定元素需要从头节点开始逐个遍历。
-
内存使用:在链表中,每个元素都需要额外的存储空间来存储指针,这导致链表的内存使用效率相对较低。另一方面,数组中的元素在内存中是连续存储的,没有额外的存储开销。
-
内存分配方式:数组需要连续的内存空间,而链表则可以利用内存中的任何空闲区域,只要能够容纳单个节点就可以。
这些特性使得链表和数组在不同的情况下各有优势。例如,如果您需要频繁地在序列中间插入和删除元素,链表可能是一个好的选择。然而,如果您的主要操作是随机访问元素,那么数组可能是更好的选择
。
接下来,就由我来细细的给大家讲解每种类型的链表,我们如何去自定义他们的数据结构,以及如何进行各种的节点操作。
单链表(单向链表)
- 这里我们主要去讲解一下单向链表的各种简单的操作,比如添加,删除一个节点,然后就是反转链表。
- 因为很多算法题的基础,其实就是在这些基础操作上去做变种,掌握了这些基础操作,你的链表也就算是入门级别的了。
定义单链表节点结构体**
我们都知道,单向链表这种数据结构都是由一个个节点组成的,那单向链表节点这种结构体都由什么组成呢?当然是由一个节点的值,与节点的一个next指针组成,以及构建节点的构造函数组成。其中节点的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;
}
查找操作
- 查找操作这个范围其实挺大的,因为我们可以有无数种查找条件,同样的就得自己去写无数种符合特定查找的逻辑。
- 而要去实现特定查找的逻辑其实就没有所谓的定式啦,不过万变不离其宗,也都是我下面解释的这几种方式的变种而已。
查找链表的中间节点
- 在这里我们给出两种实现方式,一种就是中规中矩的实现,先遍历一遍链表,查找链表节点的个数,然后计算出中间或者中间偏厚的那个节点的下标是多少,之后再遍历一遍链表找到这个节点。
- 第二种方式便是快慢指针法,快指针一次走两步,慢指针一次走一步,这样,当快指针走完的时候,慢指针正好也就走了链表的一般。
- 虽然两种方式的时间复杂度都是O(n),但是后者的的确确比第一种方式快了将近一倍的速度。
一般的实现方式:
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;
}
查找两条链表是否有交点
- 实际上就是让你去寻找两条链表中是这样的一个节点,它的节点中的val与next指针指向的对象是否完全相等,如果找到了则返回第一个这样的节点,否则返回nullptr。
- 这个问题一共有三种方法可以解决,这里继续赘述了,这篇文章的总体长度就会太长。
- 希望大家有兴趣的话,可以跳转到我的这篇文章去看看具体怎样实现
- 《程序员面试金典(第6版)》面试题 02.07. 链表相交(查找节点操作,哈希表,双指针(先走n步法,交替遍历法))
查找链表的成环点(检测链表是否成环)
- 这个问题也十分的简单,具体的方法和上一个问题没有什么太大的区别。我们也是可以去用哈希法,与双指针法去完成的。
- 具体的实现还是请大家看我的这篇文章:《程序员面试金典(第6版)》面试题 02.08. 环路检测(哈希法,双指针,检测链表是否有环)
- 结合着题目,我详细大家一定能更容易理解这个问题的奥义。
修改操作
- 修改操作其实也是一个和查找操作一样,范围很大的问题。由于特定的要求,我们有很多种修改的方式
- 那么最常见的一种修改,就是反转链表了。这里我们就单单介绍一下这一种方式吧
反转操作(原地反转,在原链表上进行操作)
反转一个链表有很多种方式,这里我们主要介绍两种,分别是迭代法,与递归法
迭代法:
- 这种方法其实就是双指针法,精髓就是找一个前驱指针和一个当前指针,之后从头往后逐个反转节点,最后返回即可
具体实现请看代码:
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);
链表的做题技巧(leetcode)
技巧一:创建链表的时候,使用哨兵节点
在数据结构中,哨兵节点(也称为哑元节点、哨兵元素等)是一种特殊的节点,它主要用于简化链表操作的编程实现。
对于单向链表来说,通常只有一个哨兵节点,位与链表的头部。
对于双向链表来说,通常有两个哨兵节点,一个位于链表的头部,一个位于链表的尾部。
使用哨兵节点的好处主要有以下几点:
-
简化边界条件的处理:在进行链表操作时,需要考虑很多边界条件,如插入或删除元素时,元素可能在链表的头部或尾部。使用哨兵节点可以将这些特殊情况转化为一般情况,简化代码的复杂度。
-
提高代码的效率和可读性:由于哨兵节点消除了许多特殊情况的需要,代码通常更短,更易于阅读和理解。同时,由于边界检查的需求减少,代码的效率可能会有所提高。
-
方便进行循环操作:在具有头尾哨兵节点的双向链表中,可以通过从一个哨兵节点开始,绕链表进行循环操作,这在某些应用场景中可能非常有用。
-
保护真实数据:哨兵节点可以作为保护屏障,防止对链表进行错误的修改。
但是,也要注意哨兵节点的使用会消耗额外的存储空间,虽然这在大多数情况下并不是问题。总的来说,是否使用哨兵节点,需要根据实际情况以及特定应用的需求来决定。
技巧二,谁说节点只能放一个数据?
- 根据题目的需要,在我们自定义创建节点的数据域的时候,不只是可以存放一个数据,可以存放多个数据。
技巧三,能创建新链表尝试解决问题的,先创建新链表解决问题。
- 虽然说创建新的链表会比在原链表上解决问题要多花一定的空间。但是,如果你不是指针大师了话,你很容易操作错你的指针,导致一些未知的bug出现。
- 所以,你可以先尝试创建新链表解决问题,之后再尝试在原链表上解决问题。
技巧四,前驱指针(删除,反转,插入操作可能都会用到)
- 当我们要删除某一个节点时,我们之知道链表的头节点。我们的链表是单链表,那这个时候就需要用两个指针去找到那个节点,并删除那个节点了。
- 前驱指针顾名思义也就是在指向当前节点的前一个节点的指针。
- 当我们的当前指针找到了要删除的节点时,我们的前驱指针也就正好指向到了被删除节点的前一个节点。
- 直接删除就好
链表题目清单
- 《程序员面试金典(第6版)》面试题 02.01. 移除重复节点(哈希映射,多指针暴力破解,节点的删除操作)
- 《程序员面试金典(第6版)》面试题 02.02. 返回倒数第 k 个节点(双指针法,节点的查找操作)
- 《程序员面试金典(第6版)》面试题 02.03. 删除中间节点(特殊的删除节点操作)
- 《程序员面试金典(第6版)》面试题 02.04. 分割链表(创建新链表)
- 《程序员面试金典(第6版)》面试题 02.05. 链表求和(构建一个新链表)
- 《程序员面试金典(第6版)》面试题 02.06. 回文链表(双指针(快慢指针),查找链表中间节点,反转链表)
- 程序员面试金典(第6版)》面试题 02.07. 链表相交(查找节点操作,哈希表,双指针(先走n步法,交替遍历法))
- 《程序员面试金典(第6版)》面试题 02.08. 环路检测(哈希法,双指针,检测链表是否有环)
总结
最后的最后,如果你觉得我的这篇文章写的不错的话,请给我一个赞与收藏,关注我,我会继续给大家带来更多更优质的干货内容
。