#036. 编程六:数据结构设计题
#学习目标:设计题怎么答
设计题与算法题的差别在于:题面先给你一个接口(连续调用 add、窗口滑动、图上带锁移动),你要选一个内部表示让每次调用都便宜。答题的通用骨架是四步:先复述接口与调用模式(多少次插入、多少次查询、能否修改输入);再选结构并说出不变量(结构内部任何时刻必须满足的性质);然后做均摊分析(单次调用的最坏与均摊代价);最后给边界(空流、单元素、重复值、溢出)。面试官评的是这条流程的完整度,而不只是能不能跑。
本章的三个主角恰好展示三种「状态设计」思路:中位数家族靠对半维护——两个堆各管一半,中位数永远在堆顶;带钥匙的可达性靠约束入状态——把「已收集的钥匙」并入搜索状态,或者用不动点迭代绕开显式状态;优惠券数位和靠数位 DP——把「数值大小约束」拆成逐位决策。设计题的本质是状态选择,这句话值得在面试里说出口。
先问清操作序列:流式 add 是均摊分析;窗口滑动含删除,堆不直接支持删除,就要多重集合或延迟删除。
双堆的正确性全部压在「左堆最大不大于右堆最小、大小差不超过一」上;讲清不变量比画十个例子更有说服力。
同一需求给出两套结构(双堆 vs 有序集合、不动点 BFS vs 状压 BFS),按数据范围选型,是设计题的满分姿势。
#知识点一:对半维护——双堆与多重集合
中位数只需要「正中间的一两个数」,因此不需要维护全序,只需要把数据切成较小的左半与较大的右半:左半用大根堆(堆顶是左半最大),右半用小根堆(堆顶是右半最小)。两条不变量:
有序不变量:任意时刻 \(\max(\text{low}) \le \min(\text{high})\)——新元素先与左堆顶比较决定进哪半,再平衡,保证不被破坏。
平衡不变量:\(|\text{low}| = |\text{high}|\) 或 \(|\text{low}| = |\text{high}| + 1\)——奇数个元素时左堆多一个,中位数即左堆顶;偶数个时取两堆顶平均。
均摊代价:每次 add 至多触发一次堆间搬运,\(O(\log n)\);查询中位数只看堆顶,\(O(1)\)。
难点在删除:标准堆不支持删除任意元素。两条路:其一,换成两个有序多重集合(multiset),删除 \(O(\log n)\) 精确完成,窗口中位数用它最省心;其二,保留双堆,把要删的元素记入「延迟删除」哈希表,等到它浮到堆顶时才真正弹出,并各维护一个「有效大小」计数——在线算法与带删除场景的标准工业做法。代价是代码量翻倍,面试里先讲清思路再决定写哪版。
有序数组(或 sort 每次查询)插入是 \(O(n)\),流式场景 \(n\) 次插入要 \(O(n^2)\);平衡 BST 或树状数组上二分能做到 \(O(\log n)\) 但代码更重。双堆是「只需要中位数」这一需求下的最小代价结构——能讲出这层取舍,设计题就赢了一半。
#知识点二:把约束放进状态——钥匙、锁与不动点
普通图搜索的状态是「当前在哪个房间」;带锁之后,能走哪条边还取决于「手里有哪些钥匙」,状态必须扩成(房间,钥匙集合)。两种实现按钥匙类型数 \(k\) 分流:
状态 \((\text{room}, \text{mask})\),mask 是已收集钥匙类型的位掩码。进入房间即把该房间钥匙类型置位。复杂度 \(O(2^k \cdot m)\),\(k \le 20\) 左右可用。
从当前已访问房间出发做一轮普通 BFS,只能穿「已有钥匙」的隧道;访问新房间可能带来新钥匙,若本轮有新增就再跑一轮,直到无新增(不动点)。每轮至少新开一个房间,至多 \(n\) 轮,复杂度 \(O(n \cdot (n + m))\),不依赖 \(k\)。
两者的关系值得多说一句:不动点版本等价于把「钥匙集合单调增长」这一性质抽出来做了松弛——钥匙只会增多不会减少,所以重复扫描必然收敛,这与贝尔曼-福特反复松弛边是同一种思想。面试里能指出「这是带单调性的松弛」会比背模板更加分。
#例题一:运行中位数(源题 66)
例题 1(源题 66):写一个函数,返回一个数列的运行中位数(running median)——每读入一个数,输出至今所有数的中位数。
思路。知识点一的双堆:新数与左堆顶比较决定去向,插入后立即恢复平衡不变量;中位数按奇偶从堆顶读取。
class RunningMedian {
public:
void add(int x) {
if (low.empty() || x <= low.top()) low.push(x); // 新数进较小的一半
else high.push(x);
if (low.size() > high.size() + 1) { // 平衡:low 恰好多 0 或 1
high.push(low.top()); low.pop();
} else if (high.size() > low.size()) {
low.push(high.top()); high.pop();
}
}
double median() const {
if (low.empty()) return 0.0 / 0.0; // 空流:中位数无定义
return low.size() > high.size()
? (double)low.top() // 奇数个:左堆顶
: (low.top() + high.top()) / 2.0; // 偶数个:两堆顶平均
}
private:
priority_queue<int> low; // 较小一半:大根堆
priority_queue<int, vector<int>, greater<int>> high; // 较大一半:小根堆
};
数值结果。流 \(2, 1, 5, 7, 2, 0, 5\) 的运行中位数依次为 \(2,\ 1.5,\ 2,\ 3.5,\ 2,\ 2,\ 2\)。
复杂度与边界。add 均摊 \(O(\log n)\)(一次插入至多再搬运一个元素)、median \(O(1)\),空间 \(O(n)\)。边界:空流返回 NaN 或抛异常(口径先说死);偶数个的平均值注意 (a + b) / 2.0 用浮点除,两个 int 相加先提升为 long long 防溢出;重复值天然支持(堆不管相等)。
面试怎么讲。三句话定调:「中位数只需要中间一两个数,所以对半维护」「两条不变量:跨堆有序、大小差至多一」「add 对数、查询常数」。然后主动延伸:若还要支持删除,换双多重集合或延迟删除双堆——顺势把下一题引出来。
#例题二:滑动窗口中位数(源题 40)
例题 2(源题 40):给定数列与窗口长度 \(k\),给出每个长度为 \(k\) 的滑动子数组(sliding sublist)的中位数。
思路。窗口每滑一步 = 一次插入 + 一次删除 + 一次查询。堆不支持删除,用两个多重集合维护左右两半(与双堆完全同构的不变量),删除用 find 精确擦除一个实例,再平衡。
vector<double> slidingMedian(const vector<int>& a, int k) {
multiset<int> low, high; // low:较小一半;high:较大一半
auto rebalance = [&]() {
while (low.size() > high.size() + 1) {
high.insert(*low.rbegin()); // 左半最大值上移
low.erase(prev(low.end()));
}
while (low.size() < high.size()) {
low.insert(*high.begin()); // 右半最小值下移
high.erase(high.begin());
}
};
vector<double> res;
for (int i = 0; i < (int)a.size(); ++i) {
if (low.empty() || a[i] <= *low.rbegin()) low.insert(a[i]);
else high.insert(a[i]); // 插入新元素到对应半区
if (i >= k) { // 窗口左界滑出:删除旧元素
int out = a[i - k];
auto it = low.find(out);
if (it != low.end()) low.erase(it);
else high.erase(high.find(out)); // 必在另一侧(不变量保证)
}
rebalance();
if (i >= k - 1) { // 窗口填满后逐个输出
long long m1 = *low.rbegin();
res.push_back(k % 2 == 1 ? (double)m1 : (m1 + *high.begin()) / 2.0);
}
}
return res;
}
数值结果。\([1,3,-1,-3,5,3,6,7]\)、\(k=3\):中位数序列 \(1, -1, -1, 3, 5, 6\)。
复杂度与边界。每步插入、删除、平衡都是 \(O(\log k)\),总 \(O(n \log k)\)、空间 \(O(k)\)。边界:\(k = 1\) 时中位数就是元素本身;\(k\) 等于数组长度退化为单次中位数;重复值由多重集合天然处理(erase(iterator) 只删一个实例,切勿传值删除)。若面试官要求双堆版,就讲延迟删除:待删值进哈希计数,堆顶命中时才弹出,有效大小单独记账——思路分与代码分可以分开拿。
面试怎么讲。先说「窗口滑动引入删除,堆不擅长删除,两个多重集合是最短路径」,写完补一句均摊 \(O(n\log k)\),并主动对比延迟删除双堆——这就是知识点一里 trade-off 表达的落地。
#例题三:房间、钥匙与可达性(源题 61)
例题 3(源题 61):\(n\) 个房间、\(m\) 条隧道,房间 \(i\) 内有一把类型为 \(r[i]\) 的钥匙,第 \(j\) 条隧道连接 \(u[j]\) 与 \(v[j]\) 但上锁,需要类型 \(c[j]\) 的钥匙才能通过。给定起点 \(s\),问能访问哪些房间;再问从哪个房间出发能访问最多房间。
建模。图搜索的边带「钥匙门」约束。按知识点二:钥匙类型数未知时用不动点 BFS——每轮从已访问房间做一次普通 BFS(只穿已持有钥匙的隧道),新房间可能带来新钥匙,有新增就再来一轮。第二个问题枚举所有起点取最大(钥匙结构一般无法共享加速,这是诚实且正确的答案)。
// tunnels[j] = {u, v, 打开该隧道所需钥匙类型};key[i] = 房间 i 内的钥匙类型
vector<int> reachableRooms(int n, int s, const vector<int>& key,
const vector<array<int, 3>>& tunnels) {
vector<vector<pair<int, int>>> adj(n); // (对端房间, 所需钥匙类型)
for (auto& [u, v, c] : tunnels) {
adj[u].push_back({v, c});
adj[v].push_back({u, c});
}
vector<char> vis(n, 0);
vis[s] = 1; // 进入起点房间
while (true) {
unordered_set<int> keys; // 已到过房间提供的钥匙
for (int i = 0; i < n; ++i) if (vis[i]) keys.insert(key[i]);
queue<int> q;
for (int i = 0; i < n; ++i) if (vis[i]) q.push(i);
int fresh = 0; // 本轮新开房间数
while (!q.empty()) {
int u = q.front(); q.pop();
for (auto& [v, c] : adj[u])
if (!vis[v] && keys.count(c)) { // 钥匙在手才能穿隧道
vis[v] = 1; ++fresh;
keys.insert(key[v]); // 新钥匙当场生效
q.push(v);
}
}
if (fresh == 0) break; // 不动点:无新房间即收敛
}
vector<int> rooms;
for (int i = 0; i < n; ++i) if (vis[i]) rooms.push_back(i);
return rooms;
}
// 第二问:枚举所有起点,取可达房间最多者
int bestStart(int n, const vector<int>& key,
const vector<array<int, 3>>& tunnels) {
int bestCnt = -1, bestRoom = -1;
for (int s = 0; s < n; ++s) {
int cnt = (int)reachableRooms(n, s, key, tunnels).size();
if (cnt > bestCnt) { bestCnt = cnt; bestRoom = s; }
}
return bestRoom;
}
数值结果。4 个房间,钥匙类型 \(\text{key} = [0,1,2,5]\),隧道 \(\{0,1,5\}, \{1,2,1\}, \{2,3,2\}\):从 0 出发只有钥匙类型 0,隧道 0–1 需要类型 5,只能访问 \(\{0\}\);从 1 出发顺次拿到钥匙 1、2、5,反过来还能打开 0–1,四个房间全到。最佳起点是 1。
复杂度与边界。每轮 BFS \(O(n + m)\),每轮至少新开一个房间,单起点 \(O(n(n+m))\);枚举全部起点 \(O(n^2(n+m))\),面试规模足够。若钥匙类型数 \(k \le 20\),主动改口:「也可以状压 BFS,状态 \((\text{room}, \text{mask})\),\(O(2^k m)\),\(k\) 小的时候更快」——同一题给两套按范围分流的方案是设计题的标准答法。边界:起点房间钥匙必收;隧道双向可达(题意如此则建双向边);自环与重边无影响。
面试怎么讲。先把「锁约束」翻译成状态扩展或不动点松弛(二选一讲透),用小例子演示「新钥匙回头开旧门」的循环打开过程,最后给两套实现的选型条件。
#例题四:岛屿、完全背包与数位 DP(源题 53、75、69)
例题 4(源题 53):给定 0/1 二维矩阵,1 表示陆地,识别其中的岛屿(四方向相邻的 1 连通块)并计数。
思路。洪泛(flood fill):扫到一块未访问的陆地就把整座岛淹没,计数加一。DFS 版就地标记;图论基础(BFS/DFS、并查集)在 005 章有完整讲解,这里给最简实现。
int numIslands(vector<vector<char>>& g) {
int n = g.size(), m = g[0].size(), ans = 0;
function<void(int, int)> dfs = [&](int r, int c) {
if (r < 0 || r >= n || c < 0 || c >= m || g[r][c] != '1') return; // 越界或非陆地
g[r][c] = '0'; // 就地标记,防止回头
dfs(r + 1, c); dfs(r - 1, c); dfs(r, c + 1); dfs(r, c - 1);
};
for (int r = 0; r < n; ++r)
for (int c = 0; c < m; ++c)
if (g[r][c] == '1') { ++ans; dfs(r, c); } // 每次洪泛即一座新岛
return ans;
}
复杂度与边界。时间空间均 \(O(nm)\),每格至多访问两次。边界:空矩阵返回 0;全 1 矩阵答案 1。追问准备:不许改输入就用 visited 数组或并查集;矩阵大到放不进内存就按行扫描 + 并查集合并(面试只需说思路)。源题另一问「按系数找连通关系」即邻接判定(上下左右),口径先复述。
例题 5(源题 75):实现 \(f(a, b)\):\(a\) 为正整数,\(b\) 为正整数列表,判断 \(a\) 能否表示为 \(b\) 中元素之和(每个元素可重复使用任意次)。
建模。完全背包(unbounded knapsack)的可达性版本:\(dp[x]\) 表示金额 \(x\) 能否凑出,\(dp[0] = \text{true}\),转移 \(dp[x] = \bigvee_{v \in b,\ v \le x} dp[x - v]\)。
bool canSum(int a, const vector<int>& b) {
if (a == 0) return true; // 空和即为 0(题设 a 为正,此行是防御)
vector<char> dp(a + 1, 0);
dp[0] = 1; // 完全背包可达性
for (int x = 1; x <= a; ++x)
for (int v : b)
if (v <= x && dp[x - v]) { dp[x] = 1; break; }
return dp[a];
}
数值结果。\(a = 11, b = [5, 3]\):\(11 = 3+3+5\),返回真;\(a = 7, b = [5, 3]\):可达集 \(\{3,5,6,8,9,10,11,\ldots\}\) 不含 7,返回假。
复杂度与边界。时间 \(O(a \cdot |b|)\)、空间 \(O(a)\)。剪枝彩蛋:若 \(\gcd(b) \nmid a\) 直接返回假;由 Frobenius 数结论,\(a\) 超过约 \((\min b)(\max b)\) 量级后必然可达——\(a\) 极大时可先用这两条短路,再对剩余小值跑 DP。
例题 6(源题 69):优惠券编号从 LowValue 到 HighValue,每张券的码是其数位和;获奖者均匀分配在所有码上。用动态规划求码的分配方式数、最少与最多的获奖人数(例:2 到 11 时,码 2 有两张券(2 与 11),其余码各一,共 9 种码、最多 2 人)。
建模。先求计数函数 \(f(N)\):\(0..N\) 中每种数位和的券数。数位 DP:预处理 \(g[i][s]\)(\(i\) 个 0–9 自由数位凑出和 \(s\) 的方案数);再逐位枚举「第 \(i\) 位首次小于 \(N\) 的数字」的情形,后缀自由。区间 \([\text{Lo}, \text{Hi}]\) 的计数为 \(f(\text{Hi}) - f(\text{Lo}-1)\),非零项即码数,取最小/最大即所求。
// f(N):0..N 中每种「数位和」的计数(数位 DP)
vector<long long> digitSumCounts(long long N) {
string D = to_string(N);
int n = D.size();
// g[i][s]:i 个 0..9 自由数位凑出和 s 的方案数
vector<vector<long long>> g(n + 1, vector<long long>(9 * n + 1, 0));
g[0][0] = 1;
for (int i = 1; i <= n; ++i)
for (int s = 0; s <= 9 * i; ++s)
for (int d = 0; d <= 9 && d <= s; ++d) g[i][s] += g[i - 1][s - d];
vector<long long> cnt(9 * n + 1, 0);
int pref = 0; // 与 N 完全相同的前缀数位和
for (int i = 0; i < n; ++i) {
for (int d = 0; d < D[i] - '0'; ++d) // 第 i 位首次小于 N 的数字
for (int s = 0; s <= 9 * (n - i - 1); ++s)
cnt[pref + d + s] += g[n - i - 1][s];
pref += D[i] - '0';
}
cnt[pref] += 1; // N 自身
return cnt;
}
// 返回:码数、每个码上最少获奖人数、最多获奖人数
tuple<long long, long long, long long> couponStats(long long Lo, long long Hi) {
auto hi = digitSumCounts(Hi);
auto lo = Lo > 0 ? digitSumCounts(Lo - 1)
: vector<long long>(hi.size(), 0);
long long codes = 0, mn = LLONG_MAX, mx = 0;
for (size_t s = 0; s < hi.size(); ++s) {
long long c = hi[s] - (s < lo.size() ? lo[s] : 0);
if (c > 0) { ++codes; mn = min(mn, c); mx = max(mx, c); }
}
if (codes == 0) return {0, 0, 0}; // 空区间防御
return {codes, mn, mx};
}
数值结果。\(\text{Lo}=2, \text{Hi}=11\):码 1 只有券 10(1 张),码 2 有券 2 与 11(2 张),码 3–9 各 1 张——共 9 种码、最少 1 人、最多 2 人,与题给示例完全一致。
复杂度与边界。数位 DP 为 \(O(L^2 \cdot 81)\)(\(L\) 为位数,\(9L\) 为最大数位和),对 long long 范围绰绰有余。边界:Lo 为 0 时 \(f(\text{Lo}-1)\) 直接置零向量;数位和最大 \(9 \times 19 = 171\),两表长度不同时以长表为准逐位相减。追问准备:若问「哪个码人数最多」,返回最大值下标即可;若码上均匀抽奖,某张券中奖概率是 \(\frac{1}{\text{该码券数}} \times \frac{1}{\text{码数}}\)——顺口就能补上概率口径。
#例题五:调度、填充与回归口径(源题 29、30、73)
编程篇收尾的三道题分属贪心调度、位运算填充与统计口径,按题库完整性收录于此;它们共同的模式是「先用小例子把口径钉死,再动手」。
例题 7(源题 29):给定若干部影片时长,求最少观看天数;每天可组合多部影片,但每天总时长不超过 3 小时。
建模。装箱问题(bin packing,箱容量 3)。先与考官确认「每天至多几部」:至多两部时是经典的双指针配对(最长带最短,装不下就单独一天);不限部数且时长为整数小时时可给闭式解;任意实数时长则是一般装箱——NP 难,标准做法是首次适应递减(FFD)启发式。
int minDays(vector<int>& dur) { // 每天总时长至多 3,每天至多两部
sort(dur.begin(), dur.end());
int i = 0, j = dur.size() - 1, days = 0;
while (i <= j) { // 最长影片尽量带一部最短的
if (i < j && dur[i] + dur[j] <= 3) ++i;
--j; ++days;
}
return days;
}
数值结果与变体。时长 \([1,1,1,1,1]\):每天至多两部的双指针版得 3 天(\((1,1),(1,1),(1)\));整数任意部版的闭式 \(n_3 + n_2 + \lceil \max(0, n_1 - n_2)/3 \rceil = 0 + 0 + 2\) 得 2 天(\((1,1,1),(1,1)\))——这个例子正好把两种口径区分开。若时长为 \([1,1,2,2,3]\),两种口径同为 3 天(\((3),(1,2),(1,2)\))。
复杂度与边界。双指针 \(O(n \log n)\)(排序主导)。边界:超过 3 小时的影片按题意不存在(存在则无解,先声明);单部影片成天。能主动说出「一般装箱 NP 难、FFD 是 \(\frac{11}{9}\) 近似」就到了追问的天花板。
例题 8(源题 30):文件上传场景:给定哪些位置已被占用,要求用大小为 \(2^n\) 的文件把其余位置填满,求最少文件数。
建模。把空位切成极大连续段,每段独立填充。长度 \(L\) 的段需要的最少 2 的幂文件数是 \(L\) 的二进制表示中 1 的个数(popcount):任何 2 的幂之和都可合并——两个 \(2^k\) 换一个 \(2^{k+1}\),故最优解就是二进制分解。
int minFiles(const string& slots) { // '.' 空位,'#' 占用
int total = 0, run = 0;
string s = slots + '#'; // 末尾哨兵 flush 最后一段
for (char c : s) {
if (c == '.') ++run;
else { total += __builtin_popcount(run); run = 0; }
}
return total;
}
数值结果。"..#...#":两段长度 2 与 3,分别需要 \(1\)(一个 \(2\))与 \(2\)(\(2+1\))个文件,共 3。
复杂度与边界。线性 \(O(n)\)。为什么 popcount 是下界也要会说:\(k\) 个 2 的幂之和,其二进制 1 的个数不超过 \(k\)(合并只减不增),所以要凑出 \(L\) 至少需要 popcount(\(L\)) 个文件,而二进制分解恰好达到。变体:若文件必须按 \(2^k\) 对齐摆放(只能落在 \(2^k\) 的倍数位置),递归对半切分即可,答案仍是 popcount——两问同答,先问清是否对齐。
例题 9(源题 73,统计题):线性回归中设计矩阵不可逆,怎么办?
诊断。\(X^\top X\) 奇异通常来自四种情形:特征完全共线(某列是其余列的线性组合)、虚拟变量陷阱(哑变量加全了常数列)、样本数小于特征数(\(n \lt p\))、重复或常数列。先做诊断再谈药方,是这道题的正确顺序。
删共线列:去掉冗余列(或去掉一个哑变量基准类),让列满秩——最干净,前提是你能识别冗余。
岭回归:解 \((X^\top X + \lambda I)\hat\beta = X^\top y\),\(\lambda \gt 0\) 使矩阵正定可逆;等价于给 \(\beta\) 加 \(L_2\) 惩罚,\(\lambda\) 由交叉验证选。
伪逆(Moore–Penrose):\(\hat\beta = X^{+} y\) 用 SVD 计算,给出最小范数最小二乘解——不选 \(\lambda\) 的「自动」版本,数值上奇异值截断要设阈值。
补数据或重新采样:若不可逆源于 \(n \lt p\) 或采样缺陷,治本靠再收集数据或特征选择。
面试怎么讲。一句话总结:「先查共线来源,能删就删;删不了用岭回归或伪逆,两者分别是『可调参数的稳定解』与『最小范数解』」。岭回归的正则化视角与选择问题在 027 章展开,OLS 的几何与推导见 026 章。
#误区与边界
一、平衡写反:左堆只允许比右堆多一个,反了会偶数取错堆顶;二、多重集合删除传值:erase(value) 会删光所有实例,必须传 find 返回的迭代器;三、偶数平均溢出:两个 int 堆顶相加先转 long long 或 double。窗口题还有一个隐藏坑:删除元素找错半区会破坏不变量,应先在左半 find,找不到再删右半——依据是跨堆有序。
钥匙题:忘收起点房间的钥匙、或把「新钥匙当场生效」写成下一轮才生效,都会漏解;状压与不动点按钥匙类型数分流,别在 \(k\) 很大时硬上 \(2^k\)。数位 DP:上界本身(\(N\) 那一支)要单独加一,区间相减时两表长度对齐。完全背包:\(a\) 特大时先做 gcd/Frobenius 短路。调度与填充题:装箱的「每天几部」与文件的「是否对齐」两个口径必须先问——这类题丢分大多丢在口径而非算法。
#检查清单
- 我能写出双堆运行中位数,口述两条不变量与 add 均摊 \(O(\log n)\)、查询 \(O(1)\) 的代价。
- 我能把双堆改造成双多重集合以支持删除,正确输出滑动窗口中位数,并说明延迟删除双堆的替代方案。
- 我能把「锁与钥匙」约束并入图搜索状态,实现不动点 BFS,并按钥匙类型数在 \(O(n(n+m))\) 与 \(O(2^k m)\) 之间选型。
- 我能用洪泛数岛屿并就地标记,说出禁止改输入时的 visited/并查集替代方案。
- 我能实现完全背包可达性 \(f(a,b)\),并用 gcd 与 Frobenius 结论对大 \(a\) 短路。
- 我能用数位 DP 求任意区间内数位和的分布,并复现 2..11 的「9 种码、最多 2 人」示例。
- 我能对设计矩阵不可逆给出诊断顺序与三类药方(删列、岭回归、伪逆),并链接到正则化章节。
- 我拿到任何设计题都会先复述接口与调用模式,再选结构、讲不变量、做均摊分析、过边界清单。