#036. 编程六:数据结构设计题

双堆对半维护、带钥匙的图搜索与数位 DP 等设计题模式导图

#学习目标:设计题怎么答

设计题与算法题的差别在于:题面先给你一个接口(连续调用 add、窗口滑动、图上带锁移动),你要选一个内部表示让每次调用都便宜。答题的通用骨架是四步:先复述接口与调用模式(多少次插入、多少次查询、能否修改输入);再选结构并说出不变量(结构内部任何时刻必须满足的性质);然后做均摊分析(单次调用的最坏与均摊代价);最后给边界(空流、单元素、重复值、溢出)。面试官评的是这条流程的完整度,而不只是能不能跑。

本章的三个主角恰好展示三种「状态设计」思路:中位数家族靠对半维护——两个堆各管一半,中位数永远在堆顶;带钥匙的可达性靠约束入状态——把「已收集的钥匙」并入搜索状态,或者用不动点迭代绕开显式状态;优惠券数位和靠数位 DP——把「数值大小约束」拆成逐位决策。设计题的本质是状态选择,这句话值得在面试里说出口。

接口优先

先问清操作序列:流式 add 是均摊分析;窗口滑动含删除,堆不直接支持删除,就要多重集合或延迟删除。

不变量即正确性

双堆的正确性全部压在「左堆最大不大于右堆最小、大小差不超过一」上;讲清不变量比画十个例子更有说服力。

trade-off 表达

同一需求给出两套结构(双堆 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\) 分流:

状压 BFS(\(k\) 较小)

状态 \((\text{room}, \text{mask})\),mask 是已收集钥匙类型的位掩码。进入房间即把该房间钥匙类型置位。复杂度 \(O(2^k \cdot m)\),\(k \le 20\) 左右可用。

不动点 BFS(\(k\) 很大或未知)

从当前已访问房间出发做一轮普通 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,找不到再删右半——依据是跨堆有序。

图与 DP 的口径坑

钥匙题:忘收起点房间的钥匙、或把「新钥匙当场生效」写成下一轮才生效,都会漏解;状压与不动点按钥匙类型数分流,别在 \(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 人」示例。
  • 我能对设计矩阵不可逆给出诊断顺序与三类药方(删列、岭回归、伪逆),并链接到正则化章节。
  • 我拿到任何设计题都会先复述接口与调用模式,再选结构、讲不变量、做均摊分析、过边界清单。