【数据结构】 深入理解 LinkedList 与链表
文章目录
- 一、链表:LinkedList 的底层基石
- 1.1 链表的概念与结构特点
- 1.2 链表的常见结构
- 二、LinkedList 的模拟实现
- 1. 接口设计(IList)
- 2. 自定义异常(IndexException)
- 3. MySingleList类
- 3.1 基础结构定义
- 3.2 增操作:头插、尾插、任意位置插
- (1)头插法(addFirst)
- (2)尾插法(addLast)
- (3)任意位置插入(addIndex)
- 3.3 查操作:包含判断与长度统计
- (1)包含判断(contains)
- (2)长度统计(size)
- 3.4 删操作:删除单个节点与删除所有节点
- (1)删除第一次出现的key(remove)
- (2)删除所有值为key的节点(removeAllKey)
- 3.5 其他工具方法
- (1)遍历打印(display)
- (2)清空链表(clear)
- 三、LinkedList 详解
- 3.1 LinkedList 的核心特性
- 3.2 LinkedList 的构造方法
- 3.3 LinkedList 的常用方法
- 3.4 LinkedList 的遍历方式
- 四、ArrayList 与 LinkedList 的区别
一、链表:LinkedList 的底层基石
1.1 链表的概念与结构特点
链表是一种物理存储结构非连续,但逻辑顺序通过节点间引用链接实现的线性数据结构。
- 物理非连续:链表的节点在内存中不一定连续,每个节点包含数据域和引用域(指向其他节点)。
- 逻辑连续:通过引用域,节点之间形成链式关系,保证了数据在逻辑上的连续。
- 节点来源:现实中,链表的节点一般从堆内存中申请,两次申请的空间可能连续也可能不连续。
1.2 链表的常见结构
链表的结构多样,通过以下三个维度组合,可形成 8 种不同的链表结构:
- 单向 / 双向:单向链表节点只有一个引用域,指向后继节点;双向链表节点有两个引用域,分别指向前驱和后继节点。
- 带头 / 不带头:带头链表有一个头节点(不存储实际数据),用于简化操作;不带头链表直接从存储数据的节点开始。
- 循环 / 非循环:循环链表的尾节点引用指向头节点(或头节点相关节点),形成闭环;非循环链表的尾节点引用为 null。
在实际应用中,我们重点掌握两种核心结构:
- 无头单向非循环链表:结构简单,一般不单独用于存储数据,常作为其他数据结构(如哈希桶、图的邻接表)的子结构,也是笔试面试中的高频考点。
- 无头双向循环链表:Java 中 LinkedList 的底层实现就是这种结构,能兼顾插入、删除和查询操作的性能。
二、LinkedList 的模拟实现
LinkedList 基于无头双向循环链表实现,以下是其核心操作的模拟实现框架:
1. 接口设计(IList)
public interface IList {// 初始化链表(创建预设节点)void createList();// 头插法:在链表头部插入数据void addFirst(int data) ;// 尾插法:在链表尾部插入数据void addLast(int data) ;// 任意位置插入:第一个节点为0号下标void addIndex(int index, int data) ;// 查找:判断关键字key是否存在boolean contains(int key) ;// 删除:移除第一次出现的key节点void remove(int key);// 删除:移除所有值为key的节点void removeAllKey(int key) ;// 统计:获取链表长度int size() ;// 清空:释放链表所有节点void clear() ;// 遍历:打印链表所有元素void display() ;
}
2. 自定义异常(IndexException)
针对「任意位置插入」时的非法下标(负数、超出链表长度),自定义运行时异常,增强错误提示的可读性。
public class IndexException extends RuntimeException {public IndexException(String message) {super(message);}
}
3. MySingleList类
3.1 基础结构定义
- 内部静态类
ListNode
:封装节点的结构,避免对外暴露实现细节; - 成员变量
head
:作为链表的头节点引用。
public class MySingleList implements IList{// 内部静态类:链表节点static class ListNode{public int val; // 数据域public ListNode next; // 指针域(下一个节点引用)// 节点构造器:初始化数据域public ListNode(int val) {this.val = val;}}ListNode head; // 头节点:链表的入口
}
3.2 增操作:头插、尾插、任意位置插
插入是单链表的核心操作,需重点处理「空链表」「头节点插入」等边界情况。
(1)头插法(addFirst)
逻辑:新节点的next
指向原头节点,再将head
更新为新节点(无论链表是否为空均适用)。
时间复杂度:O(1)
(无需遍历)。
@Override
public void addFirst(int data) {ListNode newNode = new ListNode(data); newNode.next = head; head = newNode;
}
(2)尾插法(addLast)
逻辑:先遍历到链表的最后一个节点(next = null
的节点),再将其next
指向新节点。
注意:若链表为空(head = null
),直接将head
指向新节点(避免cur.next
空指针异常)。
@Override
public void addLast(int data) {ListNode newNode = new ListNode(data);// 边界:链表为空时,直接让head指向新节点if (head == null) {head = newNode;return;}// 遍历:找到最后一个节点(cur.next == null)ListNode cur = head;while (cur.next != null) {cur = cur.next;}cur.next = newNode; // 最后一个节点指向新节点
}
(3)任意位置插入(addIndex)
核心步骤:
- 校验下标合法性(
index < 0
或index > 链表长度
均非法); - 特殊位置复用已有方法:
index=0
复用addFirst
,index=长度
复用addLast
; - 中间位置:先找到
index-1
处的前驱节点,再插入新节点(避免断链)。
@Override
public void addIndex(int index, int data) {int len = size(); if (index < 0 || index > len) {throw new IndexException("下标不合法!");}// 头插if (index == 0) {addFirst(data);return;}// 尾插if (index == len) {addLast(data);return;}// 中间位置:找到index-1的前驱节点ListNode cur = head;for (int i = 0; i < index - 1; i++) {cur = cur.next;}// 插入:新节点先连后,再让前驱连新节点(顺序不可反)ListNode newNode = new ListNode(data);newNode.next = cur.next;cur.next = newNode;
}
关键注意:插入顺序必须是「新节点先指向后一个节点」,再「前驱节点指向新节点」。若顺序颠倒,会导致后一个节点的引用丢失,造成链表断链。
3.3 查操作:包含判断与长度统计
(1)包含判断(contains)
逻辑:从head
开始遍历所有节点,若找到值为key
的节点则返回true
,遍历结束未找到则返回false
。
时间复杂度:O(n)
(最坏需遍历整个链表)。
@Override
public boolean contains(int key) {ListNode cur = head;while (cur != null) { // 遍历终止条件:cur为null(已到链表末尾)if (cur.val == key) {return true;}cur = cur.next;}return false;
}
(2)长度统计(size)
逻辑:遍历链表,用计数器累计节点数量。
时间复杂度:O(n)
。
@Override
public int size() {int cnt = 0;ListNode cur = head;while (cur != null) {cnt++;cur = cur.next;}return cnt;
}
3.4 删操作:删除单个节点与删除所有节点
删除操作的核心是「找到待删节点的前驱节点」,通过修改前驱节点的next
引用跳过待删节点,实现节点移除。
(1)删除第一次出现的key(remove)
核心步骤:
- 边界1:链表为空,直接返回;
- 边界2:头节点就是待删节点,直接将
head
指向head.next
; - 常规情况:通过辅助方法
findListNode
找到待删节点的前驱,修改前驱的next
引用。
// 辅助方法:找到值为key的节点的前驱(若未找到返回null)
ListNode findListNode(int key) {ListNode cur = head;// 遍历:判断cur的下一个节点是否为待删节点while (cur.next != null) {if (cur.next.val == key) {return cur;}cur = cur.next;}return null;
}@Override
public void remove(int key) {// 链表为空if (head == null) {return;}// 头节点为待删节点if (head.val == key) {head = head.next;return;}// 找到前驱节点ListNode cur = findListNode(key);if (cur == null) { // 未找到待删节点return;}// 前驱节点跳过待删节点ListNode del = cur.next;cur.next = del.next;
}
(2)删除所有值为key的节点(removeAllKey)
优化思路:用「双指针遍历」一次完成所有删除,避免多次遍历(效率更高):
prev
:指向当前节点的前驱(初始为head
);cur
:指向当前遍历的节点(初始为head.next
)。
逻辑:
- 先处理非头节点的待删节点(通过双指针跳过所有
val=key
的节点); - 最后处理头节点(若头节点
val=key
,更新head
)。
@Override
public void removeAllKey(int key) {// 边界:链表为空if (head == null) {return;}ListNode prev = head;ListNode cur = head.next;// 处理非头节点的待删节点while (cur != null) {if (cur.val == key) {// 待删节点:prev跳过cur,cur后移prev.next = cur.next;cur = cur.next;} else {// 非待删节点:双指针同时后移prev = cur;cur = cur.next;}}// 处理头节点(若头节点值为key)if (head.val == key) {head = head.next;}
}
3.5 其他工具方法
(1)遍历打印(display)
从head
开始遍历,打印每个节点的值,直观展示链表内容。
@Override
public void display() {ListNode cur = head;while (cur != null) {System.out.print(cur.val + " ");cur = cur.next;}System.out.println(" ");
}
(2)清空链表(clear)
逻辑:逐个断开节点的next
引用(避免内存泄漏),最后将head
置为null
。
注意:若直接将head = null
,未断开的节点引用可能导致JVM无法回收内存。
@Override
public void clear() {// 边界:链表为空if (head == null) {return;}ListNode cur = head.next;ListNode curNext;// 逐个断开节点的next引用while (cur != null) {curNext = cur.next; // 先保存下一个节点cur.next = null; // 断开当前节点的引用cur = curNext; // 移到下一个节点}head = null; // 头节点置空,链表彻底为空
}
三、LinkedList 详解
3.1 LinkedList 的核心特性
- 底层基于无头双向循环链表实现,节点间通过引用关联,无需连续存储空间。
- 实现了 List 接口,支持 List 接口的所有操作。
- 未实现 RandomAccess 接口,不支持随机访问(即不能通过下标快速定位元素,需从头或尾遍历)。
- 任意位置插入和删除元素时,只需修改节点引用,时间复杂度为 O(1)(找到目标位置需 O(n),但修改操作本身是 O(1))。
- 无“容量”概念,无需像 ArrayList 那样进行扩容。
3.2 LinkedList 的构造方法
LinkedList 提供两种常用构造方法:
方法 | 解释 |
---|---|
LinkedList() | 无参构造,创建一个空的 LinkedList |
LinkedList(Collection<? extends E> c) | 使用其他集合(如 ArrayList)中的元素构造 LinkedList |
使用示例:
public static void main(String[] args) {// 构造空的 LinkedListList<Integer> list1 = new LinkedList<>();// 先创建 ArrayList 并添加元素List<String> list2 = new ArrayList<>();list2.add("JavaSE");list2.add("JavaWeb");list2.add("JavaEE");// 使用 ArrayList 构造 LinkedListList<String> list3 = new LinkedList<>(list2);
}
3.3 LinkedList 的常用方法
LinkedList 提供了丰富的方法用于操作元素,以下是核心方法:
方法 | 解释 |
---|---|
boolean add(E e) | 尾插元素 e |
void add(int index, E element) | 在 index 位置插入元素 element |
boolean addAll(Collection<? extends E> c) | 尾插集合 c 中的所有元素 |
E remove(int index) | 删除 index 位置的元素,返回被删除的元素 |
boolean remove(Object o) | 删除遇到的第一个元素 o,返回是否删除成功 |
E get(int index) | 获取 index 位置的元素 |
E set(int index, E element) | 将 index 位置的元素设为 element,返回原元素 |
void clear() | 清空链表 |
boolean contains(Object o) | 判断元素 o 是否在链表中 |
int indexOf(Object o) | 返回第一个元素 o 的下标,若不存在返回 -1 |
int lastIndexOf(Object o) | 返回最后一个元素 o 的下标,若不存在返回 -1 |
List<E> subList(int fromIndex, int toIndex) | 截取 [fromIndex, toIndex) 区间的元素,返回新的 List |
使用示例:
public static void main(String[] args) {LinkedList<Integer> list = new LinkedList<>();// 尾插元素list.add(1);list.add(2);list.add(3);System.out.println("初始链表:" + list); // 输出:[1, 2, 3]System.out.println("链表长度:" + list.size()); // 输出:3// 在 index=0 位置插入元素 0list.add(0, 0);System.out.println("插入后链表:" + list); // 输出:[0, 1, 2, 3]// 删除操作list.remove(); // 默认删除第一个元素(调用 removeFirst())list.removeLast(); // 删除最后一个元素System.out.println("删除后链表:" + list); // 输出:[1, 2]// 判断元素是否存在if (!list.contains(0)) {list.add(0, 0);}System.out.println("添加后链表:" + list); // 输出:[0, 1, 2]// 查找元素下标System.out.println("第一个 0 的下标:" + list.indexOf(0)); // 输出:0// 修改元素list.set(0, 100);System.out.println("修改后链表:" + list); // 输出:[100, 1, 2]// 截取子链表List<Integer> subList = list.subList(0, 2);System.out.println("子链表:" + subList); // 输出:[100, 1]// 清空链表list.clear();System.out.println("清空后链表长度:" + list.size()); // 输出:0
}
3.4 LinkedList 的遍历方式
LinkedList 支持三种常见的遍历方式:
- foreach 遍历:简洁直观,适用于无需修改元素的场景。
- 迭代器遍历:支持正向和反向遍历,且在遍历过程中可安全修改元素(通过迭代器的
remove()
方法)。 - 普通 for 循环遍历:由于 LinkedList 不支持随机访问,通过下标获取元素会从头/尾遍历,效率较低,不推荐。
遍历示例:
public static void main(String[] args) {LinkedList<Integer> list = new LinkedList<>();list.add(1);list.add(2);list.add(3);list.add(4);// 1. foreach 遍历for (int e : list) {System.out.print(e + " ");}System.out.println(); // 1 2 3 4// 2. 迭代器正向遍历ListIterator<Integer> it = list.listIterator();while (it.hasNext()) {System.out.print(it.next() + " ");}System.out.println(); // 1 2 3 4// 3. 迭代器反向遍历ListIterator<Integer> rit = list.listIterator(list.size());while (rit.hasPrevious()) {System.out.print(rit.previous() + " ");}System.out.println(); // 4 3 2 1
}
四、ArrayList 与 LinkedList 的区别
对比维度 | ArrayList | LinkedList |
---|---|---|
存储空间 | 物理上一定连续(基于数组) | 逻辑上连续,物理上不一定连续(基于链表) |
随机访问 | 支持,时间复杂度 O(1)(通过数组下标直接访问) | 不支持,时间复杂度 O(n)(需从头/尾遍历) |
头插操作 | 需搬移所有元素,时间复杂度 O(n) | 只需修改节点引用,时间复杂度 O(1) |
插入操作 | 空间不足时需扩容(默认扩容为原容量的 1.5 倍),可能浪费内存 | 无容量概念,无需扩容,插入时直接创建节点 |
尾插操作 | 若无需扩容,时间复杂度 O(1);若需扩容,时间复杂度 O(n) | 时间复杂度 O(1)(直接找到尾节点修改引用) |
删除操作 | 需搬移后续元素,时间复杂度 O(n) | 找到目标节点后,修改引用即可,时间复杂度 O(1)(查找过程 O(n)) |
内存占用 | 可能存在内存浪费(扩容后未使用的空间) | 每个节点需额外存储前驱/后继引用,内存开销略大 |
应用场景 | 元素存储稳定,频繁进行查询操作 | 元素频繁进行任意位置插入/删除操作 |