1. 题面还原:堆内存申请到底在做什么
我在刷华为OD机试真题的时候遇到这道“堆内存申请”,题目名称里虽然带了“堆”,但实际不是让你写二叉堆,而是考察操作系统里的堆内存分配逻辑。它还有一个更直白的叫法:堆内存最佳分配,要求你按照最佳适配算法,从当前可用的连续空闲内存里,挑一块最合适的区域来满足一次内存申请。
先说清楚我备考时刷到的这版题面,避免后面对不上:
一个连续堆内存的总大小为 capacity,地址从 0 开始编号。现在已经有若干段内存被占用,每段用“起始地址 + 占用长度”表示。剩余没有被占用的区域都是空闲的。现在需要申请 need 字节的连续内存,要求按如下规则分配:
- 在所有长度不小于 need 的空闲区域中,选一个长度最小的。
- 如果长度相同,选起始地址最小的。
- 找到之后,返回该空闲区域的起始地址。
- 如果没有任何空闲区域能放下 need 字节,返回 -1。
这是内存分配算法里的 classic 问题:最佳适配(Best Fit)。在操作系统课程里,它和首次适配、最坏适配并称三种经典分区分配算法。OD机试把这个问题搬到考题里,其实就是在考你能不能把一个简单的内存分配模型用代码实现出来。
用一个具体的例子来理解最合适。假设内存总大小 capacity = 20,已经被占用的区间如下:
text复制20
2
2 3
8 5
4
其中:
20表示总内存 20 字节;2表示有 2 个已占用区域;2 3表示从地址 2 开始连续占用 3 个字节;8 5表示从地址 8 开始连续占用 5 个字节;4表示本次申请 4 字节。
当然,华为OD真题里很多版本会把总大小固定写为 100,地址范围就是 0 到 99,这并不影响算法思路。
将占用地块转成区间表示,更方便处理边界。我建议全程序统一使用左闭右开区间 [start, end)。这样一段长度为 len 的占用内存,如果起始地址是 start,对应的占用区间就是 [start, start + len)。区间的长度永远是 end - start,不用再反复“+1”“-1”,能省掉大量边界 bug。
上述案例中的占用区间就是:
[2, 5),也就是地址 2、3、4 被占用;[8, 13),也就是地址 8、9、10、11、12 被占用。
那么剩余空闲区域包括:
[0, 2),长度 2;[5, 8),长度 3;[13, 20),长度 7。
现在申请 4 字节。长度不小于 4 的空闲区域只有 [13, 20),所以最终输出地址是 13。
这道题的核心考点有三块:
第一,能不能理解连续内存和空闲区域的关系。内存不是一个一个独立小格子,而是一段连续地址。只有未被占用的连续区域才能分配给申请者。
第二,能不能把“最佳适配”四个字准确翻译成判断条件。很多人写成“找到第一个长度够大的空闲区”,那就变成了首次适配,题目必挂。这里选择的优先级是:先比长度,长度更接近申请的优先;长度一样,再比地址,地址更小的优先。
第三,能不能把 Java 或 Go 的列表操作做对。题目本身不复杂,但代码考到了区间扫描、排序、列表元素更新或删除,任何一个边界考虑不周到都会出错。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思路:先把“可分配的空闲块”算出来
这个题如果一上来就盯住“已占用区间”去思考,很容易绕晕。更直观的做法是:先把已占用区间转换成空闲区间列表,然后只对空闲区间做筛选。
要生成空闲区间,最实用的方法是“从低地址往高地址扫描”。内存地址从 0 开始,假设当前已经扫描到的位置是 cur。每看到一个已占用区间 [s, e),如果 cur < s,说明从 cur 到 s 之间有一段没有被占用,把 [cur, s) 加入空闲列表。然后让 cur 等于 max(cur, e),因为这段占用区间结束后,下一次扫描的位置至少是 e。这就像用一把尺子从 0 开始往右推进,遇到障碍物就把障碍物前面那段空隙记录下来,然后跳过障碍物。
这个逻辑写成伪代码是:
text复制occupied 按 start 排序
cur = 0
for [s, e) in occupied:
if cur < s:
free.add([cur, s))
cur = max(cur, e)
if cur < capacity:
free.add([cur, capacity))
为什么要先排序?因为输入给出的已占用区间很可能不是按地址顺序排列的。如果不排序,直接扫描会出现先遇到一个 start 很大的区间,再遇到一个 start 很小的区间,cur 的状态就乱了。所以做这类区间问题的第一步,永远是把区间整理成有序的。
为什么区间要用成左闭右开?我们对照一下边界情况:
假设已占用区间是 [2, 5),那空闲区间的右侧也应该是 5,而不是 4。如果写成闭区间 [2, 5],那么地址 5 到底能不能用就很模糊。把区间规范为 [start, end) 后,每个字节的归属只被检查一次:end 这个地址本身不属于当前占用段,它可能是下一段空闲区域的开始。这种约定能让代码在处理相邻区域时不重不漏。
在拿到空闲列表之后,就把问题简化成一个筛选问题。遍历所有空闲块 [s, e),计算块长 len = e - s。如果 len >= need,说明这块可以被选。然后在所有候选块里找出块长最小的,也就是 len 和 need 的差值最小。如果两个候选块的 len 一样,选择 s 更小的。
这里有个概念要特别强调:最佳适配选的是“能满足需求的最小空闲块”,不是“最靠近内存开头的那一块”。假设空闲块有三个,长度分别是 20、10、8,现在申请 6 字节。按最佳适配,应该选长度 8 的那块,因为它最接近 6,浪费最少。这个逻辑和操作系统里的 Best Fit 完全一致。
选完空闲块之后,如果只是单次申请,直接返回起始地址即可。但为了处理多申请场景,最好把分配动作也做完整:从这块空闲区域的 start 开始分配 need 字节,原空闲区 [start, end) 会变成剩余空闲区 [start + need, end);如果 need 正好等于块长,则这一整块被分配完,从空闲列表中移除。
这样保持空闲列表实时更新,后面再来申请时就无需重新读取原占用数据。
3. 华为OD堆内存申请 Java 版完整代码
我直接把可以跑通这版题意的 Java 代码贴出来。注释写得比较全,方便你对照上面的思路看。
java复制import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
public class Main {
static class Block {
int start;
int end;
Block(int start, int end) {
this.start = start;
this.end = end;
}
int length() {
return end - start;
}
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int capacity = sc.nextInt();
int usedNum = sc.nextInt();
List<Block> used = new ArrayList<>();
for (int i = 0; i < usedNum; i++) {
int start = sc.nextInt();
int len = sc.nextInt();
used.add(new Block(start, start + len));
}
// 已占用区间按起始地址排序
used.sort((a, b) -> a.start - b.start);
// 扫描生成空闲区间 [start, end)
List<Block> free = new ArrayList<>();
int cur = 0;
for (Block block : used) {
if (cur < block.start) {
free.add(new Block(cur, block.start));
}
cur = Math.max(cur, block.end);
}
if (cur < capacity) {
free.add(new Block(cur, capacity));
}
// 支持一次申请,也支持多行申请
while (sc.hasNextInt()) {
int need = sc.nextInt();
System.out.println(allocate(free, need));
}
}
private static int allocate(List<Block> free, int need) {
Block best = null;
for (Block b : free) {
if (b.length() < need) {
continue;
}
if (best == null
|| b.length() < best.length()
|| (b.length() == best.length() && b.start < best.start)) {
best = b;
}
}
if (best == null) {
return -1;
}
int ans = best.start;
if (best.length() == need) {
free.remove(best);
} else {
// 只占用前 need 字节,剩下的继续保留为空闲区
best.start += need;
}
return ans;
}
}
这段代码的主流程分成了三个非常清晰的部分:
读取输入时,先把所有已占用区间转换为 [start, start + len),存进 used 列表。长度换算成“结束地址”而不是直接保存长度,是为了后面统一用减法算块长。
排序时,用 used.sort((a, b) -> a.start - b.start),确保占用区间从低地址到高地址排列。如果你的 JDK 版本比较老,也可以用 Collections.sort 加 Comparator,效果一样。
扫描生成空闲区间的代码是整个题的核心。cur 表示当前扫描的起始位置,初始为 0。遇到一个占用区间 [s, e),如果 cur < s,说明前面有一段可用的空闲区。这里用 cur = Math.max(cur, block.end) 而不是直接 cur = block.end,是为了兼容输入区间之间可能出现重叠的脏数据。虽然题目通常保证区间不重叠,但这样写更稳。
allocate 函数里做两件事。第一,找出最佳候选块。第二,更新空闲列表。
找最佳候选块时,条件判断顺序是“先长度,后地址”。我用了一条比较长的 if 条件:候选块长度更小,或者长度相等但起始地址更小,才替换 best。这个条件的顺序不能写反,否则会变成先按地址挑,最后选出的块不是最佳适配。
更新空闲列表时,如果 best.length() == need,说明这块空闲区刚好被用光,应该从 free 列表中删除。Java 的 remove(best) 会根据对象引用删除,因为 best 一定来自 free 列表里的某个对象,所以能正确删除。
如果空闲块比 need 大,则直接让 best.start 往前移动 need。比如空闲区是 [13, 20),need 是 4,那么占用的就是 [13, 17),剩下的 [17, 20) 仍然是空闲区。这个更新方式非常简洁,不需要重新排序,因为块内起点右移之后,它和其他空闲块的前后顺序不会乱。
在使用 Java 写这段代码时,我遇到最多的问题是输入格式。题目有时候会把多个数字放在同一行,有时候会换行。用 Scanner 的 nextInt() 可以无视换行,只按空白切分,所以兼容性最好。不要在读取数字时混用 nextLine(),否则很容易读到空字符串导致类型转换失败。
另外,如果题目只做一次申请,不需要 while 循环;但用 while 循环也不会出错。多申请场景下,每分配完一块内存,free 列表会同步更新,下一次申请就在更新后的空闲区里重新找最佳块。这样就做到了“一次构建空闲区,多次动态分配”。
4. 堆内存最佳分配 Go 语言版实现
我平时用 Go 更多一些,所以再给一版 Go 的完整实现。Go 切片的特性和 Java 的 List 不太一样,删除元素时要特别注意索引。
go复制package main
import "fmt"
type Block struct {
start int
end int
}
func main() {
var capacity, usedNum int
fmt.Scan(&capacity, &usedNum)
used := make([]Block, 0, usedNum)
for i := 0; i < usedNum; i++ {
var start, length int
fmt.Scan(&start, &length)
used = append(used, Block{start: start, end: start + length})
