logo AlgoBeat OnlineJudge
登录 注册

#11. 工程题

内存限制:512 MiB 时间限制:2000 ms 标准输入输出
题目类型:传统 评测方式:文本比较
上传者: AlgoBeat 官方账号

题目描述

查看数据文件并直接输出结果的行为将被判为作弊。


这是一道工程题,你不需要自己设计算法,只需要实现我们描述的算法。

作为一个中 V 厨你当然认识天依和阿绫的所有歌曲啦,那就写个程序来识别它们吧!

具体的,你需要读取一段歌词(含错字,各种格式的分割标点,歌词截断,错位等),然后输出歌名。

保证歌曲都是中 V 歌曲,歌词足够有辨识度保证人类能够顺利识别。

太难了?没关系,我们预训练了一个 Transformer,它真的认识天依和阿绫的所有歌。

你需要实现一个程序,从标准输入读取模型权重和测试歌词,对每条歌词输出对应的歌曲名称(与模型内置的歌曲名列表完全一致)。

不会 Transformer?Nevermind! 我们下发了 《Attention Is All You Need》 的原文 PDF,还给了一份笨蛋都看得懂的解释,加油吧,少女!

输入格式

附件提供了样板代码用于输入,建议直接复制样板代码。

输入由二进制段文本段两部分从标准输入依次读入。

二进制段(从输入开头开始,长度由数据自描述)包含模型权重、Tokenizer 词表和歌曲名列表。文本段(紧跟在二进制段之后)包含待识别的歌词片段:

T
lyrics_1
lyrics_2
...
lyrics_T

第一行为测试数量 ,接下来 行每行一条歌词片段(歌词中的换行符转义为字面量 \n)。

二进制段的详细格式见下方"输入格式与接口文档"一节。

输出格式

行,每行一个歌曲名称(UTF-8 编码),与输入二进制段中 Song Names 部分的条目完全匹配。

样例

样例输入

[二进制段: Header + Tokenizer + Song Names + Weights, 详见格式文档]
5
我仍然在,无人问津的阴雨霉湿之地,和着雨音,唱着没有听众的歌曲。
Xngsagh我仍然在无人问津的英语没454事之地 或者语音,唱着没有听众nxzh的歌曲。
夏夜空,寻觅着,色彩褪去
星点的回音汇成声浪强烈的力量,将心房不断叩响
我愿把记忆燃成灯火寻遍这星空,乘夜风飘飞挣脱万有引力的枷锁。冲破空间囚笼,跨越时间洪流。逃出虚妄和无奈编制的黑洞。

样例输出

世末歌者
世末歌者
世末歌者
回音
寻遍星空

数据范围与提示

提示

  • 下发的 example.cpp / example.py标准输入一次性读取全部数据(二进制段 + 文本段)。可以用 freopen("input.bin", "rb", stdin); 重定向标准输入来测试样例是否能正确读取。
  • 下发的 example_mix.cpp文件读模型权重,从标准输入只读歌词文本。适合本地调试。
  • 具体输入方式参考下方"输入格式与接口文档"一节。
  • 使用 (cat model.bin; printf '1\n你的歌词\n') | ./solution 可以进行交互式测试。
  • 模型使用 Pre-Norm 架构(先 LayerNorm 再 Attention/FFN),激活函数为 GELU,与原论文有所不同。
  • 本题不卡常,朴素矩阵乘法实现即可通过。

数据范围

  • 每条歌词片段长度不超过 200 个 UTF-8 字符
  • Tokenize 后序列长度不超过 512
  • num_songs = 387, hidden = 192, num_heads = 4, num_layers = 4, ffn_size = 768
  • 时间限制:2s,内存限制:512MB

下发文件

下发的 down.zip 中包含:

sample/
├── model.bin            纯模型数据 (文件读取方式使用)
├── input.bin            完整样例输入 (二进制段 + 文本段,标准输入方式使用)
└── output.txt           样例标准输出 (10行歌名)
example.cpp              C++ 样板程序 — 全部从 stdin 读取 (自包含)
example.py               Python 样板程序 — 全部从 stdin 读取 (自包含)
example_mix.cpp          C++ 样板程序 — 文件读权重 + stdin 读歌词 (自包含)
Attention is All You Need.pdf   原始论文参考

两种输入方式说明

方式一:全部从标准输入读取(对应 example.cpp

评测时输入文件即为 input.bin 格式(二进制段 + 文本段拼接)。本地测试:

./solution < sample/input.bin
# 或在代码开头加:
# freopen("sample/input.bin", "rb", stdin);

方式二:文件读权重 + 标准输入读歌词(对应 example_mix.cpp

model.bin 放在同目录,标准输入只传歌词文本。本地测试:

example_mix.cpp 加载模型后会输出验证信息到 stderr:

Model loaded: 387 songs, hidden=192, 4 layers, vocab=11592
  song[0]="我的悲伤是水做的", song[386]="天魔策"
  tok_emb[5][0]=0.481048, song_queries[0][0]=0.149305
  2 test(s), first tokenized to 15 ids

看到以上输出即表示模型文件读取成功。

从标准输入正常输入歌曲数量后,每行输入对应歌词即可。

模型架构说明

本题使用的模型是一个基于 Transformer Encoder 的歌曲识别网络。输入为歌词片段的 token 序列,输出为歌曲编号。

与原始 Transformer 的关系

本模型基于 Attention Is All You Need (Vaswani et al., 2017) 中的 Encoder 部分,有以下修改:

原文 本模型
结构 Encoder-Decoder 仅 Encoder + 分类头
层数 6 4
隐层维度 d 512 192
注意力头数 h 8 (d_k=64) 4 (d_k=48)
FFN 中间维度 2048 768
位置编码 固定正弦/余弦 可学习嵌入
LayerNorm Post-Norm Pre-Norm
FFN 激活 ReLU GELU

整体流程

token_ids [seq_len]
    │
    ▼
tok_emb[token_id] + pos_emb[position]     → x: [seq_len, 192]
    │
    ▼  ×4 层
┌─────────────────────────────────────┐
│  residual = x                       │
│  x = LayerNorm(x)                   │
│  x = MultiHeadSelfAttention(x)     │
│  x = residual + x                   │
│                                     │
│  residual = x                       │
│  x = LayerNorm(x)                   │
│  x = FFN(x)                         │
│  x = residual + x                   │
└─────────────────────────────────────┘
    │
    ▼
x = LayerNorm(x)                       → H: [seq_len, 192]  (Encoder 输出)
    │
    ▼
Cross-Attention 打分                    → scores: [num_songs]
    │
    ▼
argmax(scores)                          → 预测歌曲编号

各组件详解

1. Embedding

x[t] = tok_emb[token_id[t]] + pos_emb[t]
  • tok_emb: [vocab_size, 192] 查表
  • pos_emb: [max_seq_len, 192] 查表
  • 无缩放,直接相加

2. Softmax

将任意实数向量归一化为概率分布(所有元素非负且和为 1):

softmax(x)[i] = exp(x[i]) / Σ_j exp(x[j])

实现时为避免浮点溢出,先减去最大值:

m = max(x[0], x[1], ..., x[n-1])
e[i] = exp(x[i] - m)
softmax(x)[i] = e[i] / Σ_j e[j]

3. LayerNorm

对一个长度为 d 的向量做归一化,使其均值为 0、方差为 1,再用可学习参数缩放和偏移:

mean = (1/d) * Σ x[i]                        // i = 0..d-1
var  = (1/d) * Σ (x[i] - mean)²              // i = 0..d-1
out[i] = (x[i] - mean) / sqrt(var + 1e-5) * w[i] + b[i]

其中 w 和 b 是可学习的一维向量(从权重文件读入)。

注意:本模型使用 Pre-Norm,即先 LayerNorm 再做 Attention/FFN,然后加残差。与原文的 Post-Norm(先做 Attention/FFN,加残差后再 LayerNorm)不同。

4. Multi-Head Self-Attention

4 个头,每头维度 d_k = 48。

对序列中的每个 token(行向量 x[t],长度 192),做线性投影得到 Q、K、V:

对 t = 0..seq_len-1:
    Q[t] = Wq @ x[t]        // Q[t] 是长度 192 的行向量
    K[t] = Wk @ x[t]
    V[t] = Wv @ x[t]

Q[t][i] = Σ_j Wq[i][j] * x[t][j]

对每个 head h (h = 0..3),取 Q/K/V 中对应的 48 维切片:

Q_h[t] = Q[t][h*48 .. h*48+47]
K_h[t] = K[t][h*48 .. h*48+47]
V_h[t] = V[t][h*48 .. h*48+47]

每个注意力头的缩放点积注意力:

attn[i][j] = (Q_h[i] · K_h[j]) / sqrt(48)
attn[i]    = softmax(attn[i])          // 对 j 维 softmax
out_h[i]   = Σ_j  attn[i][j] * V_h[j]

拼接所有 head 后做输出投影:

concat[t] = [out_0[t], out_1[t], out_2[t], out_3[t]]   // 192 维
output[t] = Wo @ concat[t]

权重矩阵存储:Wq, Wk, Wv, Wo 均为 [192][192] 的二维数组。乘法统一为 y = W @ x,即 y[i] = Σ_j W[i][j] * x[j]

5. FFN (Position-wise Feed-Forward)

对每个 token 独立执行:

对 t = 0..seq_len-1:
    h[t] = ffn_w1 @ x[t] + ffn_b1       // h[t] 长度 768
    h[t] = GELU(h[t])
    y[t] = ffn_w2 @ h[t] + ffn_b2       // y[t] 长度 192

h[t][i] = Σ_j ffn_w1[i][j] * x[t][j] + ffn_b1[i]

其中 GELU 近似实现为:

GELU(x) = 0.5 * x * (1 + tanh(0.7978845608 * (x + 0.044715 * x³)))
  • ffn_w1: [768][192], ffn_b1: [768]
  • ffn_w2: [192][768], ffn_b2: [192]

6. 最终 LayerNorm

Encoder 4 层结束后,对输出再做一次 LayerNorm:

H[t] = LayerNorm(x[t], final_norm_w, final_norm_b)

7. Cross-Attention 打分(分类头)

模型有 387 个可学习的 song_query 向量,每个 192 维,存储为 song_queries[num_songs][hidden]。通过单头 cross-attention 汇聚 Encoder 输出来计算每首歌的得分。

// K、V 对所有歌曲共享,只需计算一次
对 t = 0..seq_len-1:
    K[t] = cross_Wk @ H[t]          // K[t] 长度 192
    V[t] = cross_Wv @ H[t]          // V[t] 长度 192

对每首歌 s = 0..386:
    Q = cross_Wq @ song_queries[s]   // Q 长度 192

    对 j = 0..seq_len-1:
        attn[j] = (Q · K[j]) / sqrt(192)    // 点积再除以 sqrt(192)
    attn = softmax(attn)

    z = Σ_j  attn[j] * V[j]         // z 长度 192

    score[s] = song_queries[s] · z   // 标量,注意这里用的是原始 song_queries[s],非投影后的 Q

最终预测 = argmax(score),对应 song_names[预测编号]

注意事项

  • Cross-Attention 是单头(不分多头),scale 因子为 1/sqrt(192)
  • song_queries[s] 在 Q 投影前和点积打分时使用的是同一个向量(原始的,非投影后的)
  • 所有权重矩阵统一使用 y = W @ x 约定:y[i] = Σ_j W[i][j] * x[j]

参数规模

部分 参数量
Token Embedding 2,225,664
Position Embedding 98,304
Encoder (4层) 1,776,384
Final LayerNorm 384
Song Queries 74,304
Cross-Attention (Wq, Wk, Wv) 110,592
总计 4,285,632

YiSformer 输入格式与接口文档

输入结构

输入由二进制段文本段两部分拼接而成:

┌─────────────────────────────────────┐
│  Binary: Header + Tokenizer +       │
│          Song Names + Weights       │  ← 自描述长度, 解析后自动结束
├─────────────────────────────────────┤
│  Text:   T                          │  ← 测试数量
│          lyrics_1                   │  ← 每行一条歌词 (换行转义为 \n)
│          lyrics_2                   │
│          ...                        │
└─────────────────────────────────────┘

输出格式

T 行, 每行一个歌曲名称 (UTF-8), 与 song_names 中的条目完全匹配。


接口 (C++)

model.h 的内容复制粘贴到你代码的开头,然后在其后编写推理逻辑:

// ← 这里是 model.h 的全部内容(已粘贴)

int main() {
    Model m;
    m.load();                          // 从 stdin 读取二进制段
    int T = m.read_num_tests();        // 读取测试数量
    for (int i = 0; i < T; i++) {
        string lyrics = m.read_test(); // 读取一条歌词 (自动 unescape)
        vector<int> ids = m.tokenize(lyrics); // 歌词 → token id 序列
        int idx = predict(m, ids);     // 你的推理函数
        printf("%s\n", m.song_names[idx].c_str());
    }
}

编译: g++ -O2 -o solution solution.cpp

接口 (Python)

model.py 的内容复制粘贴到你代码的开头,然后在其后编写推理逻辑:

# ← 这里是 model.py 的全部内容(已粘贴)

def predict(m, ids):
    # 你的推理函数
    return 0

m = Model()
m.load()
tests = m.read_tests()
for lyrics in tests:
    ids = m.tokenize(lyrics)
    idx = predict(m, ids)
    print(m.song_names[idx])

运行: python solution.py < input.bin


存储约定

向量与矩阵的内存布局

所有数据按 row-major 顺序连续存储为 float32。

一维向量 v[n]:n 个连续 float,v[i] = 第 i 个元素。

二维矩阵 M[rows][cols]:rows × cols 个连续 float,M[i][j] = flat[i * cols + j]

Token 是行向量

序列中的每个 token 是一个行向量,维度为 hidden (192)。

序列 x[seq_len][hidden] 中,x[t][i] 表示第 t 个 token 的第 i 维分量。

矩阵乘法约定

所有权重矩阵 W 的形状为 [out_dim][in_dim],乘法定义为:

y = W @ x

即 y[i] = Σ_j  W[i][j] * x[j]

其中 x 是长度为 in_dim 的行向量,y 是长度为 out_dim 的行向量。

对序列中的每个 token 独立执行相同的变换:

对 t = 0..seq_len-1:
    y[t][i] = Σ_j  W[i][j] * x[t][j]

二进制段格式详解

Header (8 × int32, little-endian)

字段 含义 示例值
num_songs 歌曲总数 387
vocab_size 词表大小 (含特殊 token) 11592
max_seq_len 最大序列长度 512
hidden 隐层维度 192
num_heads 注意力头数 4
num_layers Encoder 层数
ffn_size FFN 中间维度 768
num_tokens Tokenizer 词条数 11494

Tokenizer (num_tokens 条)

每条: int32 token_id + int32 byte_length + byte_length 字节 UTF-8

Song Names (num_songs 条)

每条: int32 byte_length + byte_length 字节 UTF-8

Weights

按以下固定顺序排列,每个矩阵按 row-major 展开为连续 float32:

# 字段名 形状 [rows][cols] 语义
1 tok_emb [vocab_size][hidden] tok_emb[token_id] = 该 token 的行向量
2 pos_emb [max_seq_len][hidden] pos_emb[pos] = 该位置的行向量
每层 (× num_layers):
3 layers[l].norm1_w [hidden] LayerNorm 缩放
4 layers[l].norm1_b LayerNorm 偏置
5 layers[l].Wq [hidden][hidden] y = Wq @ x
6 layers[l].Wk y = Wk @ x
7 layers[l].Wv y = Wv @ x
8 layers[l].Wo y = Wo @ x
9 layers[l].norm2_w [hidden] LayerNorm 缩放
10 layers[l].norm2_b LayerNorm 偏置
11 layers[l].ffn_w1 [ffn_size][hidden] y = ffn_w1 @ x
12 layers[l].ffn_b1 [ffn_size] FFN 第一层偏置
13 layers[l].ffn_w2 [hidden][ffn_size] y = ffn_w2 @ x
14 layers[l].ffn_b2 [hidden] FFN 第二层偏置
全局:
15 final_norm_w [hidden] 最终 LayerNorm 缩放
16 final_norm_b 最终 LayerNorm 偏置
17 song_queries [num_songs][hidden] song_queries[s] = 第 s 首歌的查询行向量
18 cross_Wq [hidden][hidden] y = cross_Wq @ x
19 cross_Wk y = cross_Wk @ x
20 cross_Wv y = cross_Wv @ x

特殊 Token

ID 含义
0 PAD
1 UNK
2 BOS (tokenize 自动添加)
3 EOS (tokenize 自动添加)
4 NL (换行, trie 中以 <NL> 编码)

模型架构速览

输入 lyrics
    ↓ tokenize
token ids (len ≤ 512)
    ↓ Embedding (查表)
tok_emb[id] + pos_emb[pos]  → x[seq_len][192]
    ↓ 4× Pre-Norm Transformer Encoder Layer
    │   LayerNorm → 4-head Self-Attention (head_dim=48) → Residual
    │   LayerNorm → FFN (192→768, GELU, 768→192) → Residual
    ↓ Final LayerNorm
encoder output H[seq_len][192]
    ↓ Cross-Attention (单头)
    │   K[t] = cross_Wk @ H[t]
    │   V[t] = cross_Wv @ H[t]
    │   对每首歌 s:
    │     Q = cross_Wq @ song_queries[s]
    │     attn = softmax(Q·K^T / √192)
    │     z = Σ attn[j] * V[j]
    │     score[s] = song_queries[s] · z
    ↓ argmax(scores)
predicted song index → song_names[index]