go 链表 (标准库实现)

Go 链表简介

Go 标准库里没有单链表,只在 container/list 包里提供了双向循环链表。

两个核心类型

list.List :链表本身,包含哨兵节点和长度

list.Element :链表节点,存数据 + 前后指针

Go 复制代码
  type Element struct {
      Value interface{} // 节点数据 (任意类型)
      // next, prev 是私有字段,不能直接访问
  }

特点

• 双向:每个节点有 Next() 和 Prev(),支持双向遍历

• 循环:内部用一个哨兵节点串成环(首尾相连),所以代码里没有 nil 判断的边界 case,操作非常简洁

• Value 是 interface{}:可以存任意类型,但取出来要类型断言(e.Value.(int))

• Len() 是 O(1):内部维护了计数,不需要遍历

常用方法速查

遍历模板

Go 复制代码
package main
 
import (
	"container/list"
	"fmt"
)
 
func main() {
	// 创建一个新的链表
	l := list.New()
 
	// 在链表的尾部添加元素
	l.PushBack("Go")
	l.PushBack("is")
	l.PushBack("awesome")
 
	// 在链表的头部添加元素
	l.PushFront("Programming")
 
	// 遍历链表并打印元素
	for e := l.Front(); e != nil; e = e.Next() {
		fmt.Println(e.Value)
	}
}

几个坑

  1. Value 类型断言:e.Value.(int),类型错了会 panic

  2. 查找是 O(n):链表没有按值查找的 API,得自己写循环 → 这就是为什么 LRU 要配一个 map 做索引

  3. 节点必须先拿到 *list.Element 才能操作:Remove / InsertBefore / MoveToFront 都接收节点指针,不接收值

  4. 不要跨链表移动节点:l1 的节点别拿去 l2.Remove(),行为未定义

什么时候用 container/list

• ✅ 需要频繁在中间插入/删除且已经持有节点指针(LRU 缓存、任务调度队列)

• ❌ 只是顺序存取 / 随机访问 → 直接用 slice,cache 友好性吊打链表

99% 的场景用 slice 就够了,剩下 1% 才考虑 container/list。

LRU 示例

Go 复制代码
package main

import (
	"container/list"
	"fmt"
)

// 实现一个LRU,缓存策略,缓存满了就淘汰最久没使用的那个

type LRUCache struct {
	capability int
	ll *list.List
	cache map[int]*list.Element
}

type entry struct {
	key, value int
}

func Constructor(capability int) LRUCache {
	return LRUCache{
		capability: capability,
		ll: list.New(),
		cache: make(map[int]*list.Element),
	}
}

// 存元素,如果元素已经存在,则更新,放到头部;
// 如果元素不存在,则加到头部,然后判断是否溢出。
func(c *LRUCache) Put(key, value int) {
	if e,ok := c.cache[key]; ok{
		e.Value.(*entry).value = value
		c.ll.MoveToFront(e)
		return
	}
	e := c.ll.PushFront(&entry{key: key, value: value})
	c.cache[key] = e
	if c.ll.Len() > c.capability {
		e := c.ll.Back()
		delete(c.cache, e.Value.(*entry).key)
		c.ll.Remove(e)
	}
}

// 获取元素,如果元素存在放回,并放回头部;如果不存在,返回-1
func(c *LRUCache) Get(key int) int {
	if e, ok := c.cache[key]; ok {
		c.ll.MoveToFront(e)
		return e.Value.(*entry).value
	}
	return -1
}

func main() {
	lrucache := Constructor(2)
	lrucache.Put(1,3)
	lrucache.Put(1,4)
	lrucache.Put(2,9)
	for e:=lrucache.ll.Front(); e!=nil;e=e.Next() {
		fmt.Println(e.Value.(*entry).value)
	}
}

QA1:为什么用指针接收者 c *LRUCache

因为方法里要修改 c 的内部状态(链表、map),用值接收者会改了个寂寞。

对比

Go 复制代码
  // 值接收者:c 是副本,改了不影响原对象
  func (c LRUCache) Put(...)  { c.ll.PushFront(...) }  // 🤔 看起来生效了?
  // 指针接收者:c 指向原对象,改的就是它本身
  func (c *LRUCache) Put(...) { c.ll.PushFront(...) }  // ✅ 真正生效

⚠️ 有个细节:ll 本身是 *list.List(指针),所以值接收者下 PushFront

居然也"看似"能改链表------因为副本和原对象共享同一个底层链表。但 capacity 这种字段就改不动了,而且 map 操作也会出问题。不要依赖这种巧合。

Go 的两条经验法则

  1. 方法里要改字段 → 必须用指针接收者

  2. 结构体比较大、或包含 mutex/slice/map → 也建议用指针(避免拷贝、避免锁失效)

  3. 同一个类型的所有方法,接收者类型要统一(要么都是值,要么都是指针),否则方法集混乱、接口实现也容易踩坑

LRU 三条全占:要改状态、含 map 和 list、需要保持一致 → 必须是 *LRUCache。

QA2: *list.List为什么是指针

因为list.New()返回指针,

QA3: *list.Element 为什么这个也是指针呢

因为 container/list 里所有方法本来就要求传 *list.Element,map 存指针只是顺手保持一致,避免来回取址。

看 list 包的 API 签名:

Go 复制代码
  func (l *List) PushFront(v any) *list.Element          // 返回的就是指针
  func (l *List) MoveToFront(e *list    .Element)            // 接收指针
  func (l *List) Remove(e *list.Element) any             // 接收指针
  func (l *List) InsertBefore(v any, mark *list.Element) // 接收指针

Go 标准库设计上,链表节点天生就是用指针在传递的。所以:

Go 复制代码
  elem := c.ll.PushFront(...)   // elem 已经是 *list.Element
  c.cache[key] = elem            // 直接塞 map,类型匹配

如果存值类型,每次用还得 &v,自找麻烦。

QA4: *list.List不是一个双向链表吗,一直next(),为什么会出现nil呢

container/list 的实现确实是循环双向链表,内部有一个哨兵节点(root),最后一个节点的 next 指向 root,root 的 next 又指向第一个节点。但是 Next() 和 Prev() 这两个公开方法被故意做了处理:当下一个节点是 root 哨兵节点时,返回 nil,而不是把这个内部节点暴露出来。

标准库源码(src/container/list/list.go):

Go 复制代码
  func (e *Element) Next() *Element {
      if p := e.next; e.list != nil && p != &e.list.root {
          return p
      }
      return nil
  }
  func (e *Element) Prev() *Element {
      if p := e.prev; e.list != nil && p != &e.list.root {
          return p
      }
      return nil
  }

关键就是 p != &e.list.root 这个判断------一旦走到 root 哨兵,就返回 nil。

所以:

• 内部数据结构:循环双向链表(用 root 哨兵简化插入/删除的边界判断,不需要处理 head/tail 为 nil 的特殊情况)。

• 对外暴露的迭代接口:非循环,到末尾返回 nil,符合 Go 的习惯写法:

相关推荐
dog2506 小时前
解析几何的现代范式-算力,拟合与对偶
服务器·开发语言·网络·线性代数·php
basketball6166 小时前
C++ 嵌套类完全指南:类中类的巧妙设计
开发语言·c++
吃好睡好便好6 小时前
在Matlab中绘制阶梯图
开发语言·人工智能·学习·算法·机器学习·matlab
Deep-w6 小时前
【MATLAB】基于 MATLAB 的离网光伏储能微电网容量优化仿真研究
开发语言·算法·matlab
诙_7 小时前
由C++速通Lua
开发语言·lua
TechWayfarer7 小时前
AI大模型时代:IP数据云如何适配智能体场景需求
开发语言·人工智能·python·网络协议·tcp/ip·langchain
DN金猿7 小时前
spring.cloud.nacos.discovery.server-addr和spring.cloud.nacos.server-addr区别
java·开发语言·nacos·springcloud·sca
Jasmine_llq7 小时前
《B4261 [GESP202503 三级] 2025》
开发语言·c++·算法·条件判断算法·位运算恒等式推导·简单算术运算
海兰7 小时前
【实用应用】React+TypeScript+Next.js博客项目
开发语言·javascript·elasticsearch