1.链表基础
1.1 链表的内部结构
首先看一下什么是链表?单向链表包含多个结点,每个结点有一个指向后继元素的next指针。表中最后一个元素的next指向null。如下图:
我们在数组中就说过任何数据结构的基础都是创建+增删改查,由这几个操作可以构造很多算法题,所以我们也从这五项开始学习链表。
首先理解JVM是怎么构建出链表的,我们知道JVM里有栈区和堆区,栈区主要存引用的,而堆区存的是对象,栈里的引用存的就是对象在堆区中的地址,例如我们定义这样一个类:
public class Course{
Teacher teacher;
Student student;
}
这里的teacher和student就是指向堆的地址。假如我们这样定义:
public class Course {
int val;
Course next;
}
这时候next就指向了下一个同为Course的对象了,例如:
这里的head就存了一个堆内存的地址,所以可以直接找到val(1),然后val(1)结点又存了指向val(2)的地址,而val(3) 又存了指向val(4)的地址,所以就构造出了从head开始的链条访问结构。
我构造了一个例子BasicLink,我们debug一下看一下从head开始next会发现是这样的:
这就是一个简单的线性访问了。所以而链表就是从head开始,逐个开始向后访问,而每次访问的对象类型都是一 样的。
创建链表的方式可以很简单,在LeetCode中算法题中经常使用这样的方式:
public class ListNode {
public int val;
public ListNode next;
ListNode(int x) {
val = x;
next = null;
}
}
ListNode listnode=new ListNode(1);
这里的val就是当前结点的值, next指向下一个结点。因为两个变量都是public 的,创建对象后能直接使用listnode.val 和listnode.next来操作,虽然违背了面向对象的设计要求,但是代码更为精简,因此在算法题目中应用广泛。
而更加符合面向对象要求的定义是这样的:
public class ListNode {
private int data;
private ListNode next;
public ListNode(int data) {
this.data = data;
}
public int getData() {
return data;
}
public void setData(int data) {
this.data = data;
}
public ListNode getNext() {
return next;
}
public void setNext(ListNode next) {
this.next = next;
}
}
这样的坏处是你不知道ListNode表示的是链表还是结点,因此在很多地方还会见到在这个List里为Node单独定义一 个内部类的写法。例如:
public class LinkedListBasicUse {
static class Node {
final int data;
Node next;
public Node(int data) {
this.data = data;
}
}
}
使用的时候这么用:
LinkedListBasicUse.Node head = new Node(1);
这几种方式都经常见,一般题目会先将使用哪种方式定义的代码告诉你,然后让你来用。所以就会发现不同方式下的链表操作原理一样,但是具体代码千差万别。我们接下来的增删改查操作就使用第二种方式进行。
1.2 遍历链表
对于单链表,不管进行什么操作,一定是从头开始逐个向后访问,所以操作之后是否还能找到表头非常重要。一定要注意"狗熊掰棒子 "问题,也就是只顾当前位置而将标记表头的指针丢掉了。
单链表必须知道表头的地址,然后沿着指针向前走,当next的值为null时停止。
/**
* 获取链表长度
* @param head 链表头节点
* @return 链表长度
* */
public static int getListLength(Node head) {
int length = 0;
Node node = head;
while (node != null) {
length++;
node = node.next;
}
return length;
}
1.3 链表插入
单链表的插入,和数组的插入一样,过程不复杂,但是在编码时会发现处处是坑。和数组的插入一样,单链表的插入操作同样要考虑三种情况:首部、中部和尾部。
(1) 在链表的表头插入
链表表头插入新结点非常简单,但是非常容易想不明白。先不看图示和代码等,我们先想想怎么做。
将新数据构造成一个新结点newNode,其next为null。这个新结点怎么连接到原来的链表上呢?应该是 newNode.next=head是吧。
那这时候我们要遍历新链表就要从newNode开始一路next向下了是吧,但是我们还是习惯让head来表示,那怎么办呢?让head=newNode是不是就行了?因此主要分两步:
- 1.更新结点的next指针,使其指向当前的表头结点。
- 2.然后更新表头指针的值,使其指向新结点。
图示如下:
(2)在链表中间插入
在中间位置插入,我们必须先遍历找到要插入的位置,但是到了该位置之后我们不能获得其前面的结点,无法将结点接入进来了,该怎么办呢?我们要在目标结点的前一个位置停下来,也就是通过cur.next来判断。
例如下面图示中,如果要在7的前面插入,当cur到达值为15的结点时,判断cur.next=node(7)了就应该停下来,然后执行插入操作。
很显然new结点前后都要接入,也就是node(15).next=newNode,newNode.next=node(7),这时候该先接哪一个呢?
我们分析一下,7号结点,我们其实是通过cur.next来定位的,如果我们先执行cur.next=new,此时就无法找到7了,因此我们要先接后面。
这时候让newNode.next=cur.next就将后面的虚线建立起来了?然后cur.next=newNode就将新结点接入进来了? 如下图:
此时结点15和7之间的连线(图中红线)就自动断开了,最终结构就是:
这样就完成了中间任意位置结点的插入。
(3)在单链表的结尾插入结点
表尾插入就比较容易了,但是思想和上面中间位置是一样的。我们只要将尾结点指向新结点就行了。
综上,我们写出链表插入的方法:
/**
* 链表插入
* @param head 链表头节点
* @param nodeInsert 待插入节点
* @param position 待插入位置
* @return 插入后得到的链表头节点
* */
public static Node insertNode(Node head, Node nodeInsert, int position) {
// 需要判空,否则后面可能会有空指针异常
if (head == null) {
return nodeInsert;
}
//越界判断
int size = getLength(head);
if (position > size + 1 || position < 1) {
System.out.println("位置参数越界");
return head;
}
//在链表开头插入
if (position == 1) {
nodeInsert.next = head;
// 这里可以直接 return nodeInsert;还可以这么写: head = nodeInsert;
return head;
}
Node pNode = head;
int count = 1;
while (count < position - 1) {
pNode = pNode.next;
count++;
}
nodeInsert.next = pNode.next;
pNode.next = nodeInsert;
return head;
}
1.4 链表删除
删除同样分为在删除头部元素,删除中间元素和删除尾部元素。
(1)删除表头结点
删除表头元素还是比较简单的,对于内存泄露要求不高的场景貌似只要返回head=head.next就行了,之前的元素 delNode就再也不能访问了。
(2)删除最后一个结点
删除的过程不算复杂,重点也是找到要删除的结点curNode的前驱结点preNode,只要将preNode的next设置为 null。例如下图中删除40,其前驱结点为7。
在操作链表的时候有个问题千万别忘了,不管你后面怎么处理,最后访问链表的时候还是从head开始的,所以一定要保证有一个指针指向head。千万注意别狗熊掰棒子。
(3)删除中间结点
与尾结点类似,这里也要注意保存前驱结点。一旦找到要被删除的结点,将前驱结点next指针的值更新为被删除结 点的next值。如下图所示:
我们可以写一个删除的方法了:
/**
* 删除节点
*
* @param head 链表头节点
* @param position 删除节点位置,取值从1开始
* @return 删除后的链表头节点
* */
public static Node deleteNode(Node head, int position) {
if (head == null) {
return null;
}
int size = getListLength(head);
if (position > size || position <= 0) {
System.out.println("输入的参数有误");
return head;
}
if (position == 1) {
//curNode就是链表的新head
return head.next;
} else {
Node preNode = head;
int count = 1;
while (count < position - 1) {
preNode = preNode.next;
count++;
}
Node curNode = preNode.next;
preNode.next = curNode.next;
}
return head;
}
1.5 双向链表简介
双向链表顾名思义就是既可以向前,也可以向后,这是与单向链表最大的区别。有两个指针的好处自然是在指针在中间位置的时候,操作更方便。该结构我们在LRU设计等问题中会遇到,所以这里简单看一下。
public class Node {
public int data;
public Node next;
public Node prev;
public Node(int data) {
this.data = data;
}
//打印结点的数据域
public void displayNode() {
System.out.print("{" + data + "} ");
}
}
当然坏处就是增删改的时候,需要修改的指针多了,操作更麻烦了。我们看一下插入和删除的大致过程。
由于在算法测试中,双向链表不是重点,我们只看一下在中间位置删除的操作过程。
该操作与单向链表相似,首先标记出几个关键结点的位置。也就是图中的cur,cur.next和cur.next.next结点。由于在双向链表中,可以走回头路,所以我们使用 cur,cur.next和cur.prev任意一个位置都能实现删除。假如我们就删除cur ,则图示是这样的:
由于头结点和尾结点的指针情况不一样,所以即使使用了虚拟指针,还是要单独处理一下头部和尾部删除的情况,这个我们不再细谈。
我们再看一下在中间位置插入的情况:
虽然分析不麻烦,但是看上图的图还是比较繁琐的,而要手动实现也更为麻烦。
与单链表相比,双向链表的一个重要功能是可以方便地进行结点的调整,这在AQS的优先级问题等场景中非常适用。不过单纯的双向链表排序实现非常复杂,而且效率也不高,我们一般会根据实际需要进行简化或者直接使用 jdk封装过的类来实现。