Go 数据结构手撕题详解(LRU/LFU/跳表/Trie)

2022-02-11T10:00:00+08:00 | 8分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@
数据结构实现基础结构缓存实现高级结构动态数组链表哈希表栈队列环形队列LRU缓存LFU缓存扩容因子TTL缓存跳表扩容阈值LRU+TTLTrie树平滑增长并发LRU布隆过滤器分片设计ZSet简化一致性哈希RingBuffer

学习目标

读完本文,你应该能够:

  • 理解 Go slice 的扩容机制(Go 1.18 前后差异),并手动实现动态数组
  • 手写 LRU 缓存(map + 双向链表)和 LFU 缓存
  • 实现带过期时间的 TTL 缓存,以及 LRU + TTL 双重淘汰缓存
  • 实现跳表(Skip List)——Redis ZSet 底层数据结构
  • 手写 Trie 树用于字符串前缀匹配
  • 实现布隆过滤器和一致性哈希环

一、动态数组(模仿 Go Slice)

1.1 扩容规则(Go 1.18 前后)

面试官
Go slice 的扩容策略是什么?为什么会有变化?
面试官
候选人
Go 1.18 之前:容量 ≤ 1024 时翻倍,超过后乘以 1.25。 Go 1.18 之后:容量 ≤ 256 时翻倍,超过后使用平滑增长公式。

变化原因:小容量时翻倍内存浪费明显(比如 cap=1 申请到 2,cap=128 申请到 256)。 平滑增长公式让扩容更渐进,减少内存碎片。

1.2 代码实现

应用场景

在实际项目中,这个知识点经常应用于:

  1. 典型场景一:说明具体应用场景
  2. 典型场景二:说明另一个应用场景
  3. 面试要点:提醒面试中可能被问到的点
package main

import "fmt"

// DynamicArray 模仿 Go slice 的动态数组
type DynamicArray struct {
	elem     []interface{}
	capacity int
	size     int
}

func NewDynamicArray() *DynamicArray {
	return &DynamicArray{
		elem:     make([]interface{}, 0, 16),
		capacity: 16,
	}
}

// Append 追加元素,触发扩容
func (da *DynamicArray) Append(v interface{}) {
	if da.size >= da.capacity {
		da.grow()
	}
	da.elem[da.size] = v
	da.size++
}

// grow 扩容逻辑(Go 1.18+ 风格)
func (da *DynamicArray) grow() {
	newCap := da.capacity * 2
	if da.capacity < 1024 {
		// 小容量:直接翻倍
	} else {
		// 大容量:平滑增长
		for newCap < da.size*2 {
			newCap += newCap / 4
		}
	}
	
	newElem := make([]interface{}, da.size, newCap)
	copy(newElem, da.elem)
	da.elem = newElem
	da.capacity = newCap
}

func (da *DynamicArray) Get(i int) interface{} {
	if i < 0 || i >= da.size {
		panic("index out of range")
	}
	return da.elem[i]
}

func (da *DynamicArray) Len() int { return da.size }
复习提示:内存对齐:slice 扩容时 newCap 的计算要考虑内存对齐,实际源码中还有 roundupsize 函数处理对象大小对齐。

二、单链表与双向链表

2.1 单链表

type ListNode struct {
	Value int
	Next  *ListNode
}

func reverseList(head *ListNode) *ListNode {
	var prev *ListNode
	cur := 

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

head
	for cur != nil {
		next := cur.Next
		cur.Next = prev
		prev = cur
		cur = next
	}
	return prev
}

2.2 双向链表

type DoublyListNode struct {
	Value int
	Prev  *DoublyListNode
	Next  *DoublyListNode
}

type DoublyLinkedList struct {
	head *DoublyListNode
	tail *DoublyListNode
	size int
}

func (dll *DoublyLinkedList) AddFront(val int) {
	node := &DoublyListNode{Value: val}
	if dll.head == nil {
		dll.head = node
		dll.tail = node
	} else {
		node.Next = dll.head
		dll.head.Prev = node
		dll.head = node
	}
	dll.size++
}

func (dll *DoublyLinkedList) RemoveFro

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

nt() int {
	if dll.head == nil {
		panic("empty list")
	}
	val := dll.head.Value
	dll.head = dll.head.Next
	if dll.head == nil {
		dll.tail = nil
	} else {
		dll.head.Prev = nil
	}
	dll.size--
	return val
}

三、哈希表实现

3.1 链地址法

type hashEntry struct {
	key   string
	value interface{}
	next  *hashEntry
}

type HashMap struct {
	buckets []*hashEntry
	size    int
	cap     int
}

const defaultCapacity = 16

func NewHashMap() *HashMap {
	return &HashMap{
		buckets: make([]*hashEntry, defaultCapacity),
		cap:     defaultCapacity,
	}
}

func (hm *HashMap) hash(key string) int {
	h := uint32(0)
	for i := 0; i < len(key); i++ {
		h = h*31 + uint32(key[i])
	}
	return int(h % uint32(hm.cap))
}

func (hm *HashMap) Put(key string, value interface{}) {
	idx := hm.hash(key)
	entry := &hashEntry{key: key, value: value, next: hm.buckets[idx]}
	hm.buckets[idx] = entry
	hm.size++
	
	// 负载因子超过 0.75 时扩容
	if float64(hm.size)/float64(hm.cap) > 0.75 {
		hm.resize()
	}
}

func (hm *HashMap) Get(key string) (interface{}, bool) {
	idx := hm.hash(key)
	for e := hm.buckets[idx]; e != nil; e = e.next {
		if e.key == key {
			return e.value, true
		}
	}
	return nil, false
}

func (hm *HashMap) resize() {
	old := hm.buckets
	hm.cap *= 2
	hm.buckets = make([]*hashEntry, hm.cap)
	hm.size = 0
	for _, bucket := range old {
		for e := bucket; e != nil; e = e.next {
			hm.Put(e.key, e.value)
		}
	}
}

3.2 开放寻址法

type openAddrMap struct {
	keys   []string
	values []interface{}
	deleted []bool
	size   int
	ca

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

p    int
}

func (m *openAddrMap) Put(key string, value interface{}) {
	idx := m.hash(key)
	for {
		if !m.deleted[idx] && m.keys[idx] == "" {
			m.keys[idx] = key
			m.values[idx] = value
			m.size++
			return
		}
		if m.keys[idx] == key {
			m.values[idx] = value
			return
		}
		idx = (idx + 1) % m.cap
	}
}

四、LRU 缓存(高频!)

4.1 标准实现:map + 双向链表

type lruNode struct {
	key   string
	val   interface{}
	prev  *lruNode
	next  *lruNode
}

type LRUCache struct {
	capacity int
	cache    map[string]*lruNode
	head     *lruNode // 虚拟头
	tail     *lruNode // 虚拟尾
}

func NewLRUCache(capacity int) *LRUCache {
	head := &lruNode{}
	tail := &lruNode{}
	head.next = tail
	tail.prev = head
	return &LRUCache{
		capacity: capacity,
		cache:    make(map[string]*lruNode),
		head:     head,
		tail:     tail,
	}
}

func (c *LRUCache) Get(key string) interface{} {
	if node, ok := c.cache[key]; ok {
		c.moveToHead(node)
		return node.val
	}
	return nil
}

func (c *LRUCache) Put(key string, value interface{}) {
	if node, ok := c.cache[key]; ok {
		node.val = value
		c.moveToHead(node)
		return
	}
	
	node := &lruNode{key: key, val: value}
	c.cache[key] = node
	c.addToHead(node)
	
	if len(c.cache) > c.capacity {
		removed := c.removeTail()
		delete(c.cache, removed.key)
	}
}

func (c *LRUCache) addToHead(node *lruNode) {
	node.prev = c.head
	node.next = c.head.next
	c.head.next.prev = node
	c.head.next = node
}

func (c *LRUCache) removeNode(node *lruNode) {
	node.prev.next = node.next
	node.next.prev = node.prev
}

func (c *LRUCache) moveToHead(node *lruNode) {
	c.removeNode(node)
	c.addToHead(node)
}

func (c *LRUCache) removeTail() *lruNode {
	node := c.tail.prev
	c.removeNode(node)
	return node
}

LRU 缓存结构:哈希表 + 双向链表

cache map[key]*node
<rect x="60" y="140" width="210" height="50" rx="6"/>
<text x="165" y="170" text-anchor="middle" fill="#1a1a1a" font-size="13" stroke="none">k1 ── 指针</text>
<rect x="60" y="225" width="210" height="50" rx="6"/>
<text x="165" y="255" text-anchor="middle" fill="#1a1a1a" font-size="13" stroke="none">k2 ── 指针</text>
<rect x="60" y="310" width="210" height="50" rx="6"/>
<text x="165" y="340" text-anchor="middle" fill="#1a1a1a" font-size="13" stroke="none">k3 ── 指针</text>
双向链表(head 虚拟头 … tail 虚拟尾)
<rect x="350" y="220" width="90" height="60" rx="6" stroke-dasharray="6 4"/>
<text x="395" y="255" text-anchor="middle" fill="#1a1a1a" font-size="12" stroke="none">head</text>
<rect x="470" y="220" width="120" height="60" rx="6"/>
<text x="530" y="248" text-anchor="middle" fill="#1a1a1a" font-size="12.5" stroke="none">A (k1)</text>
<text x="530" y="268" text-anchor="middle" fill="#666" font-size="11" stroke="none">MRU</text>
<rect x="620" y="220" width="120" height="60" rx="6"/>
<text x="680" y="248" text-anchor="middle" fill="#1a1a1a" font-size="12.5" stroke="none">B (k2)</text>
<text x="680" y="268" text-anchor="middle" fill="#666" font-size="11" stroke="none">mid</text>
<rect x="770" y="220" width="120" height="60" rx="6" stroke-width="3.5"/>
<text x="830" y="248" text-anchor="middle" fill="#B03030" font-size="12.5" font-weight="700" stroke="none">C (k3)</text>
<text x="830" y="268" text-anchor="middle" fill="#666" font-size="11" stroke="none">LRU</text>
<rect x="910" y="220" width="70" height="60" rx="6" stroke-dasharray="6 4"/>
<text x="945" y="255" text-anchor="middle" fill="#1a1a1a" font-size="12" stroke="none">tail</text>
prev / next 双向指针容量满→淘汰 tail.prev(最久未用)访问即 moveToHead:移到 head 之后

看图要点:面试必考 LRU 为什么用哈希表 + 双向链表组合。哈希表提供 O(1) 按 key 定位节点;双向链表维护访问时序,head 之后是最近使用(MRU),tail.prev 是最久未用(LRU)。Get/Put 命中都把节点 moveToHead,超容量则删 tail.prev 并从 map 删除。伪头/伪尾哨兵省去了头尾空指针判断。追问:并发版用分段锁,每个 segment 各持一份 LRU。

4.2 并发安全版(分段锁)

type segment struct {
	mu       sync.RWMutex
	cache    map[string]*lruNode
	capacity int
	head     *lruNode
	tail     *lruNode
}

type ConcurrentLRU struct {
	segments []*segment
	mask     uint32
}

func NewConcurrentLRU(totalCap, numSegments int) *Concurrent

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

LRU {
	cl := &ConcurrentLRU{
		segments: make([]*segment, numSegments),
		mask:     uint32(numSegments - 1),
	}
	capPerSeg := totalCap / numSegments
	for i := 0; i < numSegments; i++ {
		cl.segments[i] = NewLRUCache(capPerSeg).toSegment()
	}
	return cl
}

func (cl *ConcurrentLRU) Get(key string) interface{} {
 seg := cl.segments[hash(key)&cl.mask]
 seg.mu.RLock()
	defer seg.mu.RUnlock()
 return seg.get(key)
}

五、LFU 缓存

5.1 核心思路

需要三个数据结构:

  • freqMap: 频率 → 双向链表(该频率下的所有节点)
  • nodeMap: key → 节点
  • minFreq: 当前最小频率
type freqNode struct {
	key   string
	val   interface{}
	freq  int
	prev  *freqNode
	next  *freqNode
}

type freqList struct {
	size   int
	head   *freqNode
	tail   *freqNode
}

func (fl *freqList) addNode(node *freqNode) {
	node.prev = fl.head
	node.next = fl.head.next
	fl.head.next.prev = node
	fl.head.next = node
	fl.size++
}

type LFUCache struct {
	capacity int
	minFreq  int
	nodeMap  map[string]*freqNode
	freqMap  map[int]*freqList
}

func (c *LFUCache) Get(key string) int {
	if node, ok := c.nodeMap[key]; ok {
		c.incrementFreq(node)
		return node.val.(int)
	}
	return -1
}

func (c *LFUCache) Put(key string, value int) {
	if c.capacity <= 0 {
		return
	}
	if node, ok := c.nodeMap[key]; ok {
		node.val = value
		c.incrementFreq(node)
		return
	}
	
	if len(c.nodeMap) >= c.capacity {
		// 删除最少使用的
		list := c.freqMap[c.minFreq]
		toDelete := list.tail.prev
		list.removeFromNode(toDelete)
		delete(c.nodeMap, toDelete.key)
	}
	
	node := &freqNode{key: key, val: value, freq: 1}
	c.nodeMap[key] = node
	c.addToList(node)
	c.minFreq = 1
}

func (c *LFUCache) incrementFreq(node *freqNode) {
	oldFreq := node.freq
	newFreq := oldFreq + 1
	c.freqMap[oldFreq].removeFromNode(node)
	if c.freqMap[oldFreq].size == 0 && c.minFreq == oldFreq {
		c.minFreq = newFreq
	}
	node.freq = newFreq
	c.addToList(node)
}

func (c *LFUCache) addToList(node *freqNode) {
	list, ok := c.freqMap[node.freq]
	if !ok {
		list = &freqList{head: &freqNode{}, tail: &freqNode{}}
		list.head.next = list.tail
		list.tail.prev = list.head
		c.freqMap[node.freq] = list
	}
	list.addNode(node)
}

六、TTL 缓存

type TTLItem struct {
	value     interface{}
	expireAt  time.Time
}

type TTLCache struct {
	items map[string]TTLItem
	mu    sync.RWMutex
	ticker *time.Ticker
	stopCh chan struct{}
}

func NewTTLCache() *TTLCache {
	tc := &TTLCache{
		items:  make(map[string]TTLItem),
		ticker: time.NewTicker(1 * time.Second),
		stopCh: make(chan struct{}),
	}
	go tc.cleanup()
	return tc
}

func (tc *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
	tc.mu.Lock()
	tc.items[key] = TTLItem{
		value:    value,
		expireAt: time.Now().Add(ttl),
	}
	tc.mu.Unlock()
}

func (tc *TTLCache) Get(key string) (interface{}, bool) {
	tc.mu.RLock()
	item, ok := tc.items[key]
	tc.mu.RUnlock()
	
	if !ok || time.Now().After(item.expireAt) {
		if ok {
			tc.Delete(key)
		}
		return nil, false
	}
	return item.value, true
}

func (tc *TTLCache) cleanup() {
	for {
		select {
		case <-tc.ticker.C:
			tc.mu.Lock()
			now := time.Now()
			for k, v := range tc.items {
				if now.After(v.expireAt) {
					delete(tc.items, k)
				}
			}
			tc.mu.Unlock()
		case <-tc.stopCh:
			return
		}
	}
}

七、LRU + TTL 双重淘汰

type LRTLTTCache struct {
	lru  *LRUCache
	ttl  *TTLCache
}

func (c *LRTLTTCache) Get(key string) interface{} {
	if v, ok := c.ttl.Get(key); ok {
		c.lru.Get(key) // 更新 LRU 顺序
		return v
	}
	return nil
}

func (c *LRTLTTCache) Put(key string, value interface{}, ttl time.Duration) {
	c.ttl.Set(key, value, ttl)
	c.lru.Put(key, value)
}

八、跳表(Skip List)—— Redis ZSet 核心

8.1 原理

跳表通过多层链表实现 O(logN) 查找,类似二叉搜索树的替身。

const maxLevel = 16

type skipListNode struct {
	value int
	score float64
	forward []*skipListNode
}

type SkipList struct {
	head   *skipListNode
	level  int
	length int
	rand   *rand.Rand
}

func NewSkipList() *SkipList {
	head := &skipListNode{forward: make([]*skipListNode, maxLevel)}
	return &SkipList{head: head, level: 1, rand: rand.New(rand.NewSource(time.Now().UnixNano()))}
}

func (sl *SkipList) randomLevel() int {
	level := 1
	for level < maxLevel && sl.rand.Float64() < 0.5 {
		level++
	}
	return level
}

func (sl *SkipList) Insert(score float64, value int) {
	update := make([]*skipListNode, maxLevel)
	current := sl.head
	
	for i := sl.level - 1; i >= 0; i-- {
		for current.forward[i] != nil && current.forward[i].score < score {
			current = current.forward[i]
		}
		update[i] = current
	}
	
	newLevel := sl.randomLevel()
	if newLevel > sl.level {
		for i := sl.level; i < newLevel; i++ {
			update[i] = sl.head
		}
		sl.level = newLevel
	}
	
	newNode := &skipListNode{
		value:   value,
		score:   score,
		forward: make([]*skipListNode, newLevel),
	}
	
	for i := 0; i < newLevel; i++ {
		newNode.forward[i] = update[i].forward[i]
		update[i].forward[i] = newNode
	}
	sl.length++
}

九、Trie 树(前缀树)

type TrieNode struct {
	children [26]*TrieNode
	isEnd    bool
	word     string
}

type Trie struct {
	root *TrieNode
}

func NewTrie() *Trie {
	return &Trie{root: &TrieNode{}}
}

func (t *Trie) Insert(word string) {
	node := t.root
	for _, ch := range word {
		idx := ch - 'a'
		if node.children[idx] == nil {
			node.children[idx] = &TrieNode{}
		}
		node = node.children[idx]
	}
	node.isEnd = true
	node.word = word
}

func (t *Trie) Search(word string) bool {
	node := t.traverse(word)
	return node != nil && node.isEnd
}

func (t *Trie) HasPrefix(prefix string) bool {
	return t.traverse(prefix) != nil
}

func (t *Trie) traverse(prefix string) *TrieNode {
	node := t.root
	for _, ch := range prefix {
		idx := ch - 'a'
		if node.children[idx] == nil {
			return nil
		}
		node = node.children[idx]
	}
	return node
}

十、布隆过滤器

type BloomFilter struct {
	bitVector []bool
	size      int
	hashFuncs []func([]byte) uint64
}

func NewBloomFilter(expectedItems int, falsePositiveRate float64) *BloomFilter {
	bitSize := int(-float64(expectedItems)*math.Log(falsePositiveRate)/math.Pow(math.Ln2, 2))
	hashCount := int(float64(bitSize)/float64(expectedItems)*math.Ln2)
	
	filter := &BloomFilter{
		bitVector: make([]bool, bitSize),
		size:      bitSize,
		hashFuncs: make([]func([]byte) uint64, hashCount),
	}
	
	for i := 0; i < hashCount; i++ {
		filter.hashFuncs[i] = makeHashFunc(i)
	}
	return filter
}

func (bf *BloomFilter) Add(item string) {
	bytes := []byte(item)
	for _, hash := range bf.hashFuncs {
		h := hash(bytes)
		bf.bitVector[h%uint64(bf.size)] = true
	}
}

func (bf *BloomFilter) MightContain(item string) bool {
	bytes := []byte(item)
	for _, hash := range bf.hashFuncs {
		h := hash(bytes)
		if !bf.bitVector[h%uint64(bf.size)] {
			return false
		}
	}
	return true
}

func makeHashFunc(seed int) func([]byte) uint64 {
	return func(data []byte) uint64 {
		var h uint64 = uint64(seed)
		for _, b := range data {
			h = h*31 + uint64(b)
		}
		return h
	}
}

十一、一致性哈希

type ConsistentHash struct {
	keys     []int       // 虚拟节点位置
	hashMap  map[int]*node  // 位置 → 节点
	hashFunc func(string) int
	n         int  // 每个真实节点的虚拟节点数
}

type node struct {
	addr string
}

func NewConsistentHash(n int, hashFunc func(string) int) *ConsistentHash {
	return &ConsistentHash{
		hashMap:  make(map[int]*node),
		hashFunc: hashFunc,
		n:        n,
	}
}

func (ch *ConsistentHash) Add(addr string) {
	for i := 0; i < ch.n; i++ {
		hash := ch.hashFunc(addr + strconv.Itoa(i))
		ch.keys = append(ch.keys, hash)
		ch.hashMap[hash] = &node{addr: addr}
	}
	sort.Ints(ch.keys)
}

func (ch *ConsistentHash) Get(key string) string {
	hash := ch.hashFunc(key)
	idx := sort.Search(len(ch.keys), func(i int) bool {
		return ch.keys[i] >= hash
	})
	if idx == len(ch.keys) {
		idx = 0
	}
	return ch.hashMap[ch.keys[idx]].addr
}

十二、环形缓冲区 RingBuffer

type RingBuffer struct {
	data   []interface{}
	size   int
	cap    int
	readPos int
	writePos int
	count  int
}

func NewRingBuffer(cap int) *RingBuffer {
	return &RingBuffer{
		data:   make([]interface{}, cap),
		cap:    cap,
	}
}

func (rb *RingBuffer) Put(item interface{}) bool {
	if rb.count == rb.cap {
		return false
	}
	rb.data[rb.writePos] = item
	rb.writePos = (rb.writePos + 1) % rb.cap
	rb.count++
	return true
}

func (rb *RingBuffer) Get() (interface{}, bool) {
	if rb.count == 0 {
		return nil, false
	}
	item := rb.data[rb.readPos]
	rb.data[rb.readPos] = nil // 帮助 GC
	rb.readPos = (rb.readPos + 1) % rb.cap
	rb.count--
	return item, true
}

十三、自测题

面试官
LRU 和 LFU 的区别是什么?各适用于什么场景?
候选人
  • LRU:最近使用的保留,不关心使用次数。适合"数据访问呈现局部性"的场景,如页面缓存。
  • LFU:使用频率最高的保留,最近使用不重要。适合有"热点数据"的场景,如 CDN 缓存、热门商品缓存。

    进阶:LRU-k 是两者的结合,记录最近 k 次访问。
面试官
跳表和平衡二叉树(如红黑树)相比有什么优劣?
候选人

跳表优点:

  • 实现简单,不需要旋转
  • 插入/删除 O(logN) 且稳定
  • 范围查询友好
  • 并发友好(局部加锁即可)

缺点:

  • 空间开销大(每层都需要指针)
  • 常数因子较大

Redis 选择跳表而不是红黑树,是因为跳表在范围操作上更高效。


十四、本章小结

复习提示:
  • LRU 缓存是面试最高频题,务必能手写 map + 双向链表的完整实现
  • Go slice 扩容在 1.18 后有重大变化,了解平滑增长公式是加分项
  • 跳表是 Redis ZSet 的核心,理解其概率特性(每层 50% 概率向上)
  • 布隆过滤器注意误判率计算,位数组大小 = -n·ln(p)/ln²(2)
  • 下一篇将讲解设计模式与工程实践,包括 Rate Limiter、Circuit Breaker 等

参考

  • 《Go 并发编程实战》第 5 章:数据结构
  • Redis 源码:src/t_zset.c(跳表实现)
  • Google Guava BloomFilter 论文
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!