ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用Go手写数据结构:从slice底层到二叉树与TopK实战

用Go手写数据结构:从slice底层到二叉树与TopK实战 最早用Go刷题那阵我的心态是这样的这也缺、那也缺C里用惯了的vector、map、queue随手取用到了Go这里连个带泛型的集合都要自己造。但恰恰是这种“缺”逼着我亲手实现了几乎每一种经典数据结构。等我把链表、栈、队列、双端队列、二叉搜索树、堆全部用Go写了一遍之后反而理解了为什么数据结构是编程内功的核心——很多底层的细节只有手写的时候才会真正暴露出来。这篇博客就是把过去这段时间的积累整理一遍先从Go内置的slice、map、string入手把底层实现的关键机制讲透再带着大家把常见的数据结构亲手实现一遍每个结构都给出可直接运行的代码和复杂度分析最后总结一下实战和面试里高频出现的坑包括map[string]interface{}类型判断、切片内存共享、并发读写这类经典问题。适合正在学Go的开发者、准备算法面试的人以及想补数据结构基础的Java/Python转Go选手。1. 为什么用Go重新讲一遍数据结构1.1 Go给了一个最适合手写数据结构的舞台很多人在大学里都用C语言写过数据结构实验报告链表、二叉树、栈和队列挨个实现一遍写完之后最大的感受就是指针好绕内存好容易泄漏。malloc了忘了free程序跑着跑着内存就飙上去了。到了工程里这些结构基本都被标准库封装好了C有STLJava有CollectionPython更是开箱即用很少有人再去关心底层长什么样。Go就不太一样。它虽然有GC内存不用你手动管理但标准库里的容器却少得可怜slice和map当然内置了可链表、栈、队列、堆、树这类结构统统没有现成的集合类型。最多有个container/list和container/heap但用起来更像是“半成品工具”真要解决某个问题时很多人还是选择自己封装一层。这种“缺东西”的状态反而给了每个人一个绝佳的练习机会你想用完整的数据结构就必须亲手把它写出来。而且Go的语法天然适合描述结构体。一个节点就是struct一个操作就是receiver方法没有复杂的继承体系不需要getter/setter写出来的代码比C简洁比Java短读起来非常直白。更重要的一点是Go有goroutine和丰富的并发原语这让它在实现队列、栈这类结构时可以顺带考虑并发安全性这一点是其他语言教程里很少涉及的。1.2 先建立全局框架线性、散列、树形、图学数据结构最怕一上来就盯着某个算法猛看看完又忘。我自己更习惯先建一个全局框架知道自己处在什么位置。按逻辑关系可以把常见的数据结构分成四类第一类是线性结构元素之间是一对一关系。典型代表是数组、链表、栈、队列和双端队列。这类结构解决的是“顺序存取”问题比如排队、撤销操作、浏览器历史记录背后都是它们。第二类是散列结构核心是哈希表。Go里的map就是工程化的哈希表它以空间换时间把查找复杂度压缩到平均O(1)。这类结构的重点是哈希函数怎么设计、冲突怎么解决、装载因子达到多少时需要扩容。第三类是树形结构元素之间是一对多关系。二叉树、二叉搜索树、堆都算这一类。树形结构最大的价值是利用递归和局部有序性把查找、插入、删除的时间复杂度降下来比如BST能到O(log n)堆能快速拿到最值。第四类是图结构元素之间是多对多关系。邻接表、并查集、图的遍历算法都归到这里工程上线网、路径规划、社交关系都依赖图。配合这些结构还要掌握两个核心算法家族排序算法和查找算法。排序里面有快排、归并、堆排序查找里面有二分、哈希、树查找。判断一个算法好不好则要回到两个度量时间复杂度和空间复杂度。Go代码里尤其要注意递归带来的栈空间以及切片扩容时的临时分配这些都会直接影响空间复杂度。我的体会是数据结构本质上是一个抽象分层物理存储决定性能逻辑接口决定使用方式。比如栈既可以用数组实现也可以用链表实现对外暴露的Push和Pop接口完全一样但内部扩容策略、内存布局、并发安全性却天差地别。理解了这层后面写代码才会游刃有余。2. Go内置的三种容器底层到底长什么样2.1 切片slice扩容机制是高频考点但更要懂“共享底层”网上很多golang八股文翻来覆去问slice扩容本质上是因为slice的源码实现足够典型。一个slice变量在内存里其实是个三字段结构体指向底层数组的指针、长度len、容量cap。你写的make([]int, 0, 3)就是让cap提前到位减少后续扩容次数。扩容策略各版本有差异但大致规律是当cap小于1024时按倍数扩容通常是翻倍超过1024后增长比例放缓大约按1.25倍增加。新版Go还结合了内存分配器的对齐规则扩容后cap可能落在16、32、48这类阶梯值上。我建议你亲手验证一下package main import fmt func main() { s : make([]int, 0, 3) for i : 0; i 8; i { s append(s, i) fmt.Printf(append %d: len%d cap%d ptr%p\n, i, len(s), cap(s), s) } }运行后你会发现第一次append到第四个元素时cap从3跳到了6之后再跳到12。ptr地址也变了说明底层数组换了位置旧数组等着GC回收。性能敏感的场景里频繁扩容意味着频繁分配和拷贝所以预先make一个足够大的cap是常规优化手段。比扩容更隐蔽的是“共享底层数组”问题。切片做切片时新切片和旧切片指向同一块底层数组a : []int{1, 2, 3, 4, 5} b : a[1:3] b[0] 100 fmt.Println(a) // [1 100 3 4 5]更坑的是append。a[1:3]的容量其实是4所以直接append(b, 6)会把6写到a[3]的位置上悄然覆盖原数组的数据。很多线上bug就是这么来的。如果确实不想互相影响就老老实实copy一份c : make([]int, len(b)) copy(c, b)这个“共享底层”的问题面试里还经常换着花样考函数参数传slice进去改下标元素外部可见但append加长外部len不变。只要抓住“底层数组共享但len在各自头里”这个本质这类题基本不会错。2.2 map哈希表实现与map[string]interface{}的类型判断map是Go内置的哈希表底层结构是hmap加bucket桶数组。每个桶可以存8对键值冲突了会用溢出桶往后链装载因子过高时触发扩容。为什么map的遍历顺序是随机的这是Go刻意为之因为哈希表的物理布局本来就不保证顺序如果默认有序反而给开发者造成错误预期以为顺序稳定。我见过不少从Python转过来的同学因为Python dict在3.7后保持插入序到Go里就踩了遍历顺序的坑。map的典型场景是配置文件解析和JSON反序列化。这种场景下数据往往先被解析成map[string]interface{}后续取值时最头疼的问题就是判断值类型。你不用一行行写value nil这种蹩脚判断Go的类型断言就是干这个的func checkMapValue(m map[string]interface{}) { for key, value : range m { switch typed : value.(type) { case string: fmt.Printf(key %s 是字符串: %s\n, key, typed) case int: fmt.Printf(key %s 是整数: %d\n, key, typed) case float64: fmt.Printf(key %s 是浮点数: %v\n, key, typed) case bool: fmt.Printf(key %s 是布尔值: %v\n, key, typed) case []interface{}: fmt.Printf(key %s 是数组: %v\n, key, typed) case map[string]interface{}: fmt.Printf(key %s 是对象\n, key) default: fmt.Printf(key %s 是未知类型: %T\n, key, value) } } }注意一个经典坑从JSON反序列化出来的数字默认是float64不是int。比如json.Unmarshal把一个整数5放进interface{}里类型断言value.(int)会失败必须用float64接收再手动转。另一个坑是nil map读不存在的key不会panic但写nil map直接抛panic。所以map一定要make之后再用这个习惯要刻进DNA里。2.3 string只读的字节切片编码上最容易翻车string在Go里底层就是一个只读的[]byte所以s[0]这种操作直接被编译器禁止想修改得先转成[]byte再转回来。这也解释了为什么len(hello世界)返回的是11而不是7。字符串里每个中文字符在UTF-8编码下占3个字节len数的是字节数不是字符数。想正确统计字符数量要么用utf8.RuneCountInString(s)要么转成[]rune(s)再len。遍历也分两种s : hello世界 for i : 0; i len(s); i { // 按字节遍历中文位置会输出乱码字符 } for i, r : range s { // 按rune遍历编译器自动做UTF-8解码 }range能正确遍历是因为编译器在底层做了逐rune解码工作。这也是八股文里常问的一个点看起来简单但能解释清楚“为什么range不会乱码”的人并不多。字符串拼接同样有讲究。用拼接短小字符串问题不大但循环里几万次拼接每次都会生成新字符串、分配新内存性能会很难看。正确姿势是strings.Builder它内部维护一个可变byte切片WriteString往里面追加最后一次性转成string。如果预先知道大概长度还可以Builder.Grow(n)预分配空间省去扩容的拷贝。3. 手写一遍所有数据结构才算真的懂3.1 链表从定义到反转的全部细节链表是其他数据结构的地基。Go里定义单链表节点非常简洁一个struct搞定type Node struct { Val int Next *Node } type LinkedList struct { Head *Node Len int }在尾部添加一个节点关键逻辑是找到最后一个节点然后把它的Next指向新节点。删除一个指定值的节点则需要记录前驱节点因为单链表只能往后走改不了上个节点的指向。头节点删除时要单独处理因为头节点没有前驱。很多初学者在这里容易漏掉特判写个if l.Head.Val val的分支就能解决。链表最经典的面试题是反转。三指针法是最容易理解的做法func reverseList(head *Node) *Node { var prev *Node curr : head for curr ! nil { next : curr.Next curr.Next prev prev curr curr next } return prev }思路就一句话每次先存住next再把当前节点的Next指向前一个节点。全程O(n)时间O(1)空间。递归版更短但递归深度等于链表长度栈空间O(n)在链表很长时有爆栈风险。面试如果要求手写我一般先写递归版本展示思路再补一个迭代版本强调空间优势。工程里Go的container/list就是标准的双向链表LRU缓存的实现核心就是它。3.2 栈、队列和双端队列如何拿到O(1)的关键操作栈在Go里最朴素的实现就是slice。Push用appendPop取最后一个元素再缩容type Stack []int func (s *Stack) Push(v int) { *s append(*s, v) } func (s *Stack) Pop() int { n : len(*s) v : (*s)[n-1] *s (*s)[:n-1] return v }队列如果用slice正面实现头删会导致后面元素整体前移每次删头O(n)。要拿到真正的O(1)入队出队有两个常规思路一是用环形数组二是用双向链表。环形数组的思路非常值得掌握它本质上把线性空间掰成一个环用head和tail两个索引配合取模运算转圈圈。双端队列deque就是基于这个思路同时允许头尾两边插入删除。我在这里给出一个简化版双端队列实现type Deque struct { data []int head int tail int size int cap int } func NewDeque(capacity int) *Deque { return Deque{ data: make([]int, capacity), cap: capacity, } } func (q *Deque) PushFront(v int) bool { if q.size q.cap { return false } q.head (q.head - 1 q.cap) % q.cap q.data[q.head] v q.size return true } func (q *Deque) PushBack(v int) bool { if q.size q.cap { return false } q.data[q.tail] v q.tail (q.tail 1) % q.cap q.size return true } func (q *Deque) PopFront() (int, bool) { if q.size 0 { return 0, false } v : q.data[q.head] q.head (q.head 1) % q.cap q.size-- return v, true } func (q *Deque) PopBack() (int, bool) { if q.size 0 { return 0, false } q.tail (q.tail - 1 q.cap) % q.cap v : q.data[q.tail] q.size-- return v, true }核心就一个取模操作(index ± 1 cap) % cap这其实就是钟表转圈的思想。不管哪一端插入删除都只动head或tail指针时间复杂度严格O(1)。环形数组唯一的代价是固定容量满了就要扩容。实际工程里环形队列在高性能日志系统、网络缓冲、生产者消费者模型里用得非常多。3.3 二叉树与二叉搜索树递归不是玄学树在Go里的定义同样简洁type TreeNode struct { Val int Left *TreeNode Right *TreeNode }递归遍历是树的入门基本功。前序遍历、中序遍历、后序遍历的区别只是访问根节点的时机不同代码写法几乎一样。中序遍历二叉搜索树会得到一个升序序列这个性质很实用可以用来验证一棵树是不是BST。递归本身不难难的是理解递归栈。树的高度为h时递归深度就是h最坏情况下斜树会退化成链表递归深度达到O(n)所以工程里DB索引不会用普通BST转而用平衡树或B树。这是八股文里“为什么不用普通二叉搜索树”的标准答案。二叉搜索树的删除操作是重点分三种情况叶子节点直接置空只有一个孩子就把孩子提上来有两个孩子则找到右子树的最小节点把值复制到当前节点再递归删掉那个最小节点。很多教材把第三种情况写得很绕我的经验是画图拆解右子树最小节点就是右子树一直往左走到底的那个节点它一定没有左孩子删起来其实是最简单的。层序遍历是BFS思想在树上的体现配合队列实现func levelOrder(root *TreeNode) [][]int { res : [][]int{} if root nil { return res } queue : []*TreeNode{root} for len(queue) 0 { size : len(queue) level : []int{} for i : 0; i size; i { node : queue[0] queue queue[1:] level append(level, node.Val) if node.Left ! nil { queue append(queue, node.Left) } if node.Right ! nil { queue append(queue, node.Right) } } res append(res, level) } return res }这里的层序遍历用slice当队列在LeetCode上完全够用。工程里如果数据量大建议换成环形数组或container/list因为queue queue[1:]这种写法底层数组依然被引用频繁出队会产生内存滞留问题这一点我会在后面的坑里细讲。3.4 堆与TopKcontainer/heap的正确打开方式堆是一种特殊的完全二叉树用数组存储非常紧凑。Go标准库的container/heap是个接口定义了不少方法论但跟C的priority_queue比起来它给你的不是现成的大顶堆小顶堆而是让你自己实现接口。这个设计一开始让人烦躁用熟了反而觉得清晰。实现一个小顶堆type MinHeap []int func (h MinHeap) Len() int { return len(h) } func (h MinHeap) Less(i, j int) bool { return h[i] h[j] } func (h MinHeap) Swap(i, j int) { h[i], h[j] h[j], h[i] } func (h *MinHeap) Push(x interface{}) { *h append(*h, x.(int)) } func (h *MinHeap) Pop() interface{} { old : *h n : len(old) x : old[n-1] *h old[:n-1] return x }用法就是heap.Init(h)把一个普通切片变成堆heap.Push(h, 5)和heap.Pop(h)操作元素。很多人不知道heap.Init的复杂度其实是O(n)以为是O(n log n)。这是建堆算法“自下而上”的巧妙之处理解这个对面试很有用。TopK问题是堆的经典应用。要找出最大的K个元素就维护一个大小为K的最小堆遍历一遍数据只要当前元素比堆顶大就弹出堆顶、插入新元素。遍历完堆里剩下的就是TopK。整体时间复杂度O(n log k)空间复杂度O(k)。当k远小于n时这个方法比全量排序高效得多。如果数据特别大比如几百G日志堆还支持流式处理不用一次把全部数据载入内存。堆排序就是先heap.Init建堆再反复heap.Pop弹出的顺序就是有序序列。缺点是堆排序对缓存不友好实际性能往往不如快排但它的优势是原地排序空间O(1)在最坏情况下也能稳定O(n log n)。快排最坏O(n^2)但在工程里几乎碰不到因为随机选pivot已经把这个概率压得极低。3.5 排序和查找数据结构的最终检阅数据结构搭好了接下来就是排序和查找的天下。这两类算法其实一直在和“结构”打交道二分查找依赖数组的随机访问哈希查找依赖哈希表的高效散列BST查找依赖树的局部有序性。排序则是对数组这个最基本结构最充分的利用。快速排序是面试第一高频排序思路是选一个pivot把小于它的放左边、大于它的放右边再递归处理两边。平均O(n log n)看着简单但分区函数写错的人一堆。Go的sort包在现代版本里用的并不只是纯快排而是在快速排序、插入排序、堆排序之间切换小数组用插入排序递归过深用堆排序兜底避免快排退化成O(n^2)。这个“混合策略”是所有排序库的最终答案值得记住。归并排序稳定但需要O(n)额外空间。堆排序原地但缓存不友好。这三种排序的实际表现我在本机用100万随机整数测过快排和Go的sort包最快归并慢一些堆排序还要慢一点。但如果数据本身就接近有序插入排序反而最快这就是为什么工程库会在小数组和近有序数组上切插入排序。小于12个元素用插入排序这个阈值Go源码里就写着也是很多通用库共同的选择。查找算法里面二分查找最容易被问边界条件。我自己的写法是左闭右开区间func binarySearch(nums []int, target int) int { lo, hi : 0, len(nums) for lo hi { mid : lo (hi-lo)/2 if nums[mid] target { lo mid 1 } else { hi mid } } if lo len(nums) nums[lo] target { return lo } return -1 }这个写法好处在于循环不变量清晰lo指向第一个不小于target的位置判断一下是否命中即可。用lo (hi-lo)/2而不是(lohi)/2是为了防止int溢出这个细节在面试里很加分。4. 面试八股与实战中那些坑我都替你踩过了4.1 深拷贝与浅拷贝slice和map共享底层的根源Go里的赋值对slice、map、interface{}来说复制的是引用头部不是底层数据。这和其他语言的“对象引用”很像但因为没有显式标记很多新手会踩坑。slice的浅拷贝前面已经说过map的拷贝更直接Go没有内置map copy你必须手动循环。深拷贝一个嵌套结构比如map[string][]int就得递归处理更下层的slice。工程上如果只是为了存档或跨协程传递可以用encoding/json的Marshal再Unmarshal虽然慢但省心。想要高性能又不想手写递归可以用第三方库但本质上还是遍历结构复制。我踩过的真实坑是函数里给传入的slice append了一个元素因为容量足够直接写进了底层数组的下一个位置把函数外面正好存在那里的数据覆盖了。排查了很久才定位到是“容量足够导致原地扩容”这个隐蔽行为。这也催生了一个习惯凡是跨函数传递后还要继续追加的slice我都提前copy一份避免不可预期的内存共享。4.2 map并发读写不是加锁那么简单Go的map天生不是并发安全的。并发写会直接抛panic程序崩溃错误信息是fatal error: concurrent map writes。只读并发不写其实没问题但判断“只读”在工程里很难保证所以正经项目里该加锁还是加锁。最简单的方案是sync.RWMutextype SafeMap struct { mu sync.RWMutex m map[string]int } func (s *SafeMap) Get(key string) int { s.mu.RLock() defer s.mu.RUnlock() return s.m[key] } func (s *SafeMap) Set(key string, val int) { s.mu.Lock() defer s.mu.Unlock() s.m[key] val }读多写少的场景可以用sync.Map它内部做了无锁读优化适合key集合相对稳定、只做增删查的缓存型场景。性能更高的方案是分片锁把key哈希到不同的小map里每个小map一把锁这样锁的粒度细竞争显著降低。不少开源库如orcaman/concurrent-map就是这个思路我们做本地缓存的时候也抄了这个设计实测高并发下吞吐量比单把大锁高出好几倍。4.3 类型断言、nil与零值的边界interface{}是Go的重要抽象但它的类型判断一不小心就会panic。类型断言有两种写法直接断言和comma-ok。直接断言失败会paniccomma-ok只返回false代码不会炸。所以线上代码我永远用comma-ok形式if str, ok : value.(string); ok { // do something }另外要注意nil的判定。一个interface{}变量本身为nil时断言任何具体类型都会失败。很多人写成if value nil前没想清楚其实interface{}的nil必须显式判断不能用类型断言代替。map取不存在的key返回对应类型的零值这个设计和“key存在但值就是零值”无法区分。要判断key到底存不存在必须用value, ok : m[key]。我之前写配置解析就出过这个问题某个开关默认应该是true配置里没写map返回false导致行为反转排查了半天最后才发现不是配置错了而是没区分“不存在”和“确实为false”这两种情况。nil slice和空slice的区别也很隐蔽。nil slice的底层数组指针是nillen和cap是0空slice是make出来的底层数组存在但长度为0。这个区别在JSON序列化时尤其明显nil slice会变成null空slice会变成[]。如果后端API约定返回数组结果返回了null前端很可能直接报错。4.4 性能优化预分配、字符串拼接与内存对齐性能优化的第一个原则是减少不必要的分配。slice最典型的优化就是预分配cap。我要解析一个可能有一万行的配置文件就会用make([]string, 0, 10000)这一下能省掉好多次扩容分配和拷贝。字符串拼接用strings.Builder前面说过。builder.Grow还需要提一下比如拼SQL、拼JSON预估最终长度是1KB那就先builder.Grow(1024)中间就不会反复扩容。struct的内存对齐也是一个容易被忽略的点。在64位平台上下面这个结构体type Bad struct { A int8 // 1字节 B int64 // 8字节 C int8 // 1字节 }看起来只用了10字节实际会占24字节因为编译器为了让64位整数对齐到8字节边界会插入大量padding。重排一下type Good struct { B int64 // 8字节 A int8 // 1字节 C int8 // 1字节 }只占16字节内存直接省了三分之一。如果你在写大量对象的缓存结构这种重排能明显降低整体内存占用和GC压力。高频创建的小对象可以用sync.Pool复用减少GC扫描。但别滥用Pool本身也有开销而且不适合有状态对象。我的原则是经过profiling确认确实是瓶颈了再上Pool不要一开始就“优化”。5. 一些个人经验与后续可以怎么扩展如果让我给一个学习路径我的建议是先把这篇博客里的内置容器底层机制吃透再手写一轮经典数据结构然后去LeetCode刷对应的题最后回过头看看Go标准库的container/heap、container/list源码以及runtime里的slice和map实现。这样一轮下来数据结构的基础会比单纯背八股文扎实得多。面试之前我又把链表反转、LRU缓存、两个栈实现队列、TopK这几个经典题各手写了两遍手感很重要。手写题不太看你背得多熟而是看你在紧张状态下能不能写出边界正确的代码。有一个习惯很管用每道题写完先在脑海中模拟空输入、单元素输入、极端输入三种情况大部分bug都能在这个环节暴露。数据结构这个东西靠看是看不明白的。我以前也收集过一堆“数据结构pdf”和期末复习资料但真正让我开窍的是某一个周末一口气把链表、栈、队列、二叉树、堆全部用Go写了一遍。写完之后很多原理不需要背自然就通了。后面我还会想继续扩展红黑树、跳表、布隆过滤器、并查集这些工程中更进阶的结构它们本质上也都是这几种基本结构的组合和变形。
返回列表