详细且图文并茂的单链表学习教程


什么是链表?

链表是一种物理存储单元上非连续、非顺序的线性表存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。相比数组,链表是一种稍微复杂一点的数据结构。从底层的存储结构上来看一看他们的区别:
数组和链表的内存分布图从图中我们看到,数组需要一块连续的内存空间来存储,对内存的要求比较高。如果我们申请一个 100MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 100MB,仍然会申请失败。
而链表恰恰相反,它并不需要一块连续的内存空间,而是通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是 100MB 大小的链表是不会有问题的。

在定义中有几个关键字

  1. 第一是线性表(Linear List): 线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有两个方向。它的数据结构有以下几个特点:
    ㈠ 存在唯一个没有前驱的(头)数据元素;
    ㈡ 存在唯一个没有后继的(尾)数据元素;
    ㈢ 存在头和尾元素。
    数组,链表、队列、栈等都是线性表结构。
    线性表结构图而与它相对立的概念是非线性表,如树、堆、图等。在非线性表中,数据之间并不是简单的前后关系,如图所示:
    非线性表结构图
  2. 第二个是非连续的内存空间:计算机在分配内存空间的时候都会对应分配一个内存地址,连续的内存空间对应的是连续的内存地址(例如:数组),计算机是通过访问内存地址会获取内存中的值。而链表的内存块地址可以是连续的也可以是零散不连续的。
  3. 第三个是指针(又叫引用):链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址,我们把这个记录下个结点地址的指针叫作后继指针 next。如果是双向链表,存储前一个结点地址的指针叫前驱指针 prev
    前驱指针后继指针
  4. 第四个是结点:链表中的数据是以结点来表示的,每个结点由元素引用(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;

删除x结点图
但是如果为空链表时,删除单表的最后一个结点,也需要做特殊处理:

if(first->next == null){
	first = null;
}

从前面的一分析我们可以看出,针对链表的插入、删除操作,需要对插入第一个结点删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。为了解决这个问题,我们引入"哨兵结点"。哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑(不存储数据元素,只存储单链表第一个结点的地址)。
还记得如何表示一个空链表吗?first = null 表示链表中没有结点了。其中 first表示头结点,它的next指向链表中的第一个结点。

引入了哨兵结点之后,这个时候first 结点会一直指向这个哨兵结点,也把这种带有哨兵结点的链表叫做带头链表。
因为哨兵结点不存储数据并且一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑。
哨兵结点头带头链表

技巧四:重点留意边界条件处理

软件开发中,代码在一些边界或者异常情况下最容易产生 BUG。链表代码也不例外。要实现没有 BUG 的链表代码,一定要在编写的过程中以及编写完成之后,检查边界条件是否考虑全面,以及代码在边界条件下是否能正确运行。

经常用来检查链表代码是否正确的边界条件有:

  1. 如果链表为空时,代码是否能正常工作?
  2. 如果链表只包含一个结点时,代码是否能正常工作?
  3. 如果链表只包含两个结点时,代码是否能正常工作?
  4. 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?

当你写完链表代码之后,除了看下你写的代码在正常的情况下能否工作,还要看下在上面我列举的几个边界条件下,代码仍然能否正确工作。如果这些边界条件下都没有问题,那基本上可以认为没有问题了。

实际上,不光光是写链表代码,在写任何代码时,也不能只是实现业务正常情况下的功能就好了,一定要多想想,我们的代码在运行的时候可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对,这样写出来的代码才够健壮。

技巧五:举例画图,辅助思考

对于稍微复杂的链表操作,比如接下来我们要提到的单链表反转,指针一会儿指这,一会儿指那,一会儿就被绕晕了。总感觉脑容量不够,想不清楚。所以这个时候就要使用大招了,举例法和画图法。
你可以找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。比如往单链表中插入一个数据这样一个操作,我一般都是把各种情况都举一个例子,画出插入前和插入后的链表变化,也就相当于写出来伪代码。看图写代码,是不是就简单多啦?而且,当我们写完代码之后,也可以举几个例子,画在纸上,照着代码走一遍,很容易就能发现代码中的 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. 向链表中插入一个指定的元素

向链表中添加元素分两种情况:

  1. 第一次向链表中插入元素,此时链表是只有一个哨兵结点的空链表:
    插入后如下图:
    在这里插入图片描述
    由图可知,首次插入只需要让当前头结点的next指向新结点(的内存地址),然后将新的结点作为链表的尾结点,即:
    first.next = newNode;
    last = newNode;// 把新结点作为链表的尾结点
    
  2. 非首次向链表中插入元素:
    插入后如下图:
    在这里插入图片描述
    所以,向链表插入新的元素,相当于将当前链表最后一个结点的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. 向链表的尾部插入指定的元素

向链表的尾部添加元素也分为两种情况:

  1. 向空链表的尾部添加:
    同第一次向链表中插入元素,即:
    first.next = newNode;
    last = newNode;
    
  2. 非空链表的尾部添加:
    插入原理图如下所示
    在这里插入图片描述
    原尾结点的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. 向链表中指定的位置插入指定的元素

往链表的指定位置添加元素,分三种情况:

  1. 添加至头部(即addAtFirst)
  2. 添加至尾部(即addAtLast)
  3. 添加至中间任意位置
    首先需要先找到下标为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 &lt; 0 || index &gt;= 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。链表代码写得好坏,可以看出一个人写代码是否够细心,考虑问题是否全面,思维是否缜密。所以,这也是很多面试官喜欢让人手写链表代码的原因。所以,这一节讲到的东西,你一定要自己写代码实现一下,才有效果。

[声明]:本文中的部分示意图和总结,是我从极客学院王争老师的数据结构与算法之美专栏中习得,请知悉。


  1. 约瑟夫问题 ↩︎

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值