|
| 1 | +\title{"深入理解并实现基本的循环链表(Circular Linked List)数据结构"} |
| 2 | +\author{"黄梓淳"} |
| 3 | +\date{"Sep 04, 2025"} |
| 4 | +\maketitle |
| 5 | +在日常生活中,我们经常遇到循环的场景,比如环形跑道上的跑步者、圆桌会议中的参与者轮流发言,这些场景都体现了循环的连续性。在计算机科学中,数据结构也需要模拟这种循环特性,这就是循环链表诞生的背景。首先,让我们回顾一下单链表。单链表是一种线性数据结构,每个节点包含数据和指向下一个节点的指针,但它的尾部节点指向 \texttt{NULL},这意味着无法直接从尾部快速访问头部或其他节点,在某些操作中效率较低,例如在尾部插入或删除时可能需要遍历整个链表。\par |
| 6 | +循环链表的核心思想是将链表的头尾相连,形成一个环状结构。这种设计解决了单链表的一些局限性,例如在约瑟夫问题、轮询调度算法或多人游戏循环中,循环链表可以提供更高效的解决方案。本文将带你深入理解循环链表的概念、特性、实现方式、关键操作以及应用场景,并通过代码示例帮助你掌握其实现。\par |
| 7 | +\chapter{初窥门径:什么是循环链表?} |
| 8 | +循环链表是一种特殊的链表结构,其中最后一个节点的指针域指向头节点(或第一个节点),从而形成一个闭环。与普通链表不同,循环链表没有真正的头尾之分,任何节点都可以作为遍历的起点。这使得从任意节点出发都能访问整个链表,增强了灵活性。\par |
| 9 | +循环链表主要有两种类型:单向循环链表和双向循环链表。单向循环链表中,每个节点只包含一个指向下一个节点的指针;而双向循环链表中,每个节点还包含一个指向前一个节点的指针,允许双向遍历。本文将重点探讨单向循环链表的实现,并在后续部分简要介绍双向循环链表。\par |
| 10 | +由于无法使用图片,我们可以用文字描述循环链表的可视化:想象一个节点序列,其中每个节点指向下一个,最后一个节点指向第一个节点,形成一个环形结构。例如,在单向循环链表中,节点 A 指向节点 B,节点 B 指向节点 C,节点 C 指回节点 A,如此循环。\par |
| 11 | +\chapter{庖丁解牛:实现单向循环链表(Singly Circular Linked List)} |
| 12 | +\section{节点结构定义 (Node Class)} |
| 13 | +在实现单向循环链表之前,我们需要定义节点的结构。节点通常包含两个部分:数据域和指针域。数据域存储实际的数据,指针域指向下一个节点。以下是一个示例代码,使用 Java 和 Python 语言。\par |
| 14 | +\begin{lstlisting}[language=java] |
| 15 | +// Java 实现节点类 |
| 16 | +class Node { |
| 17 | + int data; |
| 18 | + Node next; |
| 19 | + Node(int data) { |
| 20 | + this.data = data; |
| 21 | + this.next = null; // 初始化时指针指向 null,后续在插入操作中形成环 |
| 22 | + } |
| 23 | +} |
| 24 | +\end{lstlisting} |
| 25 | +\begin{lstlisting}[language=python] |
| 26 | +# Python 实现节点类 |
| 27 | +class Node: |
| 28 | + def __init__(self, data): |
| 29 | + self.data = data |
| 30 | + self.next = None |
| 31 | +\end{lstlisting} |
| 32 | +在这段代码中,我们定义了一个 \texttt{Node} 类,其中 \texttt{data} 字段存储整型数据(在 Python 中可以是任何类型),\texttt{next} 字段初始化为 \texttt{None} 或 \texttt{null},表示暂时没有指向其他节点。在循环链表的插入操作中,我们会调整 \texttt{next} 指针以形成循环。这种设计确保了节点的灵活性,便于后续操作。\par |
| 33 | +\section{链表类框架 (LinkedList Class)} |
| 34 | +接下来,我们定义链表类来管理节点。对于单向循环链表,通常使用一个 \texttt{tail} 指针指向尾节点,而不是 \texttt{head} 指针。这是因为通过 \texttt{tail.next} 可以快速访问头节点,从而简化某些操作,例如在尾部插入节点的时间复杂度可以降低到 $O(1)$。以下是链表类的基本框架。\par |
| 35 | +\begin{lstlisting}[language=java] |
| 36 | +// Java 实现链表类 |
| 37 | +public class CircularLinkedList { |
| 38 | + private Node tail; // 尾节点指针 |
| 39 | + // 构造函数初始化链表为空 |
| 40 | + public CircularLinkedList() { |
| 41 | + this.tail = null; |
| 42 | + } |
| 43 | +} |
| 44 | +\end{lstlisting} |
| 45 | +\begin{lstlisting}[language=python] |
| 46 | +# Python 实现链表类 |
| 47 | +class CircularLinkedList: |
| 48 | + def __init__(self): |
| 49 | + self.tail = None # 尾节点指针 |
| 50 | +\end{lstlisting} |
| 51 | +这里,我们只维护一个 \texttt{tail} 指针。当链表为空时,\texttt{tail} 为 \texttt{None};当链表非空时,\texttt{tail} 指向尾节点,而 \texttt{tail.next} 指向头节点。这种设计优化了尾部操作,但需要注意在插入和删除时维护循环性。\par |
| 52 | +\section{核心操作详解} |
| 53 | +\subsection{判断链表是否为空 (isEmpty)} |
| 54 | +判断链表是否为空是一个简单但重要的操作,它检查 \texttt{tail} 指针是否为 \texttt{null}。如果为空,表示链表没有节点;否则,链表至少有一个节点。代码实现如下。\par |
| 55 | +\begin{lstlisting}[language=java] |
| 56 | +// Java 实现 isEmpty 方法 |
| 57 | +public boolean isEmpty() { |
| 58 | + return tail == null; |
| 59 | +} |
| 60 | +\end{lstlisting} |
| 61 | +\begin{lstlisting}[language=python] |
| 62 | +# Python 实现 is_empty 方法 |
| 63 | +def is_empty(self): |
| 64 | + return self.tail is None |
| 65 | +\end{lstlisting} |
| 66 | +这段代码通过检查 \texttt{tail} 是否为空来返回布尔值。时间复杂度为 $O(1)$,因为它只涉及指针比较。在实际应用中,这个操作常用于前置检查,避免在空链表上执行无效操作。\par |
| 67 | +\subsection{在链表尾部插入节点 (insertAtEnd)} |
| 68 | +在尾部插入节点是循环链表的常见操作。我们需要处理两种场景:链表为空和非空。如果链表为空,新节点将自环(即 \texttt{next} 指向自身),并成为尾节点;如果链表非空,新节点插入到尾节点之后,并更新尾指针。以下是代码实现。\par |
| 69 | +\begin{lstlisting}[language=java] |
| 70 | +// Java 实现 insertAtEnd 方法 |
| 71 | +public void insertAtEnd(int data) { |
| 72 | + Node newNode = new Node(data); |
| 73 | + if (isEmpty()) { |
| 74 | + newNode.next = newNode; // 自环 |
| 75 | + tail = newNode; |
| 76 | + } else { |
| 77 | + newNode.next = tail.next; // 新节点指向头节点 |
| 78 | + tail.next = newNode; // 原尾节点指向新节点 |
| 79 | + tail = newNode; // 更新尾指针 |
| 80 | + } |
| 81 | +} |
| 82 | +\end{lstlisting} |
| 83 | +\begin{lstlisting}[language=python] |
| 84 | +# Python 实现 insert_at_end 方法 |
| 85 | +def insert_at_end(self, data): |
| 86 | + new_node = Node(data) |
| 87 | + if self.is_empty(): |
| 88 | + new_node.next = new_node # 自环 |
| 89 | + self.tail = new_node |
| 90 | + else: |
| 91 | + new_node.next = self.tail.next # 新节点指向头节点 |
| 92 | + self.tail.next = new_node # 原尾节点指向新节点 |
| 93 | + self.tail = new_node # 更新尾指针 |
| 94 | +\end{lstlisting} |
| 95 | +在这段代码中,我们首先创建新节点。如果链表为空,新节点的 \texttt{next} 指向自身,形成自环,并设置 \texttt{tail} 为新节点。如果链表非空,新节点的 \texttt{next} 指向当前头节点(通过 \texttt{tail.next} 访问),然后原尾节点的 \texttt{next} 指向新节点,最后更新 \texttt{tail} 为新节点。这个过程确保了循环性,时间复杂度为 $O(1)$,因为它不需要遍历。\par |
| 96 | +\subsection{在链表头部插入节点 (insertAtFront)} |
| 97 | +头部插入操作类似尾部插入,但不需要更新尾指针(除非链表为空)。如果链表为空,操作与尾部插入相同;否则,新节点插入到头节点之前,并调整指针以维持循环。代码实现如下。\par |
| 98 | +\begin{lstlisting}[language=java] |
| 99 | +// Java 实现 insertAtFront 方法 |
| 100 | +public void insertAtFront(int data) { |
| 101 | + Node newNode = new Node(data); |
| 102 | + if (isEmpty()) { |
| 103 | + newNode.next = newNode; |
| 104 | + tail = newNode; |
| 105 | + } else { |
| 106 | + newNode.next = tail.next; // 新节点指向当前头节点 |
| 107 | + tail.next = newNode; // 尾节点指向新节点,使其成为新头 |
| 108 | + } |
| 109 | +} |
| 110 | +\end{lstlisting} |
| 111 | +\begin{lstlisting}[language=python] |
| 112 | +# Python 实现 insert_at_front 方法 |
| 113 | +def insert_at_front(self, data): |
| 114 | + new_node = Node(data) |
| 115 | + if self.is_empty(): |
| 116 | + new_node.next = new_node |
| 117 | + self.tail = new_node |
| 118 | + else: |
| 119 | + new_node.next = self.tail.next # 新节点指向当前头节点 |
| 120 | + self.tail.next = new_node # 尾节点指向新节点,使其成为新头 |
| 121 | +\end{lstlisting} |
| 122 | +这里,如果链表为空,我们进行自环设置;否则,新节点的 \texttt{next} 指向当前头节点(\texttt{tail.next}),然后更新尾节点的 \texttt{next} 指向新节点,从而使新节点成为头节点。注意,尾指针 \texttt{tail} 没有改变,因为头节点变化不影响尾节点。时间复杂度为 $O(1)$。\par |
| 123 | +\subsection{删除头节点 (deleteFromFront)} |
| 124 | +删除头节点涉及调整指针以移除头节点,并维护循环性。场景包括链表为空、只有一个节点或多个节点。如果链表为空,直接返回;如果只有一个节点,将尾指针置空;否则,将尾节点的 \texttt{next} 指向头节点的下一个节点。代码实现如下。\par |
| 125 | +\begin{lstlisting}[language=java] |
| 126 | +// Java 实现 deleteFromFront 方法 |
| 127 | +public void deleteFromFront() { |
| 128 | + if (isEmpty()) { |
| 129 | + System.out.println("链表为空,无法删除"); |
| 130 | + return; |
| 131 | + } |
| 132 | + if (tail.next == tail) { // 只有一个节点 |
| 133 | + tail = null; |
| 134 | + } else { |
| 135 | + tail.next = tail.next.next; // 跳过头节点 |
| 136 | + } |
| 137 | +} |
| 138 | +\end{lstlisting} |
| 139 | +\begin{lstlisting}[language=python] |
| 140 | +# Python 实现 delete_from_front 方法 |
| 141 | +def delete_from_front(self): |
| 142 | + if self.is_empty(): |
| 143 | + print("链表为空,无法删除") |
| 144 | + return |
| 145 | + if self.tail.next == self.tail: # 只有一个节点 |
| 146 | + self.tail = None |
| 147 | + else: |
| 148 | + self.tail.next = self.tail.next.next # 跳过头节点 |
| 149 | +\end{lstlisting} |
| 150 | +在这段代码中,我们首先检查链表是否为空。如果只有一个节点,直接设置 \texttt{tail} 为 \texttt{None} 以清空链表;否则,通过 \texttt{tail.next.next} 跳过当前头节点,使尾节点直接指向新的头节点。时间复杂度为 $O(1)$,因为它只涉及指针调整。\par |
| 151 | +\subsection{删除尾节点 (deleteFromEnd)} |
| 152 | +删除尾节点是循环链表中的难点,因为单向链表无法直接访问前驱节点,需要遍历找到尾节点的前一个节点。场景包括链表为空、只有一个节点或多个节点。代码实现如下。\par |
| 153 | +\begin{lstlisting}[language=java] |
| 154 | +// Java 实现 deleteFromEnd 方法 |
| 155 | +public void deleteFromEnd() { |
| 156 | + if (isEmpty()) { |
| 157 | + System.out.println("链表为空,无法删除"); |
| 158 | + return; |
| 159 | + } |
| 160 | + if (tail.next == tail) { // 只有一个节点 |
| 161 | + tail = null; |
| 162 | + } else { |
| 163 | + Node current = tail.next; |
| 164 | + while (current.next != tail) { |
| 165 | + current = current.next; // 遍历找到尾节点的前一个节点 |
| 166 | + } |
| 167 | + current.next = tail.next; // 前一个节点指向头节点 |
| 168 | + tail = current; // 更新尾指针 |
| 169 | + } |
| 170 | +} |
| 171 | +\end{lstlisting} |
| 172 | +\begin{lstlisting}[language=python] |
| 173 | +# Python 实现 delete_from_end 方法 |
| 174 | +def delete_from_end(self): |
| 175 | + if self.is_empty(): |
| 176 | + print("链表为空,无法删除") |
| 177 | + return |
| 178 | + if self.tail.next == self.tail: # 只有一个节点 |
| 179 | + self.tail = None |
| 180 | + else: |
| 181 | + current = self.tail.next |
| 182 | + while current.next != self.tail: |
| 183 | + current = current.next # 遍历找到尾节点的前一个节点 |
| 184 | + current.next = self.tail.next # 前一个节点指向头节点 |
| 185 | + self.tail = current # 更新尾指针 |
| 186 | +\end{lstlisting} |
| 187 | +这里,如果链表为空或只有一个节点,处理方式与删除头节点类似。对于多个节点,我们从头节点开始遍历,直到找到尾节点的前一个节点(即 \texttt{current.next == tail}),然后调整指针:将前一个节点的 \texttt{next} 指向头节点,并更新 \texttt{tail} 为前一个节点。时间复杂度为 $O(n)$,其中 $n$ 是链表长度,因为需要遍历。\par |
| 188 | +\subsection{遍历链表 (display / traverse)} |
| 189 | +遍历循环链表时,终止条件不再是检查 \texttt{null},而是检查是否回到起点。通常使用 \texttt{do-while} 循环来确保至少执行一次。代码实现如下。\par |
| 190 | +\begin{lstlisting}[language=java] |
| 191 | +// Java 实现 display 方法 |
| 192 | +public void display() { |
| 193 | + if (isEmpty()) { |
| 194 | + System.out.println("链表为空"); |
| 195 | + return; |
| 196 | + } |
| 197 | + Node current = tail.next; // 从头节点开始 |
| 198 | + do { |
| 199 | + System.out.print(current.data + " -> "); |
| 200 | + current = current.next; |
| 201 | + } while (current != tail.next); // 当再次回到头节点时停止 |
| 202 | + System.out.println("( back to head )"); |
| 203 | +} |
| 204 | +\end{lstlisting} |
| 205 | +\begin{lstlisting}[language=python] |
| 206 | +# Python 实现 display 方法 |
| 207 | +def display(self): |
| 208 | + if self.is_empty(): |
| 209 | + print("链表为空") |
| 210 | + return |
| 211 | + current = self.tail.next # 从头节点开始 |
| 212 | + while True: |
| 213 | + print(current.data, end=" -> ") |
| 214 | + current = current.next |
| 215 | + if current == self.tail.next: |
| 216 | + break |
| 217 | + print("( back to head )") |
| 218 | +\end{lstlisting} |
| 219 | +在这段代码中,我们从头节点(\texttt{tail.next})开始,逐个打印节点数据,直到再次遇到头节点为止。使用 \texttt{do-while} 结构(在 Python 中用 \texttt{while True} 和 \texttt{break} 模拟)确保至少打印一次。时间复杂度为 $O(n)$,因为它需要访问每个节点一次。\par |
| 220 | +\chapter{进阶探讨:双向循环链表简介} |
| 221 | +双向循环链表是循环链表的扩展,每个节点包含两个指针:\texttt{next} 指向后继节点,\texttt{prev} 指向前驱节点。这种结构允许双向遍历,并优化了一些操作。例如,删除尾节点的时间复杂度可以从 $O(n)$ 降低到 $O(1)$,因为可以直接通过 \texttt{tail.prev} 访问前驱节点,无需遍历。\par |
| 222 | +然而,双向循环链表的实现更复杂,因为插入和删除操作需要维护两个指针(\texttt{next} 和 \texttt{prev}),代码量增加,但提供了更大的灵活性。在实际应用中,双向循环链表常用于需要频繁前后遍历的场景,如浏览器历史记录或高级数据结构的基础。\par |
| 223 | +\chapter{实际应用:循环链表用在哪里?} |
| 224 | +循环链表在计算机科学中有广泛的应用。在操作系统中,时间片轮转调度算法使用循环链表来管理进程队列,确保每个进程公平获得 CPU 时间。在多媒体应用中,循环链表用于实现循环播放功能,如音乐播放器中的歌单循环。在游戏开发中,它可以模拟玩家回合制循环,例如棋类游戏中的轮流行动。此外,循环链表作为基础数据结构,常用于实现队列,其中入队和出队操作都可以在 $O(1)$ 时间内完成,如果维护了尾指针。\par |
| 225 | +循环链表的优点包括从任意节点出发都能遍历整个链表,以及某些操作(如尾部插入)的高效性。但它也有缺点,例如实现稍复杂,容易产生无限循环的 bug,需要谨慎处理边界条件。与单链表相比,循环链表在插入和删除操作上可能更高效(如果优化了指针),但遍历操作类似。总体而言,循环链表适用于需要循环访问的场景,而单链表更适用于线性数据处理。\par |
| 226 | +\chapter{练习与思考} |
| 227 | +为了巩固学习,建议尝试解决约瑟夫环问题,这是一个经典问题,可以用循环链表来模拟 elimination 过程。此外,思考如何检测一个链表是否是循环链表?快慢指针法是一种常见解决方案:使用两个指针,一个移动快,一个移动慢,如果它们相遇,则存在环。最后,挑战自己实现双向循环链表,以加深对指针操作的理解。\par |
| 228 | +\chapter{结束语} |
| 229 | +本文详细介绍了循环链表的概念、实现和应用。通过代码示例和解读,我们希望帮助你掌握这一数据结构。动手实现是学习的关键,鼓励你编写代码并尝试解决提出的问题。在下一篇文章中,我们可能会探讨双向循环链表的详细实现或其他高级话题。 Happy coding!\par |
0 commit comments