0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

競プロ作問ハンズオン (Testlib + Rime + statements-manager + yukicoder-md)

0
Last updated at Posted at 2026-01-16

はじめに

競技プログラミングの作問初心者向けのハンズオン記事です。

想定読者

  • 競プロの作問に興味がある方
  • 競プロの作問ツールの使い方を知りたい方

この記事で説明すること

  • Testlib を使った入力ファイルの validator の書き方
  • Rime や statements-manager (以下 ss-manager) を使って問題関連ファイルを管理する方法
  • yukicoder-md を使って yukicoder 問題ページ用の html を生成する方法

この記事で説明しないこと

  • 問題原案の作り方
  • 強いテストケースの作り方
  • スペシャルジャッジの書き方
  • コンテストサイトへ問題をアップロードする方法
  • tester とのやり取りの流れ など

これらについては他の方の記事を参照してください。

本題

前提

この記事では Linux 環境で話を進めます。
また、サンプルコードで C++23 の機能を使用します。
必要に応じて Docker コンテナなどを使用するか、サンプルコードを書き換えてください。

各種ツールのインストール

$ pip install rime statements-manager
$ cd ~/.local/bin
$ wget https://github.com/koyumeishi/yukicoder-md/releases/download/v0.1.0/yukicoder-md-x86_64-unknown-linux-gnu.zip
$ unzip yukicoder-md-x86_64-unknown-linux-gnu.zip

yukicoder-md は web ブラウザ版もあるのでインストールしなくてもいいです。
ただしブラウザ版は一部機能が使えないそうです。

コンテスト用ディレクトリ作成

$ mkdir my-contest
$ cd my-contest
$ rime_init --git

.gitignore ファイルが作成されるので、ss-out を追記しておきます。

.gitignore
  rime-out
  *.pyc
  Icon*
  *.swp
  .DS_Store
  Thumbs.db
  *~
  *.out
  target
+ ss-out

問題作成

例として $A+B$ を答える問題を作ります。

以下のものが必要になります。

  • 問題文
  • テストケースの入力ファイル
  • 想定解

なるべく以下のものも用意したいです。

  • Python の想定解(実行時間制限の調整用。実際のジャッジ環境に PyPy3 で提出して調整する方が良さそうです)
  • 愚直解(想定解が本当に正しいかの確認用)
  • 想定誤解法
  • 入力ファイルを生成する generator
  • 入力ファイルの形式が正しいか、制約を満たすか確認する validator
  • 解説文

結構ありますが順番に作っていきます。
なお今回は愚直解の作成は省略します。愚直解でチェックする場合は oj を使うのが便利だと思います。

まずは問題別ディレクトリを作ります。

$ rime add . problem aplusb  # rime add の度に Vim が開きますが :q で閉じましょう
$ cd aplusb
$ rime add . testset tests
$ cd ..
$ ss-manager setup aplusb -l ja ja

aplusb/ ディレクトリの中にある problem.toml を書き換えます。

problem.toml
    id = "aplusb"
    params_path = "./tests/constraints.hpp"
    [[statements]]
-   path = "statement/ja/statement_1.md"
+   path = "statement/ja/statement.md"
    lang = "ja"
    markdown_extensions = [ "md_in_html", "tables", "fenced_code",]
    
    [[statements]]
-   path = "statement/ja/statement_2.md"
+   path = "statement/ja/editorial.md"
    lang = "ja"
    markdown_extensions = [ "md_in_html", "tables", "fenced_code",]
    
    [constraints]

aplusb/statement/ja/ の中に statement_1.md と statement_2.md が作成されているので、それぞれ statement.md と editorial.md にリネームします。

$ cd aplusb/statement/ja/
$ mv statement_1.md statement.md
$ mv statement_2.md editorial.md

statement.md に問題文を、editorial.md に解説文を Markdown で書きます。
yukicoder-md を使用するため、はじめに H1 タグを使用してください。

statement.md
# 問題文

整数 $A,B$ が与えられます。

$A+B$ を求めてください。

# 制約

* $1 \le A \le 100$
* $1 \le B \le 100$
* 入力される値はすべて整数

# 入力

入力は以下の形式で与えられます。

```
$A$ $B$
```

# 出力

答えを $1$ 行に出力してください。最後に改行してください。

# サンプル

```sample-input
1 2
```

```sample-output
3
```

$1+2=3$ であるため、$3$ を出力します。

```sample-input
100 100
```

```sample-output
200
```
editorial.md
# 解説

$A$ と $B$ を読み取って $A+B$ を出力すると正解になります。

ss-manager を使って制約やサンプルケースを管理するための作業をします。

my-contest/ の中に problemset.toml を作成し、以下のように記述してください。

problemset.toml
[template]
    sample_template_path = "./templates/yukicoder_sample_template.html"

my-contest/ の中に templates/ ディレクトリを作成し、その中に yukicoder_sample_template.html を作成してください。内容は次のようにしてください。

yukicoder_sample_template.html
{% if sample_data.input_text is defined %}
```sample-input
{{ sample_data.input_text }}```
{% endif %}

{% if sample_data.output_text is defined %}
```sample-output
{{ sample_data.output_text }}```
{% endif %}

{% if sample_data.md_text is defined %}
{{ sample_data.md_text }}
{% endif %}

{% if sample_data.explanation_text is defined %}
{{ sample_data.explanation_text }}
{% endif %}

problem.toml に以下のように追記します。

problem.toml
    id = "aplusb"
    params_path = "./tests/constraints.hpp"
    [[statements]]
    path = "statement/ja/statement.md"
    lang = "ja"
    markdown_extensions = [ "md_in_html", "tables", "fenced_code",]
    
    [[statements]]
    path = "statement/ja/editorial.md"
    lang = "ja"
    markdown_extensions = [ "md_in_html", "tables", "fenced_code",]
    
    [constraints]
+   A_MIN = 1
+   A_MAX = 100
+   B_MIN = 1
+   B_MAX = 100

tests/ ディレクトリの中に 01_sample_01.in 01_sample_01.diff 01_sample_02.in 01_sample_02.diff を作成し以下のように記述してください。末尾の改行を忘れないでください。

01_sample_01.in
1 2
01_sample_01.diff
3
01_sample_02.in
100 100
01_sample_02.diff
200

tests/ ディレクトリの中に ja/ ディレクトリを作成し、その中に 01_sample_01.md を作成し以下のように記述してください。

01_sample_01.md
$1+2=3$ であるため、$3$ を出力します。

statement.md を次のように書き換えてください。

statement.md
    # 問題文
    
    整数 $A,B$ が与えられます。
    
    $A+B$ を求めてください。
    
    # 制約
-   * $1 \le A \le 100$
-   * $1 \le B \le 100$
+   * ${@constraints.A_MIN} \le A \le {@constraints.A_MAX}$
+   * ${@constraints.B_MIN} \le B \le {@constraints.B_MAX}$
    * 入力される値はすべて整数
    
    # 入力
    
    入力は以下の形式で与えられます。
    
    ```
    $A$ $B$
    ```
    
    # 出力
    
    答えを $1$ 行に出力してください。最後に改行してください。
    
    # サンプル

-   ```sample-input
-   1 2
-   ```
-   (略)
-   ```sample-output
-   200
-   ```
+   {@samples.all}

この時点でのディレクトリ構成は以下のようになっています。

my-contest/
    aplusb/
        statement/
            ja/
                editorial.md
                statement.md
        tests/
            ja/
                01_sample_01.md
            01_sample_01.diff
            01_sample_01.in
            01_sample_02.diff
            01_sample_02.in
            TESTSET
        PROBLEM
        problem.toml
    common/
        testlib.h
    templates/
        yukicoder_sample_template.html
    .git/
        ...(略)
    .gitignore
    problemset.toml
    PROJECT

ここで一度 aplusb/ に移動し ss-manager を動かしてみます。

$ cd /path/to/my-contest/aplusb/
$ ss-manager run -o md

tests/ の中に constraints.hpp と、ss-out/ の中に aplusb1.md と aplusb2.md が生成されるはずです。

constraints.hpp
// DO NOT EDIT THIS FILE MANUALLY
// YOU MUST UPDATE CONSTRAINTS IN problem.toml

#pragma once

const long long int A_MIN = 1;
const long long int A_MAX = 100;
const long long int B_MIN = 1;
const long long int B_MAX = 100;
aplusb1.md
(略)
# 制約

* $1 \le A \le 100$
* $1 \le B \le 100$
* 入力される値はすべて整数
(略)
aplusb2.md



# 解説

$A$ と $B$ を読み取って $A+B$ を出力すると正解になります。



statement.md で変数になっていた場所が正しく置き換えられているのが確認できれば OK です。余計な空行は問題ありません。

入力 generator, validator 作成

tests/ の中にある TESTSET を以下のように書き換えてください。

TESTSET
    # -*- coding: utf-8; mode: python -*-
    
    ## Input generators.
    #c_generator(src='generator.c')
-   #cxx_generator(src='generator.cc', dependency=['testlib.h'])
+   cxx_generator(src='generator.cpp', flags=['-std=c++23', '-O2'], dependency=['testlib.h'])

    #java_generator(src='Generator.java', encoding='UTF-8', mainclass='Generator')
    #rust_generator(src='generator.rs')
    #go_generator(src='generator.go')
    #script_generator(src='generator.pl')
    
    ## Input validators.
    #c_validator(src='validator.c')
-   #cxx_validator(src='validator.cc', dependency=['testlib.h'])
+   cxx_validator(src='validator.cpp', flags=['-std=c++23', '-O2'], dependency=['testlib.h'])
    #java_validator(src='Validator.java', encoding='UTF-8',
    #               mainclass='tmp/validator/Validator')
    #rust_validator(src='validator.rs')
    #go_validator(src='validator.go')
    #script_validator(src='validator.pl')

    (略)

tests/ の中に generator.cpp を作成し以下のように記述してください。

generator.cpp
#include <bits/stdc++.h>
#include "constraints.hpp"
using namespace std;

mt19937 g;
const uint32_t seed = 12345;
const bool output_to_stdout = false;

struct input {
    int A, B;
    void dump(ostream &file) const {
        println(file, "{} {}", A, B);
    }
};

generator<input> handmade([[maybe_unused]] int num) {
    co_yield {1, 1};
    co_yield {1, 100};
    co_yield {100, 1};
    co_yield {100, 100};
}

generator<input> random_case(int num) {
    uniform_int_distribution<> dist_A(A_MIN, A_MAX);
    uniform_int_distribution<> dist_B(B_MIN, B_MAX);
    for (int i = 0; i < num; ++i) {
        int A = dist_A(g);
        int B = dist_B(g);
        co_yield {A, B};
    }
};

generator<input> same(int num) {
    uniform_int_distribution<> dist_A(A_MIN, A_MAX);
    for (int i = 0; i < num; ++i) {
        int A = dist_A(g);
        int B = A;
        clamp(B, int(B_MIN), int(B_MAX));
        co_yield {A, B};
    }
};

void write_case(const string &file_name, const input &c) {
    if (output_to_stdout) {
        c.dump(cout);
        return;
    }
    ofstream file;
    file.open(file_name);
    if (file.is_open()) {
        c.dump(file);
    } else {
        cerr << "open failed: " << file_name << endl;
        exit(1);
    }
    file.close();
}

void write_all(const auto &all_cases) {
    for (auto [i, tp] : all_cases | views::enumerate) {
        const int category_id = i + 2;
        auto [category_name, gen_func, num_cases] = tp;
        for (auto [j, single_case] : gen_func(num_cases) | views::enumerate) {
            string file_name = format("{:02}_{}_{:02}.in", category_id, category_name, j + 1);
            write_case(file_name, single_case);
        }
    }
}

int main() {
    const vector<tuple<string, function<generator<input>(int)>, int>> all_cases = {
        {"random", random_case, 5},
        {"same", same, 5},
        {"handmade", handmade, 0},
    };

    g = mt19937(seed);
    write_all(all_cases);
}

problem.toml に記述した制約を使用できることに注目してください。
なお今回はランダムケースの生成に std::uniform_int_distribution を使用していますが、環境によって動作が異なるため、Boost.Random を使用する方がよいと思われます。

続いて tests/ の中に validator.cpp を作成し以下のように記述してください。

validator.cpp
#include "testlib.h"
#include <bits/stdc++.h>
#include "constraints.hpp"
using namespace std;

int main(int argc, char *argv[]) {
    registerValidation(argc, argv);
    inf.readInt(A_MIN, A_MAX);
    inf.readSpace();
    inf.readInt(B_MIN, B_MAX);
    inf.readEoln();
    inf.readEof();
}

想定解、想定誤解法の作成

rime add で想定解用のディレクトリを作成します。

$ cd /path/to/my-contest/aplusb/
$ rime add . solution ac_cpp

aplusb/ の中にある PROBLEM ファイルを以下のように書き換えてください。
ac_cpp/ の中にあるプログラムを使ってテストケースの正解ファイルが作られます。

PROBLEM
    # -*- coding: utf-8; mode: python -*-
    
    pid='X'
    
    problem(
-     time_limit=1.0,
+     time_limit=2.0,
      id=pid,
      title=pid + ": Your Problem Name",
      #wiki_name="Your pukiwiki page name", # for wikify plugin
      #assignees=['Assignees', 'for', 'this', 'problem'], # for wikify plugin
      #need_custom_judge=True, # for wikify plugin
-     #reference_solution='???',
+     reference_solution='ac_cpp',
      )
    
    atcoder_config(
      task_id=None # None means a spare
    )

ac_cpp/ の中にある SOLUTION ファイルを次のように書き換えてください。

SOLUTION
    # -*- coding: utf-8; mode: python -*-
    
    ## Solution
    #c_solution(src='main.c') # -lm -O2 as default
-   #cxx_solution(src='main.cc', flags=[]) # -std=c++11 -O2 as default
+   cxx_solution(src='main.cpp', flags=['-std=c++23', '-O2'])
    #kotlin_solution(src='main.kt') # kotlin
    #java_solution(src='Main.java', encoding='UTF-8', mainclass='Main')
    (略)

ac_cpp/ の中に main.cpp ファイルを作成し、以下のように記述してください。

main.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int A, B;
    cin >> A >> B;
    cout << A + B << '\n';
}

Python の想定解も作っておきます。

$ cd /path/to/my-contest/aplusb/
$ rime add . solution ac_py

ac_py/ の中にある SOLUTION ファイルを書き換えます。

SOLUTION
    (略)
    #script_solution(src='main.sh') # shebang line is required
    #script_solution(src='main.pl') # shebang line is required
-   #script_solution(src='main.py') # shebang line is required
+   script_solution(src='main.py') # shebang line is required
    #script_solution(src='main.rb') # shebang line is required
    #js_solution(src='main.js') # javascript (nodejs)
    #hs_solution(src='main.hs') # haskell (stack + ghc)
    #cs_solution(src='main.cs') # C# (mono)
    
    ## Score
    #expected_score(100)

ac_py/ の中に main.py を作成します。

main.py
#!/usr/bin/python3
A, B = map(int, input().split())
print(A + B)

誤解法も作っておきます。

$ cd /path/to/my-contest/aplusb/
$ rime add . solution wa_sub

wa_sub/ の中にある SOLUTION ファイルを次のように書き換えてください。
challenge_cases を指定することで誤解法とマークされ、AC になったときエラーが出ます。

SOLUTION
    # -*- coding: utf-8; mode: python -*-
    
    ## Solution
    #c_solution(src='main.c') # -lm -O2 as default
-   #cxx_solution(src='main.cc', flags=[]) # -std=c++11 -O2 as default
+   cxx_solution(src='main.cpp', flags=['-std=c++23', '-O2'], challenge_cases=[])
    #kotlin_solution(src='main.kt') # kotlin
    #java_solution(src='Main.java', encoding='UTF-8', mainclass='Main')
    (略)

wa_sub/ の中に main.cpp ファイルを作成し、以下のように記述してください。

main.cpp
#include <bits/stdc++.h>
using namespace std;

int main() {
    int A, B;
    cin >> A >> B;
    cout << A - B << '\n';  // 引いてる
}

想定解も誤解法も複数作成できます。今回は誤解法は 1 個だけにしておきます。

テストする

サンプルケースや generator で生成するケースが制約を満たしているか、誤解法がちゃんと落ちるかなどをテストします。

$ cd /path/to/my-contest/aplusb/
$ ss-manager run -o md
$ rime clean
$ rime test

generator で生成されたテストケースは rime-out/ 以下に保存されているので確認してみましょう。

なお ss-manager runrime test は my-contest/ にいるときにも実行でき、複数の問題を一度に処理できるので便利です。
problem.toml に書かれている id が一度に処理する問題の中で重複しないようにしてください。

アップロードの準備をする

次のコマンドを実行してください。

$ cd /path/to/my-contest/aplusb/  # my-contest/ でも可
$ rime pack

rime-out/atcoder/ 以下の in/ と out/ にテストケースがアップロードしやすい形で生成されます。

次に問題文と解説を yukicoder 用の html に変換します。

$ cd /path/to/my-contest/aplusb/ss-out/
$ yukicoder-md < aplusb1.md > statement.html
$ yukicoder-md < aplusb2.md > editorial.html

以下の内容の html ファイルになっていれば OK です。
yukicoder の問題編集画面にコピペできるようになっています。

statement.html
<div class="block">
<h4 class="shadow">問題文</h4>
<p>整数 $A,B$ が与えられます。</p>
<p>$A+B$ を求めてください。</p>

</div>

<div class="block">
<h4 class="shadow">制約</h4>
<ul>
<li>$1 \le A \le 100$</li>
<li>$1 \le B \le 100$</li>
<li>入力される値はすべて整数</li>
</ul>

</div>

<div class="block">
<h4 class="shadow">入力</h4>
<p>入力は以下の形式で与えられます。</p>
<pre>$A$ $B$
</pre>
</div>

<div class="block">
<h4 class="shadow">出力</h4>
<p>答えを $1$ 行に出力してください。最後に改行してください。</p>

</div>

<div class="block">
<h4 class="shadow">サンプル</h4>
<div class="sample"> <h5 class="underline">サンプル1</h5> <div class="paragraph"> <h6>入力</h6><pre>1 2
</pre><h6>出力</h6><pre>3
</pre>
<p>$1+2=3$ であるため、$3$ を出力します。</p>
</div></div>
<div class="sample"> <h5 class="underline">サンプル2</h5> <div class="paragraph"> <h6>入力</h6><pre>100 100
</pre><h6>出力</h6><pre>200
</pre></div></div>

</div>
editorial.html
<div class="block">
<h4 class="shadow">解説</h4>
<p>$A$ と $B$ を読み取って $A+B$ を出力すると正解になります。</p>

</div>

問題をアップロードするまでの準備はこれで完了です。お疲れ様でした。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?