949. 左与右 II(Left vs Right II)
Left 和 Right 用若干个单词(每个单词都由 L 与 R 组成)轮流进行游戏,Left 先手。
在 Left 的回合中,对每个单词都可以从左侧删除任意个字母(可以删 0 个),但不能把某个单词删空;并且在该回合中,至少要在某一个单词上删去至少 1 个字母。
Right 的回合规则对称:对每个单词从右侧删除任意个字母(也可为 0),同样不能删空,并且该回合至少要在某一个单词上删去至少 1 个字母。
当所有单词都被缩短到只剩 1 个字母时,游戏结束:若剩余字母里 L 的数量多于 R,则 Left 获胜;若 R 的数量多于 L,则 Right 获胜。
本题只考虑单词数量为奇数的情形,因此不会平局。
定义 \(G(n,k)\) 为:选取 \(k\) 个长度为 \(n\) 的单词(相同集合的不同排列按不同方案计数)时,在 Left 先手条件下,Right 拥有必胜策略的方案总数。
已知 \(G(2,3)=14\),并给出 \(G(4,3)=496\)、\(G(8,5)=26359197010\)。
求 \(G(20,7)\),答案对 \(1001001011\) 取模。
分析:直接在 \(2^n\) 个单词上再做 \(k\) 重博弈枚举会指数爆炸。关键在于把“一个单词对应的对子博弈”先压缩为可加的数值(并区分冷/热),然后把 \(k\) 个单词的组合问题转成“和分布”的卷积计数。
一、数学背景
把一个单词 \(w\) 看成一个“截断游戏”:
- Left 每步只能删左端,等价于把当前串替换为某个后缀;
- Right 每步只能删右端,等价于把当前串替换为某个前缀;
- 长度为 1 时游戏立即终止:
L视作对 Left 有利,R视作对 Right 有利。
对每个局面定义上下界:
- \(u(w)\):Left 能把局面推向的“最好下界”(从 Right 角度看越大越好);
- \(d(w)\):Right 能把局面推向的“最好上界”(从 Right 角度看越小越好)。
递推关系为:
若 \(u(w)<d(w)\),该局面是冷局面,可归约为某个最简 dyadic 值;若 \(u(w)\ge d(w)\),为热局面,只能保留区间信息。
当我们把每个长度 \(n\) 单词映射为一个上界值(统一缩放到整数)后,\(k\) 个单词的和可以分两类判定 Right 必胜:
- 总和严格小于 0;
- 总和等于 0 且所有单词都为冷局面。
这就是最终计数公式的来源。
二、算法设计
1) 预处理长度 n 全部单词的值
- 按长度从 1 到 \(n\) 动态规划;
- 对每个 bitstring(
0记L,1记R)计算best_left=max(d(suffix))与best_right=min(u(prefix)); - 若
best_left < best_right,调用“最简 dyadic 选择器”在区间内选标准值; - 否则标记为 hot,并保留区间上下界;
- 最后得到长度 \(n\) 的:
upper_values:每个单词的上界整数值;hot_flags:是否 hot。
2) 直方图与卷积
- 把
upper_values压成频次直方图all_hist; - 把冷局面子集压成
cold_hist; - 设 \(k=a+b\),其中 \(a=\lfloor k/2\rfloor,\ b=k-a\);
- 分别计算
all_hist的 \(a\) 次、\(b\) 次卷积分布;cold_hist的 \(a\) 次、\(b\) 次卷积分布。
3) 判定计数
- 用排序 + 前缀和 + 二分统计所有
x+y<0的配对数; - 再统计所有
x+y=0的冷配对数; - 两部分相加后对模数取模,得到 \(G(n,k)\)。
三、复杂度分析
- 预处理全部长度 \(n\) 单词:状态数 \(2^n\),每状态需遍历前后缀,时间约 \(O(n\cdot 2^n)\),空间约 \(O(2^n)\)(再乘一个与层数相关的常数)。
- 卷积分布阶段:复杂度与“可取值种类数”相关,记为 \(M\),本题中 \(k=7\) 很小,实测成本主要在预处理阶段。
- 对目标输入 \((n,k)=(20,7)\),Python 实测单次约 15.5~15.9 秒,满足 <60 秒要求。
四、代码实现与说明
"""Project Euler 949 - Left vs Right II.
该实现使用冷热博弈值(upper/lower stops)与分布卷积计数,计算 G(20, 7) mod 1001001011。
"""
from __future__ import annotations
from bisect import bisect_left
from time import perf_counter
MOD = 1_001_001_011
TARGET_N = 20
TARGET_K = 7
def ceil_div_by_pow2(value: int, shift: int) -> int:
"""返回 ceil(value / 2**shift)。"""
if shift == 0:
return value
if value >= 0:
return (value + (1 << shift) - 1) >> shift
return -((-value) >> shift)
def pick_simplest_dyadic_between(lower: int, upper: int, scale_exp: int) -> int:
"""在 (lower, upper) 中挑选最简单的 dyadic(同一 2**scale_exp 缩放)。"""
for stripped_bits in range(scale_exp + 1):
shift = scale_exp - stripped_bits
min_p = (lower >> shift) + 1
max_p = ceil_div_by_pow2(upper, shift) - 1
if min_p > max_p:
continue
if min_p > 0:
p = min_p
elif max_p < 0:
p = max_p
else:
p = 0
if stripped_bits and p and (p & 1) == 0:
if p + 1 <= max_p and ((p + 1) & 1):
p += 1
elif p - 1 >= min_p and ((p - 1) & 1):
p -= 1
return p << shift
return 0
def compute_upper_values_and_hot_flags(n: int) -> tuple[list[int], list[int]]:
"""计算所有长度 n 单词的 upper 值与 hot 标记。"""
scale_exp = n
scale = 1 << scale_exp
total_nodes = (1 << (n + 1)) - 1
upper = [0] * total_nodes
lower = [0] * total_nodes
len1_start = 1
upper[len1_start] = scale
lower[len1_start] = scale
upper[len1_start + 1] = -scale
lower[len1_start + 1] = -scale
hot_flags = [0] * (1 << n)
for length in range(2, n + 1):
layer_start = (1 << length) - 1
layer_size = 1 << length
for bits in range(layer_size):
best_left = -(1 << 60)
for suffix_len in range(1, length):
suffix_bits = bits & ((1 << suffix_len) - 1)
idx = (1 << suffix_len) - 1 + suffix_bits
if lower[idx] > best_left:
best_left = lower[idx]
best_right = 1 << 60
for prefix_len in range(1, length):
prefix_bits = bits >> (length - prefix_len)
idx = (1 << prefix_len) - 1 + prefix_bits
if upper[idx] < best_right:
best_right = upper[idx]
idx_now = layer_start + bits
if best_left < best_right:
canonical = pick_simplest_dyadic_between(best_left, best_right, scale_exp)
upper[idx_now] = canonical
lower[idx_now] = canonical
if length == n:
hot_flags[bits] = 0
else:
upper[idx_now] = best_left
lower[idx_now] = best_right
if length == n:
hot_flags[bits] = 1
full_start = (1 << n) - 1
full_upper = upper[full_start : full_start + (1 << n)]
return full_upper, hot_flags
def build_histogram(values: list[int], mod: int) -> dict[int, int]:
"""把数值列表压成频次直方图(模 mod)。"""
hist: dict[int, int] = {}
for value in values:
hist[value] = (hist.get(value, 0) + 1) % mod
return {key: cnt for key, cnt in hist.items() if cnt}
def convolve_histograms(
left_hist: dict[int, int],
right_hist: dict[int, int],
mod: int,
) -> dict[int, int]:
"""离散和分布卷积。"""
if not left_hist or not right_hist:
return {}
if len(left_hist) > len(right_hist):
left_hist, right_hist = right_hist, left_hist
out: dict[int, int] = {}
for x, cx in left_hist.items():
for y, cy in right_hist.items():
key = x + y
out[key] = (out.get(key, 0) + (cx * cy) % mod) % mod
return {key: cnt for key, cnt in out.items() if cnt}
def histogram_power(hist: dict[int, int], times: int, mod: int) -> dict[int, int]:
"""重复卷积 times 次(times 很小,直接迭代即可)。"""
if times == 0:
return {0: 1}
result = dict(hist)
for _ in range(1, times):
result = convolve_histograms(result, hist, mod)
return result
def count_pairs_with_negative_sum(
left_dist: dict[int, int],
right_dist: dict[int, int],
mod: int,
) -> int:
"""统计 x+y<0 的配对数(模 mod)。"""
sorted_items = sorted(right_dist.items())
sums = [value for value, _ in sorted_items]
prefix = [0]
running = 0
for _, cnt in sorted_items:
running = (running + cnt) % mod
prefix.append(running)
ans = 0
for x, cx in left_dist.items():
idx = bisect_left(sums, -x)
ans = (ans + cx * prefix[idx]) % mod
return ans
def count_pairs_with_zero_sum(
left_dist: dict[int, int],
right_dist: dict[int, int],
mod: int,
) -> int:
"""统计 x+y=0 的配对数(模 mod)。"""
if len(left_dist) > len(right_dist):
left_dist, right_dist = right_dist, left_dist
ans = 0
for x, cx in left_dist.items():
ans = (ans + cx * right_dist.get(-x, 0)) % mod
return ans
def compute_g(n: int, k: int, mod: int = MOD) -> int:
"""计算题目定义的 G(n, k)(k 需为奇数)。"""
if n <= 0:
raise ValueError("n must be positive")
if k % 2 == 0:
raise ValueError("k must be odd")
upper_values, hot_flags = compute_upper_values_and_hot_flags(n)
all_hist = build_histogram(upper_values, mod)
cold_values = [v for v, hot in zip(upper_values, hot_flags) if hot == 0]
cold_hist = build_histogram(cold_values, mod)
left_count = k // 2
right_count = k - left_count
all_left_dist = histogram_power(all_hist, left_count, mod)
all_right_dist = histogram_power(all_hist, right_count, mod)
strictly_negative = count_pairs_with_negative_sum(all_left_dist, all_right_dist, mod)
cold_left_dist = histogram_power(cold_hist, left_count, mod)
cold_right_dist = histogram_power(cold_hist, right_count, mod)
zero_and_all_cold = count_pairs_with_zero_sum(cold_left_dist, cold_right_dist, mod)
return (strictly_negative + zero_and_all_cold) % mod
def main() -> None:
"""运行样例与目标规模。"""
t0 = perf_counter()
sample_1 = compute_g(2, 3, 1 << 63)
sample_2 = compute_g(4, 3, 1 << 63)
sample_3 = compute_g(8, 5, 1 << 63)
prep_elapsed = perf_counter() - t0
print(f"G(2, 3) = {sample_1}")
print(f"G(4, 3) = {sample_2}")
print(f"G(8, 5) = {sample_3}")
assert sample_1 == 14, f"sample mismatch: {sample_1}"
assert sample_2 == 496, f"sample mismatch: {sample_2}"
assert sample_3 == 26359197010, f"sample mismatch: {sample_3}"
t1 = perf_counter()
answer = compute_g(TARGET_N, TARGET_K, MOD)
solve_elapsed = perf_counter() - t1
print(f"G({TARGET_N}, {TARGET_K}) mod {MOD} = {answer}")
print(f"sample_elapsed_seconds = {prep_elapsed:.3f}")
print(f"solve_elapsed_seconds = {solve_elapsed:.3f}")
if __name__ == "__main__":
main()
代码说明(按代码顺序):
模块导入与常量
- 模块开头的三引号字符串是文件级说明,明确题号和整体方法。
from __future__ import annotations让类型注解在运行时延迟求值,避免部分前向引用问题。bisect_left用于后续“统计和小于 0”的二分计数。perf_counter用于样例阶段和正式阶段的计时。MOD、TARGET_N、TARGET_K分别对应题目模数和目标输入参数。
ceil_div_by_pow2
- 该函数处理“除以 \(2^s\) 的上取整”,且同时兼容正负整数。
shift == 0时直接返回原值,避免额外分支运算。- 对非负数使用位运算形式
(value + 2^s - 1) >> s。 - 对负数利用
-((-value) >> s)保证数学意义上的上取整。
pick_simplest_dyadic_between
- 函数目标是在开区间
(lower, upper)中,选一个“最简 dyadic 表达”的整数编码值。 - 外层循环
stripped_bits从小到大,等价于优先找分母更小、结构更简单的 dyadic。 min_p与max_p给出当前分辨率下可行整数区间,若区间为空则继续细化。- 选点策略优先靠近 0(正区间取最小正,负区间取最大负,跨 0 直接取 0)。
- 当已剥离位数且
p为偶数时,尝试微调到相邻奇数,保持“最简”特性。 - 返回值通过
p << shift还原为统一缩放整数;若所有层都不可行,返回 0。
compute_upper_values_and_hot_flags
scale_exp = n、scale = 1 << n把所有值统一放到整数网格上。total_nodes = 2^(n+1)-1用于顺序存放长度 1 到 n 的全部 bitstring 节点。upper和lower分别存放每个节点的上、下界值。- 长度 1 的两种串是递推基:
L与R对应正负基础值(同一缩放)。 - 外层
length按子串长度递增,内层bits枚举当前长度的所有串。 best_left通过遍历所有真后缀并取lower最大值得到。best_right通过遍历所有真前缀并取upper最小值得到。- 若
best_left < best_right,该节点为冷局面,调用最简 dyadic 选择器把它压成单点值。 - 否则节点为热局面,只保留上下界,长度到达
n时把hot_flags置为 1。 - 最后切片提取长度恰为
n的upper数组并返回。
build_histogram
- 输入是值列表,输出是“值 -> 次数”的字典。
- 每次累加都在模
mod下进行,防止卷积阶段数值过大。 - 末尾过滤掉计数为 0 的键,减少后续卷积无效项。
convolve_histograms
- 这是离散分布的和卷积:若左取值为
x、右取值为y,新键是x+y。 - 任一输入为空时立即返回空字典。
- 先按键数大小交换,使外层循环遍历更小的字典,减少常数。
- 双重循环累加组合计数
cx * cy,并对模数取模。 - 返回前再次移除零计数字段,保持分布紧凑。
histogram_power
- 计算同一分布重复卷积
times次。 times == 0返回单位分布{0:1},满足组合学上的空和定义。- 其余情况从
result = hist开始,循环执行卷积,适合本题很小的k。
count_pairs_with_negative_sum
- 先把右分布按键排序,拆出有序数组
sums。 - 构建前缀和
prefix,表示“右侧值小于某下标位置的累计频次”。 - 遍历左分布每个
x,要统计满足y < -x的右值数量。 - 利用
bisect_left(sums, -x)找到第一处>= -x的位置,其前面即全部满足条件。 - 用
cx * prefix[idx]贡献到总答案并取模。
count_pairs_with_zero_sum
- 统计
x + y = 0的配对。 - 先让较小字典在外层循环,减少哈希查询次数。
- 对每个
x直接查right_dist[-x],累加cx * 对应频次。 - 全过程保持模运算。
compute_g
- 首先做参数校验:
n必须正,k必须奇数。 - 调
compute_upper_values_and_hot_flags获得长度n全单词信息。 - 用全部单词构建
all_hist,用冷单词构建cold_hist。 - 把
k拆成left_count与right_count,用于两侧独立卷积分布。 strictly_negative统计总和严格小于 0 的情况。zero_and_all_cold统计总和等于 0 且两边都只来自冷集合的情况。- 两部分相加并取模即返回题目所求。
main
- 先计算三个已知样例:
G(2,3)、G(4,3)、G(8,5)。 - 通过断言保证样例与题面给值一致,样例不通过时会立即抛错终止。
- 然后计算目标
G(20,7)并输出结果与耗时。 - 脚本入口
if __name__ == "__main__":保证文件可直接运行。