作为一名对技术充满热情的学习者,我一直以来都深刻地体会到知识的广度和深度。在这个不断演变的数字时代,我远非专家,而是一位不断追求进步的旅行者。通过这篇博客,我想分享我在某个领域的学习经验,与大家共同探讨、共同成长。请大家以开放的心态阅读,相信你们也会在这段知识之旅中找到启示。
前言
链表在数据结构中的重要性是无法替代的,无论是动态增删节点,还是对内存的利用,实现多种复杂结构,甚至在预分配内存等方面都有很好的表现。今天我们就依次对单链表,双向链表,循环链表进行讲解。
一、单链表,双向链表,循环链表
单链表(Singly Linked List):
单链表
由一系列节点构成,每个节点包含二个部分:一个是存储数据的部分(data)
,另一个是指向列表中下一个节点的指针(next)
。列表的开始节点称为头节点(head)
。列表的最后一个节点指针不是指向下一个节点,而是指向一个空值(通常是null或None),表示链表的结束。单链表的特点是每个节点只有一个指针连接到下一个节点,所以操作只能是单向的,例如,仅能从头节点开始顺着链表遍历到尾节点。
图示:
双向链表(Doubly Linked List):
双向链表
的每个节点除了包含数据和指向下一个节点的指针之外,还包含一个指向前一个节点的指针(prev)
。这意味着你可以很容易地访问链表的前驱节点和后继节点。这种额外的指针使得双向链表可以从两个方向遍历(正向和反向),并且可以更方便地执行一些操作,比如在给定节点前后插入新节点或删除当前节点。不过这种灵活性是以额外的存储空间为代价的,因为双向链表的每个节点都需要额外存储一个指针。
图示:
循环链表(Circular Linked List):
循环链表
可以是单向的也可以是双向的。它的特点在于链表的尾节点指针并不是指向空值,而是指向头节点,形成一个闭环。这意味着从链表的任何一个节点出发都可以遍历整个列表,并且可以无限制地循环下去。在单向循环链表中,你可以从任意节点开始,沿一个方向遍历整个链表。而在双向循环链表中,每个节点既有向后的指针也有向前的指针,你可以沿任一方向遍历。循环链表特别适合解决那些需要循环访问列表中的数据的问题。
图示:
二、增删改查
单链表、双向链表和循环链表的增删改查(CRUD)操作
单链表(Singly Linked List)的操作:
-
增加(Add):有两种常见的添加操作,头部添加和尾部添加。
- 头部添加:创建新节点,将新节点的next指针指向原头部节点,然后更新头部节点为新节点。
- 尾部添加:遍历到链表的尾部节点,然后将尾部节点的next指针指向新节点,最后将新节点的next指针设置为null。
-
删除(Delete):要删除一个节点,你需要访问该节点的前一个节点,然后将前一个节点的next指针跳过当前节点指向下一个节点。
- 如果是删除头节点,直接将头指针指向原头节点的下一个节点即可。
- 如果是尾部节点,遍历找到尾节点的前一个节点,然后将该节点的next指针设为null。
-
查找(Search):从头节点开始,遍历每一个节点,检查其数据字段是否匹配搜索条件。
-
更新(Update):先执行查找操作找到目标节点,然后更改该节点的数据字段。
单链表:
class ListNode {
int val;
ListNode next;
ListNode(int x) {
val = x;
next = null;
}
}
// 增加节点(尾部插入)
public void add(ListNode head, int value) {
ListNode newNode = new ListNode(value);
ListNode cur = head;
while(cur.next != null) {
cur = cur.next;
}
cur.next = newNode;
}
// 删除节点(按值删除)
public void delete(ListNode head, int value) {
ListNode cur = head;
while(cur.next != null) {
if(cur.next.val == value) {
cur.next = cur.next.next;
return;
}
cur = cur.next;
}
}
// 修改节点(按值修改第一个匹配的节点)
public void update(ListNode head, int oldValue, int newValue) {
ListNode cur = head;
while(cur != null) {
if(cur.val == oldValue) {
cur.val = newValue;
return;
}
cur = cur.next;
}
}
// 查询节点(按值查询)
public ListNode search(ListNode head, int value) {
ListNode cur = head;
while(cur != null) {
if(cur.val == value) {
return cur;
}
cur = cur.next;
}
return null;
}
双向链表(Doubly Linked List)的操作:
-
增加(Add):和单链表类似,但是添加节点时还需要设置前一个节点的指针。
- 头部添加:与单链表类似,但还需要将原头节点的prev指针指向新节点。
- 尾部添加:找到尾节点,将新节点的prev指针指向尾节点,然后把尾节点的next指针指向新节点。
-
删除(Delete):找到目标节点,调整其前一个节点的next指针和后一个节点的prev指针,跳过该节点。
-
查找(Search):可以从头部开始,也可以从尾部开始,视情况而定,因为双向链表可以双向遍历。
-
更新(Update):和单链表一样,找到节点后更新其数据字段。
双向链表:
class DoublyListNode {
int val;
DoublyListNode prev;
DoublyListNode next;
DoublyListNode(int x) {
val = x;
prev = null;
next = null;
}
}
// 对于双向链表,增删改查的基本操作类似于单链表,但是需要额外处理前驱节点的引用。
// 增加节点(尾部插入)
// ...
// 删除节点(按值删除)
// ...
// 修改节点(按值修改第一个匹配的节点)
// ...
// 查询节点(按值查询)
// ...
循环链表(Circular Linked List)的操作:
-
增加(Add):与单链表相似,但在尾部添加时最后一个步骤是将新的尾节点的next指针指向头节点,而不是null。
-
删除(Delete):操作类似,但如果删除的是尾节点,在单向循环链表中需要找到倒数第二个节点,让它的next指针指向头节点;在双向循环链表中,头节点的prev指针也要调整。
-
查找(Search):因为是循环的,要小心处理,通常遍历时设置一个循环的次数限制,以防无限循环。
-
更新(Update):和单链表一样,找到节点后更新其数据字段。
循环链表:
循环链表的实现与单链表相似,但尾节点指向头节点,创建时需要特殊处理。
class CircularListNode {
int val;
CircularListNode next;
CircularListNode(int x) {
val = x;
next = this; // 指向自己形成循环
}
}
// 对于循环链表,增删改查的基本操作也类似于单链表,但遍历时需要防止无限循环。
// 增加节点(尾部插入)
// ...
// 删除节点(按值删除)
// ...
// 修改节点(按值修改第一个匹配的节点)
// ...
// 查询节点(按值查询)
// ...
请注意,以上代码是简化的伪代码,并不完整,例如没有考虑头节点为空的情况,实际编码时还需要做更多的边界条件处理。此外,根据具体的应用场景和需求,增删改查操作中的细节可能会有所变化。
三、实战项目
单链表实现简单的任务队列:
// 单链表的节点
class TaskNode {
String taskData; // 任务的具体数据
TaskNode next; // 指向下一个任务的指针
public TaskNode(String taskData) {
this.taskData = taskData;
this.next = null;
}
}
// 任务队列,基于单链表
class TaskQueue {
TaskNode head; // 指向队列头部的节点
public TaskQueue() {
head = null;
}
// 将新任务加入队尾
public void enqueue(String taskData) {
TaskNode newNode = new TaskNode(taskData);
if (head == null) {
head = newNode;
} else {
TaskNode current = head;
while (current.next != null) {
current = current.next;
}
current.next = newNode;
}
}
// 从队头取出任务
public String dequeue() {
if (head == null) {
return null; // 队列为空时返回null
}
String taskData = head.taskData;
head = head.next; // 头部节点向后移动
return taskData;
}
// 检查队列是否为空
public boolean isEmpty() {
return head == null;
}
}
// 使用案例
public static void main(String[] args) {
TaskQueue queue = new TaskQueue();
queue.enqueue("Task1");
queue.enqueue("Task2");
while (!queue.isEmpty()) {
System.out.println(queue.dequeue());
}
}
这个单链表队列可以用于执行任务队列的管理,比如在多线程环境下,主线程添加任务,工作线程获取任务进行处理。
双向链表实现LRU缓存:
import java.util.HashMap;
import java.util.Map;
// 双向链表的节点
class LRUNode {
int key;
int value;
LRUNode prev;
LRUNode next;
public LRUNode(int key, int value) {
this.key = key;
this.value = value;
}
}
// LRU缓存机制的实现
class LRUCache {
private Map<Integer, LRUNode> cache = new HashMap<>();
private int size;
private int capacity;
private LRUNode head, tail;
public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
head = new LRUNode(0, 0);
tail = new LRUNode(0, 0);
head.next = tail;
tail.prev = head;
}
public int get(int key) {
LRUNode node = cache.get(key);
if (node == null) return -1;
// 把访问过的节点移动到头部
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
LRUNode node = cache.get(key);
if (node == null) {
LRUNode newNode = new LRUNode(key, value);
cache.put(key, newNode);
addToHead(newNode);
if (++size > capacity) {
LRUNode tail = removeTail();
cache.remove(tail.key);
size--;
}
} else {
node.value = value;
moveToHead(node);
}
}
private void addToHead(LRUNode node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(LRUNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(LRUNode node) {
removeNode(node);
addToHead(node);
}
private LRUNode removeTail() {
LRUNode res = tail.prev;
removeNode(res);
return res;
}
}
这个双向链表实现的LRU缓存可以在需要快速访问最近使用过的数据时使用,如在Web服务器或数据库的缓存中。
循环链表实现时间轮调度器:
// 循环链表的节点
class TimerNode {
int ticks; // 触发时刻
Runnable task; // 要执行的任务
TimerNode next; // 下一个节点
public TimerNode(int ticks, Runnable task) {
this.ticks = ticks;
this.task = task;
}
}
// 时间轮调度器
class TimerWheel {
TimerNode wheel; // 循环链表头结点,同时也模拟轮子
int currentTick; // 当前的tick
public TimerWheel() {
this.wheel = new TimerNode(-1, null); // 创建一个哨兵节点
this.wheel.next = this.wheel; // 初始化为循环链表
this.currentTick = 0;
}
// 增加一个计时任务
public void addTimer(int ticks, Runnable task) {
TimerNode newNode = new TimerNode(ticks, task);
TimerNode current = this.wheel;
while (current.next != this.wheel) { // 遍历找到合适的插入位置
current = current.next;
}
// 插入新节点到链表中
newNode.next = this.wheel;
current.next = newNode;
}
// 调度器运行
public void run() {
while (true) {
TimerNode current = this.wheel.next;
while (current != this.wheel) { // 遍历链表执行任务
if (current.ticks == currentTick) {
current.task.run();
// 移除已经执行的节点
current.ticks = -1; // 标记为已执行
}
current = current.next;
}
// 更新当前tick,并清理已执行任务
cleanup();
currentTick++;
// 添加延迟,模拟实际情况
try {
Thread.sleep(1000); // 假设1秒一个tick
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 清除已经执行的任务
private void cleanup() {
TimerNode current = this.wheel;
while (current.next != this.wheel) {
if (current.next.ticks == -1) {
// 移除节点
current.next = current.next.next;
} else {
current = current.next;
}
}
}
}
这个循环链表实现的时间轮调度器可以用于周期性任务的调度,常见于定时器实现。
四、使用链表注意哪些问题
在使用链表进行实际开发时,有几个值得深思熟虑的方面:
-
选择合适的链表类型:根据需求选择单链表、双向链表或者循环链表。例如,如果你需要频繁地在两端添加或移除元素,可能双向链表(或甚至是双端队列)会更适合。
-
空间和时间效率:尽管链表在插入和删除操作上时间复杂度较低,但是如果你需要频繁地随机存取元素,链表可能不是最佳选择,数组或者动态数组(如ArrayList)在这方面表现更好。
-
内存管理:与数组相比,链表的节点通常是动态分配的,它可能引入额外的内存开销和碎片化问题。某些情况下,这会影响性能。
-
并发访问:在多线程环境下,修改链表结构需要特别小心。在没有适当同步的情况下,链表可能会很容易出现竞态条件问题。因此,需要适当的锁或并发控制机制。
-
迭代器失效:某些情况下,链表的修改(如删除正在迭代的元素)可能会导致迭代器失效。需要确保迭代操作时不会对链表结构进行修改,或是正确地处理迭代器失效的情况。
-
递归与迭代:对链表的操作,特别是在单链表中,经常会使用递归方法。然而递归可能会导致堆栈溢出。因此,根据链表的大小和特定场景,可能需要考虑迭代解决方案。
-
边界条件处理:在链表的操作中,如插入、删除等,要小心处理边界条件,包括空链表、只有一个或两个节点以及操作在头结点和尾结点时的情况。
-
资源管理:在链表不再使用时,要确保适时地释放分配的资源,避免内存泄漏。
-
选择合适的数据结构:链表不一定总是最佳选择,了解问题的具体情况,并根据实际需要和性能分析选择最合适的数据结构。
始终记得,在软件设计中,没有一成不变的规则。选择合适的数据结构和算法需要根据具体应用场景的实际需求来定。
五、思考:链表在多线程环境下如何保证并发访问的安全性?
在多线程环境下保证链表并发访问的安全性涉及到几个关键策略:
-
互斥锁(Mutex):
使用互斥锁来同步对链表的访问。任何线程在对链表进行修改之前必须先获取锁,在修改完成后释放锁。这确保了在同一时刻只有一个线程可以修改链表。 -
读写锁(Read-Write Lock):
如果链表的读操作比写操作频繁得多,可以使用读写锁来提高效率。读写锁允许多个读操作并行执行,但写操作会独占访问。 -
细粒度锁:
如果链表非常大且操作频繁,为了提升性能,可以使用细粒度锁或锁分段。这种方式中,链表被分成多个部分,每个部分有其自己的锁。当线程访问一个部分时,只锁定那个部分,从而允许多个线程同时操作不同的链表段。 -
无锁编程(Lock-free Programming):
使用原子操作来管理链表的节点,这可以通过原子指针(atomic references)或其他原子操作(如CAS - Compare-And-Swap)来实现。无锁数据结构可以在多核处理器上提供良好的性能,但它们通常很难正确实现。 -
软件事务内存(Software Transactional Memory, STM):
STM提供了一种抽象,用于通过一系列原子操作来管理对共享内存的访问。这可以用于简化并发控制,尽管它可能带来开销并非所有语言和框架都支持。 -
不变性(Immutability):
使用不变数据结构,也就是说,数据一旦创建就不能被修改。不变性可以大大降低并发编程复杂性,因为多个线程可以安全地访问数据而无需同步。 -
Copy-on-Write(写时复制):
在写操作时复制整个链表,这样读操作可以在无锁的情况下安全进行。这适用于读多写少的场景,并且写操作代价昂贵。 -
线程局部存储(Thread-Local Storage):
如果每个线程只需访问它自己的链表副本,可以使用线程局部存储来防止冲突。
并发访问链表的保证策略应根据应用的具体需求、预期的操作类型(读多写少或读写平衡)以及性能要求来定。软件开发者应谨慎评估并确定最适合他们特定情况的策略。
总结
链表的介绍就到这里,链表虽然简单,但是贯穿整个数据结构,我们需要了解底层原理,便于我们更好的解决实际开发中遇到的问题。
感谢大家抽出宝贵的时间来阅读博主的博客,新人博主,感谢大家关注点赞,祝大家未来的学习工作生活一帆风顺,加油!!!