djm03178's profile image

djm03178

May 14, 2021 00:14

testlib 사용하기

Codeforces, , Polygon

개요

testlib는 Polygon에서 프로그래밍 문제를 만들기 위한 유틸리티를 한데 모아놓은 라이브러리입니다. Generator, validator, checker 및 interactor 제작에 공통적으로 사용되며, 신뢰도 높은 랜덤 함수, 안정성 있으며 정규표현식을 지원하는 문자열 파싱, Polygon 및 채점 시스템과의 상호작용 등의 다양한 기능을 갖추고 있으며 Polygon과 GitHub 페이지에서 다양한 사용 예제들도 제공해 줍니다. 문제 출제를 위한 플랫폼 - Polygon 사용하기 글에 간단한 사용 예시가 나와있습니다.

유용한 기능을 많이 갖추고 있고 코드의 주석 또한 훌륭한 반면에 testlib에 대한 별도의 문서화는 이루어지지 않았습니다. 그래서 문제 출제를 위해 Polygon을 사용할 때 testlib에 적응하는 데에 어려움을 겪기 쉽습니다. 이 글에서는 testlib의 기본적인 사용법에 대한 튜토리얼을 제공하고, 유용하게 사용할 수 있는 기능들을 일부 살펴보고자 합니다.

주요 기능

testlib에서 할 수 있는 주요 기능으로는 다음과 같은 것들이 있습니다.

  • Generator, validator, checker 및 interator의 등록 (register)
  • 명령줄에서 전달한 인자에 따라 답이 결정되는 랜덤1
  • 프로그램의 출력 문자열에 대한 유효성 검사 (서식, 자료형, 범위 등)
  • 결과에 대한 구체적인 메시지를 채점 시스템에 전달

또한 기본적으로 제공되는 예제 중에는 실제 문제에서 자주 사용되는 케이스들이 많이 있는데, 특히 checker 예제들의 경우에는 Polygon 자체에서 이들을 언제나 사용할 수 있으며 실제로도 스페셜 저지가 아닌 경우에는 이들을 사용하는 것이 안전합니다. Interactive 문제가 아닌 대부분의 경우 실제로 문제를 만들 때 직접 작성해야 하는 코드는 generator와 validator이며, 스페셜 저지가 필요한 경우 checker도 작성해야 합니다. 각각을 만드는 과정을 따라가 보겠습니다.

Generator

먼저 데이터 제작에서 가장 중요한 프로그램인 generator를 만드는 예시입니다. 이 코드는 이전에 개최한 대회인 Codeforces Round #688 (Div. 2)의 A번 문제 Cancel the Trains의 랜덤 제너레이터입니다.

#include "testlib.h"
using namespace std;

const int MAX_T = 100;
const int MAX_N = 100;
const int MAX_COORD = 100;

struct TC {
	int n, m;
	vector<int> a, b;
};

TC make_tc(int max_n, int max_m, string weighted)
{
	TC cur;
	if (weighted == "yes")
	{
		cur.n = rnd.wnext(1, max_n, max_n / 5 + 1);
		cur.m = rnd.wnext(1, max_m, max_m / 5 + 1);
	}
	else
	{
		cur.n = rnd.next(1, max_n);
		cur.m = rnd.next(1, max_m);
	}
	set<int> sa, sb;
	for (int i = 1; i <= cur.n; i++)
	{
		int x = rnd.next(1, MAX_COORD);
		if (sa.find(x) != sa.end())
		{
			i--;
			continue;
		}
		cur.a.push_back(x);
		sa.insert(x);
	}
	for (int i = 1; i <= cur.m; i++)
	{
		int x = rnd.next(1, MAX_COORD);
		if (sb.find(x) != sb.end())
		{
			i--;
			continue;
		}
		cur.b.push_back(x);
		sb.insert(x);
	}
	sort(cur.a.begin(), cur.a.end());
	sort(cur.b.begin(), cur.b.end());
	return cur;
}

int main(int argc, char *argv[])
{
	registerGen(argc, argv, 1);
	int t = opt<int>("t");
	int max_n = opt<int>("max_n");
	int max_m = opt<int>("max_m");
	string weighted = opt("weighted");

	ensuref(1 <= t && t <= MAX_T, "t must be in [1,%d]", MAX_T);
	ensuref(1 <= max_n && max_n <= MAX_N, "max_n must be in [1,%d]", MAX_N);
	ensuref(1 <= max_m && max_m <= MAX_N, "max_m must be in [1,%d]", MAX_N);
	ensuref(weighted == "yes" || weighted == "no", "weighted == {yes, no}");

	vector<TC> tests;

	for (int tc = 1; tc <= t; tc++)
		tests.push_back(make_tc(max_n, max_m, weighted));

	cout << t << '\n';
	for (int tc = 0; tc < t; tc++)
	{
		TC cur = tests[tc];
		cout << cur.n << ' ' << cur.m << '\n';
		for (int i = 0; i < cur.n; i++)
			cout << cur.a[i] << (i == cur.n - 1 ? '\n' : ' ');
		for (int i = 0; i < cur.m; i++)
			cout << cur.b[i] << (i == cur.m - 1 ? '\n' : ' ');
	}
}

이 코드를 단계적으로 따라가며 분석해 보겠습니다.

Generator의 등록: registerGen

testlib를 사용하는 모든 프로그램은 항상 처음에 testlib.h를 include 하며 시작합니다. 이 헤더 파일 내부에는 기본적인 표준 라이브러리들을 include 하는 문장이 다수 포함되어 있어, 사실상 이 헤더 파일 하나만 include 하더라도 다른 표준 라이브러리들을 사용하는 데에 크게 지장이 없습니다.

우선 프로그램이 시작되면 이 프로그램이 generator라는 사실과 프로그램을 실행할 때의 인자가 무엇이었는지, 사용할 랜덤 함수의 버전이 무엇인지를 testlib에 알려야 합니다. 이를 수행하는 것이 registerGen 함수입니다.

int main(int argc, char *argv[])
{
	registerGen(argc, argv, 1);
	...
}

전달하는 인자로는 main 함수에 전달된 argcargv를 그대로 전달하면 되며, 랜덤 함수의 버전으로는 항상 1을 사용하는 것이 추천됩니다. 이 인자들을 통해 이후 testlib를 통해 호출할 랜덤 함수에서 나오는 값이 결정되므로 반드시 필요한 작업입니다.

인자들의 파싱: opt

Generator 프로그램은 하나더라도 데이터의 구체적인 성질을 달리하여 만들고 싶은 경우가 있을 것입니다. 예를 들어 대략적으로는 같은 랜덤이지만 특정 데이터에서는 $n$이 작은 것을 테스트하고 싶거나, 테스트 케이스의 수를 많이 넣은 데이터를 만들고 싶을 수도 있습니다. 이를 위해 generator를 사용할 때에는 명령줄에 보다 알아보기 쉬운 형태로 인자의 형식을 커스터마이징하여 전달하고 이를 testlib를 통해 안정적으로 파싱할 수 있습니다.

int t = opt<int>("t");
int max_n = opt<int>("max_n");
int max_m = opt<int>("max_m");
string weighted = opt("weighted");

opt는 비교적 최근에 추가된 기능입니다. 이전에는 직접 argv의 특정 인덱스에 접근하여 atoi 등의 함수를 통해 값을 파싱해와야 했기 때문에 불편하고 인자의 이름을 지어주기도 어려웠지만, 이제는 opt를 통해 편리하게 인자를 넘겨줄 수 있습니다. 자료형은 기본값인 stringint 외에도 char, long long, double 등 기본 자료형은 모두 사용할 수 있습니다. 이 코드의 의미는 이 generator는 총 4개의 추가적인 인자를 요구하며 각각의 이름이 t, max_n, max_m, weighted이고, 앞의 3개는 int형, 마지막은 일반 문자열이어야 한다는 것을 의미합니다. 명령줄에서 이 generator를 사용하는 예시로는 다음이 있습니다.

gen_random -t=100 -max_n=100 -max_m=100 -weighted=yes

opt 덕분에 명령줄의 의미가 보다 명확하게 와닿습니다. 이 명령줄을 통해 generator가 실행되면 t, max_n, max_m에는 100, weighted에는 “yes”라는 문자열이 들어가게 됩니다.

유효성 검사: ensure(f)

ensure 또는 ensuref는 기본적으로 C++ 표준 함수인 assert와 비슷한 역할을 하지만, 채점 시스템에 메시지를 전달할 수 있고 더 유연한 문자열 포맷팅을 사용할 수 있습니다. ensure를 사용하면 일반 assert와 비슷하게 단순 조건 하나만을 전달하고 실패 메시지는 기본값으로 나타나므로, 유효성 검사에 실패했을 때 구체적인 내용을 함께 제공받기 위해서는 ensuref를 사용하는 것이 좋습니다.

ensuref(1 <= t && t <= MAX_T, "t must be in [1,%d]", MAX_T);
ensuref(1 <= max_n && max_n <= MAX_N, "max_n must be in [1,%d]", MAX_N);
ensuref(1 <= max_m && max_m <= MAX_N, "max_m must be in [1,%d]", MAX_N);
ensuref(weighted == "yes" || weighted == "no", "weighted == {yes, no}");

여기서의 검사는 generator에 전달된 옵션이 문제에서 요구하는 입력의 범위와 일치하는지, 그리고 가중치를 둘 것인지 여부를 결정하는 weighted 옵션이 “yes” 또는 “no” 중 하나로 전달되었는지 등의 간단한 검사를 수행하고 있습니다. 첫 번째 인자로 해당 조건을 주고, 두 번째 이후 인자부터는 printf를 사용하는 것처럼 서식 문자열과 그 순서에 맞는 추가 인자들을 차례대로 전달하면 됩니다.

가중치 없는 / 있는 랜덤: rnd.next / rnd.wnext

이제 프로그램은 원하는 테스트 케이스의 개수만큼 루프를 돌면서 make_tc 함수를 호출하여 테스트 케이스를 하나씩 만듭니다. 이 함수에서 가장 먼저 하는 일은 우선 원하는 n, m의 범위 내에서 실제 n, m의 값을 하나 고르는 것입니다.

if (weighted == "yes")
{
  cur.n = rnd.wnext(1, max_n, max_n / 5 + 1);
  cur.m = rnd.wnext(1, max_m, max_m / 5 + 1);
}
else
{
  cur.n = rnd.next(1, max_n);
  cur.m = rnd.next(1, max_m);
}

랜덤과 관련된 작업을 할 때에는 rnd 객체를 사용합니다. rnd에는 처음에 generator를 등록할 때 전달한 인자들에 의해 seed가 초기화되어 있으므로, 같은 인자를 통해 프로그램을 실행하면 rnd를 통해 생성되는 랜덤 값들 또한 같게 나오게 됩니다.

rnd.nextrnd.wnext에는 다양한 버전이 있습니다. 이 중 가장 많이 쓰이는 것으로는 이 코드에서의 예시와 같이 특정 정수 범위 내에서의 값을 하나 고르는 것으로, rnd.next에 두 개의 정수를 전달하면 그 사이 닫힌 구간 내에서 하나의 정수를 랜덤으로 골라줍니다. rnd.wnext의 경우 하나의 정수 인자를 추가로 받으며, 이 인자의 의미는 “그 횟수만큼 해당 범위 내에서 균일 분포로 수를 고르고 그 중 최댓값을 선택한다”는 의미입니다.2 위 코드에서는 weighted가 “yes”인 경우 1과 max_n, max_m 사이에서 큰 쪽으로 치우쳐지게끔 nm을 고르게 되며, weighted가 “no”인 경우에는 균일 분포로 nm을 선택하게 됩니다.

또 자주 사용되는 rnd.next의 버전으로는 첫 수를 0으로 암시적으로 지정하고 하나의 정수만을 전달하는 것도 있습니다. 또한 int 대신 다른 자료형의 수를 전달하여 그 자료형에 맞는 오버로딩 버전을 사용할 수도 있고, 아무 인자 없이 호출하면 [0, 1) 구간에서의 아무 double 값을 하나 뽑아줍니다.

이후 make_tc 함수는 지정한 nm의 개수만큼 루프를 돌며 1~100 사이의 랜덤 값을 뽑아 데이터를 완성하게 됩니다.

데이터의 출력

완성된 데이터를 출력하는 것은 평범하게 cout이나 printf로 하면 됩니다. 문제를 풀 때 누구나 겪게 되는 문제인 느린 endl은 역시 사용하지 않는 것이 좋습니다.

cout << t << '\n';
for (int tc = 0; tc < t; tc++)
{
  TC cur = tests[tc];
  cout << cur.n << ' ' << cur.m << '\n';
  for (int i = 0; i < cur.n; i++)
    cout << cur.a[i] << (i == cur.n - 1 ? '\n' : ' ');
  for (int i = 0; i < cur.m; i++)
    cout << cur.b[i] << (i == cur.m - 1 ? '\n' : ' ');
}

출력 형식은 문제의 조건과 이후 살펴볼 validator를 통과할 수 있게끔 한치의 오차도 없게 맞춰주어야 합니다. 줄 끝에 불필요한 공백을 삽입하거나, 마지막 줄에 개행이 없는 경우 등이 대표적으로 자주 겪게 되는 오류입니다.

Validator

Validator는 만들어진 데이터가 문제의 조건을 지키는지 여부를 검증해주는 프로그램입니다. Generator가 문제를 만드는 데에 가장 중요한 프로그램이라면, validator는 대회가 망하지 않게 만드는 데에 가장 중요한 프로그램이라고 할 수 있습니다. 아무리 generator에서 미리 열심히 조건을 체크하고 입력 조건을 어기지 않도록 구성하도록 노력하더라도, 실수는 언제나 할 수 있으며 특히 여러 종류의 generator를 만드는 경우 위험성이 더욱 커지기 때문에 견고한 validator를 만드는 것은 매우 중요한 일입니다. 위 generator와 같은 문제에 대한 validator 코드는 다음과 같습니다.

#include "testlib.h"
using namespace std;

const int MAX_T = 100;
const int MAX_N = 100;
const int MAX_COORD = 100;

int main(int argc, char *argv[])
{
	registerValidation(argc, argv);
	int t = inf.readInt(1, MAX_T, "t");
	inf.readEoln();

	for (int tc = 1; tc <= t; tc++)
	{
		setTestCase(tc);
		int n = inf.readInt(1, MAX_N, "n");
		inf.readSpace();
		int m = inf.readInt(1, MAX_N, "m");
		inf.readEoln();
		int prev = -1;
		for (int i = 1; i <= n; i++)
		{
			int cur = inf.readInt(1, MAX_COORD, format("a[%d]", i));
			ensuref(cur > prev, "a[%d]=%d must be greater than a[%d]=%d", i, cur, i - 1, prev);
			if (i != n)
				inf.readSpace();
			else
				inf.readEoln();
		    prev = cur;
		}
		prev = -1;
		for (int i = 1; i <= m; i++)
		{
			int cur = inf.readInt(1, MAX_COORD, format("b[%d]", i));
			ensuref(cur > prev, "b[%d]=%d must be greater than b[%d]=%d", i, cur, i - 1, prev);
			if (i != m)
				inf.readSpace();
			else
				inf.readEoln();
			prev = cur;
		}
	}

	inf.readEof();
}

Validator 등록하기: registerValidation

Generator를 등록할 때와 마찬가지로 validator도 testlib에 등록해야 합니다. 여기에는 registerValidation이라는 함수가 사용됩니다.

int main(int argc, char *argv[])
{
	registerValidation(argc, argv);
	...
}

Validator에서 랜덤은 필요하지 않으므로 세 번째 인자는 없습니다. 대신에 testset, group이나 log file 등의 기능을 이용하기 위해 명령줄은 넘겨줄 수 있는데, 이는 이 글에서 다루지는 않겠습니다.

입력 파일로부터 읽기: inf.read***

Generator에서 출력 내용을 cout이나 printf를 통해 stdout에 써줬던 것과는 달리, validator에서 이 파일의 내용을 읽는 데에는 cin이나 scanfstdin을 직접 사용하는 것이 아니라 inf라는 전역 객체를 사용하게 됩니다. 위에서 validator를 등록할 때 이 객체에 stdin으로부터의 모든 입력 내용이 들어가기 때문입니다.

출력할 때와 달리 입력 과정에서 inf와 같은 자체 입력 스트림을 사용하는 이유는 출력은 포맷을 지정하여 정확하게 원하는 문자열을 만들어내는 것이 용이한 반면에 입력 문자열이 원하는 포맷 조건을 지키는지 정밀하게 검사하며 파싱하는 것은 표준 라이브러리들만으로는 어려운 일이며 파싱 에러가 발생했을 때 즉각 대처하기도 까다롭기 때문입니다. 따라서 validator에서는 inf에서 제공하는 다양한 입력 함수들을 활용하여 입력 조건을 철저하게 검사할 필요가 있습니다.

이 코드에서 입력에 사용된 문장들을 일부 살펴보면 다음과 같습니다.

int t = inf.readInt(1, MAX_T, "t");
inf.readEoln();
...
int n = inf.readInt(1, MAX_N, "n");
inf.readSpace();
int m = inf.readInt(1, MAX_N, "m");
inf.readEoln();
...
int cur = inf.readInt(1, MAX_COORD, format("a[%d]", i));
...
inf.readEof();

우선 일반적으로 가장 자주 쓰일 함수는 inf.readInt입니다. 이 함수는 기본적으로 두 개의 인자를 요구하며, 이는 위에서 rnd.next에 쓰인 것과 비슷하게 허용할 수의 범위를 나타냅니다. 따라서 inf.readInt(1, MAX_T, "t");는 [1,MAX_T]의 범위에서 int형 정수를 하나 읽어온다는 것을 의미합니다. inf의 입력 함수들에는 마지막에 문자열을 하나 전달하게끔 되어있는데, 이는 그 입력이 무엇을 나타내는 것인지를 알리기 위한 것으로 선택사항이지만 Polygon에서는 디버깅을 위해 강력하게 권장됩니다.3 int 외에 long long 값을 읽고 싶다면 inf.readLong을 쓸 수 있으며, doubleinf.readDouble을 쓰면 됩니다.

Validator의 역할은 입력 파일이 한 글자의 오차도 없이 올바른 형식을 갖추고 있는지를 평가하는 것이므로 공백 하나, 개행 하나도 확실하게 읽고 판단해야 합니다. 공백을 읽기 위해서는 inf.readSpace를 사용하며, 개행을 읽기 위해서는 inf.readEoln을 사용합니다. 또한 모든 입력이 끝난 뒤 파일에 여분의 문자가 남아있지 않은지 역시도 확인해야 하며 이때 inf.readEof를 사용해야 합니다.

문자열을 읽는 경우 정규표현식을 사용하여 특정 패턴의 문자열을 읽게 할 수 있습니다. 위 코드에는 나오지 않았으나, 이전 Codeforces Round #620 (Div. 2)에 사용했던 Longest Palindrome 문제의 validator에 쓰인 다음과 같은 예시가 있습니다.

string s = inf.readString("[a-z]+", format("string %d", i));

이 코드는 inf.readString을 사용하여 하나의 문자열을 읽으며, 이 문자열은 정규표현식으로 “[a-z]+” 패턴을 가져야 함을 의미합니다. 즉, 하나 이상의 소문자로 이루어진 문자열이 아니면 검증에 실패하게 됩니다.

추가로, 위에서 몇 번 쓰인 format은 표준 라이브러리인 vsprintf를 사용하여 포맷팅된 문자열과 인자를 통해 문자열을 생성하고 이를 std::string 형으로 만들어 반환해주는 간편한 함수입니다.

Checker

이제 마지막으로 checker를 만들어야 합니다. 그런데 그 전에, 이 문제가 과연 별도의 checker가 필요한 출력 형식을 가지고 있는지부터 생각해 보겠습니다. 문제에서 요구하는 것은 그저 $t$개의 정수를 한 줄에 하나씩 출력해야 하는 문제이며, 관례상 한 줄에 하나씩이 아니더라도 정답으로 인정을 해줄 것입니다. 또한 답이 여럿이 될 수도 없어 스페셜 저지가 따로 필요하지 않습니다. 이러한 경우에는 checker를 따로 작성할 필요가 없습니다. 기본으로 제공되는 예제를 사용하면 됩니다.

기본 Checker들

이 문제와 같이 단순히 하나 이상의 정수를 출력해야 하는 문제라면 ncmp.cpp를 사용할 수 있습니다. 이 파일은 일반적으로 C++에서 정수용으로 사용하는 가장 큰 범위인 int64 내의 모든 정수에 대한 처리가 가능합니다.

조금 더 일반적인 문자열에 대해서도 유연하게 사용할 수 있는 파일은 lcmp.cpp이지만, 이는 반드시 줄 단위로만 검사를 수행하며 정수만을 출력하는 문제에 사용할 경우 메시지에 융통성이 없어진다는 단점이 있습니다.

실수를 출력해야 하는 문제의 경우 일정 범위 내의 오차를 허용하는 것이 일반적입니다. 이 경우에는 오차 범위에 따라 rcmp4.cpp, rcmp6.cpp, rcmp9.cpp를 사용할 수 있습니다. 각각 $10^{-4}$, $10^{-6}$, $10^{-9}$의 오차를 허용하며, testlib.h에 포함된 doubleCompare 함수를 통해 정교하게 결과를 비교해줍니다.

문자열 출력이지만 전형적인 형태의 문제로 yes/no 문제가 있는데, 이런 문제들만을 위한 nyesno.cpp도 준비되어 있습니다. 특징으로는 대소문자를 구분하지 않으며, “yes”나 “no” 이외의 문자열이 입력된 경우에 대한 메시지를 따로 출력해준다는 점도 있습니다.

스페셜 저지

하지만 이렇게 기본으로 제공되는 예제 외에 특수한 판정 기준이 필요한 문제들도 있습니다. 답이 여럿이 될 수 있는 경우4를 위한 checker를 스페셜 저지라고 부릅니다. 이러한 경우에는 다음과 같은 절차를 거쳐 정답 여부를 판별하게 됩니다.

  1. 정답 코드에 의해 생성된 파일 읽기
  2. 참가자의 코드에 의해 생성된 출력 파일을 읽기
  3. 각각의 유효성(또는 참가자의 출력만)을 검사
  4. (필요하면) 두 코드의 답을 비교 (예: 가장 큰 답을 찾아야 하는 경우)

스페셜 저지를 구현한 예시로는 같은 대회의 D번 문제인 Checkpoints의 checker를 사용하도록 하겠습니다.

#include "testlib.h"
using namespace std;
using ll = long long;

const int MAX_T = 50;
const int MAX_N = 2000;

ll k;

void check(InStream &in)
{
	int i;
	ll ans = 0;

	int n = in.readInt(-1, MAX_N);
	if (n == -1)
	{
		if (k % 2 == 0)
			in.quitf(_wa, "an answer exists but the participant couldn't find it");
		return;
	}
	if (n == 0)
		in.quitf(_wa, "0 stages is not allowed");

	if (in.readInt(0, 1) != 1)
		in.quitf(_wa, "there must be a checkpoint on the first stage");
	int prev = 1;
	for (i = 2; i <= n + 1; i++)
	{
		int x = (i != n + 1 ? in.readInt(0, 1) : 1);
		if (x == 1)
		{
			int d = i - prev;
			if (d >= 59 || (ans += (1ll << (d + 1)) - 2) > k)
				in.quitf(_wa, "the expected number of tries exceeds k");
			prev = i;
		}
	}

	if (ans < k)
		in.quitf(_wa, "the expected number of tries is less than k");
}

int main(int argc, char *argv[])
{
	registerTestlibCmd(argc, argv);
	int t = inf.readInt();

	for (int tc = 1; tc <= t; tc++)
	{
		setTestCase(tc);
		k = inf.readLong();
		check(ans);
		check(ouf);
	}

	int extraInOufCount = 0;
	while (!ouf.seekEof())
	{
		ouf.readToken();
		extraInOufCount++;
	}

	if (extraInOufCount > 0)
		quitf(_wa, "Output contains extra information (%d tokens)", extraInOufCount);

	quitf(_ok, "ok");
}

Checker의 등록: registerTestlibCmd

Checker 역시 generator, validator와 마찬가지로 testlib에 등록해야 하며, 이때는 registerTestlibCmd 함수가 사용됩니다.

registerTestlibCmd(argc, argv);

세 개의 파일: inf, ans, out

Checker는 무려 세 개의 파일을 읽어야 합니다. Validator에서 읽었던 것과 같은 입력 파일인 inf, 정답 코드에 의해 만들어진 출력 파일인 ans, 그리고 참가자의 코드에 의해 만들어진 출력 파일인 out이 그것들입니다. 정답을 비교하는데 입력 파일이 필요한 이유는 정답의 유효성을 검사하기 위해서는 원래의 입력이 무엇이었는지 역시 checker가 알고 있어야 하기 때문입니다.

정답 코드와 참가자의 코드에 의해 만들어진 출력 파일인 ansout은 똑같은 방법으로 읽고 유효성 검사를 할 것이므로, 이 코드와 같이 하나의 유효성 검사 루틴을 두고 ansout 각각에 대해 한 번씩 실행하는 것이 권장됩니다.

for (int tc = 1; tc <= t; tc++)
{
  setTestCase(tc);
  k = inf.readLong();
  check(ans);
  check(ouf);
}

이 부분에 사용된 setTestCase 함수는 다중 테스트 케이스 문제에서 틀렸을 때 testlib가 채점 시스템에 몇 번째 테스트 케이스에서 판정된 결과인지를 같이 전달하도록 해주는 간편한 함수입니다.

판정 결과 전달: quitf

check 함수에서는 주어진 출력 파일에 대한 유효성 검사를 합니다. 그런데 만일 중도에 유효하지 않은 부분을 발견했다면 그 자리에서 즉시 틀렸다는 판정을 내리고 종료해도 될 것입니다. 이 경우 quitf 함수를 사용하게 됩니다.

예시로 이 문제에서는 ‘try’의 기댓값이 정확히 $k$가 되어야 하므로, 이 값이 이미 $k$를 초과해버렸다면 이후는 볼 것도 없이 답이 틀린 것이므로 바로 오답 판정을 내려도 됩니다. 아래 코드가 이를 판정하는 부분입니다.

if (d >= 59 || (ans += (1ll << (d + 1)) - 2) > k)
  in.quitf(_wa, "the expected number of tries exceeds k");

전역으로 선언된 quitf도 있으나 일반적으로는 파일 스트림 객체의 멤버 함수 quitf를 사용하는 것이 권장됩니다. quitf의 첫 번째 인자로는 판정 결과를 전달하는데, 밑줄로 시작하는 enum 값들이 미리 여럿 정의되어 있습니다. 일반적으로는 _wa만을 사용하면 됩니다. Checker나 정답 코드에 문제가 있음을 명시적으로 알리기 위한 _fail도 있지만 정답 코드의 유효성을 검사하는 부분에서는 사용할 필요는 없습니다. 파일 스트림 객체가 이미 그 파일이 어떤 코드로부터 나온 결과물인지를 알고 있기 때문에 _wa를 전달하더라도 해당 파일이 정답 코드의 출력이라면 fail로 처리를 해주기 때문입니다. quitf의 두 번째 인자로는 checker의 메시지를 적어주면 됩니다.

또한 출력 결과가 정답인 경우 main 함수가 종료되기 전에도 quitf를 호출해주어야 하며, 이때에는 _ok를 전달해야 합니다.

quitf(_ok, "ok");

불필요한 추가 출력 확인하기

모든 정답을 출력한 이후 불필요한 출력을 추가로 했을 경우 정답으로 인정하고 싶지 않다면 그를 확인하는 코드 또한 추가해야 합니다. 이런 경우 주로 아래와 같은 코드를 활용하게 됩니다.

int extraInOufCount = 0;
while (!ouf.seekEof())
{
  ouf.readToken();
  extraInOufCount++;
}

if (extraInOufCount > 0)
  quitf(_wa, "Output contains extra information (%d tokens)", extraInOufCount);

여기에서 사용된 seekEof 함수는 당장 EOF를 읽지 않으면 에러로 판정하는 readEof 함수와는 달리 화이트스페이스 등을 무시하고 EOF가 바로 나오는지를 확인해줍니다. 즉, 정답에 해당하는 출력 이후 화이트스페이스가 아닌 문자가 하나라도 더 남아있다면 이 부분에서 extraInOufCount가 0이 아니게 되므로 틀린 것으로 판정하도록 만든 것입니다.

기타 유용한 기능들

이외에도 testlib에는 유용한 기능들이 많이 있습니다. 대표적으로 rnd를 사용하여 리스트를 섞어주는 자체적인 shuffle 함수가 있으며, 수를 보다 읽기 쉬운 형태의 문자열로 만들어주는 toHumanReadableString 함수나 영어식으로 서수 문자열을 만들어주는 englishEnding 함수 등 편리하게 활용할 수 있는 다양한 유틸리티들이 포함되어 있습니다. 좋은 기능들을 놓치고 싶지 않다면 testlib.h를 직접 열고 전체를 정독해보는 것도 괜찮을 것입니다.

  1. 이것은 생각보다 매우 중요한 기능입니다. 같은 인자를 통해 generator를 실행했는데 그때마다 매번 다른 입력 파일이 만들어진다면 원하는 데이터가 ‘우연히’ 생성되었을 때 항상 그 결과물을 통째로 저장해두고 있어야 하는데, 이는 이미 데이터 세트가 완성된 후라면 상관이 없겠지만 작업하는 도중에는 관리하기가 까다로우며 한 번 잃어버린 경우 쉽게 재생성할 수도 없습니다. 

  2. 그렇다면 “이 횟수를 너무 크게 하면 generator의 실행이 너무 느려질 수도 있지 않을까?”라고 생각할 수 있지만 걱정하지 않아도 됩니다. testlib 자체적으로 random_t::lim이라는 한계값을 가지고 있으며, 이를 넘는 횟수가 전달될 경우 직접 그 횟수만큼을 다 골라보는 것이 아닌 공식을 통해 $\mathcal{O}(1)$ 시간에 근사적으로 기대하는 분포가 나오도록 수를 찾아줍니다. 

  3. 입력에 문제가 있을 때 전달된 그 문자열에 의한 것임을 에러 메시지로 알려줍니다. 

  4. 실제로는 하나이지만 참가자를 속이고 여럿인 것처럼 위장할 수도 있습니다. 답이 하나밖에 존재할 수 없음을 알아내는 것도 문제의 일부인 경우가 해당됩니다.