☰
USACO黄金组真题拆解:LIS二分、矩阵快速幂与区间DP
2026/10/1 5:11:08 网站建设 项目流程

真正动手去啃 USACO 历年黄金组真题的人,通常已经不是新手了。铜组让你熟悉输入输出和暴力枚举,银组逼你学贪心和最短路,到了黄金组(Gold Division),题目开始往“模型识别 + 算法优化”这条路上走——你不仅要会写代码,还得在几十秒内判断这道题该用动态规划还是矩阵快速幂。2005 年 11 月这场黄金组比赛,正好是三道非常典型的题:信号桥接、牛群接力、赶牛回栏。它们分别对应最长上升子序列的二分优化、矩阵快速幂重写最短路、区间 DP 的经典模型。这三个方向放在今天的大厂笔试里,依然是高频考点。

我之所以一直推荐别人去刷这场真题,不是因为题目有多难,而是它的风格特别“干净”:每道题都有清晰的约束条件,解法不冷门,但每一步都有值得品味的推导。你不需要什么偏门的数据结构,只要把最核心的动态规划、矩阵运算和贪心扫描的细节吃透,就能顺手拿下。这篇就把三道题从头到尾拆开讲清楚,包括我当时踩过的坑、被卡住的瞬间,以及现在复盘时觉得最值得注意的细节。

1. 2005年11月黄金组:整体风格与考点分布

1.1 三题与核心算法对照

先把三道题的定位摆出来,方便你有一个整体印象。

题目核心考点复杂度要求难度定位
Bridging Signals 信号桥接最长上升子序列 LIS + 二分优化O(n log n)基础但易错
Cow Relays 牛群接力矩阵快速幂 + 广义 Floyd 最短路O(M^3 log K)需要建模能力
Cow Run 赶牛回栏区间 DP + 最优子结构剪枝O(n^2)经典 DP 模型

Gold 组的题目有个共同点:它不会直接把算法名字印在题面上,而是把问题包在一个故事里。比如信号桥接,表面上是一堆电路板上的线要连起来,实际上就是求一个最长上升子序列。牛群接力,讲的是奶牛在节点之间跑接力,实际上是要你求“恰好经过 K 条边的最短路”。赶牛回栏则是一个非常标准的区间 DP,但如果你看不穿“已经访问过的奶牛一定是一个连续区间”这个性质,很容易被坐标的正负分布搞晕。

1.2 为什么这场比赛的题型很有代表性

很多人在刷题时会陷入一个误区:只按算法分类去刷,比如今天刷十个 DP,明天刷十个图论。这样刷出来的能力是割裂的。2005 年 11 月这组题好就好在,它把三种表面上完全不同的算法放在同一场比赛里,但它们的底层思维是相通的——都需要你先把真实问题抽象成数学模型,再考虑优化。

另外,这里的三个考点都非常适合作为“笔试真题解析”的素材。LIS 二分优化是互联网公司笔试常客,矩阵快速幂几乎每年都会出现在校招笔试题里,区间 DP 更是动态规划里最容易被考到的模型之一。把这套题搞透,对后来刷大厂笔试题的帮助是非常直接的。

2. Bridging Signals:LIS二分的教科书级应用

2.1 题意拆解:为什么不相交连接就是LIS

Bridging Signals 的题目背景大致是:电路板两侧各有一排端口,左侧第 i 个端口需要连接到右侧某个端口。连接线不能交叉,问最多能保留多少条连接线。这个背景可以换成信号线、网线、甚至城市之间的桥梁,核心都一样。

关键点是“不能交叉”。假设左侧端口按顺序编号为 1 到 n,右侧端口按顺序编号为 1 到 n。如果左侧端口 i 和 j(i < j)分别连接到右侧端口 a[i] 和 a[j],那么这两条线不相交的条件就是 a[i] < a[j]。换句话说,我们要在数组 a 里选出尽可能多的下标,使得这些下标对应的值严格递增。这就是最长上升子序列(LIS),而且这里必须是严格递增,因为一个右侧端口不会同时接两条线。

我第一次做这道题时,并没有马上反应过来是 LIS,而是一头扎进去想各种贪心策略。后来复盘才意识到,这其实就是“把几何交叉问题映射为序列递增问题”的一次典型建模。建议你自己动手画一画两条线交叉的情况,很快就能看出 a[i] 和 a[j] 的大小关系和交叉的对应关系。

2.2 从O(n²)到O(n log n):d数组的单调性

如果只看数据和题意,最直接的解法就是 O(n²) 的 DP:dp[i] 表示以 a[i] 结尾的 LIS 长度,转移时枚举 j < i 且 a[j] < a[i]。但 USACO 的黄金组题很少会给你这种轻松过去的数据范围。n 一旦到 10^5,O(n²) 直接超时。所以必须用二分优化。

二分优化 LIS 的核心是维护一个数组 d,其中 d[k] 表示长度为 k 的上升子序列的最小末尾值。你不需要知道这个序列具体长什么样,只需要知道“长度为 k 的子序列,末尾最小可以是多少”。d 数组是单调递增的,因为如果存在长度为 k+1 的子序列,它的最后一个元素一定大于长度为 k 的子序列的最小末尾,否则就可以把更小的值拼到长度为 k 的子序列上。

于是处理每个 x 时,只需要在 d 里找到第一个大于等于 x 的位置,把它替换成 x。如果 x 比所有 d 都大,就说明可以扩展出更长的子序列,直接把 x 追加到 d 末尾。这个过程中 d 始终保持有序,所以可以用 lower_bound 二分查找。最终 d 的长度就是 LIS 长度。

2.3 代码实现与必须注意的lower_bound/upper_bound区别

代码本身非常短,但越短的代码越容易在细节上出错。

#include <bits/stdc++.h> using namespace std; int main() { int n; cin >> n; vector<int> a(n); for (int i = 0; i < n; i++) cin >> a[i]; vector<int> d; for (int x : a) { auto it = lower_bound(d.begin(), d.end(), x); if (it == d.end()) { d.push_back(x); } else { *it = x; } } cout << d.size() << "\n"; return 0; }

这里最容易踩的坑就是 lower_bound 和 upper_bound 的选择。lower_bound 找到的是“第一个 >= x 的位置”,适合严格递增的 LIS。如果题目要求的是非递减子序列,就要改成 upper_bound,找“第一个 > x 的位置”。Bridging Signals 的场景里,同一右侧端口不能重复连接,所以值不会相等,用 lower_bound 是对的,但很多人在做其他 LIS 变形题时下意识沿用,就会出错。

另外一个小细节是初始化。d 一开始是空数组,不需要预先塞一个负无穷进去。因为 lower_bound 在空数组上返回 end(),会直接 push_back。这个写法不仅简洁,也天然支持从空序列开始构建。

3. Cow Relays:矩阵快速幂重写最短路

3.1 为什么要限制“恰好K条边”

Cow Relays 的题目背景是奶牛接力赛,要求从起点 S 到终点 E 恰好经过 K 条边的最短路。注意是“恰好”,不是“不超过”。这个限定直接把普通的最短路算法排除掉了。

Dijkstra 和 Bellman-Ford 的核心思路都是松弛操作,它们求的是“在不超过某些步数限制下”的最短路,或者干脆是最短路。当你要求“恰好 K 条边”时,路径上完全允许绕圈。比如从 A 到 B 恰好走 3 条边,完全可以走 A-C-A-B,只要边数凑够。普通最短路算法不会考虑这种绕圈的路径,因为绕圈会增大距离,但在“恰好 K 条边”的约束下,绕圈可能是唯一选择。

K 能到 10^6 级别,所以不可能把 K 步逐层展开。这时候必须想到矩阵乘法。这里的矩阵不是用来求路径数量的,而是用来做“最短路递推”的。

3.2 广义矩阵乘法:加法替换为取min

如果你熟悉图论里的路径计数,会知道邻接矩阵的 K 次幂,在普通矩阵乘法意义下,就表示恰好走 K 条边的路径数量。现在我们把“乘加”运算换成“加取 min”,也能得到类似效果。

设矩阵 A 和 B 都是 n×n 的方阵,A[i][j] 表示从 i 到 j 恰好走 x 条边的最短路,B[i][j] 表示从 i 到 j 恰好走 y 条边的最短路。那么从 i 到 j 恰好走 x+y 条边,路径一定经过某个中间点 k,前半段走 x 条边到 k,后半段从 k 走 y 条边到 j。总距离就是 A[i][k] + B[k][j],对所有 k 取最小值。所以定义广义矩阵乘法:

C[i][j] = min(A[i][k] + B[k][j] for k in 0..n-1)

这个 C 就是恰好走 x+y 条边的最短路矩阵。有了这个“乘法”,求 K 次幂就可以用标准的快速幂。注意这里的单位矩阵不再是普通意义的单位矩阵。在“加取 min”的运算里,单位矩阵应该满足 E * A = A * E = A,所以 E[i][i] = 0,非对角线为 INF。因为从 i 到 i 走 0 条边,最短路就是 0,从别的点走 0 条边到 i 是不可能的,距离为 INF。

3.3 离散化:把稀疏大标号变成紧凑矩阵

Cow Relays 的一个特别之处在于,顶点的标号可能很大(比如 1000 以内),但实际的边数和有效顶点数很少。矩阵乘法的复杂度是 O(n^3 log K),如果 n 直接取 1000,1000^3 是 10 亿,即使 log K 只有 20,也完全跑不动。所以必须先离散化。

你只需要把所有出现过的顶点收集起来,按顺序映射成 0 到 m-1 的编号。m 最多也就是边数的两倍。比如 T 条边最多涉及 2T 个点,如果 T 是 100,m 最多也就 200,O(m^3 log K) 就完全可行了。离散化这一步在竞赛题里经常被忽略,一旦忽略,后面矩阵开多大都不对,甚至可能因为数组越界出稀奇古怪的错。

3.4 代码注释级别的实现细节

下面是我觉得比较稳妥的写法。注意 INF 要开足够大,不然加法溢出之后会出现负数,然后把答案搞成 0 或者更小的值。

#include <bits/stdc++.h> using namespace std; const long long INF = 0x3f3f3f3f3f3f3f3fLL; int K, T, S, E; map<int, int> id; struct Matrix { int n; vector<vector<long long>> a; Matrix(int _n, bool identity = false) : n(_n), a(_n, vector<long long>(_n, INF)) { if (identity) { for (int i = 0; i < n; i++) a[i][i] = 0; } } Matrix operator*(const Matrix& other) const { Matrix res(n); for (int i = 0; i < n; i++) { for (int k = 0; k < n; k++) { if (a[i][k] == INF) continue; for (int j = 0; j < n; j++) { if (other.a[k][j] == INF) continue; res.a[i][j] = min(res.a[i][j], a[i][k] + other.a[k][j]); } } } return res; } }; int get_id(int x) { if (!id.count(x)) { int sz = id.size(); id[x] = sz; } return id[x]; } int main() { cin >> K >> T >> S >> E; vector<tuple<int, int, long long>> edges; for (int i = 0; i < T; i++) { long long w; int u, v; cin >> w >> u >> v; int x = get_id(u); int y = get_id(v); edges.push_back({x, y, w}); } Matrix base(id.size()); for (auto& [u, v, w] : edges) { base.a[u][v] = min(base.a[u][v], w); base.a[v][u] = min(base.a[v][u], w); } Matrix res(id.size(), true); int b = K; while (b > 0) { if (b & 1) res = res * base; base = base * base; b >>= 1; } cout << res.a[get_id(S)][get_id(E)] << "\n"; return 0; }

那么这个实现里要注意什么?首先是矩阵乘法的三重循环顺序。我习惯写成 i-k-j 的转置优化形式,先枚举中间点 k,这样的好处是能提前用 if 跳过 INF 的计算,减少不必要的加法和 min 操作。其次是单位矩阵的初始化,identity 参数一定不能漏。我之前就吃过一次亏,把单位矩阵初始成全是 INF,结果不管怎么乘,答案都是 INF,整个程序跑出来是垃圾值,排查了很久才发现是单位矩阵写错了。

还有一个比较隐蔽的坑:如果 K = 0,你直接输出 0 或者 res 对角线的值即可。但通常题目不会给 K = 0,这里不需要特别处理,不过心里要清楚单位矩阵在这种情况下是正确的。另外,题目给的是无向图还是无向边?Cow Relays 是无向边,所以 base 矩阵要同时更新两个方向。如果是单向边,只更新一个方向就行。

4. The Cow Run:区间DP的经典模型

4.1 原题模型与损失计算方式

The Cow Run 是这三道题里最需要“想清楚 dp 状态”的题目。大概场景是:牛从谷仓跑出来,站在一条直线道路上的不同位置,位置坐标可能是负数也可能是正数。你从原点出发,速度是每单位时间走一个单位长度,走到某头牛的位置就能把它抓回来送进围栏。每头牛在没被抓住之前,每单位时间都会产生 1 的损失,你需要安排抓捕顺序,使总损失最小。

理解损失的计算方式是关键。如果你在时刻 t 才抓到某头牛,那么这头牛从开始到被抓一共产生了 t 的损失。所有牛的损失加起来就是总损失。换一种角度看,假设某个时间段长度是 Δt,这段时间内还有 x 头牛没被抓,那么这段时间产生的损失就是 x × Δt。这个“当前未抓数量 × 移动时间”的思考方式,是后面区间 DP 转移方程的核心。

4.2 已抓牛一定是连续区间:关键性质

思考最优策略的时候,第一个要证明的性质就是:按坐标排序后,任意时刻已经抓过的牛一定构成一个连续区间。如果你已经抓了坐标位置 1 和 5 的两头牛,没有抓 3,那么你一定在从 1 去 5 的路上路过 3 却无视了它。既然都已经走到它旁边了,先抓它并不会增加额外行程,反而能让后续的损失少一点。所以最优解的已抓集合在排序后的坐标上必须是连续的。

这个性质把状态空间从“任意子集”压缩成了“区间 + 位置”,动态规划才有了可行性。如果没有这个观察,直接枚举抓牛顺序是 O(n!),拿不到任何分数。

4.3 状态设计与转移方程推导

设所有牛的位置为 x[0] ≤ x[1] ≤ ... ≤ x[n-1],排序后处理。dp[l][r][0] 表示已经抓完区间 [l,r] 内所有牛,并且你现在站在左端 l 处的最小总损失。dp[l][r][1] 表示抓完 [l,r] 后站在右端 r 处的最小总损失。

初始化时,先抓某头牛 i,从原点 0 走到 x[i],期间所有 n 头牛都在产生损失,所以 dp[i][i][0] = dp[i][i][1] = n * abs(x[i])。

转移时,从小的区间 [l,r] 往大的区间扩展。如果当前站在 l,那么下一步合理的行动是往左走到 l-1;如果当前站在 r,下一步合理行动是往右走到 r+1。为什么不能从 l 一下子跳到 r+1?因为那会越过已经访问过的区间,白白增加移动距离,而且先抓 l-1 或 r+1 并不会影响后续最优解的可行性。这是区间 DP 里很常见的剪枝。

从 [l,r] 扩展到 [l-1,r] 时,移动距离是 x[l] - x[l-1],移动前未抓牛数量是 n - (r - l + 1)(因为区间 [l,r] 里都被抓了),所以新增损失为 (n - (r - l + 1)) * (x[l] - x[l-1])。类似地,扩展到 [l,r+1] 时,新增损失为 (n - (r - l + 1)) * (x[r+1] - x[r])。

写成转移方程就是:

  • dp[l-1][r][0] = min(dp[l-1][r][0], dp[l][r][0] + (n - (r-l+1)) * (x[l] - x[l-1]))
  • dp[l][r+1][1] = min(dp[l][r+1][1], dp[l][r][1] + (n - (r-l+1)) * (x[r+1] - x[r]))

最终答案是 min(dp[0][n-1][0], dp[0][n-1][1])。

4.4 完整代码与分析

#include <bits/stdc++.h> using namespace std; int main() { int n; cin >> n; vector<long long> x(n); for (int i = 0; i < n; i++) cin >> x[i]; sort(x.begin(), x.end()); const long long INF = 1LL << 60; vector<vector<vector<long long>>> dp(n, vector<vector<long long>>(n, vector<long long>(2, INF))); for (int i = 0; i < n; i++) { dp[i][i][0] = dp[i][i][1] = 1LL * n * llabs(x[i]); } for (int len = 1; len < n; len++) { for (int l = 0; l + len < n; l++) { int r = l + len; long long remain = n - (r - l + 1); if (l > 0) { dp[l - 1][r][0] = min(dp[l - 1][r][0], dp[l][r][0] + remain * (x[l] - x[l - 1])); } if (r + 1 < n) { dp[l][r + 1][1] = min(dp[l][r + 1][1], dp[l][r][1] + remain * (x[r + 1] - x[r])); } } } cout << min(dp[0][n - 1][0], dp[0][n - 1][1]) << "\n"; return 0; }

这里有一个非常容易错的地方:remain 的取值。转移发生在从 [l,r] 扩大到 [l-1,r] 或 [l,r+1] 时,移动前还有 n - 区间长度 头牛没有抓,其中区间长度就是 r-l+1。如果你写成 n - (r-l+2),那就少算了一头牛在移动期间的损失,结果会偏小。这个细节通常是公式推错的重灾区。

另外,为什么 dp[l][r][0] 不能扩展到 dp[l][r+1][0]?因为站在 l 的时候要走到 r+1,必须穿过整个 [l,r] 区间,中间的距离是 x[r+1] - x[l],但这个移动过程并没有抓任何新牛,纯粹浪费时间。最优策略里不会出现这种跨区间移动。代码里没有保留这条转移,是正确的剪枝,也是这个经典模型时复杂度能维持在 O(n²) 的原因。

5. 实战总结:做真题最容易栽的五个细节

5.1 数据范围、INF设置与long long

2005 年机器的内存和现在没法比,但现在的 OJ 对数据范围的要求一样严格。Cow Relays 里 K 能到 10^6,路径长度不开 long long 肯定溢出。很多人 INF 喜欢开 0x7f7f7f7f 或者 1e9,这个值在加法之后可能还是不够大,比如 INF + INF 变成 2e18 超出 int。我现在的习惯是统一用 0x3f3f3f3f3f3f3f3fLL 或者 1LL << 60,反正不管怎么加都不会溢出。性质上,INF 必须大于所有可能出现的真实路径长度,否则两个 INF 一加反而变成一个比真实答案还小的数,整个程序就会输出错误结果。

5.2 恰好K步 vs 至少K步

这是 Cow Relays 最容易和多源最短路、动态规划题混淆的地方。如果题目说的是“最多 K 步”,你可以用 Bellman-Ford 或者 DP 跑 K 轮松弛。但“恰好 K 步”就必须用矩阵快速幂。一个快速判断技巧是:看 K 的范围。K 小到几百,可以用 DP 或者 Bellman-Ford;K 大到 10^6,基本就是矩阵快速幂的节奏。

我还想提醒一个细节:有些题目在“恰好 K 步”的基础上允许路径上走重复边,Cow Relays 就是这样。如果题目不允许重复边,那模型完全不一样,矩阵快速幂就不适用了,你可能需要整理边状态或者用更复杂的匹配。读题时一定要看清“可以重复经过节点和边”这类描述。

5.3 区间DP的初始化陷阱

The Cow Run 的初始化,很多人会写成 dp[i][i][0] = 0,认为一开始在原点,直接被抓的牛就在自己脚下。实际上,起点是 0,第一头牛的位置是 x[i],你必须走完这段距离才会到它那里。途中的所有牛都在损失,所以初始值必须是 n * abs(x[i]),而不是 0。

还有人在排序后忘了做绝对值函数,在坐标有正有负时直接乘,导致出现负的损失,dp 值越更新越小,输出一个乱七八糟的负数。每写一步,心里都要过一遍单位:位置单位 × 时间单位 = 损失单位,方向无关。

5.4 二分LIS的严格递增/非递减问题

Bridging Signals 的解法里,lower_bound 是严格递增,upper_bound 是非递减。这个区别在笔试里经常被出题人拿来埋坑。比如 LeetCode 300 求最长递增子序列,标准解法用 lower_bound;但如果题目改成“最长非递减子序列”,就必须换成 upper_bound。刷题时不要只背代码,要理解 d 数组的替换逻辑:替换第一个大于等于 x 的位置,保证长度相同的子序列末尾尽可能小;如果允许相等,就应该替换第一个大于 x 的位置,让相等值也能被追加到当前长度的子序列后面。

5.5 快速幂单位矩阵

矩阵快速幂的单位矩阵不是所有 1 的对角线,而是在当前广义乘法意义下的恒等元素。普通矩阵乘法里,单位矩阵对角线是 1;在“加取 min”的广义乘法里,单位矩阵对角线是 0,其他是 INF。这个点很多人第一次接触时想不明白,建议自己手动算一个 2×2 的例子,感受一下 E * A = A 的过程。想通了,Cow Relays 的代码就心里有底了。

6. 从黄金组到笔试真题:这套题的价值延伸

6.1 大厂笔试出现过的同款考点

这几年我在各路笔试真题解析里经常看到这三类题的影子。LIS 二分优化的变体几乎每个月都能遇到,题目披着“最长递增股票序列”“快递配送顺序”等各种外衣。区间 DP 就更常见了,某公司笔试考过“配送员从原点出发取件,未取件每分钟产生等待成本,求最小总等待时间”,这正是 The Cow Run 的换皮。矩阵快速幂的偶现率比前两个低,但只要出现,往往就是压轴题,因为涉及离散化 + 广义矩阵乘法 + 快速幂三个步骤,一步都不会就是零分。

USACO 黄金组之所以适合作为笔试训练素材,是因为它恰好覆盖了“建模 + 优化 + 边界处理”这三层能力。铜组银组题往往只有建模,白金组题又过于复杂,笔试通常到不了这个深度。黄金组可以说是和互联网公司笔试难度最接近的一个档位。

6.2 刷题顺序与复盘建议

如果你现在还拿不稳这三道题背后对应的算法,我的建议是不要直接硬啃,先按顺序过一遍基础:先刷三道普通的二维 LIS 题,再做一轮 bellman-ford 和快速幂,最后把区间 DP 的经典题整理成一个小专题。等到这些概念都建立起来,再回到这组 2005 年 11 月的真题,你会发现每一道题都能在二十分钟内独立想出来。

复盘时最重要的是记录“卡住的那一步”。比如 Cow Relays 你可能会卡在“为什么矩阵乘法能用于最短路”,The Cow Run 你可能会卡在“为什么不能从 l 跳到 r+1”。把这些卡点写下来,而不是只记一句“用矩阵快速幂过了”,这是最有价值的笔记。我自己刷完这套题之后,在面试里遇到类似问题时,都会下意识先想一想要不要用区间 DP 或者矩阵快速幂,反应速度比纯刷专题快不少。

6.3 三值排序这类基础题与黄金组的关系

有朋友问我为什么要做题从三值排序或者一些基础模拟题开始。USACO 经典的三值排序(Sorting a Three-Valued Sequence)适合用来练计数分析和环的分解,这种思维在处理黄金组题时依然有用。但要注意,基础题锻炼的更多是“把流程拆清楚”的能力,黄金组题锻炼的则是“在约束条件下设计算法”的能力。两者的层级不同,但并不是完全割裂的。如果你三值排序的计数都写不明白,那 The Cow Run 的 dp 状态设计大概率也会卡住。先保证基础题随手能过,再来挑战 2005 年 11 月这批题,是比较现实的上手路径。

我个人刷完这组题最大的体会是:这三道题没有一个用到冷门技巧,但每一道题的翻车点都在你自以为“懂了”的地方。LIS 的二分边界、矩阵幂的单位矩阵、区间 DP 的初始化,任何一个细节漏掉,整个程序都会挂得莫名其妙。把这套题当成面试前的算法体检再合适不过——能全部一次性 AC,说明你的基础已经很扎实。如果中途卡了,也别灰心,把每个卡点记录清楚,过两周重新做一遍,效果比连续刷十道同类题还要好。

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

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

立即咨询