线性表——★(双向)链表
由于单向链表永远只能从头节点开始搜索,而双向链表既可以从前向后搜索又可以从后向前搜索。因此,使用双向链表可以提升链表的综合性能。
**注:**Java自带的java.util.LinkedList
类就是一个双向链表。
1、双向链表(DoubleLinkedList)
与单向链表相比,双向链表具有以下特点:
- 双向链表类的成员既有指向头节点的指针
first
,也有一个指向尾节点的指针last
。 - 双向链表的每个节点既包含一个指向下一节点的指针
next
,又包含一个指向上一节点的prev
指针。
2、双向链表的设计
(1)类的结构设计
在编程之前,我们需要先对双向链表类(DoubleLinkedList
)的结构进行设计。
双向链表类需要的成员主要包括:
-
链表存放元素的数量。
1
private int size;
size = 元素(Element)数目 = 节点(Node)数目。
-
第一个节点的引用。
1
private Node<E> first;
first指向链表的第一个(头)节点。
-
最后一个节点的引用。
1
private Node<E> last;
last指向链表的最后一个(尾)节点。
-
内部静态节点(
Node
)类。由于该节点类仅供链表类使用,因此,需将该节点类定义为链表类的内部类。该类的成员包括:
-
上一个节点的引用
1
Node<E> prev;
prev
指向链表的上一个节点。 -
该节点内部的泛型元素
1
E element;
泛型元素既可以是基本数据类型,也可以是类的对象。
-
下一个节点的引用
1
Node<E> next;
next
指向链表的下一个节点。-
构造函数
-
析构函数(Java语言没有)
-
**注:**内部类的成员可以不写访问权限。
(2)接口设计
双向链表的接口和单向链表类似。主要包括:
- int size(); //元素的数量
- boolean isEmpty(); //是否为空
- boolean contains(E element); //是否包含某元素
- E get(int index); //获取index位置对应的元素
- E set(int index, E element); //设置index位置的元素(覆盖)
- void add(E element); //添加元素到尾部
- void add(int index, E element); //在index位置添加元素
- E remove(int index); //删除index位置对应的元素
- int indexOf(E element); //查看元素的位置
- void clear(); //清除所有元素
**注:**接口是供别人在外部通过对象调用的,所以要设计成公有(public)成员。
3、双向链表的实现
3.1、编程实现
在单向链表项目的基础上:
①再新建一个名称为DoubleLinkedList
(包名为:com.atangbiji
)的类,用来自己实现双向链表;
②仍然使用名称为Main
(包名为:com.atangbiji
)的类,来调用(测试)自己封装的双向链表。
③接口实现,我们这里直接考虑链表元素均为泛型的情况。
(1)定义私有成员变量和内部静态节点(Node)类
DoubleLinkedList.java
文件:
1 | package com.atangbiji; |
(2)实现size()接口
DoubleLinkedList.java
文件:
1 | /* |
(3)实现isEmpty()接口
DoubleLinkedList.java
文件:
1 | /* |
(4)实现get(int index)接口
- 获取索引为
index
的节点对象
为了方便获取任意节点,我们定义一个私有方法Node
用来获取索引为index
的节点。当index
<size
/2时,从前向后找;当index
>=size
/2时,从后向前找。
DoubleLinkedList.java
文件:
1 | /* |
- 获取节点元素
获取链表索引为index
的元素,只需:
1 | a.首先从链表中获取索引为index的节点对象; |
DoubleLinkedList.java
文件:
1 | /* |
(5)实现set(int index, E element)接口
类似地,设置链表索引为index
的元素,只需:
1 | a.首先从链表中获取索引为index的节点对象; |
DoubleLinkedList.java
文件:
1 | /* |
(6)实现indexOf(E element)接口
只需遍历链表中的元素,判断链表中是否存在该元素即可。若存在,返回索引。若不存在,返回-1。
双向链表的indexOf(E element)
接口与动态数组的indexOf(E element)
接口类似,这里不再赘述。
DoubleLinkedList.java
文件:
1 | private static final int ELEMENT_NOT_FOUND = -1; //未找到 |
(7)实现contains(E element)接口
在链表中查找该元素,若返回不为-1,则说明该元素存在。
DoubleLinkedList.java
文件:
1 | /* |
(8)实现clear()接口
清空链表只需将所有节点的内存释放,同时将size
置为0即可。
DoubleLinkedList.java
文件:
1 | /* |
注:将第一个节点和最后一个结点的引用设为空(null
)后,虽然各节点还在被循环引用。但JVM
的内存处理机制是:没有被“GC Root
对象”引用的对象就会被当做垃圾清除。
GC Root
对象主要包括:被栈指针(局部变量)指向的对象等多种对象。当我们在调用接口时,我们所使用的DoubleLinkedList
对象就是一种GC Root
对象。所以,Java
中,将第一个节点和最后一个结点的引用设为空(null
),即可实现所有节点的内存释放。
**附:**为了证明该方法的确释放了所有节点的内存,我们同样可以在内部静态节点(Node
)类中重写Java
中自带的finalize()
方法进行测试。测试过程与动态数组泛化后的clear()
接口测试方法类似,这里不再赘述。
(9)实现remove(int index)接口
删除双向链表某一节点的元素只需:
1 | ①找到被删除节点; |
此时,被删除节点便没有被任何“GC Root对象”
引用,进而被当做垃圾清除。
注意分析头节点(index=0
)、尾节点(index=size-1
)和size=1
的情况。
-
删除一般节点:
-
删除头节点(index=0):
-
删除尾节点(index=size):
-
当
size=1
时:当删除双向链表中唯一存在的元素时,直接让
first
和last
都指向空(null
)即可。
DoubleLinkedList.java
文件:
1 | /* |
(10)实现add(int index,E element)接口
链表添加新元素不用考虑是否需要扩容的问题。与单向链表一样,双向链表在index
处添加新元素只需:
1 | ①创建一个新节点; |
注意分析头节点(index=0
)、尾节点(index=size
)和size=0
的情况。
-
添加一般节点:
-
添加头节点(index=0):
-
添加尾节点(index=size):
-
当size=0时:
DoubleLinkedList.java
文件:
1 | /* |
(11)实现add(E element)接口
add(E element)
接口是add(int index,E element)
接口的特例。我们只需在size
处添加新元素即可。
DoubleLinkedList.java
文件:
1 | /* |
(12)打印链表中的所有元素
同动态数组一样,为了打印对象链表中的所有元素,我们可以通过重写(也称覆盖)toString()
方法,并在其中完成字符串的拼接的方式即可实现。
为了更加直观地展现双向链表,我们在输出各节点元素时打印**“上一节点元素←该节点元素→下一节点元素”**。
注:Java
中字符串拼接建议使用StringBuilder
类。
DoubleLinkedList.java
文件:
1 | /* |
3.2、接口测试
双向链表同样要注意边界测试,如:当index
为0
、size - 1
、size
时的情况。
在Main
类(Main.java
文件)中新建一个DoubleLinkedList
对象,用来测试自己封装的双向链表。这里仅以添加和删除元素接口为例进行测试,其它接口的测试方法与此类似,这里不再赘述。
Main.java
文件:
1 | package com.atangbiji; |
运行该程序,输出结果为:
4、java.util. LinkedList类源码分析
Java
自带的java.util.LinkedList
类就是一个双向链表。自行分析,这里不再赘述。
5、总结
(1)时间复杂度(双向链表VS单向链表)
-
粗略对比一下两者删除元素时程序指令的执行次数。
-
单向链表
$ 1+2+3+…+n= \frac{(1+n)×n}{2}=\frac{n^2}{2} +\frac{n}{2} \frac{n}{2} +\frac{1}{2}$ 。
-
双向链表
$ (1+2+3+…+\frac{n}{2})×2= \frac{(1+\frac{n}{2})×\frac{n}{2}}{2}×2=\frac{n^2}{4} +\frac{n}{2} \frac{n}{4} +\frac{1}{2}$ 。
-
所以,两者的平均时间复杂度均为O(n)
,但双向链表的程序指令执行次数比单向链表缩减了近一半。
- 是不是有了双向链表,单向链表就没有用了呢?
并不是,在哈希表的设计中就用到了单向链表。至于原因,会在哈希表中讲解。
(2)性能对比(双向链表VS动态数组)
- **动态数组:**开辟、销毁内存空间的次数相对较少,但可能造成内存空间的浪费(可以通过缩容来优化)。
- **双向链表:**开辟、销毁内存空间的次数相对较多,但不会造成内存空间的浪费。
(3)实际开发过程中,如何选择?
- 如果频繁在尾部进行添加、删除操作,选择动态数组和双向链表均可。此时,两者时间复杂度均为O(1)。
- 如果频繁在头部进行添加、删除操作,建议使用双向链表。此时,动态数组的时间复杂度为O(n),双向链表的时间复杂度为O(1)。
- 如果频繁在任意位置进行添加、删除操作,建议使用双向链表。此时,两者的平均时间复杂度均为O(n),但平均下来,双向链表的程序指令执行次数比动态数组缩减了近一半。
- 如果频繁在**查询(随机访问)**操作,建议使用动态数组。此时,动态数组的时间复杂度为O(1),双向链表的时间复杂度为O(n)。
(本讲完,系列博文持续更新中…… )
参考文献:
[1] 《恋上数据结构与算法》,小码哥MJ
[2] 《数据结构与算法》,邓俊辉
如何获取源代码?
关注 “阿汤笔迹” 微信公众号,在后台回复关键词 “数据结构与算法设计与实现” ,即可获取项目源代码。
原文地址:http://www.atangbiji.com/2023/07/22/DoubleLinkedList/
博主最新文章在个人博客 http://www.atangbiji.com/ 发布。