华为OD机试里这道“堆内存申请 / 堆内存最佳分配”,光看名字很容易让人误以为是考 JVM 堆、Go runtime 内存池,结果真正上手才发现,它的内核其实是操作系统里最常见的内存分配策略——最佳适配(Best Fit)。我第一次刷到的时候也愣了一下,直到把输入输出跑通才意识到,这道题考的是最基础的“空闲块管理 + 按规则查找 + 原地切分”,只是套了个“堆内存”的壳。
下面我用 Java 和 Go 两种语言,把这道题从题面到代码一层层剥开。不管是准备 OD 机试的,还是想补一补内存分配算法基础的,都能直接照着落地方案,愿意深挖的话还能顺带把最佳适配、首次适配、最差适配的差异都搞明白。
1. 题目理解与核心考点拆解
1.1 先还原一个接近真题的题面
不同题库里这题细节可能略有出入,但我综合考友反馈,梳理出一个非常接近原题的形态,题面大概是这样的:
系统中有若干块连续的空闲内存,每块内存用起始地址 start 和长度 size 描述。现在按顺序到达 K 个内存申请,每个申请需要一块长度不小于 applySize 的连续内存块。
分配规则采用“最佳适配”:
- 在所有长度不小于 applySize 的空闲块中,优先选择长度最小的那块。
- 如果存在多个长度相同且都是最小的空闲块,则选择起始地址最小的那块。
- 分配时从选中块的前端划出 applySize 个字节,返回起始地址;如果该块还有剩余,则剩余部分继续作为空闲块留在空闲列表中。
- 如果没有长度不小于 applySize 的空闲块,本次申请失败,输出 fail。
输入格式:
code复制N
start1 size1
start2 size2
...
K
applySize1
applySize2
...
输出格式:按申请顺序,每行输出本次申请分配到的起始地址,分配失败就输出 fail。
举一个能跑出来的完整例子:
输入:
code复制2
1 10
50 20
4
5
10
15
10
输出:
code复制1
50
fail
60
这个过程手工验证一遍:初始有两块空闲内存,地址 1 到 10 一块,地址 50 到 69 一块。
- 申请 5 字节时,两块都够,但最佳适配选的是“最小的足够块”,也就是 size=10 的那块,所以返回起始地址 1。这块剩余 5 字节,起始地址变为 6。
- 申请 10 字节时,第一块只剩 5 字节不够,第二块 size=20 可以,返回 50,剩余 10 字节,起始地址变 60。
- 申请 15 字节时,两块都不够,输出 fail。
- 申请 10 字节时,第二块剩余部分正好 10 字节,返回 60,整块耗尽。
这个样例非常典型,它同时覆盖了“能分配”“剩余继续用”“完全耗尽”“分配失败”四种情况。
1.2 什么叫“最佳分配”,为什么这样设计
操作系统里常见的空闲内存分配策略有几种:首次适配(First Fit)是从低地址开始找第一块满足大小的空闲块;最差适配(Worst Fit)是找满足条件下最大的空闲块;最佳适配(Best Fit)是找满足条件下最小的空闲块。
“最佳”体现在哪里?它希望尽可能保留大块内存,避免下一次的大内存申请被小碎片卡死。比如现在有一块 5KB 和一块 100KB 的空闲空间,来了一个 4KB 的申请,最佳适配会选择 5KB 那块,把 100KB 的大块留着应对后面的“大家伙”。
但最佳适配也并非没有缺点,它非常容易产生大量小而难用的外部碎片。实际工程里的分配器通常不会只用一种策略,而是组合拳。不过机试不就讲这个,我们只需要严格按题面规则实现。
这道题里“若多个空闲块大小相同,选择起始地址较小的”这个条件很关键,它决定了查找时不能只按 size 取最小,必须同时关心 start 字段。
1.3 考官到底想考什么
这道题表面是内存分配,实际考察了三个基本功:
- 自定义数据结构:Java 里是内部类,Go 里是 struct,用来封装“起始地址 + 长度”这个二元组。
- 线性容器上的遍历和比较:在一个列表里反复寻找符合条件的元素,本质上是一个“带条件的最优值查找”。
- 更新逻辑的严谨性:分配成功后,原空闲块的长度、起始地址怎么变化,完全耗尽时如何标记无效,这些细节一处不留神就会出错。
理解了这三点,后面写代码就不会跑偏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心数据结构与分配策略设计
2.1 空闲块怎么存,为什么不用数组
一个空闲块的基本属性是 start 和 size,用单独的两个 int 数组也能存,但可读性太差。更推荐的做法是定义成 Block 对象或结构体,把两个字段绑在一起。
存储容器方面,直接用 ArrayList(Java)或 slice(Go)就够了。有一种常见做法是用 LinkedList,配合频繁删除节点。我不推荐在机试里这样做,因为 Java 的 LinkedList 在索引遍历上反而不如 ArrayList 直接,而且无脑删除节点会引入不少边界处理。
还有一个隐藏问题:这个题目中如果只做“申请”,空闲块总数只会减少或不变,不会增加。因为每次分配是从已有块中切出一段,而不是新增一块。这就意味着我可以不用物理删除,直接把耗尽块的 size 标记成 0,后续遍历时跳过即可。这样可以省去“删除元素导致数组搬移”的开销,也不容易出现迭代过程中删元素越界的问题。
2.2 每次申请如何选出“最佳”空闲块
先明确比较规则:假设有两个空闲块 A 和 B,并且两块都满足 size >= applySize,此时 A 更优的条件是:
- A.size < B.size
- 或者 A.size == B.size 且 A.start < B.start
翻译成代码就是“更小的 size 优先,同 size 下更小的 start 优先”。这里要注意不能把事情搞反,有些人先按 start 排序,再在候选里找 size 最小的,这样能得出正确结果,但需要多一次遍历或肉眼判断,不够直接。
最省事的做法是维护一个 bestIdx = -1,从头到尾扫一遍空闲列表,遇到 size 足够且比当前 best 更优的块就更新索引。一次遍历结束,如果 bestIdx 还是 -1,说明所有空闲块都太小,申请失败;否则 bestIdx 指向的就是本申请的最佳空闲块。
这个“打擂台”的思路,本质上和在一堆数里找最小值是一样的,只是比较函数自定义为了“先比 size 再比 start”。
2.3 分配成功后怎么更新空闲块
假设选中的空闲块是 picked,分配 applySize 字节,更新逻辑分情况讨论:
- 如果
picked.size == applySize:整块正好用完,直接从空闲列表中移除或标记为无效。 - 如果
picked.size > applySize:新空闲块的起始地址变为oldStart + applySize,长度变为oldSize - applySize。
这里有个容易踩的坑:分配成功后不要立刻把原对象删掉再 new 一个新的剩余块。更稳妥的做法是直接修改原对象的 start 和 size,既不破坏切片索引,逻辑也最清晰。因为我们是“从块头切走一部分”,原空闲块的 start 移动到 start+applySize,size 减少即可。
由于空闲块之间本身不重叠,切完之后的剩余块起始地址变大,但不会越过列表里后一个空闲块的起点,这保证了剩余块依然能和整个空闲列表保持“按起始地址升序”的次序。
3. Java 实现全程拆解
3.1 先写一个 Block 对象
Java 版本我选择用 ArrayList 存 Block 对象,并且给空闲列表按 start 做一次排序。虽然本题的查找结果不受初始顺序影响,但按 start 排序后,后续如果想要打印内存布局或改成按低地址优先分配,逻辑会顺手很多。
java复制static class Block {
int start;
int size;
Block(int start, int size) {
this.start = start;
this.size = size;
}
}
读取时用一个 List 收集所有初始空闲块,然后排序:
java复制List<Block> freeList = new ArrayList<>();
for (int i = 0; i < N; i++) {
int start = sc.nextInt();
int size = sc.nextInt();
freeList.add(new Block(start, size));
}
freeList.sort(Comparator.comparingInt(b -> b.start));
这里输入规模一般不会太大,用 ArrayList 完全没有问题。值得注意的是,Java 的 Comparator.comparingInt 配合 lambda 在刷题环境下没有问题;如果考场机器 JDK 版本太老,可以写成匿名内部类,逻辑一样。
3.2 核心查找逻辑:一次遍历打擂台
定义一个比较函数,专门判断“a 是否比 b 更适合本次分配”。
java复制private static boolean better(Block a, Block b) {
if (a.size != b.size) {
return a.size < b.size;
}
return a.start < b.start;
}
然后在每次申请时遍历空闲列表:
java复制int applySize = sc.nextInt();
int bestIdx = -1;
for (int i = 0; i < freeList.size(); i++) {
Block cur = freeList.get(i);
if (cur.size == 0) {
continue;
}
if (cur.size >= applySize) {
if (bestIdx == -1 || better(cur, freeList.get(bestIdx))) {
bestIdx = i;
}
}
}
有两点值得解释:
cur.size == 0的块是之前某次申请中正好耗尽但没物理删除的残留占位,直接跳过。- 比较前必须确保 cur 满足
cur.size >= applySize,所以 better 函数不需要再重复判断大小关系。
如果 bestIdx 仍为 -1,分配失败:
java复制if (bestIdx == -1) {
sb.append("fail\n");
continue;
}
否则开始分配,并更新被选中块:
java复制Block picked = freeList.get(bestIdx);
sb.append(picked.start).append("\n");
picked.start += applySize;
picked.size -= applySize;
如果 size 减到 0,就让它继续留在列表里占位,通过遍历前的 continue 跳过它。
3.3 完整 Java 可运行代码
我习惯把结果先拼到 StringBuilder,最后一口气输出,这样比逐行 println 更少容器切换开销,也方便 DEBUG。
java复制import java.util.*;
public class Main {
static class Block {
int start;
int size;
Block(int start, int size) {
this.start = start;
this.size = size;
}
}
private static boolean better(Block a, Block b) {
if (a.size != b.size) {
return a.size < b.size;
}
return a.start < b.start;
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int N = sc.nextInt();
List<Block> freeList = new ArrayList<>();
for (int i = 0; i < N; i++) {
int start = sc.nextInt();
int size = sc.nextInt();
freeList.add(new Block(start, size));
}
freeList.sort(Comparator.comparingInt(b -> b.start));
int K = sc.nextInt();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < K; i++) {
int applySize = sc.nextInt();
int bestIdx = -1;
for (int j = 0; j < freeList.size(); j++) {
Block cur = freeList.get(j);
if (cur.size == 0) {
continue;
}
if (cur.size >= applySize) {
if (bestIdx == -1 || better(cur, freeList.get(bestIdx))) {
bestIdx = j;
}
}
}
if (bestIdx == -1) {
sb.append("fail\n");
} else {
Block picked = freeList.get(bestIdx);
sb.append(picked.start).append("\n");
picked.start += applySize;
picked.size -= applySize;
}
}
System.out.print(sb);
}
}
用前面的样例跑一遍:
输入:
code复制2
1 10
50 20
4
5
10
15
10
输出:
code复制1
50
fail
60
和手工推导完全一致。
3.4 Java 实现的两个易错细节
第一,Scanner 使用 nextInt() 时就别再用 nextLine() 混读,否则很容易被换行符残留坑到。推荐做法是全程 nextInt(),它天然忽略空白字符。
第二,我故意没有在 size 减到 0 时执行 freeList.remove(bestIdx)。这是因为如果每申请一次就物理删除,ArrayList 后续元素都会前移,最坏情况下单次删除代价是 O(N)。如果 K 和 N 都上千,频繁移动累计起来不划算;而惰性删除可以使单轮处理稳定在 O(N) 遍历代价,整体复杂度是 O(K*N),对机试数据完全够用。
如果真想追求更高性能,Java 里还有一个非常优雅的优化思路。让 Block 实现 Comparable 接口,先按 size 排序、再按 start 排序,然后用 TreeSet<Block> 维护所有空闲块。每次申请时,通过 ceiling(new Block(applySize, -1)) 直接找到满足条件的“最小块”。
java复制TreeSet<Block> freeSet = new TreeSet<>();
// 初始所有空闲块加入 freeSet
Block target = freeSet.ceiling(new Block(applySize, -1));
if (target == null) {
// fail
} else {
freeSet.remove(target);
if (target.size > applySize) {
freeSet.add(new Block(target.start + applySize, target.size - applySize));
}
}
这个写法的巧妙之处在于,ceiling 返回的是 TreeSet 中“按比较规则”大于等于查询 key 的最小元素。new Block(applySize, -1) 作为哨兵,能保证在 size=applySize 的所有块中,start 最小的一定排在它后面,因此查出来的块天然就是“size 最小,同 size 时 start 最小”的最佳块。单次查找复杂度是 O(log N)。
4. Go 实现全程拆解
4.1 Go 结构体与切片读取
Go 版本的整体思路和 Java 一模一样,只是换成了 struct 和 slice。
go复制type Block struct {
start int
size int
}
读取初始数据时,直接把结构体挂到切片上:
go复制freeList := make([]Block, 0, N)
for i := 0; i < N; i++ {
var start, size int
fmt.Scan(&start, &size)
freeList = append(freeList, Block{start: start, size: size})
}
sort.Slice(freeList, func(i, j int) bool {
return freeList[i].start < freeList[j].start
})
Go 的 sort.Slice 在 1.8 版本之后都可以用,OD 机试环境一般不会太老,可以放心写。如果担心版本太旧,也可以手写 sort.Swap 接口,不过一般没必要。
4.2 核心查找与更新逻辑
定义一个 less 函数,表达“a 是否比 b 更适合本次分配”。
go复制func less(a, b Block) bool {
if a.size != b.size {
return a.size < b.size
}
return a.start < b.start
}
主逻辑的伪代码与 Java 版本几乎一一对应。我用索引遍历,不要用 range 值拷贝,因为在 Go 里 for _, block := range freeList 拿到的 block 是拷贝,修改它不会影响原切片元素。所以我选择索引方式:
go复制for i := 0; i < K; i++ {
var applySize int
fmt.Scan(&applySize)
bestIdx := -1
for j := 0; j < len(freeList); j++ {
if freeList[j].size == 0 {
continue
}
if freeList[j].size >= applySize {
if bestIdx == -1 || less(freeList[j], freeList[bestIdx]) {
bestIdx = j
}
}
}
if bestIdx == -1 {
fmt.Println("fail")
} else {
fmt.Println(freeList[bestIdx].start)
freeList[bestIdx].start += applySize
freeList[bestIdx].size -= applySize
}
}
这里最舒服的一点是,Go 可以直接对切片元素 freeList[bestIdx] 做字段修改,不需要像 Java 那样担心对象引用和 remove 问题。一个块如果切到 size 为 0,后续通过 freeList[j].size == 0 跳过即可。
4.3 完整 Go 可运行代码
go复制package main
import (
"fmt"
"sort"
)
type Block struct {
start int
size int
}
func less(a, b Block) bool {
if a.size != b.size {
return a.size < b.size
}
return a.start < b.start
}
func main() {
var N int
fmt.Scan(&N)
freeList := make([]Block, 0, N)
for i := 0; i < N; i++ {
var start, size int
fmt.Scan(&start, &size)
freeList = append(freeList, Block{start: start, size: size})
}
sort.Slice(freeList, func(i, j int) bool {
return freeList[i].start < freeList[j].start
})
var K int
fmt.Scan(&K)
for i := 0; i < K; i++ {
var applySize int
fmt.Scan(&applySize)
bestIdx := -1
for j := 0; j < len(freeList); j++ {
if freeList[j].size == 0 {
continue
}
if freeList[j].size >= applySize {
if bestIdx == -1 || less(freeList[j], freeList[bestIdx]) {
bestIdx = j
}
}
}
if bestIdx == -1 {
fmt.Println("fail")
} else {
fmt.Println(freeList[bestIdx].start)
freeList[bestIdx].start += applySize
freeList[bestIdx].size -= applySize
}
}
}
运行同一个样例,输出与 Java 版完全一致:
code复制1
50
fail
60
4.4 Go 实现与 Java 实现的差异对照
很多从 Java 转 Go 的人,第一次写这种题会踩 range 值拷贝的坑。在 Go 里,使用 for _, item := range list 时,item 是列表元素的副本,你修改 item.size 不会影响列表里的真实数据。因此凡是需要原地修改元素的地方,要么用索引 list[i],要么循环中用指针切片。我在 4.2 和 4.3 中全部采用索引方式,编码时建议统一这个习惯。
两种语言的实现差异用一张表简单总结:
| 对比项 | Java 版本 | Go 版本 |
|---|---|---|
| 空闲块对象 | 内部静态类 Block | struct Block |
| 容器 | ArrayList |
[]Block |
| 排序 | freeList.sort(Comparator.comparingInt(b -> b.start)) | sort.Slice(freeList, ...) |
| 修改元素 | picked.start += applySize | freeList[bestIdx].start += applySize |
| 输出 | StringBuilder 统一输出 | fmt.Println 逐行输出 |
| 无效占位 | cur.size == 0 跳过 | freeList[j].size == 0 跳过 |
这两种语言的暴力遍历版本,本质复杂度一致,都是 O(K*N)。区别更多体现在语法层面:Java 需要小心对象引用和 List 容量,Go 需要小心 range 拷贝问题。把这两种语言的代码放到同一题里对比一遍,能明显体会到 Go 在数据建模上更简单直接,Java 在容器工具链上更丰富。
5. 边界用例、题目变体与实战建议
5.1 优先级最容易出错的“同 size 选更小 start”
光看题面,很多人会忽略“若存在多个相同大小的空闲块,选择起始地址较小者”这个二级排序。我设计了一个针对此点的用例:
输入:
code复制3
