单链表-SinglyLinkedList
什么是链表?
链表是一种物理存储单元上非连续、非顺序的线性表存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。相比数组,链表是一种稍微复杂一点的数据结构。从底层的存储结构上来看一看他们的区别:
从图中我们看到,数组需要一块连续的内存空间来存储,对内存的要求比较高。如果我们申请一个 100MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 100MB,仍然会申请失败。
而链表恰恰相反,它并不需要一块连续的内存空间,而是通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是 100MB 大小的链表是不会有问题的。
在定义中有几个关键字
- 第一是线性表(Linear List): 线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。它的数据结构有以下几个特点:
㈠ 存在唯一个没有前驱的(头)数据元素;
㈡ 存在唯一个没有后继的(尾)数据元素;
㈢ 存在头和尾元素。
数组,链表、队列、栈等都是线性表结构。
而与它相对立的概念是非线性表,如树、堆、图等。在非线性表中,数据之间并不是简单的前后关系,如图所示:
- 第二个是非连续的内存空间:计算机在分配内存空间的时候都会对应分配一个内存地址,连续的内存空间对应的是连续的内存地址(例如:数组),计算机是通过访问内存地址会获取内存中的值。而链表的内存块地址可以是连续的也可以是零散不连续的。
- 第三个是指针(又叫引用):链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址,我们把这个记录下个结点地址的指针叫作后继指针 next。如果是双向链表,存储前一个结点地址的指针叫前驱指针 prev。
- 第四个是结点:链表中的数据是以结点来表示的,每个结点由元素和引用(C语言中叫指针)构成。
链表为什么这么受欢迎?有哪些利弊?
链表是一种动态的数据结构,其操作需要通过指针进行。链表内存的分配不是在创建链表时一次性完成,而是每添加一个结点就分配一次内存。由于没有闲置内存,所以链表的空间效率比数组更高。
我们知道,在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移工作,时间复杂度是 O(n)。而在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移其他结点,因为链表的存储空间本身就不是连续的。所以,链表的插入和删除操作,我们只需要考虑相邻结点的指针指向的改变,所以对应的时间复杂度是 O(1)。
为了方便理解,我画了一张图:
但是,有利就有弊。链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。
你可以把链表想象成一个队伍,队伍中的每个人都只知道自己后面的人是谁,所以当我们希望知道排在第 k 位的人是谁的时候,我们就需要从第一个人开始,一个一个地往下数。所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。
常见的链表
单向链表,双向链表,循环链表(单向循环链表和双向循环链表)
单链表
单链表是一种链式存取的数据结构,它用一组地址任意(内存可连续可不连续)的存储单元(内存块)存放 线性表 中的数据元素。
从单链表图中,我们可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。
我们习惯性地把第一个结点叫作头结点(header 或 first),把最后一个结点叫作尾结点(tailer 或 last)。其中,头结点用来记录链表的基地址(base_address)。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址null,表示这是链表上最后一个结点。
该如何轻松的写出正确的链表代码呢?
首先给大家明确一点,就算链表的基础你全部都掌握了,写链表代码时依然会感觉到很费劲,想要写好链表代码并不是容易的事儿,尤其是那些复杂的链表操作,比如链表反转、有序链表合并等,写的时候非常容易出错。
究竟怎样才能轻松地写出正确的链表代码呢?
其实只要愿意投入时间大多数人都是可以学会的。比如说花上一个周末或者一整天的时间,就去写链表反转这一个代码,做到敲一遍敲一遍再敲一遍,你绝对很轻松的就能写出链表代码。我也根据自己的学习经历和亲身实践,总结了几个写链表代码的技巧分享给大家,希望大家都能够轻松拿下链表代码!
技巧一:理解指针或引用的含义
事实上,看懂链表的结构并不是很难,但是一旦把它和指针混在一起,就很容易让人摸不着头脑。所以,要想写对链表代码,首先就要理解好指针。
我们知道,有些语言有“指针”的概念,比如 C 语言;有些语言没有指针,取而代之的是“引用”,比如 Java。不管是“指针”还是“引用”,实际上,它们的意思都是一样的,都是存储所指对象的内存地址。
其实,将某个变量赋值给指针(或引用),实际上就是将这个变量的地址赋值给指针(或引用),或者反过来说,指针(或引用)中存储了这个变量的内存地址,指向了这个变量,通过指针(或引用)就能找到这个变量。
在写链表代码的时候,经常会有这样的代码:
p->next=q
,这行代码的意思是 p 结点中的 next 指针存储了 q 结点的内存地址。
还有一个更复杂的,也是写链表代码经常会用到的:
p->next=p->next->next
,这行代码表示,p 结点的 next 指针存储了 p 结点的下下一个结点的内存地址。
还不理解?木关系,正所谓有图有真相,我们今天就借用画图分析法来帮助你理解,通过画图分析可以使思路更清晰。
技巧二:警惕指针丢失和内存泄漏
初写链表者会有这样的感觉:写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。所以,我们在写的时候,一定注意不要弄丢了指针。
指针往往都是怎么弄丢的呢?我们拿单链表的插入操作为例来分析一下。
如上图所示,我们希望在结点 a 和相邻的结点 b 之间插入结点 x,假设当前指针 p 指向结点 a。如果我们将代码实现变成下面这个样子,就会发生指针丢失和内存泄露。
p->next = x; // 将p的next指针指向x结点
x->next = p->next; // 将x的结点的next指针指向b结点
初学者经常会在这儿犯错。p->next 指针在完成第一步操作之后,已经不再指向结点 b 了,而是指向结点 x。第 2 行代码相当于将 x 赋值给 x->next,自己指向自己。因此,整个链表也就断成了两半,从结点 b 往后的所有结点都无法访问到了。
对于有些语言来说,比如 C 语言,内存管理是由程序员负责的,如果没有手动释放结点对应的内存空间,就会产生内存泄露。所以,我们插入结点时,一定要注意操作的顺序,要先将结点 x 的 next 指针指向结点 b,再把结点 a 的 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。所以,对于刚刚的插入代码,我们只需要把第 1 行和第 2 行代码的顺序颠倒一下就可以了。
同理,删除链表结点时,也一定要记得手动释放内存空间,否则,也会出现内存泄漏的问题。 当然,对于像 Java 这种虚拟机自动管理内存的编程语言来说,其实不需要考虑这么多,但是为了规范编程,养成编写代码最优化的习惯还是比较有意义的。
技巧三:利用哨兵简化实现难度
回顾一下单链表的插入和删除操作。如果我们在结点 p 后面插入一个新的结点,只需要下面两行代码就可以搞定。
new_node->next = p->next;// 让新结点的next指向原来p结点的next
p->next = new_node;// 把新结点作为原来p结点的next
但是,当我们要向一个空链表中插入第一个结点,刚刚的逻辑就不能用了。我们需要进行下面这样的特殊处理,用first标识链表的头结点,last标识链表的尾结点。所以,从这段代码,我们可以发现,对于单链表的插入操作,第一个结点和其他结点的插入逻辑是不一样的,需要做头结点是否为空的判断:
if(first == null){
first = new_node;
}
再看删除结点,如果删除结点P的后继结点,只需要一行代码:
p->next = p->next->next;
但是如果为空链表时,删除单表的最后一个结点,也需要做特殊处理:
if(first->next == null){
first = null;
}
从前面的一分析我们可以看出,针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。为了解决这个问题,我们引入"哨兵结点"。哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑(不存储数据元素,只存储单链表第一个结点的地址)。
还记得如何表示一个空链表吗?first = null 表示链表中没有结点了。其中 first表示头结点,它的next指向链表中的第一个结点。
引入了哨兵结点之后,这个时候first 结点会一直指向这个哨兵结点,也把这种带有哨兵结点的链表叫做带头链表。
因为哨兵结点不存储数据并且一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑。
技巧四:重点留意边界条件处理
软件开发中,代码在一些边界或者异常情况下最容易产生 BUG。链表代码也不例外。要实现没有 BUG 的链表代码,一定要在编写的过程中以及编写完成之后,检查边界条件是否考虑全面,以及代码在边界条件下是否能正确运行。
经常用来检查链表代码是否正确的边界条件有:
- 如果链表为空时,代码是否能正常工作?
- 如果链表只包含一个结点时,代码是否能正常工作?
- 如果链表只包含两个结点时,代码是否能正常工作?
- 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
当你写完链表代码之后,除了看下你写的代码在正常的情况下能否工作,还要看下在上面我列举的几个边界条件下,代码仍然能否正确工作。如果这些边界条件下都没有问题,那基本上可以认为没有问题了。
实际上,不光光是写链表代码,在写任何代码时,也不能只是实现业务正常情况下的功能就好了,一定要多想想,我们的代码在运行的时候可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对,这样写出来的代码才够健壮。
技巧五:举例画图,辅助思考
对于稍微复杂的链表操作,比如接下来我们要提到的单链表反转,指针一会儿指这,一会儿指那,一会儿就被绕晕了。总感觉脑容量不够,想不清楚。所以这个时候就要使用大招了,举例法和画图法。
你可以找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。比如往单链表中插入一个数据这样一个操作,我一般都是把各种情况都举一个例子,画出插入前和插入后的链表变化,也就相当于写出来伪代码。看图写代码,是不是就简单多啦?而且,当我们写完代码之后,也可以举几个例子,画在纸上,照着代码走一遍,很容易就能发现代码中的 BUG。
技巧六:多写多练
如果你已经理解并掌握了我们前面所讨论的方法,但是手写链表代码还是会出现各种各样的错误,也不要着急。因为我们最开始学的时候这种状况很普遍。要想链表代码写到熟练,其实也没有什么技巧,就是把常见的链表操作都自己多写几遍,出问题就一点一点调试,正所谓熟能生巧嘛。
下面是链表常见的基本操作,只要把这些操作都能写熟练,保证你再也不怕写链表代码。
单链表的操作
在Java
中,我们可以将单链表定义成一个类,单链表的基本操作即是类的方法操作,而结点就是 这个类内部的一个个实例化的对象
,每个对象中都有“元素值(element)”和存储“下一结点的内存地址(next)”这两个属性。
其中,“下一结点的内存地址”属性中存储的是下一个对象的引用(C语言中叫指针,即下一个结点在内存中的地址),这样一个个结点对象连在一起就成为了单链表。
单链表有以下基本操作
在准备写单链表代码之前,咱们先做一些准备工作,也算不上是准备工作,其实就是搭建好一个链表类的基础"框架"。
我们知道,在Java中,我们可以将单链表定义成一个类,单链表的基本操作即是类的方法操作,这个类需要有一个属性表示链表的头(first);一个属性表示链表的尾(last);一个属性记录链表的长度(size);还需要一个属性记录链表在结构上被修改的次数(modCount)。而结点就是这个类内部的一个个实例化的对象,所以结点类须是链表类的内部类,此结点类对象应只属于该链表类对象,所以一般都把结点类设置成静态私有(private static)。
结点类的每个对象中要有 “元素值(element)” 和存储 “下一个结点的内存地址(next)”这两个属性。所以,得出链表的基本框架:
package com.umpay.common.util.code;
/**
* 单链表:是一种链式存取的数据结构,它用一组地址任意(内存可以不连续)的存储单元存放线性表中的数据元素。
* Created by admin on 2019/10
* @see java.util.LinkedList java.util.ArrayList
*/
public class SinglyLinkedList<E> {
/* transient 让对象序列化时忽略该字段 */
private transient Node<E> first;// 链表的头结点
private transient Node<E> last;// 链表的尾结点
/**
* 为什么要加modCount:
* 在多线程中,如果有一个线程正在对这个单链表进行遍历操作(主要用在遍历上,防止正在遍历的集合被修改),
* 假设这个时候有其他线程对这个 SingleLinkedList 的实例进行了一个add | remove等操作,改变了 SingleLinkedList 数据结构,
* 那么modCount就改变了,同样modCount != expectedModCount也就成立了,本次遍历有误,抛出异常。
*
* 在ArrayList、HashMap、LinkedList(都是线程不安全)中都可以找到modCount
*/
private transient int modCount = 0;// 记录链表在结构上被修改的次数
private transient int size = 0;// 链表的长度
/**
* 结点类
* @param <E>
*/
private static class Node<E>{
E element;// 结点元素,存放数据
Node<E> next;// 存储下一个结点的内存地址
/**
* 构造方法
* @param element 当前结点存放的元素值
* @param next 当前结点的下一个结点(在内存中的地址)
*/
public Node(E element, Node<E> next) {
this.element = element;
this.next = next;
}
}
}
1. 初始化单链表
初始化单链表即链表的构造方法里,new一个哨兵结点,让链表的头指向这个哨兵结点。
/**
* 初始化链表
*/
public SinglyLinkedList() {
this.first = new Node<E>(null, null);// 哨兵结点
this.last = first.next;// 即last = null
}
初始化链表之后的结果如下图:
2. 返回单链表元素的个数
这个就是看当前链表中结点的个数(除去哨兵结点),比较简单直接上代码:
/**
* 返回元素的个数
* @return the number of elements in this list.
*/
public int size() {
return size;
}
3. 向链表中插入一个指定的元素
向链表中添加元素分两种情况:
- 第一次向链表中插入元素,此时链表是只有一个哨兵结点的空链表:
插入后如下图:
由图可知,首次插入只需要让当前头结点的next指向新结点(的内存地址),然后将新的结点作为链表的尾结点,即:first.next = newNode; last = newNode;// 把新结点作为链表的尾结点
- 非首次向链表中插入元素:
插入后如下图:
所以,向链表插入新的元素,相当于将当前链表最后一个结点的next指向新结点(的内存地址),然后把新结点作为链表的尾结点,即:last.next = newNode; last = newNode;
所以,向链表中插入指定元素的完整代码:
/**
* 向链表中添加一个指定的元素
* @param element the specified element to append the end of this list
*/
public void add(E element){
// 把需要添加的元素实例化成一个结点对象
Node<E> newNode = new Node<E>(element, null);// 新结点的下一个结点未知,所以设置null
if(last == null) {// 第一次向链表中添加元素,由于引用了哨兵结点(空的头结点),所以这里不用做头结点是否为空的判断
first.next = newNode;
last = newNode;// 把新结点作为链表的尾结点
}else {// 非首次插入元素
last.next = newNode;// 将当前链表最后一个结点的next指向新结点的内存地址
last = newNode;// 把新结点作为链表的尾结点
}
size++;// 链表长度+1
modCount++;// 链表结构修改次数+1
}
4. 向链表的头部插入指定的元素
向链表头部插入新的元素,原理图如下:
如果在结点p后面插入一个新的结点只需以下两行代码:
new_node->next = p->next;
p->next = new_node;
注意这两行代码顺序不能写反,否则会造成指针丢失导致链表断裂!
所以,向链头插入元素的完整代码:
/**
* 在链表的头部添加指定元素
*
* @param element
*/
public void addAtFirst(E element) {
// 把需要添加的元素实例化成一个结点对象
Node<E> newNode = new Node<E>(element, null);
// 若插入前是空表(由于引用了哨兵结点 这里不用做头结点是否为空的判断)
if(last == null) {// 因为是往链头插入,所以第一个插入的元素是尾结点元素
last = newNode;
}
newNode.next = first.next;// 让新结点的next指向原来头结点的next
first.next = newNode;// 把新结点作为新的头结点的next
size++;
modCount++;
}
5. 向链表的尾部插入指定的元素
向链表的尾部添加元素也分为两种情况:
- 向空链表的尾部添加:
同第一次向链表中插入元素,即:first.next = newNode; last = newNode;
- 非空链表的尾部添加:
插入原理图如下所示
原尾结点的next指向新结点,将新结点作为尾结点,即:last.next = newNode; last = newNode;
所以,向链表尾部插入元素的完整代码:
/**
* 在链表的尾部添加指定元素
* @param element
*/
public void addAtLast(E element) {
// 把需要添加的元素实例化成一个结点对象
Node<E> newNode = new Node<E>(element, null);
// 如果是空链表
if(last == null) {
first.next = newNode;
last = newNode;
}else {
last.next = newNode;// 原尾结点的next指向新结点
last = newNode;// 将新结点作为尾结点
}
size++;
modCount++;
}
6. 向链表中指定的位置插入指定的元素
往链表的指定位置添加元素,分三种情况:
- 添加至头部(即addAtFirst)
- 添加至尾部(即addAtLast)
- 添加至中间任意位置
首先需要先找到下标为location的结点的前一个结点,所以需要从下标为0的结点(哨兵的next)开始遍历找到第location个位置的前一个结点p。
寻找location位置的结点代码:Node<E> p = first; for (int i = 0; i < location; i++) { p = p.next; }
所以,向链表中指定位置插入指定的元素的完整代码为:
/**
* 往链表的指定位置添加元素
*
* 分三种情况:1.添加至头部;2.添加至尾部;3.添加至中间任意位置
* @param location 下表从0开始,链表第location个位置
* @param element 指定添加的元素
*/
public void add(int location, E element) {
if(location < 0 || location > size) {// 下标越界
throw new IndexOutOfBoundsException("Index: " + location + ", Size: " + size);
}
if(location == 0) {// 添加至头部
addAtFirst(element);
}else if(location == size) {// 添加至尾部
addAtLast(element);
}else {// 添加至中间location位置
Node<E> newNode = new Node<E>(element, null);
Node<E> p = first;
for (int i = 0; i < location; i++) {// 首先需要从头开始找到location位置的前一个结点
p = p.next;
}
// 交换原来location位置元素左右指针的指向,以下两行代码不能写反,否则指针丢失导致链表断裂
newNode.next = p.next;
p.next = newNode;
size++;
modCount++;
}
}
注意:要注意防止链表下标越界问题的发生。
7. 返回单链表中指定位置的元素的值
此操作属于单链表获取元素的操作,首先需要知道指定下标index对应的结点是什么,那么就需要从链头开始遍历链表,获取到index位置对应的结点:
Node<E> current = first.next;
for (int i = 0;i < index && current != null; i++){
current = current.next;
}
所以,获取指定位置的元素完整代码为:
/**
* 获取指定位置的元素(下标从0开始)
*
* @param index index of element to return
* @return the element at the specified position in this list
*/
public E get(int index) {
return node(index).element;
}
/**
* 获取指定位置的结点
*
* @param index 结点在链表中的位置
* @return the node at the index position in this list
*/
private Node<E> node(int index){
if(index < 0 || index >= size){// 因为下标从0开始,实际下标为链表的长度减一
// 索引超出线性表范围
throw new IndexOutOfBoundsException("index:" + index + ",size:" + size);
}
Node<E> current = first.next;
for (int i = 0;i < index && current != null; i++){
current = current.next;
}
return current;
}
注意:要注意防止链表下标越界问题的发生。
8. 返回单链表中第一个与指定值相同的元素的位置
/**
* 返回指定元素在链表中第一个出现的位置
*
* @param element
* @return
*/
public int locate(E element){
Node<E> p = first.next;
for (int i = 0; i < size && p != null; i++, p = p.next){
if(p.element.equals(element)){
return i;
}
}
return -1;// 该元素在链表中不存在
}
9. 移除指定位置(指定下标)的元素
删除链表元素的思想:
第一步,先找到需要移除的结点;
第二步,改变原有指针的指向,即:p->next = p->next->next
第三步,清空被删除结点的引用和元素值,即:next=null,element=null。
/**
* 移除指定位置的元素
* Removes the element at the specified position in this list.
*
* @param index the index of the element to removed.
* @return the element previously at the specified position.
* @throws IndexOutOfBoundsException if the specified index is out of range (<tt>index < 0 || index >= size()</tt>).
*/
public E remove(int index){
return remove(node(index));
}
/**
* @desc 删除指定的结点元素
* @param node
* @return
*/
private E remove(Node<E> node) {
if(node == null) {
throw new NoSuchElementException();
}
E result = node.element;
if (node == first) {// 考虑到元素出现在第一个结点位置时
first.next = first.next.next;
} else {
// 先找到需要移除的结点
Node<E> p = first;
while (p != null && (p.next != node && p != node)) {
p = p.next;
}
// 删除操作-即改变原有指针的指向
p.next = p.next.next;
// 释放原有结点的引用和值占用的空间
node.next = null;
node.element = null;
}
size--;
modCount++;
return result;
}
10. 循环删除指定的元素
/**
* 循环删除指定的元素
*
* @param element
* @return
*/
public void remove(E element) {
if (size <= 0) {
throw new IndexOutOfBoundsException("Size: " + size);
}
if(element == null){
throw new NullPointerException("need remove element is not null");
}
int i = 0;
Node<E> p = first.next;
while (p != null) {
if (p.element.equals(element)) {
remove(i);
i = 0;// 移除一个元素后,从头开始再查找
p = first;
}else{
p = p.next;
i++;
}
}
}
11. 判断单链表是否为空
/**
* 判断线性表是否为空
*/
public boolean isEmpty(){
return size == 0;
}
12. 打印输出单链表所有元素
/**
* 打印所有元素
*
* 也可以用递归的方式实现
*/
public void printAll() {
if(first == null || last == null){
// 空链表
return;
}
Node<E> p = first.next;// 去除哨兵结点
while (p != null) {
System.out.print(p.element + " ");
p = p.next;
}
System.out.println();
}
13. 清空单链表
/**
* 清空链表
* Removes all of the elements from this list.
* The list will be empty after this call returns.
*/
public void clear() {
for (Node<E> x = first; x != null;) {
Node<E> next = x.next;
x.element = null;
x.next = null;
x = next;
}
first = last = null;
size = 0;
modCount++;
}
14. 重写单链表的toString方法
/**
* 重写toString方法
*
* @return
*/
public String toString() {
if(isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder("SinglyLinkedList [");
Node<E> p;
if(first.element != null)
p = first;
else
p = first.next;
// 一定要初始化p = first.next,否则报空指针
while(p != null && p.element != null) {
sb.append(p.element.toString()).append(", ");
p = p.next;
}
if(sb.length() > 18) {
return sb.delete(sb.length() - 2, sb.length()).append("]").toString();
}else {
return sb.append("]").toString();
}
}
15. 单链表反转
链表的转置,即把链表从头到尾反转一下,原来的头结点变为尾结点,原来的尾结点变成头结点,中间的每一个结点的指向都发生反转,原理图如下图所示:
接下来我带你们一步一步的分析反转的原理。
首先我们定义p和q两个指针 配合使两个结点的指向反向工作,同时用指针r记录剩下的链表。我们保留哨兵结点first,最后使first指向反转之后的链表。初始化p结点为下标=0的结点(即first->next) ,分析流程图如下:
分析可知,循环结束条件: q == null
所以,链表转置的完整代码为:
/**
* 单链表的反转
*/
public void reverse(){
if(first.next == null) {// 空链表
throw new RuntimeException("empty linked list");
}
Node<E> p = first.next;// 初始化p结点为链表index为0的结点,该结点反转后将成为最后一个结点
Node<E> q = p.next;
first.next = null;// 截断哨兵结点
p.next = null;// 链表的最后一个结点指向null
while(q != null){// 循环结束条件q == null
Node<E> r = q.next;
q.next = p;
p = q;
q = r;
}
// 原哨兵结点first指向整个链表
first.next = p;
modCount++;
}
还有一种简单的方法,思想就是新建一个链表,把原链表中的数据逆序插入到新链表,完整代码如下:
/**
* 单链表的反转
* 逆序插入的方法见:addReverse(E element)
*/
public void convert() {
Node<E> p = first;
Node<E> q = new Node<E>(null, null);
while (p != null && p.next != null) {
Node<E> newNode = new Node<E>(p.next.element, null);
newNode.next = q.next;
q.next = newNode;
p = p.next;
}
first = q;
modCount++;
}
16. 单链表环检测以及找出入环点
环检测问题的由来1
1.单链表有环,是指单链表中某个结点的next指针域指向的是链表中在它之前的某一个结点,这样在链表的尾部形成一个环形结构。
一般我们采取快慢指针来判断链表是否有环。思路主要是:
(1).定义两个指针。fast和slow;
(2).fast和slow都从head开始往后走。顾名思义,fast走得快一点,每次走两步;slow走得慢一点,每次走一步;
(3).没有环的情况下,fast肯定率先走到尾结点;
(4).有环的情况下,fast先入环,slow后入环。因为fast比slow每次都多走一步,所以最终在某个地方会相遇(就像跑800m的时候,A跑得特别快,B跑得特别慢;A最后在某个地方又追上了B,超了B一圈)。
链表的环检测,即检测单链表中是否存在环。首先,链表检测环的方法有很多,但是最优最巧妙的方法是 使用快慢指针(在java里就是两个对象引用) 同时指向这个链表的头结点,然后开始一个大循环,在循环体中,慢指针每次向下移动一个结点(步长为1),快指针每次向下移动两个结点(步长为2),然后比较两个指针指向的结点是否相同。如果相同则链表有环,如果不同则继续下一次循环。
例如链表A->B->C->D->B->C->D,两个指针最初都指向结点A,进入第一轮循环,指针1移动到了结点B,指针2移动到了C。第二轮循环,指针1移动到了结点C,指针2移动到了结点B。第三轮循环,指针1移动到了结点D,指针2移动到了结点D,此时两指针指向同一结点,判断出链表有环。 这种方式除了两个指针以外,没有使用任何额外存储空间,所以空间复杂度是O(1)。
此方法也可以用一个更生动的例子来形容:在一个环形跑道上,两个运动员在同一地点起跑,一个运动员速度快,一个运动员速度慢。当两人跑了一段时间,速度快的运动员必然会从速度慢的运动员身后再次追上并超过,原因很简单,因为跑道是环形的。
在单链表遍历结束前,快慢指针相遇,则存在环,如图:
环检测的完整代码为:
/**
* 环检测
* 判断链表是否有环,单向链表有环时,尾结点相同
*
* @return
*/
public boolean isLoop(Node<E> head) {
if (head.next == null)
return false;
Node<E> fast = head;// 快指针,步长2
Node<E> slow = head;// 慢指针,步长1
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (fast == slow)// 该链表有环
return true;
}
// return false;
return !(fast == null || fast.next == null);
}
2.链表的入环点,如图:
求解分析图为:
需求是我们要得出入口结点是哪一个,现在已知的是快慢指针的相遇结点,也就是12结点,我们来推导一下:
slow走x步的时候,fast走了2x步,其中在环内走了x步;
第一次相遇的时候slow还未走完一圈(最多走完一圈),我们可以想象成两个人跑800m,A速度是2m/s,B是1m/s。A跑完2圈、B跑完1圈的时候两个人相遇。这是最极端的情况,即两个人是从同一个起点(即圆形操场入口点)开始跑的。但是链表的环大多数情况下并不是从第一个结点就开始闭环(那就成循环链表了)。所以一般情况下,fast都会比slow先多走几步(多走了慢指针从头结点到环入口点的距离,也就是x),这样fast追上slow所用的时间又会少一点。那么相遇的时候,slow在环内走了s步,fast在环内走了x+2s步;假设相遇时fast已经走了n圈,那么有下面的等式:
s=x+2s-nc,即s = nc-x = nc-c+c-x = (n-1)c+c-x;
我们可以把(n-1)c+c-x理解为,fast先走完(n-1)圈,再走了c-x步。
如果上面的公式不明白,公式推导图如下(红线与绿线相抵消之后,刚好是环长c):
结论:由此我们可以知道,在相遇点的时候,我们再走x步就能又回到入口结点。那么快慢指针相遇点到环入口点的距离就是x,和链表头结点到入口点的距离是一样的!
所以,确定链表环入口点的完整代码为:
/**
* 找出链表环的入口
*
* @param head 链表的头结点
* @return 环入口结点
*/
public Node<E> findLoopPort(Node<E> head) {
if (head.next == null)
return null;
Node<E> fast = head;// 快指针,步长2
Node<E> slow = head;// 慢指针,步长1
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (slow == fast)
break;// 相遇就跳出循环
}
if (slow == null || fast.next == null) {
System.out.println("该链表没有环");
return null;
}
/**
* 已知slow是相遇点,相遇结点到环入口结点的距离和头结点到环入口结点的距离相等。
* 即:两个指针同时分别从相遇结点和头结点处出发,一步一步的走,两个指针相遇了说明走了相同的距离。
*/
Node<E> p1 = head;// 头结点
Node<E> p2 = slow;// 相遇结点 或者 p2 = fast
while (p1 != p2) {
p1 = p1.next;
p2 = p2.next;
}
return p1;
}
--------------------------------------------拓展----------------------------------------
3.求环结点个数
这个问题在我们会求相遇点后已经变得非常简单。我们让slow从相遇结点处继续走走走,又走回到相遇点就代表走完了一圈,即求得环长。代码如下:
/**
* 求环的长度,即环中结点个数
*
* @param head
* @return
*/
public int nodeNumOfLoop(Node<E> head) {
if (head.next == null)
return 0;
Node<E> fast = head;// 快指针,步长2
Node<E> slow = head;// 慢指针,步长1
int count = 0;// 计数环中结点的个数
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (slow == fast)
break;// 相遇就跳出循环
}
if (slow == null || fast.next == null) {
System.out.println("该链表没有环");
return 0;
}
Node<E> temp = slow;// 相遇结点 或者 p2 = fast
do {
slow = slow.next;
count++;
}while (temp != slow);
return count;
}
4.链表长度
看第2题的图可知总长就=c+x嘛!
完整代码如下:
/**
* 环入口结点距头结点的距离
*
* @param head
* @return
*/
public int entryNodeOfLoop(Node<E> head) {
Node<E> fast = head;// 快指针,步长2
Node<E> slow = head;// 慢指针,步长1
int count = 0;// 记录环入口结点距头结点的结点个数
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (slow == fast)
break;// 相遇就跳出循环
}
if (slow == null || fast.next == null) {
System.out.println("该链表没有环");
return 0;
}
Node<E> p1 = head;
Node<E> p2 = slow;
while(p1 != p2){
p1 = p1.next;
p2 = p2.next;
count++;
}
return count;
}
/**
* 求链表的长度
* @param head
* @return
*/
public int listLength(Node<E> head){
Node<E> p1 = head;
int length = 0;// 记录链表的长度
if(isLoop(head)){// 链表存在环
return entryNodeOfLoop(head) + nodeNumOfLoop(head);
}else{// 链表不存在环
while(p1 != null){
p1 = p1.next;
length++;
}
return length;
}
}
总结:
1.判断是否有环。转换成求相遇结点的问题,用快慢指针解决。相遇则有环,反之无环。
2.求入口结点。先求相遇结点,然后记住一个结论。头结点到入口结点的距离与相遇结点到入口结点的距离相同。
3.求环结点个数。求相遇结点,然后走一圈,计数。
4.求链表长度。转换成求相遇结点+求入口结点的问题。其中 x(根据入口结点求得)+ c(根据相遇结点求得)。
5.环检测空间复杂度O(1):因为除了两个指针以外,没有使用任何额外存储空间。
17. 两个有序的链表合并
这个比较简单,就是排序,实现方法有很多中(因为排序有很多中方法),直接上代码:
/**
* 合并链表
* 输入两个增序的链表,合并这两个链表并使新链表仍然增序
*
* @param head1
* @param head2
* @return
*/
public Node<Integer> mergeSortedLists(Node<Integer> head1, Node<Integer> head2){
// 两个链表一个或者两个都是null,两个链表只有一个结点
if(head1 == null || head1.next == null) {
return head2;
}else if(head2 == null || head2.next == null){
return head1;
}else{
Node<Integer> newHead;
Node<Integer> p = head1.next;
Node<Integer> q = head2.next;
Integer headValue1 = head1.element == null ? head1.next.element : head1.element;
Integer headValue2 = head2.element == null ? head2.next.element : head2.element;
if(headValue1 <= headValue2){
newHead = p;
p = p.next;
}else{
newHead = q;
q = q.next;
}
Node<Integer> r = newHead;
while(p!=null && q!=null){
if(p.element <= q.element){
r.next = p;
p = p.next;
}else{
r.next = q;
q = q.next;
}
r = r.next;
}
if(p != null){
r.next = p;
}
if(q != null){
r.next = q;
}
return newHead;
}
}
18. 查找链表的中间结点
采用快慢指针的方式查找单链表的中间结点,快指针一次走两步,慢指针一次走一步,当快指针走完时,慢指针刚好到达中间结点。
/**
* 求中间结点
*
* @return
*/
public Node<E> findMiddleNode() {
if (first == null || first.next == null)
return null;
Node<E> fast = first;// 快指针
Node<E> slow = first;// 慢指针
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
return slow;
}
/**
* 查找单链表的中间结点的元素
*
* @return
*/
public E searchMiddle() {
if(first == null || first.next == null)
return null;
Node<E> p = first;// 快指针,每次走两步
Node<E> q = first;// 慢指针,每次走一步
while (p != null && p.next != null) {
p = p.next.next;
q = q.next;
}
return q.element;
}
完整的单链表类代码
完整的单链表代码,包含每个操作的单元测试代码,由于本篇博客太长了,我就把完整的代码单列出来了。
代码链接:完整且详细的单链表代码
代码资源下载链接:完整且详细的单链表实现和测试代码
总结
我觉得,写链表代码是最考验逻辑思维能力的。因为,链表代码到处都是指针的操作、边界条件的处理,稍有不慎就容易产生 bug。链表代码写得好坏,可以看出一个人写代码是否够细心,考虑问题是否全面,思维是否缜密。所以,这也是很多面试官喜欢让人手写链表代码的原因。所以,这一节讲到的东西,你一定要自己写代码实现一下,才有效果。
[声明]:本文中的部分示意图和总结,是我从极客学院王争老师的数据结构与算法之美专栏中习得,请知悉。