P3369【模板】普通平衡树复盘:01Trie 维护有序多重集
P3369 普通平衡树的一种非常规做法复盘:不使用 Treap / Splay,而是用 01Trie 维护有序多重集合。
P3369 普通平衡树的一种非常规做法复盘:不使用 Treap / Splay,而是用 01Trie 维护有序多重集合。
这篇文章记录我做 洛谷 P1637 三元上升子序列 和 SPOJ INCSEQ Increasing Subsequences 的过程,以及中途无意间发现的一套可以 通解“长度为 k 的严格上升子序列计数” 的模板。
大致路线:
信息提炼:
目标是统计三元组
(i,j,k)s.t. i<j<k, ai<aj<ak朴素 O(n3) 显然不行;
即使是 “枚举中点 j,然后往左往右扫” 的 O(n2) 也过不了。
所以需要一个 O(nlogn) 级别的写法。
很多三元上升子序列的题解会强调:
我事先并不知道这种题解里的标准做法,没有这么写,而是直接按“子序列的长度”来分层维护:
从左到右扫描下标 pos,当前值为 a[pos] = p 时,分三步:
这背后的数学形式可以抽象成两组函数:
对当前值 p(离散后下标为 idx),有:
长度为 3 的新增数量f2(p)f1(p)=u<p∑f2(u)+=u<p∑f1(u)+=1整体答案就是所有“长度为 3 的新增数量”的累加。
问题变成:如何高效支持“按值域求 ∑u<pfℓ(u)”——典型的前缀和 + 单点更新场景,直接上树状数组。
因为 ai 值域不小,直接拿它做 BIT 下标不稳妥,先做离散化:
a[i] 放进 disperse;a[i] 找到它在 disperse 中的下标(从 1 开始),记作 idx。之后所有树状数组下标都用这个 idx。
代码中我开了两棵树:
cnt_opdouble_pair于是:
query(idx - 1, cnt_op);query(idx - 1, double_pair)。对每个 p,离散下标 idx,按顺序执行:
1 | ans += query(idx - 1, double_pair); |
1 | update(idx, query(idx - 1, cnt_op), double_pair, (ll)disperse.size()); |
1 | update(idx, 1, cnt_op, (ll)disperse.size()); |
整个过程是从左到右扫描、从“长度 2”再到“长度 1”更新,看上去好像会担心“当前元素更新的结果被自己读到”。
但注意:
query 用的是 idx - 1;query(idx - 1) 只会访问 index < idx 的位置;update(idx, ...) 写的是 index ≥ idx 的节点。所以,无论是二维(P1637 的 f1/f2)还是推广到 k 维(后文),从前往后滚完全不会读到当前元素刚写入的那一格,不存在污染问题。
代码完全按我自己的写法保留,用
vector + 离散化 + 两棵 Fenwick。
1 | #include<bits/stdc++.h> |
(一些做题时的草稿)
到这里,P1637 这道 k=3 的问题就解决了。下面进入本文的重点:如何把这套写法推广到任意 k,并整理成模板。
这一部分主要做三件事:
先把 P1637 的写法抽象出来。
对离散后的值域 [1,m],对所有长度 ℓ≥1,定义
fℓ(v)=长度为 ℓ,结尾值离散下标为 v 的严格上升子序列个数在 P1637 中:
cnt_op 这棵树;double_pair 这棵树;ans,但不显式存 f3(v)。对当前元素值 p(下标 idx),转移可以统一写成:
如果我们愿意把 f3 也显式地存下来,那么:
f3(p)+=u<p∑f2(u)⇒Ans=v=1∑mf3(v)于是自然得到一个对于任意 ℓ≥2 的统一公式:
fℓ(p)+=u<p∑fℓ−1(u)这就是“按长度分层做 DP,按值域用 Fenwick 求前缀和”的核心结构。
对于一般的“长度为 k 的严格上升子序列计数”,我们希望在处理完所有元素之后得到:
Answer=v=1∑mfk(v)转移规则推广为:
⎩⎨⎧f1(v)+=1fℓ(v)+=u<v∑fℓ−1(u),2≤ℓ≤k每一层对应一个 Fenwick 树:
第 ℓ 层的 Fenwick 维护 fℓ(v) 在值域上的前缀和,因此
u<v∑fℓ−1(u)=Fenwick_ℓ−1.query(idx−1)如果换成“数组写法”的 DP 形式,设
dp[ℓ][v]=fℓ(v),那么同样的转移可以写成:
⎩⎨⎧dp[1][v]+=1,dp[ℓ][v]+=u<v∑dp[ℓ−1][u],2≤ℓ≤k.再用 Fenwick 把右侧的“前缀和”这一项压缩到 O(logm) 即可。
注意上面的循环顺序(对应我的 INCSEQ 代码):
1 | for (ll p: a) { |
和很多 DP 不同,这里完全不需要从 k → 1 逆序,原因有两点:
len,我们读取的是上一层 dp_tree[len - 1],p 的循环中,还没有被更新过;query(idx - 1, dp_tree[len - 1]),idx 及其后的节点上,Fenwick 树的结构保证:
update(idx, ...) 只写 index ≥ idx 的节点;query(idx - 1) 只读 index ≤ idx - 1 的节点。因此:
从前往后 len=2..k 滚动是绝对安全的,每一层用到的永远都是“上一层在当前元素之前的状态”。
SPOJ INCSEQ - Increasing Subsequences
题目大意:给定 n、k 和一个长度为 n 的序列,统计长度恰好为 k 的严格上升子序列个数,对 MOD = 5,000,000 取模。
约束大致为:
与我们抽象出的模型完全一致,非常适合作为模板题。
结合我自己的 AC 代码,这里的关键点有几个:
仍然通过 coord 进行排序 + 去重,把原始值映射到 [1,size]:
1 | sort(coord.begin(), coord.end()); |
这样 Fenwick 的大小只需与 size 相关,而不受原值域限制。
使用一个二维数组:
1 | vector<vector<ll> > dp_tree(k + 5, vector<ll>(size + 5, 0)); |
这里的含义是:
dp_tree[len] 是“长度为 len 的那一层 Fenwick 树内部数组”;dp_tree[len][pos] 就是 fℓ(v) 在树状数组上的内部节点。配合 update / query 函数,这就形成了 k 层树状数组结构。
代码里所有加法都通过
1 | tree[i] = (tree[i] + val) % MOD; |
保持在 [0,MOD) 内,防止溢出;
同时题目需要对 5,000,000 取模,这里直接在 Fenwick 的每一步更新里处理掉。
对于每个元素 p:
len = 2..k 做一次 query + update,每次是 O(logsize);len = 1 做一次 update。整体时间复杂度:
O(n⋅k⋅logn)在 n=104,k≤50 的条件下是完全没压力的。
这份代码是我在 SPOJ 上 AC 的版本,用的正是上面抽象出的模板思想。
1 | #include<bits/stdc++.h> |
(另一些做题时的草稿)
用一句话概括这篇文章要留下的东西,就是这组状态转移:
⎩⎨⎧f1(v)+=1,fℓ(v)+=u<v∑fℓ−1(u),2≤ℓ≤k.如果换成数组写法,设
dp[ℓ][v]=fℓ(v),则等价的 DP 形式是:
⎩⎨⎧dp[1][v]+=1,dp[ℓ][v]+=u<v∑dp[ℓ−1][u],2≤ℓ≤k.再用 Fenwick 把右侧的「前缀和」这一项压缩到 O(logm),就得到一套:
P1637 是这套模板在 k=3 情况下的“特例写法”;
INCSEQ 则是把这个结构完整展开的一道标准模板题。
后面如果再遇到“长度为 k 的严格上升子序列”相关题目,可以直接在这份代码基础上做修改和扩展,而不需要重新从头推思路。
这一次的 CF1065 C1/C2,是一对非常具有代表性的 XOR 博弈题。
在比赛当时,这两题看上去像是在操作数组、模拟交换,但真正的本质来自一个非常稳定的结构:
TC1 是单 bit 游戏,C2 是多 bit 游戏,但二者的本质高度统一,甚至 C2 可以视为 C1 的自然推广。
文章会以“复盘 + 结构化分析”的方式来讲:
期间也会穿插 ASCII 示意图,帮助理解关键下标、最高有效位等结构。
下面进入正文。
给定两个长度为 n 的数组 a 与 b,其中所有元素均为 0 或 1。
游戏共进行 n 回合:
i 为奇数,则由 Ajisai 操作;i 为偶数,则由 Mai 操作。在第 i 回合,操作者可以选择:
a[i] 与 b[i],或游戏结束后,评分如下:
a[1] ⊕ a[2] ⊕ ... ⊕ a[n]b[1] ⊕ b[2] ⊕ ... ⊕ b[n]若分数不同,分数大的获胜;若分数相同,则为平局。
任何一次交换都只是在同一个下标 i 位置交换 a[i] 与 b[i]。
这种操作不会改变 a 与 b 这两个序列中全部元素的集合,因此:
T = a 全体 ⊕ b 全体
在整个游戏过程中保持不变(这是整个 C1/C2 的基础结构)。
同时还成立:
T = Ajisai_score ⊕ Mai_score
这意味着最终的胜负情况只由 T 决定。
若 T = 0
→ 两人的最终 XOR 完全相同
→ 游戏必为平局
若 T = 1
→ 两人的最终 XOR 不可能相同
→ 必然分出胜负
这一步是 C1 的基础,也是后续 C2 的关键出发点。
接下来进入 C1 的错误思路复盘。
比赛时我最初写出的思路,是基于“统计差异所在的奇数位和偶数位数量”来判断谁能获胜:
如果在 奇数位(Ajisai 能操作的位置)出现更多 a[i] != b[i] 的情况
→ Ajisai 更能“影响局势”,认为 Ajisai 会赢
如果在 偶数位(Mai 的回合)出现更多差异
→ Mai 会赢
如果数量相同,则认为是平局
这是一个看似合理、实际上完全错误的判断。
1 | #include<bits/stdc++.h> |
根本问题在于:
XOR 的胜负由唯一的关键下标决定,不由“数量”决定。
在 C1 中,所有元素都是 0/1,若整体异或 T = 1,说明最终两人的分数一高一低,而大小关系由“最后一个能翻转该位的下标”决定。
也就是说:
XOR 的比较不是累计效应,而是“最后一手效果”。
为了更直观地说明问题,我们加入 ASCII 示意图。
1 | i = 9 a=1 b=1 |
可以看到:
i = 7 这里能动手,后面已无下标能覆盖它因此:
无论前面有多少差异位,都被这个 最后的差异位 所覆盖。
因为 a[i] != b[i] 是否出现多次完全不重要。
决定 XOR 大小关系的不是次数,而是:
最后一个能改变哪一位的人是谁。
在 C1 中,这个最后位置就是“最后一个差异点”。
由于 XOR 是按位运算,且 C1 只有一位(只有 0/1),因此:
a[i] != b[i] 在 i = k 处是最后一次出现1 还是 0从而直接确定胜负。
接下来进入 C1 的正确解法。
C1 的核心在于:
当整体异或 T = 1 时,最终异或的大小完全由 最后一个能翻转该位的位置 决定。
我们已经在错误分析中看到:
只要知道差异的“最后一个下标”,就能判断胜负。
现在来严格说明这一结构。
在 C1 中,所有元素都是 0 或 1。
最终的分数为:
Ajisai_score = a[1] ⊕ ... ⊕ a[n]Mai_score = b[1] ⊕ ... ⊕ b[n]因为 T = Ajisai_score ⊕ Mai_score 且 T = 1,
两者的分数必然一为 0、一为 1。
问题变为:
谁能决定某个位置的值在最终 XOR 中是否被计为
1?
对于序列 a:
最终 a 的 XOR = (((a[1] ⊕ a[2]) ⊕ a[3]) ... ⊕ a[n])
如果我们从左到右分析:
于是结论自然形成:
在 C1 中,最后一个
a[i] != b[i]的位置,是唯一有决定权的位置。
下面是完整示意图(同上,但放在正确解法体系中):
1 | i = 9 a=1 b=1 |
7 是最后一个差异点a[7] 是否变为 1 或 0从而决定最终自己的 XOR 是否为 1
→ 决定胜负。
若最后的差异下标是偶数,则由 Mai 控制。
所以,在 C1 中:
a[i] != b[i] 的下标 iT = 0 → 平局i 为奇数 → Ajisai 胜i 为偶数 → Mai 胜这是 C1 的完整正确解法。
说明:
下方给出的 AC 代码是我在比赛现场写出的实现方式:
逻辑正确,但偏向个人习惯,与本文推导出的理论模型略有差异。
在后续 C2 中,我会给出完全按本文结构写出的“规范解法版本”。
1 | #include<bits/stdc++.h> |
这一题是 C1 的加强版。
给定两个长度为 n 的数组 a 与 b,满足 0 ≤ a[i], b[i] ≤ 10^6。
游戏仍然进行 n 回合:
i 为奇数,则该回合由 Ajisai 操作;i 为偶数,则该回合由 Mai 操作。在第 i 回合,轮到的玩家可以选择:
a[i] 与 b[i],或者游戏结束后,得分为:
a[1] ⊕ a[2] ⊕ ... ⊕ a[n]b[1] ⊕ b[2] ⊕ ... ⊕ b[n]分数更大者获胜,若分数相同则为平局。
与 C1 不同之处在于,这里 a[i] 和 b[i] 不再局限于 0/1,而是可以达到 10^6,也就是包含了更多二进制位,T 不再只可能是 0 或 1,而是一个多 bit 的整数。
和 C1 完全一样,所有操作只在同一位置的 a[i] 与 b[i] 之间做交换,不会引入新数或删除旧数,因此:
T = a 全体 ⊕ b 全体
在整个游戏过程中仍然是一个不变量。
并且依然有:
T = Ajisai_score ⊕ Mai_score
所以:
若 T = 0
→ 两人的最终得分完全相同
→ 游戏必为 Tie
若 T ≠ 0
→ 两人的最终得分一定不同
→ 必分胜负
到这里为止,C2 与 C1 的结构是同一套骨架:
只要整体异或为 0,游戏就没有悬念,直接平局。
当整体异或不为 0,问题就变成:谁能把最终的数值拉大到对自己有利。
区别在于:C1 中我们只需要处理一个 bit(0/1),而 C2 中要面对的是一个多 bit 整数。
当 T ≠ 0 时,T 是一个多 bit 的整数,例如:
T = 0010 1000 0100₂
这意味着在多个 bit 上,Ajisai 与 Mai 的最终结果存在差异。
但是,对于两个整数的比较,有一个非常重要的事实:
两个整数的大小,只由它们在“最高一个不同的二进制位”上的值决定。
也就是说:
Ajisai_score 与 Mai_score 在最高一个不同的 bit1、谁是 0,谁就赢而由于:
Ajisai_score ⊕ Mai_score = T
那么 T 在某一 bit 上为 1,就说明双方在这一位上必然不同。
特别地,在 T 的最高有效位(记作 msb)上,两人的得分在该位必然一高一低,并且这一位决定最终大小。
用一个 ASCII 示意图表示 T 的 bit 分布(示意):
1 | bit11 bit10 bit9 ... bit3 bit2 bit1 bit0 |
在 bit11 这个位置上,T 为 1,代表:
Ajisai_score 与 Mai_score 在 bit11 这一位上不相同bit11 是它们“从高到低第一个不相同的位”因此:
bit11 上取 1,谁就拥有整个数值比较上的优势bit10 以下)不会推翻这条结论换句话说,C2 虽然是多 bit 情况,但在博弈结构上 仍然退化成对 msb 这一位的控制问题:
C2 的本质是:在最高有效位
msb上进行一场 C1 风格的博弈。
后面的分析要做的,就是精确刻画:
谁拥有对这一位的“最后一次操作权”,也就是谁能在这一位上做出最终决策。
i_max我们已经知道:
T 的最高有效位 msb 决定1,谁就赢现在的问题是:
哪些下标
i能影响msb这一位?
谁是最后一个能影响它的人?
在下标 i,交换与否会造成 msb 这一位的翻转,当且仅当:
((a[i] ⊕ b[i]) >> msb) & 1 == 1
这句话的含义是:
a[i] ⊕ b[i] 在 msb 位上为 1a[i] 与 b[i],那么这一位的贡献会发生改变我们称这样的下标为:
“影响 msb 的有效下标”
因为游戏从 1 到 n 顺序进行,越靠后的回合越晚出现。
假设有下面五个可能影响 msb 的下标:
i = 2, 5, 8, 11, 14
真正能决定胜负的只有:
14)换句话说:
i_max = 最后一个能影响 msb 的下标
是本题的唯一关键点。
下面以一个示例说明:
1 | i = 12 (a⊕b 在 msb 位为 1) |
即使 i = 12 和 i = 10 也能影响 msb 位,
但它们最终都被 i = 9 的操作“覆盖”了:
i = 9 的操作者可以决定胜负到这里可以看到,C2 的结构与 C1 完全对应:
| C1 (0/1) | C2(多 bit) |
|---|---|
最后一个 a[i] != b[i] |
最后一个 (a[i] ⊕ b[i]) 在 msb 位上为 1 |
| 决定 XOR 的唯一位置 | 决定最高有效位的唯一位置 |
| 该位置的操作者获胜 | 该位置的操作者获胜 |
可以理解为:
i_max 决定胜负规则非常简单:
若 i_max 为奇数
→ Ajisai 操作
→ Ajisai 可以控制 msb 位置取 1
→ Ajisai 获胜
若 i_max 为偶数
→ Mai 操作
→ Mai 可以控制 msb 位置取 1
→ Mai 获胜
这就是 C2 的最终结论。
把前面的分析收束成一个完整的实现流程,大致可以写成如下步骤:
n,以及数组 a、bT:T ^= a[i]T ^= b[i]T = 0:Tie,因为此时两人的最终分数必然相等T ≠ 0:0 ~ 20 的 bit 范围内,找到 T 的最高有效位 msbi = n ... 1:(a[i] ⊕ b[i]) 的 msb 位是否为 1i 为 i_max,并停止扫描i_max 的奇偶性输出胜负:i_max 为奇数 → Ajisaii_max 为偶数 → MaiT:O(n)msb:bit 扫描,O(log T),在本题中约为常数(约 20)i_max:O(n)O(n)O(n)(存下 a、b)在 n 最多 2 * 10^5 的条件下,这个复杂度完全可以通过所有测试。
1 | #include<bits/stdc++.h> |
整篇文章从 C1(0/1 情况)到 C2(多 bit 情况),实际上围绕的是同一个中心主题:
XOR 的结构性 —— 不变量、按位独立性、最高有效位的决定性
以及顺序博弈中“最后一个能改变关键位的人获胜”。
下面将这套结构完整收束,作为本文的最终总结。
在这两题中,真正起作用的只有 XOR 的三个基础性质:
msb 一个 bit其中最关键的,是第三条:
只要最高有效位差异已定,低位就无法影响最终大小。
因此,XOR 博弈的核心往往是:
找到决定关键 bit 的最后一个位置。
在 C1 中:
0 或 1a[i] != b[i] 的下标决定这是典型的“顺序博弈 + 最后一手胜”的结构。
在 C2 中:
T 是一个多 bit 整数T 的最高有效位 msb 决定因此 C2 的实质是:
在
msb位上跑一遍 C1 的逻辑。
具体表现为:
(a[i] ⊕ b[i]) 在 msb 位上为 1 的最后一个 ii 的操作者拥有决定权和 C1 完全平行。
通过这两题,我们可以抽象出一类常见 XOR 博弈题的解法框架:
msb许多看似复杂的 XOR 博弈,其实都可以被拆解成这种结构。
这篇文章不仅解决了 C1 / C2,也为之后处理此类问题奠定了统一视角:
XOR 本身的逻辑并不复杂,但要真正理解它在博弈中的作用,需要对
“按位独立 + 顺序控制” 有清晰认识。
以上便是本篇的全部内容。
本文复盘洛谷 P1121 环状最大两段子段和 的解题过程。本次复盘的核心算法思路,即 分类讨论 结合 对偶思想,在初版实现中就是正确的。然而,一个由 非空 约束导致的边界情况,使得初版代码无法通过全部测试点。本文将详细分析该边界情况的触发机理,并展示如何通过一行代码进行修复,将80分的实现修正为100分的最终解。
提炼题目要素:
a[1] 和 a[n] 相邻。这意味着一个连续子段可以从数组尾部跨越到头部。N最大为2e5,算法的时间复杂度必须是O(N)或O(N log N)。此问题需要在环形结构上求解两段子段的和的最大值。重点在于设计一个线性时间复杂度的算法,以处理 环形结构 和 两段选择 的双重约束。
在环形结构上直接进行动态规划,因其缺少固定起终点而难以定义状态。因此,采用标准方法,将环形问题分解为线性问题进行处理。
对所有可能的两段子段的选择,按其是否跨越 a[1] 和 a[n] 的连接点,可分为两种情况:
a[1]-a[n] 的连接点。a[1...n] 上寻找最大两段子段和。S1 和 S2 之间必定存在一个分割点。max_L_to_R[i]:存储 前缀区间 a[1...i] 内的最大单段子段和。max_R_to_L[i]:存储 后缀区间 a[i...n] 内的最大单段子段和。i (1 到 n-1),计算 max_L_to_R[i] + max_R_to_L[i+1]。
a[1]-a[n] 的连接点。(选中元素的和) = (数组总和) - (未选中元素的和)。a[1...n] 上,寻找 最小两段子段和。min_L_to_R 和 min_R_to_L 数组,求出链上的最小两段和。数组总和 减去此值,即为情况二的解。
最终答案为 max(情况一的解, 情况二的解)。该算法的时间复杂度为O(N)。
1 | #include<bits/stdc++.h> |
问题根源:
算法的理论模型正确,但代码实现未处理一个由 非空 约束引发的特殊情况。该问题在输入数组 全为负数 的测试点上触发。
错误原因:
case_2() 函数的计算 total_a - min_val 变为 total_a - total_a,结果为 0。case_1() 会正确计算出这个负数的最优解。main 函数在 max(一个正确的负数, 0) 的比较中,会错误地选择 0。0 对应于 未选中全部元素 的情况,即 选中了空集,这与题目的 非空 约束相悖。问题已明确:当 链上最小两段和 == 数组总和 时,case_2() 会产生一个违反约束的无效解 0。
修正的目标是:识别此情况,并确保该无效解不被选为最终答案。
case_2() 函数的返回值。当 total_a == min_val 的条件成立时,函数不返回 0,而是返回一个理论上的极小值 LLONG_MIN。LLONG_MIN。因此,在最终的 max 比较中,case_1() 计算出的合法解会自动取代 case_2() 返回的 LLONG_MIN,从而保证结果的正确性。
1 | #include<bits/stdc++.h> |
实现细节剖析
核心改动位于 case_2() 函数的返回语句:return total_a == min_val ? LLONG_MIN : total_a - min_val;
该行代码使用三元运算符,将对特殊情况的判断和处理封装在函数内部,解决了问题。
健壮性提升AC代码将所有用于初始化的极大/极小值,从硬编码的数值(如1e9)替换为标准库 <climits> 中定义的 LLONG_MAX 和 LLONG_MIN。这使代码不依赖于对题目数据范围的假设,提高了通用性和健壮性。
P.S. 完整手稿
max或min)中必定会被淘汰的值(如LLONG_MIN或LLONG_MAX),从而避免复杂的外部逻辑判断。最近,我解决了一道经典的 环形DP 问题。该问题具有较强的迷惑性,我设计了一个看似正确的线性DP模型,实际上忽略了问题最核心的 环形 约束。本文旨在完整复盘从一个70分的线性解,到一个90分的 错误补丁 解,最终到100分AC的 破环成链 正解的全过程,深入剖析每一步的思维误区与正确建模的关键。
提炼题目要素:
这是一个动态规划问题。核心在于状态的设计。为了存储输入的观赏价值,我使用了一个 Plant_Position 结构体,其中 pos[i].ten, pos[i].twenty, pos[i].thirty 持有位置 i 的相应价值。
为了判断位置 i 的种植是否合法,不仅需要知道位置 i-1 的高度,还需要知道 i-1 相对于 i-2 的趋势,这样才能决定 i 的合法走向。因此,一个三维状态是必要的:dp[i][j][k]。
i:表示正在考虑第 i 棵树。j:表示第 i 棵树的高度。我们用 1, 2, 3 分别代表高度 10, 20, 30。k:表示第 i 棵树的形态。k=0:第 i 棵树是 波谷,即 H[i-1] > H[i],从 i-1 到 i 是 下降 趋势。k=1:第 i 棵树是 波峰,即 H[i-1] < H[i],从 i-1 到 i 是 上升 趋势。dp[i][j][k] 的值,就代表满足以上定义时的最大观赏价值。
最初的方案完全忽略了 环形 约束,将花园视为一个从 1 到 n 的 线性序列。基于上述 dp[i][j][k] 状态,可以推导出所有合法的状态转移方程。
状态转移方程详解:
目标状态: dp[i][1][0] (高度 10 , 波谷/下降)
10 ,前一个位置 i-1 的高度必须是 20 或 30 。同时,为了形成波谷,前一个位置 i-1 必须是波峰(上升趋势,k=1)。dp[i][1][0] = max(dp[i-1][2][1], dp[i-1][3][1]) + pos[i].ten目标状态: dp[i][2][0] (高度 20 , 波谷/下降)
20 ,前一个位置 i-1 的高度必须是 30 。前一个位置 i-1 必须是波峰(上升趋势,k=1)。dp[i][2][0] = dp[i-1][3][1] + pos[i].twenty目标状态: dp[i][2][1] (高度 20 , 波峰/上升)
20 ,前一个位置 i-1 的高度必须是 10 。前一个位置 i-1 必须是波谷(下降趋势,k=0)。dp[i][2][1] = dp[i-1][1][0] + pos[i].twenty目标状态: dp[i][3][1] (高度 30 , 波峰/上升)
30 ,前一个位置 i-1 的高度可以是 10 或 20 。前一个位置 i-1 必须是波谷(下降趋势,k=0)。dp[i][3][1] = max(dp[i-1][1][0], dp[i-1][2][0]) + pos[i].thirty不可达状态:
dp[i][1][1] (上升到最低点) 和 dp[i][3][0] (下降到最高点) 是不可能出现的。这些状态的值应保持为极小值,不参与计算。
该方案的实现直接从 i=1 循环到 n,并在最后取 dp[n] 所有合法状态的最大值作为结果。
1 | #include<bits/stdc++.h> |
n 个位置和第 1 个位置的状态,很可能不满足高低交错的约束,因此该解对于环形问题是 非法的。在提交V1.0后,我意识到了环形约束的问题。此时产生了一个错误的修正思路:先算出线性最优,再检查它是否满足环形约束。
这个思路最初的实现是一个复杂且繁琐的 while 循环:
1 | ans.push_back(dp[n][1][0]); |
这个复杂的 while 最终被我精简为两个 if 语句:
1 | if (pos[1].twenty > pos[1].ten) { |
if 虽比 while 精简了很多,其内在的错误逻辑是相同的。这个 “补丁” 的意图是,从线性DP算出的几个最优候选解中,通过一套逻辑来 推断 并 验证 起点是否合法。
其验证逻辑如下:
无约束情况:如果线性最优解的终点是 dp[n][1][0] ( H=10 , 下降) 或 dp[n][3][1] ( H=30 , 上升),则直接接受。
H[n]=10,则环要求 H[1] > 10,即 H[1] 可以是 20 或 30 。因为有两个选择,代码便假设总能找到一个合法的起点,无需干预。H[n]=30 同理。约束情况 1: 当考虑终点为 dp[n][2][1] ( H=20 , 上升) 的路径时。
H[n-1]=10。为了构成环,H[1] 必须小于 H[n]=20,所以 H[1] 必须是 10。if (pos[1].twenty > pos[1].ten)。这个判断的逻辑是:“如果在起点上,种 20 的观赏价值比种 10 要高,那么一个真正最优的路径,当初在起点时就 不可能 选择种 10 ”。基于这个贪心假设,如果条件成立,就认为这条要求起点为 10 的路径不可能是最优解,于是通过 dp[n][2][1] = 0 将其作废。约束情况 2: 当考虑终点为 dp[n][2][0] ( H=20 , 下降) 的路径时。
H[n-1]=30。为了构成环,H[1] 必须大于 H[n]=20,所以 H[1] 必须是 30。if (pos[1].twenty > pos[1].thirty)。如果种 20 的价值更高,就认为这条要求起点为 30 的路径不可能是最优解,将其作废。
1 | #include<bits/stdc++.h> |
根本性缺陷:
此方案的失败,并非因为 if 语句这个 补丁 本身不够精巧。恰恰相反,它很好地浓缩了前一版 while 循环的意图,是一个逻辑上很优质的补丁。
真正的错误出在算法的 根本设计 上。这种 事后补救 的方法,就相当于把一条通往错误方向的道路修得非常漂亮,但道路再好,也无法到达正确的终点。
其核心谬误在于,它错误地假设了 环形最优解一定包含在线性最优解之中。而事实上,真正的环形最优解,其线性部分可能根本不是最优的。代码只检查了少数几个 线性最优 的候选者,而真正的答案可能在第一次DP时,就因为在线性排列上得分不够高而被直接淘汰,永远没有机会被最后的 if 语句检查。这是一种典型的 局部最优陷阱。
正确的做法是放弃 事后补救,采用处理环形问题的 标准范式——破环成链。
其核心思想是,将一个复杂的环形问题,分解为几个独立的、带有明确起始和结束约束 的线性问题。
具体方案是,通过一个外层循环,强制指定 第 1 棵树 的高度(分三种情况: H=10 , H=20 , H=30 )。对每种情况独立进行一次从2到n的线性DP,并在最后根据固定的起点,对终点 dp[n] 的状态进行合法性筛选。
O(n)。总共进行3次独立的DP,所以总时间复杂度依然是 O(n)。O(n)。
1 | #include<bits/stdc++.h> |
实现细节剖析
该方案的精髓在于,将环形约束从一个 事后检查 的难题,转化为了一个 顶层设计 的分类讨论。在固定了起点后,最后结算时对终点的筛选逻辑如下:
若起点 H[1]=10 (波谷):
H[n] 必须是波峰,且 H[n] > H[1]。dp[n][2][1] ( H=20 , 上升) 和 dp[n][3][1] ( H=30 , 上升)。max(dp[n][2][1], dp[n][3][1])若起点 H[1]=20 (波峰或波谷):
H[n] 是波峰,则 H[n] > H[1];若 H[n] 是波谷,则 H[n] < H[1]。dp[n][3][1] ( H=30 , 上升) 和 dp[n][1][0] ( H=10 , 下降)。max(dp[n][3][1], dp[n][1][0])若起点 H[1]=30 (波峰):
H[n] 必须是波谷,且 H[n] < H[1]。dp[n][1][0] ( H=10 , 下降) 和 dp[n][2][0] ( H=20 , 下降)。max(dp[n][1][0], dp[n][2][0])最终答案就是这三个候选答案中的最大值。这种精确的筛选保证了最终解的合法性。
本题同样由那位BUAA大佬分享。
不久前,我用树状数组解决了洛谷上的逆序对模板题 。因此,当我初见这道 逆序 k 倍对 时,第一反应便是:这一定是模板的变体,核心工具依然是那个熟悉的树状数组。然而,从模板代码到这道题的AC,并非简单的复制粘贴,而是一段充满思考的魔改之旅。本文旨在完整复盘,我是如何基于逆序对模板的思路,一步步识别问题差异、调整关键逻辑,最终将一份模板代码成功改造为本题的解答。
给定一个序列 a₁, a₂, …, aₙ 和一个正整数 k,如果 1 ≤ i < j ≤ n 且 aᵢ > k · aⱼ,我们就将 (i, j) 称作一个逆序 k 倍对。请你计算序列中逆序 k 倍对的个数。
第一行两个正整数 n, k (1 ≤ n ≤ 10⁵, 1 ≤ k ≤ 10)。
第二行 n 个正整数 a₁, a₂, …, aₙ (1 ≤ aᵢ < 2³¹)。
为了提高区分度,对于得分占比 10% 的测试点,我们保证 1 ≤ n ≤ 100。
一行一个非负整数,表示序列中逆序 k 倍对的个数。
1 | 5 2 |
1 | 4 |
首先肯定要从那道经典的逆序对模板题开始分析。它的问题是统计 i < j 且 a[i] > a[j] 的数对。我当时AC的思路是:
a[j]。a[j]。a[j] 本身的信息更新到树状数组中。当时的AC代码如下:
1 | #include <bits/stdc++.h> |
这个框架非常清晰。现在,面对k倍逆序对,唯一的不同点,就是判断条件从 a[i] > a[j] 变成了 a[i] > k * a[j]。
这意味着,我的改造也必须围绕这个变化点展开。当我遍历到 a[j] 时,我需要查询的不再是比 a[j] 大的数,而是比 k * a[j] 大的数。
这个看似微小的变化,却直接影响了算法的基石——离散化。在原模板中,我们只需要对数组 a 中的值进行离散化。但现在,为了能查询 k * a[j] 的相关信息,我们必须将所有可能作为查询边界的 k * a[j] 也一并纳入离散化的范围。
这就是从模板到AC的关键一步:识别出查询目标的变化,并相应地扩大离散化的集合。
我的整个解题过程,可以看作是对逆序对模板代码的一次定向升级。
继承模板框架:
a[i] 的值域依然很大,离散化这一前置步骤也必须保留。定位改造核心:查询目标的变化
贡献 = 总数 - 小于等于a[j]的个数。贡献 = 总数 - 小于等于k*a[j]的个数。k*a[j]就是本次改造需要处理的新元素。升级离散化:
{ a[1], a[2], ..., a[n] }。{ a[1], ..., a[n] } 和 { k*a[1], ..., k*a[n] }。因为树状数组的下标是基于离散化后的排名,我们既需要能更新 a[j] 对应的排名,也需要能查询 k*a[j] 对应的排名。将它们全部放入一个集合中进行离散化,才能建立统一的“度量衡”。微调主逻辑
p (即a[j]):p 离散化后的排名 idx。k*p 离散化后的排名 idx2。ans += cnt - Sum(idx2, c)。这里的 cnt 是已处理元素个数,Sum(idx2, c) 则是利用树状数组查到的、前面出现过且值小于等于 k*p 的元素个数。Update(idx, 1, size, c),将 p 的出现次数记录到树状数组中。这次“魔改”并没有改变算法的根本结构。
时间复杂度:
2n。排序的复杂度依然是 O(n log n)。lower_bound 的操作复杂度仍为 O(log n)。O(n log n),完全可以通过。空间复杂度:
b 和树状数组 c 的大小变为原来的两倍左右,空间复杂度依然是 O(n)。结论:
通过精准地定位差异并对离散化这一关键步骤进行升级,我们成功地将逆序对模板适配到了新问题上,方案高效可行。
最终的AC代码,清晰地保留了逆序对模板的骨架,但在离散化的部分,能明显看到为了适应新规则而做的扩展。
1 | #include <bits/stdc++.h> |
整个改造过程实际只涉及两个关键环节。
模板代码:仅收集原数组中的值。
1 | // 离散化集合 b 只需包含 a 内的值 |
本题代码:同时收集原数值与其 k 倍值。
1 | // 离散化集合 b 必须同时包含 a 内的值及其 k 倍 |
原因:树状数组需要查询 k*p 的排名,因此必须在一开始就将所有可能的 k*p 值纳入离散化,以建立统一的排名体系。
模板代码:查询大于 p 的元素个数。
1 | for(int p : a){ |
本题代码:查询大于 k*p 的元素个数。
1 | for(ll p : a){ |
原因:题目的核心条件从 > p 变为 > k*p,因此计算贡献时,传入 Sum 函数的参数,必须是 k*p 对应的排名,而非 p 的排名。
P.S. 例行在最后放上做题手稿
a[i] > a[j] 变更为 a[i] > k * a[j]。本题由某位BUAA大佬分享。
初见这道图形题时,我被其酷似分形的演变过程所吸引。它看似是一个纯粹的图形模拟题,但随着操作次数 n 的增加,图形的尺寸和复杂性都呈爆炸式增长,让我意识到简单的暴力绘制绝非正解。本文旨在复盘我如何从繁杂的图形变化中发现并实现一个精妙的递推关系,最终优雅地解决这个问题的全过程。
丁香花通常由四片花瓣组成,四片花瓣的位置关系可以简要用图1表示。
单单四个正方形显然还不能表达丁香花的魅力,因此我们将该图形进行一次操作,包含以下两个步骤:
我们将上述两个步骤整体称为一次操作。在图3基础上再重复一次操作,我们就能得到图4。
![]() |
![]() |
![]() |
![]() |
| 图1: 基础图形 | 图2: 基础图形重复四次 | 图3: 进行一次操作后 | 图4: 进行两次操作后 |
本题需要你编程绘出进行了 n 次操作后的图形(注意:基础图形为进行了 0 次操作的图形)。
输出时,图形中有正方形的位置输出一个字符 o,没有正方形的位置输出一个空格 。行末的空格可以忽略。
例如,对于图1 (n=0),应该输出为:
1 | o |
又如,对于图4 (n=2),应该输出为:
1 | o o |
输入一个非负整数 n,表示操作的次数。
数据保证 0 <= n <= 6。
输出经过 n 次操作后对应的图形。
特别注意:为了方便在控制台进行输出,当操作次数 n 为奇数时,请将图形旋转45度再输出。
1 | 1 |
1 | o o |
理解题目后,我们可以将一次操作分解为两个基本步骤的组合:
直接模拟这个过程的难点在于第二步(重组)。每次重组后,新图形的边长是多少?四个象限应该放置在哪个精确的坐标上?这些参数的变化并非线性,如果不能找到其规律,算法将无从下手。因此,整个问题的核心,从如何画图转变成了如何计算每一步操作的画布尺寸与布局坐标。
我的解法选择了一个迭代的思路,从 n=0 的基础图形出发,循环 n 次,每次迭代生成下一阶段的图形。这个迭代过程并非一成不变,而是根据迭代次数的奇偶,执行两种截然不同的生成逻辑。
我的算法将题目中抽象的“一次操作”,具象化为一次奇数步膨胀和一次偶数步重组的交替执行。通过一个 for 循环,我们模拟 n 次这样的交替演变,最终得到目标图形。
我们用一个 for 循环从 i=1 到 n 进行迭代,其中 i 代表当前是第几次演变。
当 i 为奇数时:膨胀阶段
i-1)得到的完整图形(记为 temp)视为一个“瓦片”。然后,在一个尺寸为 a_temp * 2 的新画布(记为 pic)上,将这个瓦片无重叠地铺满 pic 的四个象限。这一步非常直观,图形的结构不变,只是尺寸翻倍。当 i 为偶数时:重组阶段
a_pic 以及四个“瓦片”的精确放置坐标,是这一步的关键。通过观察和推演,我发现了一个至关重要的递推规律。b[i-3] 递推规律在重组阶段,各项参数的计算并非无迹可寻。我发现,当进行第 i 次演变(i 为偶数且 i >= 4)时,新画布的尺寸以及重叠的深度,都与第 i-3 次演变完成时的画布边长 b[i-3] 存在一个确定的数学关系。
下图为做题时的手绘示意图,可供直观理解:
新画布尺寸 a_pic 的计算 (update_a 函数):a_pic(i) = a_pic(i-1) + (a_pic(i-1) - b[i-3]) * 2
这里的 a_pic(i-1) 是上一个奇数膨胀阶段的边长。这个公式描述了新画布如何在上一阶段的基础上,根据 i-3 步的历史状态进行扩张。
布局坐标的计算 (draw 函数):
在 draw 函数中,放置“瓦片”的起始坐标偏移量,也由 b[i-3] 决定。例如,左侧瓦片的 y 坐标和上方瓦片的 x 坐标,都涉及到一个关键偏移量:a_temp - (b[i - 3] - 1),其中 a_temp 是 a_pic(i-1)。
这个递推关系,正是图形演变过程的关键。它将一个复杂的几何问题,转化为了一个可以通过历史数据精确计算的数学问题。i=2 的情况由于 i-3 无意义,需要作为特殊情况单独处理。
时间复杂度:
n 次。在每次循环内部,核心操作是 draw 函数,它本质上是数组的复制,复杂度为 O(a_temp^2)。a_pic 大致以 2*sqrt(2) 为公比的等比数列增长(奇数步*2,偶数步*sqrt(2)左右)。当 n 较小时,a_pic 的增长在可控范围内,对于题目数据范围,总计算量可以接受。空间复杂度:
pic 和 temp 两个二维数组来存储图形,其大小由最终的 a_pic 决定。空间复杂度为 O(a_pic^2)。结论:
该算法将复杂的图形生成问题转化为迭代计算,通过发现递推规律避免了复杂的几何推导,方案可行。
基于以上思路,我构建了最终的AC代码。它通过 pic 和 temp 两个数组作为双缓冲,交替进行图形的生成和暂存,并通过 update_a 和 draw 函数,精确实现了奇偶交替的演变逻辑。
1 | #include<bits/stdc++.h> |
为了直观地展示算法的最终效果以及图形的演变之美,以下是 n=0 到 n=6 的完整输出图形。
b[i-3]规律),这比纯粹的几何分析要直接得多。pic 和 temp 两个数组作为双缓冲,可以很方便地根据前一阶段的图形来生成下一阶段,避免了数据的原地修改冲突。前两天做了一道蓝桥省赛题,是一道对排序思想和实现细节要求较高的题目,由于初次解题时对问题模型的理解存在偏差,导致我的算法方案迭代了两次,耗费了较多时间。本文旨在对整个求解过程进行技术性复盘,深入剖析错误思路的根源,并最终给出一个高效严谨的正确实现。
理解题目后,我们可以提炼出一个核心流程来简化题目:计算每个小朋友被交换的次数→计算每个小朋友的不高兴程度→求和。
不难发现,每次只能交换位置相邻的两个小朋友实际上就是冒泡排序的操作流程。所以问题也就变成了:对所有小朋友进行冒泡排序,统计出每个小朋友被交换了多少次,并对每个小朋友的不高兴程度进行求和。
接下来我们需要用到一个小结论:对一个序列进行冒泡排序时,总交换次数等于该序列逆序对的数量。结论其实不难理解,对于每一对元素,只有当该对元素为逆序对时,我们才会对其进行交换操作。因此,总交换次数一定严格等于总逆序对数量。
而在本题中,我们需要的不是总交换次数,而是每个元素被交换的次数,这也很好实现,仅需统计包含该元素的逆序对的数量即可。
至于最终的求和部分,只需要对每个小朋友进行一次等差数列求和:f(k) = k * (k+1) / 2,再对所有元素进行求和:Σf(k_i)即可。
在明确了计算每个元素的逆序对总数这一核心目标后,我选择使用树状数组+离散化来解决问题,下文的两次算法设计迭代也都将围绕着树状数组来进行。
最初的方案基于一个宏观的、但忽略了个体差异的思路,它整合了离散化、树状数组和双向遍历三种核心技术,旨在按身高种类进行逆序对的统计。
核心数据结构:ans_c 数组
size (唯一身高值的数量) 的数组 ans_c,其中 ans_c[idx] 用于存储身高排名为 idx 的所有小朋友的逆序对总数(或其累加值)。离散化
H_i 的值域可达 10^6,而 n 最多为 10^5。直接使用身高值作为数组下标是不可行的。[1, size] 的紧凑整数区间。一个原始身高值 H 在排序去重后数组 b 中的位置(下标+1),就是它离散化后的新排名 idx。这个 idx 将作为 ans_c 数组和树状数组的下标。树状数组
O(log n) 的时间内,同时完成 单点更新 和 查询前缀和 两种操作。update(idx, 1): 当处理一个身高排名为 idx 的小朋友时,执行此操作,意味着“身高排名为idx的人数+1”。query(idx): 执行此操作,可以得到“身高排名在 [1, idx] 区间内的人数总和”。双向遍历策略
k,等于其左侧大于它的元素数,加上其右侧小于它的元素数。这两个部分需要分开计算。算法执行流程
a。p,首先查询树状数组 c,计算在 p 左侧、且值大于 p 的元素数量。ans_c[idx],其中 idx 是 p 的身高排名。update(idx, 1),将当前元素 p 加入“已处理”集合。c。a。p,查询树状数组 c,计算在 p 右侧、且值小于 p 的元素数量。ans_c[idx] 上。update(idx, 1)。ans_c 数组,对每个 k = ans_c[idx],计算 sum(k) 并求和,得到最终答案。在编码前,对该方案的复杂度进行预估:
时间复杂度:
std::sort,复杂度为 O(n log n)。n 个元素的循环。在每次循环内部,lower_bound 操作的复杂度为 O(log size),树状数组的 query 和 update 操作复杂度均为 O(log size)。因此,这两次遍历的总时间复杂度为 O(n log size)。O(n) 或 O(size)。size <= n,因此总时间复杂度为 O(n log n)。对于 n = 10^5 的数据规模,10^5 * log(10^5) 约等于 1.7 * 10^6,远在 10^8 的安全线内,不会TLE。空间复杂度:
a, b 两个 vector 占用 O(n) 空间。cnt_c 和结果数组 ans_c 均占用 O(size) 空间,最坏情况下 size = n,即 O(n)。O(n),对于 n = 10^5,内存占用在可接受范围内。结论:
从时间/空间复杂度的角度分析,方案可行。
基于以上思路,我构建了第一版代码。其核心是创建了一个大小为 size 的数组 ans_c,其中 ans_c[idx] 旨在存储身高排名为 idx 的小朋友的总逆序对数。此时我并没有注意到,我的算法设计存在一个严重的漏洞,这也让我的初次提交仅拿到了一个测试点的分数。
1 | #include<bits/stdc++.h> |
该方案在提交后仅获得 10 分,原因在于其数据结构的设计与问题要求不匹配,存在严重的逻辑缺陷。
问题根源:
当序列中存在多个身高相同的元素时,它们在离散化后会映射到同一个排名 idx。这导致 ans_c[idx] 在计算过程中,会错误地覆盖或累加属于不同原始位置元素的数据。
例如,对于序列 [H1, H2, H1],第二个 H1 的计算结果会直接覆盖第一个 H1 的,造成信息丢失。
结论:
算法的数据统计粒度必须精确到每个独立的个体n,而非每种属性的类别size。
为修正上述缺陷,必须调整数据结构,为每个原始元素建立独立的档案。
n 的数组 ans_a,其中 ans_a[i] 用于存储原始序列中第 i 个元素的总交换次数。for i from 0 to n-1:c 查询在 [0, i-1] 区间内,值大于 a[i] 的元素数量。ans_a[i]。a[i] 的信息更新到树状数组 c 中。c。for i from n-1 down to 0:c 查询在 [i+1, n-1] 区间内,值小于 a[i] 的元素数量。ans_a[i]。a[i] 的信息更新到树状数组 c 中。ans_a 数组,对每个 k_i = ans_a[i],计算 sum(k_i) 并累加,得到最终答案。时间复杂度:
n 循环内嵌 log size 操作”。因此,总时间复杂度仍然是 O(n log n),性能上依然高效。空间复杂度:
ans_a 的大小为 n,替代了 V1.0 中大小为 size 的 ans_c。在最坏情况下 size=n,两者空间占用相当。O(n)。结论:
从时间/空间复杂度的角度分析,方案可行。
该方案通过以数组下标 i 关联原始位置,确保了数据归属的唯一性,且代码实现比 V1.0 更简洁。
1 | #include<bits/stdc++.h> |
实现细节注意:正向与反向遍历的逻辑对称性
我代码中的两次遍历,在 update 和 query 的顺序与逻辑上,展现了一种精妙的对称性,值得在此详细剖析。
正向遍历 (计算左逆序对): “先改后查”
1 | for (int i = 0; i < n; i++) { |
a[i] 加入树状数组 (update),再进行查询 (query)。query(idx, cnt_c) 查询的是包含 a[i] 自身在内的、[0, i] 区间中值小于等于 a[i] 的元素数量。因此,用已处理的总数 i + 1 减去它,就得到了 [0, i] 区间内值大于 a[i] 的数量。由于元素不可能大于自身,该结果精确等价于 a[i] 的左逆序对数。反向遍历 (计算右逆序对): “先查后改”
1 | for (int i = n - 1; i >= 0; i--) { |
query),再将当前元素 a[i] 加入树状数组 (update)。query(idx - 1, cnt_c) 查询的是在 a[i] 被加入之前,树状数组中已有的(即 [i+1, n-1] 区间内的)元素里,排名严格小于 idx(即值严格小于 a[i])的数量。这直接就是 a[i] 的右逆序对数。对比总结:
正向遍历采用“先改后查”的策略,通过总数减法巧妙地排除了自身的影响;而反向遍历则采用“先查后改”的策略,直接查询所需的结果。两者虽然实现顺序相反,但都最终达成了正确的计算目的,体现了算法实现的灵活性。
P.S. 最后附上我做题时的杂乱草稿(
希望本次复盘能为解决类似问题提供一个清晰的思路。
高考结束后的那个夏天,我没有选择狂欢,而是一头扎进了代码的世界。当两个月的算法与数据结构探索之旅告一段落,我决心将这段经历记录下来,让努力留下可视化的痕迹。为了实现这个目标,我将目光投向了 GitHub ,我计划用 GitHub 为自己打造一个独一无二的、能够动态展示我的学习历程的个人主页。
我了解到,常规的 git push 会将所有提交的时间戳记为当前时刻,导致 GitHub 贡献图无法真实反映、精确还原我暑假期间的学习轨迹。
为了解决这一问题,并为未来的学习过程建立一个可追溯、可视化的档案,我设计并实施了本次手动迁移与版本历史构建的任务。
这篇文章,将详细记录我从连 Git 指令都敲不对,到最终利用 Python 脚本实现自动化操作的完整过程以及心路历程。
我决定采用 git commit 命令,为每一份题解代码创建带有历史时间戳的提交。这个方案虽然可行,但在手动执行的过程中,我遇到并解决了一系列典型的命令行与 Git 环境配置问题。下面将对这些问题进行复盘。
本次操作的技术基石是 Git 的 commit 命令所提供的 --date 参数。该参数允许用户在创建提交时,指定一个自定义的时间戳,从而实现对版本历史的回溯性构建。
我设计的基础工作流的操作流程如下:
将代码文件添加至工作区。
在代码文件的前四行手动添加格式规范的注释,格式如下:
// Problem: 练习平台 题号 题目
// Link: 题目链接
// Author: nine19een
// Date: 日期
//
// 代码正文
使用 git add 将文件变更添加至暂存区。
执行带有特定历史日期的 git commit 命令以创建提交。
核心命令示例:
git commit --date="YYYY-MM-DD HH:MM:SS" -m "Creat 文件名"
其中,--date 参数用于指定历史时间戳,可通过练习平台的提交记录获取并手动替换进指令里。
在将上述流程的实践过程中,我遇到并解决了以下几个典型的环境配置与命令行操作问题。
No such file or directory现象:
在 Git Bash 环境下,使用 cd 命令并提供 Windows 文件管理器中的标准路径,无法成功切换目录,返回 No such file or directory 错误。
问题分析:
该错误源于 Windows 与类 Unix 环境 (Git Bash) 在路径表示法上的差异。主要有两点:
\,而 Git Bash 需要 /,因此,直接在Windows的文件资源管理器中复制路径并粘贴到 Git Bash 窗口是不可行的!解决方案:
格式修正: 若选择路径复制/粘贴方案,需手动将路径中的 \ 全部替换为 /,并确保路径完整(如 C:/Users/YourName/Documents/...)。
GUI 辅助: 作为一种更高效且不易出错的方法,可以在 cd 命令后,直接将目标文件夹从文件管理器拖拽至 Git Bash 窗口,以自动生成符合其语法规范的完整路径。强烈建议使用该方法,不易出错。
将文件拖拽进 Git Bash 窗口即会自动生成完整路径。
再按回车即可。
fatal: not a git repository现象:
在 git clone 操作成功后,在当前目录(即 clone 命令的执行目录)下运行 git status 或 git add 等命令,系统返回致命错误,提示当前目录并非一个 Git 仓库。
问题分析:git clone 命令会在当前目录下创建一个新的子目录作为仓库的根目录。所有 Git 相关的操作,都必须在这个新生成的子目录(或其内部)执行,因为根目录中包含了必需的 .git 元数据文件夹。错误发生时,我正处在仓库的父目录。
解决方案:
执行 git clone 后,必须使用 cd <repository-name> 命令,进入到新克隆的仓库根目录,再执行后续的 Git 操作。
schannel: failed to receive handshake, SSL/TLS connection failed现象:
在解决了本地的路径和仓库定位问题后,我遇到了最棘手的一个 blocker。当执行 git push, git pull, git fetch 等任何需要与远程服务器通信的网络操作时,命令会在长时间等待后失败,并返回一个底层网络错误:schannel: failed to receive handshake, SSL/TLS connection failed。
最令人困惑的是,与此同时,我的浏览器却可以正常、快速地访问 github.com 网站。
问题分析:
这个现象——“浏览器可以,但命令行不行”——是一个非常典型的网络代理配置问题。
问题根源在于,我的电脑上运行了 Clash 这样的网络代理工具来优化网络访问。浏览器被正确配置为通过代理来访问 GitHub,所以连接顺畅。然而,Git Bash 作为一个独立的命令行环境,默认不会自动使用系统的代理设置。
因此,Git 仍在尝试直接连接 GitHub 的服务器,这条直连路径受到了网络环境的干扰,导致在 SSL/TLS 加密握手阶段就因超时而失败。schannel 是 Windows 系统用于处理此过程的内置安全库的名称。
解决方案:
解决方案的核心是:必须为 Git 命令行工具,手动配置与系统代理一致的代理服务器。
这个过程分为两步:
定位代理端口号:
首先,需要找到 Clash 代理软件为 HTTP/HTTPS 协议监听的本地端口号。这里我以我使用的 Clash Verge 进行举例。
为 Git 配置全局代理:
接着,需要在 Git Bash 内执行以下两条命令,将 Git 的 http.proxy 和 https.proxy 都指向本地代理的监听地址 (http://127.0.0.1:端口号)。P.S. 127.0.0.1 是一个永远指向本机的特殊 IP 地址。
将 Git 的 HTTP 流量指向本地端口:
git config --global http.proxy http://127.0.0.1:端口号
将 Git 的 HTTPS 流量也指向本地端口 (关键):
git config --global https.proxy http://127.0.0.1:端口号
在执行完这两条命令,成功地为 Git “开启代理”之后,再次运行 git fetch,网络连接问题迎刃而解。
! [rejected] main -> main (fetch first)现象:
在本地进行 commit 操作后,执行 git push 时,推送被远程服务器拒绝。提示信息指出,远程仓库包含了本地所没有的修改。
问题分析:
这是 Git 的一种保护机制,旨在防止本地的推送意外覆盖掉远程仓库上其他人(或自己在别处)的提交。这种情况通常发生于:在本地 clone 或上次 pull 之后,远程仓库又有了新的 commit(例如,直接在 GitHub 网站上修改了 README.md 文件)。
解决方案:
请务必在每次 git push 之前先执行 git pull ,以确保本地和远程仓库的文件更新保持同步。
git pull 命令,将远程的最新变更下载到本地并自动合并。git push。LF will be replaced by CRLF现象:
在 git add 一个文件时,Git bash 会出现一条警告,提示文件中的 LF 将会被 CRLF 替换。
问题分析:
这是由于不同操作系统使用的换行符标准不同导致的。Unix/Linux/macOS 使用 LF ,而 Windows 使用 CRLF 。Git 内置了 core.autocrlf 功能,能够自动在不同系统间转换换行符,以保证版本库中的换行符格式统一。这个警告正是该功能在工作的体现。
解决方案:
无视风险,继续访问()。无需任何操作,可以安全地忽略此警告。因为这是 Git 的正常行为,代表它正在后台为我们处理跨平台兼容性问题。
git add现象:
在执行 git add . 后,发现将一些不需要的文件(或错误的修改)添加到了暂存区,需要在 commit 前进行撤销。
问题分析:
这是 Git 工作流中非常常见的需求。需要一个命令,能将文件从暂存区安全地移回工作区,而不丢失任何代码修改。
解决方案:
撤销所有暂存: 使用 git reset 命令,可以一次性清空整个暂存区。
git reset
撤销单个文件: 使用 git restore --staged <file> 命令,可以精确地将指定文件移出暂存区。
git restore --staged <filename>
这两种方式都不会修改工作区的文件内容,非常安全。
历时四个小时的手动历史提交迁移结束后,我立刻意识到:为这上百份题解代码,手动在 README.md 中创建并维护一个格式统一、内容准确、且按日期排序的表格,不仅极度耗时,且极易出错。我会死掉的。
因此,我决定采用 Python 编写自动化脚本,将整个 README.md 的更新流程自动化。P.S.懒惰是人类的第一生产力。
为了降低复杂度并确保每一步都稳定可靠,我将整个自动化任务分解为两个独立的、前后关联的子项目:
项目一:源代码注释规范化
目标: 遍历所有源代码文件,对所有注释不规范的代码进行更新,注释格式详见手动实现:基础工作流与问题排查 - 核心实现思路 - 2.。
作用: 为后续的 README 生成器提供一个统一、可靠、可离线解析的数据源。
项目二:README 生成器
目标: 读取所有经过规范化的源代码文件,提取其头部注释中的元数据,并生成最终的、完整的 Markdown 表格,用于在 README.md 中生成一个包含所有题目/题解信息的表格。
作用: 彻底替代手动编辑 README 的工作。
现象:
当我在脚本尝试读取 .cpp 文件内容时,或在使用 CLion 打开从 GitHub 克隆的文件时,文件中的中文注释显示为乱码。
问题分析:
这是典型的字符编码不匹配问题。GitHub 和 CLion 默认使用 UTF-8 编码,它兼容全球所有语言。而我之前做题时使用的 Dev-C++ 在 Windows环境下,默认使用本地化的 GBK 编码保存文件。当一个程序尝试用 UTF-8 编码去读取一个 GBK 编码的文件时,就会产生乱码。
解决方案:
在整个开发链条中,强制统一使用 UTF-8 编码。
UTF-8 支持不佳的旧 IDE,将主力开发环境迁移至现代化、默认使用 UTF-8 的 CLion。open() 函数时,明确指定 encoding 参数。读取时使用 encoding='utf-8-sig' 以兼容 Windows 下带 BOM 的 UTF-8 文件,写入时使用 encoding='utf-8'。1 | # 读取时指定编码 |
update_sources.py该脚本的核心逻辑是:遍历指定目录下的所有 C++ 文件,检查其是否已包含标准注释头。如果没有,则从文件名中解析题号,通过网络请求访问题目 URL 抓取完整标题,并从 Git 历史中获取文件首次提交的日期。最后,将这些元数据整合成标准格式的注释块,并重写回文件头部。
在让Gemini充分了解了我的诉求后,一份自动化更新注释的 Python 脚本便诞生了。
1 | import os |
在所有源文件都拥有了标准化的元数据之后,生成 README 的任务就变得纯粹而直接。
现象:
在 update_sources.py 的开发过程中,用于抓取洛谷题目难度的爬虫函数,在多次尝试后依然反复失效,返回 N/A 或空的页面内容,即便 URL 在浏览器中可以正常访问。
问题分析:
我编写了一个独立的脚本,将 requests 库获取到的原始 HTML 内容保存下来后,发现我收到的并非洛谷的题目页面,而是 Cloudflare 的人机验证页面。大概是我的操作被 Cloudflare 的反爬虫机制识别并拦截了。
解决方案:
<span>)、甚至用正则表达式直接匹配页面数据脚本,但都因为反爬虫机制的存在而宣告失败。
因此,我做出了一个关键的架构决策:放弃动态爬取,转向静态的本地数据源。
我将所有题目的难度信息,手动整理成一个 Python 字典,直接内置在脚本中。
1
2
3
4
5
6
# 本地难度数据库示例
DIFFICULTY_DATA = {
"P1001": "入门",
"P1002": "普及−",
# ... and so on
}
这个决策,让脚本彻底摆脱了 Cloudflare 的困扰,100% 可靠且运行速度速度极佳,也易于长期维护。
现象:
脚本初版成功生成了所有新题目的表格行,但在后续迭代中,我发现它会丢失 README.md 中已有的、非洛谷平台的记录(如 Codeforces)。
问题分析:
我的脚本只考虑了“生成新的”,而没有考虑“保留旧的”和“合并排序”。
解决方案:
重构脚本的核心逻辑,使其具备多平台兼容性:
README 时,不再只筛选洛谷题目,而是将所有表格行都加载到内存中。generate_readme.py这是经历了多次重构和 BUG 修复后的最终版本。它整合了本地难度数据库、多平台兼容、增量更新和全量排序等所有功能。
脚本运行后,会生成一个 update.txt 文件,包含了所有新旧题目的、完美排序的最终表格,我只需要将其整体复制并替换 README.md 中的旧表格即可。
1 | import os |
经过上述一系列的手动迁移、自动化脚本构建与 README.md 内容生成,我的 GitHub Profile 最终呈现为一个动态更新、信息丰富的个人技术档案。
它现在不仅包含了真实反映我学习时间的贡献图,还有一个内容详尽、格式统一、且能够通过自动化脚本持续更新的刷题记录面板。
这个 Profile 将忠实地记录下我大学四年走的每一步。
欢迎访问我的➡️ GitHub 主页 ⬅️以查看最新进展与项目源码,也欢迎各位大佬前来指点~