单/双链表代码实现

简介: 本文详解双链表与单链表的MyLinkedList实现,重点解析三大关键:1)持有头尾节点引用以优化插入删除效率;2)使用虚拟头尾节点简化边界处理;3)正确理解Java链表删除中的内存释放机制。代码涵盖增删查改等基本操作,结构清晰,适合学习链表底层实现原理。

几个关键点
下面我会分别用双链表和单链给出一个简单的 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
运行代码
复制代码
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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
import java.util.NoSuchElementException;
}
return getNode(size - 1).val;
}

public E get(int index) {
    checkElementIndex(index);
    Node<E> p = getNode(index);
    return p.val;
}

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

public E set(int index, E element) {
    checkElementIndex(index);
    Node<E> p = getNode(index);

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

    return oldVal;
}

// ***** 其他工具函数 *****
public int size() {
    return size;
}

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

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);
}

// 返回 index 对应的 Node
// 注意:请保证传入的 index 是合法的
private Node<E> getNode(int index) {
    Node<E> p = head.next;
    for (int i = 0; i < index; i++) {
        p = p.next;
    }
    return p;
}

public static void main(String[] args) {
    MyLinkedList2<Integer> list = new MyLinkedList2<>();
    list.addFirst(1);
    list.addFirst(2);
    list.addLast(3);
    list.addLast(4);
    list.add(2, 5);

    System.out.println(list.removeFirst()); // 2
    System.out.println(list.removeLast()); // 4
    System.out.println(list.remove(1)); // 5

    System.out.println(list.getFirst()); // 1
    System.out.println(list.getLast()); // 3
    System.out.println(list.get(1)); // 3
}

}

相关文章
|
前端开发 JavaScript Java
酒店管理|基于Springboot+Vue前后端分离实现酒店管理系统
酒店管理|基于Springboot+Vue前后端分离实现酒店管理系统
693 52
|
3月前
|
Java 索引 容器
环形数组技巧
环形数组通过模运算在逻辑上形成闭环,利用start和end双指针实现首尾O(1)增删。虽物理结构线性,但通过取余操作使指针循环,结合左闭右开区间设计,高效支持动态扩容缩容,适用于队列、双端队列等场景。
|
4月前
|
Linux Shell
Linux系统安装miniconda详细教程
本文介绍在CentOS 7系统中安装Miniconda的完整步骤:首先下载Miniconda安装包至/opt目录,接着执行安装脚本并按提示操作;安装完成后,将conda添加到环境变量,通过`conda init bash`和`source ~/.bashrc`配置生效,最终验证安装成功。
1127 5
|
3月前
|
人工智能 自然语言处理 前端开发
SpringAI+DeepSeek大模型应用开发
SpringAI整合主流大模型,支持对话、函数调用与RAG,提供统一API,简化开发。涵盖多模态、流式传输、会话记忆等功能,助力快速构建AI应用。
|
3月前
|
存储 缓存 算法
哈希表核心原理 哈希表等于Map吗?
哈希表不等于Map。Map是键值映射的接口,哈希表(如HashMap)是其一种实现。增删查改O(1)的前提是哈希函数高效且冲突处理得当。本文详解哈希表原理、哈希冲突解决、负载因子与key不可变性,助你深入理解底层机制。
|
3月前
|
Java API
用数组实现队列/栈
使用数组实现栈时,可将动态数组尾部作为栈顶,利用ArrayList的add和remove操作实现push、pop等,时间复杂度均为O(1)。若以头部为栈顶,则需借助环形数组CycleArray实现高效操作。同样,基于CycleArray可在首尾分别进行出队和入队,轻松实现队列功能,保证操作效率。
|
3月前
|
机器学习/深度学习 算法 搜索推荐
11|精准 Top K 检索:搜索结果是怎么进行打分排序的?
搜索引擎排序核心在于打分与Top K检索。本文详解三种打分算法:经典TF-IDF衡量词频与区分度;BM25引入文档长度、词频上限等优化,效果更优;机器学习则融合数百因子自动学习权重,适应复杂场景。最后通过堆排序高效实现Top K结果返回,提升性能。
|
3月前
|
存储 自然语言处理 搜索推荐
05 | 倒排索引:如何从海量数据中查询同时带有「极」和「客」的唐诗?
本文通过唐诗检索的类比,深入浅出地讲解了正排索引与倒排索引的核心原理。正排索引以文档ID为键,适用于精确查找;而倒排索引则以关键词为键,指向包含该词的文档列表,极大提升了多关键词联合查询效率。文章详细介绍了倒排索引的构建过程、链表归并求交集的查询优化方法,并拓展到作者维度检索等实际应用场景,揭示其在搜索引擎、数据库全文检索等系统中的核心地位。
|
3月前
|
存储 缓存 算法
02 | 非线性结构检索:数据频繁变化的情况下,如何高效检索?
通过树状结构与跳表优化数据检索,本文探讨如何在非线性结构中实现高效二分查找。对比有序数组、二叉检索树与跳表,解析其在动态数据场景下的性能优劣与适用边界。
|
3月前
|
存储 算法 搜索推荐
01 | 线性结构检索:从数组和链表的原理初窥检索本质
本文探讨数组与链表的检索原理及效率。数组依托连续存储支持随机访问,适合二分查找,实现O(log n)高效检索;链表则因非连续存储仅支持顺序访问,检索效率为O(n),但插入删除更灵活。通过理解二者存储特性对检索的影响,掌握“合理组织数据以缩小查询范围”的核心思想,为构建高效算法和数据结构打下基础。