☰
池化资源共享题解:用TreeSet和二分查找秒杀资源调度
2026/10/15 8:10:33 网站建设 项目流程

前几天一个准备机试的同学跟我聊,说他挂在一道叫“池化资源共享”的模拟题上:题面不长,逻辑也不算绕,但他怎么改都超时,最后才发现自己一直在用数组模拟整个资源池,每次分配都要线性扫一遍。这类问题我在辅导里见过太多次——不是不会算法,而是看到“池化”两个字,没反应过来背后其实是一棵平衡树。借这套OD机试真题,我把完整思路、数据结构选型和踩坑过程拆开讲一遍,希望能帮正在刷题的人少走弯路。

1. 题面还原与核心考点:这题到底让你干什么

1.1 一份接近原题的题面版本

不同批次的题库里,这类题的文字包装多少有点差异,有的叫“资源池管理”,有的叫“池化资源共享”,但核心模型几乎一模一样。我按最贴近原题的版本给你梳理一遍:

系统有一个资源池,池中初始包含若干整数资源ID,每个资源ID在当前池中最多出现一次(部分版本允许重复,后文我会专门说)。接下来会收到N个操作,每个操作分两种:

  • allocate x:从资源池中分配一个资源。分配规则是找到池中所有满足“资源ID不小于x”的资源里,ID最小的那个;把它从池中移除并输出它的ID。如果池中没有任何不小于x的资源,则输出-1。
  • release x:把一个ID为x的资源放回资源池中,如果资源池中本来没有这个ID,直接加入即可。

所有操作结束后,按升序输出资源池中剩余的ID。输入约束一般是资源和操作数量都在10^5量级,资源ID范围可以到10^9。就是这个范围,直接劝退了一大批用数组的人。

1.2 “池化”和“共享”在题目里的真实含义

很多人被“池化”这个词唬住了,觉得这题是不是要设计什么复杂的内存分配算法。其实机试阶段考到的池化概念,本质就是三件事:资源集中管理、按需分配、用后归还。

你不需要真的去写一个内存池,也不需要管底层页表映射,只需要模拟“一堆资源聚在一起,任务来了拿一个,任务结束还回来”这个过程。核心是“复用”:资源不是每次请求都新建的,而是从公共池子里取,用完再放回去。

这个抽象转换完成之后,题目就变成了一道妥妥的模拟题,只是模拟过程中需要用对数据结构。这道题真正的考点有三个:第一,能不能想到用有序集合来维护资源池;第二,会不会用二分查找完成“不小于x的最小ID”这个查询;第三,在数据量达到10^5时,能不能把单次操作复杂度压到O(log N)。

2. 数据结构选型复盘:为什么数组和哈希表在这里都不好使

2.1 数组计数、哈希表、列表各自死在哪一步

先说最直觉的做法:用数组记录每个资源ID是否存在,分配时从x开始往后扫到第一个存在的ID。这个做法在ID范围小的时候完全没问题,但ID上限是10^9,你不可能开一个十亿长度的布尔数组。就算用HashMap<ID, 数量>来存,分配时也得把所有key遍历一遍才能找到“不小于x的最小ID”,单次操作O(N),N次操作就是O(N^2),10^5的数据量下必死。

还有人会想,ID不重复,那我用列表存,排序后二分查找总行了吧?但是分配之后要删除元素,释放之后要插入元素,普通数组/列表的插入删除是O(N)的。最坏情况下每次都插入到中间位置,整体复杂度还是O(N^2)。

那优先队列呢?堆确实能高效拿到全局最小ID,但分配规则是“找不小于x的最小ID”,不是“找全局最小ID”。比如池子里有{3, 7, 12},你请求allocate 8,应该返回12,可堆顶是3,你把堆顶弹出来也没用。堆能高效处理“全局最值”,但不能按阈值查询最接近的资源,所以PriorityQueue也不适配这个场景。

2.2 平衡二叉搜索树才是标准答案

真正好用的数据结构是平衡二叉搜索树,在Java里就是TreeSet,在C++里就是std::set。它同时支持两个关键能力:

  • 有序存储,所有元素按升序维护;
  • 支持ceiling(Java)/lower_bound(C++),也就是在O(log N)时间内找到“大于等于某个值的最小元素”。

这个能力正好和题目里的分配规则一一对应:x就是需求大小,ceiling(x)就是满足要求的最小资源ID。找到之后删除是O(log N),释放时插入也是O(log N),整体复杂度稳在O(N log N),10^5数据量下毫无压力。

我经常用火车站候车来打比方:优先队列是一排只开放一个闸口的队伍,你只能从队首进人,根本没法从中间找人;而TreeSet相当于一个按身高排好序的队列,你能快速喊出“身高不低于180的里最矮的是谁”——这就是它最不可替代的地方。

2.3 Python选手的处境:这道题确实不友好

如果你是Python刷题,会发现标准库里没有现成的平衡树。bisect配合列表虽然能二分查找插入位置,但插入和删除本身是O(N)的,数据量一大还是会超时。常见的替代思路有三个:

  • 用ID压缩加线段树/树状数组维护存在性,能过但代码量明显变大;
  • 手写一棵Treap或红黑树,机考现场写这玩意通常是给自己挖坑;
  • 优先用Java或C++,把这个题当作“换语言的理由”。

说句大实话,OD机试对Python选手没有特别优待,遇到这类需要有序容器的题,语言差异会被放大。平时刷题可以Python为主,但上考场前至少要把Java的TreeSet和C++的std::set用熟,这是性价比很高的准备。下面两章我会分别给出两种主流语言的完整解法,再针对重复ID场景给出升级版。

3. 分配与释放的完整模拟链路:核心代码逐行拆解

3.1 Java版:TreeSet一把梭

Java里最直接的写法如下:

import java.util.*; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int k = sc.nextInt(); // 初始资源数量 int n = sc.nextInt(); // 操作数量 TreeSet<Integer> pool = new TreeSet<>(); for (int i = 0; i < k; i++) { pool.add(sc.nextInt()); } while (n-- > 0) { int op = sc.nextInt(); int x = sc.nextInt(); if (op == 1) { // allocate Integer id = pool.ceiling(x); if (id == null) { System.out.println(-1); } else { pool.remove(id); System.out.println(id); } } else { // release pool.add(x); } } // 输出剩余资源,TreeSet本身升序 for (Integer id : pool) { System.out.print(id + " "); } sc.close(); } }

逐行看几个关键点:

pool.ceiling(x)返回的是“大于等于x的最小ID”,类型是Integer。如果池中没有满足条件的资源,返回值是null,这时候输出-1。注意这里不要用first(),first()只能拿全局最小ID,不符合“按需求x找资源”的语义。

找到id之后要pool.remove(id),因为资源被分配给任务后就要从池中移除。释放操作就更简单了,直接pool.add(x),如果资源原本不存在,自然插入;如果原本存在,TreeSet不会存储重复元素,题目保证操作合法时就不用额外处理。

最后输出剩余资源时,直接遍历TreeSet即可,因为树的迭代顺序天然就是升序。这里不用再排序,也别手痒去转成List再排序,没必要。

3.2 C++版:set + lower_bound的好处

C++写法几乎和Java一一对应,只是命名不同:

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int k, n; cin >> k >> n; set<int> pool; for (int i = 0; i < k; i++) { int x; cin >> x; pool.insert(x); } while (n--) { int op, x; cin >> op >> x; if (op == 1) { auto it = pool.lower_bound(x); if (it == pool.end()) { cout << -1 << '\n'; } else { cout << *it << '\n'; pool.erase(it); } } else { pool.insert(x); } } for (int id : pool) { cout << id << ' '; } return 0; }

C++的lower_bound(x)返回第一个“不小于x”的迭代器,如果到end()说明找不到。这里有个小细节:iteraser(it)比pool.erase(*it)更高效,因为直接传迭代器删除是常数级别,传值删除会多一次查找。你平时写代码可能感觉不到差别,但机试里10^5次操作累积下来,这种微优化是能反映在用时上的。

另外C++题解里我加了ios::sync_with_stdio(false)和cin.tie(nullptr)这两行。很多人输入输出很慢然后怀疑是不是算法不对,其实是被cin/cout的同步开销拖慢了。这几行治标也治本,刷题时养成习惯。

3.3 如果资源ID允许重复:TreeMap计数版

有些变体题里,池中允许存在多个相同ID的资源,比如输入初始资源是[2, 2, 3, 3, 3],分配一个ID为2的资源后,池里还有另一个2。这种情况下TreeSet就不够用了,需要改用TreeMap<Integer, Integer>,键是资源ID,值是当前池中该ID的剩余数量。

分配逻辑变成:

TreeMap<Integer, Integer> pool = new TreeMap<>(); // 分配 Integer id = pool.ceilingKey(x); if (id == null) { System.out.println(-1); } else { System.out.println(id); int remain = pool.get(id) - 1; if (remain == 0) { pool.remove(id); } else { pool.put(id, remain); } } // 释放 pool.merge(x, 1, Integer::sum);

ceilingKey(x)直接返回满足条件的key,不会返回null之外的意外值。每次分配后要把对应key的计数减一,减到零才真正从树中删除。释放时用merge合并计数,这样同一个ID多次出现时也能正确管理。

这个升级版本同样保持O(log N)复杂度,而且更贴近真实系统中“资源池里有多个相同规格资源”的场景。如果原题允许重复ID,直接用这个版本写,稳很多。

4. 提交后才意识到的一系列边界条件:真实踩坑清单

4.1 资源池为空或没有足够大的资源时

最常见的错误是allocate x找不到资源时直接抛异常,或者打印了个空行。题目明确要求输出-1,所以首先要判断ceiling的结果是否为null/end()。

还有一类边界是:池子里有资源,但每个ID都小于x。比如池子是{1, 3, 5},请求allocate 6,同样返回null,输出-1。这两个情况在代码里是同一个判断,但很多人在自测时只测了“池子完全为空”的情况,漏了“池非空但不满足条件”的情况,结果一提交就挂。设计测试用例时,这两种场景必须都覆盖。

4.2 release操作要不要校验合法性

原题通常保证操作合法,也就是释放的ID要么已经在池中,要么是之前被分配出去的资源,不会出现往池子里塞一个从未存在过的资源的情况。但实际写代码时,我建议不要额外做合法性校验。

原因有两个:第一,多余校验会引入更多分支,代码可读性下降;第二,如果题目没保证合法,单纯pool.add(x)也不会崩,重复ID在TreeSet里会自动忽略,在TreeMap版本里只是计数加一,不会造成程序错误。你只需要按照题目给的规则做插入即可,不需要脑补“释放不存在资源”的提示逻辑。

4.3 最终输出的格式与顺序

操作结束后要输出池中剩余的资源ID,升序。因为TreeSet和std::set天然有序,这里直接遍历就好。

最容易踩的坑是行尾空格。有些在线评测系统会忽略行末空白,有些则不会。如果你实在不放心,可以在循环里控制分隔符:

boolean first = true; for (int id : pool) { if (!first) System.out.print(" "); System.out.print(id); first = false; }

虽然多数判题系统对尾部空格不敏感,但养成这个控制习惯,可以避免在那些对空格零容忍的平台上白白丢分。同理,如果题目要求输出逗号分隔,也建议用同样的方式处理,而不是在循环里无脑加逗号。

4.4 大范围数据下的输入输出性能陷阱

这道题最容易爆的点不是算法,而是IO。10^5级输入已经是高频操作了,如果在Java里用Scanner一个个读,虽然有缓冲,但遇到极端数据还是可能慢几个毫秒;在C++里不开sync_with_stdio(false),cin效率会明显下降。

更隐蔽的一个坑是输出。如果每次allocate都直接System.out.println(id),并且没有关闭自动刷新,通常还行,但大量输出时PrintWriter是更好的选择。建议我们写机试模板时,不管什么题,先把快读快写模板打底。输入输出稳定了,才能让算法复杂度真正成为决定用时的那块短板。

4.5 自测用例怎么构造才不容易漏

我给学生的建议是,每道模拟题至少准备四类用例:

  • 基础用例:少量资源,几个分配和释放,验证主流程。
  • 边界用例:池子空时分配、分配不满足条件、释放到原池子。
  • 重复操作用例:连续分配同一个资源,验证删除后不会二次拿到。
  • 最大规模用例:构造10^5个操作,验证是否超时。

这里单独说下“连续分配同一个资源”:池里有{3},第一次allocate 3能拿到3,第二次allocate 3就应该返回-1。很多人在测试时只测了“池中有多个资源”的场景,没有验证资源取出后是否真的从池中消失了,这个小点恰恰是全题最容易出逻辑漏洞的地方。

5. 从机试到工作:池化资源共享的底层思想

5.1 线程池、连接池、对象池为什么都叫“池”

机试考这道题,不代表以后做开发就要手写TreeSet管理资源,但它背后体现的资源管理思想是真实存在的。线程池里复用线程,连接池里复用网络连接,对象池里复用大对象,它们和题目里的资源池本质相同:资源创建或销毁的成本高,所以先集中囤一批,谁要用谁申请,用完释放回池子,避免频繁的创建和销毁。

以数据库连接池为例,每次新建数据库连接都需要网络握手、权限校验、分配内存,开销很大。连接池在启动时预先创建一批连接,业务请求来了就从池里取一个,用完归还。如果不归还,连接最终会耗尽;如果归还逻辑写错,资源泄漏就会像雪崩一样扩散。这和题目里allocate后必须remove、release后必须add是同一个逻辑闭环。

5.2 “最佳适配”与资源碎片化

题目里的分配策略是“找不小于需求x的最小ID”,这其实就是一种最佳适配算法。在内存分配领域,类似策略能减少资源浪费,但也可能产生很多无法利用的碎片。比如池里有{10, 20, 30},请求需求8,按规则分配10,池里剩{20, 30},10这个资源虽然被分配走了,但8以下的需求以后永远无法满足,因为池里没有小于10的资源了。

如果你在做真实系统,可能需要考虑其他策略,比如首次适配、最差适配,甚至引入分裂、合并等机制。而机试里不会考得这么深,你只需要按题目给定规则模拟,所以不要自己脑补“最节省”的方案,规则说找最小的ID就找最小的ID,规则说找不小于需求的最小值就按ceiling找。过度设计是模拟题的大忌。

5.3 这道题还能怎么变:从TreeSet到复合调度

有些变体题会把分配规则改成“从池中分配一个全局最小的资源”,那确实用PriorityQueue就够了,数据结构选型一下子就变了。关键是要根据规则判断容器的核心能力:

  • 只看最值,用堆;
  • 需要快速找阈值附近元素,用平衡树;
  • 既有阈值查询又要按优先级排序,可能要组合多种结构。

另外有一种变体是给资源加上“最后使用时间”,要求释放时按最久未使用优先淘汰。这就从TreeSet升级成了LRU缓存题,需要同时维护一套哈希表加双向链表。换汤不换药,核心还是先分析清楚资源是按什么维度被取出的,再决定结构。

这道题的价值在于,它集中考察了“资源池化”这样一个高频抽象,而机试里类似的题还有“银行叫号系统”、“任务调度器”等等。把这些题目放在一起练,你会发现它们的共同骨架都是:一堆待分配资源加一系列请求,然后按规则维护有序结构完成分配与回收。

我在实际辅导里最欣慰的时刻,就是看到学生从“这题我只能用一个列表硬刚”到“这题应该用带二分能力的有序结构”的转变。这种意识比代码本身更值钱。以后再遇到任何带“池化”“共享”“调度”字样的题目,先别急着开数组,先问自己一句:这池子里的资源,到底要按什么顺序出去?想明白这个,你就已经离满分不远了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询