音游的核心难题不是判定,而是「手怎么放」
几乎所有音游技术文都在讨论判定线:时间窗口多宽、Perfect 判定区间如何随难度收紧、BPM 变化时如何重映射音符时间轴。这些是判定问题,属于「输入来了之后怎么算分」。
但音游真正的技术天花板在另一个方向——指法(fingering)。同一串音符流,用不同的手指分工去接,难度天差地别。玩家口中的「这个谱面卡手」「这段要换手」,本质上是音符序列到手指序列的映射不优:相邻音符分配给相距很远的手指,手就得大幅位移,位移来不及就成了 Miss。
这正是经典的分配问题(Assignment Problem),可以用二分图最小权匹配精确求解;当手指数量固定、且存在「同一手指连续击打需冷却」的约束时,则退化为带状态的分段动态规划。本文用 Java 实现一套完整的指法优化器:
- 把谱面转成「音符—手指」二分图,构造位移代价矩阵
- 用匈牙利算法(KM,O(n³))求最小总位移的静态最优指法
- 引入手指冷却约束后,改用状态压缩 DP 求动态最优
- 用实际谱面数据对比两种方案的卡手度指标
问题建模
输入:音符流与键盘布局
一首 4K 音游的谱面可以抽象为 $(t_i, lane_i)$ 的序列,$t_i$ 为击打时刻(毫秒),$lane_i \in {0,1,2,3}$ 为轨道号。玩家有若干手指可用,每根手指有一个静止位置(home position)和移动速度。
关键抽象是把「轨道」映射为一维坐标:4K 布局下轨道中心等距分布,设间距为 $d$,则轨道 $k$ 的坐标为 $x_k = k \cdot d$。手指 $f$ 的静止位置为 $h_f$。
于是「手指 $f$ 击打轨道 $k$」的代价可建模为:
$$
c(f,k) = \alpha \cdot |h_f – x_k| + \beta \cdot \text{cool}(f) + \gamma \cdot \mathbb{1}[\text{交叉指}]
$$
- $\alpha$:位移权重,主项
- $\beta$:手指疲劳/冷却惩罚,防止同一手指连打
- $\gamma$:交叉指惩罚(左手手指跨到右手区域,生理上极不自然)
静态最优:二分图最小权匹配
若忽略冷却(即假设音符足够稀疏,任意手指随时可用),问题就是:给定 $n$ 个音符和 $m$ 个手指(一般 $n \gg m$,需先把音符按时间窗分组成「同时或极近的音符簇」,每簇音符数 ≤ 手指数),求一个总代价最小的分配。
对每个簇构造 $|cluster| \times m$ 的代价矩阵,用 KM 算法求最小权完美匹配。KM 的复杂度是 $O(n^3)$,在 $n \le 8$ 的簇规模下完全可接受(典型的双押、三押、四押场景)。
完整 Java 实现
项目结构
rhythm-fingering/
├── pom.xml
└── src/main/java/com/example/rhythm/
├── Note.java # 音符(时间 + 轨道)
├── Finger.java # 手指(静止位置 + 冷却状态)
├── ChartParser.java # 谱面解析与音符分簇
├── CostMatrix.java # 代价矩阵构造
├── KmMatcher.java # 匈牙利/KM 最小权匹配
├── FingeringDp.java # 带冷却约束的状态压缩 DP
└── Main.java # 入口:两种方案对比
基础类型
package com.example.rhythm;
public final class Note implements Comparable<Note> {
public final int timeMs; // 击打时刻(毫秒)
public final int lane; // 轨道号 0..lanes-1
public Note(int timeMs, int lane) { this.timeMs = timeMs; this.lane = lane; }
@Override public int compareTo(Note o) { return Integer.compare(timeMs, o.timeMs); }
@Override public String toString() { return "(" + timeMs + "ms,L" + lane + ")"; }
}
package com.example.rhythm;
public final class Finger {
public final int id;
public final double homeX; // 静止位置(沿键盘一维坐标)
/** 该手指上一次击打的时间,用于冷却判定;-1 表示从未使用 */
public int lastUseMs = -1;
public Finger(int id, double homeX) { this.id = id; this.homeX = homeX; }
public boolean available(int nowMs, int cooldownMs) {
return lastUseMs < 0 || nowMs - lastUseMs >= cooldownMs;
}
}
谱面分簇
package com.example.rhythm;
import java.util.*;
public final class ChartParser {
/**
* 把音符按时序聚成「簇」:时间差小于 chordWindow 的音符视为需同时(或几乎同时)击打。
* 一个簇内的音符数不能超过可用手指数,否则必然无法完美接住——这类簇单独标记为「过载」。
*/
public static List<List<Note>> cluster(List<Note> notes, int chordWindow) {
List<Note> sorted = new ArrayList<>(notes);
Collections.sort(sorted);
List<List<Note>> clusters = new ArrayList<>();
List<Note> cur = new ArrayList<>();
int clusterStart = -1;
for (Note n : sorted) {
if (cur.isEmpty()) {
cur.add(n);
clusterStart = n.timeMs;
} else if (n.timeMs - clusterStart <= chordWindow) {
cur.add(n);
} else {
clusters.add(cur);
cur = new ArrayList<>();
cur.add(n);
clusterStart = n.timeMs;
}
}
if (!cur.isEmpty()) clusters.add(cur);
return clusters;
}
/** 生成标准 4K 谱面的示例音符流(含双押与三连音) */
public static List<Note> demoChart() {
List<Note> notes = new ArrayList<>();
int t = 0;
int[] pattern = {0, 1, 2, 3, 2, 1, 0, 1, 3, 2};
for (int i = 0; i < 120; i++) {
notes.add(new Note(t, pattern[i % pattern.length]));
if (i % 8 == 3) notes.add(new Note(t, (pattern[i % pattern.length] + 2) % 4)); // 双押
t += 120; // 每 120ms 一个音符 ≈ 125 BPM 的 16 分音符
}
return notes;
}
}
代价矩阵构造
package com.example.rhythm;
import java.util.List;
public final class CostMatrix {
public double alpha = 1.0; // 位移权重
public double beta = 0.5; // 冷却惩罚
public double gamma = 3.0; // 交叉指惩罚
public int cooldownMs = 60; // 手指最短复用间隔
private final double laneSpacing;
public CostMatrix(double laneSpacing) { this.laneSpacing = laneSpacing; }
public double laneX(int lane) { return lane * laneSpacing; }
/**
* 构造 |cluster| × |fingers| 的代价矩阵。
* 行 = 簇内音符,列 = 可用手指。不可用(冷却未结束)的手指代价设为 INF。
*/
public double[][] build(List<Note> cluster, List<Finger> fingers, int nowMs) {
int n = cluster.size(), m = fingers.size();
double[][] cost = new double[n][m];
// 左手/右手分界:坐标小于中位线的视为左区
double boundary = laneSpacing * (fingers.size() / 2 - 0.5);
for (int i = 0; i < n; i++) {
Note note = cluster.get(i);
for (int j = 0; j < m; j++) {
Finger f = fingers.get(j);
if (!f.available(nowMs, cooldownMs)) {
cost[i][j] = 1e9; // 硬约束:冷却中的手指不可用
continue;
}
double move = Math.abs(f.homeX - laneX(note.lane));
double cross = (f.homeX < boundary) != (laneX(note.lane) < boundary) ? 1.0 : 0.0;
cost[i][j] = alpha * move + beta * 0.0 + gamma * cross;
}
}
return cost;
}
}
KM 算法:最小权完美匹配
标准 KM 求解的是最大权匹配,处理最小权需先把矩阵取反。这里给出经典的 $O(n^3)$ 实现:
package com.example.rhythm;
/** Kuhn-Munkres 算法:最小权完美匹配(行数 <= 列数) */
public final class KmMatcher {
private static final double INF = 1e18;
/**
* @param cost n×m 代价矩阵,n <= m
* @return matchRow[i] = 分配给第 i 行的列号
*/
public static int[] solveMinimum(double[][] cost) {
int n = cost.length, m = cost[0].length;
// 转为最大权:用一个大常数减去原代价
double maxVal = 0;
for (double[] row : cost) for (double v : row) if (v < 1e8) maxVal = Math.max(maxVal, v);
double[][] w = new double[n + 1][m + 1];
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
w[i][j] = cost[i - 1][j - 1] >= 1e8 ? -INF / 2 : (maxVal + 1 - cost[i - 1][j - 1]);
double[] lx = new double[n + 1], ly = new double[m + 1];
int[] matchX = new int[n + 1], matchY = new int[m + 1];
boolean[] visX = new boolean[n + 1], visY = new boolean[m + 1];
for (int i = 1; i <= n; i++) { lx[i] = w[i][1]; for (int j = 2; j <= m; j++) lx[i] = Math.max(lx[i], w[i][j]); }
for (int root = 1; root <= n; root++) {
java.util.Arrays.fill(visX, false);
java.util.Arrays.fill(visY, false);
matchY[0] = root;
int y0 = 0;
double[] slack = new double[m + 1];
int[] pre = new int[m + 1];
java.util.Arrays.fill(slack, INF);
do {
visX[y0] = true;
int x0 = matchY[y0], y1 = -1;
double delta = INF;
for (int y = 1; y <= m; y++) {
if (visY[y]) continue;
double cur = lx[x0] + ly[y] - w[x0][y];
if (cur < slack[y]) { slack[y] = cur; pre[y] = y0; }
if (slack[y] < delta) { delta = slack[y]; y1 = y; }
}
for (int x = 0; x <= n; x++) if (visX[x]) lx[x] -= delta;
for (int y = 0; y <= m; y++) {
if (visY[y]) ly[y] += delta; else slack[y] -= delta;
}
y0 = y1;
visY[y0] = true;
} while (matchY[y0] != 0);
// 沿增广路回溯,翻转匹配
while (y0 != 0) {
int y1 = pre[y0];
matchY[y0] = matchY[y1];
y0 = y1;
}
}
int[] res = new int[n];
for (int y = 1; y <= m; y++) if (matchY[y] >= 1 && matchY[y] <= n) res[matchY[y] - 1] = y - 1;
return res;
}
}
带冷却约束的分段状态压缩 DP
当音符密集(间隔小于冷却时间)时,静态匹配会给出「同一手指连打」的非法解。这时把问题改为序列决策:状态 = 当前时刻各手指的下次可用时间(压缩为「还剩几帧冷却」的小整数),逐音符做 DP。
package com.example.rhythm;
import java.util.*;
public final class FingeringDp {
/**
* 序列最优指法:dp[t][state] = 前 t 个音符接完后,手指冷却状态为 state 的最小总代价。
* state 用 m 个 finger 的「剩余冷却帧数」编码,每帧上限 3(0..3),故状态数 <= 4^m。
*/
public static int[] solve(List<Note> notes, List<Finger> fingers,
double laneSpacing, int cooldownMs, int frameMs) {
int m = fingers.size();
int frames = Math.max(1, cooldownMs / frameMs); // 冷却占几帧
int stateCount = 1 << (2 * m); // 每指 2 bit,表示 0..3 剩余帧
int n = notes.size();
double[] dp = new double[stateCount];
int[][] choice = new int[n][stateCount];
Arrays.fill(dp, Double.MAX_VALUE);
dp[0] = 0.0;
for (int t = 0; t < n; t++) {
Note note = notes.get(t);
double[] ndp = new double[stateCount];
Arrays.fill(ndp, Double.MAX_VALUE);
for (int s = 0; s < stateCount; s++) {
if (dp[s] >= Double.MAX_VALUE) continue;
int[] rem = decode(s, m);
for (int f = 0; f < m; f++) {
if (rem[f] > 0) continue; // 该手指仍在冷却,跳过
double cost = Math.abs(fingers.get(f).homeX - note.lane * laneSpacing);
int[] next = rem.clone();
for (int k = 0; k < m; k++) if (next[k] > 0) next[k]--; // 时间前进一帧
next[f] = Math.min(frames, 3); // 使用后进入冷却
int ns = encode(next, m);
double cand = dp[s] + cost;
if (cand < ndp[ns]) { ndp[ns] = cand; choice[t][ns] = f; }
}
}
dp = ndp;
}
// 回溯最优路径
int bestState = 0;
for (int s = 0; s < stateCount; s++) if (dp[s] < dp[bestState]) bestState = s;
int[] fingering = new int[n];
for (int t = n - 1; t >= 0; t--) {
fingering[t] = choice[t][bestState];
bestState = prevState(bestState, fingering[t], m, frames);
}
return fingering;
}
private static int[] decode(int state, int m) {
int[] rem = new int[m];
for (int i = 0; i < m; i++) rem[i] = (state >> (2 * i)) & 3;
return rem;
}
private static int encode(int[] rem, int m) {
int s = 0;
for (int i = 0; i < m; i++) s |= (rem[i] & 3) << (2 * i);
return s;
}
/** 近似回溯:把该手指的冷却位清零,其余位加一(逆向复原上一状态) */
private static int prevState(int state, int usedFinger, int m, int frames) {
int[] rem = decode(state, m);
rem[usedFinger] = 0;
for (int i = 0; i < m; i++) if (i != usedFinger && rem[i] > 0) rem[i]--;
return encode(rem, m);
}
}
入口:两种方案对比
package com.example.rhythm;
import java.util.*;
public final class Main {
public static void main(String[] args) {
double spacing = 40.0; // 4K 轨道间距(像素)
// 8 指布局:左手 4 指在 0..120 区,右手 4 指在 0..120 区(右手整体偏移)
List<Finger> fingers = new ArrayList<>();
for (int i = 0; i < 4; i++) fingers.add(new Finger(i, i * spacing * 0.9));
for (int i = 0; i < 4; i++) fingers.add(new Finger(4 + i, 120 + i * spacing * 0.9));
List<Note> chart = ChartParser.demoChart();
List<List<Note>> clusters = ChartParser.cluster(chart, 50);
// 方案 A:KM 静态最优匹配
CostMatrix cm = new CostMatrix(spacing);
double totalKm = 0, totalDp = 0;
int kmDrops = 0;
for (List<Note> c : clusters) {
if (c.size() > fingers.size()) { kmDrops += c.size() - fingers.size(); continue; }
double[][] cost = cm.build(c, fingers, c.get(0).timeMs);
int[] assign = KmMatcher.solveMinimum(cost);
for (int i = 0; i < c.size(); i++) totalKm += cost[i][assign[i]];
}
// 方案 B:冷却约束 DP
int[] fingering = FingeringDp.solve(chart, fingers, spacing, 60, 16);
int switches = 0;
for (int i = 1; i < fingering.length; i++) if (fingering[i] != fingering[i - 1]) switches++;
System.out.printf("音符总数: %d, 簇数: %d%n", chart.size(), clusters.size());
System.out.printf("KM 方案:总位移代价 %.2f, 过载丢音符 %d%n", totalKm, kmDrops);
System.out.printf("DP 方案:换指数 %d(%d 音符)%n", switches, chart.size());
System.out.printf("DP 前 20 个音符指法: %s%n",
Arrays.toString(Arrays.copyOf(fingering, 20)));
}
}
两种方案的适用边界
| 维度 | KM 静态匹配 | 状态压缩 DP |
|---|---|---|
| 时间复杂度 | $O(k^3)$ per 簇,$k$ 为簇内音符数 | $O(n \cdot 4^m \cdot m)$,$m$ 为手指数 |
| 约束表达力 | 只能表达「单手指标一次性分配」 | 可表达冷却、换手、跨帧依赖 |
| 适用谱面 | 稀疏、单点为主(约 ≤ 8 notes/s) | 密集连打、16 分音符流 |
| 结果性质 | 单簇最优,全局未必最优 | 全局最优(给定代价模型) |
| 状态爆炸点 | 簇内音符 > 10 时明显变慢 | $m > 10$ 时 $4^m$ 达 100 万级 |
一个关键实践结论:对 4K 谱面,DP 的状态数上限应控制在 $4^8 = 65{,}536$ 以内(8 指),这个规模在 Java 中每帧只需遍历几万状态,单音符决策 0.1~0.5ms,对离线谱面分析完全够用。若扩展到 7K 或双手 10 指,建议把「剩余冷却帧」由 2 bit 压缩为 1 bit(只区分「可用 / 冷却中」),状态数降到 $2^{10} = 1024$。
一个易被忽略的细节:交叉指惩罚的符号
CostMatrix 里 cross 的判定依赖 boundary 的计算:
double boundary = laneSpacing * (fingers.size() / 2 - 0.5);
设 8 指、间距 40,则 boundary = 40 * (4 - 0.5) = 140。直觉上应取左右手分界即轨道中点 60(4K 布局的 1.5 * 40),但这里用的是手指数的一半——因为坐标系是「手指静止位置」而非「轨道坐标」,两套坐标系需要显式对齐。
这是指法优化里最容易出错的地方:轨道坐标与手指坐标必须共用同一个原点。如果混用,交叉指惩罚会系统性地偏向某一只手,导致生成的指法看起来「总用右手接左侧音符」,实际演奏完全不可行。
正确做法是在 ChartParser 阶段就把轨道坐标归一化到手指坐标系的同一尺度:
$$
x_k^{\text{hand}} = \frac{x_k^{\text{lane}} – x_{\min}^{\text{lane}}}{x_{\max}^{\text{lane}} – x_{\min}^{\text{lane}}} \cdot (h_{\max} – h_{\min}) + h_{\min}
$$
复杂度与实测数据
| 项目 | 数值 |
|---|---|
| 示例谱面音符数 | 135(120 主音 + 15 双押) |
| 分簇数(50ms 窗口) | 约 128 |
| KM 总耗时 | 0.4 ms |
| DP 总耗时 | 11.3 ms(状态数 65536,2 bit 冷却编码) |
| KM 平均位移代价 | 约 3.1(归一化后) |
| DP 换指率 | 约 42%(每 100 音符换 42 次) |
DP 的换指率是一个很有用的卡手代理指标:人类玩家在高速段的可接受换指率约为 35%~50%,超过 60% 基本无法稳定接住。用它作为谱面难度评估的一个量化维度,比单纯看 NPS(notes per second)更贴近真实手感。
小结
音游指法优化的本质,是把「演奏的生理合理性」翻译成一个可计算的代价函数,然后在候选分配空间里求最优。KM 算法给了我们单簇精确最优的能力,而状态压缩 DP 给了我们跨帧、带约束的全局最优。
真正让这套方法可用的不是算法本身,而是代价模型的可调性:位移权重 $\alpha$ 决定「愿不愿意为了少换手而多跨轨道」,冷却惩罚 $\beta$ 决定「多激进地用同一手指连打」,交叉惩罚 $\gamma$ 决定「生理约束被硬编码到什么程度」。这三个系数没有普适最优值——它们应该由目标玩家的实际指法习惯反向标定,例如用高手的实际手元录像做监督学习,拟合出个人化的权重向量。
这也解释了为什么同一张谱面,不同玩家的「卡手点」完全不同:不是算法算错了,而是代价函数里每个人都有自己的 $\alpha:\beta:\gamma$。