跳转至

排序与查找

学习目标

学完本章后,学习者应该能够:

  1. 理解常见排序算法的复杂度和适用场景。
  2. 掌握二分查找的前提和边界。
  3. 理解 Top K 问题的常见解法。
  4. 能正确使用 Go 的 sort 包。

为什么要学排序与查找

排序和查找是很多后端功能的基础:

  • 列表排序。
  • 排行榜。
  • 分页。
  • 去重。
  • Top N。
  • 时间线。
  • 数据归并。
  • 批处理结果合并。

很多系统慢,不是因为业务复杂,而是因为用了错误的查找方式或重复排序。

常见排序算法

算法 平均时间 额外空间 特点
冒泡排序 O(n²) O(1) 教学用,工程少用
插入排序 O(n²) O(1) 小数组或基本有序时表现不错
快速排序 O(n log n) O(log n) 平均快,最坏可退化
归并排序 O(n log n) O(n) 稳定,适合链表和外部排序思想
堆排序 O(n log n) O(1) 可原地排序,不稳定

工程中通常不手写排序,优先使用标准库。

Go sort 包

排序整数:

nums := []int{3, 1, 2}
sort.Ints(nums)

排序结构体:

type User struct {
    Name string
    Age  int
}

users := []User{
    {Name: "alice", Age: 30},
    {Name: "bob", Age: 20},
}

sort.Slice(users, func(i, j int) bool {
    return users[i].Age < users[j].Age
})

注意:排序会修改原 slice。如果调用方还需要原顺序,应先复制。

二分查找

二分查找的前提是数据已经有序。

func BinarySearch(nums []int, target int) int {
    left, right := 0, len(nums)-1
    for left <= right {
        mid := left + (right-left)/2
        if nums[mid] == target {
            return mid
        }
        if nums[mid] < target {
            left = mid + 1
        } else {
            right = mid - 1
        }
    }
    return -1
}

复杂度是 O(log n)。

常见错误:

  • 数据没有排序就二分。
  • 边界条件写错。
  • mid := (left + right) / 2 在某些语言里可能溢出。
  • 不清楚要找等于、大于等于,还是最后一个小于等于。

Top K

Top K 问题是找出最大或最小的 K 个元素。

常见解法:

  1. 全量排序:O(n log n),简单但可能浪费。
  2. 小顶堆维护 K 个最大元素:O(n log k)。
  3. 快速选择:平均 O(n),实现更复杂。

如果只是找前 10 个最大值,对 100 万条数据全量排序通常不是最优。

Go 后端实际应用例子

例子一:按创建时间排序返回接口列表

type Article struct {
    ID        int64
    Title     string
    CreatedAt time.Time
}

func SortArticlesByCreatedAtDesc(articles []Article) {
    sort.Slice(articles, func(i, j int) bool {
        return articles[i].CreatedAt.After(articles[j].CreatedAt)
    })
}

如果数据来自数据库,优先考虑让数据库通过索引排序和分页,而不是把大量数据拉到应用内排序。

例子二:在有序配置中查找命中的灰度阈值

假设灰度规则按阈值升序排列,可以用二分查找找到第一个大于等于用户 hash 的规则:

type Rule struct {
    UpperBound int
    Name       string
}

func MatchRule(rules []Rule, value int) (Rule, bool) {
    idx := sort.Search(len(rules), func(i int) bool {
        return rules[i].UpperBound >= value
    })
    if idx == len(rules) {
        return Rule{}, false
    }
    return rules[idx], true
}

二分查找在配置匹配、时间区间定位、版本区间查找里很常见。

排序与数据库

后端开发中,排序经常交给数据库:

SELECT * FROM users ORDER BY created_at DESC LIMIT 20;

但要注意:

  • 排序字段是否有索引。
  • 排序是否结合分页。
  • 大 offset 分页是否会变慢。
  • 是否需要稳定排序。
  • 是否应该用游标分页。

算法意识会帮助你理解为什么某些 SQL 排序很慢。

常见误区

  • 误区一:需要 Top K 就先全量排序。

如果只要 K 个元素,堆或快速选择可能更合适。

  • 误区二:二分查找只要会模板就行。

工程中更常见的是边界二分,例如找第一个大于等于某值的位置。

  • 误区三:排序只是内存里的事情。

数据库排序、搜索引擎排序、排行榜排序都和后端系统性能直接相关。

实战任务

给定一组用户访问量,找出访问量最高的前 10 个用户。

要求:

  1. 先用全量排序实现。
  2. 再用小顶堆思路描述优化方案。
  3. 对比两种方案复杂度。
  4. 思考如果数据来自数据库,是否应该在应用内排序。
参考答案

全量排序实现:

type UserVisit struct {
    UserID int64
    Count  int
}

func TopUsersBySort(users []UserVisit, k int) []UserVisit {
    if k > len(users) {
        k = len(users)
    }
    cp := append([]UserVisit(nil), users...)
    sort.Slice(cp, func(i, j int) bool {
        return cp[i].Count > cp[j].Count
    })
    return cp[:k]
}

复杂度是 O(n log n),实现简单。

小顶堆思路:维护一个大小为 k 的小顶堆。遍历所有用户访问量,如果堆未满就加入;如果当前访问量大于堆顶,就替换堆顶。最终堆中就是 Top K。复杂度是 O(n log k),适合 k 远小于 n 的情况。

如果数据已经在数据库里,优先考虑:

SELECT user_id, count
FROM user_visits
ORDER BY count DESC
LIMIT 10;

但前提是要评估索引、数据量和查询成本。不要默认把全量数据拉到应用内排序。

面试题

1. 快速排序和归并排序有什么区别?

参考答案

快速排序平均时间复杂度 O(n log n),通常原地排序,平均性能好,但最坏情况可能退化到 O(n²),且不稳定。归并排序时间复杂度稳定为 O(n log n),是稳定排序,但通常需要 O(n) 额外空间。

工程中不一定手写,但要理解它们的复杂度、稳定性和空间成本。

2. 二分查找的前提是什么?

参考答案

二分查找的前提是数据有序,并且可以高效访问中间位置。数组或 slice 适合二分,链表不适合,因为访问中间节点不是 O(1)。

另外要明确查找目标:是找等于目标的元素,还是找第一个大于等于、最后一个小于等于的位置。不同目标边界写法不同。

3. Top K 有哪些常见解法?

参考答案

常见解法有全量排序、小顶堆和快速选择。全量排序简单,复杂度 O(n log n);小顶堆维护 K 个元素,复杂度 O(n log k),适合 K 远小于 n;快速选择平均 O(n),但实现和边界更复杂。

工程中如果数据在数据库里,还要考虑能否用索引和 SQL 排序直接得到结果,避免把大量数据拉到应用内。

4. 为什么大 offset 分页可能很慢?

参考答案

大 offset 分页通常需要数据库先扫描或排序大量记录,然后丢弃前面的 offset 条,只返回后面的 limit 条。offset 越大,浪费越多。

常见优化是游标分页,例如基于 created_at 或自增 ID 记录上一次位置,用 WHERE id < last_id ORDER BY id DESC LIMIT 20 这类方式减少扫描。

5. Go 的 sort.Slice 使用时要注意什么?

参考答案

sort.Slice 会原地修改 slice 顺序。如果调用方还需要原顺序,应先复制一份。比较函数必须满足严格弱序,否则排序结果可能不稳定或不符合预期。

如果需要稳定排序,可以使用 sort.SliceStable,但稳定排序可能有额外成本。