单/双链表代码实现

简介: 本文详解双链表与单链表的 MyLinkedList 实现,重点介绍三个关键优化:1)同时持有头尾节点引用,提升尾部操作效率;2)使用虚拟头尾节点简化边界处理;3)解析链表删除中的内存泄露误区,并强调指针置空的良好编程习惯。

几个关键点
下面我会分别用双链表和单链给出一个简单的 MyLinkedList 代码实现,包含了基本的增删查改功能。这里给出几个关键点,等会你看代码的时候可以着重注意一下。
关键点一、同时持有头尾节点的引用
在力扣做题时,一般题目给我们传入的就是单链表的头指针。但是在实际开发中,用的都是双链表,而双链表一般会同时持有头尾节点的引用。
因为在软件开发中,在容器尾部添加元素是个非常高频的操作,双链表持有尾部节点的引用,就可以在 O(1)的时间复杂度内完成尾部添加元素的操作。
对于单链表来说,持有尾部节点的引用也有优化效果。比如你要在单链表尾部添加元素,如果没有尾部节点的引用,你就需要遍历整个链表找到尾部节点,时间复杂度是 O(n);如果有尾部节点的引用,就可以在 O(1)的时间复杂度内完成尾部添加元素的操作。
细心的读者可能会说,即便如此,如果删除一次单链表的尾结点,那么之前尾结点的引用就失效了,还是需要遍历一遍链表找到尾结点。
是的,但你再仔细想想,删除单链表尾结点的时候,是不是也得遍历到倒数第二个节点(尾结点的前驱),才能通过指针操作把尾结点删掉?那么这个时候,你不就可以顺便把尾结点的引用给更新了吗?
关键点二、虚拟头尾节点
在上一篇文章 链表基础 中我提到过「虚拟头尾节点」技巧,它的原理很简单,就是在创建双链表时就创建一个虚拟头节点和一个虚拟尾节点,无论双链表是否为空,这两个节点都存在。这样就不会出现空指针的问题,可以避免很多边界情况的处理。
举例来说,假设虚拟头尾节点分别是 dummyHead 和 dummyTail,那么一条空的双链表长这样:
如果你添加 1,2,3 几个元素,那么链表长这样:
你以前要把在头部插入元素、在尾部插入元素和在中间插入元素几种情况分开讨论,现在有了头尾虚拟节点,无论链表是否为空,都只需要考虑在中间插入元素的情况就可以了,这样代码会简洁很多。
当然,虚拟头结点会多占用一点内存空间,但是比起给你解决的麻烦,这点空间消耗是划算的。
对于单链表,虚拟头结点有一定的简化作用,但虚拟尾节点没有太大作用。
虚拟节点是内部实现,对外不可见
虚拟节点是你内部实现数据结构的技巧,对外是不可见的。比如按照索引获取元素的 get(index) 方法,都是从真实节点开始计算索引,而不是从虚拟节点开始计算。
关键点三、内存泄露?
在前文 动态数组实现 中,我提到了删除元素时,要注意内存泄露的问题。那么在链表中,删除元素会不会也有内存泄露的问题呢?
尤其是这样的写法,你觉得有没有问题:
细心的读者可能认为这样写会有内存泄露的问题,因为原来的那个头结点 1 的 next 指针没有断开,依然指向着节点 2。
但实际上这样写是 OK 的,因为 Java 的垃圾回收的判断机制是看这个对象是否被别人引用,而并不会 care 这个对象是否还引用着别人。
那个节点 1 的 next 指针确实还指向着节点 2,但是并没有别的指针引用节点 1 了,所以节点 1 最终会被垃圾回收器回收释放。所以说这个场景和数组中删除元素的场景是不一样的,你可以再仔细思考一下。
不过呢,删除节点时,最好还是把被删除节点的指针都置为 null,这是个好习惯,不会有什么代价,还可能避免一些潜在的问题。所以在下面的实现中,无论是否有必要,我都会把被删除节点上的指针置为 null。
双链表代码实现
Java
运行代码
复制代码
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
import java.util.NoSuchElementException;
if (size < 1) {
throw new NoSuchElementException();
}

    return tail.prev.val;
}

// ***** 改 *****

public E set(int index, E val) {
    checkElementIndex(index);
    // 找到 index 对应的 Node
    Node<E> p = getNode(index);

    E oldVal = p.val;
    p.val = val;

    return oldVal;
}

// ***** 其他工具函数 *****

public int size() {
    return size;
}

public boolean isEmpty() {
    return size == 0;
}

private Node<E> getNode(int index) {
    checkElementIndex(index);
    Node<E> p = head.next;
    // TODO: 可以优化,通过 index 判断从 head 还是 tail 开始遍历
    for (int i = 0; i < index; i++) {
        p = p.next;
    }
    return p;
}

private boolean isElementIndex(int index) {
    return index >= 0 && index < size;
}

private boolean isPositionIndex(int index) {
    return index >= 0 && index <= size;
}

// 检查 index 索引位置是否可以存在元素
private void checkElementIndex(int index) {
    if (!isElementIndex(index))
        throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}

// 检查 index 索引位置是否可以添加元素
private void checkPositionIndex(int index) {
    if (!isPositionIndex(index))
        throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}

private void display() {
    System.out.println("size = " + size);
    for (Node<E> p = head.next; p != tail; p = p.next) {
        System.out.print(p.val + " <-> ");
    }
    System.out.println("null");
    System.out.println();
}

public static void main(String[] args) {
    MyLinkedList<Integer> list = new MyLinkedList<>();
    list.addLast(1);
    list.addLast(2);
    list.addLast(3);
    list.addFirst(0);
    list.add(2, 100);
相关文章
|
4天前
|
存储 API 索引
队列/栈基本原理 ❗前置知识
本文介绍队列和栈两种“操作受限”的数据结构:队列遵循先进先出(FIFO),只能队尾入、队头出;栈遵循先进后出(FILO),仅在栈顶进行增删操作。二者底层多由数组或链表实现,核心API包括push、pop、peek和size,是后续复杂数据结构的基础。
|
14天前
|
弹性计算 缓存 运维
阿里云服务器ECS和其他云服务器对比,有哪些特点和优势?
阿里云ECS实例规格丰富,支持多种计算架构,覆盖全场景应用。具备弹性伸缩、分钟级扩容、高可用性与99.995% SLA保障,提供多重安全防护及灵活计费方式,助力企业降本增效,是稳定可靠、简单易用的首选云服务器。
137 41
|
14天前
|
人工智能 程序员 图形学
第一章 AI 编程革命的第一步:让 Cursor 真正“听懂”你要做一款游戏
第一章 AI 编程革命的第一步:让 Cursor 真正“听懂”你要做一款游戏
78 5
第一章 AI 编程革命的第一步:让 Cursor 真正“听懂”你要做一款游戏
|
14天前
|
人工智能 自然语言处理 机器人
AI也会"三思而后答"?揭秘Self-RAG智能检索术
遇到AI胡说八道怎么办?Self-RAG就像给AI装了个"思考开关",让它知道什么时候该查资料、什么时候该独立思考,还能自我评估答案靠不靠谱。6步智能决策机制,让AI回答又准又稳!#人工智能 #RAG技术 #智能检索 #AI应用
104 11
|
8天前
|
存储 弹性计算 数据管理
阿里云OSS收费标准:流量费用、存储费及功能费价格表(详细计费规则)
阿里云OSS收费标准涵盖存储、流量及功能费用,支持按量付费与资源包两种模式。标准存储按量0.09元/GB/月,40GB包年9元,100GB包年99元,500GB预留空间118.99元/年。流量仅公网流出收费,闲时0.25元/GB,忙时0.5元/GB,可购流量包抵扣。开通Bucket免费,上传不收费,下载按流量计费。多种存储类型满足不同需求,成本透明灵活。
|
10天前
|
弹性计算 固态存储 关系型数据库
国内高性价比云服务器选型指南:阿里云低价机型配置与市场对比
今年,阿里云针对不同用户群体推出多款高性价比云服务器产品,覆盖轻量应用服务器与 ECS 实例,价格区间从 38 元 / 年至 160 元 / 月,适配个人开发、中小企业轻量业务等多种场景,具体核心机型信息如下:
|
24天前
|
消息中间件 分布式计算 大数据
别让数据平台“盲开车”:可观测性三件套(指标、日志、追踪)到底怎么落地?
别让数据平台“盲开车”:可观测性三件套(指标、日志、追踪)到底怎么落地?
89 3
|
9天前
|
SQL 存储 关系型数据库
从一条慢SQL说起:交易订单表如何做索引优化
本文首先以淘天电商交易订单表线上一条非典型慢 SQL 的深入剖析为切入点,示范如何系统地分析与排查慢 SQL;接着详尽归纳了索引分类、B+Tree 与 B‑Tree 的结构差异、B+Tree 高度估算方法、EXPLAIN 与 Query Profile 等诊断工具的使用,以及索引下推与排序的执行流程等索引优化理论;最后结合日常实践经验,提出了适用于大规模线上集群的索引变更 SOP,并总结了常见的慢 SQL 成因与相应的解决策略。
114 21
从一条慢SQL说起:交易订单表如何做索引优化
|
1天前
|
机器学习/深度学习 人工智能 并行计算
DeepSeek 开年王炸:mHC 架构用流形约束重构 ResNet 残差连接
大过节DeepSeek在arXiv发布mHC新论文,挑战Transformer残差连接范式。通过流形约束(谱范数+双重随机矩阵),在保持高带宽信息通路的同时恢复恒等映射稳定性,解决深层网络梯度传播难题,理论扎实且兼顾系统效率,或成“后Transformer时代”架构新方向。
132 7
DeepSeek 开年王炸:mHC 架构用流形约束重构 ResNet 残差连接