MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C 语言实现链表深入讲解

2022-04-217.3k 阅读

链表的基本概念

链表是一种常见的基础数据结构,它由一系列节点(node)组成,每个节点包含两部分:数据部分和指针部分。指针部分用于指向下一个节点,从而将各个节点串联起来,形成一条“链”。与数组不同,链表的内存分配不是连续的,它可以根据需要动态地分配和释放内存,这使得链表在处理动态数据时具有很高的灵活性。

C 语言中链表的实现基础

在 C 语言中,我们通过结构体(struct)来定义链表的节点。例如,定义一个简单的整数链表节点:

struct ListNode {
    int data;
    struct ListNode *next;
};

这里,data 成员用于存储节点的数据,类型为 intnext 成员是一个指针,指向同类型的下一个节点。通过这种方式,我们构建了链表节点的基本结构。

链表的创建与初始化

  1. 单个节点的创建 要创建一个链表,首先要从创建单个节点开始。以下是创建单个链表节点的函数:
struct ListNode* createNode(int value) {
    struct ListNode* newNode = (struct ListNode*)malloc(sizeof(struct ListNode));
    if (newNode == NULL) {
        printf("内存分配失败\n");
        return NULL;
    }
    newNode->data = value;
    newNode->next = NULL;
    return newNode;
}

这个函数 createNode 接受一个整数值 value,使用 malloc 函数为新节点分配内存。如果内存分配成功,将传入的值赋给 data 成员,并将 next 指针初始化为 NULL,表示这是链表的最后一个节点。

  1. 链表的初始化 链表的初始化通常是创建一个头节点(有时头节点也可以不存储实际数据,仅作为链表的入口标识)。以下是初始化链表的代码:
struct ListNode* initializeList() {
    return NULL;
}

这里简单地返回 NULL,表示一个空链表。在后续操作中,我们可以通过向链表中添加节点来构建完整的链表。

链表的插入操作

  1. 头插法 头插法是将新节点插入到链表头部的操作。这是一种常用的插入方式,因为它的时间复杂度为 O(1)。以下是头插法的实现代码:
struct ListNode* insertAtHead(struct ListNode* head, int value) {
    struct ListNode* newNode = createNode(value);
    if (newNode == NULL) {
        return head;
    }
    newNode->next = head;
    return newNode;
}

在这个函数中,首先调用 createNode 函数创建一个新节点。然后,将新节点的 next 指针指向当前的头节点 head,最后返回新节点,此时新节点成为了新的头节点。

  1. 尾插法 尾插法是将新节点插入到链表尾部的操作。这种方法需要遍历链表找到最后一个节点,时间复杂度为 O(n),其中 n 是链表的长度。代码实现如下:
struct ListNode* insertAtTail(struct ListNode* head, int value) {
    struct ListNode* newNode = createNode(value);
    if (newNode == NULL) {
        return head;
    }
    if (head == NULL) {
        return newNode;
    }
    struct ListNode* current = head;
    while (current->next != NULL) {
        current = current->next;
    }
    current->next = newNode;
    return head;
}

首先创建新节点,若链表为空,则直接返回新节点作为头节点。否则,通过遍历链表找到最后一个节点(即 current->nextNULL 的节点),然后将最后一个节点的 next 指针指向新节点。

  1. 指定位置插入 在链表的指定位置插入节点稍微复杂一些。需要先找到指定位置的前一个节点,然后进行插入操作。假设位置从 0 开始计数,代码如下:
struct ListNode* insertAtPosition(struct ListNode* head, int value, int position) {
    if (position < 0) {
        printf("无效的位置\n");
        return head;
    }
    if (position == 0) {
        return insertAtHead(head, value);
    }
    struct ListNode* newNode = createNode(value);
    if (newNode == NULL) {
        return head;
    }
    struct ListNode* current = head;
    int count = 0;
    while (current != NULL && count < position - 1) {
        current = current->next;
        count++;
    }
    if (current == NULL) {
        printf("位置超出链表长度\n");
        free(newNode);
        return head;
    }
    newNode->next = current->next;
    current->next = newNode;
    return head;
}

首先检查位置的有效性,如果位置为 0,直接调用头插法。否则,遍历链表找到指定位置的前一个节点。若找到,则将新节点插入到该位置;若位置超出链表长度,则释放新节点并返回原链表。

链表的删除操作

  1. 删除头节点 删除链表的头节点是一个相对简单的操作。只需要将头节点指向下一个节点,并释放原头节点的内存。代码如下:
struct ListNode* deleteHead(struct ListNode* head) {
    if (head == NULL) {
        printf("链表为空,无法删除\n");
        return NULL;
    }
    struct ListNode* temp = head;
    head = head->next;
    free(temp);
    return head;
}

这里使用一个临时指针 temp 保存原头节点,然后将 head 指针指向下一个节点,最后释放原头节点的内存。

  1. 删除尾节点 删除尾节点需要找到链表的倒数第二个节点,然后将其 next 指针设为 NULL,并释放尾节点的内存。实现代码如下:
struct ListNode* deleteTail(struct ListNode* head) {
    if (head == NULL) {
        printf("链表为空,无法删除\n");
        return NULL;
    }
    if (head->next == NULL) {
        free(head);
        return NULL;
    }
    struct ListNode* current = head;
    while (current->next->next != NULL) {
        current = current->next;
    }
    struct ListNode* temp = current->next;
    current->next = NULL;
    free(temp);
    return head;
}

如果链表只有一个节点,直接释放该节点并返回 NULL。否则,通过遍历找到倒数第二个节点,使用临时指针 temp 保存尾节点,将倒数第二个节点的 next 指针设为 NULL,然后释放尾节点的内存。

  1. 删除指定位置节点 删除链表中指定位置的节点,类似于指定位置插入。需要先找到指定位置的前一个节点,然后调整指针并释放目标节点的内存。假设位置从 0 开始计数,代码如下:
struct ListNode* deleteAtPosition(struct ListNode* head, int position) {
    if (position < 0) {
        printf("无效的位置\n");
        return head;
    }
    if (position == 0) {
        return deleteHead(head);
    }
    struct ListNode* current = head;
    int count = 0;
    while (current != NULL && count < position - 1) {
        current = current->next;
        count++;
    }
    if (current == NULL || current->next == NULL) {
        printf("位置超出链表长度\n");
        return head;
    }
    struct ListNode* temp = current->next;
    current->next = current->next->next;
    free(temp);
    return head;
}

首先检查位置的有效性,若位置为 0,调用删除头节点的函数。然后遍历链表找到指定位置的前一个节点,若找到且目标节点存在,则调整指针并释放目标节点的内存。

链表的遍历与输出

遍历链表是链表操作中非常常见的需求,用于查看链表中的所有数据。以下是遍历链表并输出节点数据的代码:

void traverseList(struct ListNode* head) {
    struct ListNode* current = head;
    while (current != NULL) {
        printf("%d -> ", current->data);
        current = current->next;
    }
    printf("NULL\n");
}

通过一个 while 循环,从链表头开始,依次输出每个节点的数据,并在节点之间用 -> 连接,最后输出 NULL 表示链表结束。

链表的应用场景

  1. 动态数据存储 链表适用于需要动态添加和删除数据的场景,例如实现栈、队列等数据结构。在栈的实现中,可以使用链表的头插法和删除头节点操作来实现栈的 push 和 pop 操作;在队列的实现中,可以使用尾插法和删除头节点操作来实现队列的 enqueue 和 dequeue 操作。
  2. 内存管理 在一些需要频繁分配和释放内存的场景中,链表可以有效地管理内存。例如,在操作系统的内存分配算法中,链表可以用来记录空闲内存块的信息,当有新的内存需求时,可以从链表中找到合适的空闲块进行分配。
  3. 实现图的数据结构 在图的邻接表表示中,链表起着重要的作用。每个顶点可以通过链表来存储其邻接顶点的信息,这种表示方式在处理稀疏图时具有很高的效率。

双向链表

  1. 双向链表的概念 双向链表是链表的一种扩展,它的每个节点除了包含指向下一个节点的指针(next)外,还包含一个指向前一个节点的指针(prev)。这使得双向链表在遍历和操作上更加灵活,可以从两个方向进行遍历,并且在删除和插入节点时,不需要像单向链表那样必须找到前一个节点。双向链表节点的定义如下:
struct DoublyListNode {
    int data;
    struct DoublyListNode *prev;
    struct DoublyListNode *next;
};
  1. 双向链表的插入操作
    • 头插法
      struct DoublyListNode* insertAtHeadDoubly(struct DoublyListNode* head, int value) {
          struct DoublyListNode* newNode = (struct DoublyListNode*)malloc(sizeof(struct DoublyListNode));
          if (newNode == NULL) {
              printf("内存分配失败\n");
              return head;
          }
          newNode->data = value;
          newNode->prev = NULL;
          newNode->next = head;
          if (head != NULL) {
              head->prev = newNode;
          }
          return newNode;
      }
      
    • 尾插法
      struct DoublyListNode* insertAtTailDoubly(struct DoublyListNode* head, int value) {
          struct DoublyListNode* newNode = (struct DoublyListNode*)malloc(sizeof(struct DoublyListNode));
          if (newNode == NULL) {
              printf("内存分配失败\n");
              return head;
          }
          newNode->data = value;
          newNode->next = NULL;
          if (head == NULL) {
              newNode->prev = NULL;
              return newNode;
          }
          struct DoublyListNode* current = head;
          while (current->next != NULL) {
              current = current->next;
          }
          current->next = newNode;
          newNode->prev = current;
          return head;
      }
      
  2. 双向链表的删除操作
    • 删除头节点
      struct DoublyListNode* deleteHeadDoubly(struct DoublyListNode* head) {
          if (head == NULL) {
              printf("链表为空,无法删除\n");
              return NULL;
          }
          struct DoublyListNode* temp = head;
          head = head->next;
          if (head != NULL) {
              head->prev = NULL;
          }
          free(temp);
          return head;
      }
      
    • 删除尾节点
      struct DoublyListNode* deleteTailDoubly(struct DoublyListNode* head) {
          if (head == NULL) {
              printf("链表为空,无法删除\n");
              return NULL;
          }
          if (head->next == NULL) {
              free(head);
              return NULL;
          }
          struct DoublyListNode* current = head;
          while (current->next->next != NULL) {
              current = current->next;
          }
          struct DoublyListNode* temp = current->next;
          current->next = NULL;
          free(temp);
          return head;
      }
      
  3. 双向链表的遍历
    • 正向遍历
      void traverseListForwardDoubly(struct DoublyListNode* head) {
          struct DoublyListNode* current = head;
          while (current != NULL) {
              printf("%d -> ", current->data);
              current = current->next;
          }
          printf("NULL\n");
      }
      
    • 反向遍历
      void traverseListBackwardDoubly(struct DoublyListNode* head) {
          if (head == NULL) {
              return;
          }
          struct DoublyListNode* current = head;
          while (current->next != NULL) {
              current = current->next;
          }
          while (current != NULL) {
              printf("%d -> ", current->data);
              current = current->prev;
          }
          printf("NULL\n");
      }
      

循环链表

  1. 循环链表的概念 循环链表是链表的另一种变体,其特点是链表的最后一个节点的 next 指针不再指向 NULL,而是指向链表的头节点,从而形成一个环形结构。循环链表可以是单向的,也可以是双向的。单向循环链表节点的定义与单向链表类似:
struct CircularListNode {
    int data;
    struct CircularListNode *next;
};
  1. 单向循环链表的创建与初始化

    struct CircularListNode* initializeCircularList() {
        return NULL;
    }
    struct CircularListNode* createCircularNode(int value) {
        struct CircularListNode* newNode = (struct CircularListNode*)malloc(sizeof(struct CircularListNode));
        if (newNode == NULL) {
            printf("内存分配失败\n");
            return NULL;
        }
        newNode->data = value;
        newNode->next = newNode;
        return newNode;
    }
    

    这里创建新节点时,将其 next 指针初始化为自身,因为此时链表只有一个节点,形成了一个自环。

  2. 单向循环链表的插入操作

    • 头插法
      struct CircularListNode* insertAtHeadCircular(struct CircularListNode* head, int value) {
          struct CircularListNode* newNode = createCircularNode(value);
          if (newNode == NULL) {
              return head;
          }
          if (head == NULL) {
              return newNode;
          }
          struct CircularListNode* current = head;
          while (current->next != head) {
              current = current->next;
          }
          newNode->next = head;
          current->next = newNode;
          return newNode;
      }
      
    • 尾插法
      struct CircularListNode* insertAtTailCircular(struct CircularListNode* head, int value) {
          return insertAtHeadCircular(head, value);
      }
      
      在单向循环链表中,尾插法和头插法本质上是类似的,因为链表是环形的。
  3. 单向循环链表的删除操作

    • 删除头节点
      struct CircularListNode* deleteHeadCircular(struct CircularListNode* head) {
          if (head == NULL) {
              printf("链表为空,无法删除\n");
              return NULL;
          }
          if (head->next == head) {
              free(head);
              return NULL;
          }
          struct CircularListNode* current = head;
          while (current->next != head) {
              current = current->next;
          }
          struct CircularListNode* temp = head;
          head = head->next;
          current->next = head;
          free(temp);
          return head;
      }
      
  4. 单向循环链表的遍历

    void traverseCircularList(struct CircularListNode* head) {
        if (head == NULL) {
            return;
        }
        struct CircularListNode* current = head;
        do {
            printf("%d -> ", current->data);
            current = current->next;
        } while (current != head);
        printf("(回到头节点)\n");
    }
    

    使用 do - while 循环进行遍历,确保至少遍历一次头节点,并且通过判断 current 是否回到头节点来结束循环。

链表操作的时间复杂度分析

  1. 插入操作

    • 头插法:无论是单向链表、双向链表还是单向循环链表,头插法的时间复杂度都是 O(1),因为它只需要进行常数次的指针操作。
    • 尾插法:单向链表的尾插法需要遍历链表找到尾节点,时间复杂度为 O(n),其中 n 是链表的长度;双向链表和单向循环链表的尾插法虽然实现方式不同,但同样需要遍历链表,时间复杂度也为 O(n)。
    • 指定位置插入:单向链表和双向链表的指定位置插入都需要先找到指定位置的前一个节点,时间复杂度为 O(n),因为最坏情况下需要遍历整个链表。
  2. 删除操作

    • 删除头节点:单向链表、双向链表和单向循环链表删除头节点的时间复杂度都是 O(1),因为只需要进行少量的指针操作和内存释放。
    • 删除尾节点:单向链表和双向链表删除尾节点需要遍历找到倒数第二个节点,时间复杂度为 O(n);单向循环链表删除尾节点同样需要遍历链表,时间复杂度也为 O(n)。
    • 删除指定位置节点:单向链表和双向链表删除指定位置节点都需要先找到指定位置的前一个节点,时间复杂度为 O(n)。
  3. 遍历操作 无论是单向链表、双向链表还是单向循环链表,遍历整个链表的时间复杂度都是 O(n),因为需要访问链表中的每个节点一次。

链表在实际项目中的优化与注意事项

  1. 内存管理优化 在使用链表时,频繁的内存分配和释放可能会导致内存碎片问题。为了减少内存碎片,可以采用内存池(memory pool)技术。内存池是预先分配一块较大的内存区域,然后从这个区域中分配小块内存供链表节点使用。当节点被删除时,将其内存归还给内存池,而不是直接释放给系统。这样可以减少系统级的内存分配和释放次数,提高内存使用效率。

  2. 链表长度维护 在一些应用场景中,记录链表的长度是有必要的。例如,在实现栈或队列时,知道当前元素个数可以方便进行一些边界检查。可以在链表结构体中添加一个成员变量来记录链表的长度,在插入和删除操作时更新这个变量。这样在获取链表长度时,时间复杂度可以降低到 O(1),而不需要遍历链表。

  3. 链表的安全性 在链表操作中,要注意指针的有效性。例如,在删除节点时,要确保节点存在;在遍历链表时,要避免空指针引用。可以在关键操作前进行指针有效性检查,例如:

if (head!= NULL) {
    // 进行链表操作
}

此外,在多线程环境下使用链表时,需要考虑线程安全问题。可以使用互斥锁(mutex)来保护对链表的操作,确保在同一时间只有一个线程可以修改链表,防止数据竞争和不一致问题。

  1. 链表与其他数据结构的结合 在实际项目中,链表常常与其他数据结构结合使用。例如,哈希表(hash table)可以与链表结合来解决哈希冲突。当哈希函数计算出的哈希值相同时,使用链表来存储这些冲突的元素。这种结合方式既利用了哈希表的快速查找特性,又通过链表解决了哈希冲突问题,提高了数据存储和查找的效率。

总结链表的优势与不足

  1. 优势

    • 动态性:链表可以根据需要动态地分配和释放内存,非常适合处理动态变化的数据,如在实时数据处理系统中,数据量和数据结构可能随时改变,链表能够很好地适应这种变化。
    • 灵活性:链表的插入和删除操作在时间复杂度上具有优势(特别是头插法和删除头节点操作),并且不需要像数组那样考虑内存移动的问题。这使得链表在实现栈、队列、图等数据结构时非常方便。
    • 内存利用率:由于链表的内存分配是离散的,不会像数组那样可能因为连续内存空间不足而导致分配失败。在内存资源有限的情况下,链表能够更有效地利用内存。
  2. 不足

    • 随机访问困难:链表不支持随机访问,要访问链表中的第 n 个节点,需要从头开始遍历链表,时间复杂度为 O(n)。而数组可以通过索引直接访问任意位置的元素,时间复杂度为 O(1)。因此,在需要频繁随机访问数据的场景中,数组更适合。
    • 额外空间开销:链表的每个节点除了存储数据外,还需要额外的空间存储指针。对于大量数据的存储,这些指针所占用的空间可能会变得相当可观,从而增加了内存开销。
    • 遍历效率相对较低:虽然链表的遍历时间复杂度为 O(n),与数组相同,但由于链表的内存不连续,在现代计算机的缓存机制下,链表的遍历效率可能会低于数组。因为数组的连续内存访问更有利于缓存命中,而链表的离散内存访问可能导致更多的缓存未命中,从而增加了访问时间。

通过深入理解链表的原理、实现方式以及其在不同场景下的优势和不足,我们能够在实际编程中更加合理地选择和使用链表这种数据结构,从而优化程序的性能和资源利用效率。无论是在小型的算法实现,还是大型的系统开发中,链表都有着广泛的应用,是每个 C 语言开发者需要掌握的重要知识之一。

以上就是关于 C 语言实现链表的详细讲解,希望通过这些内容,读者能够对链表有更深入的认识,并能在实际项目中灵活运用链表解决相关问题。在实际应用中,还需要根据具体的需求和场景,对链表进行适当的优化和扩展,以满足不同的性能和功能要求。同时,不断地实践和探索更多链表相关的算法和技巧,将有助于提升编程能力和解决复杂问题的能力。