设计题和业务模拟题不一定需要高级算法。它们更常考查四项能力:把自然语言规则转换为状态、为每个操作选择数据结构、维持不变量,以及在边界输入下给出确定结果。
这类题代码通常较长。最危险的做法是一边读题一边堆叠 if,直到多个状态互相矛盾。本篇先给出可复用的建模流程,再实现一个预约系统,最后用三道题完成整套系列的模拟。
一、业务模拟题的六步建模法
1. 列出实体和唯一标识
先回答:系统中有哪些对象,如何唯一找到它们?
例如预约系统包含房间和预约:
- 房间由
roomId 唯一标识;
- 一条预约包含开始时间、结束时间和预约人;
- 同一房间的预约按开始时间排序。
2. 写出状态,而不是先写操作
根据查询方式选择状态结构:
1 2 3 4
| roomId └── 按 start 排序的预约集合 ├── [start, end) -> owner └── [start, end) -> owner
|
外层按编号快速定位房间,可以使用哈希表;内层需要寻找相邻时间段,可以使用有序映射。
3. 明确定义不变量
不变量是在每次合法操作后都必须成立的条件:
- 每条预约满足
start < end;
- 同一房间的任意两个时间段不重叠;
- 使用半开区间
[start, end),所以 [10, 20) 与 [20, 30) 可以相邻;
- 查询结果按开始时间升序输出。
4. 为每个命令写操作表
| 命令 |
输入 |
读取状态 |
修改状态 |
输出 |
BOOK |
房间、起止时间、预约人 |
当前房间相邻预约 |
插入新预约 |
OK / INVALID / CONFLICT |
CANCEL |
房间、开始时间 |
对应预约 |
删除预约 |
OK / NOT_FOUND |
QUERY |
房间 |
该房间全部预约 |
无 |
有序列表和 END |
5. 先定义错误策略
题目没有说明时,不要擅自“修复”非法输入。例如 start >= end 是拒绝、忽略还是交换端点,必须按题意处理。本文选择返回 INVALID,不会自动交换。
6. 最后分析复杂度
复杂度分析可以反向检查数据结构是否匹配数据规模。如果每次查询都扫描所有房间,而题目有 10^5 次操作,就应重新设计索引。
二、贯穿场景:会议室预约系统
1. 为什么只需检查相邻预约
同一房间的预约按开始时间排序。插入 [start, end) 时,通过 lower_bound(start) 找到第一个开始时间不小于 start 的预约:
- 它是右侧唯一可能首先冲突的预约;
- 它的前一个元素是左侧唯一可能最后冲突的预约;
- 如果这两个都不冲突,更远的预约也不会冲突。
2. 完整程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
| #include <iostream> #include <iterator> #include <map> #include <string> #include <unordered_map>
struct Booking { int start; int end; std::string owner; };
enum class BookResult { ok, invalid, conflict };
class ReservationSystem { public: BookResult book( int roomId, int start, int end, const std::string& owner ) { if (start >= end) { return BookResult::invalid; }
auto& schedule = schedules_[roomId]; const auto next = schedule.lower_bound(start);
if (next != schedule.end() && next->second.start < end) { return BookResult::conflict; } if (next != schedule.begin()) { const auto previous = std::prev(next); if (previous->second.end > start) { return BookResult::conflict; } }
schedule.emplace(start, Booking{start, end, owner}); return BookResult::ok; }
bool cancel(int roomId, int start) { const auto room = schedules_.find(roomId); if (room == schedules_.end()) { return false; }
const std::size_t erased = room->second.erase(start); if (room->second.empty()) { schedules_.erase(room); } return erased == 1; }
void query(int roomId, std::ostream& output) const { const auto room = schedules_.find(roomId); if (room == schedules_.end() || room->second.empty()) { output << "EMPTY\n"; output << "END\n"; return; }
for (const auto& [start, booking] : room->second) { output << start << ' ' << booking.end << ' ' << booking.owner << '\n'; } output << "END\n"; }
private: std::unordered_map<int, std::map<int, Booking>> schedules_; };
int main() { std::ios::sync_with_stdio(false); std::cin.tie(nullptr);
int operationCount = 0; std::cin >> operationCount;
ReservationSystem system; for (int i = 0; i < operationCount; ++i) { std::string command; std::cin >> command;
if (command == "BOOK") { int roomId = 0; int start = 0; int end = 0; std::string owner; std::cin >> roomId >> start >> end >> owner;
const BookResult result = system.book(roomId, start, end, owner); if (result == BookResult::ok) { std::cout << "OK\n"; } else if (result == BookResult::invalid) { std::cout << "INVALID\n"; } else { std::cout << "CONFLICT\n"; } } else if (command == "CANCEL") { int roomId = 0; int start = 0; std::cin >> roomId >> start; std::cout << (system.cancel(roomId, start) ? "OK\n" : "NOT_FOUND\n"); } else if (command == "QUERY") { int roomId = 0; std::cin >> roomId; system.query(roomId, std::cout); } }
return 0; }
|
输入:
1 2 3 4 5 6 7 8 9 10
| 9 BOOK 1 10 20 alice BOOK 1 20 30 bob BOOK 1 15 25 carol QUERY 1 CANCEL 1 10 BOOK 1 15 20 carol QUERY 1 CANCEL 2 10 QUERY 2
|
输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| OK OK CONFLICT 10 20 alice 20 30 bob END OK OK 15 20 carol 20 30 bob END NOT_FOUND EMPTY END
|
3. 边界测试
至少覆盖:
start == end 和 start > end;
- 空房间查询;
- 取消不存在的房间和不存在的开始时间;
- 新预约与已有预约左端、右端恰好相接;
- 新预约包含已有预约,或被已有预约包含;
- 相同开始时间重复预约。
若一个房间有 m 条预约,BOOK 和 CANCEL 的内层有序映射操作为 O(log m),QUERY 为 O(m)。外层哈希表查找平均为 O(1)、最坏为 O(r),其中 r 是房间数量。
三、不要为了“设计题”过度设计
上机题的目标是正确表达规则,不是展示继承层次。以下情况通常不需要抽象基类或设计模式:
- 只有一种固定业务对象;
- 命令集合在题目中已经确定;
- 没有运行时替换策略的需求;
- 一个结构体和几个函数已经能保持不变量。
适度封装仍然有价值:让状态成为类的私有成员,把 book()、cancel() 作为唯一修改入口,可以避免主流程绕过冲突检查。
四、三题模拟:执行规则
建议在第七天连续完成下面三题。先自行实现完整程序,再对照参考代码。模拟时采用比例而不是固定分钟:
- 用约 10% 的总时间阅读三题、标记输入输出和数据范围;
- 优先完成最确定的一题,尽早获得一次成功编译和样例通过;
- 为中等题预留约 35% 时间,为最长的设计题预留约 45% 时间;
- 最后至少保留 10% 时间检查边界、类型和输出格式。
如果真实题目难度顺序不同,应按“预计完成成本和正确把握”重新排序,不必机械遵循题号。
五、模拟第一题:十六进制异或校验
题目
输入 n 个两位十六进制字节,将它们逐个异或,输出两位大写十六进制校验值。任意字节格式非法时输出 INVALID。
输入:
输出:
参考程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| #include <iomanip> #include <iostream> #include <stdexcept> #include <string>
int hexDigitValue(char ch) { if ('0' <= ch && ch <= '9') { return ch - '0'; } if ('A' <= ch && ch <= 'F') { return ch - 'A' + 10; } if ('a' <= ch && ch <= 'f') { return ch - 'a' + 10; } throw std::invalid_argument("invalid digit"); }
unsigned int parseByte(const std::string& token) { if (token.size() != 2) { throw std::invalid_argument("invalid byte"); } return static_cast<unsigned int>( (hexDigitValue(token[0]) << 4) | hexDigitValue(token[1]) ); }
int main() { std::ios::sync_with_stdio(false); std::cin.tie(nullptr);
int n = 0; if (!(std::cin >> n) || n < 0) { std::cout << "INVALID\n"; return 0; }
try { unsigned int checksum = 0; for (int i = 0; i < n; ++i) { std::string token; if (!(std::cin >> token)) { throw std::invalid_argument("missing byte"); } checksum ^= parseByte(token); }
std::cout << std::uppercase << std::hex << std::setw(2) << std::setfill('0') << checksum << '\n'; } catch (const std::invalid_argument&) { std::cout << "INVALID\n"; } return 0; }
|
时间复杂度为 O(n),除输入字符串外额外空间为 O(1)。检查点包括两位格式、大小写、空输入和缺少字节。
六、模拟第二题:至多 K 类事件的最长连续区间
题目
输入 n 个事件类型编号和整数 k,求最多包含 k 种不同事件类型的最长连续区间长度。k <= 0 时答案为 0。
输入:
输出:
参考程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| #include <algorithm> #include <iostream> #include <unordered_map> #include <vector>
int longestWindow(const std::vector<int>& events, int k) { if (k <= 0) { return 0; }
std::unordered_map<int, int> frequency; int left = 0; int answer = 0;
for (int right = 0; right < static_cast<int>(events.size()); ++right) { ++frequency[events[right]];
while (static_cast<int>(frequency.size()) > k) { const int value = events[left]; --frequency[value]; if (frequency[value] == 0) { frequency.erase(value); } ++left; }
answer = std::max(answer, right - left + 1); } return answer; }
int main() { std::ios::sync_with_stdio(false); std::cin.tie(nullptr);
int n = 0; int k = 0; std::cin >> n >> k;
if (n < 0) { std::cout << "INVALID\n"; return 0; }
std::vector<int> events(static_cast<std::size_t>(n)); for (int& event : events) { std::cin >> event; }
std::cout << longestWindow(events, k) << '\n'; return 0; }
|
左右边界都只向右移动,哈希操作平均为 O(1),因此平均时间复杂度为 O(n),空间复杂度为 O(k);严格地说,在收缩前的瞬间可能暂存 k + 1 种键,数量级仍为 O(k)。
最关键的边界是:计数降为 0 时必须删除键,否则 frequency.size() 不代表当前窗口的事件种类数。
七、模拟第三题:预约系统
将本文第二节的预约系统作为第三题,不复制代码重新练习。实现前先在纸面或注释中写出:
- 半开区间
[start, end);
- 每个房间内部按开始时间排序;
- 插入时只检查
lower_bound(start) 及其前驱;
- 空房间、非法区间、冲突和不存在的取消分别输出什么;
BOOK、CANCEL、QUERY 的复杂度。
第二次实现时可以先不封装类,确认规则正确后再整理接口。重构不能改变已经通过测试的输入输出行为。
八、三题考试的调试顺序
1. 编译阶段
- 标准头文件是否齐全;
- 是否误用了 C++20 接口,如
contains()、Ranges 算法;
- 函数签名和返回类型是否一致;
- 自定义比较器是否可调用且参数带
const;
- 类型名、变量名与命令字符串是否拼写一致。
2. 样例阶段
不要只确认输出“看起来一样”,还要检查空格、换行、大小写和是否输出了额外调试文本。
3. 自造边界
每题至少覆盖:
- 最小合法输入;
- 空集合或长度为
0 的允许情况;
- 单元素;
- 全相等和全部不同;
- 最大值、总和与乘积的类型范围;
- 恰好在边界相等的条件;
- 不存在答案或操作对象不存在。
4. 超时与内存
先重新计算复杂度,不要首先怀疑输入输出。检查是否在循环中重复排序、复制大容器、线性查找哈希表本可索引的对象,或让一个本应单向移动的指针反复回退。
九、七天复习安排
| 天数 |
内容 |
最低交付 |
| 第 1 天 |
C++17 基础与输入输出 |
默写完整程序骨架,完成 3 个输入模式 |
| 第 2 天 |
字符串、进制、位运算、字节流 |
独立完成报文解析与非法输入检查 |
| 第 3 天 |
STL 容器和数据结构 |
根据操作说出容器、接口和复杂度 |
| 第 4 天 |
排序、查找、二分 |
默写比较器和最小可行答案二分 |
| 第 5 天 |
双指针、滑动窗口、组合模式 |
写出定长窗口、变长窗口和前缀和 |
| 第 6 天 |
设计题和业务模拟 |
独立完成预约系统,补齐边界测试 |
| 第 7 天 |
三题连续模拟 |
限时完成、记录错误、只复盘高频失误 |
如果时间允许,第二轮不要从头逐字阅读,而应遮住代码,先根据标题默写模板,再只查看写错的部分。
十、最终考前速查表
语法与类型
- 标准:C++17;正文模板使用标准头文件;
- 总和、乘积、容量、时间戳:优先检查是否需要
long long;
- 大对象只读参数:
const T&;
- 范围循环修改元素:
T&;只读大对象:const T&;
std::accumulate() 需要 64 位结果:初始值 0LL。
容器
- 动态数组:
std::vector;
- 键值计数:
std::unordered_map;有序输出:std::map;
- 判重:
std::unordered_set;
- LIFO:
std::stack;FIFO:std::queue;动态最值:std::priority_queue;
- C++17 判断键存在:
find(key) != end()。
算法
- 排序:
std::sort(begin, end, comparator);比较器不能使用 <=;
- 有序边界:
lower_bound() 第一个 >=,upper_bound() 第一个 >;
- 最小可行答案:可行则收缩右边界,不可行则
left = middle + 1;
- 有序二元组:相向双指针;
- 连续区间:判断能否滑动维护;
- 静态区间和:前缀和;
- 下一个更大值:单调栈;窗口最值:单调队列。
提交前
- 删除调试输出;
- 检查非法或空输入是否属于题目范围;
- 测试单元素、重复值、边界相等和不存在答案;
- 检查整数溢出、下标越界、空容器访问和残留换行;
- 最后逐字核对输出格式。
十一、C++20 可选补充
如果平台明确允许 C++20,可以把 find() 判存在替换为 contains(),使用 Ranges 算法和 std::span 改善表达。但临场不要为了缩短几行代码,把已经稳定的 C++17 模板换成不熟悉的新接口。
十二、延伸练习
其中 LRU 缓存和“设计推特”明显高于本系列的基础冲刺范围,适合作为时间充足时的第二轮训练,不应挤占字符串、STL、二分和滑窗的复习时间。
参考资料