考研数据结构考点——第二章线性表

第二章 线性表

三、线性表概念

  • 线性表定义:线性表是具有相同数据类型的 n n n 个数据的有限序列。
  • 数组下标是从 0 开始的,位序是从 1 开始的。

四、顺序表细节操作

  • 可以随机访问、占用存储空间连续、顺序表的插入删除,需要移动多个元素、存储密度高。

  • 插入操作
    • 思想:在顺序表 L 的第 i 个位置插入新元素 e 时,如果输入的 i 不合法,则返回 false,表示插入失败。否则,将顺序表中第 i 个元素及其后面的所有元素向后移动一个位置(从后向前处理),以腾出空位插入新元素 e,顺序表的长度增加 1,最后返回 true。

    • 核心代码

      for(int j = L.length; j >= i; j--){
        L.data[j] = L.data[j-1];
      }
      L.data[i-1] = e;
      
      1. 循环初始化

        • L.length:表示顺序表当前的长度。
        • i:表示要插入新元素 e 的位置。注意在代码中,位置 i 是从 1 开始的。
      2. 循环条件

        • j = L.length:我们从顺序表的最后一个元素开始(即 L.data[L.length-1])。

        • j >= i:我们需要一直移动元素直到到达插入位置 i(这个位置之前的所有元素都需要右移一位)。

        • 每次循环,j 会递减 1。

      3. 元素移动

        • 在循环体内,L.data[j] = L.data[j-1]; 这一行的作用是将顺序表中索引为 j-1 的元素移动到索引 j 的位置。这意味着我们将所有元素从插入位置开始,向后移动一个位置,以腾出插入位置。

          例如,如果我们要在位置 i 插入新元素 e,则:

        • 第一次循环(j = L.length)会将 L.data[L.length - 1] 移动到 L.data[L.length]

        • 第二次循环(j = L.length - 1)会将 L.data[L.length - 2] 移动到 L.data[L.length - 1]

        • 以此类推,直到 j 等于 i

      4. 插入新元素

        • L.data[i-1] = e;:在完成元素移动后,L.data[i-1](即目标插入位置)被赋值为新元素 e。注意这里的 i-1 是因为我们需要将输入的 1-based 索引转换为 0-based 索引。
    • 时间复杂度分析

      • 在最坏情况下(当我们在表头插入时),需要移动所有 n 个元素,因此时间复杂度为 ( O(n) )。

      • 在表尾插入时,由于不需要移动元素,时间复杂度为 ( O(1) )。

    这样的实现方式适合于相对较小的数据量,若数据量较大,可能需要考虑更高效的数据结构,比如链表。

  • 删除操作
    • 思想:在顺序表 L 中删除第 i 个位置的元素时,如果输入的 i 不合法,则返回 false,表示删除失败。否则,将第 i 个元素的值赋给 e,然后将第 i 个位置的元素及其后面的所有元素向左移动一个位置(从前向后处理),顺序表的长度减少 1。删除成功后返回 true。

    • 核心代码

      e = L.data[i-1];
      for(int j = i;j<L.length;++j){
        L.data[j-1] = L.data[j];
      }
      
      1. 提取要删除的元素

        • e = L.data[i-1];:将顺序表中第 i 个位置的元素(1-based 索引)赋值给变量 e。这一步确保我们能够保留被删除的元素值。
      2. 移动元素

        • for(int j = i; j < L.length; ++j):循环从第 i + 1 个元素开始,直到顺序表的最后一个元素。
        • L.data[j-1] = L.data[j];:在每次迭代中,将当前元素 L.data[j] 的值复制到前一个位置 L.data[j-1]。这相当于将所有后续元素向左移动一个位置,以覆盖要删除的元素。

      假设顺序表 L 初始状态如下(L.length = 5,表示有 5 个元素):

      索引01234
      ABCDE

      如果我们要删除第 3 个位置的元素(即 C),执行过程如下:

      1. 提取要删除的元素

        • e = L.data[2]; 结果:e = C
      2. 移动元素

        • for(int j = 3; j < 5; ++j) 开始循环:
          • j = 3 时:L.data[2] = L.data[3];L.data[2] = D
          • j = 4 时:L.data[3] = L.data[4];L.data[3] = E

      最终顺序表如下:

      索引01234
      ABDEE

      顺序表的长度需要更新为 4

    • 时间复杂度分析

      • 在删除操作中,最坏情况下(当我们删除的是表头元素,即第 1 个位置)时,需将后续的所有 n - 1 个元素左移,因此时间复杂度为 ( O(n) )。

      • 当删除的是表尾元素(即第 n 个位置),在这种情况下,只需将顺序表的长度减少 1,而不需要移动其他元素,因此时间复杂度为 ( O(1) )。

五、链表的细节操作

  • 链表的插入和删除操作不需要移动元素,只需修改指针,从而提高了效率。

  • 单链表特点

    • 非随机存取的存储结构:单链表是一种线性数据结构,但其元素存储在内存中并不连续。要查找某个特定元素,需要从头节点开始逐个遍历,时间复杂度为 ( O(n) )。

    • 额外的指针域:每个节点除了存储数据外,还包含一个指向下一个节点的指针。这种设计虽然引入了额外的空间开销,但为动态插入和删除操作提供了便利。

    • 解决了顺序表的缺点:在顺序表中,插入和删除操作需要移动大量元素,而链表的插入和删除操作只需调整指针,因此在频繁修改数据的场景下表现更优。

  • 头结点与头指针

    • 头结点定义:头结点是指在单链表的第一个元素节点之前额外附加的一个节点。其指针域指向单链表的第一个元素节点。引入头结点的原因在于,它使得对链表第一个位置上的操作与对其他位置的操作一致,无需特别处理。这种统一的处理方式使得空表和非空表的处理变得简单。

    • 头指针:头指针始终指向链表的第一个节点,无论链表是否包含头结点。当链表有头结点时,头指针指向头结点;当链表没有头结点时,头指针直接指向第一个元素节点。这样设计的好处是,无论链表的状态如何,头指针始终提供了访问链表的起始点。

  • 单链表操作
    头插法建立单链表

    头插法是通过在链表的头部插入新节点来构建链表。这种方法的特点是可以实现链表的逆序。

    s = (LinkList)malloc(sizeof(LNode)); // 分配新节点的内存
    s->data = x; // 将数据赋值给新节点
    
    s->next = L->next; // 将新节点的指针指向原链表的第一个元素
    L->next = s; // 将头节点的指针指向新节点
    

    解释

    • s = (LinkList)malloc(sizeof(LNode));:分配内存为新节点 s,并将其类型转换为链表节点类型。
    • s->data = x;:将要插入的数据 x 赋值给新节点 s
    • s->next = L->next;:新节点的 next 指针指向当前链表的第一个元素,这样新节点 s 成为链表的第一个元素。
    • L->next = s;:将头节点的 next 指针指向新节点 s,从而更新链表的头部。
    尾插法建立单链表

    尾插法是通过在链表的尾部插入新节点来构建链表。读入数据的顺序与链表中元素的顺序一致。

    s = (LinkList)malloc(sizeof(LNode)); // 分配新节点的内存
    s->data = x; // 将数据赋值给新节点
    
    r->next = s; // 将当前尾节点的指针指向新节点
    r = s; // 更新尾节点为新节点
    r->next = NULL; // 新节点的指针指向 NULL,表示新节点为尾节点
    

    解释

    • s = (LinkList)malloc(sizeof(LNode));:分配内存为新节点 s
    • s->data = x;:将数据 x 赋值给新节点 s
    • r->next = s;:将当前尾节点 rnext 指针指向新节点 s
    • r = s;:更新尾节点 r 为新插入的节点 s
    • r->next = NULL;:将新节点的 next 指针设为 NULL,表示该节点为链表的尾部。
    插入节点

    在给定节点 p 之后插入新节点 s

    s->next = p->next; // 将新节点的指针指向 p 的后继节点
    p->next = s; // 将 p 的指针指向新节点
    

    解释

    • s->next = p->next;:将新节点 snext 指针指向节点 p 的后继节点。
    • p->next = s;:将节点 pnext 指针更新为新节点 s,实现插入操作。
    删除节点

    删除节点 p 的后继节点。

    q = p->next; // 将后继节点赋值给 q
    p->next = q->next; // 将 p 的指针指向 q 的后继节点
    free(q); // 释放 q 节点的内存
    

    解释

    • q = p->next;:获取节点 p 的后继节点 q
    • p->next = q->next;:将节点 pnext 指针指向 q 的后继节点,从而跳过节点 q,实现删除操作。
    • free(q);:释放节点 q 的内存,避免内存泄漏。
  • 双链表
    • 双链表允许通过每个节点访问其直接前驱和后继。这种结构使得在链表中进行插入和删除操作更加灵活和高效。双链表的每个节点包含三个部分:数据域、指向前驱节点的指针和指向后继节点的指针。
    双链表插入操作
    s->next = p->next;
    p->next->prior = s;
    s->prior = p;
    p->next = s;
    

    解释

    1. s->next = p->next;

      • 将新节点 snext 指针指向节点 p 的后继节点。这样做是为了将新节点插入到 p 之后,并连接到 p 后面的节点。
    2. p->next->prior = s;

      • 如果 p 的后继节点存在,更新该后继节点的 prior 指针,使其指向新插入的节点 s。这确保了后继节点的前驱指针正确指向新节点。
    3. s->prior = p;

      • 将新节点 sprior 指针指向节点 p,建立前驱关系,指示 s 的前驱节点是 p
    4. p->next = s;

      • 更新节点 pnext 指针,使其指向新节点 s,完成插入操作。
    双链表删除操作
    p->next = q->next;
    q->next->prior = p;
    free(q);
    

    解释

    1. p->next = q->next;

      • 将节点 pnext 指针更新为 q 的后继节点。这一步跳过了节点 q,实现了删除操作。
    2. q->next->prior = p;

      • 如果 q 的后继节点存在,更新该后继节点的 prior 指针,使其指向节点 p。这样确保了后继节点的前驱指针正确指向 p,保持了链表的完整性。
    3. free(q);

      • 释放节点 q 的内存,避免内存泄漏,完成删除操作。
  • 循环单链表
    • 定义:循环单链表是一种特殊的单链表,其中最后一个节点的指针指向链表的头节点,形成一个闭合的循环结构。

    • 结构:每个节点包含一个数据域和一个指向下一个节点的指针。链表的最后一个节点指向链表的第一个节点,而不是指向 NULL

    • 访问:可以从任意节点开始遍历整个链表,因为最后一个节点指向第一个节点,形成了一个环。

    • 优点:可以在没有头节点的情况下实现循环结构,适合需要循环访问的场景,比如约瑟夫问题。

    • 示例

      typedef struct Node {
          int data;
          struct Node *next;
      } Node;
      
      Node *createCircularLinkedList(int arr[], int size) {
          Node *head = (Node *)malloc(sizeof(Node));
          head->data = arr[0];
          Node *current = head;
      
          for (int i = 1; i < size; i++) {
              Node *newNode = (Node *)malloc(sizeof(Node));
              newNode->data = arr[i];
              current->next = newNode;
              current = newNode;
          }
          current->next = head; // 形成循环
          return head;
      }
      
  • 循环双链表
    • 定义:循环双链表是一种特殊的双链表,其中最后一个节点的后继指针指向头节点,头节点的前驱指针指向最后一个节点,形成一个闭合的循环结构。

    • 结构:每个节点包含数据域、指向前驱节点的指针和指向后继节点的指针。头节点和尾节点相互连接,形成一个环。

    • 访问:从任意节点开始都可以遍历整个链表,因为头节点和尾节点互相指向。

    • 优点:允许双向遍历,并且支持循环访问,适合需要双向访问的场景,如某些操作系统的任务调度。

    • 示例

      typedef struct DNode {
          int data;
          struct DNode *prior;
          struct DNode *next;
      } DNode;
      
      DNode *createCircularDoublyLinkedList(int arr[], int size) {
          DNode *head = (DNode *)malloc(sizeof(DNode));
          head->data = arr[0];
          DNode *current = head;
      
          for (int i = 1; i < size; i++) {
              DNode *newNode = (DNode *)malloc(sizeof(DNode));
              newNode->data = arr[i];
              current->next = newNode;
              newNode->prior = current;
              current = newNode;
          }
          current->next = head; // 形成循环
          head->prior = current; // 尾节点的前驱指向头节点
          return head;
      }
      
  • 静态链表
    • 定义:静态链表是一种基于数组实现的链表结构,每个元素在数组中占用一个固定的空间,通过下标来实现链表节点之间的连接。

    • 结构:使用一个数组来存储节点,每个节点包含数据域和一个指向下一个节点的索引(而不是指针)。

    • 优点:避免了动态内存分配的开销,适合在已知节点数量的情况下使用。

    • 缺点:数组大小固定,无法动态扩展,插入和删除操作复杂。

    • 示例

      #define MAX_SIZE 100
      
      typedef struct StaticNode {
          int data;
          int next; // 存储下一个节点的索引
      } StaticNode;
      
      typedef struct StaticLinkedList {
          StaticNode nodes[MAX_SIZE];
          int head; // 头节点的索引
          int size; // 当前链表的大小
      } StaticLinkedList;
      
      void initStaticLinkedList(StaticLinkedList *list) {
          list->head = -1; // 初始化头节点为 -1,表示空链表
          list->size = 0;
      }
      
  • 指针本身与指针指向的结点

    • 指针的定义:指针是一个变量,其值为另一个变量的地址。指针可以用来直接访问存储在该地址中的数据。在链表结构中,指针通常用于链接节点。

    • 指针本身

      • 定义
        指针本身是一个变量,它存储一个内存地址。这个地址通常是其他变量(如链表节点)的地址。

      • 声明
        在C语言中,指针的声明方式如下:

        Node *ptr; // ptr 是一个指向 Node 类型的指针
        

        这里,Node 是结构体的类型,ptr 是指针变量的名称。

      • 使用
        通过指针,我们可以访问指向的变量或结构的值。可以使用取值操作符 * 来访问指针指向的内容:

        Node temp = *ptr; // 通过 ptr 获取指向的 Node 结构体的值
        
    • 指针指向的节点

      • 节点结构
        在链表中,每个节点通常由数据域和指针域组成。指针域指向下一个节点。

        typedef struct Node {
            int data;          // 数据域
            struct Node *next; // 指向下一个节点的指针
        } Node;
        
      • 指针的作用
        指针指向的节点是指针所引用的实际内存位置。在链表中,每个节点的 next 指针指向下一个节点,这样可以通过指针进行遍历。

    • 指针与节点的关系

      • 创建节点
        当创建一个节点时,通常会分配内存并将指针指向这个节点。

        Node *newNode = (Node *)malloc(sizeof(Node)); // 动态分配内存
        newNode->data = 10; // 设置数据域
        newNode->next = NULL; // 初始化指针域
        
      • 链表连接
        在链表中,指针的作用是将节点连接在一起。通过指针,可以形成一个链式结构。

        Node *head = newNode; // 头指针指向第一个节点
        Node *secondNode = (Node *)malloc(sizeof(Node));
        secondNode->data = 20;
        secondNode->next = NULL;
        newNode->next = secondNode; // 将第一个节点的指针指向第二个节点
        
    • 访问和修改节点

      • 访问节点数据
        通过指针,可以轻松访问或修改节点的数据。

        printf("%d\n", head->data); // 访问头节点的数据
        head->data = 30; // 修改头节点的数据
        
      • 遍历链表
        使用指针可以遍历整个链表,从头节点开始,依次访问每个节点。

        Node *current = head; // 指向当前节点
        while (current != NULL) {
            printf("%d ", current->data);
            current = current->next; // 移动到下一个节点
        }
        

六、链表与顺序表的比较与选择

1. 内存使用
  • 顺序表(数组)
    • 连续内存:顺序表在内存中占用一段连续的空间。
    • 固定大小:在创建时通常需要确定大小,动态扩展需要重新分配内存并复制数据。
    • 内存利用率:可能存在空间浪费(预留的未使用空间)或空间不足(需要扩展)。
  • 链表
    • 非连续内存:链表的节点在内存中可以分散存储,通过指针连接。
    • 动态大小:可以根据需要动态增加或减少节点,内存利用更灵活。
    • 额外空间开销:每个节点需要额外的指针(单链表一个指针,双链表两个指针),增加了空间开销。
2. 访问时间
  • 顺序表

    • 随机访问:支持常数时间 ( O(1) ) 的随机访问,通过索引直接访问任意元素。
    • 遍历效率高:由于内存连续,遍历时访问速度快。
  • 链表

    • 顺序访问:只能从头节点开始,依次访问每个节点,访问任意元素的时间复杂度为 ( O(n) )。
    • 不支持随机访问:无法通过索引直接访问元素,需要逐一遍历。
3. 插入和删除
  • 顺序表

    • 插入和删除:在中间位置插入或删除元素需要移动大量元素,时间复杂度为 ( O(n) )。
    • 表尾插入高效:在表尾插入元素,如果有足够空间,时间复杂度为 ( O(1) )。
  • 链表

    • 插入和删除高效:只需修改指针,时间复杂度为 ( O(1) )(前提是已知插入或删除的位置)。
    • 无需移动元素:节点的插入和删除不会影响其他节点的位置。
4. 缓存性能
  • 顺序表

    • 缓存友好:由于内存连续,顺序表在遍历时更好地利用了 CPU 缓存,提升访问速度。
  • 链表

    • 缓存不友好:节点分散存储,访问时可能频繁发生缓存未命中,导致访问速度较慢。
5. 内存分配
  • 顺序表

    • 静态内存分配:通常在编译时确定大小,或者通过动态数组在运行时分配。
    • 重新分配成本高:当需要扩展数组时,需要分配新内存并复制数据,开销较大。
  • 链表

    • 动态内存分配:节点按需分配,灵活应对不同大小需求。
    • 分配效率:每次插入只需分配一个节点,避免了大规模内存复制。
6. 实现复杂度
  • 顺序表

    • 实现简单:数组的实现和操作较为直接,代码简洁。
    • 操作有限:插入和删除等操作需要处理元素移动,代码相对复杂。
  • 链表

    • 实现较复杂:需要管理指针,确保节点连接正确,容易出现指针错误。
    • 灵活性高:支持高效的插入和删除操作,适应性强。
7. 灵活性
  • 顺序表

    • 灵活性较低:大小固定或需要重新分配内存,扩展性受限。
    • 适合静态数据:当数据量和需求相对稳定时,顺序表表现良好。
  • 链表

    • 高度灵活:动态调整大小,适应数据量变化。
    • 适合动态数据:当数据频繁变化,插入和删除操作频繁时,链表更具优势。
8. 适用场景
  • 顺序表适用场景

    • 需要频繁随机访问:如实现栈、队列、查找表等。
    • 数据量相对固定:如存储常量集合、静态数据等。
    • 内存连续性重要:如需要高效缓存利用的场景。
  • 链表适用场景

    • 需要频繁插入和删除:如实现动态列表、图的邻接表等。
    • 数据量动态变化:如实时数据处理、动态任务调度等。
    • 内存分散可接受:不要求数据连续存储。
9. 性能比较表
特性顺序表(数组)链表
内存使用连续内存,固定或动态大小,可能有空间浪费非连续内存,动态大小,需额外指针空间
访问时间随机访问 ( O(1) ),遍历效率高顺序访问 ( O(n) ),遍历效率较低
插入和删除中间插入删除需移动元素 ( O(n) ),表尾插入 ( O(1) )插入删除只需修改指针 ( O(1) )
缓存性能高,利用 CPU 缓存更好低,节点分散导致缓存未命中
内存分配需要预分配或动态扩展,扩展成本高动态分配,插入删除开销小
实现复杂度实现简单,操作直接实现复杂,需管理指针关系
灵活性低,大小固定或需重新分配内存高,动态调整大小
适用场景需要频繁随机访问,数据量相对固定,内存连续性重要需要频繁插入删除,数据量动态变化,内存分散可接受
选择指南
  • 选择顺序表

    • 当需要频繁的随机访问,且插入删除操作较少。
    • 当数据量相对固定或变化不大,且内存连续性有利于性能优化。
    • 如实现数组、栈、队列、查找表等数据结构。
  • 选择链表

    • 当需要频繁的插入和删除操作,且对随机访问要求不高。
    • 当数据量动态变化,且不希望频繁重新分配内存。
    • 如实现动态列表、图的邻接表、任务调度等应用。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

全栈ing小甘

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

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

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

打赏作者

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

抵扣说明:

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

余额充值