版本说明
当前版本号[20231007]。
版本 | 修改说明 |
---|---|
20231007 | 初版 |
目录
文章目录
2.2 链表
1) 概述
定义
在计算机科学中,链表是数据元素的线性集合,其每个元素都指向下一个元素 ,元素存储上并不连续。
简单分类
- 单向 链表,每个元素只知道其下一个元素是谁
- 双向 链表,每个元素知道其上一个元素和下一个元素
- 循环 链表,通常的链表尾节点 tail 指向的都是 null,而循环链表的 tail 指向的是头节点 head
链表内还有一种特殊的节点称为哨兵(Sentinel)节点 ,也叫做哑元( Dummy)节点,它不存储数据,通常用作头尾,用来简化边界判断,如下图所示
随机访问性能
根据 index 查找,时间复杂度 O ( n ) O(n) O(n)
插入或删除性能
- 起始位置: O ( 1 ) O(1) O(1)
- 结束位置:如果已知 tail 尾节点是 O ( 1 ) O(1) O(1),不知道 tail 尾节点是 O ( n ) O(n) O(n)
- 中间位置:根据 index 查找时间 + O ( 1 ) O(1) O(1)
2) 单向链表
根据单向链表的定义,首先定义一个存储 value 和 next 指针的类 Node,和一个描述头部节点的引用
java
public class SinglyLinkedList {
private Node head; // 头部节点
private static class Node { // 节点类
int value;
Node next;
public Node(int value, Node next) {
this.value = value;
this.next = next;
}
}
}
- 这个类有一个私有成员变量
head
,它是一个指向Node
类型的指针,用于存储链表的头节点。 - public Node 是一个私有静态内部类,表示链表中的节点。其中还具有一个构造函数,用于初始化节点的值和下一个节点的引用。包含两个成员变量:
-
value
:一个整数类型的值 ,表示节点存储的数据。next
:一个指向下一个节点的引用 ,用于在链表中连接多个节点。
- 定义为 static 内部类,是因为 Node 不需要与 SinglyLinkedList 实例相关,多个 SinglyLinkedList实例能共用 Node 类定义
头部添加
addFirst
方法:用于在链表的头部添加一个新的节点。
java
public class SinglyLinkedList {
// ...
public void addFirst(int value) {
this.head = new Node(value, this.head);
}
}
- 创建一个新的
Node
对象,将其值设置为value
,并将其next
指针指向当前的头节点 。最后,将头节点更新为新创建的节点。 - 如果 this.head == null ,新增节点指向 null,并作为新的 this.head
- 如果 this.head != null ,新增节点指向原来的 this.head ,并作为新的 this.head
- 注意赋值操作执行顺序是从右到左
如何创建一个新的节点?
分为两种情况。一种是链表为空,另一种是链表非空。
1、在链表为空下,只要将头指针指向新节点 。最后,新节点就是我们链表中第一个节点。
java
this.head = new Node(value, null);
2、在链表非空下,只要将新节点指向下一个节点 ,然后头指针指向新节点 。最后,新节点就是我们链表中第一个节点,而原来的那个节点就是第二个节点了。
java
this.head = new Node(value, head);//括号里的head指的是原来旧的那个节点,新节点下一个就要指向这个节点
循环遍历
while遍历
在循环中,首先将头节点赋值给指针p
。然后,使用条件判断p != null
来检查指针是否为空。如果指针不为空,就执行循环体内的操作。在这个示例中,我们简单地打印了当前节点的值。
接下来,通过p = p.next
将指针p
更新为下一个节点,以便在下一次循环中处理下一个节点。循环会一直执行,直到指针p
为空,即到达链表的末尾。
java
public void loop()
{
Node p = head;
while(p != null)
{
System.out.println(p.value);
p = p.next;
}
}
测试类:
java
@Test
public void test_loop(){
单向链表 p = new 单向链表();
p.addFirst(1);
p.addFirst(2);
p.addFirst(3);
p.addFirst(4);
p.loop();
}
测试结果如下:
或者以参数的形式传递:
你可以将要对每个节点执行的操作作为Consumer<Integer>
类型的参数传递给loop
方法。例如,你可以使用System.out.println
打印节点的值,或者将节点的值添加到集合中等。
java
public void loop(Consumer<Integer> consumer) {
Node p = head; // 将头节点赋值给指针p
while (p != null) { // 当指针p不为空时执行循环
consumer.accept(p.value); // 调用consumer的accept方法处理当前节点的值
p = p.next; // 将指针p更新为下一个节点,继续遍历链表
}
}
测试类
java
@Test
public void test_loop(){
单向链表 p = new 单向链表();
p.addFirst(1);
p.addFirst(2);
p.addFirst(3);
p.addFirst(4);
p.loop(value->{
System.out.println(value);
});
}
测试结果与上面的图相同。
for 遍历
在循环中,我们使用了一个传统的for
循环来遍历链表。首先,将头节点赋值给指针p
。然后,使用条件判断p != null
来检查指针是否为空。如果指针不为空,就执行循环体内的操作。在这个示例中,我们调用了consumer
的accept
方法,并将当前节点的值作为参数传递给它。
接下来,通过p = p.next
将指针p
更新为下一个节点,以便在下一次循环中处理下一个节点。循环会一直执行,直到指针p
为空,即到达链表的末尾。
java
public void loop_for(Consumer<Integer> consumer) {
for (Node p = head; p != null; p = p.next) {
consumer.accept(p.value);
}
}
迭代器遍历
这个类,实现了Iterable<Integer>
接口。这个类用于表示一个单向链表,其中包含一个头指针head
,以及一个内部类Node
用于表示链表中的节点。
在这个类中,实现了iterator()
方法,该方法返回一个Iterator<Integer>
对象,用于遍历链表中的元素。Iterator
接口有两个方法:hasNext()
和next()
。
-
hasNext 用来判断是否还有必要调用 next
-
next 做两件事
- 返回当前节点的 value
- 指向下一个节点
NodeIterator 要定义为非 static 内部类,是因为它与 SinglyLinkedList 实例相关,是对某个 SinglyLinkedList 实例的迭代
java
public class 单向链表 implements Iterable<Integer>//整体
{
private Node head = null;//头指针
//匿名内部类
@Override //alt+enter 快捷键
public Iterator<Integer> iterator() {
return new Iterator<Integer>() {
Node p = head;
@Override
public boolean hasNext() {//是否有下一个元素
return p != null;
}
@Override
public Integer next() {//返回当前值,并指向下一个元素
int v = p.value;
p = p.next;
return v;
}
};
}
}
测试类
java
@Test
public void test_class(){
单向链表 p = new 单向链表();
p.addFirst(6);
p.addFirst(9);
p.addFirst(2);
p.addFirst(5);
for(Integer value : p)
{
System.out.println(value);
}
}
测试结果如下:
匿名内部类转换为带名字的内部类
在 IDEA 中,可以使用以下快捷键将匿名内部类转换为带名称的内部类:
- 选中匿名内部类。
- 按下 右键,选
Refactor
(Windows/Linux)后,在选中Convert Anonymous to Inner..
即可将匿名内部类转换为带名称的内部类。 - IDEA 会自动为该匿名内部类生成一个具有描述性名称的新内部类,并将其放置在当前文件中。
生成的新内部类代码如下:
java
@Override //alt+enter 快捷键
public Iterator<Integer> iterator() {
return new NodelIterator();
}
private class NodelIterator implements Iterator<Integer> {
Node p = head;
@Override
public boolean hasNext() {//是否有下一个元素
return p != null;
}
@Override
public Integer next() {//返回当前值,并指向下一个元素
int v = p.value;
p = p.next;
return v;
}
}
尾部添加
递归遍历
在尾部添加之前,需要先找到链表中最后一个元素,才可以指向下一个元素,完成尾部的添加。
- 第一个
findLast方法
首先检查链表是否为空(head == null
),如果为空,则返回null
。 - 接下来,它定义了一个指针
p
,并将其初始化为链表的头节点(head
)。 - 然后,使用一个循环来遍历链表,直到找到最后一个节点为止。
- 在每次迭代中,它将指针
p
更新为下一个节点(p.next
),直到p.next
为null
,即找到了最后一个节点。 - 最后,它返回最后一个节点的引用(
p
)。
java
private Node findLast(){
if(head == null) {
return null;
}
Node p;
for(p = head; p.next != null; p = p.next) {
// 遍历链表,直到找到最后一个节点
}
return p;
}
- 第二个
addLast方法
首先调用findLast()
方法来查找链表的最后一个节点,并将其赋值给变量last
。 - 然后,它检查链表是否为空(
head == null
),如果为空,说明链表为空,因此调用addFirst(value)
方法将新节点作为第一个节点添加到链表中,并立即返回。 - 如果链表不为空,它将创建一个新的节点,使用给定的值
value
作为节点的值,并将下一个节点的引用设置为null
。 - 然后,它将新节点的
next
指针指向最后一个节点的next
指针,即将新节点添加到最后一个节点的后面。
java
public void addLast(int value) {
Node last = findLast();
if(head == null) {
addFirst(value);
return;
}
last.next = new Node(value, null);
// 将新节点添加到最后一个节点的后面
}
- 注意,找最后一个节点,终止条件是 curr.next == null
- 分成两个方法是为了代码清晰,而且 findLast() 之后还能复用
写了一个相对应的测试类进行测试代码是否正确:
java
@Test
public void test_addLast(){
单向链表 p = new 单向链表();
p.addFirst(2);
p.addFirst(4);
p.addFirst(6);
p.addFirst(8);
p.addLast(7);
for(Integer value : p)
{
System.out.println(value);
}
}
测试结果如下,p.addLast添加的 7 在整个链表的最尾部。
根据索引获取
先查找到具有指定索引的节点,再对应去找到这个节点所对应的值。
寻找节点对象
- 这个方法接受一个整数参数
index
,表示要查找的节点的索引。它首先初始化一个计数器变量i
为 0。 - 然后,使用一个循环来遍历链表,从头节点开始,依次访问每个节点。在每次迭代中,它将指针
p
更新为下一个节点(p.next
),并将计数器i
增加 1。 - 在每次迭代中,它检查计数器
i
是否等于要查找的索引index
。 - 如果相等,说明找到了具有指定索引的节点,它返回该节点的引用(
p
)。 - 如果循环结束后仍未找到具有指定索引的节点,说明链表中不存在具有该索引的节点,它返回
null
。
java
private Node findNode(int index)//返回节点对象
{
int i = 0;
for(Node p = head; p != null; p = p.next, i++)
{
if(i == index)
{
return p;
}
}
return null;
}
寻找节点的值
- 这个方法接受一个整数参数
index
,表示要查找的节点的索引。 - 它首先调用
findNode
方法来查找具有指定索引的节点 ,并将结果存储在变量node
中。 - 然后,它检查
node
是否为null
,即是否找到了具有指定索引的节点。 - 如果
node
为null
,说明链表中不存在具有该索引的节点,它抛出一个IllegalArgumentException
异常,并使用格式化字符串提供有关错误的详细信息。 - 如果
node
不为null
,则说明找到了具有指定索引的节点,它返回该节点的值(node.Value
)。
java
public int getNode(int index) { //返回节点中的值
Node node = findNode(index);
if (node == null) {
throw new IllegalArgumentException
(String.format("索引 [%d] 不合法,无法找到对应的节点", index));
}
return node.value;
}
测试类
java
@Test
public void test_getNode(){
单向链表 p = new 单向链表();
p.addFirst(2);
p.addFirst(4);
p.addFirst(6);
p.addFirst(8);
System.out.println("原链表为:");
for(Integer value : p)
{
System.out.println(value);
}
System.out.println("---------------");
int i = p.getNode(2);
System.out.println("下标为所对应的值为:"+i);
}
测试结果如下,输入下标 2 后就会返回其所对应的值为 4 .
插入
- 这个方法接受两个参数:
index
表示要插入的位置,value
表示要插入的值。 - 首先,它调用
findNode
方法来查找给定索引的前一个节点,并将结果存储在prev
变量中。 - 如果找不到前一个节点(即
prev
为null
),则抛出一个IllegalArgumentException
异常,指示索引不合法。 - 如果找到了前一个节点,那么将创建一个新的节点,并将其插入到链表中。
- 新节点的值为
value
,它将作为前一个节点的下一个节点。 - 通过将新节点的
next
指针指向前一个节点的下一个节点,实现了节点的插入操作。
java
public void insert(int index, int value)
{
Node prev = findNode(index - 1);
if(prev == null)
{
throw new IllegalArgumentException(
String.format("索引 [%d] 不合法,无法找到对应的节点", index));
}
prev.next = new Node(value, prev.next);
}
测试类
java
@Test
public void test_insert(){
单向链表 p = new 单向链表();
p.addLast(2);
p.addLast(4);
p.addLast(6);
p.addLast(8);
p.insert(2,5);
for(Integer value : p)
{
System.out.println(value);
}
}
测试结果如下:
删除
删除链表中的第一个节点
- 首先,它会检查链表的头节点是否为空(即
head == null
),如果为空,则抛出一个IllegalArgumentException
异常,表示没有节点可以继续删除。 - 如果链表不为空,那么它会将头节点指向下一个节点(即
head = head.next
)。 - 这样就实现了删除第一个节点的操作。
- 需要注意的是,在调用该方法之后,原先的第一个节点将不再存在,并且与该节点相关联的其他节点的引用也会发生变化。
java
public void removeFirst()
{
if(head == null)
{
throw new IllegalArgumentException(
String.format("无节点继续删除"));
}
head = head.next;
}
测试类
java
@Test
public void test_removeFirst(){
单向链表 p = new 单向链表();
p.addLast(2);
p.addLast(4);
p.addLast(6);
p.addLast(8);
p.removeFirst();
for(Integer value : p)
{
System.out.println(value);
}
}
测试结果如下:
删除某个索引位置中的节点
这个方法接受一个整数参数index
,表示要删除节点的位置 。首先,它会检查index
是否为0,如果是,则调用removeFirst()
方法来删除第一个节点,并立即返回。
如果index
不为0,那么它会调用findNode(index - 1)
方法来查找指定位置的前一个节点,并将结果存储在prev
变量中。如果找不到前一个节点(即prev
为null
),则抛出一个IllegalArgumentException
异常,指示无法找到上一个节点。
接下来,它会根据index
找到要删除的节点,并将其存储在removed
变量中。如果找不到要删除的节点(即removed
为null
),则抛出一个IllegalArgumentException
异常,指示无法找到需要删除的节点。
最后,它将前一个节点的next
指针指向要删除节点的下一个节点,从而完成了节点的删除操作。需要注意的是,在调用该方法之后,原先指定位置的节点将被移除,并且与该节点相关联的其他节点的引用也会发生变化。
java
public void remove(int index)
{
if(index == 0)
{
removeFirst();
return;
}
Node prev = findNode(index - 1);
if(prev == null)
{
throw new IllegalArgumentException(
String.format("上一个节点无法找到"));
}
Node removed = prev.next;
if(removed == null)
{
throw new IllegalArgumentException(
String.format("需要删除的节点无法找到"));
}
prev.next = removed.next;
}
测试类
java
@Test
public void test_remove(){
单向链表 p = new 单向链表();
p.addLast(1);
p.addLast(3);
p.addLast(5);
p.addLast(7);
p.remove(2);
for(Integer value : p)
{
System.out.println(value);
}
}
测试结果如下:
3) 单向链表(带哨兵)
观察之前单向链表的实现,发现每个方法内几乎都有判断是不是 head 这样的代码,能不能简化呢?
用一个不参与数据存储的特殊 Node 作为哨兵,它一般被称为哨兵或哑元 ,拥有哨兵节点的链表称为带头链表。
java
//private Node head = null;//头指针
//加上哨兵节点后,哨兵的值不那么重要,其下一个指向为空
private Node head = new Node(666,null);
加入哨兵后,相关代码就可以适当进行修改了。
如:
既然已经有哨兵节点了,就证明其头节点不可能为空。
修改后的代码将直接在找到的最后一个节点后插入新的节点 ,而不需要先调用findLast()
方法来获取最后一个节点。这样做可以减少一次不必要的查找操作,提高代码的效率。
java
//原代码
public void addLast(int value) {
Node last = findLast();
if (head == null) {
addFirst(value);
return;
}
last.next = new Node(value, null);
}
//修改后的代码
public void addLast(int value) {
Node last = findLast();
last.next = new Node(value, null);
}
使用原先未进修改的测试类进行测试:
java
@Test
public void test_addLast(){
单向链表 p = new 单向链表();
p.addFirst(2);
p.addFirst(4);
p.addFirst(6);
p.addFirst(8);
for(Integer value : p)
{
System.out.println(value);
}
}
测试的结果如下。发现它会把哨兵节点的值也一并输出:
然后我们对输出的接口方法修改一下,要求只需要输出不包括哨兵节点的节点值即可。
一旦涉及要遍历,都需要将代码修改成 Node p = head.next;
.
java
private class NodelIterator implements Iterator<Integer> {
Node p = head.next;// 将其改成 head.next 即可直接遍历哨兵节点后面的节点值了
@Override
public boolean hasNext() {//是否有下一个元素
return p != null;
}
@Override
public Integer next() {//返回当前值,并指向下一个元素
int v = p.value;
p = p.next;
return v;
}
}