109. 飞镖(Darts)
在飞镖游戏中,玩家会向一个靶盘投三镖。靶盘被分成二十个等大小的扇区,编号为 1 到 20。
一镖的得分由其落点区域编号决定。若落在最外层红/绿环之外,则得分为 0。该外环内部的黑色和米色区域为单倍分。外侧红/绿环与中间红/绿环分别计双倍分与三倍分。
靶心处有两个同心圆,称为牛眼区域。外牛眼记 25 分,内牛眼是双倍区,记 50 分。
规则有很多变体,但最常见玩法是从 301 或 501 起分,先把累计分数减到 0 的玩家获胜。不过通常采用“双倍结束”规则,即最后一镖必须命中双倍区(包含中心的双倍牛眼);否则若某镖使分数降到 1 或更低,则该组三镖记为“爆镖”(bust)。
当玩家可以在当前分数完成清台时,称为一次“checkout”。最高 checkout 是 170:T20 T20 D25(两个三倍 20 加双倍牛眼)。
当分数为 6 时,恰好有 11 种不同的 checkout 方式:
D3
D1 D2
S2 D2
D2 D1
S4 D1
S1 S1 D2
S1 T1 D1
S1 S3 D1
D1 D1 D1
D1 S2 D1
S2 S2 D1
注意,D1 D2 与 D2 D1 视为不同,因为它们结束于不同的 double。另一方面,S1 T1 D1 与 T1 S1 D1 视为相同。
另外,在统计组合时不计 miss;例如,D3 与 0 D3 以及 0 0 D3 视为同一组合。
总计一共有 42336 种不同的 checkout 方式。
问:分数小于 100 时,不同 checkout 方式共有多少种?
分析:本题是一个有限状态计数问题。核心约束只有两条:最后一镖必须是 double;前两镖的顺序不区分,但结束镖(最后一镖)不同则算不同方案。因为题目明确忽略 miss,所以 1 镖与 2 镖结束可以自然并入同一计数框架。
一、数学背景
把一次 checkout 记为 \((a,b,d)\):
- \(d\) 是结束镖,必须属于 double 集合;
- \(a,b\) 是前两镖,取自所有非 miss 投法;
- 若只需要两镖或一镖,则等价于省略前缀 miss,不需要显式放入 0 分投法。
关键判重规则:
- 固定结束镖 \(d\) 时,\((a,b,d)\) 与 \((b,a,d)\) 视为同一种;
- 但 \((a,b,d_1)\) 与 \((a,b,d_2)\) 在 \(d_1 \neq d_2\) 时是不同方案。
因此,前两镖可以建模为“可重复组合”(combination with replacement),结束镖单独枚举。
二、算法设计
候选方案对比
- 方案 A(基线):枚举三镖有序序列,允许 miss,再通过规范化和集合去重。
- 时间复杂度近似 \(O(M^3)\)(\(M\) 为单镖候选数,含 miss)。
- 实现简单,但判重逻辑繁琐,且会产生大量冗余状态。
- 方案 B(采用):枚举结束镖,再对前两镖做无序可重复组合,分别累加 1 镖、2 镖、3 镖结束。
- 时间复杂度 \(O(D \cdot (N + N^2))\),其中 \(D=21\)(结束 double 数),\(N=62\)(非 miss 投法数)。
- 状态规模很小,判重天然正确,代码清晰。
具体步骤
- 构造所有非 miss 投法:
S1..S20,S25D1..D20,D25T1..T20- 构造合法结束镖(仅 double):
D1..D20,D25- 对每个结束镖
d: - 累加一镖结束:
d - 累加两镖结束:
a + d(a遍历全部非 miss 投法) - 累加三镖结束:
a + b + d(a,b用可重复组合生成) - 得到每个分数的方案数后,求和
score < 100的区间。
三、复杂度分析
- 设非 miss 投法数为 \(N=62\),结束 double 数为 \(D=21\)。
- 两镖层枚举量:\(D \cdot N\)。
- 三镖层枚举量:\(D \cdot \binom{N+1}{2}\)。
- 总时间复杂度为 \(O(D \cdot (N + N^2))\),在本题规模下是常数级。
- 空间复杂度为 \(O(S)\),其中 \(S\) 是统计分数上界(本题只需到 99)。
四、代码实现与说明
"""Project Euler 109 - Darts."""
from __future__ import annotations
from itertools import combinations_with_replacement
from time import perf_counter
TARGET_LIMIT = 100
SAMPLE_SCORE = 6
SAMPLE_WAYS = 11
def build_all_scoring_darts() -> list[tuple[str, int]]:
"""Return all non-miss dart results with labels and scores."""
darts: list[tuple[str, int]] = []
darts.extend((f"S{n}", n) for n in range(1, 21))
darts.append(("S25", 25))
darts.extend((f"D{n}", 2 * n) for n in range(1, 21))
darts.append(("D25", 50))
darts.extend((f"T{n}", 3 * n) for n in range(1, 21))
return darts
def build_finishing_doubles() -> list[tuple[str, int]]:
"""Return all valid finishing doubles with labels and scores."""
doubles: list[tuple[str, int]] = [(f"D{n}", 2 * n) for n in range(1, 21)]
doubles.append(("D25", 50))
return doubles
def count_checkout_ways_by_score(max_score: int) -> list[int]:
"""Count distinct checkout ways for every score up to `max_score`."""
all_darts = build_all_scoring_darts()
finishing_doubles = build_finishing_doubles()
ways = [0] * (max_score + 1)
for _, finish_score in finishing_doubles:
if finish_score <= max_score:
ways[finish_score] += 1
for _, first_score in all_darts:
total = first_score + finish_score
if total <= max_score:
ways[total] += 1
for (_, first_score), (_, second_score) in combinations_with_replacement(all_darts, 2):
total = first_score + second_score + finish_score
if total <= max_score:
ways[total] += 1
return ways
def count_checkout_below(limit: int) -> int:
"""Return the number of distinct checkout ways for scores below `limit`."""
if limit <= 0:
return 0
ways = count_checkout_ways_by_score(limit - 1)
return sum(ways)
def run_solve_once(limit: int = TARGET_LIMIT) -> tuple[int, float]:
"""Compute the answer and elapsed seconds for one run."""
start = perf_counter()
answer = count_checkout_below(limit)
elapsed = perf_counter() - start
return answer, elapsed
def main() -> None:
"""Run validations and print the Project Euler 109 answer."""
ways = count_checkout_ways_by_score(SAMPLE_SCORE)
assert ways[SAMPLE_SCORE] == SAMPLE_WAYS, (
f"Sample mismatch: got {ways[SAMPLE_SCORE]}, expected {SAMPLE_WAYS}"
)
answer, elapsed = run_solve_once(TARGET_LIMIT)
print(f"checkouts_below_{TARGET_LIMIT} = {answer}")
print(f"solve_elapsed_seconds = {elapsed:.6f}")
if __name__ == "__main__":
main()
代码说明(按执行顺序):
常量区
TARGET_LIMIT指定统计区间上界(小于该值)。SAMPLE_SCORE与SAMPLE_WAYS用于题面样例断言。
build_all_scoring_darts
- 初始化
darts保存每一种非 miss 投法及其分值。 - 依次加入单倍区、外牛眼、双倍区、内牛眼、三倍区。
- 返回值中的 label 用于区分不同投法身份,避免把同分值不同区域混为一类。
build_finishing_doubles
- 构建合法结束镖集合,仅包含所有 double 与
D25。 - 这一步直接编码了“doubles out”约束。
count_checkout_ways_by_score
- 先调用前两个构造函数准备候选集合。
- 创建
ways数组,ways[s]表示总分恰好为s的 checkout 数。 - 外层循环固定结束镖
finish_score。 - 第一段分支累加一镖结束(只投结束镖)。
- 第二段循环累加两镖结束(前一镖 + 结束镖)。
- 第三段使用
combinations_with_replacement枚举前两镖无序可重复组合,累加三镖结束。 - 每次都检查
total <= max_score,保证索引合法。
count_checkout_below
- 处理
limit <= 0的边界情况。 - 调用
count_checkout_ways_by_score(limit - 1)获取所有score < limit的分布。 - 对数组求和得到目标计数。
run_solve_once
- 记录开始时间。
- 调用主计数函数得到结果。
- 返回结果与本次耗时,便于基准测试。
main
- 先计算样例分数的方案数并断言为题面给定值。
- 再执行正式求解并打印结果与耗时。
- 该流程确保“先样例后目标”的验证顺序。