目录
LinkedList与链表
1. ArrayList的缺陷
优点:
当给下标的时候,查找速度非常快。适合给定下标的查找,O(1)
缺点:
- ArrayList底层使用连续的空间,任意位置插入或删除元素时(最坏情况在0下标位置),需要将该位置后序元素整体往前或者往后搬移,故时间复杂度为O(N)。
- 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
- 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
由于其底层是一段连续空间,当在ArrayList任意位置插入或者删除元素时,就需要将后序元素整体往前或者往后搬移,时间复杂度为O(n),效率比较低,因此ArrayList不适合做任意位置插入和删除比较多的场景。为了能够解决这些问题,java集合中又引入了LinkedList,即链表结构。
2. 链表的概念及结构
链表是一种物理存储结构上非连续存储结构,数据元素的逻辑顺序是通过链表中的引用链接次序实现的 。
链表:类似火车一样他是由一个一个的节点/结点(车厢)组成的。
注意:
- 从上图可看出,链式结构在逻辑上是连续的,但是在物理上不一定连续。
- 现实中的节点一般都是从堆上申请出来的。
- 从堆上申请的空间,是按照一定的策略来分配的,两次申请的空间可能连续,也可能不连续。
链表是一个个叫做节点/结点的东西组成的,因此可以把节点定义为一个类(内部类)。
每一个数据节点都是由两部分组成的value域和next域。
节点的组织是通过当前这个节点它的next域存储下一个节点的地址。
- 不带头 单向 非循环链表,定义head引用代表当前链表的头节点(即第一个节点,随时可能变化)。
- 尾节点:它的next域是一个null 不指向任何节点
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
- 带头的链表,头节点里面的数据没有意义,无效的数据。
- head就是一个头节点,删除12头节点不会改变,head 相当于火车头永远都是头节点;
- 不带头删除12,第一个节点变成了23,随时可能变化。
带头与不带头的区别:
- 不带头里面做的就是乘客车厢(相当于无人驾驶),添加一个车厢(节点)可以放到乘客车厢最前面,哪个乘客在前面都一样。即不带头,第一个节点随时可能变化,但head永远指向第一个节点(头节点)。
- 带头的情况下头节点就是司机车厢(相当于火车头),添加一个车厢(节点)不能在司机车厢前面,必须在司机车厢后面(永远不能取代司机的位置),火车头永远都是头节点。即带了头,这个头节点永远都是第一个节点,head永远指向这个头节点。
- 单向 不带头 非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多
![]()
- 双向 不带头 非循环链表:在Java的集合框架库中LinkedList底层实现就是双向 不带头 非循环链表。
![]()
- 至少有三个域:value数值域,prev前驱,next后继
- head 永远指向双向链表的第一个节点
- last 永远指向双向链表的最后一个结点
3. 链表的实现(单链表)
实现一个单向 不带头 非循环链表。定义一个接口IList里面有许多增删改查的方法(其实这个接口可有可无。接口是公共的行为规范标准),再定义一个类MySingleList实现这个接口并重写其中的方法。
3.1 定义节点,内部类
- 链表由一个一个节点组成,把节点起名为Node,即为Node类,因为每个链表中的节点都是由value域和next域构成的。即有两个成员属性。
- 把Node定义为内部类,即节点脱离链表这个类就没有存在的意义了,链表是由一个一个节点构成的,所以定义在链表中的内部类。
- 反面例子;class类中的老师和学生脱离学校,它们还能单独做一个类存在的。但是Node脱离链表就没有存在的意义了。
- 如果Node定义为静态内部类,那我们认为Node不需要外部类对象(链表),就可以单独存在,但是节点单独存在没有意义。
- Node 加stastc,Node不依赖外部对象;Node 不加stastc,Node 依赖外部对象;
- 节点定义为静态内部类或实例内部类,看具体需求。
- next域中存储的是下一个节点的地址,节点是一个对象,所以next类型应该是节点对象引用的类型。引用类型,默认值为null
- 提供带有一个参数的构造方法,为什么不提供两个?因为创建节点的时候你不知道next域中存储下一个节点的地址是什么,这个需要业务实现。next默认值为null
- 单向 不带头 非循环链表,提供一个haed引用代表当前链表的头节点,head属于链表的成员。
通过枚举,先手动创建一个链表,真正的链表并非这样创建的。
通过node1.next = node2;...... 把节点都串起来,这个时候通过node1就可以把串起来的节点都拿到。但是node1、node2、node3、node4都是createLink方法的局部变量,创建完链表之后就被回收了。如何拿到链表中所有节点。
【解决方法】
- 把createLink方法返回值变为ListNode,return node1;
- 或者让this.head = node1; head引用指向了node1引用所指向的对象。即head指向了node1指向的位置,通过head就能找到链表中所有的节点。
- 没有被引用指向的节点对象都会被回收。串起来后每个节点对象被前一个节点(前驱节点)next域中的引用(地址)指向着,第一个节点则定义一个ListNode类型的head引用变量指向它。
- 这样就能通过head找到所有的节点对象了,而node1, node2......这些局部变量(里面存储也是节点的地址)方法结束后就被回收了。
- 例如,创建一个人的对象,Preson preson = new Preson(); 如果preson被回收了,不再存储这个对象的地址,不再指向这个对象了,这个对象就被回收了。节点也是类,也能实例化对象。当引用变量被回收,不再存储这个节点对象地址,不再指向这个节点了,这个节点对象就被回收了。
3.2 打印链表(遍历链表)
【总结】
- 只要涉及到链表遍历,几乎都需要创建一个变量(cur)代替head,防止下次使用链表找不到头。
- 从一个节点,走到下一个节点:每次cur移动都是 cur = cur.next; 语句执行的。
- cur != null 把整个链表的每一个节点,都遍历完成。走到了最后一个节点后面的位置,cur指向null。
- cur.next != null 遍历到链表的尾巴。走到了最后一个节点得位置,cur指向最后一个节点。
- 修改当前节点的next值为指定的节点,node1.next = node2;
3.3 得到单链表的长度 O(N)
两种方法:
- 得到单链表的长度,遍历数组时间复杂度为O(N)
- 或者定义一个useSize为成员变量,在添加节点组成链表的时候,添加一个,useSize就增加1;或删一个减一个。时间复杂度就会被降为0(1)
public int size() {
ListNode cur = this.head;
int count = 0;
while (cur != null) {
count++;
cur = cur.next;
}
return count;
}
3.4 查找是否包含关键字key是否在单链表当中
public boolean contains(int key) {
ListNode cur = this.head;
//遍历比较是否包含该key
while (cur != null) {
if (cur.val == key) {
return true;
}
cur = cur.next;
}
return false;
}
- 此时节点val域中的数据为int类型的,如果是引用类型或自定义类型不能用==比较,需要用equals
- 自定义类型比较的时候一定要重写equals方法
3.5 头插法 O(1)
当要插入节点的时候,实例化一个节点,插入到当前链表的第一个位置。
public void addFirst(int data) {
ListNode node = new ListNode(data);
//链表为空时
if (head == null) {
this.head = node;
return;
}
node.next = this.head;
this.head = node;
}
- if语句后的两行代码,其实包含了链表为空的情况。可以不用if语句。
- 注意:如果写成head = node node.next = head 就会出现找不到后面节点的情况。
3.6 尾插法 O(N)
指的是将待插入的节点,存放在链表的最后一个位置。
实例化一个节点;遍历找尾巴 O(N)
注意判断是否空链表第一次尾插入,如果不判断 cur.next 可能会发生空指针异常。
public void addLast(int data) {
Node node = new Node(data);
//链表为空时
if (this.head == null) {
this.head = node;
return;
}
ListNode cur = this.head;
//寻找最后一个节点
while (cur.next != null) {
cur = cur.next;
}
cur.next = node;
}
【结论】
1、如果想让cur停在最后一个节点的位置 cur.next != null
2、如果想把整个链表的每个节点,都遍历完,那么就是 cur ! = null
3、头插法的时间复杂度O(1);尾插法的时间复杂度O(N)
4、链表的插入,只需要改变指向就可以了。
3.7 任意位置插入,第一个数据节点为0号下标
- 链表其实是没有索引的,我们就只是把节点当做0索引 1索引,加不加没有关系。索引位置只是相对的。
- 插到哪个位置不能是找到这个位置,只能找到待插位置的前一个;因为插入的方法是 node.next = cur.next; cur.next = node;(cur为待插入位置的前一个节点,node是待插入节点)链表是单向的,如果走到要待插入的位置,就找不到前面的位置了,无法将节点插入。
- 所以该题的关键点就是找到cur的位置,也就是index-1的位置。如何找到,只需要让cur = cur.next 往前走 index-1 步就可以了。
- 如图:如果要插入intdex的位置,2的位置
在插入一个节点的时候,一定要先绑定后面这个节点。
private void checkIndex(int index) throws ListIndexOutOfException {
if (index < 0 || index > size()) {
throw new ListIndexOutOfException("任意位置插入下标不合法:"
+ index);
}
}
//任意位置插入,第一个数据节点为0号下标
@Override
public void addIndex(int index, int data) {
checkIndex(index);
//头插法
if (index == 0) {
addFirst(data);
return;
}
//尾插法
if (index == size()) {
addLast(data);
return;
}
//往中间位置任意插入
ListNode cur = searchPrev(index);
ListNode node = new ListNode(data);
node.next = cur.next;
cur.next = node;
}
//找到 index - 1 位置下标
private ListNode searchPrev(int index) {
ListNode cur = this.head;
int count = 0;
//让cur 走index - 1 步
while (count != index - 1) {
cur = cur.next;
count++;
}
return cur;
}
插入位置合法性异常类:
public class ListIndexOutOfException extends RuntimeException{
public ListIndexOutOfException(String message) {
super(message);
}
}
3.8 删除第一次出现关键字为key的节点
- 删除第一次出现关键字为key时,不能让cur直接走到要删除的位置,只能在该位置的前一个位置。因为删除该位置时,需要将前一个位置节点与要删除位置后面的节点串起来,链表是单向的,无法再找到前一个节点了。(遍历一次链表前提下)。
- 利用 cur.next.val == key; 来找到要删除位置前一个节点。
- 如图,要删除的key为23
思路顺序:①->②->③,先寻找最基本最简单的情况 构造出逻辑写出代码,然后慢慢完善特殊情况。
- 这里需要判断第一个节点是否是要删除的元素,因为第一个节点没有前驱,cur.next.val直接跳过了第一个节点,从第二个节点开始了。
- 删除时cur和del同时指向一个节点,del是局部变量,方法结束后就被回收掉,当del被回收之后就不会存储这个节点的地址,不再指向这个节点,这个节点就被回收了。
- 删除和任意位置插入,都需要拿到该节点的前驱。
- 绑定节点的时候一定先绑定后面的。
3.9 删除所有值为key的节点(遍历一遍)
思路:删除某个节点必须知道前一个节点是谁,即前驱节点是谁,否则这个节点不能删除。
设置两个快慢引用。cur代表当前需要删除的节点;prev是删除节点的前一个节点。
没引用的节点不用管,直接就被回收了,不用置空;在C中类似的要free置空。
有一个非常妙的方法,就是把该if语句放到最后面就可以了;因为写的代码是从第二个节点开始判断的,只有头没有处理,所以最后处理一下头就可以了。
public void removeAllKey(int key) {
//判断链表是否为空
if (head == null) {
return;
}
/*while (head.val == key) {
head = head.next;
}*/
//准备一个快慢节点,用来记录前驱节点
ListNode prev = head;
ListNode cur = head.next;
while (cur != null) {
if (cur.val == key) {
prev.next = cur.next;
} else {
prev = cur;
}
cur = cur.next;
}
//最后判断第一个节点的val值是否为key
if (head.val == key) {
head = head.next;
}
}
3.10 清空链表
两种方法:
- 如果value域中是基本类型。head = null;没有指向头节点,所以头节点被回收,下一个节点也是,没有指向,也被回收。也可以通过for循环一个一个置空。
- 如果value域中是引用类型。通过for循环的方法把每个节点value域和next域都置为null。
最后head需要手置空
public void clear() {
//暴力置空
//this.head = null;
//
ListNode cur = head;
while (cur != null) {
ListNode curNext = cur.next;
cur.val = 0;
cur.next = null;
cur = curNext;
}
//手动置空head
head = null;
}
4. 链表面试题
4.1 移除链表元素
删除链表中等于给定值 val 的所有节点,与上面3.9 删除所有值为key的节点方法一样。OJ链接
OJ题 在线判题系统(英语:Online Judge,缩写OJ)是一种在编程竞赛中用来测试参赛程序的在线系统,也可以用于平时练习。许多OJ网站会自发组织一些竞赛。
4.2 反转链表(常考)
反转一个单链表。OJ链接
思路:利用头插法进行反转,定义一个cur进行头插,并且再定义一个curNext记录cur.next;因为cur进行头插的时候cur的next值一定会改变。
时间复杂度O(n),空间复杂度O(1)
public ListNode reverseList() {
if (head == null) {
return null;
}
//只有一个节点
if (head.next == null) {
return head;
}
ListNode cur = head.next;
head.next = null;
while (cur != null) {
//curNext 记录cur中的next
ListNode curNext = cur.next;
cur.next = head;
head = cur;
cur = curNext;
}
return head;
}
指定节点位置打印:
/**
* 这个是从指定位置开始打印
* @param newHead
*/
public void display(ListNode newHead) {
//用一个变量代替newHead,防止找不到头(第一个节点)
ListNode cur = newHead;
while (cur != null) {
System.out.print(cur.val + " ");
cur = cur.next;
}
System.out.println();
}
测试类:
public static void main(String[] args) {
MySingleList mySingleList = new MySingleList();
mySingleList.addLast(12);
mySingleList.addLast(11);
mySingleList.addLast(23);
mySingleList.addLast(12);
mySingleList.addLast(34);
mySingleList.display();
MySingleList.ListNode ret = mySingleList.reverseList();
mySingleList.display(ret);
}
//执行结果
12 11 23 12 34
34 12 23 11 12
4.3 链表中间节点
给定一个带有头结点 head 的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点。OJ链接
第一种方法:
思想:利用快慢指针
- 快指针走两步,慢指针走一步,当快指针走终点的时候,慢指针一定是走到了中间的位置。同时出发,用时相同,速度是二倍关系,走的路程也一定是二倍关系。
- 如果fast走4步,slow走2步可不可以? 不可以,如果节点个数不够就直接无了,会发生越界,一定会发生空指针异常,所有走一步和走两步是最好的选择。
- (fast != null && fast.next != null)条件不能互换,因为fast 可能为null,如果互换了fast为null,就会发生空指针异常。
public ListNode middleNode(ListNode head) {
ListNode fast = head;
ListNode slow = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
return slow;
}
第二种方法:
- 先求整个链表的长度
- 再求长度/2 找到这个中间节点
public ListNode middleNode(ListNode head) {
//1、求数组长度
ListNode cur = this.head;
int len = size();
//2、求长度/2 找到这个中间节点
//让cur走 长度/2 步
for (int i = 0; i < len/2; i++) {
cur = cur.next;
}
return cur;
}
4.4 输出倒数第k个节点
输入一个链表,输出该链表中倒数第k个结点。OJ链接
第一种方法:
可以让cur走 length - k步,此时cur的位置就是倒数第k个节点的位置。但是此方法相对简单,我们用另一种方法。
第二种方法:
思路:利用快慢指针,根据下图可知,慢指针slow与快指针fast相差 k-1步;即倒数第k个节点 与 最后一个节点之间的关系相差 k -1 步,所以slow的位置就是倒数第k个节点。我们利用这个关系,
- 先让fast走k-1步
- fast 和slow开始同时一步一步走
- 当fast.next为空的时候,slow所指的位置就是倒数第k个
size()是求单链表的长度,也是遍历了一遍链表。我们对此代码进行优化:
public ListNode FindKthToTail(int k) {
if (k <= 0 || head == null) {
return null;
}
ListNode fast = this.head;
ListNode slow = this.head;
for (int i = 0; i < k - 1; i++) {
fast = fast.next;
//处理 K 太大的问题
if (fast == null) {
return null;
}
}
while (fast.next != null) {
fast = fast.next;
slow = slow.next;
}
return slow;
}
4.5 合并两个有序链表
将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。OJ链接
思路:
- 两个链表的节点head1.val与head2.val比较,较小的值作为合并后新链表的头节点。创建一个虚拟节点记录这个头;
- 同时创建一个tmpH引用指向拼接时链表的最后一个节点;
- 通过循环比较两个链表的节点然后合并。
这里是mergeTwoLists方法合并两个有序链表,放在MySingleList类(一个链表)中不太合适,但是也并非不可以;
把mergeTwoLists方法的实现放到测试类(Test)中,创建两个有序链表,然后调用mergeTwoLists方法,实现两个有序链表的合并。
public class Test {
//将两个有序链表合并为一个新的有序链表并返回。
//新链表是通过拼接给定的两个链表的所有节点组成的。
public static MySingleList.ListNode mergeTwoLists(MySingleList.ListNode head1, MySingleList.ListNode head2) {
//虚拟节点,起标识作用
MySingleList.ListNode newH = new MySingleList.ListNode(-1);
MySingleList.ListNode tmpH = newH;
while (head1 != null && head2 != null) {
if (head1.val < head2.val) {
tmpH.next = head1;
head1 = head1.next;
} else {
tmpH.next = head2;
head2 = head2.next;
}
tmpH = tmpH.next;
}
//上面代码走完head1、head2可能还有剩余节点需要拼接
if (head1 != null) {
tmpH.next = head1;
}
if (head2 != null) {
tmpH.next = head2;
}
return newH.next;
}
}
测试结果:
public static void main(String[] args) {
MySingleList mySingleList1 = new MySingleList();
mySingleList1.addLast(8);
mySingleList1.addLast(12);
mySingleList1.addLast(23);
mySingleList1.addLast(45);
mySingleList1.addLast(90);
mySingleList1.display();
MySingleList mySingleList2 = new MySingleList();
mySingleList2.addLast(5);
mySingleList2.addLast(11);
mySingleList2.addLast(22);
mySingleList2.addLast(25);
mySingleList2.display();
System.out.println("===========================");
MySingleList.ListNode head = mergeTwoLists(mySingleList1.head, mySingleList2.head);
mySingleList1.display(head);
}
//执行结果
8 12 23 45 90
5 11 22 25
===========================
5 8 11 12 22 23 25 45 90
用指定节点位置打印(4.2反转链表处),这里哪个对象调用display方法打印都可以,因为指定的是从拼接后新链表头结点位置开始打印。
4.6 链表的回文结构
- 对于一个链表,请设计一个时间复杂度为O(n),额外空间复杂度为O(1)的算法,判断其是否为回文结构。OJ链接
- 给定一个链表的头指针head,请返回一个boolean值,代表其是否为回文结构。保证链表长度小于等于900。测试样例:
思路:
- 找到中间节点。利用快慢指针,快指针走两步,慢指针走一步。
- 反转。反转中间节点后的半部分链表,利用头插法。
- 从前往后,从后往前。比较节点的val值。
怎么反转中间节点后半部分的链表?
- 在中间节点后边定义cur和curNext 两个引用。cur代表当前需要反转的节点,因为反转需要修改cur的next值,又因为为单向链表,
- 防止修改后的cur找不到下一个需要反转的节点, 所以要用curNext先记录一下cur.next原来的值(也就是下一个节点)。然后开始移动slow,cur,curNext
这里注意一定是slow从后往前走,因为反转后slow一定指向最后一个节点,而fast可能不是指向最后一个节点。即偶数节点情况下fast指向最后节点的后面null。如上图所示
代码可以先完成处理奇数节点的情况,然后再完善处理偶数的情况。
处理偶数节点情况的 if 语句,一定是在判断两个val值相等if语句的后面。
public boolean chkPalindrome() {
if (head == null) {
return true;
}
if (head.next == null) {
return true;
}
ListNode fast = head;
ListNode slow = head;
//1.找到中间节点 利用快慢指针
while (fast!= null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
//2. 反转slow后面的链表 利用头插法
ListNode cur = slow.next;
while (cur != null) {
ListNode curNext = cur.next;
cur.next = slow;
slow = cur;
cur = curNext;
}
//3.从后往前 从前往后
while (head != slow) {
if (head.val != slow.val) {
return false;
}
//偶数节点情况的判断
if (head.next == slow) {
return true;
}
head = head.next;
slow = slow.next;
}
return true;
}
4.7 链表分割
现有一链表的头指针 ListNode* pHead,给一定值x,编写一段代码将所有小于x的结点排在其余结点之前,且不能改变原来的数据顺序,返回重新排列后的链表的头指针。OJ链接
- 思路:遍历原来的链表,(假设有两段)第一段放小于x的节点,第二段放大于或等于x的节点,线段中的节点采用尾插连接(用的还是原来的节点,没有实例化节点,没有创建新的链表,只是对链表本身的修改),不会改变原来的数据结构,最后两个向量串起来,返回重新排列后的链表头节点。
- 注意重新排列后的链表尾结点next一定要置为null,因为尾结点可能不是原来链表的尾结点,如果不置空会进入死循环。
- 两段不一定都同时存在。即有可能存在全部小于x或全部大于等于x的数据。可以能出现空指针异常,一定要判断。
be 和 ae 永远指向的是,最后一个节点。
public ListNode partition(ListNode head, int x) {
if (head == null) {
return null;
}
ListNode cur = head;
ListNode bs = null;
ListNode be = null;
ListNode as = null;
ListNode ae = null;
while (cur != null) {
if (cur.val < x) {
// 判断小于 x 是否是第一次插入,用尾插法
if (bs == null) {
//插入第一个节点
bs = cur;
be = cur;
} else {
be.next = cur;
//be永远指向的是 最后一个节点
be = be.next;
}
} else {
// 判断大于 x 是否是第一次插入,用尾插法
if (as == null) {
//插入第一个节点
as = cur;
ae = cur;
} else {
ae.next = cur;
//ae永远指向的是 最后一个节点
ae = ae.next;
}
}
cur = cur.next;
}
//开始串起来
//可能存在全部小于x 或全部大于等于x的数据
if (bs == null) {//包含第一段为空和链表为空的情况
return as;
}
//第一段不为空
be.next = as;//走到这里be(第一段)一定不为空
if (ae != null) {//这里需要判读一下第二段是否为空
ae.next = null;
}
return bs;
}
4.8 相交链表(常考)
输入两个链表,找出它们的第一个公共结点。OJ链接
注意,相交的链表是Y 形状,不是X形状,因为节点地址只有一个,都指向了相交的节点
思路:
- 先遍历链表,两个链表长度相同,可以同时走,走到相交的节点(相遇),返回相交节点。
- 先遍历链表,两个链表长度不同,最长的链表先走相差的步数(相差的步数一定是在相交之前的节点个数),然后同时走一步一步走,走到相交的节点(相遇),返回相交节点。
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode pl = headA;
ListNode ps = headB;
int lenA = 0;
int lenB = 0;
//1. 遍历两个链表计算长度
while (pl != null) {
lenA++;
pl = pl.next;
}
while (ps != null) {
lenB++;
ps = ps.next;
}
// 走到这里,如果没有进入下面if语句
// pl和ps此时都为null,需要重新修正指向
pl = headA;
ps = headB;
//相差的步数
int len = lenA - lenB;
if (len < 0) {
//修正一下 pl和ps的指向
pl = headB;
ps = headA;
len = lenB - lenA;
}
//代码执行到这里,len一定是正数
//pl一定指向最长的链表,ps一定指向最短的链表
//2. 让最长链表先走差值步
while (len != 0) {
pl = pl.next;
len--;
}
//3. 同时开始一步一步走 4. 相遇就是相遇点
//包含pl和ps都为null的情况
while (pl != ps) {
pl = pl.next;
ps = ps.next;
}
//没有相交的情况
if (pl == null) {
return null;
}
return pl; //包含了没有相交的情况。
}
4.9 环形链表(重点)
给定一个链表,判断链表中是否有环。OJ链接 贝壳面试题
思路:
- 利用快慢指针,即慢指针一次走一步,快指针一次走两步,两个指针从链表起始位置同时开始运行,
- 如果链表带环则一定会在环中相遇,否则快指针率先走到链表的末尾。
判断环的问题,其实就是数学上的"追击问题"(可以理解为)
public boolean hasCycle(ListNode head) {
ListNode fast = head;
ListNode slow = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
//有环一定会相遇
if (fast == slow) {
return true;
}
}
return false;
}
【 问题】为什么快指针每次走两步,慢指针走一步可以?
- 假设链表带环,两个指针最后都会进入环,快指针先进环,慢指针后进环。当慢指针刚进环时,可能就和快指针相遇了,最差情况下两个指针之间的距离刚好就是环的长度(相差一个环)。
- 此时,两个指针每移动一次,之间的距离就缩小一步,不会出现每次刚好是套圈的情况,因此:在慢指针走完一圈之前,快指针一定是能追上慢指针的,即相遇。
- 快指针一次走3步,走4步,...n步行吗?可能永远不相遇也可能相遇
4.10 环形链表II(重点)
给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 NULL。OJ链接
思路:
让一个指针从链表起始位置开始遍历链表,同时让另一个指针从判断环时相遇点的位置开始绕环运行,两个指针 都是每次均走一步,最终相遇的地方就是入口点。
(N-1)C + Y = X
- 其中N值越大,表明环越小,fast走的圈数越多(此时的slow走的还是X的路程,还没有入环);
- N值越小,表明环越大,fast走的圈数越少,N的值最小是1,即fast最少走1圈,也就是上图左边的情况。
【注意】
1、当慢指针进入环时,快指针可能已经在环中绕了n圈了,n至少为1。
因为︰快指针先进环走到相遇点的位置,最后又在相遇点的位置与慢指针相遇
2、慢指针进环之后,快指针一定会在慢指针走一圈之内追上慢指针
因为︰慢指针进环后,快慢指针之间的距离最多就是环的长度,而两个指针在移动时,每次它们之间的距离都缩减一步,因此在慢指针移动一圈之前快指针肯定是可以追上慢指针的。
而快指针速度是慢指针的两倍,因此有如下关系是:
2*(X+(C-Y))= X+NC+(C-Y)
X+X+C+C-Y-Y =X+NC+C-Y
X+C-Y=NC
(N-1)C +Y = X(n为1,2,3,4....….n的大小取决于环的大小,环越小n越大)
极端情况下,假设n = 1,此时:X = Y
即:一个指针从链表起始位置运行,一个指针从相遇点位置绕环,每次都走一 步,两个指针最终会在入口点的位置相遇。
public ListNode detectCycle(ListNode head) {
ListNode fast = head;
ListNode slow = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
//有环一定会相遇
if (fast == slow) {
break;
}
}
if (fast == null || fast.next == null) {
return null;
}
//代码走到这一定有环
slow = head;
while (slow != fast) {
fast = fast.next;
slow = slow.next;
}
return fast;
}
【快慢指针两种用法】
- 走差值步,然后同时开始一步一步走。不同位置同时出发,时间相同(while循环),速度相同,路程永远相差差值步。有环也永远相差差值步。例题,4.4、4.8
- 快指针一次走两步,慢指针一次走一步。同一位置同时出发(头节点处),时间相同(while循环),速度相差二倍,路程也相差二倍。有环快指针一定能追上慢指针。例题4.3、4.6、4.9、4.10
链表刷题链接:Leetcode OJ链接 + 牛客 OJ链接
好啦Y(^o^)Y,本节内容到此就结束了。下一篇内容一定会火速更新!!!
后续还会持续更新数据结构与算法方面的内容,还请大家多多关注本up,第一时间获取新鲜的知识。
如果觉得文章不错,别忘了一键三连哟!