📢编程环境:idea
【java-数据结构】模拟无头单向非循坏链表,掌握背后的实现逻辑
- [1. 先回忆一下:](#1. 先回忆一下:)
-
- [1.1 内存分布中的堆和虚拟机栈](#1.1 内存分布中的堆和虚拟机栈)
- 1.2基本类型变量和引用类型变量的区别
- [1.3 两个引用类型变量之间的赋值运算](#1.3 两个引用类型变量之间的赋值运算)
- [2. 链表](#2. 链表)
-
- 2.1链表是啥
- [2.2 链表的分类](#2.2 链表的分类)
- [3. 用java语言模拟实现一个无头单向非循坏链表(以存int类型元素为例)](#3. 用java语言模拟实现一个无头单向非循坏链表(以存int类型元素为例))
-
- 3.1穷举法创建一个链表
- [3.2 显示链表中的所有元素](#3.2 显示链表中的所有元素)
- [3.3 获取单链表的长度](#3.3 获取单链表的长度)
- 3.4查找单链表中是否包含元素key
- [3.5 头插](#3.5 头插)
- [3.6 尾插](#3.6 尾插)
- [3.7 链表的读取(假设第一个结点为1号位置)](#3.7 链表的读取(假设第一个结点为1号位置))
-
- [3.71 已知结点的位置,读取该结点的前一个结点](#3.71 已知结点的位置,读取该结点的前一个结点)
- [3.72 已知结点中存储的元素key,读取该结点的前一个结点](#3.72 已知结点中存储的元素key,读取该结点的前一个结点)
- [3.8 在任意位置index插入元素](#3.8 在任意位置index插入元素)
- [3.9 删除首次出现的存储了元素key的结点](#3.9 删除首次出现的存储了元素key的结点)
- 3.10删除所有存储了元素key的结点
- 3.11清空链表
- [4. 分析单链表插入和删除操作的时间复杂度](#4. 分析单链表插入和删除操作的时间复杂度)
- [5. 链表的优缺点以及应用场景](#5. 链表的优缺点以及应用场景)
- [6. 附完整源码](#6. 附完整源码)
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语言模拟实现一个无头单向非循坏链表,首先必定要创建一个类来表示这个链表,类里面有一个静态内部类,一个成员变量,若干个成员方法。静态内部类是为了描述结点;成员变量是附设的头结点,头结点永远指向链表中第一个结点;通过成员方法对链表进行增删改查。
java
public class MySingleLinkedList {
static class ListNode{
public int val;//存储当前元素,int类型
public ListNode next;//存储下一个元素的地址
public ListNode(int val){
this.val = val;
}
}
public ListNode head;//附设的头结点
//穷举法创建一个链表
public void createList(){
}
//显示链表中的所有元素
public void display(){
}
//获取单链表的长度
public int size(){
}
//查找单链表中是否包含元素key
public boolean contains(int key){
}
//头插
public void addFirst(int data){
}
//尾插
public void addLast(int data){
}
//已知结点的位置,读取该结点的前一个结点
private ListNode findIndexSubOne(int index){
}
//在任意位置插入元素,假设第一个结点为1号下标
public void addIndex(int index,int data){
}
//找到关键字key的前驱
private ListNode searchPrev(int key){
}
//删除第一次出现关键字为key的节点
public void remove(int key){
}
//删除所有值为key的节点
public void removeAllKey(int key){
}
//清空链表
public void clear(){
}
}
3.1穷举法创建一个链表
实际中基本不会用穷举法创建链表,通常都是通过头插尾插创建一个链表。这里用穷举法先创建一个链表,是为了方便理解链表的遍历,头插尾插的基础都是遍历链表。
java
public void createList(){
ListNode node1 = new ListNode(12);
ListNode node2 = new ListNode(23);
ListNode node3 = new ListNode(34);
ListNode node4 = new ListNode(45);
ListNode node5 = new ListNode(56);
node1.next = node2;
node2.next = node3;
node3.next = node4;
node4.next = node5;
this.head = node1;
}

3.2 显示链表中的所有元素
思路:遍历链表的所有结点,每遍历一个结点,打印结点中的元素。
要注意的是:
- 怎么遍历?
从头结点head开始,一路head.next
,直到头结点head==null
说明遍历结束。- 不能直接拿头结点head遍历。因为如果直接拿head遍历,当遍历结束的时候,
head==null
,此时头结点head中国存的不是第一个结点的地址了。
新建一个引用变量cur,让cur也指向第一个结点,就能让cur代替head遍历链表。这样既遍历了链表,也不会影响头结点head。时间复杂度:O(n)。
java
//显示链表中的所有元素
public void display(){
ListNode cur = head;
while(cur != null){
System.out.print(cur.val + " ");
cur = cur.next;
}
}
3.3 获取单链表的长度
思路:创建一个计数器count从0开始,每遍历一个元素,计数器就+1。
时间复杂度:O(n)。
java
//得到单链表的长度
public int size(){
int count = 0;
ListNode cur = head;
while(cur!=null){
count++;
cur = cur.next;
}
return count;
}
3.4查找单链表中是否包含元素key
思路:遍历链表中的结点,每遍历一个结点,把当前结点中的元素和key作比较。
时间复杂度:O(n)。
java
//查找单链表中是否包含元素key
public boolean contains(int key){
ListNode cur = head;
while(cur!=null){
if(cur.val == key){
return true;
}
cur = cur.next;
}
return false;
}
3.5 头插
以下面这个单链表为例,如何把元素100插入到该链表的第一个位置?
思路:先让元素100所在的结点绑定链表的原结点,再更新头结点。
- 先创建一个结点node,把元素data存储到结点node中
- 再把头结点的地址存储到node中
- 让头结点和node都指向元素data所在的空间。
时间复杂度:O(1)。
java
//头插
public void addFirst(int data){
ListNode node = new ListNode(data);
node.next = head;//把头结点的地址存储到node中
head = node;//让头结点和node都指向元素data所在的空间。
}
3.6 尾插
以下面这个单链表为例,如何把元素100插入到该链表的最后一个位置?
思路:
先创建一个结点node,把元素100存储到node中。
找到链表的最后一个结点,把node的地址存到最后一个结点中。
要注意的是:怎么找到链表的最后一个结点?
链表的最后一个结点中,存储着一个null地址。也就是链表最后一个结点的地址域为null。
从第一个结点开始遍历,当遍历到链表的最后一个结点时,遍历停止。继续遍历的条件是:cur.next!=null
;遍历停止的条件是:cur.next==null
。cur不能为null,否则cur.next会空指针异常。即当链表为空的情况要特殊处理。
遍历链表的代码中,区分
cur==null
和cur.next==null
- 变量cur里面存储的是当前结点的地址,指向当前结点。当cur==null时,cur不指向任何结点,可能代表链表的每个结点都遍历完了,也可能是当前链表为空。
- cur.next里面存的是当前结点里面存储的地址。当cur.next ==null时,代表cur现在处于链表的最后一个结点。
时间复杂度:O(n)。
java
//尾插
public void addLast(int data){
ListNode node = new ListNode(data);
ListNode cur = head;
//特殊情况处理:当链表为空时,cur等于null,cur.next会越界。
if(cur == null){
head = node;
return;
}
//找到链表中的最后一个结点
while(cur.next!=null){//检查:cur不能为null
cur = cur.next;
}
//把node的地址存到最后一个结点中
cur.next = node;
}
3.7 链表的读取(假设第一个结点为1号位置)
以下两个方法都是为了服务MySingleLinkedLis类的内部方法存在的,所以用了private进行封装。
3.71 已知结点的位置,读取该结点的前一个结点
思路:若当前结点的位置是index,从一号位置开始遍历,向后遍历index-2步,就能到达当前结点的前一个结点了。
要注意的是:
- 第一个结点没有前驱。下面代码能返回第二节点到最后一个结点之间的前驱,包括返回第二结点的前驱和最后一个结点的前驱。
java
//已知结点的位置,读取该结点的前一个结点
private ListNode findIndexSubOne(int index){
//判断位置是否合法:
if(index<2||index>size()){
System.out.println("位置不合法");
return null;
}
ListNode cur = head;
for (int i = 1;i <= (index-2);i++){
cur = cur.next;
}
return cur;
}
3.72 已知结点中存储的元素key,读取该结点的前一个结点
思路:遍历链表中结点,每遍历一个结点,判断存储在当前结点中的地址指向的对象的值 是否和元素相等。
要注意的是:
- 第一个结点没有前驱。下面代码能返回第二节点到最后一个结点之间的前驱,包括返回第二结点的前驱和最后一个结点的前驱。
- 返回null,表示链表中,遍历完第二结点到最后一个结点,都没有找到元素key的前驱,也就是该链表中key不存在。
java
//找到关键字key所在结点的前驱
private ListNode searchPrev(int key){
ListNode cur = head;
while(cur.next != null){
if(cur.next.val == key){//检查:cur.next不能为null
return cur;
}
cur = cur.next;
}
return null;
}
3.8 在任意位置index插入元素
以下面这个单链表为例,如何把元素100插到链表的第三个结点前面?也就是如何让元素100所在的结点成为链表的第三个结点,链表中原来以第三结点为首的结点们跟在元素100所在的结点后面?假设第一个结点为1号位置,类推。
思路:先找到第二个结点(二号位置),从而能得到第二结点的地址和第三结点的地址。再让新节点先和第三结点链接,再和第二结点链接。
先创建一个结点node,把元素100存储到node中
找到要插入位置的前一个位置findIndexSubOne(int index):得到该位置的地址,和该位置中存储的下一个位置的地址。
把node结点插到链表的第三个位置。让node结点先和后面结点链接,再和前面结点链接 。
要注意的是:检查index是否合法。
index等于size()+1也是合法位置。index等于size()+1包括两种情况,一种是链表为空时,插入元素;一种情况是在链表的末尾插入元素。以上两种情况下直接尾插元素更方便。
若要插入位置是一号位置,一号位置没有前驱,所以这种情况要单独拎出来。相当于头插。
时间复杂度:O(n)。
java
//在任意位置插入元素,假设第一个结点为1号下标
public void addIndex(int index,int data){
//判断位置是否合法:
if(index<1||index>size()+1){
System.out.println("位置不合法");
return;
}
//头插
if(index == 1){
addFirst(data);
return;
}
//尾插
if(index == size()+1){
addLast(data);
return;
}
//其他位置插
ListNode node = new ListNode(data);
ListNode subNode = findIndexSubOne(index);
node.next = subNode.next;
subNode.next = node;
}
//已知结点的位置,读取该结点的前一个结点,设第一个结点为1号下标
private ListNode findIndexSubOne(int index){
ListNode cur = head;
//让cur走index-2步
for (int i = 1;i <= (index-2);i++){
cur = cur.next;
}
return cur;
}
3.9 删除首次出现的存储了元素key的结点
以下面点链表为例:怎么删除存储了元素60的结点?
思路:先确定当前结点的前驱,然后把当前结点中存储的后继的地址存储到当前结点的前驱中。
- 找到要删除结点的前驱searchPrev(int key):前驱结点中存储着要删除结点的地址 。该地址指向要删除的结点。要删除的结点中又存储着要删除结点的后继结点的地址 。
所以找到要删除结点的前驱意味着:得到了要删除结点的地址,和要删除结点的后继结点地址。- 连接要删除结点的前后结点:让要删除结点的前驱结点中 存储要删除结点中存储的后继结点的地址。
要注意的是:
- 一号节点没有前驱,所以这种情况要单独拎出来讨论。
- 当链表为空时,不能执行删除操作。
时间复杂度:O(n)。
java
//找到关键字key所在结点的前驱
private ListNode searchPrev(int key){
ListNode cur = head;
while(cur.next != null){
if(cur.next.val == key){//检查:cur.next不能为null
return cur;
}
cur = cur.next;
}
return null;//链表中没有该元素
}
//删除第一次出现关键字为key的节点
public void remove(int key){
//key在头结点,删除头结点
if(head.val == key){
//head = null;
head = head.next;
return;
}
//链表为空时
if(head == null){
System.out.println("链表为空");
return;
}
//key在第二结点到最后一个结点之间
ListNode cur = searchPrev(key);//找到要删除结点的前驱
if(cur == null){
System.out.println("没有你要删除的元素");
return;
}
ListNode del = cur.next;//前驱结点cur中存储着要删除结点的地址
cur.next = del.next;//连接要删除结点的前后结点
}
3.10删除所有存储了元素key的结点
以下面单链表为例,如何把链表中所有存储了元素34的结点删掉?(只能遍历链表一遍)
思路:
- 遍历链表中所有结点,
- 若当前结点中的元素和key相等,就删除当前结点,删除后继续向后遍历链表。
- 若当前结点中的元素与key不相等, 也继续向后遍历链表。
要注意的是:
- 怎么删除当前结点:先确定当前结点的前驱,然后把当前结点中存储的后继的地址存储到当前结点的前驱中。
- 从链表的第二个结点开始遍历,而且每遍历一个元素,不仅要确定当前元素是否是要删除的元素,还要确定当前元素的前驱。此时若当前元素是要删除的元素,执行删除该元素的操作就非常方便。
- 头结点没有前驱,当要删除的元素是头结点时,这种情况需要最后单独处理 。
- 头结点为什么必须放到最后处理:如果先判断头结点,再判断后面的元素,若头结点是要删除的元素,删除头结点后,在删后面元素的时候,还是会从第二结点(也就是新的头结点)开始遍历。此时若新的头结点还是要删除的元素,删完头结点后,新的头结点依然会遗漏。如果非要先判断头结点再判断后面元素,就搞个循坏,先删头结点,直到新的头结点不是要删除的元素,再删后面的元素。
时间复杂度:O(n)。
java
//删除所有值为key的节点
public void removeAllKey(int key){
//链表为空时
if(head == null){
System.out.println("链表为空");
return;
}
//要删除的元素在第二结点到最后一个结点之间
ListNode cur = head.next;
ListNode prev = head;
while(cur != null){
if(cur.val == key){
prev.next = cur.next;//删除当前结点
cur = cur.next;//继续向后遍历
}else {
prev = cur;
cur = cur.next;
}
}
//若头结点也是要删除的元素时
if(head.val == key){
head = head.next;
}
}
3.11清空链表
思路:直接把头结点设置为null。
要注意的是:
这虽然做到了清空链表的所有结点,即下一次用链表存储元素,该元素所在的结点是链表的第一个结点。但是链表中的原结点依然在堆中占据内存,这些内存并没有真正被释放。
如果想要删除链表中的结点并释放内存,需要遍历链表的所有结点,把每个结点中的引用数据类型变量都设置为空。
java
//清空链表
public void clear(){
this.head = null;
}
public void clear2(){
ListNode cur = head;
while( cur!= null){
ListNode curNext = cur.next;
cur.next = null;
cur = curNext;
}
head = null;
}
4. 分析单链表插入和删除操作的时间复杂度
单链表的插入和删除操作,都是
- 先遍历链表,找到要插入位置或删除结点的前驱,时间复杂度O(n)。
- 然后插入或删除元素,时间复杂度O(1)。
从整个插入算法或删除算法来说,插入和删除操作的时间复杂度都是O(n)。
5. 链表的优缺点以及应用场景
链表的缺点 :
单链表的物理结构不连续,没办法做到随机访问链表中的元素。
链表的优点:
- 随用随分配,不会浪费空间。
- 向链表中插入或删除元素不需要挪元素。
链表的使用场景 :
链表适合存储动态数据,即经常对数据进行插入和删除的操作。
6. 附完整源码

java
public class MySingleLinkedList {
static class ListNode{
public int val;//存储当前元素
public ListNode next;//存储下一个元素的地址
public ListNode(int val){
this.val = val;
}
}
public ListNode head ;//附设的头结点
//穷举法创建一个链表
public void createList(){
ListNode node1 = new ListNode(12);
ListNode node2 = new ListNode(23);
ListNode node3 = new ListNode(34);
ListNode node4 = new ListNode(45);
ListNode node5 = new ListNode(56);
node1.next = node2;
node2.next = node3;
node3.next = node4;
node4.next = node5;
this.head = node1;
}
//显示链表中的所有元素
public void display(){
ListNode cur = head;
while(cur != null){
System.out.print(cur.val + " ");
cur = cur.next;
}
}
//得到单链表的长度
public int size(){
int count = 0;
ListNode cur = head;
while(cur!=null){
count++;
cur = cur.next;
}
return count;
}
//查找单链表中是否包含元素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);
node.next = head;
head = node;
}
//尾插
public void addLast(int data){
ListNode node = new ListNode(data);
ListNode cur = head;
//特殊情况处理:当链表为空时,cur等于null,cur.next会越界。
if(cur == null){
head = node;
return;
}
while(cur.next!=null){
cur = cur.next;
}
cur.next = node;
}
//已知结点的位置,读取该结点的前一个结点,设第一个结点为1号下标
private ListNode findIndexSubOne(int index){
//判断位置是否合法:
if(index<2||index>size()){
System.out.println("位置不合法");
return null;
}
ListNode cur = head;
for (int i = 1;i <= (index-2);i++){
cur = cur.next;
}
return cur;
}
//在任意位置插入元素,假设第一个结点为1号下标
public void addIndex(int index,int data){
//判断位置是否合法:
if(index<1||index>size()+1){
System.out.println("位置不合法");
return;
}
//头插
if(index == 1){
addFirst(data);
return;
}
//尾插
if(index == size()+1){
addLast(data);
return;
}
//其他位置插
ListNode node = new ListNode(data);
ListNode subNode = findIndexSubOne(index);
node.next = subNode.next;
subNode.next = node;
}
//找到关键字key所在结点的前驱
private ListNode searchPrev(int key){
ListNode cur = head;
while(cur.next != null){
if(cur.next.val == key){//检查:cur.next不能为null
return cur;
}
cur = cur.next;
}
return null;//链表中没有该元素
}
//删除第一次出现关键字为key的节点
public void remove(int key){
//key在头结点,删除头结点
if(head.val == key){
//head = null;
head = head.next;
return;
}
//链表为空时
if(head == null){
System.out.println("链表为空");
return;
}
//key在第二结点到最后一个结点之间
ListNode cur = searchPrev(key);//找到要删除结点的前驱
if(cur == null){
System.out.println("没有你要删除的元素");
return;
}
ListNode del = cur.next;//前驱结点cur中存储着要删除结点的地址
cur.next = del.next;//连接要删除结点的前后结点
}
//删除所有值为key的节点
public void removeAllKey(int key){
//链表为空时
if(head == null){
System.out.println("链表为空");
return;
}
//要删除的元素在第二结点到最后一个结点之间
ListNode cur = head.next;
ListNode prev = head;
while(cur != null){
if(cur.val == key){
prev.next = cur.next;//删除当前结点
cur = cur.next;//继续向后遍历
}else {
prev = cur;
cur = cur.next;
}
}
//若头结点也是要删除的元素时
if(head.val == key){
head = head.next;
}
}
//清空链表
public void clear(){
this.head = null;
}
public void clear2(){
ListNode cur = head;
while( cur!= null){
ListNode curNext = cur.next;
cur.next = null;
cur = curNext;
}
head = null;
}
}