前言:
嘿,亲爱的编程小伙伴
如果你正在为学习链表感到一丝紧张或者有点小茫然,别担心!链表就像一个神秘的魔法盒子,打开它,你会发现它其实并没有那么可怕,相反是一个充满无限可能和乐趣的世界。就像哈利·波特最初接触魔法那样
这里,你将与节点 、搜索 和下一步 "神秘添加 、**删除删除 和**查首次操作,您将逐步解锁链
另外,别看链表看上去有点儿抽象,我们将以最简单易懂的方式,带你一步一步攻克每一个难关。从一个个分段练习开始,阐明你可以自如地操作链表,就像和它打成一片,玩得不亦乐乎
那么,准备好了吗?放下手中的忧虑,带着满满的好奇心和大象的心态,一起进入有趣的编程大冒险吧!谁知道,学会链表之后,你可能会发现,它不仅仅是编程世界里的宝藏,还能激发你更多的编程创意和思路!🌈
开始吧,未来的链表大师,让我们一起愉快地探险,逐步征服这个神秘的编程大陆!🚀
1.链表的介绍
1.1什么是 Java 链表?
在Java中,链表(Linked List)是一种线性数据结构,由一系列节点(Node)组成。每个节点包含两个部分:
- 数据部分:存储实际的数据。
- 指针部分:指向链表中的下一个节点。该指针帮助连接链表中的所有节点。
链表的一个特点是,它的要素不需要在内存中是连续存储的,每个节点通过指针与下一个节点连接起来。
1.2链表的概念及结构
链表是一种物理存储结构上非连续存储结构,数据元素的逻辑顺序是通过链表中的引用链接次序实现的 。
2.链表的作用
链表的主要作用是提供一种灵活的方式来动态存储数据,特别是对于那些不需要随机访问数据,而是主要进行插入和删除操作的场景。相比于数据库,链表具有以下优势:
- 动态大小:链表的大小不是固定的,可以动态增长或缩小,不像队列那样需要提前定义大小。
- 插入和删除效率高:链表的插入和删除操作通常比阵列更高效(尤其是在中间插入或删除元素时),因为不需要移动元素,只需要调整指针。
- 内存使用灵活性:链表在内存中的分配不需要连续,因此阵列更适合存储大量动态数据,尤其是在内存分配不连续的情况下。
3.链表应用场景
链表在很多场景下都有应用,以下是一些常见的应用场景:
-
实现动态数据结构:
- 链表非常适合实现需要间隙插入、删除元素的场景。例如:队列、栈等数据结构。
- Java 提供了
LinkedList
类来实现链表结构,该类不仅可以作为队列(Queue)或栈(Stack)使用,还可以作为队列链表使用。
-
内存管理:
- 操作系统中的内存管理,特别是对于内存碎片的管理,可以使用链表来实现。链表中的每个内存块表示一个节点,指向下一个可用内存块。
-
多层存储系统(LRU Cache):
- LRU(最近最少使用,最近最少使用)存储算法可以通过链表来实现。每次访问一个元素时,元素就会被移到链表的头部,最少使用的元素则在链表的尾部,可以方便地在O(1) 期限终止。
-
圖表表示:
- 在图中的邻接表表示中,链表用于存储每个节点的邻接节点。 每个节点存储一条链表,链表中的元素代表与该节点相邻的节点。
-
保险:
- 在一些文件系统中,用链表来表示文件和目录之间的结构。特别是目录和文件有系统关系时,链表能够很方便地进行表示。
-
编辑器的实现:
- 许多文本编辑器(如 Sublime Text、Vim 等)实现撤销功能时,采用了链表的数据结构,通过链表来存储操作历史,使得撤销操作更加高效。
-
实现复杂数据结构:
- 链表也常用于实现其他复杂的数据结构,如跳表(Skip List)、LRU缓存、缓存表的碰撞链表等。
4.链表的优缺点
4.1优点:
- 插入和删除操作效率高,尤其是在链表的头部或中间。
- 动态分配内存,大小可以根据需求变化。
4.2缺点:
- 随机访问效率低,查找特定元素需要从头遍历链表。
- 每个节点都需要额外的空间仓储,增加了空间开销。
- 操作复杂度尤为突出,尤其是在实际情况下需要管理指针。
5.链表基本类型
链表可以根据不同的组织方式划分不同的类型:
1.单向链表(Singly Linked List)
单向链表是追踪的链表类型,每个节点有两个部分:
- 数据部分:存储实际的数据。
- 指针部分 :指向链表中的下一个节点。如果是最后一个节点,则指针指向
null
。
特点:
- 只能从头节点开始遍历链表。
- 每个节点只包含一个指向下一个节点的指针。
- 操作简单,但只能单向遍历。
Head -> [data | next] -> [data | next] -> [data | null]
2.结构链表(双向链表)
链表与单向链表类似,但每个节点除了有一个指向下一个节点的指针(next
)外,还包含一个指向前一个节点的指针(prev
)。因此,每个节点允许在两个方向上进行遍历。
特点:
- 可以从头到尾和从尾到头进行遍历。
- 每个节点有两个指针(
next
和prev
),需要更多的内存空间。 - 插入和删除操作更加灵活,尤其是在中间操作时。
null <- [prev | data | next] <-> [prev | data | next] <-> [prev | data | next] -> null
3.循环链表(Circular Linked List)
循环链表是一种特殊类型的链表,在这种链表中,最后一个节点的指针不指向null
,而是指向链表的头节点。根据节点的指向方向,循环链表可以分为:
- 单向循环链表:每个节点指向下一个节点,最后一个节点指向头节点。
- 同时循环链表 :每个节点指向相邻两个节点,最后一个节点的
next
指向头,头节点的prev
指向最后一个节点。
特点:
- 无头尾之分,可以从任意一个节点开始遍历。
- 不会遇到
null
值,因此需要额外的控制条件来结束循环。
Head -> [data | next] -> [data | next] -> [data | next] -> (head)
4.循环双向链表(Circular Double Linked List)
循环节点链表结合了节点链表和循环链表的特点:每个节点都有两个指针(指向同类节点),并且链表的尾节点和头节点通过指针形成一个循环。
特点:
- 可以在两个方向上循环遍历链表。
- 每个节点有两个指针(
next
和prev
),比单向链表和节点链表更灵活。 - 在尾部和头部插入、删除节点时更加高效。
(head) <-> [prev | data | next] <-> [prev | data | next] <-> [prev | data | next] <-> (head)
总结
- 单向链表:每个节点指向下一个节点,适合顺序遍历,但不能逆向遍历。
- 节点链表:每个节点都有指向前后节点的指针,支持节点遍历,操作更灵活。
- 循环链表:最后一个节点指向头节点,形成环形结构,适合某些循环操作的场景。
- 循环节点链表:结合了节点链表和循环链表的特点,适合需要进行节点循环交换的场景。
2.单向链表的实现
1.单向链表实现的方法:
//头插法
public void addFirst(int data);
//尾插法
public void addLast(int data);
//任意位置插入,第一个数据节点为0号下标
public void addIndex(int index,int data);
//查找是否包含关键字key是否在单链表当中
public boolean contains(int key);
//删除第一次出现关键字为key的节点
public void remove(int key);
//删除所有值为key的节点
public void removeAllKey(int key);
//得到单链表的长度
public int size();
//清空链表
public void clear();
// 打印链表
public void display() ;
2.2不同方法的实现讲解
在实现链表前,我们需要构造出实现链表的基础:
static class ListNode {
public int val;
//val表示链表中的值
public ListNode next;
//此val之下所对应的地址下表
public ListNode(int val) {
this.val = val;
}
}
public ListNode head;
//第一个val值的头结点
|----------|------|------|------|------|------|
| val | 12 | 23 | 34 | 45 | 56 |
| val.next | 0x12 | 0x56 | 0x89 | 0x23 | 0x75 |
| | head | | | | |
1.链表的打印:
cur
指标指向链表的头节点(head
)。- 通过
while
循环,当cur
不null
为时,将当前打印节点的值cur
移动到下一个节点。 - 直到遍历完整个链表,
cur
即为null
,结束。
注意事项:
- 链表为空时 :如果链表为空(即考虑
head == null
),这个方法将不会打印任何东西。是否需要添加一个提示信息,比如"链表为空"
。 - 换行符 :在链表输出完成后,你可能要添加一个行符
System.out.println()
,保证输出格式正确换行。
2.链表的长度:
- 使用
cur
遍历链表,当cur
不null
为时,递增count
。 - 遍历完成后,返回节点的总数。
注意事项:
- 链表为空时 :当链表为空时,
head
为null
,方法会返回0
- 效率问题 :
size()
方法的时间复杂度是O(n),它需要遍历链表来统计节点数量。对大链表来说可能会慢一些,但这是链表本身的特性。
3.判断链表中是否存在该值
- 使用
cur
遍历链表,遇到与key
找到的节点时,返回true
。 - 如果旅程完成都没有找到,返回
false
。
注意事项:
- 链表为空时 :如果
head == null
,方法会直接返回false
,没有问题。 - 值的处理:当链表中存在多个相同的值时,该方法仅返回第一个重复匹配的节点。如果需要检查所有节点是否都包含该值,需要调整方法逻辑。
4.添加头结点:
- 创建一个新节点
node
,node.next
指向当前的头节点(head
)。 - 然后,将
head
更新为新节点node
,使得新节点成为链表的头节点。
注意事项:
- 链表为空时 :如果链表为空,
head
则null
此方法仍然能正确工作,head
将指向新节点。 - 空指针问题 :
node.next = head;
能处理head == null
的情况。如果没有正确处理,可能会引发空指针异常。
5.添加尾节点
- 如果链表为空(
head == null
),则新节点成为链表的唯一节点,head
指向新节点。 - 否则,遍历链表直到找到最后一个节点,将其
next
指向新节点,从而将新节点添加到链表的末尾
注意事项:
- 空链表时的处理 :在链表为空时,应该将
node.next
指向null
,而不是指向head
,否则会导致死循环。需要修改为:node.next = null;
- 遍历时的边界问题 :
while (cur.next != null)
确保能走到链表的最后一个节点,但如果链表有一个节点时,cur
的next
就是null
,跳出循环,防止空指针异常。 - 尾部插入的效率:每次在尾部插入时都需要遍历整个链表,效率较低(O(n))。可以考虑维护一个尾部卸载来优化该操作。
6.任意位置插入,第一个数据节点为0号下标
- 如果
index
小于0或大于链表的当前大小,则输出错误信息并返回。 - 如果
index == 0
,调用addFirst(data)
在头部插入。 - 如果
index == size()
,调用addLast(data)
在尾部插件。 - 否则,遍历链表到指定位置,在该位置插入新节点。
- 插入操作:首先将新节点的
next
指向当前节点的下一个节点,然后将当前节点的next
指向新节点。
- 插入操作:首先将新节点的
注意事项:
index > size()
的情况 :在判断位置合法时,需要检查index == size()
是否合法,因为在addLast
中已经处理了index == size()
的情况。所以判断条件应该是index < 0 || index > size()
。- 位置插入时的逻辑 :当
index == 0
或index == size()
时直接调用addFirst
和addLast
,但在其他位置插入时,遍历过程中应注意保证正确更新cur.next
。 index--
的错误 :在循环中,index--
是为了控制循环次数,但代码写到了while (index - 1 != 0)
,应该写成while (index - 1 > 0)
,否则会导致死循环或越界。
7.删除第一次出现关键字为key的节点
- 如果链表为空,直接返回。
- 如果头节点的得分
key
,则将head
更新为头节点的下一个节点,删除头节点。 - 否则,调用
finfNodeOfKey(key)
查找包含key
的节点。- 如果缺少节点,直接返回。
- 如果找到,删除该节点:通过调整前一个节点(
cur
)的next
指针,跳过待删除节点。
注意事项:
- 头节点删除 :如果
head.val == key
,直接删除头节点,但此时head
已经更新为head.next
,如果链表为空,head
可能为null
,需要小心避免空指针异常。 - 找不到节点时的处理 :如果
cur
为null
,说明没有找到价值key
的节点,此时方法应该返回,不进行后续操作。
- 遍历链表,找到计算出
key
的节点时,返回它的前一个节点。
注意事项:
- 空指针异常 :在访问
cur.next.val
时,应该首先检查cur.next
是否为null
。如果是null
,将导致空指针异常。修改为:if (cur != null && cur.next != null && cur.next.val == key)
。 - 返回值的处理 :是返回节点
cur
,而不是cur.next
,否则将返回错误的节点。
8.删除所有匹配key
的节点
- 使用
prev
和cur
遍历链表:- 如果
cur.val == key
,则删除当前节点,prev.next
指向cur.next
。 - 否则,移动
prev
和cur
。
- 如果
- 如果头节点的估值
key
,需要特别处理,直接将head
更新为头节点的下一个节点。
注意事项:
- 删除操作的错误 :在删除节点时,
prev.next = cur;
这行可能会导致链表断开。应改为prev.next = cur.next;
,即跳过当前节点(cur
)而不是prev.next
指向cur
自身。 - 链表头部删除 :删除所有
key
节点时,还需要检查头节点是否是key
,如果是,需要更新头节点为head.next
。
9.清空链表
- 遍历链表,依次断开每个节点的
next
指针,释放内存。 - 最后将
head
设置为null
,表示链表为空。
注意事项:
- 链表清空的效率 :遍历链表时将每个节点的
next
设置为null
,这虽然是清理链表的方法,但在cur = Ncur;
之后,实际上不会再使用这个节点了。此时Java会自动进行垃圾回收(GC),不需要手动释放内存。 head = null
重要性 :必须将head
设置为null
,否则链表的引用存在,造成内存泄漏。
3.给出初学者指出一些建议
1.理解链表的基本概念
- 链表的结构 :链表是一种动态数据结构,由一系列引用节点组成,每个存储节点包含数据和指向下一个节点的(
next
)。与数组不同,链表的元素在内存中不连续,访问时需要通过指针遍历链表。 - 头节点(
head
) :链表的操作通常从头节点开始。头节点指向链表的第一个元素,若链表为空,head
为null
。
学习链表时,首先需要掌握这些基本概念,以及链表与集群的差异。
2.小心空指向异常
-
链表为空时的处理 :链表操作中最常见的错误就是忘记处理空链表的情况。例如,在访问
cur.next
或cur.val
之前,必须确保链表不为空。每次操作前,检查head == null
或cur == null
非常重要。 -
空链表:在删除操作、操作插入、查找操作等常见场景中,一定要处理链表为空的情况,否则会导致空指针异常或逻辑错误。
示例 :在游览时检查
cur != null
。
3.边界条件的处理
-
插入与删除边界 :插入或删除节点时,特别要注意边界条件。例如,当插入操作的
index == 0
时,应特别处理为插入头部;index == size()
当时,应处理为插入尾部。 -
删除头节点 :删除链表中的头节点时需要特别小心。如果头节点的值匹配,需要直接将
head
更新为head.next
,否则可能导致链表丢失。示例 :删除操作中,确保更新头节点时处理
head
为null
的情况。
4.理解链表的效率
-
线性时间复杂度(O(n)):链表的常见操作(如查找、删除、插入)时间复杂度通常为 O(n),其中 n 是链表的长度。例如,在尾部插入时,我们需要遍历整个链表找到最后一个节点。这对于长链表来说可能会比较慢。
-
尾部优化 :为了避免每次尾部插入时维护链表,可以考虑一个尾部卸载
tail
,这样可以在 O(1) 的期限进行尾部插入。建议 :可以在链表中增加一个
tail
向导来优化尾部插入操作,从而减少时间复杂度。
5.边界条件与特殊情况
-
index
位置插入时的检查 :当index
小于0或大于链表的当前大小时,应该直接返回或发送异常。避免非法索引带来的问题。 -
删除所有相同元素 :在删除链表中所有某个值
key
的节点时,需要处理所有节点都匹配的情况(例如,链表全是同一个值)。考虑清空头节点及其后面的所有节点。建议 :要特别注意当要删除出现在头部或尾部的节点时,如何正确调整
head
或链表的指针。
6.调试技巧
-
打印链表 :在实现链表操作时,增加
display()
方法,可以帮助你洞察地看到链表的当前状态。调试时,通过打印链表的每个节点可以帮助你了解每一步的变化。 -
逐步修改调试:在实现或代码时,可以逐步调试,检查每个方法的执行过程,尤其是插入、删除和查找方法。
建议:使用调试器或打印调试信息,检查每次操作后链表是否按预期变化。
7.内存管理员垃圾回收
-
链表节点的释放 :在Java中,垃圾回收器(GC)会自动管理内存,但需要理解对象引用的工作原理。在删除节点时,不再引用节点后,GC会回收这些节点的内存。避免使用
cur.next = null
这种方式强制清除节点内容,除非你有特定的需求。 -
清空链表:在清空链表时,遍历每个节点并指向后续节点的引用是一个好习惯,这样可以保证链表中的所有节点被正确地垃圾回收。
建议 :在清空链表时,逐个断开每个节点的引用,并最终将
head
设置为null
,以便垃圾回收器能够处理这些无用的节点。
8.避免不必要的重复代码
- 例如,在
addIndex
方法中,对于index == 0
和index == size()
,已经分别通过调用addFirst
和addLast
进行了处理,可以避免代码重复。但在某些操作中,可以进一步提炼代码,减少重复。
9.掌握链表的常见操作与应用
- 学习链表的常见操作和应用场景(如栈、队列的实现),这样可以帮助你更好地理解链表的实际使用和效率。
- 栈(Stack) :可以使用链表的头部操作(如
addFirst
和removeFirst
)来实现。 - 队列(Queue) :可以通过链表的尾部操作(如
addLast
和removeFirst
)来实现。
4.结语:
恭喜你,成功踏上了链表的奇妙旅程!🎉
在这个充满了指针、节点和动态内存的世界里,每一步都需要小心翼翼,每个操作都在拼图中看起来很有趣,缺少了一个空间细节,链表就可能"崩溃"!但别担心,只要你牢牢的牢牢掌握了插入、删除、查找这些基本操作,链表就不再是问题,而是你手中的"神奇工具"。
你可能会遇到过链表为空时的尴尬,或者解决过程中无尽的循环成功困扰,但只需耐心调试,边界条件、空指针等常见问题即可迎刃而解! 最重要的是:当你处理好一个问题时一个小小的挑战时,那份成就感,简直不比通过一场关超级难的游戏老板还要爽!🔥
编程就像是和计算机玩的"捉迷藏",有时候它藏在那里的儿不给你答案,但如果你认真思考、静静排查,每个问题都会迎刃而解。而且,你会发现:每掌握一个新、突破一个难关,你就离成为编程高手又近一步了。💪
所以,继续加油吧!每一次的编码实践都是一次马德里的探险,链表只是你编程之旅的第一站。未来还有很多数据结构等待去挑战------堆、栈、队列、图、哈希表......它们各有各的魅力和神秘的面纱等你去揭开。
最后,记住,编程是无尽乐趣的源泉。遇到困难时,对自己微笑,拍拍肩膀说一声:"嘿,我能行!"🚀
加油,未来的编程大师!🎉🎉
下期预告:
双向链表的实现