算法与数据结构 - 链表详解


前言

点赞再看,养成习惯!


引言

韩梅梅是个喜欢网上冲浪的八卦达人
今天韩梅梅在某博刷热搜的时候发现如下排序:

  1. 某啤酒品牌起诉吴某某
  2. 热剧且试夏天剧照
  3. 某学者喊话某网站要清醒
  4. 西瓜价格跌破5毛
  5. 某市新房成交量首次低于二手房

正当韩梅梅觉得今天的热搜都平平无奇的时候,一条信息突然挤上了热搜

  1. 某啤酒品牌起诉吴某某
  2. 热剧且试夏天剧照
  3. 热瓜,某当红演员与其妻子已离婚
  4. 某学者喊话某网站要清醒
  5. 西瓜价格跌破5毛
  6. 某市新房成交量首次低于二手房

韩梅梅点进热搜,发现是陈年老瓜,于是失望的关掉了手机并骂了句垃圾热搜。

聪明的小伙伴已经猜到了,我们今天的第一个任务就是简单的模拟一个热搜排行榜!

业务实现

1. 通过数组保存热搜信息

首先我们不考虑新增热搜的场景,用之前学过的数组类型做个实现:

还没有学习的小伙伴请参考:
算法与数据结构 - 数组详解

public class Demo {
    public static void main(String[] args) {
        String[] hotSearchArray = new String[10] ;
        // 没有更新的热搜信息
        hotSearchArray = arrayAdd(hotSearchArray,"某啤酒品牌起诉吴某某");
        hotSearchArray = arrayAdd(hotSearchArray,"热剧且试夏天剧照");
        hotSearchArray = arrayAdd(hotSearchArray,"某学者喊话某网站要清醒");
        hotSearchArray = arrayAdd(hotSearchArray,"西瓜价格跌破5毛");
        hotSearchArray = arrayAdd(hotSearchArray,"某市新房成交量首次低于二手房");
        Array2String(hotSearchArray);

    }
    // 格式化输出
    private static void Array2String(String[] hotSearchArray) {
        for (int i = 0; i < hotSearchArray.length; i++) {
            if (hotSearchArray[i] == null || hotSearchArray[i].equals("")){
                return ;
            }
            System.out.println((i+1)+"."+hotSearchArray[i]);
        }
    }

    // 数组插入方法
    private static String[] arrayAdd(String[] hotSearchArray, String str) {
        for (int i = 0; i < hotSearchArray.length; i++) {
            if (hotSearchArray[i] == null || hotSearchArray[i].equals("")){
                hotSearchArray[i] = str ;
                return hotSearchArray ;
            }
        }
        return hotSearchArray;
    }
}

运行结果:
在这里插入图片描述
那么为什么刚刚我说先不考虑新增热搜的场景呢?兄弟们考虑下如果新增热搜我们应该如何实现?我们来动手写下:

public class Demo {
    public static void main(String[] args) {
        String[] hotSearchArray = new String[10];
        // 没有更新的热搜信息
        hotSearchArray = arrayAdd(hotSearchArray, "某啤酒品牌起诉吴某某");
        hotSearchArray = arrayAdd(hotSearchArray, "热剧且试夏天剧照");
        hotSearchArray = arrayAdd(hotSearchArray, "某学者喊话某网站要清醒");
        hotSearchArray = arrayAdd(hotSearchArray, "西瓜价格跌破5毛");
        hotSearchArray = arrayAdd(hotSearchArray, "某市新房成交量首次低于二手房");
        // 在索引3的位置插入新热搜
        hotSearchArray = ArrayInsert(hotSearchArray, "热瓜,某当红演员与其妻子已离婚", 3);
        Array2String(hotSearchArray);

    }

    // 指定位置插入数据
    private static String[] ArrayInsert(String[] hotSearchArray, String str, int index) {
        assert index >= 1;
        String temp = "";
        for (int i = hotSearchArray.length - 1; i > index-1 ; i--) {
            hotSearchArray[i] = hotSearchArray[i - 1];
        }
        hotSearchArray[index-1] = str ;
        return hotSearchArray;
    }

    // 格式化输出
    private static void Array2String(String[] hotSearchArray) {
        for (int i = 0; i < hotSearchArray.length; i++) {
            if (hotSearchArray[i] == null || hotSearchArray[i].equals("")) {
                return;
            }
            System.out.println((i + 1) + "." + hotSearchArray[i]);
        }
    }

    // 数组插入方法
    private static String[] arrayAdd(String[] hotSearchArray, String str) {
        for (int i = 0; i < hotSearchArray.length; i++) {
            if (hotSearchArray[i] == null || hotSearchArray[i].equals("")) {
                hotSearchArray[i] = str;
                return hotSearchArray;
            }
        }
        return hotSearchArray;
    }
}

运行结果:
在这里插入图片描述
虽然我们完成了需求,但是当我们深思一下就会觉得,数组似乎并不适用与这个场景,为什么呢?

  1. 由于数组的内存空间是物理连续的,因此当我们想要插入新的元素的时候需要讲原有元素按照顺序后移,很浪费时间
  2. 如果数组空间满了,我们再插入新的元素需要创建新的数组才可以。

那么针对数组的劣势,我们有么有新的办法呢?


一、链表简介

1.1 什么是链表

链表和数组一样都属于线性表的一种。既然是线性表代表着链表也拥有着两种特性:

  1. 链表是有序序列,链表中存储的元素是按照固定顺序一个接一个排列的。(链表更加注重的是逻辑的有序而非物理空间的连续有序)。
  2. 链表用来存放的元素应该是具有相同特性的。

而相较于普通的线性表,链表又在结构上发生了变化:

  1. 链表以节点的方式进行数据存储
  2. 每一个节点不仅仅需要存放数据,还需要存放节点信息(包含下一节点地址,上一节点地址等)
  3. 链表的每一个节点之间并不是物理连续的

1.2 图话链表

我们此时有A,B,C,D四个元素需要按照顺序存储。
此时我们有一段如下图所示的内存空间可供我们使用:
在这里插入图片描述
通过观察我们可以发现,上图空间并没有很多连续的空闲空间可供我们使用,这也意味着之前我们学习的数组结构并不能应用在这里。那我们有什么办法可以保证A,B,C,D四个元素有序存储吗?让我们一步一步来。


第一步:
我们先什么都不考虑,将A,B,C,D四个元素插入到空闲的内存空间中:
在这里插入图片描述
此时我们已经成功地将元素插入到内存中,但是紧接着我们就发现了一个问题,我们还没有办法保证可以按照顺序从内存中找到各个元素。此时我们需要一种结构可以保证我们能够顺利的找到各个节点。怎么办呢?其实很简单,我们只需要将下一节点的地址信息保存在当前节点中即可。那么第二步我们就需要改造下节点的结构:
在这里插入图片描述

此时我们更近一步,已经可以通过当前节点寻找到下一节点地址,我们将现在的数据结构拉直了看就是下图所示:
在这里插入图片描述
上述流程就是一个简单链表的诞生过程。


二、手撕链表

接下来让我们通过手写一个简单链表来实现我们引言中的业务场景来加深我们对链表的理解。

2.1 节点编写

实现思路:

  1. 需要一个存放数据的data字段,该字段需要使用泛型来适配所有类型
  2. 需要一个存放下一节点信息的nextNode字段,用来表示节点与节点之间的顺序关系
  3. 我们还可以创建一个hasNext方法,用来快速判断当前节点是否有下一个节点。

源码实现:

/**
 * @Classname Node
 * @Description 普通节点
 * @Date 2022/5/12 15:45
 * @Created by 晓龙Oba
 */
public class Node<E> {
    /*节点数据*/
    private E data;
    /*下一节点信息*/
    private Node<E> nextNode;

    /*是否有下一个节点*/
    public Boolean hasNext(){
        return nextNode != null ;
    }

    /*构造函数*/
    public Node(E data, Node nextNode) {
        this.data = data;
        this.nextNode = nextNode;
    }

    public Node(E data) {
        this.data = data;
    }
    /*修改器与访问器*/
    public E getData() {
        return data;
    }

    public void setData(E data) {
        this.data = data;
    }

    public Node getNextNode() {
        return nextNode;
    }

    public void setNextNode(Node nextNode) {
        this.nextNode = nextNode;
    }
}

2.2 链表基础结构

实现思路:

  1. 我们应该设置一个headNode(头节点),该节点并没有任何业务含义,仅是为了让我们在空链表时能够很好的保持链表结构的一致性和帮助我们完成如插入着这样的链表常见操作。headNode属性可以在链表初始化时一起被初始化。
  2. 我们可以设置一个计数字段size帮助我们统计链表长度。

代码实现:

/**
 * @Classname SimpleLink
 * @Description 简单链表 不考虑线程安全问题
 * @Date 2022/5/12 16:42
 * @Created by 晓龙Oba
 */
public class SimpleLink<E> {
    private Node<E> headNode;
    private Integer size;

    /* 构造函数 初始化头节点与计数器*/
    public SimpleLink() {
        this.headNode = new Node<E>(null, null);
        size = 0 ;
    }

}

2.3 链表的插入

链表有两种插入方式,分别是头插法尾插法

2.3.1 头插法

头插法是指我们每次插入元素的时候是在元素头的位置进行插入,这种插入方法可以很好的减少我们去寻找链表尾部的工作。我们可以参考下图进行理解:
在这里插入图片描述
实现思路:

  1. 当头结点的nextNode为空时,直接将指针指向新的节点
  2. 当头结点的nextNode不为空时,将指针指向新的节点,并将之前nextNode指针指向的节点作为新节点的nextNode

代码实现:

    /*头插法*/
    public Boolean headInsert(Node<E> node) {
        if (headNode.hasNext()) {
            node.setNextNode(headNode.getNextNode());
            headNode.setNextNode(node);
        } else {
            headNode.setNextNode(node);
        }
        size++;
        return true;
    }

2.3.2 尾插法

尾插法就比较好理解了,因为尾插法就是按照我们逻辑中的将节点插入到最后一个节点之后。
实现思路:

  1. 当头结点的nextNode为空时,直接将指针指向新的节点
  2. 当头结点的nextNode不为空时,向下查找到最后一个节点,并将最后一个节点的nextNode指向新的节点
    在这里插入图片描述
    代码实现:
    /*尾插法*/
    public Boolean tailInsert(Node<E> node){
       if( !headNode.hasNext()){
           headNode.setNextNode(node);
           size ++ ;
           return true ;
       }
        Node last  =getLast(headNode);
        last.setNextNode(node);
        size++ ;
        return true ;
    }
    /*获取最后一个节点*/
    private Node getLast(Node<E> node) {
            if (node.hasNext()){
                node = getLast(node);
            }
            return node ;
    }

当然如果觉得每次插入都要遍历性能不好的话,我们还可以创建个尾指针节点,记录尾部位置。

代码实现:

	private Node<E> lastNode;
    /*尾插法*/
    public Boolean tailInsert(Node<E> node) {
        if (headNode.hasNext()){
            lastNode.setNextNode(node);
        }else{
            headNode.setNextNode(node);
        }
        lastNode = node ;
        size++ ;
        return  true ;
    }

2.4 链表的查询

后面我们的业务实现都是基于尾插!

2.4.1 遍历

链表的遍历很简单,我们只需要一个节点接一个节点的去获取就好。
代码实现:

    /*遍历*/
    public void getList(){
        Node mark = headNode.getNextNode() ;
        Integer index = 1 ;
        while (mark.hasNext()){
            System.out.println(index++ +"."+mark.getData());
            mark = mark.getNextNode() ;
        }
        System.out.println(index +"."+mark.getData());
    }

2.4.2 运行结果

当我们实现了链表的遍历后,就可以写个测试代码初步看下我们的成果了:

public class Demo {
    public static void main(String[] args) throws Exception {
        SimpleLink<String> SimpleLink = new SimpleLink<>();
        Node<String> nodeA = new Node<>("某啤酒品牌起诉吴某某");
        Node<String> nodeB = new Node<>("热剧且试夏天剧照");
        Node<String> nodeC = new Node<>("某学者喊话某网站要清醒");
        Node<String> nodeD = new Node<>("西瓜价格跌破5毛");
        Node<String> nodeE = new Node<>("某市新房成交量首次低于二手房");
        SimpleLink.tailInsert(nodeA);
        SimpleLink.tailInsert(nodeB);
        SimpleLink.tailInsert(nodeC);
        SimpleLink.tailInsert(nodeD);
        SimpleLink.tailInsert(nodeE);
        SimpleLink.getList();
    }
}

运行结果:

在这里插入图片描述

2.4.3 随机访问

首先要强调下: 链表在物理结构上并不支持随机访问,我们只能够通过诸如遍历等逻辑手段来实现。

实现思路:

  1. 首先要判断一下索引越界的问题
  2. 按照索引数遍历即可

代码实现:

    /*随机访问*/
    public Node getByIndex(Integer index){
        if (index < 0 || index >= size){
            throw new IndexOutOfBoundsException("Index: "+index + ", Size:" + size);
        }
        Node node = headNode.getNextNode();
        for (int i = 0; i < index; i++) {
           node= node.getNextNode();
        }
        return node ;
    }

2.5 链表的固定位置插入

我们的需求终于来到了最后一步,其实我们前面已经把我们需要的能力都已经逐一实现了,而我们只需要将随机访问和尾插结合一下就可以实现固定位置的插入。
实现思路:

  1. 判断索引位置是否超长,这里需要注意允许索引插入在最后一个节点之后
  2. 判断索引是否在最后一个节点之后,若是则直接尾插
  3. 若不是则获取索引出前一节点,改变节点引用即可

代码实现:

    /*根据索引插入*/
    public Boolean insertByIndex(Node<E> node, Integer index) {
        // 判断索引越界
        if (index < 0 || index > size) {
            throw new IndexOutOfBoundsException("Index: " + index + ", Size:" + size);
        }
        if (index == size){
            lastNode.setNextNode(node);
            size ++ ;
        }else{
            Node preNode = getByIndex(index - 1);
            node.setNextNode(preNode.getNextNode());
            preNode.setNextNode(node);
            size++ ;
        }
        return true;
    }

2.6 验证

我们已经完成了实现引言中业务场景的所有API,接下来让我们写一个测试类来测试下:

public class Demo {
    public static void main(String[] args) throws Exception {
        SimpleLink<String> SimpleLink = new SimpleLink<>();
        Node<String> nodeA = new Node<>("某啤酒品牌起诉吴某某");
        Node<String> nodeB = new Node<>("热剧且试夏天剧照");
        Node<String> nodeC = new Node<>("某学者喊话某网站要清醒");
        Node<String> nodeD = new Node<>("西瓜价格跌破5毛");
        Node<String> nodeE = new Node<>("某市新房成交量首次低于二手房");
        SimpleLink.tailInsert(nodeA);
        SimpleLink.tailInsert(nodeB);
        SimpleLink.tailInsert(nodeC);
        SimpleLink.tailInsert(nodeD);
        SimpleLink.tailInsert(nodeE);
        Node<String> nodeF = new Node<>("热瓜,某当红演员与其妻子已离婚");
        SimpleLink.insertByIndex(nodeF,2);
        SimpleLink.getList();
    }
}

验证结果:
在这里插入图片描述

2.7 完整代码

完整代码已放入GIT,需要的小伙伴自取:
简单链表实现源码


三、链表拓展

3.1 双向链表

所谓的双向链表就是普通链表的一种变形。相比于单向链表只能向一侧遍历,双向链表有向前和向后两种能力,这样减少了我们寻找节点的成本。
简单双向链表:
双向链表
当然我们为了保证结构的一致性和API实现的遍历,我们通常还会为双向链表添加头节点和尾节点:

在这里插入图片描述
关于双向链表由于它的变化不是很大,为了节省篇幅,因此就不通过代码演示了。

3.2 循环链表

循环链表也是传统链表的一种变型,它的特点就是链表中的最后一个节点会指向头节点,从而将链表变成一个环状。
它的实现也很简单,只需要将终端节点的nextNode指针指向开始节点即可。
循环链表:
在这里插入图片描述
头结点的意义依旧是为了标识初始节点是哪个。

3.3 跳表

跳表又叫做跳跃表,它是在有序链表的基础上又加了多级索引以优化查询效率。我们可以先简单的看一下跳表的结构:
在这里插入图片描述

我们以寻找元素5举例:

  1. 跳表会首先在最顶层索引(粉色区域)进行查找,直到寻找到大于等于5的索引,亦或是到达当前链表的尾部。
  2. 若寻找到等于5的元素则表示当前元素已被找到。
  3. 若找到大于5的元素则退回当层前一索引(如图从粉8退到粉4),然后进入到下一级索引继续寻找(蓝色区域)。
  4. 重复步骤1,2,3,直到查到找元素后者到达链表尾终止。

注意:

  1. 跳表的索引是随机产生的,并不会均匀的进行分布,因此极端情况下会出现两个索引之间元素过多的情况。
  2. 每个节点都拥有两个指针,一个指向下一个索引元素,一个指向下一层的元素。
  3. 删除元素时需要将索引同步删除
  4. 跳表是最典型的空间换时间的结构,通过牺牲内存空间来提高查询效率。

四、算法实战

4.1 反转链表

题目:https://leetcode.cn/problems/UHnkqh/

给定单链表的头节点 head ,请反转链表,并返回反转后的链表的头节点。
示例 1:
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]
示例 2:
输入:head = [1,2]
输出:[2,1]
示例 3:
输入:head = []
输出:[]

思路: 简单的链表反转,我们只需要创建一个头结点记录第一个节点信息即可。
代码实现:

 class Solution {
    public ListNode reverseList(ListNode head) {
        if (head== null){
            return head ;
        }
        ListNode returnNode = new ListNode();
        returnNode.next = head;
        return reverse(returnNode, returnNode);

    }

    private  ListNode reverse(ListNode head, ListNode returnNode) {
        if (head.next == null) {
            returnNode.next = head;
        } else {
            ListNode reverse = reverse(head.next, returnNode);
            if (head.equals(returnNode)) {
                reverse.next = null;
                return returnNode.next;
            }
            reverse.next = head;
        }
        return head;
    }
}

4.2 环形链表

题目:环形链表

给你一个链表的头节点 head ,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。
如果链表中存在环 ,则返回 true 。 否则,返回 false 。
如:
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

实现思路:

  1. 如果不考虑空间复杂度问题,用一个hashmap记录遍历过的节点即可,这种实现最容易想到。
  2. 如果考虑空间复杂度可以使用快慢指针,我们每次遍历的时候移动两个指针,一个快指针移动两格,一个慢指针移动一格,如果存在循环链表,快慢指针最终会重合。

代码实现:

public class HasCycleLinked {
    public static void main(String[] args) {
        ListNode A = new ListNode(3);
        ListNode B = new ListNode(2);
        ListNode C = new ListNode(0);
        ListNode D = new ListNode(4);
        A.next = B;
        B.next = C;
        C.next = D;
        D.next = B;
        Boolean result = hasCycle(A);
        System.out.println(result);
    }

    private static Boolean hasCycle(ListNode head) {
        //常量级内存
        ListNode fast = head;
        while (head != null && head.next != null) {
            if (head.next == null) {
                return false;
            }
            if (fast == null || fast.next == null) {
                return false;
            }
            if (head.next.equals(fast.next.next)) {
                return true;
            }
            head = head.next;
            fast = fast.next.next;
        }
        return false;
    }
}

运行结果:
在这里插入图片描述


反思

既然小伙伴都可以手写一个链表了,是不是能够自己总结一下链表的特性呢?


结语

今天的内容就到此结束了,有疑问的小伙伴欢迎评论区留言或者私信博主,博主会在第一时间为你解答。
Spring通用架构及工具已上传到gitee仓库,需要的小伙伴们可以自取:
https://gitee.com/xiaolong-oba/common-base

码字不易,感到有收获的小伙伴记得要关注博主一键三连,不要当白嫖怪哦~
如果大家有什么意见和建议请评论区留言或私聊博主,博主会第一时间反馈的哦。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

晓龙oba

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值