数据结构与算法-链表

链表

链表说明

链表是一种用于存储数据集合的数据结构。链表有以下属性:

  • 相邻元素之间通过指针连接
  • 最后一个元素的后继指针值为NULL在程序执行过程中,链表的长度可以增加或缩小。
  • 链表的空间能够按需分配(直到系统内存耗尽)。
  • 没有内存空间的浪费(但是链表中的指针需要一些额外的内存开销)

链表抽象数据类型

链表抽象数据类型中的操作如下:

链表的主要操作

  • ·插入:插入一个元素到链表中。
  • ·删除:移除并返回链表中指定位置的元素。

链表的辅助操作

  • 删除链表:移除链表中的所有元素(清空链表)。
  • 计数:返回链表中元素的个数。
  • 查找:寻找从链表表尾开始的第n个结点(node).

数组和链表的对比

数组概述

整个数组所有的元素都存储在操作系统分配的一个内存块中。通过使用特定元素的索引作为数组下标,可以在常数时间内访问数组元素。

  • 为什么能在常数时间内访问数组元素

    • 访问一个数组元素只需要将该数组基地址加上其偏移量就可以获得该元素内存地址
      • 因为该过程只需要一次乘法一次加法都是常数时间,所以可以认为是在常数时间内完成
  • 数组的优点

    • 简单易用
    • 访问元素快
  • 数组的缺点

    • 大小固定 数组的大小是静态的-在使用前确定数组大小
    • 分配一个连续的空间块
    • 基于位置的插入操作实现复杂
  • 动态数组

    • 实现的简单方法是初始化一个固定大小的数组一但满了就创建一个二倍原数组的新数组,同样如果存储小于一半就将数组大小减少一半
  • 链表的优点

    • 相比数组的空间确定,链表的空间是可变的
    • 插入操作比数组简单很多
  • 链表的缺点

    • 访问单个元素的时间开销问题
      • 数组的访问开销为O(1)而链表最差情况为O(n)
    • 检索数据开销大
    • 额外指针需要额外内存

单向链表

链表通常是指单向链表,它包含多个结点,每个结点有一个指向后继元素的next(下一个)指针。表中最后一个结点的next指针值为NULL,表示该链表的结束

链表类型声明:

public class ListNode {
    private int data;
    private ListNode next;

    public int getData() {
        return data;
    }

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

    public ListNode getNext() {
        return next;
    }

    public void setNext(ListNode next) {
        this.next = next;
    }
}

链表的基本操作

  • 遍历链表。
  • 在链表中插入一个元素。
  • 从链表中删除一个元素。

链表的遍历

假设表头结点的指针指向链表中的第一个结点。遍历链表需完成以下步骤。

  • 沿指针遍历。
  • 遍历时显示结点的内容(或计数)。
  • 当next指针的值为NULL.时结束遍历。
int ListLength(ListNode headNode) {	//遍历然后返回链表长度
    int length = 0;
    ListNode currentNode = headNode;
    while (currentNode != null){
        length++;
        currentNode = currentNode.getNext();
    }
    return length;
}

时间复杂度为O(n),用于扫描长度为n的链表。空间复杂度为0(1),仅用于创建临时变量。

单向链表的插入

单向链表的插入操作可分为以下3种情况:

  • 在链表的表头前插入一个新结点(链表开始处)。

  • 在链表的表尾后插入一个新结点(链表结尾处)。

  • 在链表的中间插入一个新结点(随机位置)。

  • 都可以归结为最后一种状态

实现方法新位指向前位的下一位,前位指向新位。

ListNode InsertInLinkedList(ListNode headNode,ListNode nodeToInsert,int position) {
    if(headNode == null){//链表为空直接插入然后返回插入节点-因为java属性引用默认为空所以这里不用置null
        return nodeToInsert;
    }
    int size = ListLength(headNode);
    if(position > size+1 | position < 1){
        System.out.println("插入位置错误!");
        return headNode;
    }
    if(position == 1){//在链表开头插入将插入指向头然后将插入节点返回出去
        nodeToInsert.setNext(headNode);
        return nodeToInsert;
    }else{//在除开始以外任意节点插入
        ListNode previousNode = headNode;
        int count = 1;
        while (count < position-1){
            previousNode = previousNode.getNext();
            count++;
        }
        ListNode currentNode = previousNode.getNext();
        nodeToInsert.setNext(currentNode);
        previousNode.setNext(nodeToInsert);
    }
    return headNode;
}

单向链表的删除

  • 与插入类似
ListNode DeleteNodeFromLinkedList(ListNode headNode,int position){
    int size = ListLength(headNode);
    if(position > size | position < 1){
        System.out.println("删除位置错误!");
        return headNode;
    }
    if(position == 1){
        ListNode currentNode = headNode.getNext();
        return currentNode;
    }else{
        ListNode previousNode = headNode;
        int count = 1;
        while (count < position){
            previousNode = previousNode.getNext();
            count++;
        }
        ListNode currentNode = previousNode.getNext();
        previousNode.setNext(currentNode);
        currentNode = null;
    }
    return headNode;
}

删除单向链表

这个操作通过将当前结点存储在临时变量中然后释放当前结点(空间)的方式来完成。当释放完当前结点(空间)后,移动到下一个结点并将其存储在临时变量中,然后不断重复该过程直至释放所有结点。

void DeleteLinkedList(ListNode head){
    ListNode auxilaryNode,iterator = head;
    while (iterator != null){
        auxilaryNode = iterator.getNext();
        iterator = null;
        iterator = auxilaryNode;
    }
}

双向链表

双向链表的优点是:

  • 对于链表中一个给定的结点,可以从两个方向进行操作。
  • 在单向链表中,只有获得结点的前驱结点的指针,才能删除该结点。
  • 然而,在双向链表中,即使没有一个结点的前驱结点的地址,也能删除该结点
  • (因为每个结点有都一个指向前驱结点的指针,可以直接后退到前驱结点)。

双向链表主要的缺点是:

  • 每个结点需再添加一个额外的指针,因此需要更多的空间开销。
  • 结点的插入或删除更加费时(需要更多的指针操作)。
public class DLLNode {
    private int data;
    private DLLNode next;
    private DLLNode previous;

    public int getData() {
        return data;
    }

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

    public DLLNode getNext() {
        return next;
    }

    public void setNext(DLLNode next) {
        this.next = next;
    }

    public DLLNode getPrevious() {
        return previous;
    }

    public void setPrevious(DLLNode previous) {
        this.previous = previous;
    }
}

双向链表的操作

  • 插入操作
  • 双向链表的删除

循环链表

  • 在单向链表和双向链表中,都采用NULL.值表示链表的结束。然而,循环链表没有结束标志。
  • 当遍历循环链表时需要特别小心,否则将会无限地遍历链表,因为在循环链表中每个结点都有一个后继结点。
  • 注意,与单向链表不同,循环链表中没有next指针为NUL.L.的结点。循环链表在某些情况下是非常有用的。例如,当多个进程需要在相同的时间内使用同一个计算机资源(CPU)时,必须确保在所有其他进程使用这些资源完前,没有进程访问该资源(轮询算法)
  • 在循环链表中,使用表头结点访问元素(与单向链表和双向链表中的表头结点相似)。

假设循环链表的类名为CLNode.循环列表的类结构可以为单向或者双向是一样的只是节点首尾相连

循环节点的操作

统计循环节点的个数

​ 循环链表可以通过标记为表头的结点进行访问。为了统计结点个数,只能从标记为表头的结点开始遍历,利用虚拟结点--当前结点(current),当当前结点再次到达开始结点表头时结束计数过程。

​ 如果链表为空,表头结点为NULL,在这种情况下设结点个数(count)等于0。否则,设置当前结点指向第一个结点(表头结点),然后遍历链表进行计数,直到当前结点达到开始结点。

    int CircularListLength(CLLNode headNode){
        int length =0;
        CLLNode currentNode - headNode;
        while (currentNode != null){
            length++;
            currentNode = currentNode.getNext();
            if(currentNode == headNode)
                break;
        }
        return length;
    }

输出循环链表的内容

​ 假设循环链表可以通过表头结点进行访问。
​ 由于所有的结点采用循环方式排列,所以链表的表头结点的前驱结点就是表尾结点。
​ 假设要输出从表头结点开始的结点的内容。
​ 输出结点内容,移动到下一个结点,继续输出直至再次到达表头结点。

在循环链表的表尾插入结点

在由表头开始的循环链表的表尾插入一个包含数据(data)的结点。
新结点将放在表尾结点(即循环链表的最后一个结点)的后面,也就是说,在表尾结点和第一个结点之间插人该新结点

在循环链表的表头插入结点

在循环链表的表头前插入结点与在表尾插入结点的唯一区别是,在插入新结点后,还需要更新指针。

其他操作

  • 删除循环链表中的最后一个结点

    • 遍历循环链表,找到表尾结点及其前驱结点。
    • 更新表尾结点的前驱结点的next指针,使其指向表头结点。
    • 移除表尾结点。
  • 删除循环链表中的第一个结点

循环链表的应用

循环链表可用于管理计算机的计算资源,还可用于实现栈和队列。

其他链表

一种存储高效的双向链表

  • 在双向链表常规的实现中,需要一个指向后继结点的正向指针和一个措向前驱结点的反向指针。这表明双向链表中的结点由数据、一个指向后继结点的指针和一个指向前驱结点的指针构成。
  • 而这种新的双向链表只需要一个指针即可

具体定义:

松散链表

  • 与数组相比,链表的最大优势在于,在任何位置插入元素的时间开销仅为0(1)。然而,在链表中查找某个元素的时间开销则是0(n)。下面介绍一种单向链表的简单变种,称为松散链表。
  • 松散链表中的每个结点存储多个元素(简称为块)。而每一块中的所有结点由循环链表链接在一起。

这个只作为一个思路emmm感觉插入麻烦(要保证块结构),查询比数组又要慢。。除非是两个都要的情况下才可能选择这个。。


链表相关的问题

  • 解题思考方向
  • 蛮力《 排序《 散列表 《栈 《 集合其他方法《 独特想法

问题1

找到链表的倒数第n个结点。

蛮力法:从链表的第一个结点开始,统计当前节点后面的结点个数。如果后面结点的个数小于n-1,那么算法结束并返回消息“链表中的结点个数不足”。如果数量大于n-1,则移动到下一个结点(作为新的当前结点)。重复该过程直至当前结点后面的结点个数等于n-1,算法结束。

时间复杂度为O(n)。对于每个结点,都需要从当前结点扫描一次链表中剩余的结点。
空间复杂度为0(1)

散列表法:

以下链表为例子

​ 为了创建散列表,当遍历链表时,可以得到链表的长度。令M表示链表的长度,这样就将寻找链表的倒数第n个结点的问题,转换为寻找链表正数第M-n+1个结点。因为已知链表的长度,所以求解这个问题只需要返回散列表中主键为M-n+1的值即可。

​ 时间复杂度为0(m),主要是创建散列表的时间开销。

​ 空间复杂度为0(m),因为需要建立一个大小为m的散列表。

散列表法优化:

​ 在不创建散列表的情况下,用问题3中的方法解决问题1?

​ 仔细观察问题3的解决方案,实际上就是求链表的长度。

​ 也就是说,该方案使用散列表来确定链表的长度。然而,只要从表头结点开始遍历链表,也能得到链表的长度。

​ 因此不用创建散列表同样可以求链表的长度。得到长度后,计算M-n+1的值,然后从表头开始再遍历一次就能得到第M-n+1个结点。这个方法需要两次遍历:一次用于确定链表的长度,另一次用于找到从表头开始的第M-n+1个结点。

​ 时间复杂度等于确定链表长度的时间开销加上从表头开始寻找第M-n+1个结点的时间开销,
所以T(n)=0(n)+0(n)~0(n)。因为不需要建立散列表,所以空间复杂度为0(1)

(最优解)

能否只用一次(链表)扫描就解决问题1?

​ 有效方法为:使用两个指针pNthNode和pTemp,

​ 首先,两个指针都指向链表的表头结点。仅当pTemp(沿着链表)进行了n次移动后,pNthNode才开始移动。然后两个指针同时移动直至pTemp到达表尾。

​ 这时pNthNode指针所指的结点就是所求的结点,也就是链表的倒数第n个结点。

问题2

判定给定的链表是以NULL结尾,还是形成一个环。

蛮力法:
考虑下面的链表,其中包含一个环。这个链表与常规链表的区别在于,其中有两个结点的后继结点是相同的。在常规链表中是不存在环的,每个结点的后继结点是唯一的。换言之,若链表中出现(多个结点的)后继指针重复,就表明存在环。
这个方法正确吗?根据该算法,需要不断地检查后继指针地址,但是如何确定链表的表尾呢?否则算法将会出现死循环。

是否能使用散列表技术求解问题2?
可以。使用散列表是可以求解上题的。

  • 从表头结点开始,逐个遍历链表中的每个结点。
  • 对于每个结点,检查该结点的地址是否存在于散列表中。
  • 如果存在,则表明当前访问的结点已经被访问过了。出现这种情况只能是因为给定链表中存在环。
  • 如果散列表中没有当前结点的地址,那么把该地址插入散列表中。
  • 重复上述过程,直至到达表尾或者找到环。

能否使用排序技术求解问题6?

不行。首先给出如下基于排序的算法。然后,再分析这个算法失败的原因。
从表头结点开始,逐个遍历链表的每个结点,并把所有结点的后继指针的值保存在数组中。对该(保存了后继指针值的)数组进行排序。
如果给定链表中存在环,那么根据定义,将有两个结点的后继指针指向相同的结点。
如果链表中存在环,那么在排序后后继指针值相同的结点将是相邻的。
如果出现了这样的结点对,那么可以判定链表中存在环。
上述算法有问题吗?只有当确定链表的长度时,上述算法才适用。否则,如果链表存在环,那么可能会出现死循环。因此这个算法是无效的。

(最优解)

​ 有效的方法(内存开销更少的方法)是由Floyd提出的,所以该方法称为Floyd环判定算法。该方法使用两个在链表中具有不同移动速度的指针。一旦它们进入环就会相遇,即表示存在环。这个判定方法的正确性在于,快速移动指针和慢速移动指针将会指向同一位置的唯一可能情况,就是整个或者部分链表是一个环。

​ 设想一下,乌龟和兔子在一个轨道上赛跑。如果它们在一个环上赛跑,那么跑得快的兔子将赶上乌龟。下面的图例展示了Floyd算法的过程。从下图中可以发现,执行最后
-步后,它们将在环中可能并非起点的某一点相遇。

时间复杂度为O(n)。空间复杂度为0(1)

拓展

​ 判定给定的链表是否以NUL结束。如果链表中存在环,找到环的起始结点。
​ 这个题目的求解方法需要对求解问题2最优解的方法进行扩展。在找到链表中的环后,初始化slowPtr的值,使其指向链表的表头结点。然后,slowPtr和fastPtr从各自的位置开始沿着链表移动,每次均移动一个结点。它们相遇的位置就是环的开始结点。通常可用这种方法来删除环。

​ 这个有点意思->我们来计算一下就知道了

以上图为例子吧
设乌龟速度为 x 则兔子速度为 2x
设从起点到环起点处长度为: S
设环长为			   L
    有乌龟到环起点时兔子在环中移动了S距离(速度是2倍)
    此时兔子相当于从它当前位置出发去追离自己(L-S)距离的乌龟
    兔子追到乌龟时有:	
    	((L-S)/(2x-x))*x = L-S = 这个就是乌龟和兔子相遇的地方
		L-S 就是从环起点开始正向移动的距离而这个距离其实再正向移动S就到达环起点
		也就是说如果让一个点在此位置一个点到链表起点位置,同时移动一个距离,那么他们相遇的地方		   就是环起点的地方

问题3

逆置单向链表。

//选代版本
ListNode ReverseList(ListNode head){
    ListNode temp = null,nextNode = null;
        while(head!= null){
        nextNode-head.getNext();
        head.setNext(temp);
        temp = head;
        head=nextNode;
        return temp;
}

问题4

假设两个单向链表在某个(结)点相交后,成为一个单向链表。两个链表的表头结点是已知的,但是相交的结点未知。也就是说,它们相交之前各自的结点数是未知的,并且两个链表的结点数也可能不同。假设链表List1和链表List2在相交前的结点数分别为n和m,那么m可能等于或小于n,也可能大于n。请设计算法找到两个链表的合并点。

最优解

算法:

  • 获得两个链表(1.1和1.2)的长度一0(n)+0(m)=0(max(m,n))。
  • 计算两个长度的差d--O(1).
  • 从较长链表的表头开始,移动d步--O(d).
  • 在两个链表中开始同时移动,直至出现两个后继指针值相等的情况O(min(m,n))
  • 时间复杂度为O(max(m,n))
  • 空间复杂度为0(1)

问题5

如何找到链表的中间结点?

  • 使用两个指针。让第一个指针的移动速度是另一个的两倍。
  • 当第一个指针到达表尾时,另一个指针则指向中间结点。

问题6

如何从表尾开始输出链表?

  • 递归遍历至表尾。当返回时,输出元素。

问题7

检查链表的长度是奇数还是偶数?

使用一个在链表中每次向后移动两个结点的指针。这里要先判断有无后续节点
最后,如果指针值为NULL,那么链表长度为偶数,否则指针指向表尾结点,链表长度为奇数。

posted @ 2021-02-06 23:11  筮石头  阅读(138)  评论(0)    收藏  举报