Go:排序、查找、二叉树、堆、图这些算法基础怎么从会写题走向会落地

学 Go 学到算法和数据结构这一段时,最容易出现一种断层:

  • 题会做一些,但一进项目就不知道这些东西放哪里
  • 知道快排、二分、堆、图遍历这些名字,但和真实服务之间连不上
  • 看到标准库里有 sortcontainer/heap,却不知道什么时候该直接用,什么时候该自己封装
  • 写缓存、调度器、依赖执行器、批处理器时,结构选型很随意,后面越改越乱

问题不在于“题刷得不够多”,而在于没有把算法结构和工程问题对应起来

真实项目里,排序、查找、树、堆、图出现得非常频繁,只是它们通常不会以“请实现一个二叉树”这种题面出现,而是藏在下面这些需求里:

  • 要把一批任务按优先级和重试时间排好
  • 要在一堆阈值和区间规则里快速定位命中的配置
  • 要维护一套有序数据,支持范围查询或最近值查询
  • 要做最早到期任务调度
  • 要处理任务依赖、服务依赖、流程依赖

这一篇不按刷题顺序讲,而是按工程问题来讲:排序解决顺序问题,查找解决定位问题,树解决有序组织问题,堆解决最值优先问题,图解决关系问题。

贯穿全文的小项目是一个最小的巡检任务编排器。它要做几件事:

  1. 读取一批巡检任务定义
  2. 按优先级、计划时间和任务名排序
  3. 根据阈值规则快速判断任务等级
  4. 用最小堆调度“下一批该执行的任务”
  5. 用有向图校验任务依赖并给出执行顺序
  6. 在需要范围查询时,说明什么时候可以继续用切片,什么时候才值得上树结构

这个场景不大,但足够把这篇文章的五条主线串起来。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  1. 排序、查找、树、堆、图分别在工程里解决什么问题
  2. Go 标准库已经覆盖了哪些能力
  3. 哪些场景适合直接用切片和 sort,哪些场景才值得自己写结构
  4. 二分查找为什么比“遍历找一下”更值得在配置和规则里使用
  5. 堆为什么比“每次排序一下”更适合做调度
  6. 图遍历和拓扑排序在依赖执行里怎么落地
  7. 自定义数据结构时,测试和排错该怎么做

如果这些问题能答清,后面你写批处理器、任务调度器、规则引擎、缓存组件、依赖编排模块时,结构选型会稳很多。

二、先把一句话结论说清楚

算法基础在 Go 项目里的价值,不是“把 LeetCode 题解背下来”,而是:

把顺序、定位、优先级和依赖关系,收敛成可维护、可测试、复杂度可控的数据结构。

可以先记住这组最实用的映射关系:

  1. 需要稳定地组织顺序
    先看排序

  2. 需要在有序集合里快速定位
    先看二分查找

  3. 需要维护有序结构并做范围操作
    先看树,但先确认切片加二分是否已经够用

  4. 需要持续拿到“最小值”或“最大值”
    先看堆

  5. 需要表达“谁依赖谁、谁先谁后”
    先看图和拓扑排序

很多项目里真正出问题的,不是没学过这些结构,而是结构和问题没有对上

  • 用全量排序解决最小值问题,白白多做一堆工作
  • 用线性扫描解决有序查找问题,后面规则一多就变慢
  • 用树去解决其实 map + slice 就能完成的事情,结构过度复杂
  • 用数组硬维护依赖顺序,最后一改配置就乱

三、先看这篇文章贯穿始终的小项目

假设现在要做一个最小巡检任务编排器,任务结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package scheduler

import "time"

type Severity int

const (
SeverityLow Severity = iota + 1
SeverityMedium
SeverityHigh
)

type Task struct {
Name string
Service string
Priority int
NextRunAt time.Time
LatencyP95 int
DependsOn []string
}

type ThresholdRule struct {
MaxLatency int
Severity Severity
}

这个编排器的需求看起来很日常:

  • 展示页要把任务按优先级和时间排好
  • 任务执行前,要根据延迟阈值给任务打等级
  • 调度器每次只想拿到“最早该执行”的任务
  • 有些任务有依赖关系,要先完成上游再执行下游
  • 后面如果要支持“在某个优先级区间里找任务”,就需要重新评估结构

这一套需求分别会落到不同结构上:

  • 排序:任务列表展示、结果输出
  • 二分查找:阈值规则匹配
  • 二叉树:有序集合、范围查找、最近值查找的思路模型
  • 堆:下一次执行时间最小的任务调度
  • 图:依赖校验和拓扑执行

四、先给一个最小可运行版本

先不追求结构最优,先给一个完整骨架,让后面的每个结构都能挂上去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package scheduler

import (
"container/heap"
"errors"
"sort"
"time"
)

type Planner struct {
rules []ThresholdRule
}

func NewPlanner(rules []ThresholdRule) *Planner {
copied := append([]ThresholdRule(nil), rules...)
sort.Slice(copied, func(i, j int) bool {
return copied[i].MaxLatency < copied[j].MaxLatency
})
return &Planner{rules: copied}
}

func (p *Planner) SeverityForLatency(latency int) Severity {
for _, rule := range p.rules {
if latency <= rule.MaxLatency {
return rule.Severity
}
}
if len(p.rules) == 0 {
return SeverityLow
}
return p.rules[len(p.rules)-1].Severity
}

func SortTasks(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
if tasks[i].Priority != tasks[j].Priority {
return tasks[i].Priority > tasks[j].Priority
}
if !tasks[i].NextRunAt.Equal(tasks[j].NextRunAt) {
return tasks[i].NextRunAt.Before(tasks[j].NextRunAt)
}
return tasks[i].Name < tasks[j].Name
})
}

type taskMinHeap []Task

func (h taskMinHeap) Len() int { return len(h) }
func (h taskMinHeap) Less(i, j int) bool { return h[i].NextRunAt.Before(h[j].NextRunAt) }
func (h taskMinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *taskMinHeap) Push(x any) { *h = append(*h, x.(Task)) }
func (h *taskMinHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}

func NextTasks(tasks []Task, limit int) []Task {
h := taskMinHeap(append([]Task(nil), tasks...))
heap.Init(&h)
result := make([]Task, 0, limit)
for h.Len() > 0 && len(result) < limit {
result = append(result, heap.Pop(&h).(Task))
}
return result
}

func ValidateTask(task Task) error {
if task.Name == "" {
return errors.New("task name can not be empty")
}
if task.Service == "" {
return errors.New("service can not be empty")
}
if task.NextRunAt.IsZero() {
return errors.New("next_run_at can not be zero")
}
return nil
}

这个版本已经有了三个基础点:

  1. sort.Slice 解决多字段排序
  2. container/heap 解决最早执行任务选取
  3. 用一个最简单的线性扫描版本做阈值匹配

后面会在这个骨架上逐步把结构换对。

五、排序在真实项目里解决的到底是什么问题

排序最常见的误解,是把它看成“面试题里的快排归并”。

真实项目里,排序更常对应这些需求:

  • 列表页怎么排
  • 优先级怎么排
  • 告警怎么排
  • 重试队列怎么排
  • 日志聚合后的结果怎么排
  • 报表输出怎么排

在 Go 里,绝大多数排序需求都优先从标准库开始:

  • sort.Intssort.Strings
  • sort.Slice
  • sort.SliceStable
  • 如果是 Go 1.21+,还可以用 slices.Sortslices.SortFunc

工程里优先关心的不是“这是不是快排”,而是三个判断:

  1. 排序键是什么
  2. 相等元素是否要保持原顺序
  3. 排序是偶尔做一次,还是会被高频调用

六、一个最小的排序例子

先看任务列表排序:

1
2
3
4
5
6
7
8
9
10
11
func SortTasks(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
if tasks[i].Priority != tasks[j].Priority {
return tasks[i].Priority > tasks[j].Priority
}
if !tasks[i].NextRunAt.Equal(tasks[j].NextRunAt) {
return tasks[i].NextRunAt.Before(tasks[j].NextRunAt)
}
return tasks[i].Name < tasks[j].Name
})
}

这里的排序键很明确:

  1. 优先级高的排前面
  2. 同优先级时,执行时间早的排前面
  3. 再相同,用任务名兜底,保证顺序可预测

第三条很容易被忽略,但它很重要。
如果没有最后一个兜底键,结果在不同输入顺序下可能表现得不稳定,测试也更难写。

七、什么时候要用稳定排序

稳定排序的意思是:如果两个元素按比较规则看起来“相等”,它们原本的先后顺序会被保留。

例如你已经先按“创建时间”排过一次,现在又想按“优先级”排序,但同优先级的任务仍然希望保持原来的创建顺序,这时就适合 sort.SliceStable

1
2
3
4
5
func SortForBoard(tasks []Task) {
sort.SliceStable(tasks, func(i, j int) bool {
return tasks[i].Priority > tasks[j].Priority
})
}

这类需求在列表展示、报告导出、测试结果排序里很常见。

如果业务完全不关心“相等元素原来的顺序”,用普通 sort.Slice 就够了。

八、排序里最常见的错误写法

先看一个容易埋雷的版本:

1
2
3
4
5
func BadSortTasks(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].Priority >= tasks[j].Priority
})
}

这个写法的问题有两层:

  1. 比较函数没有形成清晰的一致顺序
    >= 会让相等时同时出现“i 小于 j”和“j 小于 i”的模糊语义

  2. 次级排序键丢了
    同优先级任务的顺序不可预测

再看另一个高频问题:

1
2
3
4
5
func SortTaskPointers(tasks []*Task) {
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].NextRunAt.Before(tasks[j].NextRunAt)
})
}

如果 tasks 里可能有 nil,这里会直接 panic。
指针集合排序前,先把 nil 边界处理掉,或者明确约束输入不允许 nil

九、怎么测试排序代码

排序代码最值得测的不是“能不能排出来”,而是:

  1. 多字段排序是否符合预期
  2. 相等元素是否稳定
  3. 零值或空切片是否安全
  4. 输出顺序是否确定

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package scheduler

import (
"testing"
"time"
)

func TestSortTasks(t *testing.T) {
now := time.Date(2024, 8, 1, 9, 0, 0, 0, time.UTC)
tasks := []Task{
{Name: "b", Priority: 1, NextRunAt: now.Add(2 * time.Minute)},
{Name: "a", Priority: 2, NextRunAt: now.Add(5 * time.Minute)},
{Name: "c", Priority: 2, NextRunAt: now.Add(1 * time.Minute)},
}

SortTasks(tasks)

got := []string{tasks[0].Name, tasks[1].Name, tasks[2].Name}
want := []string{"c", "a", "b"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("index %d: got %s, want %s", i, got[i], want[i])
}
}
}

如果测试断言只写“第一个元素是最高优先级”,而不校验完整顺序,后面一改次级排序键就容易漏掉回归。

十、查找在项目里最值钱的地方是“规则定位”

很多业务场景并不是在超大的数组里找某个数,而是在一组已经排好序的规则里快速定位命中的边界:

  • 响应时间阈值在哪一档
  • 限流配置落在哪一段
  • 报警分级命中哪条规则
  • 价格或积分区间落在哪个范围

这种场景里,查找的核心价值不是“省掉几行循环”,而是:

  • 明确要求输入有序
  • 把复杂度从线性扫描收成对数级
  • 把“规则表”和“命中逻辑”拆开

Go 里查找也优先从标准库或标准思路开始:

  • sort.Search
  • Go 1.21+ 的 slices.BinarySearchslices.BinarySearchFunc

十一、把线性扫描改成二分查找

前面 SeverityForLatency 先用了最简单的扫描版本。
当规则越来越多时,更适合改成二分查找:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (p *Planner) SeverityForLatency(latency int) Severity {
if len(p.rules) == 0 {
return SeverityLow
}

idx := sort.Search(len(p.rules), func(i int) bool {
return latency <= p.rules[i].MaxLatency
})

if idx == len(p.rules) {
return p.rules[len(p.rules)-1].Severity
}
return p.rules[idx].Severity
}

这段代码的重点不是“记住模板”,而是理解 sort.Search 的语义:

找到第一个让条件为真的位置。

所以要先把规则按 MaxLatency 升序排好,再定义“什么叫命中”。

十二、查找里最容易写错的不是代码,而是边界

先看几个最常见的边界:

  1. 规则数组为空怎么办
  2. 命中第一个元素怎么办
  3. 比所有规则都大怎么办
  4. 比较条件是 <<=> 还是 >=

例如下面这个版本就很危险:

1
2
3
4
5
6
func (p *Planner) BadSeverityForLatency(latency int) Severity {
idx := sort.Search(len(p.rules), func(i int) bool {
return latency < p.rules[i].MaxLatency
})
return p.rules[idx].Severity
}

问题有两层:

  1. < 还是 <= 会直接改变阈值命中结果
  2. 如果 idx == len(p.rules),这里会越界

二分查找最该测试的不是“平常能不能找到”,而是这四类边界值。

十三、怎么测试查找代码

二分查找的测试适合表驱动,因为边界很清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func TestSeverityForLatency(t *testing.T) {
planner := NewPlanner([]ThresholdRule{
{MaxLatency: 100, Severity: SeverityLow},
{MaxLatency: 300, Severity: SeverityMedium},
{MaxLatency: 800, Severity: SeverityHigh},
})

cases := []struct {
name string
latency int
want Severity
}{
{name: "hit first", latency: 80, want: SeverityLow},
{name: "equal first", latency: 100, want: SeverityLow},
{name: "hit second", latency: 260, want: SeverityMedium},
{name: "overflow max", latency: 1200, want: SeverityHigh},
}

for _, tc := range cases {
got := planner.SeverityForLatency(tc.latency)
if got != tc.want {
t.Fatalf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
}

如果项目里阈值来自配置中心,还值得补一层“乱序输入规则也能被正确排序”的测试。

十四、二叉树该怎么理解,才不会一学完就硬套

二叉树在刷题里出现很多,但在 Go 工程里,不会经常遇到“手写一棵普通二叉树然后遍历”的直接需求。

它更值得被理解成一种有序组织数据的思维模型

  • 左边更小,右边更大
  • 插入、查找、范围查询都围绕这个顺序展开
  • 如果树保持平衡,很多操作能维持在对数级

工程里真正对应的需求更像这些:

  • 维护一组有序阈值,支持最近值查询
  • 维护一个有序集合,支持区间扫描
  • 做一个最小版内存索引
  • 做排行榜、时间窗口、价格区间这类顺序结构

但这里要先把一个很关键的工程判断讲清楚:

大多数业务代码,并不需要自己实现二叉搜索树。

原因通常有三点:

  1. Go 标准库没有现成通用平衡树
  2. 业务规模没大到切片加二分查找扛不住
  3. 自己维护树结构的复杂度、测试成本和 bug 风险都不低

十五、先看一个最小二叉搜索树实现

这段代码不是为了鼓励把树写进每个项目,而是为了把“有序组织”的核心行为讲清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
type node struct {
key int
value string
left *node
right *node
}

type BST struct {
root *node
}

func (t *BST) Insert(key int, value string) {
t.root = insertNode(t.root, key, value)
}

func insertNode(root *node, key int, value string) *node {
if root == nil {
return &node{key: key, value: value}
}
if key < root.key {
root.left = insertNode(root.left, key, value)
return root
}
if key > root.key {
root.right = insertNode(root.right, key, value)
return root
}
root.value = value
return root
}

func (t *BST) Find(key int) (string, bool) {
cur := t.root
for cur != nil {
switch {
case key < cur.key:
cur = cur.left
case key > cur.key:
cur = cur.right
default:
return cur.value, true
}
}
return "", false
}

这个最小版本已经说明了树结构的几个核心点:

  1. 数据按大小分布在左右子树
  2. 查找不是全量遍历,而是按比较结果缩小范围
  3. 重复 key 的处理策略要提前定义

十六、树结构在工程里的真正边界

刚才这个树结构虽然能跑,但离真实工程可用还差很远:

  • 没做平衡,极端输入下会退化成链表
  • 没做删除
  • 没做范围查询
  • 没做并发保护
  • 没做迭代器
  • 没做内存回收和复用策略

所以在真实 Go 项目里,关于树的判断通常是这样的:

  1. 只是偶尔做查询,数据量不大
    slice + sort + binary search 往往就够了

  2. 主要做精确查找,不关心顺序
    优先考虑 map

  3. 需要持续维护有序集合,并且有区间、最近值、前驱后继需求
    再认真评估树结构,可能自写,也可能引入成熟第三方库

很多“我是不是该上红黑树”的场景,最后都会回到一个更务实的答案:
先确认切片和二分查找到底是不是真的不够。

十七、堆为什么比“每次排序一下”更适合做调度

如果你只需要把一批任务排好展示出来,排序很好用。
但如果你的问题变成:

  • 不断有新任务进来
  • 每次只想拿最早执行的一个
  • 取走一个后,还要继续拿下一个
  • 还会动态插入重试任务

这时继续“每次把整个切片排序一次”,成本就高了。
这个问题更适合堆。

堆解决的是持续维护最值的问题:

  • 最小堆:最小元素总在堆顶
  • 最大堆:最大元素总在堆顶

Go 标准库已经给了现成支持:container/heap

这类场景优先用标准库,而不是自己从零维护上浮下沉逻辑。

十八、一个最小堆调度器示例

把前面的 taskMinHeap 稍微补完整一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
type taskMinHeap []Task

func (h taskMinHeap) Len() int { return len(h) }

func (h taskMinHeap) Less(i, j int) bool {
if !h[i].NextRunAt.Equal(h[j].NextRunAt) {
return h[i].NextRunAt.Before(h[j].NextRunAt)
}
return h[i].Priority > h[j].Priority
}

func (h taskMinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }

func (h *taskMinHeap) Push(x any) {
*h = append(*h, x.(Task))
}

func (h *taskMinHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}

type Scheduler struct {
queue taskMinHeap
}

func NewScheduler(tasks []Task) *Scheduler {
q := taskMinHeap(append([]Task(nil), tasks...))
heap.Init(&q)
return &Scheduler{queue: q}
}

func (s *Scheduler) PushTask(task Task) {
heap.Push(&s.queue, task)
}

func (s *Scheduler) PopNext() (Task, bool) {
if s.queue.Len() == 0 {
return Task{}, false
}
return heap.Pop(&s.queue).(Task), true
}

这比“每次排序后拿第一个”更适合做长时间运行的调度器。

十九、堆里最容易踩的坑

container/heap 不难,但有几个坑很常见:

  1. PushPop 要用指针接收者
    不然改不到原切片

  2. Less 决定的是堆顶是谁
    想要最小堆还是最大堆,要先想清楚

  3. Pop 取的是切片最后一个元素
    真正的堆顶元素会在 heap.Pop 内部先被换到末尾

  4. 直接修改堆中元素后,如果优先级变了,还要配合 heap.Fix

例如下面这个更新逻辑就不完整:

1
2
3
func (s *Scheduler) DelayFirst() {
s.queue[0].NextRunAt = s.queue[0].NextRunAt.Add(5 * time.Minute)
}

这段代码修改了堆顶元素,但没有重新调整堆结构,后面出队顺序可能就错了。
如果你维护的是可更新优先级的队列,需要记录元素索引,并在更新后调用 heap.Fix

二十、图在真实项目里最常对应“依赖关系”

图在工程里最常见的表现,不是画出一张复杂拓扑图,而是:

  • 任务 A 依赖任务 B
  • 服务 C 调用服务 D
  • 流程节点之间有前后依赖
  • 发布步骤存在前置条件

只要出现“谁先谁后”和“依赖链路”,图就已经出现了。

对这类需求,最常用的两个动作是:

  1. 遍历
    找出从某个节点开始能走到哪里

  2. 拓扑排序
    给出一条满足依赖约束的执行顺序

二十一、用图做任务依赖校验和拓扑排序

先看一个最小实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func TopoSort(tasks []Task) ([]string, error) {
graph := make(map[string][]string, len(tasks))
inDegree := make(map[string]int, len(tasks))

for _, task := range tasks {
if _, ok := inDegree[task.Name]; !ok {
inDegree[task.Name] = 0
}
for _, dep := range task.DependsOn {
if _, ok := inDegree[dep]; !ok {
return nil, fmt.Errorf("dependency %s not found", dep)
}
graph[dep] = append(graph[dep], task.Name)
inDegree[task.Name]++
}
}

queue := make([]string, 0)
for name, degree := range inDegree {
if degree == 0 {
queue = append(queue, name)
}
}
sort.Strings(queue)

order := make([]string, 0, len(tasks))
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
order = append(order, cur)

for _, next := range graph[cur] {
inDegree[next]--
if inDegree[next] == 0 {
queue = append(queue, next)
sort.Strings(queue)
}
}
}

if len(order) != len(tasks) {
return nil, errors.New("dependency cycle detected")
}
return order, nil
}

这段代码做了几件事:

  1. 用邻接表表达图
  2. 用入度统计找出没有前置依赖的节点
  3. 按 Kahn 算法不断弹出可执行节点
  4. 最后如果还有节点没处理完,就说明有环

在真实任务编排里,这已经够处理一大类依赖执行问题。

二十二、图结构里最常见的错误示例

先看一个高频错误:

1
2
3
4
5
6
7
8
9
func BadBuildGraph(tasks []Task) map[string][]string {
graph := make(map[string][]string)
for _, task := range tasks {
for _, dep := range task.DependsOn {
graph[task.Name] = append(graph[task.Name], dep)
}
}
return graph
}

这个版本未必“代码错到不能跑”,但它经常会把边方向定义反。
如果你的拓扑排序实现期待的是“前置节点 -> 后续节点”,这里却写成了“当前节点 -> 前置节点”,最后出来的执行顺序就会错。

图相关代码最该先明确的是:

  1. 边方向怎么定义
  2. 节点唯一标识是什么
  3. 缺失依赖怎么处理
  4. 环怎么报错

二十三、什么时候直接用标准库,什么时候才值得自定义结构

这是这篇文章最关键的工程判断。

1. 排序

优先用标准库:

  • sort.Ints
  • sort.Strings
  • sort.Slice
  • sort.SliceStable
  • Go 1.21+ 的 slices.Sortslices.SortFunc

只有在这几类场景里,才考虑自己封装:

  • 同一排序规则会在很多地方重复使用
  • 需要把比较逻辑独立出来便于复用和测试
  • 需要组合多个排序器

2. 查找

优先用标准思路或标准库:

  • sort.Search
  • slices.BinarySearch

只有在这几类场景里,才值得自定义:

  • 规则匹配除了二分,还需要额外上下文信息
  • 命中逻辑要支持区间合并、回退、兜底策略
  • 需要把查找器包装成稳定组件对外提供接口

3. 树

优先不要自己写,先问:

  • map 能不能解决精确查找
  • slice + sort + binary search 能不能解决有序查找
  • 范围查询是否真的高频

只有在“有序集合长期维护 + 范围操作高频 + 数据量上来 + 切片重排成本明显”时,树结构才更值得投入。

4. 堆

优先用 container/heap
通常没有必要自己从零写上浮下沉逻辑,除非:

  • 需要极致性能,标准库抽象已成为热点
  • 需要更复杂的元素更新语义
  • 需要对象池、批量更新、定制内存布局

5. 图

图一般都需要根据业务自定义,因为节点、边、属性和约束几乎总是业务相关的。
但内部基础容器仍然优先用标准类型:

  • map[string][]string
  • map[string]int
  • []string

二十四、把这几个结构串回同一个完整场景

现在把小项目重新串一次,看看它们在同一条链里各自负责什么:

  1. 任务配置加载进来后
    先用排序把展示和输出顺序固定下来,方便日志、测试和报表保持稳定

  2. 巡检结果出来后
    用二分查找在阈值规则里快速定位任务等级

  3. 调度循环运行时
    用最小堆持续拿到最早该执行的任务,而不是每轮全量排序

  4. 如果某些任务有依赖
    先用图做环检测和拓扑排序,确认执行顺序

  5. 如果后面要做“按优先级区间找任务”或“找最接近某个阈值的任务”
    先试 slice + binary search,只有在更新和范围操作都很频繁时,再考虑树结构

这就是“从会写题走向会落地”的关键变化:

  • 不再先想“该用什么经典题型”
  • 而是先想“这个问题本质是顺序、定位、最值还是依赖”

二十五、一个更接近值班现场的完整案例

假设某天晚上,巡检平台出现一个现象:

  • 部分高优先级任务一直没有被及时执行
  • 一批依赖任务卡住不跑
  • 列表页里同优先级任务顺序来回跳
  • 相同延迟值在不同实例上命中的严重级别不一致

排查时不要一上来就看“是不是机器卡了”,先顺着结构看:

1. 先看排序

  • 比较函数是否漏了次级排序键
  • 是否用了不稳定排序,导致同优先级任务顺序不固定
  • 排序前是否有零值时间或空任务名

2. 再看阈值查找

  • 规则是否先按 MaxLatency 升序整理
  • 二分条件是 < 还是 <=
  • 大于最大阈值时是否有兜底

3. 再看堆调度

  • 动态修改任务执行时间后有没有 heap.Fix
  • 堆顶弹出后,重试任务有没有重新入堆
  • 比较函数是否只比较时间,漏掉了优先级兜底

4. 最后看依赖图

  • 依赖边方向是否定义反了
  • 是否存在缺失节点
  • 是否有环导致拓扑排序无法完成

实际修复后,常见会落到这些动作:

  • 给排序补上稳定兜底键
  • 给二分匹配补齐边界测试
  • 给堆更新逻辑加 heap.Fix
  • 给依赖加载过程加“缺失依赖”和“环检测”校验

这类值班问题表面上是“任务没跑对”,本质上通常是结构选型或结构边界没有收住。

二十六、怎么验证这篇文章里的核心代码

如果本地有 Go 环境,可以按最小包结构补下面这些测试:

1
go test ./...

如果要重点验证调度和依赖,可以继续补:

1
2
3
go test -run TestSortTasks ./...
go test -run TestSeverityForLatency ./...
go test -run TestTopoSort ./...

一个最小的图测试示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestTopoSort(t *testing.T) {
tasks := []Task{
{Name: "collect-config"},
{Name: "check-api", DependsOn: []string{"collect-config"}},
{Name: "check-db", DependsOn: []string{"collect-config"}},
}

got, err := TopoSort(tasks)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

want := []string{"collect-config", "check-api", "check-db"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("index %d: got %s, want %s", i, got[i], want[i])
}
}
}

如果要继续提高可信度,还值得再补三类测试:

  1. 环检测测试
  2. 堆更新后的顺序测试
  3. 乱序规则输入时的二分匹配测试

二十七、排障时最值得先看的几个信号

这类结构相关问题,排障顺序通常可以固定成下面这样:

  1. 看输入前置条件
  • 排序和二分要求的数据是否已排序
  • 图里的节点名是否唯一
  • 堆里的时间字段是否有零值
  1. 看比较逻辑
  • 是否用了不一致的比较条件
  • 是否少了次级排序键
  • 是否把边界值条件写错
  1. 看结构不变量
  • 堆顶是否始终满足最小值语义
  • 拓扑排序处理后节点数是否等于总节点数
  • 树插入后中序遍历是否保持有序
  1. 看修改后的重排动作
  • 改了堆元素后有没有重新调整
  • 改了规则后有没有重新排序
  • 改了依赖后有没有重新做环校验

如果这四步不先看清,就很容易在日志和现象里兜圈子。

二十八、什么时候该用,什么时候不要硬套

这几类结构都很好用,但边界也要讲清:

排序适合:

  • 展示排序
  • 结果输出
  • 一次性批处理前整理顺序

排序不太适合:

  • 动态频繁插入、每次只拿最值的场景
    这类需求更适合堆

二分查找适合:

  • 有序规则
  • 阈值匹配
  • 区间定位

二分查找不太适合:

  • 输入本身无序,且每次都要先排序
  • 命中逻辑不单依赖顺序,还依赖复杂上下文状态

树适合:

  • 有序集合长期维护
  • 范围查询高频
  • 最近值、前驱后继查询高频

树不太适合:

  • 只是偶尔查一下
  • 可以被 mapslice + binary search 替代
  • 团队没有足够时间把结构测试和边界维护好

堆适合:

  • 优先级队列
  • 定时调度
  • 频繁取最小值或最大值

堆不太适合:

  • 需要频繁按任意字段扫描全量元素
  • 主要需求是排序展示而不是持续取最值

图适合:

  • 依赖执行
  • 环检测
  • 路由和链路分析

图不太适合:

  • 实际上只是简单线性流程,却被过度抽象成复杂关系网

二十九、可以自己做的一个练习

可以把这篇文章的小项目补成一个最小命令行程序,练这四件事:

  1. 从 JSON 文件读取任务和阈值规则
  2. 打印排序后的任务列表
  3. 输出最早执行的前 3 个任务
  4. 对任务依赖做拓扑排序,如果有环就报错

练习时至少补三组输入:

  1. 正常输入
  2. 阈值乱序输入
  3. 存在依赖环的输入

如果要再往前走一步,可以继续补:

  • 一个 UpdateTaskTime(name string, nextRunAt time.Time),并正确调用 heap.Fix
  • 一个“按优先级区间查找”的接口,先用 slice + binary search
  • 一个最小 BST 的中序遍历测试,验证插入后是否仍然有序

三十、结语

排序、查找、二叉树、堆、图这些内容,在 Go 里真正值得学的,不是题型本身,而是它们分别对应的工程问题:

  • 排序处理顺序
  • 查找处理定位
  • 树处理有序组织
  • 堆处理最值优先
  • 图处理依赖关系

把这条映射关系建立起来之后,很多结构选择会变得清楚:

  • 列表和报表先看排序
  • 阈值和区间先看二分
  • 调度器先看堆
  • 依赖执行先看图
  • 树先别急着上,先确认切片和二分到底够不够

从会写题走向会落地,不是把算法忘掉,而是把它们放回真实代码里,变成稳定、可测试、边界清楚的工程结构。