一、ArrayList 的优缺点
📚 在介绍"LinkedList"之前,我们先回顾一下上一篇文章中,我们所提到的内容:
上一篇文章中,我们讲到了"List"以及它的一个实现类"ArrayList",并且我们知道了"List(线性表)"分为"顺序表"和"链表",而上篇文章中我们所讲到的"ArrayList"就是"顺序表"~
📚 由于"ArrayList"有很多的方法,并且也有它独特的优点,比如:
📌 随机访问性能好:因为"ArrayList"的元素在内存中都是连续存储的,所以可以通过下标直接访问指定元素,因此随机访问的时间复杂度仅仅为O(1)。
📌 存储效率高:又由于"ArrayList"的元素在内存中是连续存储,所以不用额外空间存储每个元素的地址,故存储效率也较高。
📚 而相应的,"ArrayList"也有很多缺点:
📌 插入和删除操作效率低:恰恰也是因为"ArrayList"的元素在内存是连续存储,所以当需要移动或删除某元素时,则需要移动其他的元素来腾出空间或填补空间,因此"ArrayList"的插入和删除时间复杂度为O(n)。
📌 容易造成空间浪费:虽然"ArrayList"能够做到自动扩容,但是自动扩容是"按倍扩容"的,所以有时当"顺序表已满"时,如果我们只想再加入一个元素,顺序表也可能给我们开辟出一个非常大的空间,但我们只要一个就够了,这就会导致空间的浪费。
所以当应对一些"需要频繁使用插入和删除操作"的需求时,"ArrayList"就派不上什么用场了~所以为此,"List"便有了第二个实现类 —> "LinkedList(链表)"
二、认识链表
① 链表的概念
我们知道,线性表是由n个具有相同数据类型的元素组成的有限序列,并且线性表中的元素在逻辑上是相互关联的,表中的每个元素都有唯一的前驱和后继元素,之前我们介绍的"顺序表"就是"物理存储结构上连续"的存储结构,由图来表示是这样的:
那么对应的,链表的概念就是:
物理存储结构上非连续存储结构,数据元素的逻辑顺序是通过链表中的引用链接次序实现的。
那么什么是"逻辑顺序通过引用链接次序实现"呢?我们来看看:
上面这张图就是一个链表,从上图中我们能看出一些链表的特征:
📕 链式结构在逻辑上是连续的,但在物理上不一定连续。
📕 现实中的结点一般都是从堆上申请出来的。
📕 从堆上申请的空间按照一定策略分配,两次申请的空间是否连续都有可能。
📚 而链表并不像顺序表,链表是多种多样的,以下主要有3种因素,就能组合成8种链表结构:
② 链表的分类
📕 单向或者双向
📕 带头或者不带头
📕 循环或者非循环
📚 其实这么多链表结构,我们重点掌握两种即可:
📕 无头单项非循环链表:结构简单,一般不用来单独存储数据,但在之后的学习中更多用来作为其他数据结构的子结构。
📕 无头双向链表:在Java的集合框架库中LinkedList底层实现就是⽆头双向循环链表。
三、单向链表(方法及实现)
让我们先看一下"单向链表"都有什么方法:
方法名 | 方法作用 |
void addFirst(int data) | 头插法 |
void addLast(int data) | 尾插法 |
void addIndex(int index,int data) | 任意位置插入 , 第一个数据节点为 0 号下标 |
boolean contains(int key) | 查找关键字 key 是否在单链表当中 |
void remove(int key) | 删除第一次出现关键字为 key 的节点 |
void removeAllKey(int key) | 删除所有值为 key 的节点 |
int size() | 得到单链表的长度 |
void clear() | 清空单链表 |
void display() | 打印单链表 |
接下来我们将一边讲解"单向链表"的方法,一边对其模拟实现,巩固大家对它的理解~
① 单向链表基本框架
首先,在介绍各种方法之前,我们先来思考一下:单向链表的基本框架应该都包含什么呢?
📚 再让我们观察一下单向链表的结构:
由此我们能够梳理出,想要实现单向链表,先要在类中包含这么多的东西~
📕 我们需要定义一个"ListNode"内部类,其中需要有"该结点数据","下结点地址"两种元素
📕 我们还需要定义一个"头结点",用于记录该单向链表的头
📖 代码示例:
public class SingleLinkedList {
static class ListNode {
private int val;
private ListNode next;
public ListNode(int val) {
this.val = val;
}
}
public ListNode head;
需要注意的是,我们的构造方法只初始化了 val 而并没有初始化 next,这是因为:当我们创建该结点的时候,下一个结点还未存在,于是我们尚不可得知下个结点的地址是什么,所以只能够初始化 val~
随后我们再将刚刚列举出的方法也都一一写入单向链表中,于是我们的代码现在是这样的:
② addFirst 方法
单向链表的addFirst(头插法)方法,顾名思义,也就是在单向链表的头部插入一个元素~
那么该如何实现呢?我们先思考一下,想要在单向链表的头部插入元素,那么就意味着,之前的头结点便不再是头结点了,而是我们创建的这个新元素取代头结点,于是我们就得到了第一个结论:
📕 需要将 head 代表的"旧头结点"改换成插入的"新头结点"
而单向链表又是通过 next 环环相扣的,如果不将"新头结点"与"旧头结点"连接起来,那么即便有 head 也找不到原先的链表了,所以就来了第二个需求:
📕 需要在"新头结点"的后面连接"旧头结点"
📖 代码示例:
public void addFirst(int data){
ListNode node = new ListNode(data);
node.next = head;
head = node;
}
想打印试试看看对不对?别着急嘛,吃一堑长一智,别忘了我们还没写打印单向链表的函数呢~
③ display 方法
📚 想要打印单向链表,我们首先要明确,链表与顺序表的差别:
顺序表的元素只存储数字,所以需要用获取长度的方法来打印顺序表
链表的结点存储数字和下一结点的地址,我们从头结点遍历,直到某一刻结点的next为null即可
📖 代码示例:
public void display() {
ListNode cur = head;
while(cur != null){
System.out.print(cur.val + " ");
cur = cur.next;
}
System.out.println();
}
看来是没有问题的~
④ addLast 方法
顾名思义,该方法就是尾插法,作用是在单向链表后插入一个结点,这个实现起来比较简单,我们只需找到最后一个结点,将新结点链接在其后就好了。
public void addLast(int data){
ListNode node = new ListNode(data);
ListNode cur = head;
if(cur == null){
head = node;
return;
}
while(cur.next != null){
cur = cur.next;
}
cur.next = node;
}
(需要注意的是,如果此时为空链表,则找不到尾结点链接 node 所以空链表情况需要单独处理)
⑤ size 方法
该方法作用是获取单向链表的结点个数,思路很简单,遍历链表,定义计数器随时自增就好了。
public int size(){
ListNode cur = head;
int len = 0;
while(cur != null){
cur = cur.next;
len++;
}
return len;
}
⑥ addIndex 方法
该方法的作用是,在单向链表任意位置插入新结点,这个方法相较于头插和尾插要复杂不少,我们一点点分析:
首先,传入的下标 Index 必须是合法的,不能小于0,也不能大于单向链表本身的长度,所以我们实现该方法有以下注意点:
📕 需要对 Index 是否合法进行判断,并且给出相应的异常信息
📕 我们还可以根据查看 index 是否等于 0 或 size 来适当使用 addFirst 和 addLast
接下来我们看一下,如何将结点插入对应位置:
(不让新结点先与前一位相连,是因为如果 cur.next = 新结点,那么便不能通过 cur 找到之前的链表,也就丢失了之前链表 cur 以后的全部结点。)
📖 代码示例:
private static class IndexException extends RuntimeException{
public IndexException(){
}
public IndexException(String message){
super(message);
}
}
private void checkIndexOfAdd(int index){
if(index < 0 || index > size()){
throw new IndexException("插入的位置index不合法!");
}
}
public void addIndex(int index,int data) {
checkIndexOfAdd(index);
if (index == 0) {
addFirst(data);
return;
}
if (index == size()) {
addLast(data);
return;
}
ListNode cur = head;
ListNode node = new ListNode(data);
int num = 0;
while (num != index - 1) {
cur = cur.next;
num++;
}
node.next = cur.next;
cur.next = node;
}
头插,尾插,正常的插入,以及 index 不合法的情况,都是没有问题的~
⑦ contains 方法
该方法的作用是查找是否包含关键字 key 是否在单链表当中,想要实现该方法并不难,我们只需要遍历链表,当遇到 key 的时候返回 true 就好了~
📖 代码示例:
public boolean contains(int key){
ListNode cur = head;
while(cur != null){
if(cur.val == key){
return true;
}
cur = cur.next;
}
return false;
}
⑧ remove 方法
remove方法的作用是删除第一次出现关键字为 key 的节点,其实思路并不难,就和上一个方法 contains 一样,遍历找到 key 对其进行删除就好了,而重点在于应该如何删除~
其实,核心思路就是:我们不需要删除该节点,我们只需要单向链表链接时,将它跳过即可~也就是将之前与它链接的前一结点,直接 next 等于它的下一个结点即可~
但想要同时得到 key 结点的前驱与后继,只用一个遍历结点是不够的,所以还需要一个辅助结点跟在 cur 结点身后,用来在遇到 key 时,标记前驱结点~
(由于删除结点要求链表至少有一个结点,故可以对链表是否为空进行判断,如果为空则抛出异常)
这样看似是没有问题的,但是我们仔细观察一下,如果 head.val == key 的话,其实是解决不了的,因为我们是用 cur0 去查找 key,而 cur0 一上来就是 head.next ~(不必纠结,单向链表的删除就是要用两个结点,如果我们使用 cur 去查找 key 的位置,那么也会导致无法正常删除最后一项,所以这是正常的~)所以对于 head.val == key 我们需要单独处理一下~
📖 代码示例:
private static class listEmptyException extends RuntimeException{
public listEmptyException(){
}
public listEmptyException(String message){
super(message);
}
}
private void isEmpty(){
if(head == null){
throw new listEmptyException("链表为空!");
}
}
public void remove(int key){
isEmpty();
if(head.val == key){
head = head.next;
return;
}
ListNode cur0 = head;
ListNode cur = head.next;
while(cur != null){
if(cur.val == key){
cur0.next = cur.next;
return;
}
cur0 = cur;
cur = cur.next;
}
}
删除头结点,删除尾结点,删除中间结点,以及抛出异常都是正确的~
⑨ removeAllKey 方法
该方法的作用是删除单向链表中出现的所有 key,只要完成了 remove 方法,这个方法也就都到擒来了~我们只需要在"能找到 key "的前提下"循环 remove 方法"就可以了~
📖 代码示例:
public void removeAllKey(int key){
while(this.contains(key)){
remove(key);
}
}
⑩ clear 方法
清空链表~随手的事儿啦,直接 head = null 就好了。
📖 代码示例:
public void clear() {
head = null;
}
四、单向链表实现代码
📖 SingleLinkedList.java
public class SingleLinkedList {
static class ListNode {
private int val;
private ListNode next;
public ListNode(int val) {
this.val = val;
}
}
public ListNode head;
//头插法
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;
if(cur == null){
head = node;
return;
}
while(cur.next != null){
cur = cur.next;
}
cur.next = node;
}
//任意位置插入,第一个数据节点为0号下标
public void addIndex(int index,int data) {
checkIndexOfAdd(index);
if (index == 0) {
addFirst(data);
return;
}
if (index == size()) {
addLast(data);
return;
}
ListNode cur = head;
ListNode node = new ListNode(data);
int num = 0;
while (num != index - 1) {
cur = cur.next;
num++;
}
node.next = cur.next;
cur.next = node;
}
//查找是否包含关键字key是否在单链表当中
public boolean contains(int key){
isEmpty();
ListNode cur = head;
while(cur != null){
if(cur.val == key){
return true;
}
cur = cur.next;
}
return false;
}
//删除第一次出现关键字为key的节点
public void remove(int key){
isEmpty();
if(head.val == key){
head = head.next;
return;
}
ListNode cur0 = head;
ListNode cur = head.next;
while(cur != null){
if(cur.val == key){
cur0.next = cur.next;
return;
}
cur0 = cur;
cur = cur.next;
}
}
//删除所有值为key的节点
public void removeAllKey(int key){
while(this.contains(key)){
remove(key);
}
}
//得到单链表的⻓度
public int size(){
ListNode cur = head;
int len = 0;
while(cur != null){
cur = cur.next;
len++;
}
return len;
}
public void clear() {
head = null;
}
public void display() {
ListNode cur = head;
while(cur != null){
System.out.print(cur.val + " ");
cur = cur.next;
}
System.out.println();
}
private static class IndexException extends RuntimeException{
public IndexException(){
}
public IndexException(String message){
super(message);
}
}
private void checkIndexOfAdd(int index){
if(index < 0 || index > size()){
throw new IndexException("插入的位置index不合法!");
}
}
private static class listEmptyException extends RuntimeException{
public listEmptyException(){
}
public listEmptyException(String message){
super(message);
}
}
private void isEmpty(){
if(head == null){
throw new listEmptyException("链表为空!");
}
}
}
那么关于链表中的单向链表的知识就为大家分享到这里啦~作者能力有限,作者能力有限,如果有讲得不清晰或者不正确的地方,还请大家在评论区多多指出,我也会虚心学习的!那我们下次再见哦~