北航数据结构 2026 春大作业个人解法说明
观前提醒:本文介绍的方法包含各种乱搞,不能保证在所有数据下都输出正确结果。
如果您认为这不可接受,您可选择关闭本文页面。
坐稳了!!!
问题描述
本文是北京航空航天大学 2026 春《数据结构与程序设计(信息类)》课程大作业的个人题解,或者说个人解法说明。提交系统关闭后,作业内容可能不再可见了,下面是大作业的题目描述供参考。
[问题描述]
智能问答是一项学术界和工业界都备受关注的新兴技术,已广泛应用于代码理解、文档理解、智能医疗等领域。智能问答的核心是检索问题(query)的相关语料,再由大型语言模型LLM理解相关语料,最后输出人类友好的答案。显然,如何检索问题相关的语料是关键。
以下是一个基于向量相似性的简单检索算法的实现描述。
- 文档预处理
将输入的文档进行以下处理,形成一个语句集合:
分句:以英文句号'.'、问号'?'、感叹号'!'或换行符'\n'作为分割符,将文档分割成独立的语句(含分割符)。
分词与清洗:对每条语句,执行分词操作,依据为空白符(通过库函数 isspace() 判定,需包含头文件<ctype.h>)和标点符号(通过库函数 ispunct() 判定)。特别说明:单引号"'虽被 ispunct() 函数判定为标点符号,但不纳入分词分隔符范围,包含单引号的字符串(如 "don't")需作为一个完整分词保留,而非拆分为 "don" 和 "t"。
统一小写:将所有字母转换为小写,以确保单词在不同大小写情况下被统一处理。
去除停用词:过滤掉在 stopList.txt 中出现的停用词(如 "the", "is", "at", "which" 等),这些词语义贡献较低。stopList.txt 中的停用词按字典序升序排列,每行一个单词。
- 语句向量化
将预处理后的自然语言语句转化为数值向量。本任务采用一种混合向量化方法,同时捕捉语句的语义内容和形态结构。每个语句的最终向量是语义子向量和结构子向量的拼接。
2.1 语义子向量(基于全局词典)
此部分用于捕捉语句的核心含义。
构建全局词典:
遍历整个语料库中预处理后的所有单词(停用词除外)。
按照单词首次出现的顺序,为每个不重复的单词分配一个唯一的整数索引(从0开始),形成全局词典。
生成语义向量:
使用 TF (词频) 模型,向量长度为全局词典的大小 N_dict。向量在第 i 维的值是全局词典中对应单词在当前语句中出现的次数。
2.2 结构子向量(基于形态特征)
此部分捕捉语句的统计风格和结构属性,与词汇具体含义无关。
结构部分向量是一个 7维的密集向量,每个维度计算如下:
平均单词长度:语句中所有有效单词的平均字符数(停用词除外)。
单词长度方差:语句中所有有效单词长度的总体样本方差,反映单词长度的变化程度。计算方式如下:
令n个单词组成的集合为 \(W = \{w_1, w_2, w_3, \ldots, w_n\}\),这些单词的平均长度为\(\mu\),则样本方差的计算公式为
\(s^2 = \sum \frac{(|w_i| - \mu)^2}{n}\)
句长:语句中的有效单词总数。
平均元音比例:对每个有效单词,计算其元音字母(a, e, i, o, u)数量占该单词长度的比例,再对所有有效单词求平均值。
标点符号密度:原始语句中,标点符号数量与有效总字符数的比例(标点符号由ispunct()判断)。有效总字符不包括空白字符(使用isspace函数判断)。
大写字母密度:原始语句中,大写字母数量与有效总字符数的比例。
数字字符密度:原始语句中,数字字符(0-9)数量与有效总字符数的比例。
注:原始语句,即第1步分句后形成的独立语句。
2.3 向量归一化
归一化:在进行相似性计算前,必须将待计算的两个语句的两个子向量分别进行归一化。
- 语义子向量:采用L2归一法,其核心思想是将一个非零向量转化为单位向量(长度为1),而方向保持不变。转换方法是将向量的每个分量除以该向量的L2范数,即欧几里得长度。
数学定义:
对于一个d维向量 \(X = [x_1, x_2, \ldots, x_d]\),其L2范数为:
\(||X||_2 = \sqrt{x_1^2 + x_2^2 + \ldots + x_d^2}\)
L2归一化后的向量为:
\(X_{norm} = \frac{X}{||X||_2} = [\frac{x_1}{||X||_2}, \frac{x_2}{||X||_2}, \ldots, \frac{x_d}{||X||_2}]\)
- 结构子向量:使用 Min-Max 归一化,将所有维度缩放到 [0, 1] 区间。
对任一语句结构子向量 \(X = [x_1, x_2, \ldots, x_7]\)。若Min-Max 归一化会把每个分量缩放到一个给定的区间 [a,b]。通常是[0,1]。
公式为:
\(x_i' = \frac{x_i - min(x)}{max(x) - min(x)}\)
其中:
\(Min(x)\) 是该向量所有分量中的最小值
\(Max(x)\) 是该向量所有分量中的最大值
\(x_i'\) 是归一化后的第i个分量
- 相似性匹配
当接收到一个用户问题(query)时,通过以下步骤找到语料库中最相关的语句:
问题query向量化:对query执行与文档完全相同的预处理和向量化流程,使用同一个全局词典和结构特征计算方法,分别生成语义子向量和结构子向量。注:此处的全局词典是第2步中针对给定语料语句创建的词典。无需针对用户问题更新全局词典。
双路相似度计算:
语义相似度计算:使用余弦相似度计算问题query的语义子向量与文档中每个语句语义子向量之间的相似性得分 \(S_{semantic}\)。
\(S_{semantic}(q, d_i) = cos(v_q, v_{d_i}) = \frac{v_q \cdot v_{d_i}}{||v_q|| ||v_{d_i}||}\)
此处,q表示问题query; \(d_i\) 表示文档中的第i个语句; \(v_q\) 是query的语义子向量; \(v_{d_i}\) 是语句\(d_i\)的语义子向量; \(S_{semantic}(q, d_i)\) 是二者的语义相似度得分
结构相似度计算:使用余弦相似度计算查询结构子向量与每个文档语句结构子向量之间的相似性得分 \(S_{structure}\)。
混合相似度融合:将两个相似度得分按权重融合,得到最终的混合相似度得分:
\(S_{hybrid} = \alpha S_{semantic} + (1 - \alpha) S_{structure}\),其中\(\alpha\)为语义相似度权重系数,取值范围为 [0, 1],用于平衡语义和结构特征的重要性。\(\alpha\)通过标准输入给定。
检索Top-K结果:
根据计算出的混合相似度得分 Shybrid,对语料库中的所有语句进行降序排序。最终返回相似度最高的 K 个语句作为与用户问题最相关的语料。
若两条语句的相似度相同,则按照语句在文本中出现的顺序进行排序,遵循先出现先输出的原则。
【输入形式】
第一行输入查询语句;
第二行输入语义向量权重系数 \(\alpha\);
第三行输入需要返回的语句数量 K。
检索语料从文档 document.txt 中读入,停用词从文档 stopList.txt 中读入(“课程信息”->“教学资料”下载,停用词按字典序排列)。
【输出形式】
输出共K行,每行一个语句,严格按照指定顺序依次输出。
数据结构的选择
在开始的开始,我们要选择合适的数据结构与算法,使得算法的时间复杂度最优。
-
你可能会使用二叉搜索树、字典树甚至平衡树维护词典,这是很慢的。我们需要的只是一个 C++ 中的
unordered_map<string,int>这样的,单纯维护 \(\text{字符串}\to\text{整数}\) 的映射的数据结构,不需要任何其他性质,所以完全可以使用快得多的开放寻址式哈希表 + 字符串哈希,它也比各种树形数据结构好写得多。关于字符串哈希,选择一个大质数作为 \(\text{base}\) 然后将字符串当 \(\text{base}\) 进制数即可把字符串映射到一个正整数,然后直接用unsigned long long存储,自然溢出(在这种工程题里一般不会冲突,算法题就不一定了)。这里 \(\text{base}\) 最好超过字符集大小。 -
Top-K 部分你可能会把所有句子答案都求出来然后
qsort,这是 \(O(S\log S)\) 的,比较慢。一个更好的方法是使用小根堆维护当前 Top-K,当堆内元素达到 \(K\) 个时,把新来的元素和堆顶比较,如果更小就跳过,如果更大就换掉堆顶然后调整堆,这样可做到 \(O(S\log K)\)。 -
如果直接维护稀疏向量的每一维,会遇到爆空间的问题。稀疏向量之所以稀疏就是因为它有值的地方很少,我们完全可以只维护有值的位置,点乘就在有序的位置序列上跑双指针即可。
选用合理的数据结构,你应当能做到以 \(O(n+S\log K)\) 的复杂度解决问题。
卡常
逻辑优化
只是复杂度达到最优是没什么用的,复杂度相同的代码的实际性能也可能有天壤之别。这就需要逻辑上的优化,尽可能减少 \(O(n)\) 的过程执行的次数。
-
减少遍历
document.txt的次数,所有工作都尽可能在一轮遍历中完成。例如字符串哈希可以边读单词边算,当检测到单词截止时就自动得到了单词哈希值,不用先确定单词的范围再在范围内遍历算字符串哈希。 -
把禁词表和词典存在同一个哈希表里,用不同值来分辨词的类型,例如 \(-1\) 是不存在,\(-2\) 是禁词,正整数是词典里的词。
-
任何向量处理好之后,直接统一 L2 归一化,余弦相似度就转化为点积。
-
注意到询问只有一次,而语义向量是点乘的,所以完全可以不维护语义稀疏向量,只考虑每个文章句子中,在询问句子中出现了的单词。换句话说,设一个单词是 \(W\),它在某个句子中出现了 \(W_s\) 次,在询问中出现了 \(W_q\) 次,则它对语义相似度向量的贡献就是 \(W_s\times W_q\)。\(W_q\) 是可以用哈希表预处理的,所以我们可以直接在遍历句子时算出所有句子和询问的语义相似度的一大部分,即 \(\frac{\vec{v}_q\cdot\vec{v}_d}{|\vec{v}_d|}\)。
-
结构相似度向量没有上面那么方便的办法,只能正常维护,但我们仍可以进行一些优化。正常算方差需要先遍历句子算出均值,再遍历句子计算方差,这就遍历了两次句子。我们改用平方的期望减期望的平方算方差,则只需在遍历句子时维护词长的平方和即可,减少了一次遍历。
-
MinMax 归一化时,注意到当向量有任意一维为零时,归一化等价于向量整体除最大值,而 L2 归一化是与倍数无关的,所以可以跳过 MinMax 归一化直接 L2 归一化。
-
Top-K 部分,\(K\) 小时直接插入排序即可,常数比堆要小得多。
传统卡常
当逻辑上的优化已经无法再提高性能时,我们就该卡常了。
-
稠密向量是固定的 \(7\) 维,所以直接全部循环展开。
-
开放寻址哈希表大小设为 \(2\) 的整数次幂,这样取模直接简化为
&(M-1)。 -
哈希表使用 Fibonacci 哈希函数。这是一种改进的线性探测,利用了 \(\phi-1=0.6180339887\cdots\) 的倍数序列的低差异性。它能使得探测到的下一个点尽可能地在两个已有点的空隙内,大大减少探测次数。具体来说使用以下两个哈希函数:
inline unsigned __attribute__((always_inline)) G(ull x){return (x*11400714819323198485ull)&(M-1);}
inline unsigned __attribute__((always_inline)) G(unsigned x){return (x*2654442313u)&(M-1);}
以上两个函数分别适用于 \(32\) 位和 \(64\) 位的元素,这个 magic number 实际上就是 \(\phi-1\) 与值域的乘积。
-
字符串哈希的 \(\text{base}\) 改为能用位运算简单表达的值,例如 \(257=2^8+1\),这样哈希迭代一次就变为了若干右移和若干加法,比乘法快得多。
-
使用
mmap,直接在文件上遍历,不把文件读到缓存内。 -
预处理整数逆元和 invsqrt。预处理各种符号判断然后压成整数用位运算判断。
-
浮点数全用
float,哈希表用uint32_t。这个比较危险,但因为这是工程题不会故意卡你,所以一般来说问题不大。(卡这玩意还不如卡哈希) -
删除所有冗余的语句和函数。
-
适当
inline,把执行次数显著很高的函数设置inline __attribute__((always_inline)),其他的不管。这里还有一点是,强制不inline也是有用的。如果有些函数在循环内的执行次数显著很低,可以设置__attribute__((noinline)),执行次数低的语句块也可以类似包装到函数内放到主流程外。这个可以避免没什么用的指令挤占 I-cache。
非传统卡常
考虑从计算机组成原理方向继续卡常,也就是卡 cache 和卡分支预测。这道题基本没有进一步卡 cache 的空间,所以我们直接考虑卡分支预测。当然,CPU 分支预测器不是我们能控制的,但我们可以用 __builtin_expect 控制 gcc 如何针对分支优化代码。
我们使用 gcov -b 来分析哪些分支较优。运行 gcc -coverage 编译,然后运行一次你的程序,然后再运行 gcov -b your_program,你会得到一个文件 your_program.c.gcov,用文本编辑器打开它就能看到所有语句的执行次数和所有分支的各方向占比。
对概率显著高(如高于 \(90\%\)),或显著低(如低于 \(5\%\))的分支,我们用 __builtin_expect 显式引导 gcc 进行分支预测,如下:
#define likely(x) __builtin_expect(!!(x),1)
#define unlikely(x) __builtin_expect(!!(x),0)
例如若 if(x) 的 fallthrough 概率非常高,我们把它改为 if(likely(x)) 提示 gcc 这个分支为真可能性高。类似地还有 unlikely。这个不一定总是有效,我们还可以人工对分支进行排序,把概率高的放在代码前面,概率低的放在后面(一定程度上其实就是帮 gcc 把事做了),一般也有不错的效果。
一般来说,对于完全没有进行分支预测优化的实现,进行这个优化后会取得非常可观的性能提升。
如果你做了以上所有的优化并且实现比较精细,你应当能在官方测试点上达到 25ms 左右。
“深不可测的伟力”
将以上各种方法应用到极致,在官方测试点上也很难稳定突破 20ms。要更进一步,我们只能牺牲正确率,与性能进行一个 trade-off。
对一个句子计算完整的信息是非常慢的。考虑先对一个句子进行某种粗略的乐观估计,如果估计出来它都没有机会进入答案,就直接忽略它,否则重新进行完整计算。
考虑如何粗略地乐观估计。结构向量的计算太慢了且难以简省,考虑直接放弃掉结构相似度,只计算语义相似度,然后把结构相似度往大了估计,假装结构相似度是一个很接近 \(1\) 的数。
现在考虑计算语义相似度。语义相似度的公式是 \(S_{\text{sem}}=\frac{\vec{v}_q\cdot\vec{v}_d}{|\vec{v}_q||\vec{v}_d|}\),注意到这里有一个问题:要算 \(|\vec{v}_q|\),我们必须知道询问句子中的哪些词在词典中,这必须扫完整个文档构造出词典后才能进行。但我们需要一个马上就能得到的 \(|\vec{v}_q|\)。
考虑直接假设询问中所有的非停用词都在词典内出现过,然后根据这个算一个 \(|\vec{v}_q'|\)。显然有 \(|\vec{v}_q'|>|\vec{v}_q|\),所以这会导致预估的 \(S_{\text{sem}}\) 减小,这并不是我们想要的。
启发式地(乱搞地),我们找一个东西来抵消这个效果。注意到,显然有 \(|\vec{v}_d|\geq 1\),且大部分情况下甚至有 \(|\vec{v}_d|\gg 1\),所以我们考虑直接把它放缩成 \(1\),这在绝大部分情况下要比 \(|\vec{v}_q|\) 升高的幅度要猛得多,使得 \(S_{\text{sem}}\) 最终按我们预期的那样增大。
以上方法不是唯一的,完全是通过脑洞得到的(或者说,本来我以为这是对的,后来才发现这是乱搞)。你也可以尝试将 \(|\vec{v}_q|\) 放缩为 \(1\),只算 \(|\vec{v}_d|\),我没有试这种做法,因为它直觉上比较慢。
同时,注意到,如果我们只算 \(\vec{v}_q\cdot\vec{v}_d\),那我们只需确定什么词在询问句子内出现就行了,这只需要一个询问句子的非停用词组成的小哈希表即可,不需要查大哈希表。这个小哈希表是能轻松卡进 L1 cache 的,并且可以直接线性探测,大大降低了常数。
所以最终我们可以以相比完整计算来说非常小的代价,得到一个较为乐观估计的的语义相似度 \(S_{\text{sem}}'\)。我们假设结构相似度的最大值是 \(M\)(接近 \(1\)),直接按最大值考虑,则这个句子的混合相似度的较为乐观的估计值就是 \(\alpha S_{\text{sem}}'+(1-\alpha)M\)。我们判断这个是不是比当前得到的最小入围答案要大,即有没有机会是答案,如果更大就判定为重算。这就需要我们先精确计算一些句子预处理出 \(K\) 个入围答案,否则这个过程无法进行。
然后再混合一些别的启发式策略,再调调参,我们就可以用约 15ms 以很高的正确率解决问题。
突破串行
串行已经被我们做到极限了,自然而然地我们想到了并行,我们能不能并行加速什么?
多线程显然不太优,考虑能不能用 AVX2 指令集加速,一次处理 \(32\) 个字符。回顾我们的粗略扫描流程:我们一个一个字符扫过去,每次判断字符类型,同时哈希,当探测到单词结束时就用已经得到的哈希值去查哈希表。这里的哈希是严格串行的,非常讨厌,这说明我们没法直接用指令集。难道这就完了吗?
二级扫描
为了使用指令集,我们能不能一定程度上避免哈希?
设询问句子的单词集合为 \(S\),考虑到,粗扫过程中我们只关注当前句子中有哪些词属于 \(S\),显然这些词的数量不会很多,而剩下的词完全没必要去哈希。所以我们考虑加入第二层扫描:先扫出哪些词可能在 \(S\) 中,然后对这些词一一字符串哈希去查哈希表。这第二层扫描无需哈希,所以有很大机会进行指令集优化。
考虑如何快速判断“哪些词可能在 \(S\) 中”,即哪些词是“候选词”。显然有一个简单的方法:考虑到单词长度不会很长,所以我们对每个字符 \(c\),预处理 \(S\) 中所有以 \(c\) 为首字母的单词长度,压成一个二进制数。这样得到 \(128\) 个表,我们只需知道一个单词的首字母和长度然后查一下表就行了。具体来说,第二层扫描的过程是:从左到右扫过去,找到每个词的起始位置和长度,然后根据起始位置查首字母的表。如果查到了长度,就重新扫这个词并哈希,正常做原来的流程。
现在考虑上面这个流程如何转化为可以并行的。只有对每个字符独立的操作才可并行,我们发现,这里唯一可并行的东西就是判断字符种类,所以我们把这部分提出来,流程变为:并行判断每个词的首字母的表是不是非空,提出那些表非空的起始位置,然后对它们一个个正常算长度,进行下面的过程。
判断每个字符种类是一个极其简单的操作,我们要做的其实就是两件事,一个是把 1<<c 与分隔符表(掩码)按位与,一个是判断 c 对应表是不是非零。AVX2 SIMD 指令可优化各种位运算,并且有 VPSHUFB 这样的指令优化查表。上面的操作中,第一个操作中 1<<c 可转化为查表,第二个操作本来就是查表,所以理论上完全可以指令集优化。但我们无法直接这么做:VPSHUFB 指令只支持 \(16\) 位表,我们要查的两个表都是 \(128\) 位的。这该怎么解决?
指令集优化的分块查表
先抽象出我们的问题:我们要处理一堆字节,对每个字节查一个 \(128\) 位表,这个表是以判断条件 \(F(c)\) 构建的。
注意到,一个字节可以被拆分成高半字节和低半字节,而半字节是 \(4\) 个 bit 的,只有 \(2^4=16\) 种。考虑利用半字节,设一个字节 c 对应的高半字节和低半字节分别是 hi,lo,我们令表 G 是这样的表:对所有 \(F(c)\) 位真的 c,G[lo]|=(1<<hi),其意思就是一个低半字节对应的那 \(16\) 种字节里哪些字节对应的高半字节符合条件。这样对一个字节查 \(128\) 位的表,就转化为对低半字节查 \(16\) 位表,然后用高半字节与查出来的东西按位与。这里只涉及查 \(16\) 位表与各种位运算,终于可以直接指令集优化了。
能指令集优化查表后,我们实际上就能对字符进行任意分类,剩下的各种判断用基本的位运算即可解决。但我们还有一坨大的:AVX2 SIMD 指令一次处理 \(32\) 个字节的块,可是句子和词大都是跨块的,该怎么判定单词起始位置和句子终点等等东西?
我们的工具已经足够强大,我们用分类轮子和各种位运算体操一点点解决这个问题。先分析单词怎么做。正常的做法是,先查表出分隔符位置,然后取补集弄出单词字符位置。前一个字符是非单词字符的单词字符就是单词起始位置,这个与自己左移一位的补集按位与一下即可。注意这里的左移,等价于默认了块开头的前一个字符不是单词字符,于是只需修正这一点即可:记录上一块最后一个字符是不是单词字符,然后补充到下一块的“自己左移一位”的结果里就行。
然后分析句子终点怎么做。这个是简单的,一个块内如果有句子终点,则用之前的分类轮子我们可以分类出句终符的位置,我们直接位运算把单词字符位置终句终符后面的内容全扬了就行。
这样就解决了主要的跨块问题。候选词跨块之类的问题也可以类似解决。
多级过滤
经过重重困难与巨量的赤石,你终于成功实现了上述算法,但测试后你发现,提升远远没有你想象的那么巨大,问题出在哪里?
问题就出在我们只加速了“判断每个词的首字母的表是不是非空”这个过程。这个条件太弱了,大多数词我们都要正常重扫算长度,再判断能不能进行下面的过程。所以我们增加一些判断,不仅判断首字母是否在询问里存在,还判断第二字母是否在询问里存在,这就以更多的 SIMD 指令调用次数换了更少的词遍历次数,显然是很值的。加入这个优化后,我们就可以用约 7.5ms 解决问题。
指令集优化部分可能是上述所有内容中最难以实现的一部分,它几乎是伪装成 C 代码的汇编,写起来限制非常多,并且难以调试。我推荐的一个办法是写一套与指令集等价的函数(指令集操作是非常简单的),然后在它基础上调试。这个用 C++ bitset 来写非常简单,且 bitset 能直接打印,十分利于调试。
百尺竿头,更进一步
评测机支持 AVX512,所以上述流程可以完全替换为 AVX512 的,一次处理 \(64\) 个字节。考虑到 AVX2 已经足够快,瓶颈已经不再是遍历整个文章,AVX512 应该不会有很显著的优化效果,但原则上,AVX512 总应该能带来一点提升。直接把 AVX2 改成 AVX512 后,我们发现这未带来任何提升,甚至还有负优化效果。我们知道 Intel CPU 有著名的 AVX512 降频问题,但对于至强 CPU 来说,Ice Lake 及以后的至强就没有这个问题了。经过简单测试,可以发现评测机没有显著的 AVX512 降频问题。所以负优化不是因为 AVX512 降频,那问题出在哪呢?
我们原有的架构,实际上不能完全发挥指令集的优化效果。原有的架构是按句子处理,我们在上面加入了二级扫描。二级扫描过程中,对每个句子,我们用指令集 \(64\) 个一块地加速扫描,余数部分再正常串行扫描。显然句子长度大部分时候都不是 \(64\) 的倍数,所以会有大量剩余部分被扫描。这里 \(64\) 并不小,所以会带来大量开销:设句子长度均匀分布,若文章有 \(5\times 10^4\) 个句子,则有 \(5\times 10^4\times 32=1.6\times 10^6\) 个字符被串行扫描。英语中句子期望长度为 \(80\),这样一篇 \(5\times 10^4\) 句子的文章期望有 \(4\times 10^6\) 个字符,也就是说有 \(40\%\) 的字符完全没吃到指令集加速! 使用 AVX2 时,这个值只有一半,即有 \(20\%\) 的字符是串行扫描的,影响相对较小,而 \(40\%\) 这个数字就比较大了。
考虑构建一个适合指令集优化的新架构解决该问题。原来我们是按句子扫描,现在我们考虑直接按 \(64\) 个字节的块扫描,对每个块整体用指令集优化扫描,把两级扫描合为一个。这个过程是比较简单的:我们记录当前块之前没结束的句子的信息,对每一块,我们还是先二级粗筛弄出候选词位置,然后进行魔改的粗筛:我们用之前的分类轮子找出句终符,枚举之,计算相邻两个句终符之间句子的信息,这里我们用位运算弄出句子内的候选词位置,然后枚举候选词计算即可。信息计算出来后正常判断是否能跳过即可。这整个过程是基于字符流的,在字符流上一块块处理,相比旧架构明显更加“原生”,同时也更好写。相比逐句处理,现在必须串行扫描的余数缩减为整个文章对 \(64\) 的余数,几乎可以忽略不计,用老方法即可。
修改架构后,AVX512 终于能发挥些微的优势,我们最终以 6.5ms 解决了问题。
完整代码?
本题的完整代码是我手工实现的,共 \(757\) 行 21KB,码风非常烂,可读性极差,是纯粹的反面教材,所以不公开。代码中涉及的所有方法和技巧在上面都已经介绍,其用处高于不可读的代码,根据它理论可以得到一个效果相同的实现。
21KB 的代码超过了我此前写过的最长单文件代码(“駄作”,10KB)的一倍长,如果算上删掉和修改的部分,我可能共写了不少于 70KB 代码。这整个过程中,主要是两部分非常恶心:第一部分是操作非常难绷的指令集,这是我没怎么接触过的部分,必须从头开始学,然后去一点点实现再调试,这还牵涉到很多部分要大改(改成位运算的),非常难调。第二部分是想 idea,我是对着测出来的每个小部分的耗时去想 idea 的,这里有很多 idea 看起来是对的实际上是错的,你写了一大坨,写完发现完全没有用,有时候还是负优化,甚至大量 WA 了,这时候写的那一大坨就完全白写了。不难发现,在 2026 年上半年这个到处都是 LLM Agent 的时代,我这么做完全没有意义,我完全可以让 Agent 去自动化实现、测试、调试、优化,或是让 Agent 帮我优化。我为什么坚持去这么做?
因为我享受这个在问题的不断提出与解决中获得新知识和经验的过程。这整篇文章中,只有“数据结构的选择”部分以及“传统卡常”部分完全是从我已有的知识来的,剩下的所有内容都是我在不断尝试优化的过程中新探索出来的,它们一点点累积形成了现在的这篇文章。
我们都知道,DeepSeek-R1-Zero 用纯强化学习,使模型在探索如何解决问题的过程中,自发涌现出了主动回溯的行为,这个分界点被称作“aha moment”。现在我们使用 LLM 时经常能看到这种 aha moment,模型在尝试解决问题的过程中,突然发现了一个新角度,或是突然意识到之前的一个错误,然后自己回溯。
我们每个人都是 LLM 的深度使用者,我们早已习惯于把问题交给 LLM,然后旁观 LLM 经历属于自己的 aha moment。但反过来想一想,我们自己已经有多长时间没有过这样的 aha moment 了?我们已经多久没有过为了一个问题不断钻研,不断探索,最终成功在问题上取得进展,直至解决问题的那种体验了?
LLM 的代劳是如此的触手可及,在无形中消弭了学习和探索本身的乐趣。找回这种乐趣,是我手工完成这个大作业最初的动机。若你能读到这里,无论你热爱哪个领域,无论你正在哪个领域内探索,我都真诚地祝福你能享受这个过程,享受探索过程中的那些 aha moment!

浙公网安备 33010602011771号