羽球场边工具 HarmonyOS 元服务实战(05):双打配对算法的轻量复用
排阵前先把上场机会放在第一位
临时组局并不追求理论上的全局最优,第一目标是让已上场较少的人优先进入下一轮。每轮可上场人数由场地数乘以四再与总人数取小值,因此候场逻辑不会把不存在的球位算进去。
候选阵容会经过有限次数的确定性洗牌。相同的轮次和尝试序号会产生同一候选顺序,便于复盘;尝试次数随着人数增加,不会把场边操作拖入无界搜索。
搭档重复为什么比对手重复权重更高
const playersPerRound = Math.min(courtCount * 4, n);
indices.sort((a, b) => playCounts[a] !== playCounts[b]
? playCounts[a] - playCounts[b] : a - b);
const selected = indices.slice(0, playersPerRound);
评分函数把搭档重复乘以三,再叠加对手重复。连续做同伴往往比连续隔网更容易让轮换失去新鲜感,所以权重明确偏向拆开重复搭档。
有限尝试中的确定性洗牌
const attempts = Math.max(60, n * n);
for (let t = 1; t <= attempts; t++) {
const candidate = PairingAlgorithm.shuffleIdx(selected.slice(), roundIndex * 100 + t);
if (PairingAlgorithm.overlapScore(candidate, courtCount, partnerHistory, opponentHistory) < bestScore) bestArr = candidate;
}
选中一组阵容后,代码同步更新搭档矩阵、对手矩阵和上场次数。下一轮不需要回读界面文字,只要读取这三类历史数据就能做出新的选择。

每轮如何更新两类历史矩阵
| 关注点 | 处理方式 | 可观察结果 |
|---|---|---|
| 排阵前先把上场机会放在第一位 | 临时组局并不追求理论上的全局最优,第一目标是让已上场较少的人 | 状态不依赖页面文案 |
| 有限尝试中的确定性洗牌 | 评分函数把搭档重复乘以三,再叠加对手重复。连续做同伴往往比连 | 分支可回读 |
| 场地数不足时的停止条件 | 人数少于四、场地数为零或轮次数为零时直接返回空结果;场地数多 | 操作结果可核对 |
人数少于四、场地数为零或轮次数为零时直接返回空结果;场地数多于足够人数时,循环在剩余不足四人处停止。这样不会生成只有一半队员的伪比赛。
场地数不足时的停止条件
score += partner[p0][p1] * 3 + partner[p2][p3] * 3;
score += opponent[p0][p2] + opponent[p0][p3];
score += opponent[p1][p2] + opponent[p1][p3];
临时组局并不追求理论上的全局最优,第一目标是让已上场较少的人优先进入下一轮。每轮可上场人数由场地数乘以四再与总人数取小值,因此候场逻辑不会把不存在的球位算进去。
选中一组阵容后,代码同步更新搭档矩阵、对手矩阵和上场次数。下一轮不需要回读界面文字,只要读取这三类历史数据就能做出新的选择。
查看一轮排阵时应核对什么
partnerHistory[p0][p1] += 1;
partnerHistory[p1][p0] += 1;
opponentHistory[p0][p2] += 1;
playCounts[p0] += 1;
人数少于四、场地数为零或轮次数为零时直接返回空结果;场地数多于足够人数时,循环在剩余不足四人处停止。这样不会生成只有一半队员的伪比赛。
候选阵容会经过有限次数的确定性洗牌。相同的轮次和尝试序号会产生同一候选顺序,便于复盘;尝试次数随着人数增加,不会把场边操作拖入无界搜索。
关键实现片段
import { MatchItem } from './Models';
export class PairingAlgorithm {
static canDistributeEvenly(playerCount: number, courtCount: number, roundCount: number): boolean {
if (playerCount <= 0) {
return false;
}
const playersPerRound = Math.min(courtCount * 4, playerCount);
return (playersPerRound * roundCount) % playerCount === 0;
}
static recommendedRounds(playerCount: number, courtCount: number, maxRound: number): number[] {
const result: number[] = [];
for (let r = 1; r <= maxRound; r++) {
if (PairingAlgorithm.canDistributeEvenly(playerCount, courtCount, r)) {
result.push(r);
}
}
return result;
}
static generate(participants: string[], courtCount: number, roundCount: number): MatchItem[] {
const matches: MatchItem[] = [];
const n = participants.length;
if (n < 4 || courtCount <= 0 || roundCount <= 0) {
return matches;
}
const playersPerRound = Math.min(courtCount * 4, n);
const playCounts: number[] = new Array(n).fill(0);
const partnerHistory: number[][] = [];
const opponentHistory: number[][] = [];
for (let i = 0; i < n; i++) {
partnerHistory.push(new Array(n).fill(0));
opponentHistory.push(new Array(n).fill(0));
}
for (let roundIndex = 0; roundIndex < roundCount; roundIndex++) {
const indices: number[] = [];
for (let i = 0; i < n; i++) {
indices.push(i);
}
indices.sort((a: number, b: number) => playCounts[a] !== playCounts[b] ? playCounts[a] - playCounts[b] : a - b);
const selected = indices.slice(0, playersPerRound);
let bestArr = selected.slice();
let bestScore = PairingAlgorithm.overlapScore(bestArr, courtCount, partnerHistory, opponentHistory);
const attempts = Math.max(60, n * n);
for (let t = 1; t <= attempts; t++) {
const candidate = PairingAlgorithm.shuffleIdx(selected.slice(), roundIndex * 100 + t);
const score = PairingAlgorithm.overlapScore(candidate, courtCount, partnerHistory, opponentHistory);
if (score < bestScore) {
bestScore = score;
bestArr = candidate;
}
}
for (let courtIndex = 0; courtIndex < courtCount; courtIndex++) {
const start = courtIndex * 4;
if (start + 3 >= bestArr.length) {
break;
}
const p0 = bestArr[start];
const p1 = bestArr[start + 1];
const p2 = bestArr[start + 2];
const p3 = bestArr[start + 3];
partnerHistory[p0][p1] += 1;
partnerHistory[p1][p0] += 1;
partnerHistory[p2][p3] += 1;
partnerHistory[p3][p2] += 1;
opponentHistory[p0][p2] += 1;
opponentHistory[p2][p0] += 1;
opponentHistory[p0][p3] += 1;
opponentHistory[p3][p0] += 1;
opponentHistory[p1][p2] += 1;
opponentHistory[p2][p1] += 1;
opponentHistory[p1][p3] += 1;
opponentHistory[p3][p1] += 1;
playCounts[p0] += 1;
playCounts[p1] += 1;
playCounts[p2] += 1;
playCounts[p3] += 1;
matches.push({
id: `match_${roundIndex + 1}_${courtIndex + 1}_${Date.now()}`,
roundIndex: roundIndex + 1,
courtIndex: courtIndex + 1,
teamA: { players: [participants[p0], participants[p1]] },
teamB: { players: [participants[p2], participants[p3]] },
scoreA: 0,
scoreB: 0,
finishedAt: 0
});
}
}
return matches;
}
static overlapScore(arr: number[], courtCount: number, partner: number[][], opponent: number[][]): number {
let score = 0;
for (let c = 0; c < courtCount; c++) {
const s = c * 4;
if (s + 3 >= arr.length) {
break;
}
const p0 = arr[s];
const p1 = arr[s + 1];
const p2 = arr[s + 2];
const p3 = arr[s + 3];
score += partner[p0][p1] * 3 + partner[p2][p3] * 3;
score += opponent[p0][p2] + opponent[p0][p3] + opponent[p1][p2] + opponent[p1][p3];
}
return score;
}
static shuffleIdx(arr: number[], seed: number): number[] {
const next = arr.slice();
for (let index = next.length - 1; index > 0; index--) {
搭档重复为什么比对手重复权重更高、有限尝试中的确定性洗牌与每轮如何更新两类历史矩阵共同约束了这条处理链:输入先被归类,计算或异步调用只在定义的入口发生,页面随后读取一个明确的结果。把这些判断分散在按钮回调里会让一次重进、一次重复点击或一次失败返回都变成难以定位的差异。
场地数不足时的停止条件不是事后补上的提示,而是模型在边界条件下仍然要给出的答案。用户只需要看到可继续的操作,模块内部则保留足够的状态来解释为什么当前结果是这样。
实施取舍
评分函数把搭档重复乘以三,再叠加对手重复。连续做同伴往往比连续隔网更容易让轮换失去新鲜感,所以权重明确偏向拆开重复搭档。
人数少于四、场地数为零或轮次数为零时直接返回空结果;场地数多于足够人数时,循环在剩余不足四人处停止。这样不会生成只有一半队员的伪比赛。
排阵前先把上场机会放在第一位所对应的数据不应依赖临时文本或视图顺序。将事实保留为字段、记录或令牌,能够让同一动作在再次进入页面后得到一致的解释,也为后续把能力收敛为轻量组件留下稳定边界。
操作验证
人数少于四、场地数为零或轮次数为零时直接返回空结果;场地数多于足够人数时,循环在剩余不足四人处停止。这样不会生成只有一半队员的伪比赛。
在验证过程中,先观察初始状态,再执行唯一的主题动作,最后回读结果区或系统状态。若输入不满足条件,页面应给出可理解的分支;若条件恢复,用户可以从当前页面再次发起操作,而不需要退出并重新建立上下文。
更多系统能力可参考 HarmonyOS 开发者文档。
规则落到界面之前
排阵前先把上场机会放在第一位不是展示层的装饰语,而是决定输入如何进入状态、状态如何产生结果的约束。临时组局并不追求理论上的全局最优,第一目标是让已上场较少的人优先进入下一轮。每轮可上场人数由场地数乘以四再与总人数取小值,因此候场逻辑不会把不存在的球位算进去。
有限尝试中的确定性洗牌需要把可复算的数据留在模型里:评分函数把搭档重复乘以三,再叠加对手重复。连续做同伴往往比连续隔网更容易让轮换失去新鲜感,所以权重明确偏向拆开重复搭档。
场地数不足时的停止条件要求页面把正常路径与异常路径放到同一个可观察范围中。人数少于四、场地数为零或轮次数为零时直接返回空结果;场地数多于足够人数时,循环在剩余不足四人处停止。这样不会生成只有一半队员的伪比赛。
当用户重复点击、修改输入或离开后再回来时,结果应当来自已定义的状态和规则,而不是依赖某一次组件渲染的偶然顺序。
| 操作阶段 | 应观察的字段 | 通过条件 |
|---|---|---|
| 初始化 | 默认状态与输入 | 不遗留上一轮结果 |
| 主动作 | 主题相关值 | 变化与规则一致 |
| 回读 | 结果或提示 | 可继续操作 |
| 风险 | 防护点 | 处理结果 |
|---|---|---|
| 重复动作 | 单一状态入口 | 不生成旁路数据 |
| 无效输入 | 条件分支 | 返回明确提示 |
| 页面重进 | 受控恢复 | 状态可解释 |
状态变化应可复盘,结果应可解释,并且能在下一次操作时继续被用户使用。每一次状态迁移都应保留足够的业务语义:用户能看懂当前结果,维护者能从输入、规则和结果之间还原处理过程。把边界条件写成明确分支,能够避免异常发生时把旧结果误当成新结果,也让后续扩展不必回到页面里寻找隐式判断。
更多推荐


所有评论(0)