循环链表(Circular Linked List)

循环链表(Circular Linked List)

一、什么是循环链表

循环链表是一种特殊的单链表,其尾节点的 Next 指针不指向 nil,而是回指到头节点,从而形成一个闭合的环。

普通单链表的遍历走到尾节点就结束了(node.Next == nil),而循环链表没有天然的"终点"------如果你不停下来,可以无限遍历下去。

复制代码
普通单链表:  A -> B -> C -> D -> nil

循环链表:    A -> B -> C -> D -> A  (D 的 Next 指回 A)
             ^                      |
             |______________________|

1.1 循环链表 vs 单链表

对比维度 单链表 循环链表
尾节点指向 nil 头节点
遍历终止条件 cur == nil cur.Next == head
从任意节点可达性 只能向后走,无法回到起点 可以绕一圈回到起点
环形结构
适用场景 线性序列 轮询调度、约瑟夫环、资源循环分配

1.2 循环链表的变种

  • 单循环链表:只有 Next 指针,尾指回头
  • 双循环链表:有 Prev 和 Next 两个指针,尾的 Next 指头,头的 Prev 指尾

双循环链表在 Go 的 container/ring 标准库中就有实现。

二、Go 实现:单循环链表

2.1 节点定义与基本结构

go 复制代码
package main

import "fmt"

// Node 循环链表节点
type Node struct {
	Data int
	Next *Node
}

// CircularList 循环链表
type CircularList struct {
	Head *Node
	Size int
}

2.2 尾部插入

往循环链表尾部添加节点,核心步骤:

  1. 新建节点
  2. 如果链表为空,新节点自己指向自己,并设为 Head
  3. 如果链表非空,找到尾节点(tail.Next == head 的那个),让尾节点指向新节点,新节点指向头节点
go 复制代码
// Append 在尾部插入节点
func (cl *CircularList) Append(data int) {
	newNode := &Node{Data: data}

	if cl.Head == nil {
		cl.Head = newNode
		newNode.Next = newNode // 自己指向自己,形成环
	} else {
		// 找到尾节点(Next 指向 Head 的就是尾)
		tail := cl.Head
		for tail.Next != cl.Head {
			tail = tail.Next
		}
		tail.Next = newNode
		newNode.Next = cl.Head
	}
	cl.Size++
}

2.3 头部插入

头部插入需要将新节点插在 Head 之前,然后把 Head 指向新节点。但有个容易遗漏的点:尾节点的 Next 也需要更新指向新的 Head

go 复制代码
// Prepend 在头部插入节点
func (cl *CircularList) Prepend(data int) {
	newNode := &Node{Data: data}

	if cl.Head == nil {
		cl.Head = newNode
		newNode.Next = newNode
	} else {
		// 找尾节点
		tail := cl.Head
		for tail.Next != cl.Head {
			tail = tail.Next
		}
		// 新节点插在 head 前面
		newNode.Next = cl.Head
		tail.Next = newNode
		cl.Head = newNode
	}
	cl.Size++
}

2.4 删除节点

删除指定值的节点,需要维护环的闭合性。关键在于:

  • 如果删除的是 Head,需要把 Head 移到下一个节点,同时尾节点也要更新指向新 Head
  • 如果删除的是中间节点,只需前驱节点跳过它
go 复制代码
// Remove 删除第一个匹配的节点
func (cl *CircularList) Remove(data int) bool {
	if cl.Head == nil {
		return false
	}

	// 只有一个节点的情况
	if cl.Head.Next == cl.Head {
		if cl.Head.Data == data {
			cl.Head = nil
			cl.Size--
			return true
		}
		return false
	}

	// 查找目标节点及其前驱
	prev := cl.Head
	cur := cl.Head.Next
	for cur != cl.Head {
		if cur.Data == data {
			prev.Next = cur.Next
			cl.Size--
			return true
		}
		prev = cur
		cur = cur.Next
	}

	// 检查 Head 是否是目标
	if cl.Head.Data == data {
		// 找到尾节点,让尾指向 Head.Next
		tail := cl.Head
		for tail.Next != cl.Head {
			tail = tail.Next
		}
		tail.Next = cl.Head.Next
		cl.Head = cl.Head.Next
		cl.Size--
		return true
	}

	return false
}

2.5 遍历

循环链表的遍历不能像单链表那样 for cur != nil,否则会死循环。正确的做法是:从 Head 出发,走一圈回到 Head 就停。

go 复制代码
// Traverse 遍历打印所有节点
func (cl *CircularList) Traverse() {
	if cl.Head == nil {
		fmt.Println("空链表")
		return
	}

	cur := cl.Head
	for {
		fmt.Printf("%d", cur.Data)
		cur = cur.Next
		if cur == cl.Head {
			break
		}
		fmt.Print(" -> ")
	}
	fmt.Println(" (回到头部)")
}

2.6 完整测试

go 复制代码
func main() {
	cl := &CircularList{}

	// 尾部插入
	cl.Append(10)
	cl.Append(20)
	cl.Append(30)
	fmt.Print("Append 10,20,30: ")
	cl.Traverse() // 10 -> 20 -> 30 (回到头部)

	// 头部插入
	cl.Prepend(5)
	fmt.Print("Prepend 5: ")
	cl.Traverse() // 5 -> 10 -> 20 -> 30 (回到头部)

	// 删除中间节点
	cl.Remove(20)
	fmt.Print("Remove 20: ")
	cl.Traverse() // 5 -> 10 -> 30 (回到头部)

	// 删除头节点
	cl.Remove(5)
	fmt.Print("Remove 5 (head): ")
	cl.Traverse() // 10 -> 30 (回到头部)

	// 验证环形结构
	fmt.Printf("Size: %d\n", cl.Size) // 2

	// 验证尾节点指向头
	tail := cl.Head
	for tail.Next != cl.Head {
		tail = tail.Next
	}
	fmt.Printf("Head=%d, Tail=%d, Tail.Next=%d (应等于Head)\n",
		cl.Head.Data, tail.Data, tail.Next.Data)
}

运行结果:

复制代码
Append 10,20,30: 10 -> 20 -> 30 (回到头部)
Prepend 5: 5 -> 10 -> 20 -> 30 (回到头部)
Remove 20: 5 -> 10 -> 30 (回到头部)
Remove 5 (head): 10 -> 30 (回到头部)
Size: 2
Head=10, Tail=30, Tail.Next=10 (应等于Head)

三、Go 标准库 container/ring

Go 标准库提供了 container/ring,这是一个双循环链表 的实现。它的所有节点值都是 interface{} 类型。

go 复制代码
package main

import (
	"container/ring"
	"fmt"
)

func main() {
	// 创建一个长度为 5 的环
	r := ring.New(5)

	// 填充数据
	i := 0
	r.Do(func(v interface{}) {
		r.Value = i * 10
		i++
		r = r.Next()
	})

	// 遍历(手动)
	r.Do(func(v interface{}) {
		fmt.Printf("%d ", v)
	})
	fmt.Println()
	// 输出: 0 10 20 30 40

	// 移动指针
	r = r.Move(2) // 向前移动2步
	fmt.Printf("移动2步后当前值: %d\n", r.Value) // 20

	// 链接(跳过中间节点)
	r.Link(ring.New(1))
}

container/ring 的核心方法:

  • New(n) --- 创建 n 个节点的环
  • Next() / Prev() --- 前进/后退一步
  • Move(n) --- 移动 n 步(可为负数)
  • Link(s) --- 将另一个环链接进来
  • Unlink(n) --- 移除 n 个节点
  • Do(f) --- 对环中每个节点执行函数 f
  • Len() --- 返回环长度

四、循环链表的应用场景

  1. 轮询调度(Round Robin):操作系统进程调度中,所有就绪进程排成环,调度器循环分配 CPU 时间片
  2. 约瑟夫环问题:经典的数学问题,N 人围坐报数出列
  3. 资源池管理:数据库连接池、线程池中的资源循环分配
  4. 音乐播放器:单曲循环、列表循环播放模式
  5. 游戏中的回合制:玩家轮流操作,一圈接一圈
  6. 心跳检测:分布式系统中节点间的周期性健康检查

五、复杂度分析

操作 时间复杂度 说明
头部插入 O(1) 但需要更新尾节点指向,找尾 O(n)
尾部插入 O(n) 需要遍历找到尾节点(除非维护尾指针)
头部删除 O(n) 同样需要找尾节点更新
查找 O(n) 最坏遍历整个环
遍历 O(n) 一圈正好 n 个节点

优化技巧 :如果维护一个 tail 指针,尾部插入可以做到 O(1)。container/ringNext()/Prev() 都是 O(1) 的,因为它本质是双循环链表。

六、小结

循环链表的核心思想是将线性结构首尾相连变成环形结构 ,从而消除了"终点"的概念。它在需要循环处理、轮转调度的场景中非常自然。Go 标准库的 container/ring 提供了开箱即用的双循环链表实现,实际开发中应优先使用标准库而非自己造轮子------但理解底层原理对于面试和算法学习仍然重要。

与单链表相比,循环链表最大的陷阱在于遍历终止条件 :绝不能用 cur != nil,否则会死循环;正确的条件是 cur.Next == head(回到起点即停)。

相关推荐
Wang's Blog1 小时前
Go-Zero项目开发30: 微服务配置中心的设计与实现
开发语言·微服务·golang·go-zero
名字还没想好☜3 小时前
Go for-range 循环变量陷阱:goroutine 里全打印同一个值(Go 1.22 前后差异)
开发语言·后端·golang·go
会编程的土豆3 小时前
功能详解 01 · 用户注册:从表单到数据库一行
开发语言·数据库·后端·mysql·golang
go不是csgo4 小时前
从模板到五道 LeetCode:完全背包的 Golang 教学笔记
笔记·leetcode·golang
Wang's Blog4 小时前
Go-Zero项目开发33: IM实时通信前后端对接与微服务基础设施实践
开发语言·微服务·golang·go-zero
Wang's Blog15 小时前
Go-Zero 项目开发22:用户群聊功能的实现与完善
开发语言·golang
Wang's Blog17 小时前
Go-Zero项目开发24: 基于Bitmap实现群聊消息已读未读
开发语言·后端·golang
布朗克1681 天前
Go 入门到精通-33-unsafe 与 CGO
开发语言·后端·golang·unsafe·cgo
ttwuai1 天前
AI 生成后台删除按钮后,MySQL 软删除和唯一索引怎么验
数据库·mysql·golang