「笔记」《大话数据结构》第三章:线性表

版权声明:欢迎转载,转载请注明出处。 如果本文帮助到你,本人不胜荣幸,如果浪费了你的时间,本人深感抱歉。如果有什么错误,请一定指出,以免误导大家、也误导我。感谢关注。 https://blog.csdn.net/Airsaid/article/details/78528713

本文笔记来自《大话数据结构》

前言

线性表是数据结构中最常用也是最简单的一种结构。

线性表的定义

线性表(List):零个或多个数据元素的有限序列。

注意这里说线性表是序列,也就是说,元素之间是有顺序的。若元素存在多个,则第一个元素无前驱(也就是它的前面没有元素,循环链表除外),最后一个元素无后继,其他的每个元素都有并只有一个前驱与后继。

线性表的抽象数据类型

ADT 线性表(List)
Data
    线性表的数据对象集合为 {a1, a2, ..., an},每个元素的类型均为 DataType。其中,除第一个元素 a1 之外,每一个元素有切只有一个前驱元素,除了最后一个元素 an 之外,其他元素都有且只有一个后继元素。数据元素之间的关系是一对一的关系。
Operation
    initList(*L):初始化操作,创建一个空的线性表 L。
    listEmpty(L):如线性表为空,返回 true,否则返回 false。
    clearList(*L):将线性表清空。
    getElem(L, i, *e):将线性表 L 中的第 i 个位置的元素返回给 e。
    locateElem(L, e):在线性表 L 中查找与给定值 e 相等的元素,如果查找成功,返回该元素在表中的序号。否则,返回 0 表示失败。
    listInsert(*L, i, e):在线性表 L 中的第 i 个位置插入元素 e。
    listDelete(*L, i, *e):在线性表 L 中删除第 i 个位置的元素,并返回给 e。
    listLength(L):返回线性表的元素个数。

对于不同的应用,线性表的基本操作是不同的,上述操作是最基本的,对于实际问题中更复杂的操作,完全可以用如上基本操作组合实现。

线性表的顺序存储结构

顺序存储结构定义

线性表的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表中的元素。

顺序存储方式

顺序存储方法就是在内存中,开辟了一块空间,然后用相同类型的元素依次存放到这块空间中。在 Java 语言中,一维数组就是如此。

顺序存储结构需要三个属性,分别是:
1. 存储空间的起始位置。
2. 线性表的最大存储容量。
3. 线性表的当前长度。

数据长度与线性表长度区别

数据长度是存储空间的大小,一般是不可变的。但是在高级语言中,如 Java,都会进行动态增长。比如 Java 中的数组,默认长度为 10,当元素存储不下时,会进行动态扩容,当然了,这会带来一定的性能损耗。

线性表长度则是线性表中储存元素的长度,当元素增删时,其长度也会跟着变化。

在任意时刻,线性表的长度应该小与数据长度。

地址计算方法

数组元素的序号和存放它的数组下标之间存在对应关系。

在 Java 的数组中,是从 0 开始第一个下标的,也就是说 元素 1、2、3 就分别对应了第 0、1、2 号下标。

假如我们要获取一个数组中的第 3 个元素,那么则就是数组中的 3-1 处位置的内存地址。

内存中的地址,就和电影院的座位一样,都是有编号的。存储器中的每个存储单元都有自己的编号,这个编号称为地址。
数组中其实存储的就是这些地址,而我们实际上要获取的元素,都是根据地址值所去查找那块地址所存储的元素的。

因为数组角标的存储,当我们想获取线性表中任意位置的地址,不管是第一个或者是最后一个,都可以根据下标直接取出。因此按照上篇中所说到的时间复杂度来说,其时间复杂度是 O(1)。我们通常把具有这一特点的存储结构称为随机存储结构。

顺序存储结构的插入与删除

获得元素操作

对于线性表的顺序存储结构来说,获取元素就是把线性表上的第 i 个位置元素值返回。对于程序而言,只要 i 的数值是在下标的范围内,那么把第 i - 1 下标的元素返回即可。

插入操作

插入算法的思路:

  • 如果插入的位置不合理,抛出异常。
  • 如果线性表长度大于等于数组长度,则抛出异常或者动态增加容量。
  • 从最后一个元素开始向前依次遍历到插入的第 i 个位置,分别将它们都向后移动一位。
  • 将要插入的元素存放在位置 i 处。
  • 表长 +1。

删除操作

删除算法的思路:

  • 如果删除的位置不合理,抛出异常。
  • 取出删除元素。
  • 从删除位置开始遍历到最后一个元素位置,分别将它们都往前移动一位。
  • 表长 -1。

分析下,线性表的顺序存储结构插入和删除的时间复杂度。

按最好的情况,比如说要插入元素到最后一个位置,或者说是删除最后一个元素,那么这种情况下就不用移动其他元素了,所以其时间复杂度是 O(1)。

最坏的情况呢,就是插入或删除第一个元素,那么也就意味着后续的所有元素都需要往前移动。所以这时的时间复杂度是 O(n)。

至于平均的情况,由于元素插入到第 i 个位置,或是删除第 i 个位置的元素,都需要移动 n - i 个元素。根据概率原理,每个位置插入或删除元素的可能性都是相同的,也就是说,位置靠前,移动元素多,位置靠后,移动元素少。最终平均移动次数和最中间的那个元素的移动次数相等,为

n12
。所以其平均时间复杂度还是 O(n)。

这说明,线性表的顺序存储结构,在存、读数据时,不管是哪个位置,时间复杂度都是 O(1)。在插入、删除时,时间复杂度是 O(n)。

线性表顺序结构的优缺点

优点:

  • 无须为表示表中的元素之间的逻辑关系而增加额外的存储空间。(“反面教材”:链表)
  • 可以快速的存取表中任意位置的元素。

缺点:

  • 插入和删除操作需要移动大量的元素。(链表则不会)
  • 当线性表的长度变化较大时,难以确定存储空间的大小,如果动态扩容会造成额外开销。
  • 造成存储空间的 “碎片”。

线性表的链式存储结构

顺序存储结构不足的解决方法

在前面所讲的线性表的顺序存储结构有一个缺点,我们上面也讲到了。就是在插入或删除时,需要移动大量的元素,这显然时需要耗费时间的,那为什么会出现这种情况呢?

原因就是相邻的两个元素的的存储位置也具有邻居关系。它们编号是 1、2、3..n,它们在内存中的位置也是挨着的,中间并没有空隙,当然也就无法介入了,而删除后,当中就会出现空隙,自然就需要移动元素进行弥补,问题就出在了这里。

线性表的链式存储结构就很好的解决了这个问题:

链式存储结构并不需要元素在一块内存中诶着存储,哪里有空位就存哪里就行了,只不过需要额外维护下一个元素的信息,这样通过第一个元素就能找到下一个元素,通过下一个元素就能找到下下个元素,依次类推,这样所有的元素都能通过遍历查找到了。

线性表链式存储结构定义

为了表示每个数据元素 ai 与其直接后继数据元素 ai + 1 之间的逻辑关系,对数据元素 ai 来说,除了存储其本身的信息之外,还需要存储一个其直接后继的存储位置。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称为指针或链。这两部分信息组成数据元素 ai 的存储映像,称为结点(Node)。

n 个结点(ai 的存储映像)链结成一个链表,即为线性表(a1,a2,……,an)的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做单链表。

链表的第一个结点的存储位置,我们称为头指针。那么整个链表的存取就必须是从头指针开始进行了。后续的结点,都是由上一个后继指针所指向的。

最后一个结点的指针,由于没有后继了,所以一般情况下,最后一个结点的指针为 “空“。(通常用 null 或 ”^“ 表式)

有时,为了方便对链表进行操作,会在单链表的第一个结点前附设一个结点,称为头结点。这个结点可以不存储任何信息,也可以存储线性表的长度等附加信息,头结点的指针称为头指针,指向第一个结点。

头指针与头节点的异同

头指针:

  • 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针。
  • 头指针具有标示作用,所以常用头指针冠以链表的名字。
  • 无论链表是否为空,头指针均不为空。头指针是链表必要的元素。

头结点:

  • 头结点是为了操作的统一和方便而设立的,放在第一元素的结点之前,其数据域一般无意义(也可存放链表的长度)。
  • 有了头结点,对在第一元素结点前插入结点和删除第一个结点,其操作与其他结点的操作就统一了。
  • 头结点不一定是链表的必须要素。

线性表链式存储结构代码描述

用 Java 语言来定义一个单链表的结点:

class Node<E>{
    E data;
    Node next;
}

可以看到,结点由存放数据元素的数据域和存放后继结点地址的指针域组成。

单链表的读取

在线性表的顺序存储结构中,我们要获取任意位置的元素是很容易的。
但是在链表中,没办法一开始就知道,而是需要从头结点开始进行遍历。

获取链表第 i 个数据的算法思路:
1, 声明一个指针 p 指向链表的第一个结点,初始化 j 从 1 开始。
2, 当 j < i 时,就遍历链表,让 p 的指针向后移动,不断指向下一个结点,j 累加 1。
3, 若到链表末尾 p 为空,则说明这个结点不存在。
4, 否则查找成功,返回结点 p 的数据。

实现代码如下:

public E get(int i){
    // 声明要查找到的结点 p,并指向链表的第一个结点
    Node<E> p = first;
    // 初始化计数器 j 从 0 开始
    int j = 0;
    // 如果 p 不为空且计数器 j 还没有等于 i,则一直循环
    while (p != null && j < i){
        p = p.next;
        j++;
    }
    // 如果循环结束后,p 为 null,则说明该结点不存在,返回 null
    if(p == null || j > i){
        return null;
    }
    // 否则返回 p 结点存储的元素
    return p.data;
}

可以看到,线性表的链式存储结构的获取效率并不如链表的顺序存储结构,因为需要从头开始循环,并且循环次数取决与 i,当 i 为 0 时,不需要遍历,第一个就是。而当 i = n 时则需要遍历 n 次。所以其时间复杂为 O(n)。

单链表的插入与删除

单链表的插入

先来看单链表的插入。假如现在链表中已经有 a、b 两个元素,并且 a 元素的结点指针所指向的是 b 的元素结点。那么这时要往中间插入一个新元素 c,该怎么做呢?

这时只需要将 a 元素的结点指针,指向 s 元素的结点,并且 s 元素结点的指针指向 b 元素的结点,即可实现插入。这个操作只会影响到要插入位置相邻的两个结点,其他的则不会受影响。

代码演示:

public boolean put(int i, E data){
    // 获取要插入位置处的结点
    int j = 0;
    Node p = first;
    while (p != null && j < i) {
        p = p.next;
        j++;
    }
    if(p == null || j > i){
        return false;
    }
    // 创建要插入的新结点
    Node<E> newNode = new Node<>();
    newNode.data = data;
    // 新结点的下个结点 p 的后继结点
    newNode.next = p.next;
    // p 的后继结点为新结点
    p.next = newNode;
    return true;
}

这只是一个简易版的代码演示,理解其中的互换思路即可。其中并没有校验插入的元素是否是第一个元素等因素。

单链表的删除

了解了上面的插入思路,单链表的删除就很好理解了。
就是把要删除元素结点的前驱结点指针指向删除结点的后驱结点即可。

了解了单链表的插入和删除后我们发现,其实它们都是由两部分组成,第一部分就是遍历查找到指定结点,然后做插入或删除操作。
由于涉及到遍历,所以在时间复杂度上,都是 O(n)。

既然如此,那么它和链表的顺序存储结构比,又有何优势呢?

没错,当我们不知道指定结点的位置时,单链表的插入和删除,与线性表的顺序存储结构并没有多大优势。

但是,假如我们要往指定位置插入多个元素,那么只需遍历一次,后续的元素直接依次添加即可。并不像链表的顺序存储结构一样,每一个元素的加入都需要移动后续的所有元素。

所以,对于插入和删除数据来说,越是复杂,单链表的优势就越发明显。

单链表结构与顺序存储结构优缺点

存储分配方式上:

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

时间性能:

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

空间性能:

  • 顺序存储结构需要预分配存储空间,分配大了浪费,小了则易发生上溢。
  • 单链表不需要预先分配存储空间,只需在使用的时候分配即可,元素个数也没有限制。

总的来说,线性表的顺序存储结构和单链表结构各有优缺点,我们需要根据我们的实际情况来运用,比如说用户的信息,只有在注册的时候才会写入,其他大多都是查找,那么这种情况顺序存储结构就比较适合了。

静态链表

静态链表:用数组描述的链表。

为什么会有静态链表这个东西呢?我们都知道,在 C 语言中有指针、Java 语言中有引用,都可以使我们很方便的操作内存中的地址和数据。但是在早期的编程语言中,如 Basic、Fortra n 等,是没有指针或类似的概念的。那么这时候,像我们上面讲到的链表结构又该如何去实现呢?

之前的大佬们就想出了一个解决方法:用数组来描述链表。

具体的操作就是,让数组的每个元素由两个数据域组成,分别是 data 和 cur。其中 data 代表的是数据元素,cur 则相当于单链表中的 next 指针,存储该元素后继在数组中的下标,我们把 cur 叫做游标。

为了方便插入数据,通常会把数组建立的大一些,以便不容易在插入数据后溢出。

另外通常还会将数组的第一个和最后一个元素作特殊元素处理,不存数据。把没有被使用到的数组元素称为备用链表。而数组的第一个元素,也就是下标为 0 的元素的 cur 就存放备用链表的第一个结点的下标,而数组的最后一个元素的 cur 则存放第一个有数值的元素的下标。

静态链表的插入操作

假设数组中已经存放了 a、c、d 三个元素,其下标分别为 1、2、3。这时候 b 想插入到 a 的后面,这时候只要先把 b 放到现有元素的后面,也就是数组下标为 4 的位置,然后将 a 的 cur 由 2 改为 4,再将 b 的 cur 改为 2,这时候就在不移动元素的情况下,插入了元素 b。

静态链表的删除操作

假设现在要删除 a 元素,那么在删除后数组下标为 1 的位置就空了,所以就需要将下标为 0 元素的 cur 改为 1,让下次的添加,优先从这个位置添加。

静态链表优缺点

优点:

  • 在插入和删除时,不需要移动元素,只需要修改游标即可。从而改进了在顺序存储结构中插入和删除操作需要移动大量元素的不足。

缺点:

  • 没有解决连续存储分配带来的表长难以确定的问题。
  • 失去了顺序存储结构随机存储的特性。

总的来说,静态链表其实是为了给没有指针的语言设计的一种实现单链表的思路。在实际的情况中可能会很少用到,但是这种这种巧妙的实现思想倒是值得思考理解。

循环链表

对于单链表,由于每个节点只存储了向后的指针。那么到了尾节点,由于尾节点的后续没有节点了,则就无法继续向后操作了。

而循环链表则是将单链表的尾节点由 NULL 改为指向头节点,这样一来,整个链表就头尾相连了起来。这样无论从哪里开始遍历,都能完整遍历整个链表了。

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

双向链表

对于单链表,我们一直都是从头到尾进行查找,没办法从尾到头进行反向查找,因为单链表的结点中,只有 next 一个指针,只能往下查找。

那么为了解决单链表单向性的缺点,老科学家们又设计了双向链表。

双向链表(double linked list):是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。所以在双向链表中,每个结点除了数据域外,还有两个指针域,分别指向其前驱结点和后继结点。

注:在 Java 语言中,LinkedList 的内部实现就是双向链表。

既然单链表可以有循环链表,那么双向链表当然也可以是循环表。想实现一个双向链表的循环表,只需要将其尾结点的 next 指针指向头节点,头节点的 pre 指针指向尾节点即可。

双向链表比单链表多了如可以反向遍历查找数据等优点,那么自然也需要牺牲一些小代价。在插入和删除数据时,需要更改两个指针变量。并且每个结点中多了一个指针域,在空间占用上要稍多一些。

总结回顾

在该篇中,我们学习了线性表,知道了线性表是零个或多个具有相同类型的数据元素的有限序列。

学习了线性表的两大结构:顺序存储结构和链式存储结构。
顺序存储结构是用一组地址连续的存储单元依次存储线性表的元素,查找方便,插入和删除并不方便。
而链式存储结构由于不受固定的存储空间限制,可以比较快捷的进行插入和删除操作。
在链式存储结构中,又分为了单链表、循环链表、双向链表,其中的实现有着些许差别。

总的来说,线性表是最常见也是最简单的一种数据结构,是我们学习数据结构的基础。学会这个后,对我们后面的其他数据结构学习有着很重要的作用。

猜你喜欢

转载自blog.csdn.net/Airsaid/article/details/78528713