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)\)

关键判重规则:

因此,前两镖可以建模为“可重复组合”(combination with replacement),结束镖单独枚举。

二、算法设计

候选方案对比

具体步骤

  1. 构造所有非 miss 投法:
  2. S1..S20, S25
  3. D1..D20, D25
  4. T1..T20
  5. 构造合法结束镖(仅 double):
  6. D1..D20, D25
  7. 对每个结束镖 d
  8. 累加一镖结束:d
  9. 累加两镖结束:a + da 遍历全部非 miss 投法)
  10. 累加三镖结束:a + b + da,b 用可重复组合生成)
  11. 得到每个分数的方案数后,求和 score < 100 的区间。

三、复杂度分析

四、代码实现与说明

"""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()

代码说明(按执行顺序):

常量区

build_all_scoring_darts

build_finishing_doubles

count_checkout_ways_by_score

count_checkout_below

run_solve_once

main