[链表] [JAVA]一文让你真正弄懂链表

前言:
我认为数据结构是算法的基础,数组和链表就是数据结构的基础啦。哈哈哈,神马逻辑。这篇文章我们就来讲解一下链表。

什么是链表

链表是物理存储单元上非连续的、非顺序的存储结构,它是由一个一个结点,通过指针来联系起来的,其中每个结点包括数据和指针。
在这里插入图片描述
链表是非连续的,非顺序的,对应数组的连续、顺序,在内存中之怎样联系起来的呢?
先看一看数组在内存中的表示。
在这里插入图片描述
可以看到数组的每个元素都是连续紧邻分配的,这叫连续性,同时由于数组的元素占用的大小是一样的。在JAVA中int型大小固定为4个字节,所以如果数组的起始地址是100.由于这些元素在内存中都是连续紧邻分配的,大小也一样,可以很容易地找出数组中任意一个元素的位置,比如数组中的第三个元素起始地址为100+2*4 = 108,这就叫顺序性。查找时间复杂度为O(1),效率很高。

看完了数组在内存中的表示,下面我们来看一看链表在内存中的表示:
在这里插入图片描述
可以看到每个结点都分配在非连续的位置,结点与结点之间通过指针连在了一起,所以如果我们比如值为 3 的结点时,只能通过结点 1 从头到尾遍历寻找,如果元素少还好,如果元素太多(比如超过一万个),每个元素的查找都要从头开始查找,时间复杂度是O(n),比起数组的 O(1),差距不小。
除了查找性能链表不如数组外,还有一个优势让数组的性能高于链表,这里引入程序局部性原理,啥叫程序局部性原理。
我们知道 CPU 运行速度是非常快的,如果 CPU 每次运算都要到内存里去取数据无疑是很耗时的,所以在 CPU 与内存之间往往集成了挺多层级的缓存,这些缓存越接近CPU,速度越快,所以如果能提前把内存中的数据加载到如下图中的 L1, L2, L3 缓存中,那么下一次 CPU 取数的话直接从这些缓存里取即可,能让CPU执行速度加快,那什么情况下内存中的数据会被提前加载到 L1,L2,L3 缓存中呢,答案是当某个元素被用到的时候,那么这个元素地址附近的的元素会被提前加载到缓存中
在这里插入图片描述
以上文整型数组 1,2,3,4为例,当程序用到了数组中的第一个元素(即 1)时,由于 CPU 认为既然 1 被用到了,那么紧邻它的元素 2,3,4 被用到的概率会很大,所以会提前把 2,3,4 加到 L1,L2,L3 缓存中去,这样 CPU 再次执行的时候如果用到 2,3,4,直接从 L1,L2,L3 缓存里取就行了,能提升不少性能。
而链表呢,由于链表的每个结点在内存里都是随机分布的,只是通过指针联系在一起,所以这些结点的地址并不相邻,自然无法利用 程序局部性原理 来提前加载到 L1,L2,L3 缓存中来提升程序性能。

链表的优势

  • 大内存空间分配
    由于数组空间的连续性,如果要为数组分配500M的空间,这500M的空间必须是连续的,未使用的,所以在内存空间的分配上数组的要求会比较严格,如果内存碎片太多,分配连续的大空间很可能导致失败。而链表由于是非连续的,所以这种情况选择链表更合适。
  • 元素的频繁删除和插入
    如果涉及到元素的频繁删除和插入,用链表就会高效很多,对于数组来说,如果要在元素间插入一个元素,需要把其余元素一个个往后移(如图示),以为新元素腾空间(同理,如果是删除则需要把被删除元素之后的元素一个个往前移),效率上无疑是比较低的。
    在这里插入图片描述
    在 1,2 间插入 5,需要把2,3,4 同时往后移一位)。
    而链表的插入删除相对来说就比较简单了,修改指针位置即可,其他元素无需做任何移动操作(如图示:以插入为例)
    在这里插入图片描述
    综上所述:如果数据以查为主,很少涉及到增和删,选择数组,如果数据涉及到频繁的插入和删除,或元素所需分配空间过大,倾向于选择链表。

链表的表示

由于链表的特点(查询或删除元素都要从结点开始),所以我们只要在链表中定义头结点即可,另外如果要频繁用到链表的长度,还可以额外定一个变量来表示。
需要注意的是这个头结点的定义是有讲究的,一般来说头结点有两种定义形式,一种是直接以某个元素结点为头结点,如下:
在这里插入图片描述
一种是以一个虚拟的节点作为头结点,即我们常说的哨兵,如下
在这里插入图片描述
定义这个哨兵有啥好处呢,假设我们不定义这个哨兵,来看看链表及添加元素的基本操作怎么定义的:

package com.asong.leetcode.Link;

public class LinkedList {
    
    int length = 0;
    Node head = null;
    public void addNode(int val)
    {
        if(head == null)
        {
            head = new Node(val);
        }
        else {
            //定义一个新节点 从头开始遍历
            Node tmp = head;
            while(tmp.next != null)
            {
                tmp.next = tmp;
            }
            tmp.next = new Node(val);
        }
    }
    
}


class Node {
    int data;
    Node next =null;
    public Node(int data){
        this.data = data;
    }
}

问题就在于这里:

在这里插入图片描述
有两个问题:

  1. 每插入一个元素都要对头结点进行判空比较,如果一个链表有很多元素需要插入,就需要进行很多次的判空处理,不是那么高效
  2. 头结点与其他结点插入逻辑不统一(一个需要判空后再插入,一个不需要判空直接插入),从程序逻辑性来说不是那么合理(因为结点与结点是平级,添加逻辑理应相同)
    如果定义了哨兵就应该如下所写:
public class LinkedList {
    int length = 0; // 链表长度,非必须,可不加
    Node head = new Node(0); // 哨兵结点
    public void addNode(int val) {
        Node tmp = head;
        while (tmp.next != null) {
            tmp.next = tmp;
        }
        tmp.next = new Node(val);
    }
}

可以看到,定义了哨兵结点的链表逻辑上清楚了很多,不用每次插入元素都对头结点进行判空,也统一了每一个结点的添加逻辑。

链表的几种应用

头插法
    private int length;//链表长度
    private ListNode head = new ListNode(0); //哨兵结点
    
    //头插法
    public void headInsert(int val)
    {
        //创建新节点
        ListNode newNode = new ListNode(val);
        
        //新结点指向头结点之后的结点
        newNode.next = head.next;
        //头结点指向新节点
        head.next = newNode;
    }
一道面试题

给定单向链表的头指针和一个节点指针,定义一个函数在 O(1) 内删除这个节点。

在这里插入图片描述
如图示:给定值为 2 的结点,如何把这个结点给删了。
我们知道,如果给定一个结点要删除它的后继结点是很简单的,只要把这个结点的指针指向后继结点的后继结点即可。
在这里插入图片描述
如图示:给定结点 2,删除它的后继结点 3, 把结点 2 的 next 指针指向 3 的后继结点 4 即可。
但给定结点 2,该怎么删除结点 2 本身呢,注意题目没有规定说不能改变结点中的值,所以有一种很巧妙的方法,狸猫换太子!我们先通过结点 2 找到结点 3,再把节点 3 的值赋给结点 2,此时结点 2 的值变成了 3,这时候问题就转化成了上图这种比较简单的需求,即根据结点 2 把结点 3 移除即可,看图
在这里插入图片描述
不过需要注意的是这种解题技巧只适用于被删除的指定结点是中间结点的情况,如果指定结点是尾结点,还是要老老实实地找到尾结点的前继结点,再把尾结点删除,代码如下:

    /**
     * 删除指定结点
     */
    public void removeSelectNode(ListNode deleteNode)
    {
        //如果删除的是尾节点我们还是要从头开始遍历
        if(deleteNode.next == null)
        {
            ListNode tmp = head;
            while(tmp.next != null)
            {
                tmp = tmp.next;
            }
            tmp.next = null;
        }else {
            ListNode nextNode = deleteNode.next;

            deleteNode.data = nextNode.data;

            deleteNode.next = nextNode.next;

            nextNode.next = null;
        }
    }
链表反转

这道题就不再这里说了,直接看这篇文章吧

反转链表变形题

给定一个链表的头结点 head,以及两个整数 from 和 to ,在链表上把第 from 个节点和第 to 个节点这一部分进行翻转。例如:给定如下链表,from = 2, to = 4 head–>5–>4–>3–>2–>1 将其翻转后,链表变成 head–>5—>2–>3–>4–>1。

有了之前翻转整个链表的解题思路,现在要翻转部分链表就相对简单多了,主要步骤如下:
1.根据 from 和 to 找到 from-1, from, to, to+1 四个结点(注意临界条件,如果 from 从头结点开始,则 from-1 结点为空, 翻转后需要把 to 设置为头结点的后继结点, from 和 to 结点也可能超过尾结点,这两种情况不符合条件不翻转)。
2.对 from 到 to 的结点进行翻转
3.将 from-1 节点指向 to 结点,将 from 结点指向 to + 1 结点

在这里插入图片描述
所以代码如下:

    /**
     * 迭代翻转from 到 to 的结点
     *
     */
    public void iterationInvertLinkedList(int fromIndex,int toIndex) throws Exception
    {
        ListNode fromPre = null; //from-1 结点
        ListNode from = null; //from结点
        ListNode to = null; //to结点
        ListNode toNext = null; //to+1结点


        //找到这四个结点
        ListNode tmp = head .next;
        //头结点索引
        int curIndex = 1;
        while(tmp != null) {

            if(curIndex == fromIndex-1)
            {
                fromPre = tmp;
            }else if(curIndex == fromIndex)
            {
                from = tmp;
            }else if(curIndex == toIndex)
            {
                to = tmp;
            }else if(curIndex == toIndex+1)
            {
                toNext = tmp;
            }
            tmp = tmp.next;
            curIndex ++;

        }

        if(from == null || to ==null){
            throw  new Exception("不符合条件");
        }

        //迭代翻转从from到to结点
        ListNode pre = from;
        ListNode cur = pre.next;
        while(cur.next!=toNext)
        {
            ListNode next = cur.next;
            cur.next = pre;
            pre = cur;
            cur = next;
        }
    // 将 from-1 节点指向 to 结点(如果从 head 的后继结点开始翻转,则需要重新设置 head 的后继结点),将 from 结点指向 to + 1 结点
        if(fromPre != null)
        {
            fromPre.next = to;
        }else {
            head.next = to;
        }
        from.next = toNext;
    }
变形题

给出一个链表,每 k 个节点一组进行翻转,并返回翻转后的链表。k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么将最后剩余节点保持原有顺序。
示例 : 给定这个链表:head–>1->2->3->4->5 当 k = 2 时,应当返回: head–>2->1->4->3->5 当 k = 3 时,应当返回: head–>3->2->1->4->5 说明 :
你的算法只能使用常数的额外空间。
你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
举例说明:
在这里插入图片描述
来看看怎么翻转 3 个一组的链表(此例中 k = 3)

  • 首先,我们要记录 3 个一组这一段链表的前继结点,定义为 startKPre,然后再定义一个 step, 从这一段的头结点 (1)开始遍历 2 次,找出这段链表的起始和终止结点,如下图示
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
  • 找到 startK 和 endK 之后,根据之前的迭代翻转法对 startK 和 endK 的这段链表进行翻转
    在这里插入图片描述
  • 然后将 startKPre 指向 endK,将 startK 指向 endKNext,即完成了对 k 个一组结点的翻转。
    在这里插入图片描述
    知道了一组 k 个怎么翻转,之后只要重复对 k 个结点一组的链表进行翻转即可,对照图示看如下代码应该还是比较容易理解的:
/**
 * 每 k 个一组翻转链表
 * @param k
 */
public void iterationInvertLinkedListEveryK(int k) {
    Node tmp = head.next;
    int step = 0;               // 计数,用来找出首结点和尾结点

    Node startK = null;         // k个一组链表中的头结点
    Node startKPre = head;      // k个一组链表头结点的前置结点
    Node endK;                  // k个一组链表中的尾结点
    while (tmp != null) {
        // tmp 的下一个节点,因为由于翻转,tmp 的后继结点会变,要提前保存
        Node tmpNext = tmp.next;
        if (step == 0) {
            // k 个一组链表区间的头结点
            startK = tmp;
            step++;
        } else if (step == k-1) {
            // 此时找到了 k 个一组链表区间的尾结点(endK),对这段链表用迭代进行翻转
            endK = tmp;
            Node pre = startK;
            Node cur = startK.next;
            if (cur == null) {
                break;
            }
            Node endKNext = endK.next;
            while (cur != endKNext) {
                Node next = cur.next;
                cur.next = pre;
                pre = cur;
                cur = next;
            }
            // 翻转后此时 endK 和 startK 分别是是 k 个一组链表中的首尾结点
            startKPre.next = endK;
            startK.next = endKNext;

            // 当前的 k 个一组翻转完了,开始下一个 k 个一组的翻转
            startKPre = startK;
            step = 0;
        } else {
            step++;
        }
        tmp = tmpNext;
    }
}

时间复杂度是多少呢,对链表从头到尾循环了 n 次,同时每 k 个结点翻转一次,可以认为总共翻转了 n 次,所以时间复杂度是O(2n),去掉常数项,即为 O(n)。注:这题时间复杂度比较误认为是O(k * n),实际上并不是每一次链表的循环都会翻转链表,只是在循环链表元素每 k 个结点的时候才会翻转.

变形题3

变形 2 针对的是顺序的 k 个一组翻转,那如何逆序 k 个一组进行翻转呢
例如:给定如下链表, head–>1–>2–>3–>4–>5 逆序 k 个一组翻转后,链表变成
head–>1—>3–>2–>5–>4 (k = 2 时)
这道题是字节跳动的面试题,确实够变态的,顺序 k 个一组翻转都已经属于 hard 级别了,逆序 k 个一组翻转更是属于 super hard 级别了,不过其实有了之前知识的铺垫,应该不难,只是稍微变形了一下,只要对链表做如下变形即可
在这里插入图片描述

/**
 * 逆序每 k 个一组翻转链表
 * @param k
 */
public void reverseIterationInvertLinkedListEveryK(int k) {
    // 先翻转链表
    ReverseList();
    // k 个一组翻转链表
    iterationInvertLinkedListEveryK(k);
    // 再次翻转链表
    iterationInvertLinkedList();
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值