算法课堂——铁索连环(链表)
这是我参与2022首次更文挑战的第4天,活动详情查看:2022首次更文挑战」。
昨天我们讲解了算法中最基础的数据结构--数组,今天我们就来介绍一下在算法中另一个常见的数据结构--链表,并且它在面试中经常和数组成对出现。那么为什么今天的课题叫做铁锁连环呢?为啥不叫顺手牵羊(老三国杀了)?相信看了今天的算法课堂你会对链表有一个基础的认识。
一、链表的概念
通过上节课我们知道,数组在计算机中是一段连续的空间中具有一定顺序数据的集合,这种存储方式非常容易理解,操作简便,但是同学们一定还不知道数组也存在很多缺点。
因为数组在计算机内存中是连续的,所以我们在插入或者删除一个数组的中的数据时,需要对这个位置后面所有的数据进行移位。其次,我们也需要在内存中找出这么一大块连续的地方用来存放数组数据。
这个时候链表也就应运而生,我们先来看下链表的结构,分析下为什么链表可以克服数组的这些缺点。
和数组一样,我们还是从三个方面来理解链表,首先先看下我们呢典型的链表的结构示意图:
1、数据的逻辑结构:首先,我们链表由一个一个节点构成,每个节点里面分为数据和地址,数据即是该节点存放的数据内容,地址也就是上图的箭头,表示此节点的下一个节点是哪一个。
2、数据的存储结构:和数组不同,链表在计算机中的存储空间不连续,数组在内存中的存储如图1所示,那么链表在内存中就可能会想图2一样看起来毫无顺序,只靠每个节点的next指针来关联。
3、数据结构的运算:链表和数组类似,有着一些常用的方法,包括但不仅限于链表的遍历,链表的插入,链表的删除,链表的检索,链表的排序、链表的合并等等
二、链表与数组
那么为什么说链表就可以克服我们上述数组的一些缺点呢?
首先我们看链表的插入与删除,我们要想在链表中插入一个节点,只需要将两个节点的指针断开,再将前一个节点的指针指向要插入的节点,新插入的节点指针指向下一个节点就ok了。如图三所示:
同理,删除也很简单,如图四所示:
那么有些同学就会问了,既然链表这么好,那为什么还要数组呢?很明显,链表也有着自己明显的缺点,由于链表的数据在内存中不是连续的,只用next指针来关联,所以我们想要查询链表中的某个值,就必须从头找起,顺藤摸瓜,从head一个节点一个节点往下寻找。
总结下来:链表和数组没有绝对的好和坏,关键是针对不一样的情况选择合适的数据结构,数组的优势在于能够快速定位元素,对于读操作多,写操作少的场景来说,数组显然更加合适;不过,在需要频繁插入删除的场景下,链表则更加合适。
三、手写单链表
链表在理解起来比数组稍微复杂,所以我们先打个基础,手写链表的基本方法。
首先我们定义一个链表:
public class ListNode {
int val;
Node next;
Node() {}
Node(int val) { this.val = val; }
Node(int val, Node next) { this.val = val; this.next = next; }
}
第一步我们要做的是取得链表里面某一节点,根据上文所描述的链表的结构,我们通过循环的方式找到我们想要得到的节点,不过在此之前我们需要定义下链表的头节点和链表的长度,方便我们使用,但是切记这样的代价是每次链表有变化的时候要将这两个值更新!
public class Node {
int val;
Node next;
Node() {}
Node(int val) { this.val = val; }
Node(int val, Node next) { this.val = val; this.next = next; }
//链表的头节点
private Node head;
//链表长度
private int size;
public Node get(int index)throws Exception {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("位置不正确");
}
Node temp = head;
for (int i = 0;i<index;i++){
temp = temp.next;
}
return temp;
}
}
接下来我们就要手写链表的插入了,需要注意的是,链表的插入方法可能会改变链表的头节点以及一定会改变链表的长度,所以这两个值一定要在插入的同时更新。
public void insert(int data,int index)throws Exception {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("该位置不能插入");
}
Node needInsertNode = new Node(data);
if (size == 0) {
head = needInsertNode;
} else if (index == 0) {
//在头部插入节点
needInsertNode.next = head;
head = needInsertNode;
} else if (index == size) {
//在尾部插入节点
} else {
//在中间插入节点
}
}
在以上代码中我们发现,在头部插入新的节点我们很好操作,但是我们在尾部插入节点似乎就有点复杂,因此,我们大可把链表的尾节点和头节点一样也定义出来,但不要忘记,如果尾节点变了,要记得更新哦。
//链表的头节点
private Node head;
//链表长度
private int size;
//链表的尾节点
private Node last;
public void insert(int data,int index)throws Exception {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("该位置不能插入");
}
Node needInsertNode = new Node(data);
if (size == 0) {
head = needInsertNode;
last = needInsertNode;
} else if (index == 0) {
//在头部插入节点
needInsertNode.next = head;
head = needInsertNode;
} else if (index == size) {
//在尾部插入节点
last.next = needInsertNode;
last = needInsertNode;
} else {
//在中间插入节点
Node prevNode = get(index-1);
needInsertNode.next = prevNode.next;
prevNode.next = needInsertNode;
}
size++;
}
接下来就是链表的删除操作,同样从三个方面考虑,删除头节点,删除尾节点,删除中间节点,代码如下:
public Node delete(int index)throws Exception {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("位置不正确");
}
Node needDeleteNode = null;
if(index == 0){
//删除头节点
needDeleteNode = head;
head = head.next;
}else if(index == size-1){
//删除尾节点
Node prevNode = get(index-1);
needDeleteNode = prevNode.next;
prevNode.next = null;
last = prevNode;
}else {
//删除中间节点
Node prevNode = get(index-1);
Node nextNode = prevNode.next.next;
needDeleteNode = prevNode.next;
prevNode.next = nextNode;
}
size--;
return needDeleteNode;
}
接下来就是遍历整个链表了,和查询一样,需要遍历整个链表:
public void printAllNode(){
Node temp = head;
while (temp != null){
System.out.println(temp.val);
temp = temp.next;
}
}
到此,我们就手写出一个最基本的单链表
四、总结
很多同学对于链表还很陌生,对这种指针的模式不能很好的理解,没关系,认真看完手写链表的代码,最起码能对链表的基本操作有个大概的了解,所以我给点时间让大家消化理解下。那么链表在具体算法题中又会以怎样的形式出现呢?不用着急,我们明天再见!