CYaRon Is All You Need | 使用 CYaRon 制造数据

· · 科技·工程

你还在被 Datagen 困扰吗?

需要一棵树、一张没有自环和重边的连通图、几组区间操作?还要让标程自动生成输出。放进同一套对拍?

让我们使用 CYaRon 吧!(肯定和 CYaRon! 没有关系!)

英明的你谷想到了这一点。你谷的 CYaRon 把这些环节放在了一组接口中,极大方便了数据生成和测试的流程!CYaRon 正是用来处理这些重复工作的!(当然,它不能替你写 std),但可以让你造结构、写文件、运行程序和比较输出连起来!

注:发挥大注意力注意到疑似没有完整介绍 CYaRon 的文章,作此篇,兼怀子由。

:::warning[开始前注意]{open}

  1. 你需要会 Python 的基础使用。
  2. 请通过 pip install cyaron 提前安装西外诶而欧恩。
  3. 为了方便,大部分示例使用 from cyaron import *(虽然这样似乎不太像工程规范)。如无特殊说明都采用这种写法。
  4. 我们注意到 VectorRandomMode 不在默认导出,所以被单独导入了。

Example

第一次使用时,先生成几组 .in/.out 文件。

这里提供一个示例。

from cyaron import *

point_counts = ati([0, 5, 20, 100])
edge_counts = ati([0, 4, 30, 300])

for case_id in range(1, 4):
    io = IO(file_prefix="data", data_id=case_id)
    n = point_counts[case_id]
    m = edge_counts[case_id]

    io.input_writeln(n, m)
    graph = Graph.graph(
        n,
        m,
        weight_limit=(1, 100),
        self_loop=False,
        repeated_edges=False,
    )
    io.input_writeln(graph.to_str(shuffle=True))
    io.output_gen("std.exe")

好了,你已经会写数据器了!(会写 int main(){return 0;} 了!)

我们注意到:

ati() 会把可迭代对象逐项转成 int。数据规模写成 1e4、2e5 时,可以用它转回整数。

让我们 import

一般写一行即可。

from cyaron import *

它会导出这些:

Compare  Edge  Graph  IO  Merger
Polygon  Sequence  String  Vector

:::info[注] PyPI 版似乎没有 RangeQuery,但如果你查看 GitHub 源码会发现是有的。(这是为什么?) :::

Python random 中常用的 choice、randint、randrange 和 uniform 也会一并导出。(你谷贴心的让你可以直接写 randint 什么的,不需要导入)不过 random 这个名字有点特殊:from cyaron import * 之后它指向的是 random 模块本身(被 cyaron.math 里同名的导入覆盖了),直接写 random() 会报 TypeError,得写 random.random()。

但是!But!这个需要单独导入!

from cyaron.vector import VectorRandomMode

:::info[其他] 如果你非常有兴趣去翻代码的话还会发现一个 visual.py 和 visualize(),不过并不能使用。

不知道是干什么的。

IO 管理文件和标程

让我们使用 IO 吧!

它可以非常方便的管理文件。图、序列和字符串最终通过它写进文件,标程生成输出和程序对拍也会使用它。

创建文件

让我们创建文件!

from cyaron import *

IO("a.in", "a.out")
IO(file_prefix="data")
IO(file_prefix="data", data_id=3)
IO("data{}.in", "data{}.out", data_id=3)
IO(file_prefix="hack/case", data_id=7, make_dirs=True)
IO(file_prefix="only_input", disable_output=True)
IO()

这些写法分别对应以下场景。

可以使用 with 让文件在离开 with 块后关闭。

from cyaron import *

with IO(file_prefix="data", data_id=1) as io:
    io.input_writeln(1, 2, 3)

写入内容

input_write()、input_writeln() 写输入文件,output_write()、output_writeln() 写输出文件。

带 ln 的函数会补换行,不带就不补。

from cyaron import *

io = IO("demo.in", "demo.out")
io.input_writeln(3, 5)
io.input_writeln([1, 4, 9])
io.input_write("hello")
io.input_write("world", separator=",")
io.input_writeln()
io.output_writeln("8")

:::info[输出] demo.in:

3 5
1 4 9
hello,world

demo.out:

8

:::

写入的默认分隔符是空格,列表和元组会递归展开(所以你可以注意到例子里列表的方括号没有写进文件)

需要其他分隔符时传入 separator。

from cyaron import *

with IO("comma.in", disable_output=True) as io:
    io.input_writeln(
        Sequence(lambda i, previous: i - 1).get(1, 10),
        separator=",",
    )

input_clear_content(pos=0) 和 output_clear_content(pos=0) 可以截断文件。flush_buffer() 会刷新输入缓冲区。程序需要立刻读取刚写入的内容时,这个方法能帮助排查缓冲问题。

Run std

from cyaron import *

with IO(file_prefix="sum", data_id=1) as io:
    io.input_writeln(5)
    io.input_writeln(1, 2, 3, 4, 5)
    io.output_gen("std.exe")

output_gen() 会把当前输入文件交给标程,将标准输出写进对应的 .out 文件。

常用参数包括 shell_cmd、单位为秒的 time_limit(你可以让标程 \textcolor{purple}{\text{Time Limit Exceeded}}?)和控制换行的 replace_EOL。

输出已经关闭的 IO 对象不能调用 output_gen()。

Graph 生成图

图是 CYaRon 最常用的一部分。随机图、树、连通图和 DAG 都可以直接使用,手工构造则从 Graph 本身开始。

手工建图和输出

from cyaron import *

graph = Graph(4, directed=True)
graph.add_edge(1, 2, weight=7)
graph.add_edge(2, 4, weight=3)
graph.add_edge(1, 3, weight=9)

print(graph.edge_count())
print(graph)

:::info[输出]

3
1 2 7
1 3 9
2 4 3

:::

点默认从 1 开始编号。无向图调用一次 add_edge(1, 2) 就够了,内部会保存反向边,iterate_edges() 和字符串输出只会把这条无向边输出一次。

str(graph) 等价于 graph.to_str()。无权图可以换用 Edge.unweighted_edge。

from cyaron import *

graph = Graph.graph(5, 6, self_loop=False, repeated_edges=False)
print(graph.to_str(output=Edge.unweighted_edge))

:::info[输出] 注:图本身是随机生成的,每次输出都不一样,这里仅给出一种情况。

1 5
1 2
2 3
2 5
3 5
3 4

:::

to_str(shuffle=True) 会随机重映射点号,同时打乱输出,影响点号和边的输出顺序。可以让构造规律不出现在点号中。

邻接矩阵

from cyaron import *

graph = Graph(3, True)
graph.add_edge(1, 3, weight=3)
graph.add_edge(2, 3, weight=7)
graph.add_edge(2, 3, weight=4)

print(graph.to_matrix())
print(graph.to_matrix(default=0))
print(graph.to_matrix(default=0, merge=lambda old, edge: max(old, edge.weight)))

:::info[输出]

-1 -1 3
-1 -1 4
-1 -1 -1
0 0 3
0 0 4
0 0 0
0 0 3
0 0 7
0 0 0

:::

默认情况下,不存在的边记为 -1。多重边会以后一次写入的权值覆盖前一次。merge(old_value, edge) 能改成取最大值、最小值或其他规则。

树和森林

from cyaron import *

chain = Graph.chain(6, weight_limit=(1, 10))
flower = Graph.flower(6, weight_limit=10)
random_tree = Graph.tree(10)
mostly_chain = Graph.tree(10, chain=1)
mostly_flower = Graph.tree(10, flower=1)
mixed_tree = Graph.tree(10, chain=0.4, flower=0.3)
forest = Graph.forest(20, 4, weight_limit=10)

chain() 生成链,flower() 生成菊花图,tree() 用于随机树,forest(total_points, tree_count) 生成指定连通块数的森林。

Graph.tree() 的 chain 和 flower 都在 0 到 1 之间(试一试一起开会不会炸?)控制树形偏向,精确控制父亲时则使用 father_gen(cur)。

from cyaron import *

graph = Graph.tree(
    10,
    weight_gen=lambda: randint(100, 200),
    father_gen=lambda current: max(1, current - randint(1, 3)),
)

二叉树由 binary_tree() 生成。

from cyaron import *

graph = Graph.binary_tree(15, left=0.35, right=0.35, weight_limit=(1, 5))

left 和 right 都必须位于 [0, 1],二者之和不能超过 1。

一般图、连通图和 DAG

from cyaron import *

graph = Graph.graph(
    8,
    12,
    directed=False,
    self_loop=False,
    repeated_edges=False,
    weight_limit=(1, 20),
)

Graph.graph() 默认允许自环和重边。题目要求简单图时,务必显式写出 self_loop=False, repeated_edges=False。

无向连通图使用 UDAG()。

from cyaron import *

graph = Graph.UDAG(
    10,
    18,
    self_loop=False,
    repeated_edges=False,
    weight_limit=100,
)

边数必须至少为 point_count - 1。UDAG() 仍默认允许自环,简单图同样要明确关闭自环和重边。

from cyaron import *

graph = Graph.DAG(
    10,
    20,
    self_loop=False,
    repeated_edges=False,
    weight_limit=(1, 9),
)

DAG() 在 loop=False 时按点号约束方向,通常可以保证无环。将 loop=True 打开后,接口不再强制 u \lt v,结果未必还是严格意义上的 DAG。

Graph.connected() 只是统一入口。directed=False 时调用 UDAG(),directed=True 时调用 DAG()。

特殊图

作为万恶的出题或数据,想必是要让:

关于 SPFA 它死了。

CYaRon 贴心的为你提供了 hack_spfa()。

from cyaron import *

graph = Graph.hack_spfa(
    30,
    directed=True,
    extra_edge=4,
    weight_limit=(1, 100),
)

hack_spfa() 提供一份偏向卡 SPFA 的内置构造。虽然不一定能卡所有,但也可以让选手感到十分开心。

from_degree_sequence() 可以按度数序列生成图。

from cyaron import *

undirected = Graph.from_degree_sequence((2, 2, 1, 1, 1, 1))
directed = Graph.from_degree_sequence(((1, 2), (1, 1), (1, 0)))

无向图传入整数序列,有向图传入 (in_degree, out_degree) 序列。

self_loop、repeated_edges、weight_limit、weight_gen、n_iter 和 iter_limit 都可以进一步控制生成过程。

Vector 批量生成参数

Vector 适合生成点集、坐标、操作参数和随机数对。

from cyaron import *

points = Vector.random(5, [(1, 10), (1, 20)])
print(points)

position_range 中的一项代表一个维度。单个整数 x 表示 [0, x],二元组 (left, right) 表示闭区间 [left, right]。

Vector.random(5, [10])
Vector.random(5, [(1, 10)])

前者生成范围为 0 到 10 的一维数,后者从 1 到 10。

Vector.random() 有三种模式。

from cyaron import *

unique_points = Vector.random_unique_vector(5, [(1, 10), (1, 10)])
repeatable_points = Vector.random_repeatable_vector(5, [(1, 10), (1, 10)])
float_points = Vector.random_float_vector(5, [(0, 1), (0, 1)])

默认模式为 unique。唯一模式下,向量数量超过整个取值空间时会抛出 ValueError。

from cyaron import *

n = 8
m = 5
array = Vector.random(n, [(1, 100)])
operations = Vector.random_repeatable_vector(
    m,
    [(1, n), (1, n), (1, 100)],
)

这类写法比连续写多个 randint() 更容易检查维度和范围。

String 生成随机文本

from cyaron import *

lowercase = String.random(10)
custom = String.random(12, charset="abc")
mixed = String.random(20, charset=ALPHABET + NUMBERS)

默认字符集是 ALPHABET_SMALL。当 charset 是列表或元组时,String.random() 会从中选一个完整元素。(并不是字符哦!)

from cyaron import *

token = String.random(None, charset=["YES", "NO", "MAYBE"])

这会得到三个单词中的一个。

from cyaron import *

sentence = String.random_sentence(
    8,
    word_length_range=(2, 6),
    first_letter_uppercase=True,
    charset="abcde",
    word_separators=" ",
    sentence_terminators=".",
)

paragraph = " ".join(
    String.random_sentence(
        randint(6, 10),
        charset=["aaa", "bbb", "ccc"],
        sentence_terminators=".",
    )
    for _ in range(5)
)

random_sentence() 可以控制词数、词长、首字母、字符集、词间分隔符和句末符。random_paragraph() 与 word_count_range 一起使用目前会炸掉(PyPI 版和 GitHub 源码都一样),所以你可以注意到上面的写法比较诡异(并没有使用 word_count_range)。

满足格式约束的字符串还可通过正则生成。

from cyaron import *

identifier = String.random_regular(r"[0-9]+\w_.{0,9}", limit=5)

limit 控制各量词(如 +、{m,n})的最大重复次数,默认值为 10,它不是整串长度的上界。传入多个 pattern 时会随机选择其中一个。

Sequence 写递推数列

from cyaron import *

arithmetic = Sequence(lambda i, previous: 2 * i + 1)
print(arithmetic.get(1))
print(arithmetic.get(1, 5))

fibonacci = Sequence(
    lambda i, previous: previous(i - 1) + previous(i - 2),
    [0, 1],
)
print(fibonacci.get(2, 10))

formula(i, previous) 中的 i 是当前下标,previous 用于访问前面的项。初值可以是列表、元组或字典。

from cyaron import *

sequence = Sequence(lambda i, previous: previous(i - 1) + 3, {5: 100})
print(sequence.get(5))
print(sequence.get(6))

get(i) 取单项,get(left, right) 返回闭区间 [left, right] 的列表。内部带缓存,递推数列不需要反复计算已求出的项。

Polygon 处理几何数据

from cyaron import *

polygon = Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
print(polygon.perimeter())
print(polygon.area())

显然这是一个正方形。

perimeter() 返回周长,area() 返回面积。

from cyaron import *

hull = Polygon.convex_hull(
    20,
    fx=lambda x: int(x * 1000),
    fy=lambda y: int(y * 1000),
    strict=True,
)

points = Vector.random_unique_vector(30, [(0, 1000), (0, 1000)])
simple = Polygon.simple_polygon(points)

convex_hull() 先在接近 [0, 1] 的区域生成点,再通过 fx、fy 映射到目标坐标。strict=True 会倾向避免共线边界点。simple_polygon() 用给定点集构造不自交的简单多边形。

Compare 对拍和比较输出

数据生成后,可以让 CYaRon 直接运行程序并比较结果。

比较现成输出

from cyaron import *

standard = IO("dummy.in", "std.out")
standard.output_writeln("114514")
Compare.output("another.out", std=standard)

std 可以是文件路径,也可以是 IO 对象。默认评测器是 DEFAULT_GRADER 的 NOIPStyle。

运行程序后比较

from cyaron import *

io = IO()
io.input_writeln(4)
io.input_writeln(1, 2, 3, 4)

Compare.program(
    "my.exe",
    input=io,
    std_program="std.exe",
)

同一份输入会先后交给待测程序和标程,再由指定评测器比较输出。可以同时传入多个待测程序。

from cyaron import *

io = IO()
io.input_writeln("hello")

Compare.program(
    "prog1.exe",
    "prog2.exe",
    "prog3.exe",
    input=io,
    std_program="std.exe",
)

NOIPStyle 会统一换行符,去掉每行行尾空白和结尾多余空行。FullText 对完整文本计算 SHA-256,任何字符差异都会判为不同。(除非你想体验恶心 \textcolor{orange}{\text{Presentation Error}} 不然还是 NOIPStyle 吧)

from cyaron import *

io = IO()
io.input_writeln(1)

Compare.program(
    "my.exe",
    input=io,
    std_program="std.exe",
    grader="FullText",
)

待测程序可以写成 (command, timeout),超时单位为秒。

from cyaron import *

Compare.program(
    (("python", "wa.py"), 1),
    input=IO(),
    std_program=("python", "std.py"),
)

Compare.output() 和 Compare.program() 都支持 max_workers、job_pool。默认 max_workers=-1 不会自动创建线程池。需要并行时,显式传 None 或非负整数。

Merger 拼接多张图

from cyaron import *

first = Graph.tree(5, weight_limit=10)
second = Graph.graph(4, 5, self_loop=False, repeated_edges=False, weight_limit=10)

merged = Merger(first, second)
merged.add_edge((0, 3), (1, 2), weight=114)
print(merged)

Merger(first, second, ...) 会重新编排各图的点号。add_edge((graph_index, vertex), (graph_index, vertex), weight=...) 用于跨图连边。底层大图保存在 merged.G,str(merged) 和 merged.to_str() 都能直接输出结果。

常量、数学函数和小工具

默认导出的常量包括以下内容。

PI  E
ALPHABET_SMALL  ALPHABET_CAPITAL  ALPHABET  NUMBERS
SENTENCE_SEPARATORS  SENTENCE_TERMINATORS  DEFAULT_GRADER

数论和组合相关函数包括 is_prime()、miller_rabin()、factor()、prime_sieve()、exgcd()、mod_inverse()、phi()、miu()、fibonacci()、binomial() 和 catalan_number()。

utils 中常见的小工具有 ati()、list_like()、int_like()、strtolines()、make_unicode()、process_args() 和 escape_path()(最后这个和 RangeQuery 一样只在 GitHub 源码里)。不再一一介绍。

process_args() 识别 --randseed=,可用固定种子复现数据。

from cyaron import *

process_args()
print(randint(1, 100))
python gen.py --randseed=114514

日志详细程度可由 log.set_quiet()、log.set_normal() 和 log.set_verbose() 控制。

模板

这里写一份模板(可以 Ctrl/Command-C-V)。你也可以写一份自己的模板以便使用。(或者让能工智人生成?)

from cyaron import *

def random_ranges(count, upper):
    """生成 count 个 [1, upper] 上的区间 (l, r),保证 l <= r。"""
    ranges = []
    for _ in range(count):
        l = randint(1, upper)
        ranges.append([l, randint(l, upper)])
    return ranges

for case_id in range(1, 1001):
    io = IO()
    n = randint(1, 30)
    q = randint(1, 30)

    io.input_writeln(n, q)
    io.input_writeln(Vector.random_repeatable_vector(n, [(1, 100)]))
    for l, r in random_ranges(q, n):
        io.input_writeln(l, r)

    try:
        Compare.program(
            "my.exe",
            input=io,
            std_program="bf.exe",
            grader="NOIPStyle",
        )
    except Exception:
        # IO() 的临时文件会在进程退出时被删掉,先把失败输入拷出来
        io.flush_buffer()
        with open(io.input_filename, "r", encoding="utf-8") as fin, \
                open(f"failed_{case_id}.in", "w", encoding="utf-8") as fout:
            fout.write(fin.read())
        print(f"Failed on case {case_id}")
        print(f"Input saved at: failed_{case_id}.in")
        raise

注意

  1. Graph.graph() 默认允许自环和重边。简单图必须明确写出 self_loop=False, repeated_edges=False。
  2. Vector.random() 的整数范围是 [0, x],RangeQuery.random() 的整数范围是 [1, x]。
  3. Graph.to_str(shuffle=True) 会重映射点号。
  4. String.random() 的 charset 为列表或元组时,返回一个完整元素。
  5. chain() 和 flower() 的当前默认方向是无向的,但似乎没有人注意到这一点。

实战

让我们使用 CYaRon 来试一试吧!

实战?

随机生成发现几组都几乎一样?没有子任务限制?或者……

“根据最新消息,敌军摧毁了第 45 号、171 号据点,我军依然顽强抗争,修复了据点 37、98。现在可以反攻吗?”

“不可以,总司令。”

“敌军摧毁了第 33 号据点到 42 号据点的虫洞,现在可以反攻吗?”

“不可以,总司令。”

“我方修复了第 42 号据点,可以反攻吗?”

“不可以,总司令。”

“我方……”

“不可以,总司令。”

“同志,你只是一直在回答‘不可以’吗?你到底有没有好好判断形势?现在是关乎国家危亡的时刻……”

“总司令,您知道我不太聪明,没找到快速而正确地计算出结果的办法,但是据某项统计,我一直回答‘NO’的话,在一次战役中判断完全正确的概率是 45%。”

“那……”

“不可以,总司令。”

显然你需要先想清楚 Subtask,想清楚准备卡掉哪种假算法。到处 randint 已经把选手半夜笑醒了。

CYaRon 可以直接非常方便就把基础结构迅速造出来。 但是题目要求存在性、唯一性、合法操作序列这类性质,需要自己解决。

测试点

一般每个 Subtask 通常不只是把 n 的上界逐档调大。边数、权值、字符串总长、操作种类都可能跟着变。所以是不能直接 if 完事的。

拿一道 DAG 题目举例,假设三档数据分别限制 n,m,w_i。

测试点 n m w_i
1-5 1\le n\le 1000 n-1\le m\le 2000 1\le w_i\le 300
6-10 1\le n\le 20000 n-1\le m\le 50000 1\le w_i\le 500
11-20 1\le n\le 10^6 n-1\le m\le 5\times10^6 1\le w_i\le 500

十秒钟找出所有坑?

  1. 测试点 1 到 5 的权值上界是 300,全填 500 显然不可以。

  2. CYaRon 的简单 DAG 最多只有 \dfrac{n(n-1)}{2} 条不同边。

找出坑了?那写一下?

:::info[数据生成器代码]

from cyaron import *

for case_id in range(1, 21):
    io = IO(file_prefix="dag_", data_id=case_id)

    if case_id <= 5:
        max_n, max_m, max_w = 1000, 2000, 300
    elif case_id <= 10:
        max_n, max_m, max_w = 20000, 50000, 500
    else:
        max_n, max_m, max_w = 10**6, 5 * 10**6, 500

    # 这里就是你自己设计的了,这个示例是选择 1 号测最小规模,5、10、20 号测最大 n
    if case_id == 1:
        n = 1
    elif case_id in (5, 10, 20):
        n = max_n
    else:
        n = randint(500, max_n)

    max_edges = n * (n - 1) // 2
    upper_m = min(max_m, max_edges)
    lower_m = n - 1
    if lower_m > upper_m:
        raise ValueError(f"case {case_id}: no feasible edge count for n={n}")

    m = upper_m if case_id in (5, 10, 20) else randint(lower_m, upper_m)
    if case_id in (5, 10, 20):
        weight_gen = lambda: max_w
    else:
        weight_gen = lambda: randint(1, max_w)
    graph = Graph.DAG(
        n,
        m,
        weight_gen=weight_gen,
        repeated_edges=False,
    )

    # 检查一下,当然你不检查也不是不行
    assert 1 <= n <= max_n
    assert lower_m <= m <= upper_m

    io.input_writeln(n, m)
    io.input_writeln(graph)
    io.output_gen("std.exe")

:::

当然,除了满足题目要求还要注意一些小问题。

  1. 最小值和边界值。 需要给小规模、上限、关键参数留出测试点。随机显然是测不出来上界炸掉的边缘问题的。
  2. 结构性质。 子任务如果限制了树形、字符集、权值或操作类型,也是需要单独编写的。
  3. 结构形态差异。 同样规模的 n 和 m,既能生成链,也能生成星图、稀疏图、稠密图或随机树。如果选手口胡做法只在深链上爆,或者在稠密图里 \textcolor{purple}{\text{Time Limit Exceeded}},光把规模调大不一定能卡掉。
  4. 分布。 无解与有解、答案为 0 与极大值、可行解是否唯一?需要你考虑一下。

顺序

如果有多种性质,也是有解决顺序要求的。(例如先选 n,再算 m_{\min}=n-1、m_{\max}=\min(M,\frac{n(n-1)}{2}),最后才在交集里取 m。)

区间操作先选左端点,再选不小于它的右端点,保证 1\le l\le r\le n。(为什么?随机 l 和 r 不一定合法呀!)

具体的注意事项不再一一列举了(不然也写不完……)不过需要注意的是怎样从答案性质反推构造,避免整份测试数据都被 NO 或 -1 占满。(依旧不可以,总司令!)

实际使用

既然这样,那来试一试吧!(以下例子均适用于 Microsoft Windows,所以使用 std.exe 作为示例标准程序名称,你需要自行准备 std 程序。)

::::info[P8819 [CSP-S 2022] 星战]{open} 可查看题目。

你会注意到这一题就是著名的“不可以总司令”原题!

把活着和被毁的边分别放进 EdgePool 来高效率随机取边,也能按值删边。

随机图的边数如果不等于 n,那么“所有点的出度都恰好为 1”显然是不可能的。

即便 m=n,普通随机图也不保证初始状态满足条件。所以这里直接构造 m=n 的函数图“先破坏一小批边、再逐条原样修回”的片段生成,片段修完的瞬间状态回到初始函数图,答案就是 YES 了!

所以你会注意到 不可以,总司令! 在这个数据下骗不到多少分。

from cyaron import *

class EdgePool:
    """支持 O(1) 随机取边、按值删边的动态边集"""
    def __init__(self):
        self.items = []
        self.pos = {}

    def __len__(self):
        return len(self.items)

    def __contains__(self, edge):
        return edge in self.pos

    def add(self, edge):
        if edge not in self.pos:
            self.pos[edge] = len(self.items)
            self.items.append(edge)

    def discard(self, edge):
        index = self.pos.pop(edge)
        last = self.items.pop()
        if index < len(self.items):
            self.items[index] = last
            self.pos[last] = index

    def random(self):
        return choice(self.items)

for id in range(1, 21):
    io = IO(file_prefix="galaxy_", data_id=id)

    if id == 1:
        n = 5
        q = randint(2, 50)
        allow_t2 = allow_t4 = True
    elif id <= 3:
        n = randint(2, 10)
        q = randint(2, 50)
        allow_t2 = allow_t4 = True
    elif id <= 8:
        n = randint(2, 10**3)
        q = randint(2, 10**3)
        allow_t2 = allow_t4 = True
    elif id <= 10:
        n = randint(2, 5 * 10**5)
        q = randint(2, 5 * 10**5)
        allow_t2 = allow_t4 = False     # 无 t = 2 和 t = 4
    elif id <= 12:
        n = randint(2, 5 * 10**5)
        q = randint(2, 5 * 10**5)
        allow_t2, allow_t4 = True, False    # 无 t = 4
    elif id <= 16:
        n = randint(2, 10**5)
        q = randint(2, 5 * 10**5)
        allow_t2 = allow_t4 = True
    else:
        n = randint(2, 5 * 10**5)
        q = randint(2, 5 * 10**5)
        allow_t2 = allow_t4 = True

    # 初始图取 m = n 的函数图:每个点恰好一条出边(无自环),
    # 所有出度天生为 1,恰好就是“可以反攻”的状态。
    # id = 1 固定为一个完整的单环。
    if id == 1:
        edges = [(u, u % n + 1) for u in range(1, n + 1)]
    else:
        edges = []
        for u in range(1, n + 1):
            v = randint(1, n - 1)       # 随机排除自环
            if v >= u:
                v += 1
            edges.append((u, v))
    m = n

    io.input_writeln(n, m)

    in_edges = [[] for _ in range(n + 1)]
    for u, v in edges:
        in_edges[v].append((u, v))
        io.input_writeln(u, v)

    io.input_writeln(q)

    alive = EdgePool()        # 当前可用的虫洞
    destroyed = EdgePool()    # 当前被摧毁的虫洞
    for edge in edges:
        alive.add(edge)

    step = 0
    used_t2_ever = False
    while step < q:
        remain = q - step
        k_max = min(4, remain // 2)
        if k_max >= 1:
            # 规划一个“先破坏一小组边、再原样逐条修回”的片段:
            # 修完的瞬间状态回到初始函数图,出度全为 1,答案就是 YES;
            # 破坏阶段与修复中途的答案则是 NO。
            k_target = randint(1, k_max)
            plan = []
            cost = 0
            locked = set()      # 已被据点级摧毁的终点,不能再单独删它的入边
            picked = set()
            tries = 0
            while len(plan) < k_target and tries < 64:
                tries += 1
                need_v = allow_t2 and not used_t2_ever and \
                    not any(p[0] == 'v' for p in plan)
                if allow_t2 and (need_v or randint(0, 2) == 0):
                    u = alive.random()[1]
                    extra = 2 if allow_t4 else 1 + len(in_edges[u])
                    if u in locked or len(in_edges[u]) == 0 or cost + extra > remain:
                        continue
                    plan.append(('v', u))
                    locked.add(u)
                    cost += extra
                else:
                    e = alive.random()
                    if e in picked or e[1] in locked or cost + 2 > remain:
                        continue
                    plan.append(('e', e))
                    picked.add(e)
                    cost += 2
            if not plan:
                plan.append(('e', alive.random()))

            for kind, val in plan:              # 破坏
                if kind == 'e':
                    u, v = val
                    alive.discard(val)
                    destroyed.add(val)
                    io.input_writeln(1, u, v)
                else:
                    io.input_writeln(2, val)
                    for e2 in in_edges[val]:
                        if e2 in alive:
                            alive.discard(e2)
                            destroyed.add(e2)
                step += 1
            for kind, val in plan:              # 先逐条修单边
                if kind == 'e':
                    u, v = val
                    destroyed.discard(val)
                    alive.add(val)
                    io.input_writeln(3, u, v)
                    step += 1
            for kind, val in plan:              # 再修复据点级摧毁
                if kind == 'v':
                    u = val
                    if allow_t4:
                        io.input_writeln(4, u)
                        step += 1
                        for e2 in in_edges[u]:
                            if e2 in destroyed:
                                destroyed.discard(e2)
                                alive.add(e2)
                    else:
                        for e2 in in_edges[u]:
                            if e2 in destroyed:
                                destroyed.discard(e2)
                                alive.add(e2)
                                io.input_writeln(3, e2[0], e2[1])
                                step += 1
            used_t2_ever = used_t2_ever or any(p[0] == 'v' for p in plan)
        else:
            # 只剩一步:随便删一条边收尾
            u, v = alive.random()
            alive.discard((u, v))
            destroyed.add((u, v))
            io.input_writeln(1, u, v)
            step += 1

    io.output_gen("std.exe")

:::warning[注意]{open} 边的条数上界在无自环时是 n(n-1)(有向)或 \frac{n(n-1)}{2}(无向)。

如果使用随机边数需要考虑边界情况。
::::

::::info[P14359 [CSP-J 2025] 异或和]{open} 可查看题目。

要求为给定长度为 n 的非负整数序列和整数 k,要求选出尽可能多的互不相交区间,使每个区间的异或和都为 k。

如果数组完全随机生成,由于前缀异或和分散,能凑出异或和为 k 的区间通常极少,所以是不行的。

让我们构造边界!

:::info[想一想]{open} 第二种交替构造不能直接套到 k=0。此时数组全变成零,每个单点反而都满足条件。 :::

第二种构造不能直接套到 k=0。这时数组会变成全零,每个单点又都合法了。生成器里把它单独归到全零,免得注释和实际数据说两套话。

from cyaron import *

def make_array(n, k, vmax, mode):
    if mode == 0:
        return [k] * n
    if mode == 1:
        if k == 0:
            # [0, 0, ...] 的每个单点区间都合法
            return [0] * n
        return [k if i % 2 == 0 else 0 for i in range(n)]
    return Vector.random_repeatable_vector(n, [(0, vmax)])

for id in range(1, 21):
    io = IO(file_prefix="xor_", data_id=id)

    if id == 1:
        n, k, vmax = 2, 0, 1
        a = [1] * n  # 特殊性质 A
    elif id == 2:
        n, k, vmax = 10, randint(0, 1), 1
        a = Vector.random_repeatable_vector(n, [(0, 1)])
    elif id == 3:
        n, k, vmax = 100, 0, 1
        a = [1] * n  # 特殊性质 A
    elif id <= 5:
        n, k, vmax = 100, randint(0, 1), 1
        a = Vector.random_repeatable_vector(n, [(0, 1)])
    elif id <= 8:
        n, k, vmax = 100, randint(0, 255), 255
        a = Vector.random_repeatable_vector(n, [(0, 255)])
    elif id <= 10:
        # 测试点 9, 10:k <= 255 且 a_i <= 255(特殊性质 C)
        n, k, vmax = 1000, randint(0, 255), 255
        a = make_array(n, k, vmax, randint(0, 2))
    elif id <= 12:
        n, k, vmax = 1000, randint(0, (1 << 20) - 1), (1 << 20) - 1
        a = make_array(n, k, vmax, randint(0, 2))
    elif id == 13:
        n, k, vmax = 2 * 10**5, randint(0, 1), 1
        a = Vector.random_repeatable_vector(n, [(0, 1)])
    elif id <= 15:
        n, k, vmax = 2 * 10**5, randint(0, 255), 255
        a = make_array(n, k, vmax, randint(0, 2))
    elif id == 16:
        n, k, vmax = 2 * 10**5, randint(0, (1 << 20) - 1), (1 << 20) - 1
        a = make_array(n, k, vmax, randint(0, 2))
    elif id == 17:
        n, k, vmax = 5 * 10**5, randint(0, 255), 255
        a = make_array(n, k, vmax, randint(0, 2))
    else:
        n, k, vmax = 5 * 10**5, randint(0, (1 << 20) - 1), (1 << 20) - 1
        a = make_array(n, k, vmax, 0 if id == 20 else randint(0, 2))

    io.input_writeln(n, k)
    io.input_writeln(a)
    io.output_gen("std.exe")

:::warning[注意]{open} 测试点 9 与 10 属于特殊性质 C,限定 k\le 255 且 a_i\le 255。 ::: ::::

:::info[P9753 [CSP-S 2023] 消消乐]{open} 可查看题目。

给定一个小写字母串,求有多少个非空连续子串可以通过反复消除相邻相同字符变成空串。

这里显而易见不能随机生成字符串了(不然大部分情况根本没必要消除)。

让我们构造合法字符串!

奇数长度的字符串显然绝不可能整体消除。(读者自证不难!)

我们可以在奇数长度时保留长度为 n-1 的可消除前缀,末尾追加一个随机字符,专门检测程序对奇偶分界的处理。

from cyaron import *

def reducible_string(n):
    """生成一个长度恰为偶数 n 的可消除串。"""
    if n % 2:
        raise ValueError("a reducible string must have even length")
    pieces = []
    for _ in range(n // 2):
        c = choice("abc")
        pieces.append(c + c)
    return "".join(pieces)

for id in range(1, 21):
    io = IO(file_prefix="game_", data_id=id)

    if id <= 5:
        n = randint(1, 10)
        charset = None
    elif id <= 7:
        n = randint(1, 800)
        charset = None
    elif id <= 10:
        n = randint(1, 8000)
        charset = None
    elif id <= 12:
        n = randint(1, 2 * 10**5)
        charset = None  # 特殊性质 A:26 个字符独立等概率
    elif id <= 14:
        n = randint(1, 2 * 10**5)
        charset = "ab"  # 特殊性质 B
    elif id <= 17:
        n = randint(1, 2 * 10**5)
        charset = None
    elif id == 20:
        n, charset = 2 * 10**6, None
    else:
        n = randint(10**4, 2 * 10**6)
        charset = None

    io.input_writeln(n)

    # 1~5、15~19 号点以构造的可消除串为主,避免答案清一色是 0
    if 6 <= id <= 14 or (id != 20 and randint(0, 3) == 0):
        s = String.random(n, charset=charset) if charset else String.random(n)
    else:
        even_length = n if n % 2 == 0 else n - 1
        s = reducible_string(even_length)
        if n % 2:
            # 奇数长度不可能整体消除,所以好心加一点
            s += choice("abc")

    io.input_writeln(s)
    io.output_gen("std.exe")

:::

:::info[P14363 [CSP-S 2025] 谐音替换]{open} 可查看题目。

给出 n 个等长字符串替换对,要求处理 q 次询问,计算把字符串 t_1 按规则替换后得到 t_2 的方案数。

特殊性质 B 要求串中只包含字符 a 和 b,且 b 恰好出现一次。所以我们直接用 'a' * pos + 'b' + 'a' * remaining 定位生成就可以了。

题目保证所有询问都满足 t_1\ne t_2,特殊性质 B 下也不例外,怎么办呢?随机吗?

可以给 t_1 和 t_2 指定两个不同的下标放入唯一的 b,这样两串肯定不同了!

from cyaron import *

def special_string(length, b_pos=None):
    """特殊性质 B:仅含 a、b,且恰好一个 b。"""
    if b_pos is None:
        b_pos = randint(0, length - 1)
    return 'a' * b_pos + 'b' + 'a' * (length - 1 - b_pos)

def lengths(count, char_limit, max_len, min_len=1):
    """返回 count 个长度,且两份等长字符串的总长度不超过 char_limit。"""
    total = min(char_limit // 2, count * max_len)
    if total < count * min_len:
        raise ValueError("the length limit is too small")
    base, extra = divmod(total, count)
    result = [base] * count
    for i in range(extra):
        result[i] += 1
    return result

def different_string(s):
    replacement = 'b' if s[0] == 'a' else 'a'
    return replacement + s[1:]

def embed_pair(craftable, length):
    """把某个替换对嵌进随机前后缀,返回一对等长的询问;放不下就返回 None。"""
    for _ in range(64):
        s1, s2 = choice(craftable)
        if len(s1) <= length:
            left = String.random(randint(0, length - len(s1)))
            right = String.random(length - len(s1) - len(left))
            return left + s1 + right, left + s2 + right
    return None

for id in range(1, 21):
    io = IO(file_prefix="replace_", data_id=id)

    if id <= 2:
        n, q, limit, special_b = 100, 100, 200, False
    elif id <= 5:
        n, q, limit, special_b = 1000, 1000, 2_000, False
    elif id == 6:
        n, q, limit, special_b = 1000, 1, 1_000_000, True
    elif id <= 8:
        n, q, limit, special_b = 10_000, 1, 1_000_000, False
    elif id <= 10:
        n, q, limit, special_b = 2 * 10**5, 2 * 10**5, 1_000_000, True
    elif id <= 12:
        n, q, limit, special_b = 2 * 10**5, 2 * 10**5, 2_000_000, False
    elif id <= 14:
        n, q, limit, special_b = 2 * 10**5, 1, 5_000_000, False
    elif id <= 16:
        n, q, limit, special_b = 2 * 10**5, 2 * 10**5, 5_000_000, True
    else:
        n, q, limit, special_b = 2 * 10**5, 2 * 10**5, 5_000_000, False

    io.input_writeln(n, q)

    pairs = []
    for length in lengths(n, limit, max_len=20):
        s1 = special_string(length) if special_b else String.random(length)
        s2 = special_string(length) if special_b else String.random(length)
        pairs.append((s1, s2))
        io.input_writeln(s1, s2)

    min_query_len = 2 if special_b else 1
    craftable = [p for p in pairs if p[0] != p[1]]
    for length in lengths(q, limit, max_len=20, min_len=min_query_len):
        if special_b:
            first_pos = randint(0, length - 1)
            second_pos = (first_pos + randint(1, length - 1)) % length
            t1 = special_string(length, first_pos)
            t2 = special_string(length, second_pos)
        elif q <= 8 or randint(0, 1) == 0:
            # 纯随机的询问几乎必然无解(答案是 0),把替换对嵌进随机前后缀里,
            # 就能构造出至少有 1 种方案(答案 >= 1)的询问。
            built = embed_pair(craftable, length) if craftable else None
            if built is None:
                t1 = String.random(length)
                t2 = different_string(t1)
            else:
                t1, t2 = built
        else:
            t1 = String.random(length)
            t2 = different_string(t1)
        io.input_writeln(t1, t2)

    io.output_gen("std.exe")

:::

:::info[P2383 狗哥玩木棒]{open} 可查看题目。

题目给出若干根木棒的长度,询问能否将它们恰好用完,拼成一个正方形。

纯随机生成大概率无法拼成正方形,会产生大量 no,可以直接复现不可以总司令的荣光。

所以我们可以先确定正方形边长,再把四条边各自拆成若干小段,打乱后输出。显然可以保证至少有一种方式可以拼成正方形。

额外增加了几个边界测试点。

from cyaron import *
from random import shuffle

for id in range(1, 21):
    io = IO(file_prefix="stick_", data_id=id)

    T = randint(1, 10)
    io.input_writeln(T)

    for case_index in range(T):
        m = randint(4, 20)

        make_yes = case_index == 0 or (
            case_index >= 2 and randint(0, 2) == 0
        )
        if make_yes:
            counts = [1, 1, 1, 1]
            for _ in range(m - 4):
                counts[randint(0, 3)] += 1
            side = randint(max(counts), 2000)
            sticks = []
            for count in counts:
                remaining = side
                for i in range(count - 1):
                    l = randint(1, remaining - (count - 1 - i))
                    sticks.append(l)
                    remaining -= l
                sticks.append(remaining)
            shuffle(sticks)
        elif case_index == 1:
            # 总长度不能被 4 整除,显然不可以啊喂
            sticks = [1, 1, 1, 2]
        else:
            sticks = Vector.random_repeatable_vector(m, [(1, 2000)])

        io.input_writeln(len(sticks), *sticks)

    io.output_gen("std.exe")

:::

:::info[P1073 [NOIP 2009 提高组] 最优贸易]{open} 可查看题目。

给出包含有向边与无向边的混合图,要求从节点 1 走到节点 n,沿途买入并卖出一次水晶球,求最大收益。

首先你需要确保 1 号点必须能到达 n 号点。

可以先随机一个目标差价 P,把所有价格都限制在 [low, low+P] 里,再让链上前段的买点取到最低价 low、后段的卖点取到最高价 low+P。这样答案恰好就是 P,不会全是最大的 99。

点数较小时可以枚举所有合法候选边洗牌选取,大数据可以逐条随机抽取并用 used_pairs 查重来增加效率(当然你不在意也可以全都枚举)。

from cyaron import *
from random import shuffle

for id in range(1, 21):
    io = IO(file_prefix="trade_", data_id=id)

    if id <= 5:
        n = randint(2, 100)
        max_m = min(200, n * (n - 1) // 2)
    elif id <= 10:
        n = randint(100, 1000)
        max_m = min(5000, n * (n - 1) // 2)
    else:
        n = randint(10**4, 10**5)
        max_m = min(5 * 10**5, n * (n - 1) // 2)
    m = randint(n - 1, max_m)

    profit = randint(0, 99)
    low = randint(1, 100 - profit)
    prices = Vector.random_repeatable_vector(n, [(low, low + profit)])
    buy_pos = randint(0, n - 2)
    sell_pos = randint(buy_pos + 1, n - 1)
    prices[buy_pos][0] = low
    prices[sell_pos][0] = low + profit
    io.input_writeln(n, m)
    io.input_writeln(prices)

    # 保证 1 可达 n
    edges = []
    used_pairs = set()
    for i in range(1, n):
        edges.append((i, i + 1, randint(1, 2)))
        used_pairs.add((i, i + 1))

    if n <= 1000:
        candidates = [
            (u, v)
            for u in range(1, n + 1)
            for v in range(u + 1, n + 1)
            if (u, v) not in used_pairs
        ]
        shuffle(candidates)
        for u, v in candidates[:m - (n - 1)]:
            edges.append((u, v, randint(1, 2)))
    else:
        while len(edges) < m:
            u, v = randint(1, n), randint(1, n)
            if u == v:
                continue
            pair = (min(u, v), max(u, v))
            if pair in used_pairs:
                continue
            used_pairs.add(pair)
            edges.append((u, v, randint(1, 2)))

    shuffle(edges)
    for u, v, z in edges:
        io.input_writeln(u, v, z)

    io.output_gen("std.exe")

:::

:::info[P5658 [CSP-S 2019] 括号树]{open} 可查看题目。

一棵包含 n 个节点的树,每个节点标有一个括号,求根节点到各节点路径上合法括号子串的统计值。

需要注意树结构和括号匹配。

括号序列也要分层设计:全左、全右括号各留一个测试点,其余用混合或分层构造,不然一堆测试点答案全是 0,直接输出 0 就能骗到不少分。

这里可以维护未闭合左括号,在剩余长度不足时加右括号。

from cyaron import *

for id in range(1, 21):
    io = IO(file_prefix="brackets_", data_id=id)

    if id <= 2:
        n, force_chain = randint(1, 8), True
    elif id <= 4:
        n, force_chain = randint(1, 200), True
    elif id <= 7:
        n, force_chain = randint(1, 2000), True
    elif id <= 10:
        n, force_chain = randint(1, 2000), False
    elif id <= 14:
        n, force_chain = randint(1, 10**5), True
    elif id <= 16:
        n, force_chain = randint(1, 10**5), False
    elif id == 20:
        n, force_chain = 5 * 10**5, False
    else:
        n, force_chain = randint(1, 5 * 10**5), False

    io.input_writeln(n)

    if id == 5:
        mode = 0        # 全左括号,单独留一个测试点
    elif id == 15:
        mode = 1        # 全右括号,单独留一个测试点
    elif id == 20:
        mode = 3        # 分层构造
    else:
        mode = randint(2, 3)
    if mode == 0:
        s = String.random(n, charset="(")
    elif mode == 1:
        s = String.random(n, charset=")")
    elif mode == 2:
        s = String.random(n, charset="()")
    else:
        # 偶数长度时生成完整合法串,奇数长度时会余下一个左括号
        stack_size = 0
        chars = []
        for pos in range(n):
            remaining = n - pos
            if stack_size and (stack_size >= remaining or randint(0, 1) == 0):
                chars.append(')')
                stack_size -= 1
            else:
                chars.append('(')
                stack_size += 1
        s = "".join(chars)

    io.input_writeln(s)

    # f_i 满足 1 <= f_i < i;指定测试点还要求 f_i = i - 1。
    fathers = (
        [i - 1 for i in range(2, n + 1)]
        if force_chain
        else [randint(1, i - 1) for i in range(2, n + 1)]
    )
    io.input_writeln(fathers)

    io.output_gen("std.exe")

:::

:::info[P1025 [NOIP 2001 提高组] 数的划分]{open} 可查看题目。

将整数 n 分成 k 份,且每份不能为空,求不考虑顺序的不同划分方案数。

显然这题并不会有神秘图论那么难造。

这个程序前三个测试点测极端情况,后面的则正常生成。

from cyaron import *

for id in range(1, 21):
    io = IO(file_prefix="divide_", data_id=id)

    if id == 1:
        n, k = 7, 2      # 最小数据
    elif id == 2:
        n, k = 200, 6    # 最大数据
    elif id == 3:
        n, k = 200, 2    # 另一组边界
    else:
        n = randint(7, 200)
        k = randint(2, 6)

    io.input_writeln(n, k)
    io.output_gen("std.exe")

:::

::::info[P1092 [NOIP 2004 提高组] 虫食算]{open} 这一题是研究最久的……

可查看题目。

在 n 进制下,n 个字母各代表 0\sim n-1 的一个数字,使竖式加法成立。题目保证有且仅有一组解,且 n 个字母都至少出现一次。

随机数据似乎可以,但怎么确保有且只有一组解?(显然题解也只能找出解,不能说只有一种吧?)

显然很容易想到逐列回溯!

但你会注意到 n \gt 18 时速度飞快(实则一分钟生成不出一组)。

不搜索呢?可惜的是能力所限,使用强制进位链,Distinct 和一串整数算术约束在大数据下还是炸……

最后发现了一个叫位向量编码的神器?

先让求解器找一组解,再排除这组完整赋值,第二次判定无解才说明解唯一。这里引入 Z3 进行数学求解(微软开源还是太强大了),让 CYaRon 负责文件组织和随机抽样。

这个求解器可以核验这份随机结果是否严格符合题意。

:::info[备注]{open} 需要先安装 z3-solver。位向量适合交给 SAT 求解器处理。唯一的实例确认“不存在第二解”通常很快。 :::

当时迭代了几版本。

第一版,纯回溯。&#x8FD9;个没什么好解释的。

(找不着当时的代码了,所以这个是 Kimi K3 根据信息重写的版本。)

class SearchBudgetExceeded(Exception):
    pass

def count_mappings(a, b, c, n, limit=2, budget=10**5):
    """从低位到高位逐列回溯,统计 n 进制加法 a + b = c 的映射方案数。

    数到 limit 就提前返回;搜索节点数超过 budget 就抛 SearchBudgetExceeded,
    调用方直接把这种实例丢掉重摇,避免在病态实例上耗死。
    """
    columns = [
        (ord(a[i]) - 65, ord(b[i]) - 65, ord(c[i]) - 65)
        for i in range(n)
    ]
    assign = [-1] * n
    used = [False] * n
    total = 0
    steps = 0

    def dfs(col, carry):
        nonlocal total, steps
        if total >= limit:
            return
        if col < 0:
            if carry == 0:
                total += 1
            return
        x, y, z = columns[col]
        letters = []
        for letter in (x, y, z):
            if assign[letter] == -1 and letter not in letters:
                letters.append(letter)

        def place(idx):
            nonlocal total, steps
            if total >= limit:
                return
            steps += 1
            if steps > budget:
                raise SearchBudgetExceeded
            if idx == len(letters):
                s = assign[x] + assign[y] + carry
                if s % n == assign[z]:
                    dfs(col - 1, s // n)
                return
            letter = letters[idx]
            for value in range(n):
                if not used[value]:
                    assign[letter] = value
                    used[value] = True
                    place(idx + 1)
                    used[value] = False
                    assign[letter] = -1

        place(0)

    dfs(n - 1, 0)
    return total

它在 n\le 15 时可以使用,但是当你提升数据之后……

n 15 16 17 \ge 18
唯一解 16 2 1 0

注:以上为 45 秒内统计。

所以严肃退役。

第二版,手工构造。&#x641C;不到就造!

比如想要链式进位,想让每列都进位 1,就需要 x+y+c=n+z 且 z 取遍一整组字母。推导下去会发现 z=x 等价于 y=n-c,可最低列上没有进位输入(c=0),这就要求 y=n,已经不是合法数字了……

……严肃失败(有人有想法可以发在评论区)

第三版,Z3 整数编码。&#x8BA9;我们使用求解器降维打击!

把字母建成整数变量,加 Distinct,进位用布尔变量。

直觉上机器搜索总该比手写回溯强……吧。

可惜并非,和手写速度比起来其实差不多,依旧慢死,严肃放弃。

var = {ch: z3.Int(ch) for ch in letters}
solver.add(z3.Distinct(*var.values()))
for v in var.values():
    solver.add(v >= 0, v < n)
carry = z3.IntVal(0)
for i in range(n - 1, -1, -1):
    s = var[a[i]] + var[b[i]] + carry
    new_carry = z3.Int(f"c_{i}")
    solver.add(z3.Or(new_carry == 0, new_carry == 1))
    solver.add(s == var[c[i]] + n * new_carry)   # 整数算术 + Distinct
    carry = new_carry
solver.add(carry == 0)

整数 Distinct 加一串带进位的线性算术,Z3 通用算术推理,求解速度远不如布尔逻辑。

那怎么办呢?位向量!

第四版,位向量。&#x628A;整数换成 z3.BitVec,进位判断从算术比较变成位运算,SAT 求解器对这种问题计算更快,速度大幅提升。

from cyaron import *
from random import shuffle
import z3

def unique_solution(a, b, c, n, timeout=15000):
    """用 Z3 位向量编码判定 n 进制虫食算 a + b = c 是否恰有 1 solution"""
    letters = sorted(set(a + b + c))
    if len(letters) < n:
        return False
    bw = (2 * n).bit_length() + 1# 容纳 2n-1 加进位
    var = {ch: z3.BitVec(ch, bw) for ch in letters}
    solver = z3.Solver()
    solver.set("timeout", timeout)
    solver.add(z3.Distinct(*var.values()))
    for v in var.values():
        solver.add(z3.ULE(v, n - 1))
    carry = z3.BitVecVal(0, bw)
    for i in range(n - 1, -1, -1):
        s = var[a[i]] + var[b[i]] + carry
        new_carry = z3.BitVec(f"c_{i}", bw)
        solver.add(z3.Or(new_carry == 0, new_carry == 1))
        solver.add(s == var[c[i]] + n * new_carry)
        carry = new_carry
    solver.add(carry == 0)
    if solver.check() != z3.sat:
        return False
    model = solver.model()
    solver.add(z3.Or(*[var[ch] != model[var[ch]] for ch in letters]))
    return solver.check() == z3.unsat # 没有第二组解

def make_case(n, max_retries=300):
    for _ in range(max_retries):
        values = list(range(n))
        shuffle(values)
        letter_of = [0] * n
        for letter, digit in enumerate(values):
            letter_of[digit] = letter

        addend1 = [randint(0, n - 1) for _ in range(n)]
        addend2 = [randint(0, n - 1) for _ in range(n)]
        total_digits = []
        carry = 0
        for i in range(n - 1, -1, -1):
            s = addend1[i] + addend2[i] + carry
            total_digits.append(s % n)
            carry = s // n
        if carry:
            continue
        total_digits.reverse()

        a = "".join(chr(65 + letter_of[d]) for d in addend1)
        b = "".join(chr(65 + letter_of[d]) for d in addend2)
        c = "".join(chr(65 + letter_of[d]) for d in total_digits)

        if unique_solution(a, b, c, n):
            return a, b, c
    raise RuntimeError(f"n={n}: {max_retries} 次尝试内没有造出唯一解")

for id in range(1, 21):
    io = IO(file_prefix="alpha_", data_id=id)

    if id <= 6:
        n = randint(3, 10)
    elif id <= 10:
        n = randint(11, 15)
    elif id <= 18:
        n = randint(16, 20)
    else:
        n = randint(21, 24)# 这里也没有占满,但是更大即使 Z3 版本也不稳定了,你可以试试

    a, b, c = make_case(n)
    io.input_writeln(n)
    io.input_writeln(a)
    io.input_writeln(b)
    io.input_writeln(c)
    io.output_gen("std.exe")

注意到其中最大也只有 24,主要是因为测试时开到 26 速度也比较慢且不稳定,所以这里没有占满数据范围。

注:这个生成器没有排除“a + b = c + n”(即最高位溢出 1 的情况,此时 c 是和的低 n 位),但题目未对此进行说明。(例如:BACB + ACDC = DADA 的输入时构造 A=1, B=2, C=3, D=0(这里作为数据本身,所以没有使用 \LaTeX)。)所以保留这种可能。

CYaRon 省下了写循环和拼图的时间,但很多地方还是得自己解决哦!

后记

这篇文章从今年六月就开始计划编写……但一直没多少时间。

前前后后拖了大概三个月才完成。也花费了不少时间。希望对你有帮助。

主要写作灵感来源于基于CYaRon的数据制造流程漫谈,但我主要是想对完整使用进行更详细的讲解。(更加小白一点?)

:::info[AI 使用说明]{open} 虫食算中的第一版代码使用 Kimi K3 重写。

部分 API 列表由 AI 辅助整理,但由人工编写。

保证智人贡献严格大于人工智能。

本文采用 Creative Commons CC0 1.0 Universal 发布,不提供任何担保。

在适用法律允许的最大范围内,本作品的全部著作权及相关权利为公共领域。