目标:数据规模 100 亿(10^10),寻找最小 100 个数(Top-100 Min),在单机/分布式场景下的可行方案与 Go 代码参考。
用 Go 从 100 亿个数中找到最小的 100 个数 ------ 实战与原理
- [1 思路总览(按优先级)](#1 思路总览(按优先级))
- [2 为什么用"最大堆"找最小 Top-K?](#2 为什么用“最大堆”找最小 Top-K?)
- [3 Go 代码(最大堆实现 Top-100 Min)](#3 Go 代码(最大堆实现 Top-100 Min))
- [4 大数据落地要点(100 亿规模)](#4 大数据落地要点(100 亿规模))
- [5 其他方案简述](#5 其他方案简述)
- [6 常见坑位](#6 常见坑位)
- [7 小结](#7 小结)
1 思路总览(按优先级)
| 场景 | 推荐方案 | 复杂度 | 适用条件 |
|---|---|---|---|
| 单机,内存够放 100 个元素,流式读取 | 大小为 100 的最大堆(推荐) | 时间 O(N log K),空间 O(K) | 文件/流输入,K≪N |
| 单机,数据已在内存且可修改 | 快速选择(nth_element 思想) |
平均 O(N),空间 O(1) | 全量在内存,允许原地分区 |
| 数据大于内存 | 外排序 + 多路归并 | O(N log N) + O(N) 归并 | 可用磁盘临时文件 |
| 分布式(HDFS/S3) | MapReduce/Flink/Spark:分片局部 Top-K + 全局归并 | O(N/p log K) | 集群可用 |
| 近似需求 | Count-Min Sketch + 小顶堆 | 近似 | 可接受误差,仅对频率 Top-K 有用 |
本文聚焦 单机流式最大堆方案,因为:实现最简单、内存消耗常数级、对 100 亿数据也可行(I/O 成本远大于计算)。
2 为什么用"最大堆"找最小 Top-K?
- 我们只需保留当前见过的"最小 100 个数"。
- 维护一个 容量 100 的"最大堆":堆顶是当前最大值(即"候选最小集合"中最大的那个)。
- 遍历新数
x时:- 若堆未满:直接入堆。
- 若堆已满且
x < 堆顶:弹出堆顶,再插入x(把更大的候选淘汰)。 - 若
x >= 堆顶:忽略。
- 结束后,堆中 100 个元素就是全局最小的 100 个数,最终按升序输出。
复杂度:
- 时间 O(N log K) ≈ 1e10 × log₂100 ≈ 1e10 × 7(CPU 仍可接受,瓶颈在 I/O)。
- 空间 O(K) = 100 个
int64,忽略不计。
3 Go 代码(最大堆实现 Top-100 Min)
go
package main
import (
"bufio"
"container/heap"
"fmt"
"io"
"os"
)
// -------- 最大堆定义(用于找最小 Top-K) --------
type MaxHeap []int64
func (h MaxHeap) Len() int { return len(h) }
func (h MaxHeap) Less(i, j int) bool { return h[i] > h[j] } // 反转为最大堆
func (h MaxHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *MaxHeap) Push(x interface{}) { *h = append(*h, x.(int64)) }
func (h *MaxHeap) Pop() interface{} {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
// -------- 核心逻辑:从流中提取最小 Top-K --------
func TopKMin(r io.Reader, k int) []int64 {
h := &MaxHeap{}
heap.Init(h)
in := bufio.NewReader(r)
for {
var v int64
_, err := fmt.Fscan(in, &v)
if err == io.EOF {
break
}
if err != nil {
panic(err)
}
if h.Len() < k {
heap.Push(h, v)
} else if v < (*h)[0] { // 只有更小的才替换堆顶
heap.Pop(h)
heap.Push(h, v)
}
}
// 升序输出
res := make([]int64, h.Len())
for i := len(res) - 1; i >= 0; i-- {
res[i] = heap.Pop(h).(int64)
}
return res
}
func main() {
// 示例:从 stdin 读取,实际可从文件:
// f, _ := os.Open("numbers.txt"); defer f.Close(); res := TopKMin(f, 100)
res := TopKMin(os.Stdin, 100)
for _, v := range res {
fmt.Println(v)
}
}
使用示例
bash
# numbers.txt 含有大量整数(空白分隔)
go run main.go < numbers.txt > top100min.txt
4 大数据落地要点(100 亿规模)
-
I/O 优化:
- 必须使用
bufio.Reader(上文已使用); - 文件存储建议使用 SSD;
- 多文件可并行分片读取(多进程/多线程各自做局部 Top-100,再归并)。
- 必须使用
-
多文件归并(单机并行):
- 线程/进程数 = CPU 核心数;
- 每个分片跑一次
TopKMin得到 100 个数; - 把所有分片结果(假设 M 份)合并再跑一次
TopKMin(输入量 M×100,很小)。
-
防越界/健壮性:
- 输入异常要捕获(上面代码用
panic,生产可改为日志并跳过); - 处理空文件、行尾空白的容错。
- 输入异常要捕获(上面代码用
-
内存占用:
- 100 个
int64+ heap 元数据 < 5 KB,可忽略; - 流式读取,文件再大也不会爆内存。
- 100 个
-
时间预估:
- 单核扫描 2--3 GB/s(纯读取);
- 100 亿
int64~ 80 GB(若文本更大),纯 IO 需数十秒到数分钟; - 堆操作是轻量级,瓶颈几乎全在 I/O。
5 其他方案简述
- 快速选择:需全量在内存,选出第 100 小元素,再筛;对 80 GB 文本不现实。
- 外排序:全排序后取前 100,成本高于堆法(O(N log N))。
- 分布式 MapReduce :
- Map:每分片做 Top-100(最大堆)。
- Reduce:合并所有分片 Top-100,输出全局 Top-100。
- 网络只传极小数据量(分片数 × 100 条),可扩展到 TB 级。
6 常见坑位
- 用小根堆找最小 Top-K :会导致堆内保留的是最小元素,但要 O(N log N)(因为会反向操作)。正确做法是 最大堆 限制容量。
- 未裁剪 :堆满后必须判断
v < 堆顶才替换,否则退化为存全部。 - 越界 :
Pop/Top前未判空。 - 未做二次合并:多线程分片后忘记把各分片 Top-100 再归并一次。
7 小结
- 100 亿数据找最小 100 个,工业默认方案:容量 100 的最大堆 + 流式读取。
- 复杂度 O(N log K),空间 O(K),I/O 为主导;分片并行 + 二次合并可线性扩展。
- Go 标准库
container/heap足够稳定可靠,按上文代码即可落地。
下面是一个DIY的大顶堆,手写这个的能力也是要有的,简单来说就是加入一个元素就放到数组末尾,然后把它往树根的方向移动;如果弹出一个元素,就把它跟最后一个元素交换,然后把这个元素再往下放
go
package main
import (
"bufio"
"fmt"
"os"
)
// Heap 大顶堆
type Heap struct {
data []int64
}
func NewHeap() *Heap {
return &Heap{data: make([]int64, 1)}
}
func (h *Heap) Push(x int64) {
h.data = append(h.data, x)
h.pushUp(len(h.data) - 1)
}
// 从下往上传递
func (h *Heap) pushUp(x int) {
if x == 1 {
return
}
fa := x >> 1
if h.data[x] < h.data[fa] {
h.data[x], h.data[fa] = h.data[fa], h.data[x]
h.pushUp(fa)
}
}
// 从上往下传递
func (h *Heap) pushDown(i int) {
l := i << 1
r := i<<1 | 1
n := len(h.data)
biggest := i
if l < n && h.data[l] > h.data[biggest] {
biggest = l
}
if r < n && h.data[r] > h.data[biggest] {
biggest = r
}
if biggest == i {
return
}
h.data[i], h.data[biggest] = h.data[biggest], h.data[i]
h.pushDown(biggest)
}
func (h *Heap) Pop() int64 {
top := h.data[1]
ln := len(h.data)
h.data[ln-1], h.data[1] = h.data[1], h.data[ln-1] // 把最后一个元素放到堆顶
h.data = h.data[:ln-1]
h.pushDown(1) // 向下放
return top
}
func (h *Heap) Top() int64 {
return h.data[1]
}
func (h *Heap) Len() int {
return len(h.data) - 1
}