算法的基础是数据结构,任何数据结构的基础都是创建+增删改查,所有的链表算法题分解到最后,都是这几个操作,所以下面也从这五项开始学习链表。
什么是链表?
答:单向链表就像一个铁链一样,元素之间相互连接,包含多个节点,每个节点有一个指向后继元素的next指针。表中最后一个元素的next指向null。如下图
注意:单链表中(核心的一个节点只能有一个后继,但是不代表一个节点只能有一个被指向。也就是好比法律规定一夫一妻,你只能和一个人结婚,不能同时跟两个人结婚,但是可以很多个人去爱你),就比如图二,图三就不符合
在链表中,每个点都由值和指向下一个结点的地址组成的独立单元,称为一个结点,有时也称为节点,含义都是一样的。
对于单链表,如果知道了第一个元素,就可以通过遍历访问整个链表,因此第一个结点最重要,一般称为“头结点”
在做题以及在工程里经常会看到虚拟结点的概念,其实就是一个结点dummyNode,其next指针指向head,也就是dummyNode.next=head。
因此,如果我们在算法里使用了虚拟结点,则要注意如果要获得head结点,或者从方法(函数)里返回的时候,就应该使用dummyNode.next。
注意:dummyNode的val不会被使用,初始化为0或者-1等都是可以的,既然值不会使用,那虚拟结点有啥用?
答:为了方便我们在处理首部结点,否则我们需要在代码里单独处理首部结点的问题。
我们知道JVM里有栈区和堆区,栈区主要存引用,也就是一个指向实际对象的地址,而堆区存的才是创建的对象。
public class Course{
Teacher teacher;
Student student;
}
这里的teacher和student就是指向堆的引用,比如我们再定义一个:
public class Course{
int val;
Course next;
}
这个时候 next就指向了下一个同为Course类型的对象了,例如:
这里通过栈中的引用(也就是地址)就可以找到val(1)。然后val(1)结点又存了指向cal(2)的地址,而val(3)又存了指向 val(4)的地址,所以就构造出来了一个链条访问结构。
根据面向对象的理论,在JAVA里规范的链表应该这么定义 :
public class ListNode1 {
private int data;
private ListNode1 next;
public ListNode1(int data) {
this.data = data;
}
public int getData() {
return data;
}
public void setData() {
this.data = data;
}
public ListNode1 getNext(){
return next;
}
public void setNext(){
this.next=next;
}
}
但是在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 static int getListLength(Node head) {
int length = 0;
Node node = head;
while (node != null) {
length++;
node = node.next;
}
return length;
}
单链表的插入,和数组插入是一样的,过程不复杂,但是很多坑 。
首先单链表插入要考虑三种情况:首部、中部、尾部
在链表的表头插入新的结点非常简单,容易出粗的是经常会忘记head需要重新指向表头,当我们创建一个新结点为newNode时,怎么连接到原来的链表上呢?
执行newNode.next=head即可。
但是之后我们还是要遍历新链表就要从newNode开始一路nex向下了,对吧?
但是我们的习惯是让head来表示,所以更改为head=newNode就好。
step1:先遍历找到要插入的位置,之后将当前位置接入到前驱结点和后继结点之间。
注意:到这里我们发现,既不能获得前驱结点,也无法将结点接入进来。
step2:我们要在目标结点前的一个位置停下来,也就是使用cur.next的值而不是使用cur的值来判断(这是链表最常用的策略)
如下列图,如果要在7的前面插入,当cur.next=node(7)了就应该停下来,此时cur.val=15。然后需要给newNode前后接两根线,此时只能先让new.next=node(15).next(图中虚线),然后node(15).next=new,“顺序不能错”。
为什么顺序不能错呢?
由于每个结点只有一个next,因此执行了node(15).next=new,之后,结点15和7之间的连线就自动断开了,如下图所示:
表尾插入就比较容易,我们只需要将尾结点指向新结点就可以了
综上,我们写出的链表插入的方法如下所示:
/**
*
* @param head 链表头结点
* @param nodeInsert 带插入结点
* @param position 带插入位置,从1开始
* @return 插入后得到的链表头结点
*/
public static Node insertNode(Node head, Node nodeInsert, int position) {
if (head == null) {
return nodeInsert;
}
//已经存放的元素个数
int size = getListlength(head);
if (position < 1 || position > size + 1) {
System.out.println("位置参数越界");
return head;
}
if (position == 1) {
nodeInsert.next = head;
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;
}
删除同样分为:删除头部元素、中间元素、尾部元素
执行:head=head.next即可。将head向前移动一次之后,原来的结点不可达,就会被JVM回收掉。
同样也是要找到“要删除的结点的”前驱结点,这里同样在提前一个位置判断,例如下图中删除40 ,前驱结点是7。遍历的时候需要判断cur.next时候为40,如果是,则执行cur.next=null即可,原来的40结点不可达,就会被JVM回收,
删除中间结点时,也会用cur.next来比较,找到位置后,将cur.next指针的值更新为cur.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<1||position>size){
System.out.println("输入的参数有误");
return head;
}
if (position==1){
return head.next;
}else {
Node cur=head;
int count=1;
while (count