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

先用一个小例走通

流式数1、5、2到来时,排序中位数分别1、3、2。双堆让较小一半的最大值和较大一半的最小值始终可读,偶数时平均两边界。多重集合允许同一个值存多个副本,删除一个5不该删光所有5。设计题先说支持哪些操作及每项成本,再挑结构;“用了堆”不是完整接口说明。

双堆对半维护、带钥匙的图搜索与数位 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 numeric_limits<double>::quiet_NaN();             // 空流:中位数无定义
        return low.size() > high.size()
                   ? (double)low.top()                  // 奇数个:左堆顶
                   : ((double)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 对数、查询常数」。然后主动延伸:若还要支持删除,换双多重集合或延迟删除双堆——顺势把下一题引出来。

推导补全与自检。左侧大顶堆保存较小一半,右侧小顶堆保存较大一半;维持左最大\(\le \)右最小、两边大小差\(\le 1\)。输入[5,1,3,2]的中位数应为5、3、3、2.5。偶数取(a+b)/2时先转浮点或更宽整数,避免两极值相加溢出。两个堆内部都无序,但分区不变量保证只有堆顶会参与中位数。

#例题二:滑动窗口中位数(源题 40)

例题 2(源题 40):给定数列与窗口长度 \(k\),给出每个长度为 \(k\) 的滑动子数组(sliding sublist)的中位数。

思路。窗口每滑一步 = 一次插入 + 一次删除 + 一次查询。堆不支持删除,用两个多重集合维护左右两半(与双堆完全同构的不变量),删除用 find 精确擦除一个实例,再平衡。

vector<double> slidingMedian(const vector<int>& a, int k) {
    if (k <= 0 || k > (int)a.size()) return {};
    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 表达的落地。

推导补全与自检。滑窗\([1,3,-1,-3,5]\)、\(k=3\)的中位数为1、\(-1\)、\(-1\)。删除只能擦掉一个相同值实例;multiset.erase(value)会删除该值全部副本,应find后erase(iterator)。每轮插入和删除后都恢复分区与大小不变量。若用延迟删除双堆,还需区分逻辑大小与物理堆长度,不能拿含过期元素的size直接比较。

#例题三:房间、钥匙与可达性(源题 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\) 小的时候更快」——同一题给两套按范围分流的方案是设计题的标准答法。边界:起点房间钥匙必收;隧道双向可达(题意如此则建双向边);自环与重边无影响。

面试怎么讲。先把「锁约束」翻译成状态扩展或不动点松弛(二选一讲透),用小例子演示「新钥匙回头开旧门」的循环打开过程,最后给两套实现的选型条件。

推导补全与自检。到达房间时获得新钥匙,必须重新激活此前因缺钥匙而阻塞的门;普通“一次扫描所有邻边后永久忘记”会漏路。可按钥匙类型存等待门,首次获得某类钥匙时把该类等待目标加入队列。单源每个门只需登记和解锁有限次;所有起点都求最优则朴素重复O(n(n+m)),不能把单源复杂度误报为整体。

#例题四:岛屿、完全背包与数位 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 数组或并查集;矩阵大到放不进内存就按行扫描 + 并查集合并(面试只需说思路)。源题另一问「按系数找连通关系」即邻接判定(上下左右),口径先复述。

推导补全与自检。四连通的递归洪泛在访问一个1时立即改0,再看四邻居;否则相邻陆地会互相递归。\(1\times 1\)的1返回1、0返回0;空矩阵要先检查再取grid[0]。改原数组的副作用需明确,若后面还要使用原地形,应传副本或单独visited。所有格子只处理一次给O(RC),递归栈最坏也为O(RC)。

例题 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\) 直接返回假;gcd整除只是必要条件,不能凭“a很大”直接返回真。若要用Frobenius或模最小币值的最短路作短路,须证明对应条件与阈值;本例代码仍以完全背包为准。

推导补全与自检。可重复正整数凑和,从\(dp[0]=true\)出发,若按每种币遍历则金额必须正向;\(b=[3],a=6\)应为真,而\(0/1\)背包倒序会返回假。gcd(b)不整除a时可立即判假,但整除不充分,例如\(b=[4,6],a=2\)仍不可达。\(a=0\)应为真(空组合),零币和负币必须按输入约束排除以避免循环意义混乱。

例题 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{码数}}\)——顺口就能补上概率口径。

C69的ways表述不清。为避免把不同统计量混成一个输出,先给每种数位和的完整券数分布。2…11的例子有9个非空码,最小桶大小1(8个码达到),最大桶大小2(只有码2达到)。若“ways”问可选择的码数,取9;若问达到最多获奖人数的选码方式数,取1;若问达到最少人数的方式数,取8。原文无法唯一决定它指哪个,不能把“最少人数1”直接等同于“方式数1”。digitSumCounts已经返回逐码计数,可据明确的输出规格统计。

推导补全与自检。数位DP状态含位数、当前数位和、是否贴上界;若统计[Low,High],用\(F(High)-F(Low-1)\)逐个数位和相减。前导零不改变数位和,但若不允许编号0,应在范围差或started标记里去掉。取最小获奖人数时只对非零计数的码求最小,否则把从未出现的码算进去会错误得到0。

#例题五:调度、填充与回归口径(源题 29、30、73)

编程篇收尾的三道题分属贪心调度、位运算填充与统计口径,按题库完整性收录于此;它们共同的模式是「先用小例子把口径钉死,再动手」。

例题 7(源题 C29):电影排片的接口与条件回顾

原题每天只限制总时长3小时,不限制片数。完整精确搜索实现见032章电影排片的minDaysMinutesExact;同一问题不再保留一份只解“每天两部”的代码冒充原题。

若时长恰为正整数小时1、2、3,可用计数闭式:设三类片数n₁、n₂、n₃,先让每部3小时独占一天,每部2小时尽可能带一部1小时,其余1小时每三部一天。总天数为\(n_3+n_2+\lceil\max(0,n_1-n_2)/3\rceil\)。[1,1,1,1,1]因此为2天。任意分钟时长不满足这个特殊值域条件,应回到装箱模型。

推导补全与自检。两个章节统一指向同一个原规则精确实现;受限版本须在接口、示例和复杂度中明确条件。三小时是180分钟,不能把参数用整数分钟而代码仍拿常数3比较。

例题 8(源题 C30):用2的幂长度文件填满空段;地址对齐另设条件

无对齐要求的原文版本。PDF只给占用位置与2的幂大小,没有给地址对齐要求。不同连续空段不能跨越,长度L的每段可按二进制分解,最少popcount(L)个文件。下界理由:k个2的幂相加,二进制1的数量不超过k;构造恰好用每一个1位对应的块,达到下界。

若额外要求对齐。大小2ᵏ的块起始地址必须是2ᵏ的倍数,此时段起点也影响答案。在每段当前起点,从能对齐且不超过余长的块中选最大者。对齐的二进制块要么不交、要么包含,最大可行块不会跨越空段,其他对齐覆盖只能将它细分;合并细分块不会增加数量,所以贪心可取最少块。

long long minFiles(const string& slots, bool aligned = false) {
    long long answer = 0, n = slots.size();
    for (long long i = 0; i < n;) {
        if (slots[i] != '.') { ++i; continue; }
        long long start = i;
        while (i < n && slots[i] == '.') ++i;
        long long remaining = i - start;
        if (!aligned) { answer += __builtin_popcountll(remaining); continue; }
        while (remaining > 0) {
            long long block = 1;
            while (block <= remaining / 2 && start % (2 * block) == 0) block *= 2;
            start += block; remaining -= block; ++answer;
        }
    }
    return answer;
}

slots=".#.."的空段为地址0的一格、地址2起的两格:两种模式均用2个块。slots="#..#"只有地址1、2两格空闲:不对齐可用一个大小2的块;对齐模式必须用两个大小1的块。默认aligned=false;地址从0开始,'.'为空闲、其他字符为已占。无对齐模式O(n)扫描、O(1)额外空间;对齐模式最坏O(n log n)时间,额外空间仍O(1)。

推导补全与自检。长度相同但起点不同的空段,在对齐要求下可能有不同答案,不能继续声称“无论对齐与否都等于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 章。

推导补全与自检。\(X^TX\)奇异时系数不唯一,但最小二乘拟合向量\(X\widehat\beta\)是到列空间的唯一投影。SVD伪逆给最小欧氏范数解,Ridge改变目标并给收缩解,删冗余列改变参数表示,三者不是同一种处理。近奇异而非完全奇异时也应看条件数与样本外稳定性,不能只检查det是否恰为0;QR/SVD通常比显式求逆可靠。

#误区与边界

中位数家族的三个坑

一、平衡写反:左堆只允许比右堆多一个,反了会偶数取错堆顶;二、多重集合删除传值: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阈值短路。
  • 我能用数位 DP 求任意区间内数位和的分布,并复现 2..11 的「9 种码、最多 2 人」示例。
  • 我能对设计矩阵不可逆给出诊断顺序与三类药方(删列、岭回归、伪逆),并链接到正则化章节。
  • 我拿到任何设计题都会先复述接口与调用模式,再选结构、讲不变量、做均摊分析、过边界清单。