📢编程环境:idea
📢在链表中,单向链表是基础,双向链表是对单向链表的优化。
【java-数据结构】模拟双向无头非循环链表,掌握背后的实现逻辑
1. 先回忆一下:
1.1 内存分布中的堆和虚拟机栈
内存是一段连续的存储空间,主要用来存储程序运行时的数据的。乱存数据肯定不行,所以为了更好的管理内存,jvm根据内存功能,对内存进行了如下划分:
- 堆:堆是jvm所管理的最大内存区域。使用new创建的对象都是在堆上保存的。堆是随着程序开始运行时而创建的,随着程序的退出而销毁。堆中的数据只要还有在使用,堆就不会被销毁。
- 虚拟机栈:每个方法在执行时,都会先创建一个栈帧。虚拟机栈中主要保存与方法调用相关的信息。比如局部变量就会被保存在栈帧中。当方法运行结束后,栈帧就被销毁了,即栈帧中保存的数据也被销毁了。
1.2基本类型变量和引用类型变量的区别
-
基本数据类型创建的变量,称为基本变量,通常存储单个数据,该变量空间中直接存放数据本身。
-
引用数据类型创建的变量,称为对象的引用,通常存储一组数据,该变量空间中存储的是对象所在空间的地址。
比如在下列代码中:
执行main()方法
的时候,会先在虚拟机栈上创建一个栈帧,栈帧中主要保存与方法调用相关的信息,也就是基本变量a,基本变量b,引用类型的变量arr。但是变量arr中存的地址,指向的数据是存储在堆上面的。从上图可以看出:引用类型变量并不直接存储对象本身,可以简单理解为:引用类型变量中存储的是对象在堆中空间的起始地址。通过该地址,引用类型变量可以操作对象。
1.3 两个引用类型变量之间的赋值运算
在如下代码中:
如上图所示:
array1 = array2;
的意思是:让array1去引用array2引用的数组的空间。此时,array1 和array2实际上是一个数组,无论是array1还是array2,都能对数组进行增删改查。不能只按照代码的表面意思把array1 = array2;
理解成“引用指向引用”,这是错误的!
2. 链表
2.1 链表是啥
官方是这样定义链表的:
链表是一种物理结构上不一定连续,逻辑结构上连续的线性结构。链表是线性表的链式存储结构。
物理结构上不一定连续指的是:用链表存储一组元素,其中每个元素都存在一个引用类型的变量空间中,这个变量空间又被称为结点。每存储一个元素,都要在堆上申请一个结点空间,从堆上申请的空间,是按照一定的策略来分配的,两次申请的空间可能连续,也可能不连续。所以链表的物理结构不一定连续。
以双向链表为例,逻辑结构上连续指的是:
- 结点中不但要存储该元素,
- 还要存储下一个元素所在空间的地址,通过这个地址,可以找到下一个元素。
- 还要存储上一个元素所在空间的地址,通过这个地址,可以找到上一个元素。
n个节点链接成一个链表,处于当前结点时,既知道上一个结点,也知道下一个结点。所以链表的逻辑结构是连续的。
比如:用双向链表存储下列数据:12,23,34,45,56
2.2 链表的分类
链表的结构非常多样,包括单向链表,双向链表,带头链表,不带头链表,循坏链表,非循坏链表。以上情况通过排列组合,得到以下八种类型的链表。
- 链表是单向的还是双向的,这取决于链表的结点中存储了几个地址。
-
双向链表的结点空间中,不仅存储了当前元素,还存储了下一个元素的地址和上一下元素的地址。
-
单向链表的结点空间中,同时存储了当前元素和下一个元素的地址。
-
比如:分别用单向链表和双向链表存储下列数据:
12,23,34,45,56
- 链表是带头的还是不带头的,取决于该链表有没有头指针。
-
头指针是一个引用类型变量,变量中存储着链表第一个结点的地址。带头链表有头指针,不带头链表没有头指针。头指针是带头链表的必要元素。
头结点是为了更方便的操作链表,在链表的第一个结点前附设一个结点。头结点中除了存储链表第一个结点的地址,还可以存链表的其他信息。头结点根据需要而存在。
头指针和头结点是完全两个概念。 -
比如:分别用不带头链表和带头链表存储下列数据:12,23,34,45,56
- 链表是循坏的还是非循坏的,取决于链表的最后一个结点中,有没有存储第一个结点的地址。
- 比如:分别用非循坏链表和循坏链表存储下列数据:12,23,34,45,56
本篇要模拟实现的是:不带头双向非循环链表。
3. 用java语言模拟实现一个无头双向非循坏链表(以存int类型元素为例)
用java语言模拟实现一个无头双向非循坏链表,首先必定要创建一个类来表示这个链表,类里面有一个静态内部类,两个成员变量,若干个成员方法。静态内部类是为了描述结点;成员变量是附设的头结点和尾结点,头结点永远指向链表中第一个结点,尾结点永远指向链表中最后一个结点;通过成员方法对链表进行增删改查。
public class MyLinkedList {
static class ListNode{
private int val;//元素
private ListNode prev;//上一个结点的地址
private ListNode next;//下一个结点的地址
public ListNode(int val){
this.val = val;//每存储一个元素,都要在堆上申请一个结点空间
}
}
public ListNode head;//头结点
public ListNode last;//尾结点
//显示链表中的所有元素
public void display(){
}
//得到链表的长度
public int size(){
}
//查找关键字key是否在链表当中
public boolean contains(int key){
}
//头插法
public void addFirst(int data){
}
//尾插法
public void addLast(int data){
}
//任意位置插入,第一个数据节点为1号下标
public void addIndex(int index,int data){
}
//删除第一次出现关键字为key的节点
public void remove(int key){
}
public void removeAllKey(int key){
}
//清空链表
public void clear1(){
}
public void clear2(){
}
}
3.1 遍历链表
- 怎么正向遍历?
从头结点head开始,一路head.next
,直到头结点head==null
说明遍历结束。- 不能直接拿头结点head遍历。因为如果直接拿head遍历,当遍历结束的时候,
head==null
,此时头结点head中国存的不是第一个结点的地址了。
新建一个引用变量cur,让cur也指向第一个结点,就能让cur代替head遍历链表。这样既遍历了链表,也不会影响头结点head。- 反向遍历链表同理。
- 时间复杂度:O(n)
3.11 显示链表中的所有元素
思路:
- 从前向后遍历链表中的所有节点,每遍历一个结点,打印结点中的元素。
- 从后向前遍历链表中的所有结点,每遍历一个结点,打印结点中的元素。
//显示链表中的所有元素
public void display1(){//正向遍历
ListNode cur = head;
while (cur != null){
System.out.print(cur.val+" ");
cur = cur.next;
}
}
public void display2(){//反向遍历
ListNode cur = last;
while(cur!=null){
System.out.println(cur.val+" ");
cur = cur.prev;
}
}
3.12 得到链表的长度
思路:创建一个计数器count从0开始,每遍历一个元素,计数器就+1。
//得到链表的长度
public int size(){
int count = 0;
ListNode cur = head;
while(cur != null){
count++;
cur = cur.next;
}
return count;
}
3.13 查找关键字key是否在链表当中
思路:遍历链表中的结点,每遍历一个结点,把当前结点中的元素和key作比较。
//查找关键字key是否在链表当中
public boolean contains(int key){
ListNode cur = head;
while(cur != null){
if(cur.val == key){
return true;
}
cur = cur.next;
}
return false;
}
3.2 向链表中插入元素
3.21 头插
以下面这个双向链表为例,如何把元素100插入到链表的第一个位置?
思路:先让元素100所在的结点绑定链表的原结点,再更新头结点和尾结点。
- 先创建一个结点node,把元素存储到接结点node中
- 把头结点的地址存储到node中
- 把node的地址存储到头结点中
- 更新头结点和尾结点
要注意的是:
- 链表为空的情况需要单独拎出来处理:
当链表不为空时,在链表中头插元素不影响尾结点。但是当链表为空时,在链表中头插元素后,头结点和尾结点都要更新。
时间复杂度:O(1)。
//头插法
public void addFirst(int data){
ListNode node = new ListNode(data);
//链表为空
if(head == null){
head = node;
last = node;
return;
}
//链表不为空
node.next = head;
head.prev = node;//检查:head不能为null,否则head.prev会空指针异常
head = node;
}
3.22 尾插
以下面这个双向链表为例,如何把元素100插入到链表的最后一个位置?
思路:
- 先创建一个结点node,把元素存储到接结点node中
- 尾结点中存储node的地址
- node中存储尾结点的地址
- 更新头结点和尾结点。
要注意的是:
- 链表为空的情况需要单独拎出来处理:
当链表不为空时,在链表中尾插元素不影响头结点。但是当链表为空时,在链表中尾插元素后,头结点和尾结点都要更新。时间复杂度:O(1)。
//尾插法
public void addLast(int data){
ListNode node = new ListNode(data);
//链表为空时
if(head == null){
head = node;
last = node;
}else{
//链表不为空时
last.next = node;
node.prev = last;
last = node;
}
}
3.33 已知结点的位置index,读取该结点的地址(假设头结点是一号位置)
该方法用private修饰,是提供给双向链表的内部方法addIndex()使用的。
思路:已知结点的位置是index,从一号位置开始遍历,向后遍历index-1步,就能到达要当前结点了。
//已知结点的位置index,读取该结点的地址(假设头结点是一号位置)
private ListNode findIndexSubOne(int index){
ListNode cur = head;
for (int i = 1; i <= index-1; i++) {
cur = cur.next;
}
return cur;
}
3.34 任意位置index插入元素data
以下面这个单链表为例,如何把元素100插到链表的第三个结点前面?也就是如何让元素100所在的结点成为链表的第三个结点,链表中以原来第三结点为首的结点们跟在元素100所在的结点后面?假设第一个结点为1号位置,类推。
思路:
- 先创建一个结点node,把元素100存储到node中。
- 找到要插入的位置index
findIndex(int index)
,这意味着得到了该位置的地址,该位置前驱结点的地址。- 把node结点插入到index位置。先连接next域,再连接prev域。连接顺序都是先和后面节点绑定,再和前面结点绑定。
要注意的是:
- 检查index的合法性:
假设第一个结点是1位置,所以index的范围[1,size()+1]。
- index等于size()+1包括两种情况,一种是链表为空时,插入元素;一种情况是在链表的末尾插入元素。以上两种情况下直接尾插元素更方便。
- index等于1时,代表要插入位置是1号位置。一号位置没有前驱,此时相当于头插。
时间复杂度:O(n)。
//任意位置插入,第一个数据节点为1号下标
public void addIndex(int index,int data){
//检查index是否合法
if(index<1||index>size()+1){
System.out.println("插入位置不合法");
return;
}
//头插
if(index == 1){
addFirst(data);
return;
}
//尾插
if(index == size()+1){
addLast(data);
return;
}
//index在2号位置到最后一号位置之间
ListNode node = new ListNode(data);
//找要插入的位置cur
ListNode cur = findIndexSubOne(index);
node.next = cur;
cur.prev.next = node;//检查:cur.prev不能为null,否则会报错空指针异常
node.prev = cur.prev;
cur.prev = node;
}
//已知结点的位置index,读取该结点的地址(假设头结点是一号位置)
private ListNode findIndex(int index){
ListNode cur = head;
for (int i = 1; i <= index-1; i++) {
cur = cur.next;
}
return cur;
}
下面是测试:
3.3 删除链表中的元素
3.31 已知元素key,读取元素key所在的结点
该方法用private修饰,是提供给双向链表的内部方法remove()使用的。
思路:遍历链表中的结点,每遍历一个结点,判断当前结点中存储的元素和元素key是否相等。
要注意法的是:
如果返回值是null,说明链表中没有元素key。
//已知元素key,读取元素key所在的结点
private ListNode searchNode(int key){
ListNode cur = head;
while(cur != null){
if(cur.val == key){
return cur;
}
cur = cur.next;
}
return null;
}
3.32 删除第一次出现关键字为key的节点
以下面双向链表为例,如何删除元素56?
思路:
- 先找到要删除的结点:这意味着同时还知道了要删除结点的前驱和后继。
- 在代码中体现时,该结点的前驱和后继都不能为null。
- 从前到后遍历链表,找到要删除的结点就停止。
- 连接要删除结点的前驱和后继:先让前驱结点的next域存储要删除节点的next域,再让后继结点的prev域存储要删除结点的prev域。
要注意的是:
- 一号结点没有前驱,在找要删除的结点的时候,虽然是在一号位置和最后一号位置之间找的,但是,当要删除结点是一号位置时,执行删除操作会报空指针异常。所以,要先单独判断一号位置是否是要删除的节点。
- 当链表中只有一个结点时,该结点还是要删除的结点,该一号结点既没有前驱也没有后继。删完以后链表为空,要更新尾结点last。
- 最后一号结点没有后继,在找要删除的结点的时候,虽然是在一号位置和最后一号位置之间找的,但是,当要删除结点是最后一个位置时,执行删除操作会报空指针异常。所以,前面元素判断过后,还要单独判断最后一号位置是否是要删除的节点。
- 删除操作的前提:链表不能为空。
时间复杂度:O(n)。
//删除第一次出现关键字为key的节点
public void remove(int key){
//链表为空时
if(head == null){
System.out.println("链表为空,操作无效");
return;
}
//要删除结点是一号结点时
if(head.val == key){
head = head.next;
//如果链表中只有一个结点,删完以后链表为空,要更新last
if(head == null){
last = null;
return;
}//链表中不止一个结点
head.prev = null;
return;
}
//要删除结点在二号位置和最后一号位置之间
ListNode cur = searchNode(key);
if(cur!=last){
if(cur == null){
System.out.println("要删除的元素不在链表中");
}else{
cur.prev.next = cur.next;//检查:cur.prev不能为空
cur.next.prev = cur.prev;//检查:cur.next不能为空
}
}else{//要删除结点是最后一个结点时
last = last.prev;
last.next = null;
}
}
//已知元素key,读取元素key所在的结点
private ListNode searchNode(int key){
ListNode cur = head;
while(cur != null){
if(cur.val == key){
return cur;
}
cur = cur.next;
}
return null;
}
测试用例:
3.33 删除存储了元素key的所有结点
思路:
- 依次遍历链表中的所有结点
- 若当前结点中的元素和key相等,就删除当前结点(删除的具体操作同3.32),删除后继续向后遍历链表。
- 若当前结点中的元素与key不相等, 也继续向后遍历链表。
要注意的是:
- 空链表不能进行删除
- 当要删除一号结点时,
- 链表中只有一个结点时,删除完元素后链表为空,要更新尾结点。
- 当链表中有多个结点,一号节点没有前驱,需要单独处理
- 当要删除的结点是最后一号结点时,该结点没有后继。这种情况要单独处理。
时间复杂度:O(n)。
//删除值为key的所有节点
public void removeAllKey(int key){
//链表为空时:
if(head == null){
System.out.println("空链表");
return;
}
//遍历链表中的所有结点
ListNode cur = head;
while(cur!=null){
if(cur.val == key){//删除结点,并继续向后遍历
//删除头结点
if(cur == head){
head = head.next;
if(head!=null){
head.prev = null;
}else{
last = null;//链表中只有一个结点,删完后链表为空,更新头结点
}
}else{//删除中间节点和尾巴结点
if(cur.next!=null){
cur.prev.next = cur.next;//检查:cur.prev不能为空
cur.next.prev = cur.prev;//检查:cur.next不能为空
}else{
last = last.prev;
last.next = null;
}
}
cur = cur.next;
}else{//继续向后遍历
cur = cur.next;
}
}
3.4 清空双向链表
思路:直接把头结点和尾结点都设置为null
要注意的是:
这虽然做到了清空链表的所有结点,即下一次用链表存储元素,该元素所在的结点是链表的第一个结点。但是链表中的原结点依然在堆中占据内存,这些内存并没有真正被释放。
如果想要删除链表中的结点并释放内存,需要遍历链表的所有结点,把每个结点中的引用数据类型变量都设置为空。
//清空链表
public void clear1(){
this.head = null;
this.last = null;
}
public void clear2(){
ListNode cur = head;
while(cur != null){
ListNode curNext = cur.next;
cur.prev = null;
cur.next = null;
cur = cur.next;
}
head = null;
last = null;
}
4. 链表的优缺点以及应用场景
链表的缺点:
单链表的物理结构不连续,没办法做到随机访问链表中的元素。
链表的优点:
- 随用随分配,不会浪费空间。
- 向链表中插入或删除元素不需要挪元素。
链表的使用场景:
链表适合存储动态数据,即经常对数据进行插入和删除的操作。
5. 附完整源码
public class MyLinkedList {
static class ListNode{
private int val;//元素
private ListNode prev;//上一个结点的地址
private ListNode next;//下一个结点的地址
public ListNode(int val){
this.val = val;//每存储一个元素,都要在堆上申请一个结点空间
}
}
public ListNode head;
public ListNode last;
//得到链表的长度
public int size(){
int count = 0;
ListNode cur = head;
while(cur != null){
count++;
cur = cur.next;
}
return count;
}
//显示链表中的所有元素
public void display1(){//正向遍历
ListNode cur = head;
while (cur != null){
System.out.print(cur.val+" ");
cur = cur.next;
}
}
public void display2(){//反向遍历
ListNode cur = last;
while(cur!=null){
System.out.println(cur.val+" ");
cur = cur.prev;
}
}
//查找关键字key是否在链表当中
public boolean contains(int key){
ListNode cur = head;
while(cur != null){
if(cur.val == key){
return true;
}
cur = cur.next;
}
return false;
}
//头插法
public void addFirst(int data){
ListNode node = new ListNode(data);
//链表为空
if(head == null){
head = node;
last = node;
return;
}
//链表不为空
node.next = head;
head.prev = node;//检查:head不能为null,否则head.prev会空指针异常
head = node;
}
//尾插法
public void addLast(int data){
ListNode node = new ListNode(data);
//链表为空时
if(head == null){
head = node;
last = node;
}else{
//链表不为空时
last.next = node;
node.prev = last;
last = node;
}
}
//任意位置插入,第一个数据节点为1号下标
public void addIndex(int index,int data){
//检查index是否合法
if(index<1||index>size()+1){
System.out.println("插入位置不合法");
return;
}
//头插
if(index == 1){
addFirst(data);
return;
}
//尾插
if(index == size()+1){
addLast(data);
return;
}
//index在2号位置到最后一号位置之间
ListNode node = new ListNode(data);
//找要插入的位置cur
ListNode cur = findIndex(index);
node.next = cur;
cur.prev.next = node;//检查:cur.prev不能为null,否则会报错空指针异常
node.prev = cur.prev;
cur.prev = node;
}
//已知结点的位置index,读取该结点的地址(假设头结点是一号位置)
private ListNode findIndex(int index){
ListNode cur = head;
for (int i = 1; i <= index-1; i++) {
cur = cur.next;
}
return cur;
}
public static void main(String[] args) {
MyLinkedList myLinkedList = new MyLinkedList();
myLinkedList.addLast(12);
myLinkedList.addLast(23);
myLinkedList.addLast(34);
myLinkedList.addLast(45);
myLinkedList.display1();
System.out.println();
System.out.println("*****测试删除操作*****");
myLinkedList.remove(12);//删第一个元素
myLinkedList.display1();
System.out.println();
myLinkedList.remove(34);//删中间元素
myLinkedList.display1();
System.out.println();
myLinkedList.remove(45);//删最后一个元素
myLinkedList.display1();
System.out.println();
myLinkedList.remove(23);//删第一个元素,且链表中只有这个元素
myLinkedList.display1();
System.out.println();
myLinkedList.remove(23);//空表时进行删除操作
}
//删除第一次出现关键字为key的节点
public void remove(int key){
//链表为空时
if(head == null){
System.out.println("链表为空,操作无效");
return;
}
//要删除结点是一号结点时
if(head.val == key){
head = head.next;
//如果链表中只有一个结点,删完以后链表为空,要更新last
if(head == null){
last = null;
return;
}//链表中不止一个结点
head.prev = null;
return;
}
//能走到这说明一号结点中没有存储key
//要删除结点在二号位置和最后一号位置之间
ListNode cur = searchNode(key);
if(cur!=last){
if(cur == null){
System.out.println("要删除的元素不在链表中");
}else{
cur.prev.next = cur.next;//检查:cur.prev不能为空
cur.next.prev = cur.prev;//检查:cur.next不能为空
}
}else{//要删除结点是最后一个结点时
last = last.prev;
last.next = null;
}
}
//已知元素key,读取元素key所在的结点
private ListNode searchNode(int key){
ListNode cur = head;
while(cur != null){
if(cur.val == key){
return cur;
}
cur = cur.next;
}
return null;
}
//删除值为key的所有节点
public void removeAllKey(int key){
//链表为空时:
if(head == null){
System.out.println("空链表");
return;
}
//遍历链表中的所有结点
ListNode cur = head;
while(cur!=null){
if(cur.val == key){//删除结点,并继续向后遍历
//删除头结点
if(cur == head){
head = head.next;
if(head!=null){
head.prev = null;
}else{
last = null;//链表中只有一个结点,删完后链表为空,更新头结点
}
}else{//删除中间节点和尾巴结点
if(cur.next!=null){
cur.prev.next = cur.next;//检查:cur.prev不能为空
cur.next.prev = cur.prev;//检查:cur.next不能为空
}else{
last = last.prev;
last.next = null;
}
}
cur = cur.next;
}else{//继续向后遍历
cur = cur.next;
}
}
}
//清空链表
public void clear1(){
this.head = null;
this.last = null;
}
public void clear2(){
ListNode cur = head;
while(cur != null){
ListNode curNext = cur.next;
cur.prev = null;
cur.next = null;
cur = cur.next;
}
head = null;
last = null;
}
}