数据结构与算法(四)——链表

线性表顺序存储结构不足的解决

顺序结构存储最大的缺点就是插入和删除时需要移动大量元素,需要消耗很多时间,使用链式存储结构就可以解决这个问题。

前面提到数组之所以会有这个问题,是由于其需要一块连续的内存空间。而链式存储结构即链表则是通过离散的内存块来存储数据,不需要考虑相邻位置,哪里有空位就去哪里,只要每个元素都知道它的下一个元素的位置在哪里就行了。这样链表的每个元素就可以通过一组零散的内存串联起来使用。

线性表链式存储结构定义

线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的,这就意味着这些数据元素可以存在内存未被占用的任意位置。但是由于每个数据都要知道它的下一个元素的位置,所以链式结构中除了要存数据元素信息外,还要存储它的后继元素的存储地址。

把存储数据元素信息的域称为数据域(data),把存储直接后继的信息(即下一个元素的位置信息)称为指针域(next)。指针域存储的信息称作指针或链。这两部分信息组成数据元素的存储映像,称为结点(Node)。

n个结点链结成一个链表,即为线性表的链式存储结构。常见的三种链表结构为:单链表、双向链表和循环链表。当链表的每个结点中只包含一个指针域,所以叫做单链表。单链表正是通过每个结点的指针域将线性表的数据元素按其逻辑次序链接在一起,如下图所示。
数据结构与算法(四)——链表_第1张图片

把链表中第一个结点的存储位置叫做头指针,那么整个链表的存取就是从头指针开始进行了,之后的每一个结点都是是一个的后继指针指向的位置;而最后一个结点,其直接后继不存在,所以规定其指针为空,通常用null或“^”表示,如下图所示。

数据结构与算法(四)——链表_第2张图片

有时,为了更方便对链表进行操作,会在单链表的第一个结点前附设一个结点,称为头结点。头结点的数据域可以不存储任何信息,也可以存储如线性表的长度等附加信息,头结点的指针域指向第一个结点的指针,如下图所示。
数据结构与算法(四)——链表_第3张图片

头指针和头结点的异同

头指针 头结点
头指针是指链表指向第一个结点的指针,若链表有头结点,则是头结点的指针 头结点是为了操作的统一和方便而设立的,放在第一元素的结点之前,其数据域一般无意义(也可以存储如线性表的长度)
头指针具有标识作用,所以常用头指针冠以链表的名字 有了头结点,对在第一元素结点前插入结点或删除第一结点,其操作与其它结点的操作就统一了
无论链表是否为空,头指针均不为空。头指针是链表的必要元素 头结点不一定是链表必须要素

单链表的数据查找、插入和删除操作

插入和删除

因为链表的存储空间本身就不是连续的,并不需要为了保持内存的连续性而搬移结点,所以,在链表中插入和删除一个数据是非常快速的。如下图所示。
数据结构与算法(四)——链表_第4张图片

不管是链表的插入还是删除操作,都只需要更改其相邻结点的指针即可,其时间复杂度都是O(1)。比如,要插入一个结点x,只需要把要插入的位置的前继结点b的指针更改,使其指向x所在的位置即可,然后再通过x的指针指向c所在的位置即可,就可以在不影响其它任何结点的情况下插入新结点构成一个新链表。但是要注意的是,在编写代码时,必要要注意指针更改的顺序:必须是先使x的指针指向c所在位置,再把b的指针更改使其指向x。如果顺序倒过来了,就会导致c结点的位置丢失,再也无法通过这个链表访问到c,也就是说在c这里发生了断链。删除操作也是如此,只需改变相邻结点的指针指向,就可以完成删除操作。

数据查找

与数组相反,链表的插入和删除很快,而对数据的查找和访问就很低效。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。所以链表随机访问的性能没有数组好,需要O(n) 的时间复杂度。

单链表结构与顺序存储结构对比

  • 存储分配方式

    • 顺序存储结构用一段连续的存储单元依次存储线性表的数据元素
    • 单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素
  • 时间性能

    • 查找
      • 顺序存储结构O(1)
      • 单链表O(n)
    • 插入和删除
      • 顺序存储结构需要平均移动表长一半的元素,时间为O(n)
      • 单链表在找出某位置的指针后,插入和删除时间仅为O(1)
  • 空间性能

    • 顺序存储结构需要预分配存储空间,分大了造成浪费,分小了易发生溢出
    • 单链表不需要分配存储空间,只要有就可以分配,元素个数也不受限制

循环链表

循环链表是一种特殊的单链表。它跟单链表唯一的区别就在尾结点。单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。

将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表(circular linked list)。循环链表带有头结点的空链表如下图所示。

而对于非空循环链表如下。

循环链表解决了如何从当中一个结点出发,访问到链表的全部结点。单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题,用循环链表实现的话,代码就会简洁很多。

双向链表

单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点,使查找下一结点的时间复杂度为O(1),但是要查找上一结点的话,最坏的时间复杂度就是O(n)了。双向链表就是为了解决这个缺点。双向链表是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。如下图所示。

双向链表是从单链表中扩展出来的结构,所以它的很多操作是和单链表相同的。但是,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。在插入和删除时,需要更改两个指针变量。

虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。从结构上来看,双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。

插入

由于双向链表比单链表的关系更为复杂一些,所以在进行插入和删除操作时,更要注意不能写反了顺序,如下图所示。

假设要把结点s插入到结点p和p.next之间,那么其顺序应该是

  1. 把p赋值给s的前驱,即使s的前驱指向p,如图中步骤1;
  2. 把p.next赋值给s的后继,即使s的后继指向p.next,如图中步骤2;
  3. 把s赋值给p.next的前继,即使p.next的前驱指向s,如图中步骤3;
  4. 把s赋值给p的后继,即使p的后继指向s,如图中步骤4;

对比前面单链表的操作可以发现,都是最后才处理p的后继,使其指向更改,这样才可以保证插入工作的完成。

删除

删除的操作更简单一点,只需要两步,如图所示。

查询

除了插入、删除操作有优势之外,对于一个有序链表,双向链表的按值查询的效率也要比单链表高一些。可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。

空间换时间

对比双向链表和单链表,双向链表要比单链表更加高效。双向链表尽管比较费内存,但还是比单链表的应用更加广泛。双向链表其实就是用空间换时间的设计思想。当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路。

在链表应用场景中,经典的常用的就是LRU 缓存淘汰算法。缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:

  • 先进先出策略 FIFO(First In,First Out)
  • 最少使用策略 LFU(Least Frequently Used)
  • 最近最少使用策略 LRU(Least Recently Used)

缓存实际上就是利用了空间换时间的设计思想。如果我们把数据存储在硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果我们通过缓存技术,事先将数据加载在内存中,虽然会比较耗费内存空间,但是每次数据查询的速度就大大提高了。

基于链表实现 LRU 缓存淘汰算法

维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,从链表头开始顺序遍历链表。

  1. 如果此数据之前已经被缓存在链表中了,遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
  2. 如果此数据没有在缓存链表中,又可以分为两种情况:
    • 如果此时缓存未满,则将此结点直接插入到链表的头部;
    • 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。

链表常见算法题

反转一个单链表

leetcode第206题:https://leetcode.com/problems/reverse-linked-list/
题目描述:

方法一:遍历链表,从头到尾依次反转每个结点

  • 从头结点开始,如果头结点为空,直接返回;
  • 如果不为空,定义两个临时结点分别存储头结点的前驱结点pre和后继结点next;
  • 遍历链表,把头结点的下一个结点赋给next,即先把结点原来的后继结点存起来;
  • 再使头结点的下一个结点指向pre,即反转该结点的后驱;
  • 接着就是对下一个结点进行同样的操作,先把当前头结点赋值给前驱pre,作为下一个结点的前驱
  • 再把next的值(即前面存储的后继结点)赋给头结点head,也就是把下一个结点作为头结点再次进行同样的操作,依次往后反转每个结点,最后就可以完成整个链表的反转。
    代码如下:
		public ListNode reverseList(ListNode head) {
		        if(head == null || head.next==null)
			    return head;
			ListNode pre = null;
			ListNode next = null;
			while(head != null) {
			    next = head.next;
			    head.next = pre;
			    pre = head;
			    head = next;
			}
			
			return pre;
		}

方法二:递归

public ListNode reverseList(ListNode head) {
        if(head==null || head.next==null)
            return head;
        ListNode nextNode=head.next;
        ListNode newHead=reverseList(nextNode);//递归,相当于遍历到链表的最后一个结点
        nextNode.next=head;
        head.next=null;
        return newHead;
 }

上面的代码中,在第5行进行了递归,为了更好地理解这段代码,先看1,假设1后面的链表已经完成了反转,那么代码的思路就是:

  • 第4行:首先得到头结点的下一个结点2,并记为nextNode;
  • 第5行:然后得到已经反转好的新链表的头结点,并记为newHead,即5;
  • 第6行:把原来的头结点放到新链表的最后,即把原来还没处理的头结点1放到nextNode即2的后面,由于此时2及其后面链表已经完成了反转,所以2就是新链表的最后一个结点;
  • 第7行:将1的指针域指向null,即将1置为最后一个结点;
  • 第8行,返回新链表的头结点5;
  • 同理,对于2及其后面的链表来说,也是相同的流程。

两两交换链表中的节点

这是leetcode上的第24题:https://leetcode.com/problems/swap-nodes-in-pairs/
,题目描述:

方法一:

public ListNode swapPairs(ListNode head) {
	ListNode dummy = new ListNode(0);
	dummy.next = head;
	ListNode current = dummy;
	ListNode first = null;
	ListNode second = null;
	while (current.next != null && current.next.next != null) {
	    first = current.next;
	    second = current.next.next;
	    first.next = second.next;
	    current.next = second;
	    second.next = first;
	    current = first;
	}
	return dummy.next;
}

在上面的代码中,定义了一个额外的结点dummy,用来存储临时的结点及结点之间的关系。结点current用来存储遍历过程中的当前结点。因为两两交换结点,不能直接对两个结点进行交换,而且每交换完两个,就要跳到下一个相邻的两个结点进行操作,所以需要额外的两个结点进行操作。

  • 首先,使dummy的指针域指向原来的头结点1,当前操作的结点current为dummy,结点first和second分别为当前结点的下一个结点,下下一个结点;
  • 只要当前结点的下一个结点和下下一个结点不为空,也就是说还有交换的机会,就继续循环;
  • 对first即1和second即2进行交换操作,第10行:把first(1)的下一个结点指向second(2)的下一个结点3,也就是说1原来是指向2的,现在指向了3;
  • 第11行:使second作为当前结点current的下一个结点,即current指向2;
  • 第12行:使first(1)作为second(2)的下一个结点,即2指向了,到此,结点1和2已经完成反转,此时的关系为current(dummy) -> 2 -> 1 ->3 ->4;
  • 最后,开始进行下两个结点的交换,同样要使current在结点3和4的前面,所以需要把当前结点改为1,即把此时的first赋值给current;
  • 重复以上操作,此时的first和second依然是current的下一个和下下一个结点,只是由原来的1和2变成了3和4;
  • 当结束了第9行之后,此时的关系为dummy-> 2 -> 1(current) -> 3 -> 4,然后重复上面的操作;

方法二:递归

public ListNode swapPairs(ListNode head) {
        if ((head == null)||(head.next == null))
            return head;
        ListNode n = head.next;
        head.next = swapPairs(head.next.next);
        n.next = head;
        return n;
}

在这里,由于是两两交换,头结点的下一个结点会成为新的头结点,所以递归返回的n是head.next。

同样道理,假设前两个结点后面的所有结点已经完成了交换,那么就将head结点的下一个结点指向完成交换的链表,因为交换后原来的头结点就变成了第二个结点,指向下一个已完成交换的结点。

然后,再把原来的头结点赋值给n的指针域,即n的下一个结点就是原来的头结点,这样n就变成了头结点,原来的head变成了第二个结点,完成交换。

你可能感兴趣的:(数据结构和算法,链表,算法,数据结构,leetcode)