오랜만에 작성하는 KOI 1차 풀이 글입니다. 2026 한국정보올림피아드 1차 대회 2교시에 출제된 6문제의 풀이를 다룹니다.
과거 대회 풀이는 아래 링크에서 확인할 수 있습니다.
단순 구현 문제입니다. $1 \le i,j \le N$ 이고 $i \ne j$ 인 모든 정수 순서쌍 $(i, j)$를 순회하면서, $i$와 $j$가 이웃 조건을 만족한다면 $i$번째 학생의 이웃 수를 1 증가시키도록 구현하면 됩니다.
#include <iostream>
#include <iterator>
#include <cmath>
#include <vector>
using namespace std;
int main(){
int n, k1, k2;
cin >> n >> k1 >> k2;
vector<int> s(n), res(n);
for(auto &i : s) cin >> i;
for(int i=0; i<n; i++){
for(int j=0; j<n; j++){
if(i == j) continue;
if(s[i] == s[j] && abs(i - j) <= k1) res[i]++;
if(s[i] != s[j] && abs(i - j) <= k2) res[i]++;
}
}
copy(res.begin(), res.end(), ostream_iterator<int>(cout, " "));
}
초등부 1번 문제를 여러 방향에서 확장시킨 문제입니다.
$j$번 학생이 $i$번 학생과 이웃이기 위해서는 아래 조건 중 하나를 만족해야 합니다.
첫 번째 조건은 학교가 같은 학생의 위치를 하나의 배열에 모은 다음 이분 탐색을 사용하면, 조건을 만족하는 $j$의 개수를 $O(\log N)$ 시간에 구할 수 있습니다. 같은 방식으로 두 번째 조건까지 처리하기 위해서는 $i$번 학생이 다니지 않는 모든 학교를 순회하면서 이분 탐색을 수행해야 하는데, 학교가 최대 $N$가지 있기 때문에 너무 오래 걸립니다.
2번 조건을 만족하는 $j$의 수는 아래의 첫 번째 조건을 만족하는 학생의 수에서 두 번째 조건을 만족하는 학생의 수를 뺀 것과 같습니다.
따라서 $S_i \ne S_j$ 인 친구의 수도 이분 탐색을 이용하면 $O(\log N)$ 시간에 구할 수 있습니다.
각 학생에 대해 $O(\log N)$ 시간에 답을 구할 수 있으므로 전체 시간 복잡도는 $O(N \log N)$입니다. Radix sort 와 투 포인터 기법을 이용하면 $O(N)$ 시간에 해결할 수도 있습니다.
#include <bits/stdc++.h>
using namespace std;
int N, K1, K2, R[505050];
vector<pair<int,int>> G[505050];
vector<pair<int,int>> A;
int Get(const vector<pair<int,int>> &v, int le, int ri){
auto lo = lower_bound(v.begin(), v.end(), make_pair(le, -1));
auto hi = upper_bound(v.begin(), v.end(), make_pair(ri, N+1));
return distance(lo, hi);
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> K1 >> K2;
for(int i=1; i<=N; i++){
int x, s; cin >> x >> s;
G[s].emplace_back(x, i);
A.emplace_back(x, i);
}
for(int i=1; i<=N; i++) sort(G[i].begin(), G[i].end());
sort(A.begin(), A.end());
for(int v=1; v<=N; v++){
for(auto [x,i] : G[v]){
int local = Get(G[v], x-K1, x+K1);
int global = Get(A, x-K2, x+K2) - Get(G[v], x-K2, x+K2);
R[i] = local + global - 1;
}
}
for(int i=1; i<=N; i++) cout << R[i] << " ";
}
$i$보다 왼쪽에 있는 사람과 오른쪽에 있는 사람이 대결할 수는 없으므로, $A[1\cdots i]$와 $A[i\cdots N]$을 독립적으로 판정해도 됩니다. 바위(R)을 낸 $i$번 사람이 $A[1\cdots i]$를 모두 이길 수 있는지 판정하는 상황을 생각합시다.
만약 $A[1\cdots i-1]$에 바위(R)를 이기는 보(P) 로만 구성되어 있다면 $i$는 절대 우승할 수 없습니다. 반대로, 보(P)가 하나도 없다면 $i$번 사람이 모든 사람을 상대로 승자가 될 수 있으므로 우승할 수 있습니다.
$A[1\cdots i-1]$에 보(P)와 나머지(R, S)가 둘 다 나오는 경우에는, 보(P)를 모두 없앨 수 있다면 $i$가 우승할 수 있을 것입니다. 이때는 보(P)가 인접한 위치에 있는 모든 바위(R)를 없앤 다음 가위(S)가 보(P)를 잡는 식으로 매치를 구성하면 됩니다. 따라서 둘 모두 나오는 경우 가위(S)가 하나라도 있으면 $i$번 사람이 우승할 수 있습니다.
정리하면, 구간에 $i$번 사람을 이길 수 있는 사람이 한 명도 없거나, $i$번 사람에게 지는 사람이 한 명이라도 있는 경우 $i$번 학생이 우승할 수 있습니다.
누적 합 배열을 이용하면 구간에 특정 카드가 존재하는지를 $O(1)$ 시간에 판별할 수 있습니다. 따라서 $O(N)$ 시간에 전처리하면 각 사람마다 $O(1)$ 시간에 답을 구할 수 있고, 전체 시간 복잡도는 $O(N)$이 됩니다.
#include <bits/stdc++.h>
using namespace std;
string X = "RSP";
int N, A[202020], B[202020][3];
string S;
bool f(int l, int r, int v){
if(l > r) return true;
int win = (v + 1) % 3, lose = (v + 2) % 3;
if(B[r][win] - B[l-1][win] > 0) return true;
if(B[r][lose] - B[l-1][lose] == 0) return true;
return false;
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> S;
for(int i=1; i<=N; i++) A[i] = X.find(S[i-1]);
for(int i=1; i<=N; i++) memcpy(B[i], B[i-1], sizeof B[i]), B[i][A[i]] += 1;
for(int i=1; i<=N; i++) cout << (f(1, i-1, A[i]) && f(i+1, N, A[i]));
}
편의상 좌표 압축을 해서, 수열 $A$에 $1, 2, \cdots, M$이 모두 각각 한 번 이상 등장하는 상황만 생각합시다.
어떤 정수 $v$가 처음으로 등장하는 위치를 $L(v)$, 마지막으로 등장하는 위치를 $R(v)$라고 합시다. 모든 $a < b$에 대해 $R(a) < L(b)$가 되도록 만드는 것이 목표입니다. 만약 $a < b$ 이고 $R(a) > L(b)$ 라면 $[a, b)$ 범위의 정수 중 하나를 $x$로 정해서 연산을 수행해야 $R(a) < L(b)$로 만들 수 있습니다.
잘 생각해 보면 $a = i, b = i + 1$인 $(a, b)$ 쌍만 고려해도 된다는 사실을 알 수 있습니다. 즉, $R(i) > L(i+1)$ 인 모든 $i$를 찾은 뒤 $x = A_i$로 연산을 수행하면 수열을 정렬할 수 있고, 이보다 더 적은 횟수로 정렬할 수 없음을 증명할 수 있습니다.
구현 방식에 따라 $O(N)$ 또는 $O(N \log N)$ 시간에 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
int N, A[303030], B[303030], R;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
fill(A+1, A+N+1, N+1);
for(int i=1; i<=N; i++){
int t; cin >> t;
A[t] = min(A[t], i);
B[t] = max(B[t], i);
}
int lst = 0;
for(int i=1; i<=N; i++){
if(A[i] > N) continue;
if(lst > A[i]) R++;
lst = B[i];
}
cout << R;
}
정점에 가중치($A_i$)가 없다면 트리의 지름을 찾는 문제입니다. 정점의 가중치는 가장 큰 값만 $-\max A_i$ 형태로 반영되므로, $A_i \le x$ 인 정점으로만 구성된 포레스트에서 $(\text{지름} - x)$ 를 구하는 것을 모든 $x$에 대해 반복하면 답을 구할 수 있습니다.
이를 위해 $A_i$ 오름차순으로 정점을 추가하면서, union find를 이용해 두 컴포넌트를 합칠 때 지름을 효율적으로 계산하는 방법을 알아봅시다. 가중치가 0 이상인 트리는 아래 성질이 있다는 것이 잘 알려져 있습니다.
따라서 합치고자 하는 두 컴포넌트 $T_a$와 $T_b$의 지름이 각각 $(a_1, a_2)$, $(b_1, b_2)$ 일 때, 합쳐진 컴포넌트의 지름은 아래 6가지 중 하나입니다.
경로의 길이는 LCA를 이용하면 $O(N \log N)$ 시간 전처리 후에 매번 $O(\log N)$ 시간에 구할 수 있으므로 두 컴포넌트를 $O(\log N)$ 시간에 합칠 수 있습니다. 이러한 연산을 총 $N$번 수행하므로 전체 시간 복잡도는 $O(N \log N)$입니다. Radix sort를 이용해 정점을 정렬하고, $O(N)$ 전처리와 $O(1)$ 쿼리를 지원하는 LCA를 구현한다면 $O(N \alpha(N))$ 시간에도 문제를 해결할 수 있습니다. Union 연산의 순서를 사전에 모두 구할 수 있으므로 여기에 있는 방법을 이용하면 선형 시간에 해결할 수도 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, A[303030], C[303030], D[303030], P[22][303030];
vector<tuple<ll,ll,ll>> E;
vector<tuple<ll,ll,int>> G[303030];
int Active[303030];
void DFS(int v, int b=-1){
for(auto [i,w,e] : G[v]) if(i != b) P[0][i] = v, C[i] = C[v] + w, D[i] = D[v] + 1, DFS(i, v);
}
int LCA(int u, int v){
if(D[u] < D[v]) swap(u, v);
for(int i=0, k=D[u]-D[v]; k; i++, k>>=1) if(k & 1) u = P[i][u];
if(u == v) return u;
for(int i=21; i>=0; i--) if(P[i][u] != P[i][v]) u = P[i][u], v = P[i][v];
return P[0][u];
}
ll Dist(int u, int v){
return C[u] + C[v] - 2 * C[LCA(u, v)];
}
int UF[303030], U[303030], V[303030];
ll M[303030], Diameter[303030], R=LLONG_MIN, ru, rv;
void Init(){
iota(UF+1, UF+N+1, 1);
iota(U+1, U+N+1, 1);
iota(V+1, V+N+1, 1);
copy(A+1, A+N+1, M+1);
memset(Diameter, 0xc0, sizeof Diameter);
}
int Find(int v){ return v == UF[v] ? v : UF[v] = Find(UF[v]); }
void Merge(int u, int v){
u = Find(u); v = Find(v);
if(u == v) return;
UF[u] = v; M[v] = max(M[v], M[u]);
vector<int> can = {U[u], U[v], V[u], V[v]};
for(int i=0; i<4; i++){
for(int j=i+1; j<4; j++){
int x = can[i], y =can[j];
ll dist = Dist(x, y);
if(dist > Diameter[v]) Diameter[v] = dist, U[v] = x, V[v] = y;
}
}
if(Diameter[v] - M[v] > R){
R = Diameter[v] - M[v];
ru = U[v]; rv = V[v];
}
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N; E.resize(N-1);
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<N; i++) get<0>(E[i-1]) = i;
for(auto &[u,v,w] : E) cin >> v;
for(auto &[u,v,w] : E) cin >> w;
for(int i=0; i+1<N; i++){
auto [u,v,w] = E[i];
G[u].emplace_back(v, w, i);
G[v].emplace_back(u, w, i);
}
DFS(1);
for(int i=1; i<22; i++) for(int j=1; j<=N; j++) P[i][j] = P[i-1][P[i-1][j]];
vector<int> O(N);
iota(O.begin(), O.end(), 1);
sort(O.begin(), O.end(), [](int x, int y){ return A[x] < A[y]; });
Init();
for(auto v : O) for(auto [i,w,e] : G[v]) if(Active[e]++) Merge(v, i);
cout << ru << " " << rv;
}
러프하게 요약하면, DAG 가 주어졌을 때 각 정점에서 도달할 수 있는 정점의 수를 세는 문제입니다. 그래프를 naive하게 만들면 간선이 $O(N^2)$개라는 것, 그리고 일반적인 그래프에서 이런 문제는 $O(N^2)$보다 빠르게 풀 수 없다는 문제점이 있습니다. 문제의 성질을 이용해서 이 두 가지 요소를 풀어나가는 것이 핵심입니다.
아래 두 가지 성질을 관찰합시다.
여기에서 $p_{i,R}$은 $(X_i, i)$ 보다 오른쪽에 있으면서 한 번에 갈 수 있는 가장 낮은 발판이라고 생각할 수 있습니다.
(1)은 어렵지 않게 증명할 수 있고, (2) 또한 (1) 을 관찰했다면 어렵지 않게 증명할 수 있습니다. 따라서 $i$의 왼쪽과 오른쪽에서 각각 갈 수 있는 가장 낮은 발판으로 가는 간선, 그리고 자신의 바로 위로 갈 수 있는 가장 낮은 발판으로 가는 간선만 추가해도 그래프의 정보를 온전히 나타낼 수 있습니다. 세그먼트 트리 등을 사용하면 $O(N \log N)$ 시간에 모든 간선을 구할 수 있고, 이를 통해 간선을 $O(N^2)$개에서 $O(N)$개로 줄일 수 있습니다. 구체적인 방법은 아래 코드를 참고하시면 됩니다.
이제 도달 가능한 정점의 수를 빠르게 세야 합니다. DP를 다음과 같이 정의합시다.
$D_U(i)$ 는 단순히 $i < j$ 이고 $X_i = X_j$ 인 $j$의 개수를 세면 됩니다. 모든 $i$에 대해 $D_U(i)$를 $O(N \log N)$에 구하는 다양한 방법이 있습니다.
$D_R(i)$ 는 우선 $D_R(p_{i,R})$의 값을 가져오면 $X_{p_{i,R}} < X_j$ 인 $(X_j, j)$는 모두 커버할 수 있습니다. $X_i < X_j \le X_{p_{i,R}}$ 인 발판을 추가로 세어야 하는데, 정의의 따르면 $X_{p_{i,R}} \le X_i + D$ 이므로 $X_i < X_j \le X_{p_{i,R}}$ 인 $(X_j, j)$ 는 모두 $(X_i, i)$ 에서 도달할 수 있습니다. 따라서 $i = N, N-1, \cdots, 1$ 순으로 처리하면서 세그먼트 트리 등을 이용해 $i < j$ 이고 $X_i < X_j \le X_{p_{i,R}}$ 인 $j$의 수를 매번 $O(\log N)$ 시간에 구할 수 있습니다.
$D_L(i)$는 $D_R(i)$와 같은 방식으로 구하면 됩니다.
$p_{i,L}$과 $p_{i,R}$을 구하는 것, 그리고 $D_L$, $D_R$, $D_U$ 를 계산하는 것 모두 $O(N \log N)$ 시간에 수행할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int SZ = 1 << 19;
constexpr int INF = 0x3f3f3f3f;
namespace min_segment_tree{
int T[SZ<<1];
void init(){ memset(T, 0x3f, sizeof T); }
void update(int x, int v){
for(x|=SZ; x; x>>=1) T[x] = min(T[x], v);
}
int query(int l, int r){
int res = INF;
for(l|=SZ, r|=SZ; l<=r; l>>=1, r>>=1){
if(l & 1) res = min(res, T[l++]);
if(~r & 1) res = min(res, T[r--]);
}
return res;
}
}
namespace fenwick_tree{
int T[SZ];
void add(int x, int v){ for(x+=3; x<SZ; x+=x&-x) T[x] += v; }
int get(int x){ int r = 0; for(x+=3; x; x-=x&-x) r += T[x]; return r; }
int get(int l, int r){ return l <= r ? get(r) - get(l-1) : 0; }
}
int N, D, X[303030], L[303030], R[303030];
int LD[303030], RD[303030], UP[303030];
vector<int> C;
vector<int> PrvL[303030], PrvR[303030];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> D;
for(int i=1; i<=N; i++) cin >> X[i];
C = vector<int>(X+1, X+N+1);
sort(C.begin(), C.end());
C.erase(unique(C.begin(), C.end()), C.end());
for(int i=1; i<=N; i++) X[i] = lower_bound(C.begin(), C.end(), X[i]) - C.begin();
min_segment_tree::init();
for(int i=N; i>=1; i--){
int lo = lower_bound(C.begin(), C.end(), C[X[i]] - D) - C.begin();
int hi = upper_bound(C.begin(), C.end(), C[X[i]] + D) - C.begin() - 1;
L[i] = min_segment_tree::query(lo, X[i]-1);
R[i] = min_segment_tree::query(X[i]+1, hi);
if(L[i] < INF) PrvL[L[i]].push_back(i);
if(R[i] < INF) PrvR[R[i]].push_back(i);
min_segment_tree::update(X[i], i);
}
for(int i=N; i>=1; i--){
fenwick_tree::add(X[i], 1);
UP[i] = fenwick_tree::get(X[i], X[i]);
for(auto j : PrvL[i]) LD[j] = LD[i] + fenwick_tree::get(X[i], X[j]-1);
for(auto j : PrvR[i]) RD[j] = RD[i] + fenwick_tree::get(X[j]+1, X[i]);
}
for(int i=1; i<=N; i++) cout << LD[i] + RD[i] + UP[i] << " ";
}
AtCoder Typical DP Contest (link)의 풀이입니다. solved.ac 기준 골드 하위권부터 다이아 중위권 정도 범위의 문제를 다룹니다.
TDPC는 2013년에 열린 대회이므로 최근에 유행한 테크닉을 공부하길 원한다면 다른 대회를 참고하는 것이 좋을 수 있습니다. 2026년에 개최된 AtCoder Next DP Contest (link) 도 함께 공부하는 것을 추천합니다.
유형과 solved.ac 기준 체감 난이도는 다음과 같습니다.
| 문제 | 유형 | 난이도 |
|---|---|---|
| A. コンテスト (Contest) | 배낭 문제 | G5 |
| B. ゲーム (Game) | 게임 이론 | G3 |
| C. トーナメント (Tournament) | 확률 DP, 트리 DP | G2 - P5 |
| D. サイコロ (Dice) | 확률 DP, 정수론 | G4 - G2 |
| E. 数 (Number) | Digit DP | P3 |
| F. 準急 (Semiexp) | 누적 합 | G2 - P5 |
| G. 辞書順 (Lexicographical) | 사전순 $K$번째 | P5 - P3 |
| H. ナップザック (Knapsack) | 배낭 문제, 토글링 | P4 - P2 |
| I. イウィ (Iwi) | 구간 DP | G1 - P4 |
| J. ボール (Ball) | 확률 DP, 비트 DP | P5 - P3 |
| K. ターゲット (Target) | LIS | P4 |
| L. 猫 (Cat) | 누적 합 | P5 - P3 |
| M. 家 (House) | TSP, 인접 행렬 제곱 | P4 - P2 |
| N. 木 (Tree) | 조합론 | P3 |
| O. 文字列 (String) | 조합론 | P1 - D4 |
| P. うなぎ (Eel) | 트리 DP, 검은돌 | D5 - D3 |
| Q. 連結 (Concat) | 비트 DP | P3 - P1 |
| R. グラフ (Graph) | SCC, 바이토닉 투어 | P1 |
| S. マス目 (Grid) | Connection Profile DP | D3 |
| T. フィボナッチ (Fibonacci) | 선형 점화식 | D4 |
제가 작성한 코드는 https://atcoder.jp/contests/tdpc/submissions?f.Status=AC&f.User=justiceHui 에서도 확인할 수 있습니다.
대회에 $N$개의 문제가 출제되었고, $i$번 문제의 점수는 $p_i$이다. 대회에서 나올 수 있는 서로 다른 점수의 수를 구하는 문제
$1 \le N \le 100$; $1 \le p_i \le 100$
$1, \cdots, i$번 문제만 고려했을 때 $j$점을 만들 수 있을 때 $D(i, j) = 1$ 이라고 합시다. 상태 전이는 $D(i, j) \leftarrow D(i-1, j-A_i)$ 와 같이 나타낼 수 있습니다. $D$ 배열을 일차원 배열로 만들 수도 있고, 구체적인 방법은 이 슬라이드의 29페이지 이후(동적 계획법 - 예시 7. BOJ 12865 평범한 배낭)을 참고하면 됩니다.
시간 복잡도는 $O(N\sum A_i)$입니다.
void Solve(){
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
S = accumulate(A+1, A+N+1, 0);
D[0] = 1;
for(int i=1; i<=N; i++) for(int j=S; j>=A[i]; j--) D[j] |= D[j-A[i]];
cout << accumulate(D, D+S+1, 0) << "\n";
}
bitset 을 이용해 연산량을 $1/64$배 수준으로 줄일 수도 있습니다.
void Solve(){
int N; cin >> N;
bitset<10101> D; D[0] = 1;
for(int i=1,t; i<=N; i++) cin >> t, D |= D << t;
cout << D.count() << "\n";
}
두 개의 카드 더미가 있다. 첫 번째 더미에 있는 카드는 위에서부터 각각 $a_1, a_2, \cdots, a_A$가, 두 번째 더미에 있는 카드는 위에서부터 $b_1, b_2, \cdots, b_B$가 적혀 있다. 두 명의 플레이어가 번갈아 가며 원하는 카드 더미의 맨 위에 있는 카드 한 장을 갖고 간다. 두 플레이어 모두 자신이 가져간 카드에 적힌 수의 합을 최대화하기 위해 최선을 다할 때, 선공이 가져간 수의 합을 구하는 문제
$1 \le A,B \le 1\,000$
지금까지 첫 번째 더미에서 $i$장, 두 번째 더미에서 $j$장 가져갔을 때, 앞으로 선공이 추가로 얻게 될 점수를 $D(i, j)$라고 합시다.
$i+j$ 가 짝수일 때는 선공 차례이므로 $D(i, j)$를 최대화, $i+j$ 가 홀수일 때는 후공 차례이므로 $D(i, j)$를 최소화해야 합니다. 따라서 $i+j$가 짝수면 $D(i, j) = \max\lbrace D(i+1, j) + A_{i+1}, D(i, j+1) + B_{j+1}\rbrace$, 홀수면 $D(i, j) = \min\lbrace D(i+1, j), D(i, j+1)\rbrace$로 계산하면 됩니다.
int f(int i, int j){
if(i == N && j == M) return 0;
int &res = D[i][j];
if(res != -1) return res;
if((i + j) % 2 == 0){ // maximize
res = 0;
if(i < N) res = max(res, f(i+1, j) + A[i+1]);
if(j < M) res = max(res, f(i, j+1) + B[j+1]);
}
else{ // minimize
res = 1e9;
if(i < N) res = min(res, f(i+1, j));
if(j < M) res = min(res, f(i, j+1));
}
return res;
}
void Solve(){
cin >> N >> M;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=M; i++) cin >> B[i];
memset(D, -1, sizeof D);
cout << f(0, 0) << "\n";
}
$2^K$명이 토너먼트에 참가한다. $i$번 사람의 레이팅은 $R_i$이고, 레이팅이 $R_p$인 사람과 $R_q$인 사람이 대결할 때 $p$가 승리할 확률은 $1/(1+10^{(R_q-R_p)/400})$ 이다. $1 \le i \le 2^K$에 대해 $i$번 사람이 승리할 확률을 각각 구하는 문제
$1 \le K \le 10$
세그먼트 트리의 구조를 생각하면, 토너먼트의 과정은 정점이 $2^{K+1}-1$개인 이진 트리로 표현할 수 있습니다. 트리의 $x$번 정점에서의 승자가 $p$일 확률을 $D(x, p)$라고 정의합시다.
어떤 두 사람 $a, b$가 대결할 때 $l$이 승리할 확률을 $f(a, b)$라고 하면, $x$의 왼쪽 구간에 속한 사람 $l$과 오른쪽 구간에 속한 사람 $r$이 각각 올라왔을 때 아래와 같이 상태 전이를 할 수 있습니다.
크기가 $2^t$인 구간의 답을 계산하는 데 $O(2^{t/2} \times 2^{t/2}) = O(2^t)$ 만큼의 시간이 걸리고, 크기가 $2^t$인 구간은 $2^K / 2^t = 2^{K-t}$개 존재합니다. 따라서 전체 시간 복잡도는 $O(K\times 2^K)$입니다.
double f(double rp, double rq){
return 1 / (1 + pow(10, (rq-rp)/400));
}
void Go(int node, int s, int e){
if(s == e){ D[node][s] = 1; return; }
int m = (s + e) / 2;
Go(node*2, s, m);
Go(node*2+1, m+1, e);
for(int l=s; l<=m; l++){
for(int r=m+1; r<=e; r++){
double p = f(A[l], A[r]);
D[node][l] += D[node*2][l] * D[node*2+1][r] * p;
D[node][r] += D[node*2][l] * D[node*2+1][r] * (1 - p);
}
}
}
void Solve(){
cin >> K; N = 1 << K;
for(int i=0; i<N; i++) cin >> A[i];
Go(1, 0, N-1);
for(int i=0; i<N; i++) cout << fixed << setprecision(20) << D[1][i] << "\n";
}
1부터 6까지의 수가 적힌 정육면체 주사위를 $N$번 던졌을 때, 나온 수들의 곱이 $D$의 배수가 될 확률을 구하는 문제
$1 \le N \le 100$; $1 \le D \le 10^{18}$
주사위는 1부터 6까지의 수만 만들 수 있으므로, $D$가 7 이상의 수를 소인수로 갖는다면 답은 0입니다. 따라서 2, 3, 5만으로 구성된 수만 고려해도 충분합니다.
주사위를 $i$번 던져서 2, 3, 5를 각각 $a, b, c$번 만들 확률을 $E(i,a,b,c)$라고 정의합시다. $D \le 10^{18}$ 이므로 $a \le 60, b\le 40, c \le 30$ 정도 범위만 고려해도 괜찮으며, 따라서 제한 시간 안에 답을 구할 수 있습니다. 전체 시간 복잡도는 $O(N \times \log_2 D \times \log_3 D \times \log_5 D) \approx O(\frac{N\log_2^3 D}{3.68})$ 입니다.
void Solve(){
cin >> N >> K;
D[0][0][0][0] = 1;
for(int i=0; i<N; i++){
for(int x=0; x<60; x++){
for(int y=0; y<40; y++){
for(int z=0; z<30; z++){
const auto now = D[i][x][y][z];
if(now == 0) continue;
for(int d=1; d<=6; d++){
int xx = min(x + (d % 2 == 0) + (d % 4 == 0), 59);
int yy = min(y + (d % 3 == 0), 39);
int zz = min(z + (d % 5 == 0), 29);
D[i+1][xx][yy][zz] += now / 6;
}
}
}
}
}
int x = 0, y = 0, z = 0;
while(K % 2 == 0) K /= 2, x++;
while(K % 3 == 0) K /= 3, y++;
while(K % 5 == 0) K /= 5, z++;
if(K != 1){ cout << "0\n"; return; }
double res = 0;
for(int i=x; i<60; i++) for(int j=y; j<40; j++) for(int k=z; k<30; k++) res += D[N][i][j][k];
cout << fixed << setprecision(20) << res << "\n";
}
$N$ 이하의 양의 정수 중, 10진법으로 표기했을 때 각 자릿수의 합이 $D$의 배수인 수의 개수를 구하는 문제
$N \le 10^{10000}$; $1 \le D \le 100$
“$N$ 이하” 라는 조건이 없다면 길이가 $i$이고, 각 자릿수의 합을 $D$로 나눈 나머지가 $j$인 수의 개수 $A(i, j)$를 세면 되겠지만, 귀찮은 조건이 하나 붙어있습니다.
이런 문제는 leading zero를 허용한 채로 항상 길이가 $N$과 동일한 문자열을 만든다고 생각하는 것이 편합니다. 앞에서부터 한 자리씩 결정하면서, 지금까지 $N$을 그대로 따라가고 있는지, 아니면 이미 $N$보다 작아졌는지를 관리하는 변수 $mx$를 포함해서 점화식을 $A(i, j, mx)$와 같이 세우면 됩니다.
이런 유형을 digit dp 라고 부릅니다. 구체적인 구현 방식은 아래 코드를 참고하세요.
int N, K; string S;
ll D[10101][111][2]; // len, sum, mx
void Solve(){
cin >> K >> S; N = S.size();
D[0][0][1] = 1;
for(int len=0; len<N; len++){
for(int sum=0; sum<K; sum++){
for(int mx=0; mx<2; mx++){
const ll now = D[len][sum][mx];
if(!now) continue;
for(int d=0; d<10; d++){
if(mx && d > S[len]-'0') break;
Add(D[len+1][(sum+d)%K][mx&&d==S[len]-'0'], now);
}
}
}
}
cout << (D[N][0][0] + D[N][0][1] - 1 + MOD) % MOD << "\n";
}
$N$개의 역이 일렬로 배치되어 있다. 급행 열차는 1번 역에 정차한 뒤, $\lbrace2, 3, \cdots, N-1\rbrace$번 역의 일부에 정차하고, 마지막에 $N$번 역에 정차한다. 연속한 $K$개 이상의 역에 모두 정차하면 안 된다고 할 때, 급행 열차가 정차하는 역의 조합으로 가능한 경우의 수를 구하는 문제
$2 \le K \le N \le 1\,000\,000$
$N-2$개의 역에 대해 각각 정차 여부를 결정하는 문제입니다. 편의상 $2, 3, \cdots, N-1$번 역을 $1, 2\cdots,N-2$ 로 표현합시다. 정차할 역보다는 정차하지 않는 역 기준으로 생각하는 것이 더 편합니다.
조건을 만족하도록 $1, 2, \cdots, i$번 역의 정차 여부를 정했고, $i$번 역에 정차하지 않는 방법의 수를 $D(i)$라고 정의합시다. $i$번 역 직전에 통과한 역이 $j$라면, 그 사이에 있는 $i-j-1$개의 역에 정차를 해야 하고, 이 값은 $K$ 미만이어야 합니다. $i-K \le j < i$ 가 성립해야 하므로, $D(i) = \sum_{j=i-K}^{i-1} D(j)$와 같이 점화식을 세울 수 있습니다.
또한, $i < K$ 이면 $i$번 역이 처음으로 정차하지 않는 역이 될 수 있습니다. 따라서 이 경우 $D(i)$를 1 증가시켜야 합니다.
누적 합 배열을 이용하면 각 상태의 답을 상수 시간에 계산할 수 있으므로 $O(N)$ 시간에 $D$배열을 모두 계산할 수 있으며, 문제의 답은 $\sum_{i=N-K}^{N-2} D(i)$입니다.
ll N, K, D[1010101], S[1010101];
ll f(int l, int r){
if(l > r || r < 0) return 0;
return (S[r] - (l > 0 ? S[l-1] : 0) + MOD) % MOD;
}
void Solve(){
cin >> N >> K;
if(N <= 2){ cout << "1\n"; return; }
for(int i=1; i<=N-2; i++){
D[i] = f(i-K, i-1);
if(i < K) D[i] = (D[i] + 1) % MOD;
S[i] = (S[i-1] + D[i]) % MOD;
}
cout << f(N-K, N-2) << "\n";
}
길이가 $N$인 문자열 $S$와 양의 정수 $K$가 주어진다. 중복을 제외하고 $S$의 subsequence 중 사전순 $K$번째인 것을 구하는 문제
$1 \le N \le 10^6$; $1 \le K \le 10^{18}$
일반적으로 서로 다른 부분 수열의 개수를 세는 문제에서는, 다음 문자를 고를 때 최대한 앞에 있는 위치를 선택해서 일종의 canonical form만 고려하는 것이 편합니다. $F(i,c)$를 $i$보다 뒤에서 $c$가 등장하는 첫 위치라고 정의합시다. $F$ 배열은 뒤에서부터 계산하면 $O(26N)$ 시간에 계산할 수 있습니다.
$D(i)$를 $i$ 또는 그 이후에 있는 문자만 사용해서 만들 수 있는 서로 다른 부분 수열의 개수라고 정의합니다. 주어진 문자열 $S$에서 만들 수 있는 서로 다른 부분 수열의 개수는 $\sum_{c=0}^{25} D(f(0,c))$ 이고, 이 값이 $K$보다 작다면 Eel을 출력하면 됩니다. 뒤에서부터 계산하면 $D(\ast)$도 $O(26N)$ 시간에 계산할 수 있습니다.
$K$번째 부분 수열이 존재한다면, 이를 구하는 것은 어렵지 않습니다. 마지막으로 사용한 문자의 위치를 $x$라고 합시다. $x$의 초깃값은 0입니다.
$c$를 0부터 차례대로 보면서, $K > D(F(x,c))$ 라면 $K$번째 부분 수열은 뒤에 $c$보다 큰 문자를 붙여야 한다는 것을 알 수 있습니다. 따라서 $K$에서 $D(F(x,c))$를 뺀 뒤에 다른 $c$를 확인하면 됩니다.
$K \le D(F(x,c))$ 라면 뒤에 $c$가 붙은 부분 수열 중 우리가 원하는 부분 수열이 있다는 것을 의미합니다. 따라서 $x$를 $F(x,c)$로 이동시킨 뒤 같은 과정을 계속 반복하면 됩니다. 부분 수열의 마지막 문자가 $c$인 경우도 고려해야 하므로 이 경우에도 $K$를 1 감소시켜야 하고, $K = 0$이 되는 순간에 종료하면 됩니다.
$F$와 $D$를 각각 $O(26N)$에 구할 수 있고, $K$번째 부분 수열을 복원하는 것도 $O(26N)$ 시간에 할 수 있습니다. 따라서 전체 시간 복잡도는 $O(26N)$입니다.
void Solve(){
cin >> S >> K; N = S.size();
for(int i=1; i<=N; i++) A[i] = S[i-1] - 'a';
memset(L, -1, sizeof L);
for(int i=N; i>=1; i--) memcpy(F[i], L, sizeof F[i]), L[A[i]] = i;
memcpy(F[0], L, sizeof F[0]);
D[N+1] = 1;
for(int i=N; i>=1; i--){
D[i] = 1;
for(int j=0; j<26; j++) if(F[i][j] != -1) D[i] = min(D[i] + D[F[i][j]], K + 1);
}
ll total = 0;
for(int i=0; i<26; i++) if(F[0][i] != -1) total = min(total + D[F[0][i]], K + 1);
if(total < K){ cout << "Eel\n"; return; }
int x = 0;
while(K > 0){
for(int i=0; i<26; i++){
if(F[x][i] == -1) continue;
if(K <= D[F[x][i]]){ cout << char('a'+i); x = F[x][i]; K--; break; }
else K -= D[F[x][i]];
}
}
cout << "\n";
}
$N$개의 물건이 있다. $i$번째 물건의 무게는 $w_i$, 가치는 $v_i$, 색깔은 $c_i$이다. 최대 $C$가지 종류의 물건만 선택해 무게의 합을 $W$ 이하로 만들었을 때, 가능한 가치 합의 최댓값을 구하는 문제
$1 \le N \le 100$; $1 \le W, w_i, v_i \le 10^4$; $1 \le C, c_i \le 50$
사용한 색깔의 개수를 관리하기 위해 점화식에 $2^C$짜리 비트 집합을 넣기에는 $C$가 너무 큽니다. 물건들을 색깔 오름차순으로 정렬해 색이 같은 물건끼리 인접한 위치에 오도록 만든 다음, “$i$번 물건과 같은 색을 사용한 적 있는가?” 와 같은 변수를 추가하는 방법을 생각해 볼 수 있습니다.
$D(n,w,c,u)$를 $1, \cdots, n$번 물건만 고려했을 때, 무게의 합이 $w$, 사용한 색깔의 개수가 $c$이면서 $n$번 물건과 같은 색의 물건을 사용한 적 있는지의 여부가 $u$일 때 가능한 가치 합의 최댓값이라고 정의합시다. $c_{i-1}$과 $c_{i}$가 같은지 다른지에 따라 점화식이 조금 다릅니다.
전체 시간 복잡도는 $O(NWC)$입니다.
struct Info{ int w, v, c; };
void Max(int &a, int b){ a = max(a, b); }
int N, W, C, D[2][10101][55][2];
Info A[111];
void Solve(){
cin >> N >> W >> C;
for(int i=1; i<=N; i++) cin >> A[i].w >> A[i].v >> A[i].c;
sort(A+1, A+N+1, [](auto a, auto b){ return a.c < b.c; });
memset(D, 0xc0, sizeof D);
D[0][0][0][0] = 0;
for(int i=1; i<=N; i++){
memset(D[i&1], 0xc0, sizeof D[i&1]);
for(int w=0; w<=W; w++){
for(int c=0; c<=C; c++){
if(A[i-1].c == A[i].c){
Max(D[i&1][w][c][0], D[i-1&1][w][c][0]);
Max(D[i&1][w][c][1], D[i-1&1][w][c][1]);
if(w+A[i].w <= W && c+1 <= C) Max(D[i&1][w+A[i].w][c+1][1], D[i-1&1][w][c][0] + A[i].v);
if(w+A[i].w <= W) Max(D[i&1][w+A[i].w][c][1], D[i-1&1][w][c][1] + A[i].v);
}
else{
Max(D[i&1][w][c][0], D[i-1&1][w][c][0]);
Max(D[i&1][w][c][0], D[i-1&1][w][c][1]);
if(w+A[i].w <= W && c+1 <= C) Max(D[i&1][w+A[i].w][c+1][1], D[i-1&1][w][c][0] + A[i].v);
if(w+A[i].w <= W && c+1 <= C) Max(D[i&1][w+A[i].w][c+1][1], D[i-1&1][w][c][1] + A[i].v);
}
}
}
}
int res = 0;
for(int w=0; w<=W; w++) for(int c=0; c<=C; c++) for(int u=0; u<2; u++) Max(res, D[N&1][w][c][u]);
cout << res << "\n";
}
‘i’와 ‘w’로만 구성된 문자열 $s$가 있에서 부분 문자열 ‘iwi’ 를 제거하는 연산을 할 수 있다. 연산을 수행할 수 있는 최대 횟수를 구하는 문제
$1 \le \vert s \vert \le 300$
구간 $[i,j]$에서 수행할 수 있는 연산의 최대 횟수를 $D(i,j)$라고 정의합시다.
구간의 양 끝과 가운데에 있는 적당한 문자 $S_k$를 제거하기 위해서는, $S_i$와 $S_j$는 i, $S_k$는 w 여야 하고, 구간 $[i+1,k-1]$과 $[k+1,j-1]$을 모두 지울 수 있어야 합니다. 즉, $3\times D(i+1,k-1) = k-i-1$과 $3\times D(k+1,j-1) = j-k-1$이 성립해야 합니다. 이 경우 $D(i,j) \leftarrow D(i+1,k-1) + D(k+1,j-1) + 1$ 과 같은 상태 전이를 할 수 있습니다.
그렇지 않은 경우, 모든 연산은 적당한 $i \le k < j$에 대해 $[i,k]$에 속한 연산과 $[k+1,j]$에 속한 연산으로 나눠서 생각할 수 있습니다. 따라서 $D(i,j) \leftarrow D(i,k) + D(k+1,j)$ 와 같은 상태 전이를 할 수 있습니다.
$j-i$가 증가하는 순서대로 점화식을 계산하면 $O(N^3)$ 시간에 문제를 해결할 수 있습니다.
bool Check(int a, int b, int c){
if(S[a] != 'i' || S[b] != 'w' || S[c] != 'i') return false;
if(D[a+1][b-1] * 3 != b-a-1 || D[b+1][c-1] * 3 != c-b-1) return false;
return true;
}
void Solve(){
cin >> S; N = S.size();
for(int d=0; d<N; d++){
for(int i=0, j=i+d; j<N; i++, j++){
for(int k=i; k<=j; k++){
D[i][j] = max(D[i][j], D[i][k] + D[k+1][j]);
if(Check(i, k, j)) D[i][j] = max(D[i][j], D[i+1][k-1] + D[k+1][j-1] + 1);
}
}
}
cout << D[0][N-1] << "\n";
}
수직선 위에 $N$개의 물건이 있다. $i$번째 물건의 위치는 $x_i$이다. $x$를 겨냥해 공을 던지면 $x-1,x,x+1$ 중 한 곳에 각각 1/3의 확률로 날아가서, 해당 위치에 있는 물건을 쓰러뜨린다. 모든 물건을 쓰러뜨리기 위해 공은 던져야 하는 횟수의 기댓값을 최소화하는 문제
$1 \le N \le 16$; $0 \le x_i \le 15$; $x_i$는 모두 서로 다름
현재 물건이 있는 위치를 비트로 나타낸 뒤, 물건이 $bit$에 있을 때 모든 물건을 쓰러뜨리기 위해 공을 던져야 하는 횟수의 기댓값을 $D(bit)$ 라고 정의합시다. $D(bit)$를 계산할 때 $D(bit)$이 필요한 경우가 있어서 조심해야 합니다.
위치 $p$를 겨냥해서 공을 던지는 상황을 생각해 봅시다. 공은 $p-1$, $p$, $p+1$ 중 한 곳으로 날아가게 되는데, 이중 실제로 물건이 있는 위치의 수에 따라 식이 어떻게 되는지 관찰해 봅시다.
다행히 일차 방정식을 풀면 일반적인 점화식 꼴이 나오는 것을 확인할 수 있습니다.
위 식을 그대로 코드로 옮기면 좌표의 범위를 $X$라고 할 때 $O(X\times 2^X)$ 시간에 문제를 해결할 수 있습니다.
double f(int bit){
if(bit == 0) return 0;
double &res = D[bit];
if(res >= 0) return res;
res = 1e9;
for(int p=-1; p<=16; p++){
double sum = 0; int hit = 0;
for(int i=p-1; i<=p+1; i++){
if(0 <= i && i < 16 && (bit >> i & 1)) sum += f(bit ^ (1<<i)), hit++;
}
res = min(res, (sum + 3) / hit);
}
return res;
}
void Solve(){
cin >> N;
for(int i=0,t; i<N; i++) cin >> t, S |= 1 << t;
fill(D, D+(1<<16), -1);
cout << fixed << setprecision(20) << f(S) << "\n";
}
$K$개의 원 $C_1, C_2, \cdots, C_K$가 있을 때, 모든 $i$에 대해 $C_i$가 $C_{i+1}$에 strictly 포함되어 있다면 이 원들의 나열을 크기가 $K$인 과녁이라고 부른다.
$N$개의 원이 있다. $i$번째 원의 중심은 $(x_i,0)$이고 반지름은 $r_i$이다. 만들 수 있는 과녁의 최대 크기를 구하는 문제
$1 \le N \le 10^5$; $0 \le x_i \le 10^8$; $1 \le r_i \le 10^8$
중심의 $y$좌표가 모두 $0$이므로 원 대신 수직선 상의 구간 $[x_i-r_i, x_i+r_i]$ 라고 바꿔서 생각하면, 시작점이 감소하고 끝점이 증가하도록 몇 개의 구간을 선택하는 문제가 됩니다.
모든 구간을 시작점 내림차순, 시작점이 같다면 끝점 내림차순으로 정렬한 뒤, 끝점 좌표를 이용해 LIS를 구하면 됩니다.
void Solve(){
int N; cin >> N;
vector<pair<int,int>> A; A.reserve(N);
for(int i=0; i<N; i++){
int x, r; cin >> x >> r;
A.emplace_back(x-r, x+r);
}
sort(A.begin(), A.end(), greater<>());
vector<int> L;
for(auto [_,i] : A){
if(L.empty() || L.back() < i) L.push_back(i);
else *lower_bound(L.begin(), L.end(), i) = i;
}
cout << L.size() << "\n";
}
수직선 위에 $N$마리의 고양이가 있다. 두 고양이 $i, j$의 친밀도는 $f_{i,j}$이고, 어떤 고양이의 행복도는 거리가 1 이하에 있는 고양이들과의 친밀도의 합이다.
고양이 $N$마리의 위치를 수직선 위에 오름차순으로($x_1 < x_2 < \cdots < x_N$) 배치하려고 한다. 좌표는 정수가 아니어도 된다. 모든 고양이의 행복도의 총합을 구하는 문제
$1 \le N \le 1000$; $-1000 \le f_{i,j} \le 1000$; $f_{i,i} = 0$; $f_{i,j} = f_{j,i}$
편의상 거리가 1 이하인 두 고양이 쌍의 $f_{i,j}$값의 합을 구한 뒤 마지막에 2배해서 출력할 것입니다.
$i$번째 고양이와 거리가 1 이하인 가장 왼쪽 고양이의 번호를 $L_i$라고 합시다. 고양이의 좌표는 증가해야 하므로 $L_{i-1} \le L_i$가 성립해야 하며, $L_1 \le L_2 \le \cdots \le L_N$이 되도록 고양이를 배치할 수 있습니다. 이를 이용해 점화식을 세울 수 있습니다.
$1, 2, \cdots, i$번째 고양이만 고려했을 때, $L_i = j$ 일 때 가능한 최댓값을 $D(i,j)$라고 정의합시다. $L_{i-1} \le L_i = j$가 성립해야 하므로 $D(i,j) = \min_{1\le k\le j} D(i-1,k) + \sum_{t=j}^{i-1} f_{i,t}$로 계산할 수 있습니다.
naive 하게 계산하면 시간 복잡도가 $O(N^3)$이 되지만, $D(i-1, \ast)$의 prefix min과 $f_{i,\ast}$의 prefix sum을 이용하면 $O(N^2)$에 계산할 수 있습니다.
void Solve(){
cin >> N;
for(int i=1; i<=N; i++) for(int j=1; j<=N; j++) cin >> F[i][j];
for(int i=1; i<=N; i++) partial_sum(F[i]+1, F[i]+N+1, F[i]+1);
memset(D, 0xc0, sizeof D); D[1][1] = 0;
for(int i=2; i<=N; i++){
int mx = D[i-1][1];
for(int j=1; j<=i; j++){
mx = max(mx, D[i-1][j]);
D[i][j] = mx + F[i][i] - F[i][j-1];
}
}
cout << *max_element(D[N]+1, D[N]+N+1) * 2 << "\n";
}
$H$개의 층이 있고, 모든 층의 구조가 동일한 건물이 있다. 각 층에는 $R$개의 방이 있고, 두 방을 양방향으로 연결하는 통로가 몇 개 있다. 또한, 임의의 정수 $h,r$에 대해, $h$층의 $r$번 방에서 $h-1$층의 $r$번 방으로 내려갈 수 있다. $H$층의 $1$번 방에서 출발해 같은 방을 두 번 이상 방문하지 않고 $1$층의 $1$번 방으로 이동하는 경로의 수를 세는 문제
$2 \le H \le 10^9$; $1 \le R \le 16$
우선 한 층 안에서만 이동해서 $i$번 방에서 $j$번 방으로 가는 경로의 수 $M_{i,j}$를 계산합시다. 시작점 $i$가 고정되어 있을 때 TSP와 같은 비트 DP를 이용해 $O(R^2\times 2^R)$ 시간에 계산할 수 있습니다. 이를 $R$번 수행해야 하므로 $M_{i,j}$를 모두 계산하는 데 $O(R^3\times 2^R)$ 시간이 걸립니다.
정점이 $R$개 있고, $i$에서 $j$로 가는 간선이 $M_{i,j}$개 있는 새로운 그래프를 생각해 봅시다. 간선을 타고 한 번 이동한 다음 한 층 밑으로 내려간다고 생각하면, 이 문제는 새로운 그래프의 1번 정점에서 출발해서 간선을 따라 총 $H$번 이동해서 다시 1번 정점으로 돌아오는 walk의 개수를 세는 문제가 됩니다.
간선을 $i$번 타고 이동해서 $u$번 정점에서 $v$번 정점으로 가는 경우의 수를 $D(i,u,v)$라고 정의하면, $D(1,u,v) = M_{u,v}$이고, $i > 1$ 이면 $D(i,u,v) = \sum D(i-1,u,x)\times M_{x,v}$ 입니다. $O(HR^3)$ 시간에 답을 구할 수 있지만 $H \le 10^9$라서 제한 시간 안에 문제를 해결할 수 없습니다.
하지만 점화식을 다시 잘 살펴보면, 행렬 곱셈과 똑같다는 것을 알 수 있습니다. 즉, $D(i,u,v) = (M^i)_{u,v}$입니다. 따라서 점화식을 계산하는 대신, 단순히 $M^H$를 $O(R^3 \log H)$ 시간에 구하는 것으로 문제를 해결할 수 있습니다.
행렬 $M$을 구하는 데 $O(R^32^R)$, 이후 행렬의 거듭 제곱을 구하는 데 $O(R^3 \log H)$ 만큼의 연산이 필요하므로, 전체 시간 복잡도는 $O(R^32^2 + R^3 \log H)$ 입니다.
vector<vector<ll>> Mul(const vector<vector<ll>> &a, const vector<vector<ll>> &b){
int n = a.size(), m = b.size(), k = b[0].size();
vector<vector<ll>> res(n, vector<ll>(k));
for(int i=0; i<n; i++) for(int t=0; t<m; t++) for(int j=0; j<k; j++) res[i][j] = (res[i][j] + a[i][t] * b[t][j]) % MOD;
return res;
}
vector<vector<ll>> Pow(vector<vector<ll>> a, ll b){
int n = a.size();
vector<vector<ll>> res(n, vector<ll>(n));
for(int i=0; i<n; i++) res[i][i] = 1;
for(; b; b>>=1, a=Mul(a,a)) if(b & 1) res = Mul(res, a);
return res;
}
ll K, N, D[1<<16][16];
vector<int> G[16];
vector<ll> Get(int st){
memset(D, 0, sizeof D);
D[1<<st][st] = 1;
for(int bit=0; bit<(1<<N); bit++){
for(int i=0; i<N; i++){
if(D[bit][i] == 0) continue;
for(auto j : G[i]) if(~bit >> j & 1) Add(D[bit|1<<j][j], D[bit][i]);
}
}
vector<ll> res(N);
for(int bit=0; bit<(1<<N); bit++) for(int i=0; i<N; i++) Add(res[i], D[bit][i]);
return res;
}
void Solve(){
cin >> K >> N;
for(int i=0; i<N; i++){
for(int j=0; j<N; j++){
int t; cin >> t;
if(t) G[i].push_back(j);
}
}
vector<vector<ll>> M(N);
for(int i=0; i<N; i++) M[i] = Get(i);
cout << Pow(M, K)[0][0] << "\n";
}
$N$개의 정점이 있고, $N-1$개의 간선을 그려 트리를 만들려고 한다. 간선을 그리는 도중에, 지금까지 그린 간선들이 항상 연결되어 있도록 유지하려고 할 때, 간선을 그리는 순서로 가능한 경우의 수를 구하는 문제
$2 \le N \le 1000$; 간선은 입력으로 주어짐
모든 간선이 연결되도록 간선을 그리는 것은, 첫 번째로 그릴 간선을 고정하면 트리의 위상 정렬 개수를 구하는 것과 비슷하게 할 수 있습니다. 구체적으로, 처음에 그릴 간선을 녹여서 두 정점을 하나의 정점으로 합치고 나면, 남은 간선을 그리는 순서를 정하는 방법의 수는 rooted tree의 위상 정렬 개수와 같습니다. 따라서 rooted tree의 위상 정렬 개수를 $O(N)$에 셀 수 있다면 전체 문제를 $O(N^2)$ 시간에 해결할 수 있습니다.
$v$의 자식 정점 $c_1, c_2, \cdots, c_k$에 대해, 각 자식을 루트로 하는 서브트리의 크기를 $s_i$, 그 서브트리의 위상 정렬 개수를 $d_i$라고 합시다. $v$를 루트로 하는 서브트리의 위상 정렬의 순서를 구한다고 하면, $v$는 맨 앞에 와야 하니까 논외로 하고 남은 $\sum c_i$개의 순서를 정해야 합니다.
이는 색깔이 $i$인 공이 $c_i$개 있는 상황에서 모든 공을 순서대로 나열한 다음, 앞에 있는 공부터 차례대로 보면서 공의 색깔에 해당하는 서브트리의 정점을 하나씩 가져가는 것이라고 생각할 수 있습니다. 공을 나열하는 방법의 수는 $\frac{(\sum c_i)!}{\prod s_i!}$ 이므로, $v$를 루트로 하는 서브트리의 위상 정렬 개수는 $\prod d_i \times \frac{(\sum c_i)!}{\prod s_i!} $로 계산할 수 있습니다.
$1!, 2!, \cdots, N!$ 의 모듈러 곱셈 역원을 미리 전처리하면 트리 하나의 위상 정렬 개수를 $O(N)$ 시간에 구할 수 있으므로, 전체 문제를 $O(N^2)$ 시간에 해결할 수 있습니다.
pair<ll,ll> DFS(int v, int b=-1){
ll dp = 1, sz = 1;
for(auto i : G[v]){
if(i == b) continue;
auto [d,s] = DFS(i, v);
dp = dp * d % MOD;
dp = dp * Inv[s] % MOD;
sz += s;
}
dp = dp * Fac[sz-1] % MOD;
return {dp, sz};
}
void Solve(){
cin >> N;
for(int i=1,u,v; i<N; i++) cin >> u >> v, G[u].push_back(v), G[v].push_back(u);
Fac[0] = 1;
for(int i=1; i<=N; i++) Fac[i] = Fac[i-1] * i % MOD;
Inv[N] = Pow(Fac[N], MOD-2);
for(int i=N-1; i>=0; i--) Inv[i] = Inv[i+1] * (i+1) % MOD;
ll res = 0;
for(int i=1; i<=N; i++){
for(auto j : G[i]){
if(i > j) continue;
auto [dp1,sz1] = DFS(i, j);
auto [dp2,sz2] = DFS(j, i);
ll now = Fac[N-2] * Inv[sz1-1] % MOD * Inv[sz2-1] % MOD;
now = now * dp1 % MOD * dp2 % MOD;
res = (res + now) % MOD;
}
}
cout << res << "\n";
}
‘a’ 를 $freq_1$개, ‘b’를 $freq_2$개, … , ‘z’를 $freq_{26}$개 사용하면서, 인접한 문자가 서로 다른 문자열의 개수를 구하는 문제
$0 \le freq_i \le 10$
편의상 a, b, c, … , z 대신 $0, 1, 2, \cdots, 25$를 사용합니다. $freq$ 배열을 0-based로 만들면 $i$를 $freq_{i}$개 사용한다고 생각할 수 있습니다.
$0,1,\cdots,i$를 모두 사용해서 문자열을 만들 때, 인접한 두 문자가 동일한 쌍(bad pair)가 $j$인 경우의 수를 $D(i, j)$라고 정의합시다. 또한, $S_i = freq_0 + freq_1 + \cdots freq_i$라고 정의합시다. $D(i, j)$ 상황에서 $i+1$번 문자를 삽입하는 상황을 생각해 보면, 이미 길이가 $S_i$인 문자열이 있으므로 문자를 삽입할 수 있는 위치가 $S_i+1$개 있고, 이중 $j$개의 위치가 bad pair 인 상황입니다.
$freq_{i+1}$개의 문자를 $k$개의 블록으로 분할한 뒤, 그중 $x$개는 bad pair에, 나머지 $k-x$개는 good pair에 배치합시다. $k$개의 블록으로 분할하는 방법의 수는 $freq_{i+1}-1\choose k-1$, $x$개의 블록을 bad pair에 배치하는 방법의 수는 $j\choose x$, $k-x$개의 블록을 good pair에 배치하는 방법의 수는 $S_i+1-j\choose k-x$입니다. 이 경우 bad pair는 $x$개 감소한 뒤에 $freq_{i+1}-k$개 증가하므로, 상태 전이는 다음과 같이 나타낼 수 있습니다.
\[\displaystyle D(i+1,j-x+freq_{i+1}-k) \leftarrow D(i,j)\times {freq_{i+1}-1 \choose k-1} \times {j\choose x} \times {S_i+1-j\choose k-x}\]가능한 모든 $1 \le k \le freq_{i+1}$, $0 \le x \le \min(j,k)$에 대해 위 식을 이용해 점화식을 계산하면 답을 구할 수 있습니다.
void Solve(){
Comb[0][0] = 1;
for(int n=1; n<333; n++){
Comb[n][0] = 1;
for(int k=1; k<=n; k++) Comb[n][k] = (Comb[n-1][k-1] + Comb[n-1][k]) % MOD;
}
for(int i=0; i<26; i++){
int t; cin >> t;
if(t) C.push_back(t);
}
partial_sum(C.begin(), C.end(), back_inserter(S));
D[0][C[0]-1] = 1;
for(int i=0; i+1<C.size(); i++){
for(int j=0; j<=S[i]; j++){
const ll now = D[i][j];
if(!now) continue;
for(int k=1; k<=C[i+1]; k++){
for(int x=0; x<=min(j,k); x++){
ll split = Comb[C[i+1]-1][k-1];
ll bad = Comb[j][x];
ll good = Comb[S[i]+1-j][k-x];
ll way = split * bad % MOD * good % MOD;
ll j_nxt = j - x + C[i+1] - k;
D[i+1][j_nxt] = (D[i+1][j_nxt] + now * way) % MOD;
}
}
}
}
cout << D[C.size()-1][0] << "\n";
}
$N$개의 정점으로 구성된 트리가 주어지면, 길이가 0이 아닌 vertex disjoint path를 $K$개 선택하는 방법의 수를 구하는 문제
$2 \le N \le 1\,000$; $1 \le K \le 50$
트리 DP를 합시다. $v$를 루트로 하는 서브트리에서 만들어지는 경로는 세 가지로 분류할 수 있습니다.
이중 (3)은 $v$의 부모 정점과 연결될 수도 있습니다. 따라서 점화식을 두 가지로 나눠서 생각하는 것이 좋아 보입니다.
$v$가 리프일 때는 close 이면서 길이가 0이 아닌 경로를 만들 수 없으므로 $Close(v,0) = 1$ 이고 나머지는 $0$이며, open 인 경로를 하나 만들 수 있으므로 $Open(v,1) = 1$입니다.
$v$가 리프가 아니면 자식 정점의 dp값을 합쳐야 합니다. 자식 정점들을 합칠 때, $v$와 자식을 연결하는 간선을 $i$ ($0\le i\le 2$)개 사용하면서 경로를 $x$개 만드는 경우의 수를 $sub(i,x)$라고 정의합시다. $v$의 자식 $c$를 추가하면 $sub$ 배열은 아래와 같이 변합니다.
모든 자식을 합친 후에 아래와 같이 $Close(v,\ast)$와 $Open(v,\ast)$를 계산할 수 있습니다.
$Close(v,k) \leftarrow sub(2,k+1)$은 $v$로 향하는 두 경로를 합쳐서 하나의 close 경로(2)를 만드는 것, $Open(v,k) \leftarrow sub(0,k-1)$는 $v$로 시작해 올라가는 새로운 경로(3)를 만드는 것을 나타냅니다.
두 서브트리를 합칠 때 $O(K^2)$ 만큼의 연산이 필요하므로 전체 시간 복잡도는 $O(NK^2)$입니다. 두 서브트리를 합칠 때 $\min(\vert T_1\vert, K) \times \min(\vert T_2\vert, K)$ 만큼의 연산만 하도록 구현하면 $O(NK)$가 되지만, 이 문제에서는 굳이 그렇게 구현할 필요는 없습니다.
int N, K;
ll Close[1010][55], Open[1010][55];
ll sub[3][55], prv[3][55];
vector<int> G[1010];
void Apply(int v){
memcpy(prv, sub, sizeof prv);
memset(sub, 0, sizeof sub);
for(int i=0; i<3; i++) for(int x=0; x<=K+1; x++) for(int y=0; x+y<=K+1; y++) Add(sub[i][x+y], prv[i][x] * Close[v][y] % MOD);
for(int i=0; i<2; i++) for(int x=0; x<=K+1; x++) for(int y=0; x+y<=K+1; y++) Add(sub[i+1][x+y], prv[i][x] * Open[v][y] % MOD);
}
void DFS(int v, int b=-1){
bool leaf = true;
for(auto i : G[v]) if(i != b) DFS(i, v), leaf = false;
if(leaf){ Close[v][0] = Open[v][1] = 1; return; }
memset(sub, 0, sizeof sub); sub[0][0] = 1;
for(auto i : G[v]) if(i != b) leaf = false, Apply(i);
for(int i=0; i<=K; i++){
Close[v][i] = (sub[0][i] + sub[1][i] + sub[2][i+1]) % MOD;
Open[v][i] = ((i?sub[0][i-1]:0) + sub[1][i]) % MOD;
}
for(int i=0; i<=K; i++) Close[v][i] %= MOD, Open[v][i] %= MOD;
}
void Solve(){
cin >> N >> K;
for(int i=1; i<N; i++){
int u, v; cin >> u >> v;
G[u].push_back(v); G[v].push_back(u);
}
DFS(1);
cout << Close[1][K] << "\n";
}
$N$개의 이진 문자열 $w_1,w_2,\cdots,w_N$이 주어진다. 중복을 허용해서 문자열을 원하는 만큼 이어 붙여서 길이가 $L$인 문자열을 만들 때, 만들 수 있는 서로 다른 문자열의 개수를 구하는 문제
$1 \le N \le 510$; $1 \le \vert w_i \vert \le 8$; $1 \le L \le 100$
문자열을 직접 $w_i$들의 나열로 분할하거나, 문자열 뒤에 $w_i$를 붙여넣는 방식으로 세면 같은 문자열을 여러 번 세어서 올바르지 않은 답을 구하게 될 수 있습니다.
중복을 피하기 위해 $\vert w_i\vert \le 8$ 이라는 점을 이용합니다. 문자열 뒤에 비트를 하나씩 추가하면서, 이 문자열이 $w_i$들의 나열로 만들 수 있는지를 관리합니다. $\vert w_i\vert \le 8$ 이므로 최근 8개의 비트만 점화식의 상태에 넣어도 항상 올바르게 판정할 수 있습니다. 구체적으로, 점화식의 상태로 마지막 8비트의 값과 마지막 8자리에서 각각 끝나는 문자열을 $w_i$의 나열로 만들 수 있는지를 관리합니다.
$D(len, lst, end) :=$ 길이가 $len$이고, 마지막 8비트가 $lst$, 마지막 8자리에서 끝낼 수 있는지 여부가 $end$인 문자열의 개수라고 정의합시다. $end$에서 $2^i$를 나타내는 비트가 켜져 있다면 $len-i$번째 비트에서 끝나는 문자열을 만들 수 있음을 의미합니다.
길이가 0인 문자열은 항상 만들 수 있으므로 초기 상태는 $D(0,0,1) = 1$입니다. 문자열의 맨 뒤에 $d \in \lbrace0, 1\rbrace$을 추가하면 $lst \leftarrow (lst\times 2+d) \mod 256$, $end \leftarrow end\times 2 \mod 256$으로 갱신됩니다. 이후 지금까지 만든 문자열의 suffix가 $w_i$와 일치하고, 길이가 $len-\vert w_i\vert$인 문자열을 만들 수 있었다면 $end$의 최하위 비트를 켜서 상태를 갱신할 수 있습니다.
$W =- \max\vert w_i\vert$ 라고 하면 상태 공간의 크기는 $O(L\times 2^{2W})$이고 각 상태의 답을 $O(W)$ 시간에 구할 수 있으므로 전체 시간 복잡도는 $O(2^{2W}WL)$입니다. 각 상태의 답을 $O(N)$ 시간에 구하면 시간 초과를 받습니다.
int N, L, A[9][256];
ll D[111][256][256];
void Solve(){
cin >> N >> L;
for(int i=1; i<=N; i++){
string s; cin >> s;
int now = 0;
for(auto c : s) now = now << 1 | c-'0';
A[s.size()][now] = 1;
}
D[0][0][1] = 1;
for(int len=0; len<L; len++){
for(int last=0; last<256; last++){
for(int end=0; end<256; end++){
const ll now = D[len][last][end];
if(!now) continue;
for(int d=0; d<2; d++){
int nxt_last = (last << 1 | d) & 255;
int nxt_end = (end << 1) & 255;
for(int i=1, p=2; i<=8; i++, p*=2){
if(A[i][nxt_last&(p-1)] && (end >> (i-1) & 1)) nxt_end |= 1;
}
Add(D[len+1][nxt_last][nxt_end], now);
}
}
}
}
ll res = 0;
for(int last=0; last<256; last++) for(int end=0; end<256; end++) if(end & 1) Add(res, D[L][last][end]);
cout << res << "\n";
}
$N$개의 정점으로 구성된 유향 단순 그래프가 있다. 아래 연산을 최대 2번 할 수 있다.
- 원하는 정점을 하나 고르고, 그 정점에서 출발해 자유롭게 움직인다.
- 한 번 이상 방문한 정점을 모두 검은색으로 칠한다.
검은색으로 칠할 수 있는 정점 개수를 최대화하는 문제
$1 \le N \le 300$
같은 정점을 여러 번 방문할 수 있으므로 어떤 SCC 안에 들어가면 그 안의 모든 정점을 방문할 수 있습니다. 따라서 SCC를 압축해서 DAG로 만든 뒤, 각 정점의 가중치를 SCC의 크기로 두고 진행합시다.
두 사람이 원하는 지점에서 시작해서 항상 위상 정렬 순으로 정점을 방문한다고 생각합시다. 구체적으로, 두 사람이 각각 마지막으로 방문한 정점이 $x, y$ ($x \le y$) 일 때 지금까지 검은색으로 칠한 정점의 수의 최댓값을 $D(x, y)$라고 정의합시다.
$x, y$에서 갈 수 있는 정점 $z$에 대해, $D(x, y) + W_z \rightarrow D(y,z), D(x,z)$ 와 같이 갱신하면 되는데, 이때 $x = y$가 되는 경우와 점화식을 갱신하는 순서를 잘 고려해야 합니다. $\max(x,y)$가 증가하는 순서대로 계산하면 중복이나 빠뜨리는 케이스 없이 잘 계산할 수 있습니다.
int N, K, A[333][333], C[333], S[333], D[333][333];
vector<int> G[333], R[333], V;
void DFS1(int v){ C[v] = -1; for(auto i : G[v]) if(!C[i]) DFS1(i); V.push_back(v); }
void DFS2(int v, int c){ C[v] = c; S[c] += 1; for(auto i : R[v]) if(C[i] == -1) DFS2(i, c); }
void GetSCC(){
for(int i=1; i<=N; i++) if(!C[i]) DFS1(i);
reverse(V.begin(), V.end());
for(auto i : V) if(C[i] == -1) DFS2(i, ++K);
memset(A, 0, sizeof A);
for(int i=1; i<=K; i++) A[0][i] = 1;
for(int i=1; i<=N; i++) for(auto j : G[i]) if(C[i] != C[j]) A[C[i]][C[j]] = 1;
}
void Solve(){
cin >> N;
for(int i=1; i<=N; i++) for(int j=1; j<=N; j++) cin >> A[i][j];
for(int i=1; i<=N; i++) for(int j=1; j<=N; j++) if(A[i][j]) G[i].push_back(j), R[j].push_back(i);
GetSCC();
memset(D, 0xc0, sizeof D); D[0][0] = 0;
for(int i=1; i<=K; i++){
for(int x=0; x<i; x++){
for(int y=x; y<i; y++){
if(A[x][i]) D[y][i] = max(D[y][i], D[x][y] + S[i]);
if(A[y][i]) D[x][i] = max(D[x][i], D[x][y] + S[i]);
if(A[x][i] && A[y][i]) D[i][i] = max(D[i][i], D[x][y] + S[i]);
}
}
}
int res = 0;
for(int i=0; i<=K; i++) for(int j=i; j<=K; j++) res = max(res, D[i][j]);
cout << res << "\n";
}
$H\times W$ 크기의 격자가 있다. $(1, 1)$과 $(H, W)$를 포함해 일부 칸을 검은색으로 칠해서, 칠해진 칸들만 이용해 $(1, 1)$과 $(H, W)$를 연결하려고 한다. 가능한 색칠 방법의 수를 구하는 문제
$2 \le H \le 6$; $2 \le W \le 100$
전형적인 Connection Profile DP 문제입니다. USACO Guide 의 DP on Broken Profile 문서에서 여러 튜토리얼을 확인할 수 있습니다.
간단한 아이디어만 소개하자면, 첫 번째 열부터 차례대로 보면서, 가장 최근 열의 칸들 간의 연결 상태를 표현하는 유니온 파인드 배열 자체를 DP의 상태로 넣는 방식입니다. 이때 상태의 수를 줄이기 위해 유니온 파인드 배열을 그대로 넣는 것보다는 정규화를 해서 넣는 것이 좋습니다.
$(1, 1)$과 연결된 칸을 항상 $0$번으로 고정하면 구현이 조금 편해집니다.
struct union_find{
vector<int> p;
union_find(int n) : p(n) { iota(p.begin(), p.end(), 0); }
int find(int v){ return v == p[v] ? v : p[v] = find(p[v]); }
void merge(int u, int v){ p[find(u)] = find(v); }
};
union_find Build(const vector<int> &v){
int n = v.size();
union_find uf(n);
for(int i=0; i<n; i++) for(int j=i+1; j<n; j++) if(v[i] != -1 && v[i] == v[j]) uf.merge(i, j);
return uf;
}
vector<int> Normalize(vector<int> v, int root_pos){
int n = v.size(), cnt = 0;
vector<int> conv(n, -1);
if(root_pos != -1 && v[root_pos] != -1) conv[v[root_pos]] = 0;
for(int i=0; i<n; i++){
if(v[i] == -1) continue;
if(conv[v[i]] == -1) conv[v[i]] = ++cnt;
v[i] = conv[v[i]];
}
return v;
}
vector<int> Next(vector<int> v, int bit){
int n = v.size(), cnt = max(0, *max_element(v.begin(), v.end()));
vector<int> row(n);
for(int i=0; i<n; i++) row[i] = bit >> i & 1;
vector<int> res(n, -1);
for(int i=0; i<n; i++) if(row[i]) res[i] = v[i] != -1 ? v[i] : ++cnt;
int pos = find(res.begin(), res.end(), 0) - res.begin();
if(pos == res.size()) pos = -1;
auto uf = Build(res);
for(int i=1; i<n; i++) if(res[i-1] != -1 && res[i] != -1) uf.merge(i-1, i);
for(int i=0; i<n; i++) if(res[i] != -1) res[i] = uf.find(i);
return Normalize(res, pos);
}
int N, M;
map<vector<int>, ll> D[111];
void Solve(){
cin >> N >> M;
for(int bit=0; bit<(1<<N); bit++){
vector<int> state(N, -1);
for(int i=0; i<N; i++) if(bit >> i & 1) state[i] = i;
for(int i=1; i<N; i++) if(state[i-1] != -1 && state[i] != -1) state[i] = state[i-1];
state = Normalize(state, 0);
D[0][state] += 1;
}
for(int i=0; i+1<M; i++){
for(auto [state,val] : D[i]){
for(int bit=0; bit<(1<<N); bit++){
auto nxt = Next(state, bit);
D[i+1][nxt] = (D[i+1][nxt] + val) % MOD;
}
}
}
ll res = 0;
for(auto [state,val] : D[M-1]) if(state.back() == 0) res = (res + val) % MOD;
cout << res << "\n";
}
$a_1 = a_2 = \cdots = a_K = 1$ 이고 $i > K$ 이면 $a_i = a_{i-1} + \cdots + a_{i-K}$ 로 정의되는 수열 $a$의 $n$번째 항 $a_n$을 구하는 문제
$2 \le K \le 1\,000$; $1 \le N \le 10^9$
현재 값이 이전 $K$개의 항의 선형 결합으로 결정되는 선형 점화식의 $N$번째 항을 빠르게 계산하는 문제입니다. 다양한 방법이 존재하며, Problem Solving 범위에서는 $O(KN)$, $O(K^3 \log N)$, $O(K^2\log N)$, $O(K \log K \log N)$ 시간에 계산하는 방법들이 잘 알려져 있습니다.
구체적인 방법은 키타마사법 설명을 참고하세요.
using poly = vector<ll>;
void Add(ll &a, ll b){ if((a += b) >= MOD) a -= MOD; }
void Sub(ll &a, ll b){ if((a -= b) < 0) a += MOD; }
poly Mul(const poly &a, const poly &b){
poly res(a.size() + b.size() - 1);
for(int i=0; i<a.size(); i++) for(int j=0; j<b.size(); j++) Add(res[i+j], a[i] * b[j] % MOD);
return res;
}
poly Mod(const poly &a, const poly &b){
poly res = a;
for(int i=res.size()-1; i>=b.size()-1; i--) for(int j=0; j<b.size(); j++) Sub(res[i+j-b.size()+1], res[i] * b[j] % MOD);
res.resize(b.size()-1);
return res;
}
poly PowMod(poly a, ll b, const poly &mod){
poly res{1};
for(; b; b>>=1, a=Mod(Mul(a,a),mod)) if(b & 1) res = Mod(Mul(res, a), mod);
return res;
}
// A[n] = c[1]A[n-1] + c[2]A[n-2] + ... + c[k]A[n-k]
// = d[0]A[0] + d[1]A[1] + ... + d[k-1]A[k-1] for some d[*]
// given A[0..k-1], c[1..k], and n, return A[n] in O(k^2 log n)
ll Kitamasa(poly c, poly a, ll n){
if(n < a.size()) return a[n];
poly f(c.size() + 1);
for(int i=0; i<c.size(); i++) f[i] = c[i] ? MOD - c[i] : 0;
f.back() = 1;
poly d = PowMod({0, 1}, n, f);
ll res = 0;
for(int i=0; i<c.size(); i++) Add(res, a[i] * d[i] % MOD);
return res;
}
void Solve(){
int k, n; cin >> k >> n;
cout << Kitamasa(vector<ll>(k,1), vector<ll>(k,1), n-1) << "\n";
}
AtCoder Next DP Contest (link)에 출제된 20문제의 DP 문제 중 앞 14문제의 풀이입니다. solved.ac 기준 실버 상위권부터 다이아 중위권 정도 범위의 문제를 다룹니다.
유형과 solved.ac 기준 체감 난이도는 다음과 같습니다.
| 문제 | 유형 | 난이도 |
|---|---|---|
| A. Polyomino | 기초 DP | S2 - G5 |
| B. DAG | DAG DP | G3 |
| C. String | 기초 DP | G5 - G3 |
| D. Banknote | Digit DP | P3 |
| E. Summer Vacation | 스파스 테이블 | D5 |
| F. Set | 카르테시안 트리, 검은돌 | D4 - D3 |
| G. Mouth | 구간 최대 합 세그먼트 트리 | D5 |
| H. Coin | 기초 DP, 누적 합 | G1 - P4 |
| I. Update Positions | 조합론 | P2 - D5 |
| J. Number and Total | Digit DP, 배낭 문제 | D5 - D3 |
| K. Addition and Subtraction | 배낭 문제, 비트셋 | P1 - D5 |
| L. LCM | 뫼비우스 반전 공식 | P1 - D5 |
| M. Numeral | SOS DP | D5 |
| N. Knapsack | 배낭 문제, 그리디 | D3 이상 |
$2\times 1$ 크기의 블록과 $3$칸짜리 ㄴ 모양의 블록을 이용해 $2\times N$ 크기의 격자를 채우는 경우의 수를 구하는 문제
$1 \le N \le 40$
세로로 쪼갤 수 없는 $2 \times n$ 크기의 덩어리를 만드는 방법의 수를 생각해 봅시다. $n = 1$ 일 때는 $2\times 1$ 크기의 블록을 세로로 배치하는 것 1가지, $n = 2$ 일 때는 $2\times 1$ 크기의 블록 2개를 위 아래로 배치하는 것 1가지가 있습니다.
$n = 3$ 일 때는 ㄴㄱ 모양으로 배치하는 것과 반대로 배치하는 것까지 총 두 가지가 있습니다. $n$이 3 보다 큰 홀수일 때는 ㄴ과 ㄱ 사이에 $2\times 1$ 크기의 블록을 위 아래로 각각 $(n-3)/2$개씩 배치할 수 있으므로, $n$이 3 이상인 홀수라면 항상 두 가지 방법이 존재합니다.
$n = 4$ 일 때는 ㄴ 모양 블록 2개를 아래와 같이 배치한 뒤, 빈 공간에 $2\times 1$ 크기의 블록 1개를 가로로 배치하는 방법과, 이를 상하 반전시킨 것까지 총 두 가지 방법이 존재합니다.
| |
+- -+
$n$이 4보다 큰 짝수일 때는 두 ㄴ 모양 블록 사이에 $2\times 1$ 크기의 블록을 위 아래로 각각 $(n-4)/2$개씩 배치할 수 있으므로, $n$이 4 이상인 짝수일 때도 항상 두 가지 방법이 존재합니다.
따라서 $D(n) = D(n-1) + D(n-2) + 2\times \sum_{i=3}^n D(n-i)$라는 점화식을 얻을 수 있습니다. $N \le 40$ 이므로 $O(N^2)$에 계산해도 충분합니다.
void Solve(){
cin >> N;
D[0] = D[1] = 1;
for(int i=2; i<=N; i++){
D[i] = D[i-1] + D[i-2];
for(int j=3; j<=i; j++) D[i] += D[i-j] * 2;
}
cout << D[N] << "\n";
}
$S(n) = D(0) + D(1) + \cdots + D(n)$이라고 정의하면 $D(n) = D(n-1) + D(n-2) + 2S(n-3)$으로 계산할 수 있으므로 $O(N)$ 시간에 계산할 수도 있습니다.
상태 전이를 아래와 같이 행렬 곱셈으로 표현할 수 있다는 점을 이용해 $O(\log N)$ 시간에 계산할 수도 있습니다.
\[\begin{pmatrix}1&1&0\\0&0&1\\2&1&1\end{pmatrix}\begin{pmatrix}S(n-3)\\D(n-2)\\D(n-1)\end{pmatrix}=\begin{pmatrix}S(n-2)\\D(n-1)\\D(n)\end{pmatrix}\]void Solve(){
cin >> N;
matrix A({ {1, 1, 0}, {0, 0, 1}, {2, 1, 1} });
matrix B({ {1}, {1}, {2} }); // {S[0]; D[1]; D[2]}
// A^{N-2} * B = {S[N-2]; D[N-1]; D[N]}
if(N <= 2) cout << N << "\n";
else cout << (Pow(A, N-2) * B)[2][0] << "\n";
}
$N$개의 정점과 $M$개의 간선으로 구성된 DAG에서 $1 \rightarrow N$ 경로 개수를 구하는 문제
$2 \le N \le 2\times 10^5$; $0 \le M \le \min(N(N-1)/2, 2\times 10^5)$; simple DAG
$1$번 정점에서 $v$번 정점으로 가는 경로의 수를 $D(v)$라고 정의합시다. 처음에는 $D(1)$만 $1$인 상태에서 시작합니다.
$x$에서 $y$로 가는 간선이 있다면 $D(x)$를 $D(y)$에 더해야 하는데, $D(x)$의 값이 확정된 후에 이러한 덧셈 연산을 해야 한다는 문제가 있습니다. 연산의 순서를 잘못 처리하는 경우 올바르지 않은 값이 더해질 수 있어서 순서를 잘 정해야 하는데, 이는 위상 정렬 순서대로 처리하면 쉽게 해결할 수 있습니다.
정점을 위상 정렬 순서대로 보면서 경로의 수를 계산합니다. 현재 $x$번 정점을 보고 있고, $x$에서 $y$로 가는 간선이 있다면 $D(x)$를 $D(y)$에 더하면 $O(N+M)$ 시간에 문제를 해결할 수 있습니다.
void Solve(){
cin >> N >> M;
for(int i=1,u,v; i<=M; i++) cin >> u >> v, G[u].push_back(v), In[v]++;
D[1] = 1;
queue<int> Q;
for(int i=1; i<=N; i++) if(!In[i]) Q.push(i);
while(!Q.empty()){
int v = Q.front(); Q.pop();
for(auto i : G[v]){
D[i] += D[v];
if(D[i] >= MOD) D[i] -= MOD;
if(!--In[i]) Q.push(i);
}
}
cout << D[N] << "\n";
}
문자열 $S_1,S_2,S_3$이 주어지면, 세 문자열을 모두 subsequence로 포함하지 않는 길이가 $N$인 문자열의 수를 구하는 문제
$1 \le N \le 10^3$; $1 \le \vert S_i\vert \le 10$
$S_i$의 첫 $p_i$글자를 subsequence로 포함하는 길이가 $n$인 문자열의 개수를 $D(n,p_1,p_2,p_3)$라고 정의합시다.
현재 상황이 $(n, p_1, p_2, p_3)$인 상황에서 $n+1$번째 문자로 $c$를 붙인다고 하면, $S_i[p_i] = c$ 일 때 $p_i$가 1 증가합니다. 이 관계를 그대로 점화식으로 옮겨서 계산하면 $O(26N\times \prod\vert S_i\vert)$ 시간에 문제를 해결할 수 있습니다.
void Add(int &a, const int b){ if((a += b) >= MOD) a -= MOD; }
void Solve(){
cin >> N;
for(int i=0; i<3; i++) cin >> A[i];
for(int i=0; i<3; i++) S[i] = A[i].size();
D[0][0][0][0] = 1;
for(int i=0; i<N; i++){
for(int x=0; x<S[0]; x++){
for(int y=0; y<S[1]; y++){
for(int z=0; z<S[2]; z++){
const int now = D[i][x][y][z];
if(!now) continue;
for(int c=0; c<26; c++){
int xx = x + (A[0][x]-'a' == c);
int yy = y + (A[1][y]-'a' == c);
int zz = z + (A[2][z]-'a' == c);
Add(D[i+1][xx][yy][zz], D[i][x][y][z]);
}
}
}
}
}
int res = 0;
for(int x=0; x<S[0]; x++) for(int y=0; y<S[1]; y++) for(int z=0; z<S[2]; z++) Add(res, D[N][x][y][z]);
cout << res << "\n";
}
만약 subsequence가 아니라 substring 조건이 있었다면, KMP 알고리즘의 실패 함수를 이용해서 같은 시간에 문제를 해결할 수 있습니다. 구체적으로, $S[0:p] + c$와 일치하는 $S$의 가장 긴 접두사의 길이 $F(p, c)$를 $O(26\times \vert S\vert)$ 시간에 계산하면, 점화식의 상태 전이는 위 코드에서 xx를 구할 때 xx = F[x][c]를 사용하는 식으로 계산할 수 있습니다. 2020년 ICPC 서울 예선 B번 문제에서 연습할 수 있습니다.
vector<int> GetFail(const string &s){
int n = s.size();
vector<int> fail(n);
for(int i=1, j=0; i<n; i++){
while(j > 0 && s[i] != s[j]) j = fail[j-1];
if(s[i] == s[j]) fail[i] = ++j;
}
return fail;
}
void GetNext(const string &s, int nxt[][26]){
int n = s.size();
auto fail = GetFail(s);
for(int i=0; i<n; i++){
for(int c=0; c<26; c++){
int j = i;
while(j > 0 && s[j] != c+'a') j = fail[j-1];
if(s[j] == c+'a') nxt[i][c] = ++j;
}
}
for(int c=0; c<26; c++) nxt[n][c] = n;
}
$1,10,100,\cdots,10^{100}$원짜리 지폐를 이용해 $N$원을 지불하려고 한다. 지불하는 돈의 지폐 수와 거스름돈의 지폐 수의 합을 최소화하는 문제
$1 \le N \le 10^{18}$
거스름돈의 액수를 $x \ge 0$이라고 하면, $N+x$원을 지불하고 $x$원을 거슬러 주는 상황이라고 볼 수 있습니다. 즉, 적당한 음이 아닌 정수 $x$에 대해, $\text{digitsum}(N+x) + \text{digitsum}(x)$의 최솟값을 구하는 문제입니다. 가능한 모든 $x$에 대해 위 식을 계산하고 최솟값을 구하는 것은 불가능하므로 다른 방법을 생각해야 합니다.
$x$의 각 자리를 낮은 자리부터 하나씩 확정하는 방식으로 문제를 해결해 봅시다.
$N$의 $10^i$의 자리를 $S_i$라고 하고, $x$의 $10^i$의 자리를 $d$로 정하는 상황을 생각해 봅시다. 그러면 $\text{digitsum}(N+x)$는 $(S_i + d) \mod 10$ 만큼 증가하고, $\text{digitsum}(x)$는 $d$ 만큼 증가합니다. 또한, 만약 $S_i+d \ge 10$이었다면 받아올림이 발생해 $S_{i+1}$가 1 증가하게 됩니다.
따라서 다음과 같은 점화식을 세울 수 있습니다: $D(i, c) :=$ $x$의 $10^0, 10^1, 10^2, \cdots, 10^i$의 자리까지 결정했고, $10^i$의 자리에서 받아올림 발생 여부가 $c$일 때 가능한 $\text{digitsum}(N+x) + \text{digitsum}(x)$의 최솟값
$x$의 $10^i$의 자리를 $d$로 정하는 경우, $s = S_i + d + c$라고 하면 $D(i+1, [s\ge 10]) \leftarrow D(i, c) + (s \mod 10) + d$ 와 같이 상태 전이를 할 수 있습니다.
void Solve(){
cin >> S;
reverse(S.begin(), S.end());
S += "0";
memset(D, 0x3f, sizeof D);
D[0][0] = 0;
for(int i=0; i<S.size(); i++){
for(int c=0; c<2; c++){
if(D[i][c] == INF) continue;
for(int d=0; d<10; d++){
int sum = S[i] - '0' + d + c;
D[i+1][sum/10] = min(D[i+1][sum/10], D[i][c] + sum%10 + d);
}
}
}
cout << D[S.size()][0] << "\n";
}
$M$개의 구간이 있다. 쿼리로 $L, R$이 주어지면, 시작점이 $L$ 이상이고 끝점이 $R$ 이하인 구간만 이용해 만든 구간 그래프의 최대 독립 집합 크기를 구하는 문제
$1 \le M,Q \le 2\times 10^5$
쿼리가 없을 때는 구간들을 끝점 기준 오름차순으로 정렬한 뒤, 지금까지 선택한 구간들의 끝점보다 더 늦게 시작하는 구간을 선택하는 것을 반복해 해결할 수 있습니다.
void Naive(){
cin >> N >> M;
for(int i=1; i<=M; i++) cin >> A[i][0] >> A[i][1];
sort(A+1, A+M+1, [](const auto &a, const auto &b){ return a[1] < b[1]; });
int res = 0, lst = -1;
for(int i=1; i<=M; i++) if(lst < A[i][0]) res++, lst = A[i][1];
cout << res << "\n";
}
쿼리가 주어지더라도 “다음 구간”을 선택하는 방식은 변하지 않으므로, 먼저 $f(i) :=$ $i$번 구간 바로 다음에 선택할 구간의 번호를 전처리합시다. 이는 구간을 시작점 기준으로 정렬한 뒤, 뒤에서부터 보면서 현재 구간이 $I_i = [l_i, r_i]$라고 할 때 $r_i < l_j$ 이면서 $r_j$가 최소인 $j$를 구하면 되고, 세그먼트 트리를 이용해 $O(M \log N)$ 시간에 모두 계산할 수 있습니다.
pair<int,int> T[SZ<<1]; // Set(start, {end, index})
void Set(int x, pair<int,int> v){
for(x|=SZ; x; x>>=1) T[x] = min(T[x], v);
}
pair<int,int> Query(int l, int r){
pair<int,int> res(INF, INF);
for(l|=SZ, r|=SZ; l<=r; l>>=1, r>>=1){
if(l & 1) res = min(res, T[l++]);
if(~r & 1) res = min(res, T[r--]);
}
return res;
}
void Solve(){
cin >> N >> M >> Q;
for(int i=1; i<=M; i++) cin >> A[i][0] >> A[i][1];
sort(A+1, A+M+1);
fill(T, T+SZ*2, make_pair(INF, INF));
for(int i=M; i>=1; i--){
// // r[i]+1 <= l[j] 이면서 r[j]가 최소인 구간
auto [end,index] = Query(A[i][1]+1, N); // end = INF 면 다음 구간이 없음
if(end <= N) P[0][i] = index;
Set(A[i][0], {A[i][1], i});
}
// TODO
}
$f(i)$를 구했다면 쿼리를 처리하는 것은 어렵지 않습니다. 먼저, $l \le l_i$ 인 구간 중 $r_i$가 가장 작은 구간 $i$를 찾은 뒤, 구간의 끝점이 $r$ 이하라면 계속해서 $f(f(f(\cdots f(i)\cdots)))$처럼 $f$를 따라가면 됩니다. 이때 $f$를 따라간 횟수가 정답니 됩니다. 하지만 매번 $f$를 따라가도록 구현하면 쿼리마다 $O(M)$ 시간에 걸려 시간 제한 안에 문제를 풀 수 없습니다.
$f(i), f^2(i), f^4(i), f^8(i), \cdots$를 미리 전처리하면 각 쿼리를 $O(\log M)$ 시간에 처리할 수 있습니다. $f^{2^i}(i) = f^{2^{i-1}}(f^{2^{i-1}}(i))$라는 점을 이용하면 전처리 또한 $O(M \log M)$ 시간에 할 수 있습니다.
따라서 전체 시간 복잡도는 $O(M \log N + (M+Q) \log M)$입니다.
WF 2014, UCPC 2020 등에 출제된 적 있는 나름 유명한 테크닉입니다.
pair<int,int> T[SZ<<1];
void Set(int x, pair<int,int> v){
for(x|=SZ; x; x>>=1) T[x] = min(T[x], v);
}
pair<int,int> Query(int l, int r){
pair<int,int> res(MOD, MOD);
for(l|=SZ, r|=SZ; l<=r; l>>=1, r>>=1){
if(l & 1) res = min(res, T[l++]);
if(~r & 1) res = min(res, T[r--]);
}
return res;
}
int N, M, Q, P[22][202020];
array<int,2> A[202020];
void Solve(){
cin >> N >> M >> Q; A[0] = {MOD, MOD};
for(int i=1; i<=M; i++) cin >> A[i][0] >> A[i][1];
sort(A+1, A+M+1);
fill(T, T+SZ*2, make_pair(MOD,MOD));
for(int i=M; i>=1; i--){
auto [end,index] = Query(A[i][1]+1, N);
if(end <= N) P[0][i] = index;
Set(A[i][0], {A[i][1], i});
}
for(int i=1; i<22; i++) for(int j=1; j<=M; j++) P[i][j] = P[i-1][P[i-1][j]];
for(int q=1; q<=Q; q++){
int l, r; cin >> l >> r;
auto [end,p] = Query(l, r);
if(p == MOD || r < A[p][1]){ cout << "0\n"; continue; }
int res = 1;
for(int i=21; i>=0; i--) if(A[P[i][p]][1] <= r) res += 1 << i, p = P[i][p];
cout << res << "\n";
}
}
길이가 $N$인 수열 $A$와 순열 $P$가 있다. 아래 조건이 성립하도록 $\lbrace1,2,\cdots,N \rbrace$의 부분 집합 $S$를 선택하려고 한다.
- $x,y\in S$ ($x<y$)면 $P_z=\min(P_x,P_{x+1},P_y)$ 인 $z$도 $z\in S$가 성립해야 함.
$K = 1, 2, \cdots, N$에 대해 크기가 $K$인 가능한 $S$ 중 $\sum_{i\in S} A_i$의 최솟값을 각각 구하는 문제
$1 \le N \le 5\,000$
길이가 $N$인 수열 $A$가 있을 때, 아래 조건을 만족하는 정점이 $N$개인 이진 트리를 카르테시안 트리라고 부릅니다.
즉, 카르테시안 트리는 수열에서 최솟값이 있는 위치 $p$가 루트 정점이고, $p$보다 앞에 있는 인덱스는 왼쪽 서브 트리, 뒤에 있는 인덱스는 오른쪽 서브 트리에 넣은 트리입니다. 카르테시안 트리는 스택을 이용해 $O(N)$ 시간에 구축할 수 있습니다. (link)
$P_z = \min(P_x,P_{x+1}, \cdots, P_y)$는 카르테시안 트리에서 $z = LCA(x, y)$라는 것을 의미합니다. 따라서 문제에서 요구하는 $S$의 조건은, 어떤 두 정점을 선택했다면 두 정점의 LCA도 선택해야 한다는 것으로 해석할 수 있습니다.
$v$를 루트로 하는 서브 트리에서 $k$개의 정점을 선택했을 때 비용 최솟값을 $D(v, k)$라고 정의합시다. 만약 $v$의 양쪽 서브 트리에서 모두 1개 이상의 정점을 선택했다면 $v$를 무조건 선택해야 합니다. 따라서 상태 전이는 다음과 같이 만들 수 있습니다.
$D(v)$를 계산할 때 길이가 최대 $N+1$인 두 배열의 convolution을 $O(N^2)$ 시간에 계산해야 하므로 전체 시간 복잡도가 $O(N^3)$일 것 같지만, 실제로는 $O(N^2)$입니다. 구체적으로, 크기가 각각 $A, B$인 서브 트리의 정보를 각각 $O(AB)$ 시간에 합칠 수 있으면 전체 시간 복잡도가 $O(N^2)$이 된다는 것을 증명할 수 있습니다.
$A\times B$는 왼쪽 서브 트리의 정점과 오른쪽 서브 트리의 정점을 짝짓는 경우의 수와 같습니다. 이때, 두 정점 $a \in A$와 $b \in B$는 $LCA(a, b)$에서만 정확히 한 번 만나게 되므로, 트리에서 $A\times B$의 합은 트리에서 임의의 두 정점을 선택하는 경우의 수와 같습니다. 따라서 트리의 형태와 관계 없이, 심지어 이진 트리가 아니더라도 항상 $O(N^2)$ 시간에 문제를 해결할 수 있습니다.
한국에서는 2019 KOI 고등부 3번에 이 테크닉이 출제되어서 많이 알려져 있습니다.
vector<ll> Conv(const vector<ll> &a, const vector<ll> &b){
// min-plus convolution
// c[k] = min a[i] + b[k-i]
vector<ll> res(a.size() + b.size() - 1, INF);
for(int i=0; i<a.size(); i++){
for(int j=0; j<b.size(); j++){
res[i+j] = min(res[i+j], a[i] + b[j]);
}
}
return res;
}
int Build(){
vector<int> stk;
for(int i=1; i<=N; i++){
int lst = 0;
while(!stk.empty() && P[stk.back()] > P[i]) lst = stk.back(), stk.pop_back();
if(!stk.empty()) R[stk.back()] = i, Par[i] = stk.back();
if(lst) L[i] = lst, Par[lst] = i;
stk.push_back(i);
}
return find(Par+1, Par+N+1, 0) - Par; // return root
}
vector<ll> DFS(int v){
if(v == 0) return {0};
auto lv = DFS(L[v]), rv = DFS(R[v]);
auto res = Conv(lv, rv);
for(auto &i : res) i += A[v];
res.insert(res.begin(), 0);
for(int i=0; i<lv.size(); i++) res[i] = min(res[i], lv[i]);
for(int i=0; i<rv.size(); i++) res[i] = min(res[i], rv[i]);
return res;
}
void Solve(){
cin >> N;
for(int i=1; i<=N; i++) cin >> P[i];
for(int i=1; i<=N; i++) cin >> A[i];
int root = Build();
auto res = DFS(root);
for(int i=1; i<=N; i++) cout << res[i] << " ";
cout << "\n";
}
길이가 $N$인 수열 $A$가 주어진다. 쿼리로 두 정수 $i, v$가 주어지면, $A_i \leftarrow v$로 수정한 뒤에 아래 문제의 정답을 출력하는 문제
길이가 $N$인 수열 $B$가 있다. $B$의 모든 원소는 초기에 $0$이다. 원하는 정수 $x$ ($1 \le x \le N$)을 고른 뒤, 아래 연산을 원하는 만큼 반복한다.
- 연산: 현재 위치를 $y$라고 하자. $y-1, y, y+1$ 중 원하는 곳으로 이동한 뒤, $B_y$를 1 만큼 증가시킨다. 단, 수열 밖으로 나갈 수 없다
연산을 모두 끝낸 뒤, $\sum_{i=1}^N \vert A_i-B_i\vert$으로 가능한 최솟값을 출력
$1 \le N,Q \le 10^5$
$B$에서 $0$이 아닌 원소는 하나의 구간을 이루며, 그 구간 안에서는 값을 원하는 대로 만들 수 있습니다. 따라서 구간 $0$이 아닐 구간 $[l, r]$을 선택했다면, 비용은 다음과 같이 계산할 수 있습니다.
첫 번째 항은 $l, r$과 무관하므로 뒤에 있는 두 항의 합을 최소화해야 합니다. $W_i = [A_i=0] - A_i$라고 정의하면, $W_i$에서 합이 최소인 구간을 구하면 되고, 이는 값 업데이트가 있는 상황에서도 세그먼트 트리를 이용해 $O(\log N)$ 시간에 갱신과 최솟값 쿼리를 모두 수행할 수 있습니다.
구체적으로, 세그먼트 트리의 각 정점에서 아래 네 가지 정보를 관리하면 상수 시간에 두 정점을 합칠 수 있으므로, 각 쿼리를 $O(\log N)$ 시간에 처리할 수 있습니다.
한국에서는 2014 KOI 중등부 3번에 이러한 자료구조를 사용하는 문제가 출제되어 많이 알려져 있습니다.
struct node{
ll l, r, mn, sum;
node() : node(INF, INF, INF, 0) {}
node(ll v) : node(v, v, v, v) {}
node(ll l, ll r, ll mn, ll sum) : l(l), r(r), mn(mn), sum(sum) {}
};
node operator + (const node &a, const node &b){
node res;
res.sum = a.sum + b.sum;
res.l = min(a.l, a.sum + b.l);
res.r = min(a.r + b.sum, b.r);
res.mn = min({a.mn, b.mn, a.r + b.l});
return res;
}
ll N, Q, A[101010], W[101010], S;
node T[SZ<<1];
void Update(int x, ll v){
for(T[x|=SZ]=v; x>>=1; ) T[x] = T[x<<1] + T[x<<1|1];
}
node Query(int l, int r){
node lv, rv;
for(l|=SZ, r|=SZ; l<=r; l>>=1, r>>=1){
if(l & 1) lv = lv + T[l++];
if(~r & 1) rv = T[r--] + rv;
}
return lv + rv;
}
void Solve(){
cin >> N >> Q;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=N; i++) W[i] = A[i] == 0 ? 1 : -A[i];
for(int i=1; i<=N; i++) T[i|SZ] = {W[i], W[i], W[i], W[i]};
for(int i=SZ-1; i; i--) T[i] = T[i<<1] + T[i<<1|1];
S = accumulate(A+1, A+N+1, 0LL);
for(int q=1; q<=Q; q++){
ll x, v; cin >> x >> v;
S -= A[x]; S += v;
A[x] = v; W[x] = A[x] == 0 ? 1 : -A[x];
Update(x, W[x]);
cout << S + min(0LL, Query(1, N).mn) << "\n";
}
}
$N\times N$ 격자가 있다. 일부 칸에는 동전이 하나씩 있다. $(1, 1)$에서 시작해서 매번 오른쪽 또는 아래로만 이동하려고 한다. $x = 0, 1, 2, \cdots, 2N-2$에 대해, 동전을 정확히 $x$개 획득해서 갈 수 있는 칸의 개수를 출력하는 문제
$2 \le N \le 4\,000$; $(1, 1)$에 동전 없음
$(i, j)$로 갈 때 획득하는 코인의 최소 개수 $mn[i][j]$와 최대 개수 $mx[i][j]$는 $O(N^2)$ 시간에 쉽게 구할 수 있습니다. 그리고 놀랍게도 $mn[i][j] \le k \le mx[i][j]$인 모든 $k$에 대해, 코인을 정확히 $k$개만 획득하면서 $(1, 1)$에서 $(i, j)$로 가는 경로가 존재합니다. RD 를 DR로 바꾸거나, 반대로 DR을 RD로 바꾸면 기존 경로와 정확히 한 칸만 다른 경로를 만들 수 있다는 점을 생각해 보면 어렵지 않게 받아들일 수 있습니다.
따라서 $mn$과 $mx$를 계산한 뒤, 누적 합 배열을 이용하면 $O(N^2)$ 시간에 답을 구할 수 있습니다.
void Solve(){
cin >> N;
for(int i=1; i<=N; i++){
string s; cin >> s;
for(int j=1; j<=N; j++) A[i][j] = s[j-1] == '@';
}
memset(X, 0x3f, sizeof X);
memset(Y, 0xc0, sizeof Y);
X[1][1] = Y[1][1] = 0;
for(int i=1; i<=N; i++){
for(int j=1; j<=N; j++){
if(i + j == 2) continue;
X[i][j] = min(X[i-1][j], X[i][j-1]) + A[i][j];
Y[i][j] = max(Y[i-1][j], Y[i][j-1]) + A[i][j];
}
}
for(int i=1; i<=N; i++) for(int j=1; j<=N; j++) R[X[i][j]]++, R[Y[i][j]+1]--;
partial_sum(R, R+8080, R);
for(int i=0; i<=2*N-2; i++) cout << R[i] << "\n";
}
$N$개의 막대가 있다. $i$번째 막대의 길이는 $A_i$이다. 왼쪽에서 보면 $L$개, 오른쪽에서 보면 $R$개의 막대가 보이도록 막대를 배치하는 경우의 수를 구하는 문제. 단, 길이가 같은 막대는 구분하지 않는다.
$1 \le N \le 400$
막대의 길이가 모두 다를 때의 풀이를 먼저 알아봅시다.
길이가 긴 막대부터 하나씩 배치할 것입니다. 지금까지 $n$개의 막대를 배치했고, 왼쪽에서 $l$개, 오른쪽에서 $r$개 보이는 상황이라고 가정합시다.
만약 새로 추가하는 막대를 가장 왼쪽에 배치한다면 왼쪽에서 보이는 막대의 수가 1 증가하고, 가장 오른쪽에 배치하면 오른쪽에서 보이는 막대의 수가 1 증가합니다. 나머지 경우에는 보이는 막대의 수가 증가하지 않으며, 그런 경우는 총 $n-1$가지가 있습니다. 따라서 상태 전이는 다음과 같이 만들 수 있습니다.
또는 $D(n,l,r) = D(n-1,l-1,r) + D(n-1,l,r-1) + (n-2)D(n-1,l,r)$ 으로 계산할 수도 있습니다. $O(N^3)$ 시간에 답을 구할 수 있습니다.
같은 길이의 막대가 여러 개 있을 때도 길이가 긴 막대부터 배치하는 건 똑같이 하는데, 길이가 같은 막대를 한 번에 모두 배치할 것입니다. 지금까지 $n$개의 막대를 배치했고, 이번에 길이가 같은 $c$개의 막대를 배치해야 한다고 합시다.
이때 $l$과 $r$을 모두 증가시키지 않기 위해서는 막대와 막대 사이에 있는 $n-1$개의 공간에 $c$개의 막대를 모두 배치해야 하고, 이런 경우는 총 $D(n,l,r)\times H(n-1,c)$가지 존재합니다. $l$만 증가시키기 위해서는 1개의 막대를 가장 왼쪽에 배치해야 하고, 나머지 $c-1$개의 막대는 $n$개의 공간에 자유롭게 배치하면 되므로 $D(n,l,r)\times H(n, c-1)$이 됩니다. $r$만 증가시키는 경우와 둘 다 증가시키는 경우도 비슷하게 계산할 수 있습니다.
따라서 상태 전이를 정리하면 다음과 같습니다. 여기에서 $H(n, k) = {n+k-1\choose k}$는 중복 조합을 의미합니다.
ll Pow(ll a, ll b){
ll res = 1;
for(; b; b>>=1, a=a*a%MOD) if(b & 1) res = res * a % MOD;
return res;
}
ll N, M, A[444], L, R, D[444][444][444];
ll Fac[444], Inv[444];
ll C(int n, int r){ return 0 <= r && r <= n ? Fac[n] * Inv[r] % MOD * Inv[n-r] % MOD : 0; }
ll H(int n, int r){ return C(n+r-1, r); }
void Solve(){
cin >> N >> L >> R;
for(int i=1; i<=N; i++) cin >> A[i];
Fac[0] = 1;
for(int i=1; i<=N; i++) Fac[i] = Fac[i-1] * i % MOD;
Inv[N] = Pow(Fac[N], MOD-2);
for(int i=N-1; i>=0; i--) Inv[i] = Inv[i+1] * (i+1) % MOD;
vector<int> V;
sort(A+1, A+N+1, greater<>());
for(int i=1, j=1; i<=N; i=j){
while(j <= N && A[i] == A[j]) j++;
V.push_back(j - i);
}
M = V.size();
for(int i=1; i<=M; i++) A[i] = V[i-1];
D[1][1][1] = 1;
for(int n=1, use=0; n<M; n++){
int c = A[n+1]; use += A[n];
for(int l=1; l<=n; l++){
for(int r=1; r<=n; r++){
const ll now = D[n][l][r];
if(now == 0) continue;
Add(D[n+1][l][r], H(use-1, c) * now % MOD);
Add(D[n+1][l+1][r], H(use, c-1) * now % MOD);
Add(D[n+1][l][r+1], H(use, c-1) * now % MOD);
Add(D[n+1][l+1][r+1], H(use+1, c-2) * now % MOD);
}
}
}
cout << D[M][L][R] << "\n";
}
길이가 $N$인 수열 $A$와 양의 정수 $S, T$가 주어진다. $c_1+c_2+\cdots c_N=S$, $A_1c_1+A_2c_2+\cdots+A_Nc_N=T$를 만족하는 음이 아닌 정수로 구성된 수열 $C = (c_1,c_2,\cdots,c_N)$의 개수를 구하는 문제
$1 \le N \le 20$; $1 \le S,T \le 10^{18}$; $1\le A_i \le 200$; $\sum A_i \le 200$
$c_i$를 이진법으로 생각해서 $c_i = \sum_e b_{i,e}2^e$ 처럼 나타냅시다. 그러면 이 문제는 각 $e$마다 어떤 $i$에 배치할지 선택하는, 즉 $B_e \subseteq \lbrace 1, 2, \cdots, N \rbrace$를 각 $e$에 대해 결정하는 문제로 생각할 수 있습니다. 조금 더 구체적으로, $X_e = \vert B_e\vert = \sum_i b_{i,e}$, $Y_e = \sum_{i\in B_e} A_i$ 라고 정의하면, $\sum_e X_e2^e = S$, $\sum_e Y_e2^e = T$가 성립하는 $B = (B_0, B_1, \cdots)$ 의 개수를 구하는 문제입니다. 이때 $0 \le X_e \le N \le 20$, $0 \le Y_e \le \sum A_i \le 200$ 입니다.
낮은 자리부터 차례대로 보면서, $D(i, c_S, c_T) := $ $S, T$의 $2^0, 2^1, \cdots, 2^i$의 자리까지 만들었고, $2^{i+1}$의 자리에서 $S, T$에 각각 $c_S, c_T$ 만큼의 받아올림이 발생하는 경우의 수라고 정의합시다. $D(59, 0, 0)$이 우리가 원하는 답입니다.
$2^{i+1}$의 자리에 $(X_{i+1}, Y_{i+1})$을 더하기 위해서는 $c_S$와 $c_T$가 각각 $c_S+X_{i+1}\equiv S[i+1] \pmod 2$, $c_T + Y_{i+1} \equiv T[i+1] \pmod 2$ 가 성립해야 하고, $\lfloor (c_S+X_{i+1})/2 \rfloor$, $\lfloor (c_T+Y_{i+1})/2 \rfloor$ 로 바뀝니다. 여기에서도 $c_S \le N \le 20$, $c_T \le \sum A_i \le 200$ 이 성립하므로, DP의 상태 공간은 $60 \times(N+1)\times (1+\sum A_i) = 60\times 21\times 201 = 253\,260$ 정도로 충분히 작습니다.
상태 전이를 위해 가능한 모든 $(X_e, Y_e)$를 순회할 때 $2^N$가지의 부분 집합을 모두 순회하는 것은 비효율적입니다. $i = 1, 2, \cdots, N$에 대해 $A_i$를 사용하는 경우와 사용하지 않는 경우를 고려하면 되므로, 0/1 knapsack과 같은 식으로 처리하면, $i+1$번째 비트에서 값이 $c_S+X_{i+1}, c_T+Y_{i+1}$이 되도록 만드는 경우의 수 $E(c_S+X_{i+1}, c_T+Y_{i+1})$를 $O(N^2\sum A_i)$ 시간에 셀 수 있습니다.
이후 위에서 언급한 조건을 만족하는 $(c_S+X_{i+1}, c_T+Y_{i+1})$에 대해서만 $D(i+1, \ast, \ast)$로 값을 옮기면, $D(i, \ast, \ast)$ 에서 $D(i+1, \ast, \ast)$로의 전이를 $O(N^2\sum A_i + N\sum A_i)$ 시간에 모두 처리할 수 있습니다.
전체 시간 복잡도는 $O(N^2 \log \max(S,T) \sum A_i)$입니다.
bool Check(int pos, int c_s, int c_t){
return c_s % 2 == S[pos] && c_t % 2 == T[pos];
}
void Solve(){
cin >> N >> _S >> _T;
for(int i=1; i<=N; i++) cin >> A[i];
K = accumulate(A+1, A+N+1, 0);
for(int i=1; i<=60; i++) S[i] = _S >> i-1 & 1;
for(int i=1; i<=60; i++) T[i] = _T >> i-1 & 1;
D[0][0][0] = 1;
for(int n=0; n<60; n++){
memcpy(E, D[n], sizeof E);
for(int i=1; i<=N; i++) for(int s=N+N; s>=0; s--) for(int t=K+K; t>=0; t--)
if(E[s][t]) Add(E[s+1][t+A[i]], E[s][t]);
for(int s=0; s<=N+N; s++) for(int t=0; t<=K+K; t++)
if(Check(n+1, s, t)) Add(D[n+1][s/2][t/2], E[s][t]);
}
cout << D[60][0][0] << "\n";
}
길이가 $N$인 수열 $A$가 주어진다. 초깃값이 $0$인 정수 변수 $x$가 있고, 아래 연산을 $i = 1, 2, \cdots, N$에 대해 차례대로 한 번씩 수행해야 한다.
- 연산: $x$를 $x+A_i$, $\vert x-A_i\vert$, $x$ 중 하나로 바꾼다.
$N$번의 연산을 모두 수행했을 때 가능한 결과로 가능한 수의 개수를 구하는 문제
$1 \le N \le 5\times 10^5$; $1 \le A_i \le 2\times 10^6$; $\sum A_i \le 2\times 10^6$
편의상 $\sum A_i = X$라고 정의합시다.
만들 수 있는 수가 어떤 형태인지를 먼저 파악해야 합니다. 수학적 귀납법을 이용하면, 만들 수 있는 수의 필요 충분 조건이 $\vert \sum_{i=1}^N c_iA_i \vert$ 라는 것을 알 수 있습니다. (단, $c_i \in \lbrace -1, 0, 1 \rbrace$) 또한, 연산 순서를 마음대로 바꾸더라도 만들 수 있는 값의 집합은 바뀌지 않습니다.
일반적으로 절댓값을 다루는 것은 까다롭기 때문에, $\sum c_iA_i$로 만들 수 있는 값을 모두 구한 다음 $x$ 또는 $-x$를 만들 수 있는지 확인하는 식으로 문제를 해결할 것입니다.
$D(i, x) := $ $A_1, \cdots, A_i$를 이용해 $x$를 만들 수 있으면 1, 아니면 0 으로 정의합시다. $D(i, x) = D(i-1, x-A_i) \text{ or } D(i-1, x+A_i)$로 계산할 수 있지만, 시간이 너무 오래 걸립니다. $N \le 5\times 10^5$, $X \le 2\times 10^6$으로 둘 모두 너무 큰 것이 문제입니다.
문제를 배낭 문제의 관점에서 다시 생각해 봅시다. $A_i$가 같은 것끼리 모으면, 각 물건에 사용 횟수 제한이 있는 배낭 문제라고 생각할 수 있습니다. 물건마다 사용 횟수 제한이 있는 배낭 문제는 각 물건을 1개, 2개, 4개, 8개, … 짜리 덩어리로 묶으면, $O(\log A_i)$개의 덩어리를 각각 0번 또는 1번 사용하는 배낭 문제로 바꿀 수 있습니다. 또한, 문제에 $\sum A_i = X \le 2\times 10^6$ 조건이 있으므로 서로 다른 $A_i$의 값은 최대 약 $\sqrt{2X} = \sqrt{4\times 10^6}$개밖에 존재하지 않습니다.
$M (\in O(\sqrt X \log X))$개의 물건을 각각 최대 한 번씩 사용할 수 있을 때, 무게의 합을 특정 값으로 만들 수 있는지 판단하는 건 bitset을 이용해 $O(MX/64)$ 시간에 할 수 있습니다. 구체적으로 길이가 $2X+1$인 비트셋 $B$를 만든 뒤, 무게가 $w$인 물건을 처리할 때 $B \leftarrow B \text{ or } (B»w) \text{ or } (B«w)$ 와 같이 갱신하면 됩니다.
$M \le \sqrt X \log X$ 이므로 전체 시간 복잡도는 $O(N \log X + X \sqrt X \log X / 64)$ 입니다.
constexpr int X = 2'000'000;
int N, A[505050], S;
vector<int> V;
bitset<4'000'001> D;
void Solve(){
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
S = accumulate(A+1, A+N+1, 0);
sort(A+1, A+N+1);
for(int i=1, j=1; i<=N; i=j){
while(j <= N && A[i] == A[j]) j++;
int cnt = j - i;
for(int k=1; k<=cnt; k*=2) V.push_back(A[i] * k), cnt -= k;
if(cnt > 0) V.push_back(A[i] * cnt);
}
D[X] = 1;
for(auto i : V) D = D | (D >> i) | (D << i);
int res = 0;
for(int i=0; i<=S; i++) res += D[X+i] || D[X-i];
cout << res << "\n";
}
수열 $A$에서 값이 같은 원소끼리 묶은 결과를 $\lbrace (v_i, c_i) \rbrace$ 라고 합시다. 값이 $v_i$인 원소가 $c_i$개 있다는 것을 의미하고, $v_i$는 모두 서로 다르며, 당연히 $\sum v_ic_i = \sum A_i$입니다. 순서쌍의 개수는 최대 $O(\sqrt X)$개입니다.
위에서는 값이 같은 원소를 $c_i = 1 + 2 + 4 + 8 + \cdots$이 되도록 $\lfloor \log_2 c_i \rfloor + 1$개의 덩어리로 쪼갠 뒤, 덩어리의 개수 $M = \sum \lfloor \log_2 c_i \rfloor + 1$의 상한을 $O(\sqrt X \log X)$라고 했습니다. 하지만 실제로는 $M \in O(\sqrt X)$임을 증명할 수 있습니다.
앞에서 본 것처럼 $c_i \ge 1$인 순서쌍은 최대 $O(\sqrt X)$개입니다. 비슷하게, $c_i \ge 2$인 순서쌍은 최대 $O(\sqrt {X/2})$개만 존재할 수 있고, $c_i \ge 4$인 순서쌍은 최대 $O(\sqrt{X/4})$개 존재할 수 있습니다. 즉, $c_i \ge 2^k$인 순서쌍은 최대 $O(\sqrt{X/2^k})$개 존재할 수 있습니다. 따라서 $M = \sum \lfloor \log_2 c_i \rfloor + 1 \in O(\sum_{k=0}^{\infty} \sqrt{\frac{X}{2^k}}) = O(\sqrt X)$ 가 성립합니다.
전체 시간 복잡도는 $O(N + X \sqrt X / 64)$가 됩니다.
같은 테크닉을 사용하는 문제로는 ABC269G, 2021 SWERC K번 등이 있습니다.
길이가 $N$인 순열 $A$가 주어진다. $1 \le i < j \le N$인 두 정수 $i, j$에 대해, $i$에서 $j$로 가는 간선이 $\text{LCM}(i,j)$개 있는 DAG를 생각하자. $v = 2, 3, \cdots, N$에 대해, $1$번 정점에서 $v$번 정점으로 가는 경로의 수를 각각 구하는 문제
$2 \le N \le 2\times 10^5$
$1$번 정점에서 $v$번 정점까지 가는 경로의 수를 $D(v)$라고 정의합시다. $D(v) = \sum_{i<v} LCM(A_v,A_i)\times D(i)$ 입니다. $LCM(a,b) = ab/GCD(a,b)$이므로, $D(v) = A_v\sum_{i<v}\frac{A_iD(i)}{GCD(A_v,A_i)}$ 로 나타낼 수 있습니다. 분모에 있는 gcd를 $A_v$와 $A_i$로 갈라야 $O(N^2)$보다 빠르게 계산할 수 있어 보입니다.
뫼비우스 반전 공식(mobius inversion foluma)는 양의 정수 집합을 정의역으로 하는 두 함수 $f,g$에 대해 아래 두 조건이 동치임을 알려줍니다. 이때 $\mu$는 뫼비우스 함수입니다.
$f(n) = 1/n$으로 두면 $g(n) = \sum_{d\vert n} \frac{\mu(n/d)}{d}$ 이고, 따라서 $f(n) = \frac{1}{n} = \sum_{d\vert n} g(d) = \sum_{d\vert n} \sum_{e\vert d} \frac{\mu(d/e)}{e}$ 가 성립합니다. $n$에 $GCD(A_v,A_i)$를 대입하면, $\frac{1}{GCD(A_v,A_i)}=\sum_{d\vert A_v,\ d\vert A_i} g(d)$로 나타낼 수 있습니다.
이를 이용해 점화식을 다시 작성해 봅시다.
$D(v) = A_v \sum_{i<v} A_iD(i) \times \sum_{d\vert A_v,\ d\vert A_i} g(d)$
시그마 순서를 적절히 조절하면
$D(v) = A_v\sum_{d\vert A_v}g(d)\times \sum_{i<v,\ d\vert A_i} A_iD(i)$
안쪽 시그마를 $S(d)$ 로 묶으면 $D(v) = A_v\sum_{d\vert A_v}g(d)S(d)$ 가 됩니다.
$\mu(\ast)$는 $O(N)$, $g(\ast)$는 $O(N \log N)$ 시간에 계산할 수 있습니다. $D(v)$ 하나는 $O(\sigma(A_v))$ 시간에 계산할 수 있고, $D(v)$의 값이 확정된 후에 $S(\ast)$를 $O(N/A_v)$ 시간에 갱신할 수 있습니다.
$A$는 길이가 $N$인 순열이므로 $\sum_{i=1}^{N} \sigma(A_i) = \sum_{i=1}^{N} \lfloor N/A_i \rfloor \in O(N \log N)$입니다. 따라서 전체 문제를 $O(N \log N)$ 시간에 해결할 수 있습니다.
// f[n] = 1/n
// g[n] = \sum_{d|n} mu[n/d]*f[d] = \sum_{d|n} mu[n/d]/d
// f[n] = \sum_{d|n} g[d]
int sp[MX+1], mu[MX+1];
vector<int> primes, factors[MX+1];
ll inv[MX+1], g[MX+1];
void Init(int sz=MX){
mu[1] = 1;
for(int i=2; i<=sz; i++){
if(!sp[i]){ primes.push_back(i); sp[i]= i; mu[i] = -1; }
for(auto j : primes){
if(i * j > sz) break;
sp[i*j] = j;
if(i % j == 0){ mu[i*j] = 0; break; }
mu[i*j] = mu[i] * mu[j];
}
}
inv[1] = 1;
for(int i=2; i<=sz; i++) inv[i] = MOD - MOD/i * inv[MOD%i] % MOD;
for(int d=1; d<=sz; d++) for(int n=d; n<=sz; n+=d) factors[n].push_back(d);
for(int d=1; d<=sz; d++) for(int n=d; n<=sz; n+=d) g[n] = (g[n] + mu[n/d] * inv[d] + MOD) % MOD;
}
ll N, A[MX+1], D[MX+1], S[MX+1];
void Solve(){
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
// D[v] = A[v] \sum_{d|A[v]} g[d] * S[d]
// S[d] = \sum_{d|A[i]} A[i] * D[i]
Init();
auto upd = [&](int i) -> void {
for(auto d : factors[A[i]]) S[d] = (S[d] + A[i] * D[i]) % MOD;
};
D[1] = 1; upd(1);
for(int v=2; v<=N; v++){
for(auto d : factors[A[v]]) D[v] = (D[v] + g[d] * S[d]) % MOD;
D[v] = A[v] * D[v] % MOD;
upd(v);
}
for(int i=2; i<=N; i++) cout << D[i] << "\n";
}
1-9 숫자로 구성된 길이가 $2^N$인 문자열 $S$가 주어진다. $0 \le n < 2^N$인 정수 $n$에 대해, $f(n)$을 다음과 같이 정의하자.
- $n\ \text{OR}\ i = n$인 $i$를 각각 $i_1 < i_2 < \cdots < i_k$라고 하자.
- $S$의 $(i_1+1)$번째 문자, $(i_2+1)$번째 문자, $\cdots$를 이어붙인 문자열을 $T$라고 하자.
- $T$를 십진법으로 해석한 값을 $X$라고 할 때, $f(X) = X \mod 998244353$
$\sum_{n=0}^{2^N-1}(f(n)\ \text{XOR}\ n)$을 구하는 문제
$1 \le N \le 22$
$n\ \text{OR}\ i = n$ 은 $i$가 $n$의 부분 집합(서브마스크)임을 의미합니다. $n$의 서브마스크를 직접 순회하면서 답을 계산하면, $0 \le n < 2^N$인 모든 $n$의 서브마스크 개수의 합은 $3^N$이므로 시간 복잡도 또한 $O(3^N)$이 됩니다. 이는 이항 정리를 이용해 증명할 수 있습니다.
구체적으로, 크기가 $k$인 집합은 $n \choose k$개 있고, 크기가 $k$인 집합의 부분 집합은 $2^k$개 있으므로 $\sum_{k=0}^{n} {n \choose k} \times 2^k$가 되는데, 이항 정리를 이용하면 $(1+2)^N = 3^N$이 됩니다.
void Naive(){
cin >> N >> S; K = 1 << N;
ll res = 0;
for(int bit=0; bit<(1<<N); bit++){
string t;
for(int sub=bit; sub; sub=(sub-1)&bit) t += S[sub];
t += S[0];
reverse(t.begin(), t.end());
ll now = 0;
for(auto c : t) now = (now * 10 + c - '0') % MOD;
res += now ^ bit;
}
cout << res << "\n";
}
하지만 $N \le 22$ 이므로 $3^N$보다 빠르게 해결해야 합니다. 이런 류의 문제는 보통 SOS DP 라는 테크닉을 이용해 해결할 수 있습니다.
각 마스크 $bit$에 대해, $bit$의 모든 서브마스크 $i$를 오름차순으로 보면서 $S_i$를 이어붙인 문자열 $T_{bit}$를 생각합시다. 모든 $bit$에 대해 $T_{bit}$를 직접 계산하는 것은 비효율적이기 때문에 문자열의 길이와, 문자열을 십진법으로 해석한 값을 998244353으로 나눈 나머지만 저장할 것입니다. 이 두 가지 정보만 갖고 있더라도 두 문자열 $a, b$를 이어붙이는 연산은 $a \times 10^{\vert b\vert} + b \mod 998244353$으로 상수 시간에 계산할 수 있습니다.
struct Info{
ll len, val;
Info() : Info(0, 0) {}
Info(ll len, ll val) : len(len), val(val) {}
};
Info operator + (const Info &a, const Info &b){
return {a.len + b.len, (a.val * P[b.len] + b.val) % MOD};
} // P[i] = 10^i mod 998244353
이제 $T_{bit}$의 정보 $D(bit)$를 계산하는 방법을 알아봅시다. 처음에는 $D(bit) = \lbrace 1, S_i\rbrace$에서 시작합니다. 이 상태에서 낮은 비트부터 하나씩 보면서, 해당 비트가 켜져 있는 마스크에 대해 $D(bit) = D(bit-2^i) + D(bit)$를 계산하면 우리가 원하던 $i$를 오름차순으로 보면서 $S_i$를 이어붙인 문자열을 얻을 수 있습니다.
즉, 다음과 같은 반복문을 이용해 우리가 원하는 정보를 얻을 수 있습니다.
for(int i=0; i<N; i++){
for(int bit=0; bit<(1<<N); bit++){
if(bit >> i & 1) D[bit] = D[bit^(1<<i)] + D[bit];
}
}
이 반복문이 어떻게 중복 없이 각 서브마스크를 정확히 한 번씩만 반영하는지 궁금할 수 있는데, 각 차원의 길이가 2인 $N$차원 누적 합 배열이라고 생각하면 어느 정도 받아들일 수 있을 것입니다. 더 자세한 내용이 궁금하다면 SOS DP 또는 Sum of Subset DP라는 키워드로 검색해 보면 많은 정보를 얻을 수 있을 것입니다.
ll N, K, P[(1<<22)+1];
string S;
Info D[1<<22];
void Solve(){
cin >> N >> S; K = 1 << N;
P[0] = 1; for(int i=1; i<=K; i++) P[i] = P[i-1] * 10 % MOD;
for(int i=0; i<K; i++) D[i] = {1, S[i] - '0'};
for(int i=0; i<N; i++) for(int bit=0; bit<K; bit++) if(bit >> i & 1) D[bit] = D[bit^(1<<i)] + D[bit];
ll res = 0;
for(int i=0; i<K; i++) res += D[i].val ^ i;
cout << res << "\n";
}
$N$가지 종류의 물건이 무한히 많이 있다. $i$번째 물건의 무게는 $i$, 가치는 $v_i$이다. 쿼리로 $W$가 주어지면, 무게의 합이 정확히 $W$가 되도록 물건을 선택했을 때 가능한 가치 합의 최댓값을 출력하는 문제
$1 \le N \le 4\,000$; $1 \le Q \le 2\times 10^5$; $1 \le W \le 10^9$
Subtask 1. $N \le 300$
무게의 합이 정확히 $w$가 되도록 물건을 선택했을 때 가능한 가치 합의 최댓값을 $D(w)$라고 정의합시다. 또한, 가성비가 가장 좋은 물건, 즉 $v_i / i$가 최대인 물건을 $k$라고 합시다. $w$가 충분히 크다면 $D(w) = D(w-k) + v_k$가 성립할 것입니다. 위 식이 성립할 $w$의 하한을 구해 봅시다.
$1 \le i \le N$에 대해 $v_k/k \ge v_i/i$가 성립하므로, $w \ge LCM(k,i)$ 라면 무게가 $i$인 물건을 $k/GCD(k,i)$개 사용하는 것보다 무게가 $k$인 물건을 $i/GCD(k,i)$개 사용하는 것이 항상 이득입니다. 따라서 $w \ge \max_i LCM(k,i)$ 라면 항상 $D(w) = D(w-k) + v_k$가 성립합니다. $1 \le i \le N$에 대해 $LCM(k,i) \le k\times i \le N^2$이 성립하므로 $w \ge N^2$ 이면 항상 해당 식이 성립한다는 것을 알 수 있습니다.
따라서 $w \le N^2$ 범위에서 DP를 이용해 $O(N^3)$ 시간에 정확한 답을 계산한 뒤, $N^2$보다 큰 $w$에 대해서는 $A_k\times \lceil \frac{w-N^2}{k} \rceil + D(w-k\times \lceil \frac{w-N^2}{k} \rceil)$를 계산해서 출력하면 됩니다.
void Solve(){
cin >> N >> Q;
for(int i=1; i<=N; i++) cin >> A[i];
memset(D, 0xc0, sizeof D); D[0] = 0;
for(int i=1; i<=N; i++) for(int j=i; j<=N*N; j++) D[j] = max(D[j], D[j-i] + A[i]);
ll k = 1;
for(int i=2; i<=N; i++) if(A[i] * k > A[k] * i) k = i;
for(int q=1; q<=Q; q++){
int w; cin >> w;
if(w <= N*N){ cout << D[w] << "\n"; continue; }
ll t = (w - N*N + k - 1) / k; // ceil (w-n^2) / k
cout << A[k] * t + D[w-t*k] << "\n";
}
}
Subtask 2. $N \le 4\,000$
Chan, He 의 “More on change-making and related problems” 논문(link)에서 Section 5. All-capacities unbounded knapsack 문단에 따르면, DP 전처리 과정을 $O(N^2 \log N^2)$에 할 수 있다고 합니다.
구체적으로, 모든 물건을 $v_i/w_i$ 내림차순으로 정렬한 뒤, 아래 점화식을 이용해 계산하면 됩니다. 증명은 생략합니다.
\[\displaystyle D(w) = \max_{1 \le i \le \lceil3n^2/w\rceil; w_i\le w} D(w-w_i)+v_i\]시간 복잡도는 $\sum_{k=1}^{N^2} \min(N, \frac{3N^2}{k}) \le \sum_{k=1}^N N + \sum_{k=N+1}^{N^2} \frac{3N^2}{k} \approx N^2 + 3N^2 \int_{N+1}^{N^2} \frac{1}{x} dx \in O(N^2 \log N)$입니다.
void Solve(){
cin >> N >> Q;
for(int i=1; i<=N; i++) cin >> A[i];
vector<int> O(N+1);
iota(O.begin(), O.end(), 0);
sort(O.begin()+1, O.end(), [](int x, int y){
return A[x] * y > A[y] * x;
});
ll k = O[1];
memset(D, 0xc0, sizeof D); D[0] = 0;
for(int w=1; w<=N*N; w++){
int limit = min(N, (3*N*N + w - 1) / w); // ceil(3n^2/w)
for(int i=1; i<=limit; i++){
if(O[i] <= w) D[w] = max(D[w], D[w-O[i]] + A[O[i]]);
}
}
for(int q=1; q<=Q; q++){
int w; cin >> w;
if(w <= N*N){ cout << D[w] << "\n"; continue; }
ll t = (w - N*N + k - 1) / k; // ceil (w-n^2) / k
cout << A[k] * t + D[w-t*k] << "\n";
}
}
어느새 2년 차(?) 개발자가 되어버린 한 직장인의 이야기입니다. 직장과 취미를 병행하는 게 쉽지 않음을 뼈저리게 느끼고 있습니다.
이 블로그는 2018년에 개설해서 벌써 8년째 운영하고 있습니다. 2021년까지는 정말 많은 애정을 갖고 관리했었는데, 점점 글을 올리는 것이 뜸해지고 있습니다. 연도별 포스트 개수는 다음과 같습니다.
| 2018 (고1) | 2019 (고2) | 2020 (고3) | 2021 (대1) | 2022 (대2) | 2023 (대3) | 2024 (대4) | 2025 |
|---|---|---|---|---|---|---|---|
| 160 | 390 | 193 | 99 | 14 | 82 | 9 | 4 |
SCPC와 ICPC 준비에 집중하느라 글을 작성할 시간이 없었던 2022년을 제외하면, PS에 대한 애정도와 글 개수가 비례하는 것 같습니다. 돌이켜보면 고등학생 때는 모든 게 처음이라 새로운 것을 배우고 정리해서 공유하는 것이 정말 즐거웠습니다. 물론 대학생이 된 후에도 공부하는 것은 여전히 즐거웠습니다. 다만 그 당시에 새롭게 접한 것들은 글로 적기에는 너무 사소한 테크닉인 경우가 많았습니다. 반대로 너무 복잡해서 완전히 이해하지 못한 채 구현체만 블랙박스로 가져다 쓰는 경우도 많아서 글을 올리지 못했습니다. PS만 10년 가까이 하다 보니 새로운 것을 접하는 일 자체가 줄기도 했고, 초창기에 비해 블로그가 너무 유명해진(?) 탓에 완성도가 높은 글만 올려야 한다는 부담감도 한몫했습니다.
올해는 노선을 바꿔서, 다른 곳에서 보기 어려운 글을 작성해 보자는 생각을 했습니다. 예를 들면 LGCPC나 NYPC처럼 대회 공식 풀이가 없거나 빈약한 대회의 풀이를 적는 것, 또는 규모가 큰 알고리즘 대회의 운영 후기를 적는 것 등이 있습니다. 올해 NYPC에서 도저히 풀기 싫은 두 문제를 제외한 모든 문제의 풀이(예선, 본선)를 올린 것과, Hello BOJ 2025의 개최 후기(링크)를 매우 자세하게 적은 것 모두 그러한 노력의 일환이었습니다. LGCPC 풀이와 KOI/ICPC 후기도 작성해 보려고 했지만, LGCPC는 실력과 시간의 부족 때문에, KOI와 ICPC는 후기를 적을 만큼 많은 역할을 맡지 못해서 적지 못했습니다. 그래도 이 글에서 조금이나마 풀어보려고 합니다.
내년에는 어떤 전략을 갖고 글감을 찾아야 할까요? 회사에서 사용하는 기술에 대해 공부한 기록을 올릴 수도 있고, 예전처럼 문제 풀이를 올리는 방법도 있습니다. 의도한 것은 아니지만 이 블로그가 저를 설명하는 가장 큰 아이덴티티라는 것은 부정할 수 없어서, 내년에는 블로그를 살리기 위해 노력하려고 합니다.
PS 라이브러리 만들기
라이브러리를 쉽게 테스트하고 배포할 수 있는 툴을 만든 뒤에 지쳐서 정작 해야 할 일을 하지 못했습니다. 이미 팀노트에 코드가 있으니 배포 프로세스만 만들면 금방 끝나리라 판단해 우선순위를 정했습니다. 하지만 4년에 걸쳐 작성한 코드를 하나의 스타일로 통일하는 작업이 필요하다는 점을 간과했습니다. 토이 프로젝트 할 때 MSA를 하겠다면서 각종 인프라 세팅부터 끝내놓고 시작하다가 퍼지는 게 이런 느낌일까요? 아무튼 하나라도 깨달은 것이 있다는 점에서 (결과와 별개로) 완전히 망한 프로젝트는 아닌 것 같습니다.
ICPC 출제
올해 서울 리저널에 두 문제를 출제했습니다. 자세한 이야기는 뒤에서 이어집니다.
효율적으로 일하기
1년 전에 비해 많이 나아졌지만, 아직 개선할 여지가 많습니다. 제 머리의 concurrency를 늘리는 것과 스케줄링을 효율적으로 하는 것 모두 개선이 필요합니다. 회사 특성상 쉽지는 않지만, 생활 패턴을 규칙적으로 만드는 것도 중요한 목표 중 하나입니다. 일단 이 마크다운 파일의 생성 시각이 12월 15일 오전 4시 9분인 것부터 문제가 있어 보입니다.
올해는 한국에서 열린 메이저 PS 대회 중 SCPC를 제외한 모든 대회의 운영에 관여했습니다. 정말 엄청난 오지랖꾼입니다.
제가 출제한 문제에 대한 몇 가지 코멘트를 덧붙이자면…
2025 ICPC 서울 H. Fair Problemset 문제는 예전에 ICPC 준비할 때 팀 연습을 하면서 떠올린 소재입니다. 3명이 문제를 나눠서 읽다 보면 어느 한 명만 너무 어려운 문제를 몰아서 받게 되는 경우가 종종 있는데, 이런 일을 방지하기 위한 문제 배치를 고민하다가 만들게 되었습니다. 지금까지 한국 ICPC 대회에 나오지 않던 유형이라 많은 사람이 당황했으리라 생각합니다. 2021년 서울 리저널에 출제된 Squid Game을 제외하면 한국 ICPC에 어려운 수학 문제가 나온 적이 없었고, 그에 따라 한국에서 ICPC를 준비하는 팀들은 정수론이나 조합론을 잘 준비하지 않는 경향이 있습니다. 돌이켜 보면 제가 APAC나 WF에서 더 좋은 성적을 거두지 못했던 결정적인 이유는 매번 수학이었습니다. 다른 팀들은 저와 같은 후회를 하지 않길 바라는 마음으로 이번 ICPC에 수학 문제를 출제했습니다. 어차피 한 대 맞을 거라면 서울 리저널에서 맞아서 정신 차리고 APAC나 WF에서 좋은 성적을 거두는 게 좋을 테니까요. 수학 문제 때문에 리저널 챔피언을 12년 만에 해외 팀에 빼앗길까 봐 걱정을 조금 했지만, 다행히 서울대학교가 올해도 성공적으로 외침을 방어했습니다.
2025 ICPC 서울 M. Triple Fairness는 H번에서 다루는 수열 하나를 직접 구성하는 문제입니다. 모든 팀이 풀 수 있을 정도로 매우 쉬운 문제를 의도했고, 실제로 한 팀을 제외한 모든 팀이 이 문제를 해결했습니다. 사실 N이 3의 배수일 때도 어렵지 않게 풀 수 있기 때문에 저는 그 방향을 더 선호했습니다. 하지만 대회는 출제자의 자아실현보다 문제 세트의 전체적인 밸런스가 더 중요하기 때문에 지금의 제한으로 출제하게 되었습니다.
2025 LGCPC 예선 A. 서브태스크 점수 문제는 지문을 읽으면 알 수 있겠지만, 제가 필요해서 만든 문제입니다. 서브태스크 점수가 주어지는 대회는 서로 해결한 서브태스크 집합이 다른 두 참가자의 점수가 항상 다르게 만드는 것이 여러모로 좋습니다. 그래서 원래는 최소한의 점수 수정으로 동점이 발생하지 않도록 만드는 문제를 만들려고 했지만, 너무 어려워서 현재의 상태가 되었습니다. 한 문제 안에서 비트 마스크를 이용한 완전 탐색, 위상 정렬, 냅색 DP를 모두 구현해야 하므로, 알고리즘 대회보다는 코딩 테스트에 더 적합한 유형이라고 생각합니다.
2025 서울사이버대 교내 대회 J. 대충 그래프 개수 세는 문제 는 connected induced subgraph의 개수를 쉽게 셀 수 있는 그래프 클래스에 대해 고민하다가 만들게 되었습니다. biconnected, chordal, bounded treewidth 등 여러 클래스를 고민했지만, 일부 클래스는 풀이를 찾지 못해서, 또 다른 클래스는 풀이가 너무 복잡해서 결국 interval graph에서 개수를 세는 문제로 출제하게 되었습니다. interval graph로 확정한 뒤에도 incremental, decremental, $i$번째 구간만 제거했을 때의 정답을 구하는 쿼리, 쿼리로 주어진 구간까지 $N+1$개의 구간에서의 정답을 구하는 쿼리 등 여러 변형을 고민해 봤지만 효율적인 풀이를 찾지 못해서 쿼리 없이 문제를 냈습니다.
3달 동안 준비한 Good Bye, BOJ 2025! 도 성공적으로 마무리되었습니다. 오랜 시간 애정을 갖고 만들어 온 대회인 만큼 하고 싶은 이야기가 정말 많은데, 아직 생각을 완전히 정리하지 못해서 다른 글에서 다시 이야기해 보려고 합니다. 결론만 미리 말하자면 지금과 같은 형식의 대회는 아마 더 이상 열리지 않을 것 같습니다.
올해 PS를 하면서 가장 많은 노력을 기울인 프로젝트는 계절학교 강의자료를 다시 만드는 것이었습니다. 사실 처음반 강의 리뉴얼은 몇 년 전부터 “하면 좋다” 정도의 이야기가 계속 나왔었습니다. 하지만 계절학교 수준의 강의자료를 3시간 * 16강 분량으로 만드는 건 매우 힘든 일이고, 주도적으로 드라이브를 거는 사람도 없었기 때문에 아무 일도 일어나지 않았습니다.
그러던 와중 2024년 8월에 3주 동안 KAIST에서 국가대표 합숙 교육이 진행되었고, 오랜만에 많은 코치가 모인 김에 본격적으로 논의를 시작했습니다. 커리큘럼은 저(jhnah917)와 구재현(koosaga), 김준겸(ryute) 코치가 주축이 되어 구성했습니다.
다행히 제가 예전 계절학교 자료를 모두 보관하고 있었고, 지난 몇 년간 동아리 활동을 하며 만들어둔 강의자료(ssu-sccc-study)도 있었습니다. 이를 바탕으로 제가 주도하여 자료를 제작했고, 다른 코치분들이 검수와 제가 익숙하지 않은 분야의 자료 제작을 맡아주었습니다.
긴 노력 끝에 올해 8월에 진행된 여름학교에 처음으로 새로운 강의자료가 사용되었으며, 아직 고쳐야 할 점이 여럿 있긴 하지만 반응은 꽤 좋았습니다. 사실 처음반을 저처럼 두 번씩 듣는 사람이 흔하지 않기 때문에, 학생들의 후기보다는 코치들과 실제로 강의를 진행하시는 교수님들의 후기를 주로 들었습니다. 학생들은 어떻게 느꼈는지 잘 모르겠습니다. 아직 공개되지 않은 자료는 돌아오는 1월부터 진행되는 겨울학교 강의에서 사용될 예정입니다. 여름학교보다 훨씬 재미있는 내용으로 알차게 구성했으니까 학생들이 재미있게 즐겨주면 좋겠습니다.
올해 8월에 진행된 여름학교는 코로나 이후로 5년 반 만에 오프라인으로 진행되었습니다. 2020년 1월 17일에 계절학교 끝나고 세상 밖으로 나왔더니 지구가 망해있어서 당황했던 게 엊그제 같은데, 벌써 6년 전 일입니다. 아무튼, 정말 오랜만에 오프라인으로 진행된다는 소식을 들은 저는 무려 일주일 동안 휴가를 내고(!!) 계절학교 코치를 하러 용인에 내려갔습니다. 여기에 말할 수 없는 우여곡절이 많았지만, 오랜만에 반가운 얼굴도 보고, 다음 세대를 이끌어가기 위해 열심히 공부하는 학생들도 보면서 5년 만에 계절학교의 열기를 느끼고 왔습니다. 코로나 이전처럼 학생들 전자기기를 압수할 수 없어서 아쉬웠지만, 그래도 일과가 끝난 후에 숙소에 돌아가서까지 문제를 토론하는 학생들이 참 기특하고 ‘나도 한때 저랬었지…’ 하는 생각에 잠기기도 했습니다. 물론 코치 속 썩이는 학생들을 보면서도 ‘나도 한때 저랬었지…’ 라는 생각을 했습니다.
당연히 PS를 하면서 매번 행복한 일만 있지는 않았지만, 힘든 일이 있을 때마다 계절학교에서 친구/코치님들과 열심히 즐겁게 공부했던 기억이 항상 든든한 버팀목이 되어주었습니다. 알고리즘 공부 외에도 계절학교에서 알게 된 친구들 덕분에 도움받은 일도 많기도 하고요. 이번 기수 학생들도 계절학교에서 열심히 공부했던 기억과 소중한 인연을 꼭 간직했으면 좋겠습니다.
이제는 학생 신분이 아니기 때문에 나갈 수 있는 대회가 별로 없습니다. 드디어 현대모비스 알고리즘 경진대회에서 일반부로 꿀 좀 빨아먹나 했더니 대회가 없어졌고, NYPC 코드배틀은 2002년생이라 참가 자격은 있었지만 NYPC 출제위원이라 참가하지 못했습니다.
하지만 2022년을 끝으로 제가 화나서 없애버린(…) 숭고한 연합 알고리즘 대회가 예상치 못하게 3년 만에 돌아왔고, 1년 이내 졸업생까지 참가할 수 있다고 해서 오주원(kyo20111) 선배와 함께 신나게 달려갔습니다. 다른 사람을 구해 3인팀으로 나가는 것도 고민했지만, 3인팀 1등 상금과 2인팀 3등 상금이 같다는 계산이 서서 2인팀 참가를 결정했습니다. 다른 팀들도 같은 이유였는지는 모르겠지만 2인팀이 많았던 것으로 기억합니다.
팀 이름은 PS AGI로 지었습니다. ICPC 때의 팀명인 PS akgwi를 이어받으면서도, 아기(baby)와 AGI(강인공지능)이라는 상반되는 두 단어를 중의적으로 포함하는 이름이었습니다. 두 명 모두 대학생 때보다는 실력이 감소했기에 팀원 이름은 초창기 언어 모델의 일종인 GPT-1과 Word2Vec 정하는 약간의 유머도 더했지만, 스코어보드에는 팀원 이름이 표시되지 않아 아쉬웠습니다. 대회 중에 약간의 우여곡절이 있었지만 다행히 월파 2회 진출자라는 타이틀에 먹칠을 하지 않고 1등을 했습니다.
6월에는 제14회 스마트테크 코리아(STK 2025, link)의 부대 행사로 열린 Cybersec Algo Wars 라는 대회에 참가했습니다. 대회 목적에 ‘보안 코딩’ 같은 문구가 있어 불안했지만, 공고를 자세히 보니 프로그래머스 플랫폼에서 진행된다는 점을 확인했습니다. 프로그래머스라면 순수 알고리즘 대회일 가능성이 높다고 판단해 일단 신청했습니다. 평일 점심시간에 열린 대회라 그런지 아니면 홍보가 제대로 되지 않은 건지 모르겠지만, 아무튼 저처럼 알고리즘 공부를 많이 한 사람은 보이지 않았고, 당연히(?) 1등 해서 상금 100만 원을 받았습니다.
이렇게 올해 참가한 모든 대회에서 빈집 털이 1등 했다는 타이틀을 지키기 위해 다른 대회에 더 나가지 않을 생각이었으나, 페이스북 해커컵이 올해도 열린다고 해서 티셔츠를 받기 위해 참가했습니다. 600등 정도 해서 아쉽게도 R3에는 진출하지 못했지만 티셔츠는 받을 수 있게 되었습니다. 200등대에 몰려있는 인도 국적의 치터를 모두 자르면 500등 안에 들어서 R3 진출할 수 있었을 텐데, 페이스북의 대처가 아쉬웠습니다. 이제는 제가 가장 자신 있는 분야에서도 LLM을 이길 수 없는 시기가 온 것 같습니다.
가끔씩 저에게 PS를 하는 사람의 입장에서 AI 모델의 발전에 대해 어떻게 생각하는지 물어보시는 분들이 있습니다. 이런 질문에 대한 제 생각을 짧게 작성해 보려고 합니다.
일단 제 취미 생활이 망가지고 있다는 생각이 가장 먼저 듭니다. 1-2년 전부터는 대회를 여는 게 PS에서 가장 재미있는 요소가 되었는데, 올해 여러 대회를 운영해 보면서 온라인 대회는 유지하기 어려울 수 있겠다고 생각하고 있습니다. 코로나 시절의 SCPC나 KOI 처럼 감독하지 않는 이상 쉽지 않아 보입니다.
그럼에도 불구하고 공부하는 입장에서는 좋은 도구가 생겼다고 생각합니다. AI 엔진이 나온 이후로 체스와 바둑을 공부하는 사람들이 AI를 이용해 공부하듯이, PS도 그렇게 공부할 수 있으리라 생각합니다. 풀이를 찾을 수 없는 플래티넘 정도의 문제는 GPT 5나 Gemini 3에 넣으면 풀이를 꽤 잘 알려준다는 점에서, 공부할 때 많은 도움이 될 수 있어 보입니다. 다만 LLM 모델이 잘하는 것과 하지 못하는 것을 구분하지 못한다면, 오히려 더 나쁜 방향으로 빠질 수 있기 때문에 항상 조심해야 합니다.
사실 작년에 o1이 나왔을 때만 하더라도 세상 사람들이 떠드는 것에 비해 별로 똑똑하지 않아서 아직 한참 남았다고 생각했었는데, 제가 생각했던 것보다 훨씬 빠르게 발전해서 당황하기도 했습니다. 하지만 그 과정에서도 얻은 게 있었습니다. 한동안 OpenAI가 자사 모델을 홍보할 때 코드포스 레이팅을 사용했었는데, 제가 잘 아는 메트릭이다 보니 얼마나 과장해서 발표하는지를 알 수 있었고, 덕분에 벤치마크를 곧이곧대로 믿으면 안 된다는 교훈 하나는 확실히 얻었습니다. LLM의 학습 방식을 이해한 뒤 그 성능을 다시 살펴보며, 제 공부 방법이 틀리지 않았다는 확신(혹은 위안)을 얻기도 했습니다.
직장인인데 회사 이야기가 없는 게 참 아이러니합니다. 네이버에서 인턴했을 때는 모든 오픈소스에 기여하는 것이 주요 업무였기에 블로그에 부담 없이 올릴 수 있었는데, 지금은 그렇지 않아서 회사 이야기를 적는 게 부담스럽습니다. 아마 앞으로도 계속 안 적을 것 같습니다.
작년에는 글을 마무리하면서 앞으로 어떤 일이 펼쳐질지 기대가 되면서도 두렵다고 했었습니다. 회사에 좋은 분들이 많아서 그런지 직장인도 생각보다는(?) 할 만한 것 같습니다. 올해 강연할 기회가 몇 번 있어서 제가 지금까지 어떻게 공부했는지를 돌아보는 시간을 가졌었는데, 이 과정에서 회사가 저의 어떤 면을 보고 뽑았는지를 조금이나마 이해할 알 수 있게 되었고, 회사에서도 그 강점을 살리기 위해 노력해야겠다는 다짐도 했습니다. 체력 관리만 잘 되면 참 좋을 텐데… 추석 이후로 여러 가지 일이 겹쳐서 운동을 잠시 쉬고 있는데, 내년에는 다시 운동을 시작해야겠습니다.
내년에는 올해보다 더 건강하고 튼튼한 사람이 되어서 돌아오겠습니다.
끝!
]]>2019년 5월 25일에 “BOJ 1000솔브!”라는 글(link)을 블로그에 올렸던 적이 있습니다. 그로부터 정확히 6년 7개월이 지난 오늘(2025년 12월 25일), solved.ac 기준 티어가 다이아몬드인 문제만 1000문제를 풀게 되었습니다. 감회가 참 새롭습니다. 고등학생 때 블로그에 올린 여러 글을 읽으면서 ‘그땐 그랬지…’ 같은 생각이 들기도 하고, “이번에는 꼭 KOI 본선에 나가야지” 라며 열심히 공부하던 2019년 5월의 마음가짐도 어렴풋이나마 떠오릅니다.

2018년 선린 정보올림피아드반 OT 때 한 선배가 “아무리 똑똑한 사람이더라도 1000문제도 안 푼 사람은 거의 없으니 1000문제 풀고 복권 긁어봐라. 혹시 당첨일지 어떻게 아느냐?”와 같은 이야기를 했었습니다. 지금 와서 돌아보면 다행히 저도 당첨이었던 것 같습니다.
사실 저는 그 슬라이드에서 다룬 대상인 “재능충”과는 거리가 멀었습니다. 재능을 구성하는 요소를 어떻게 정의하는지에 따라 다르겠지만, 최소한 저와 비슷한 위치에서 경쟁하던 사람들만큼 머리가 뛰어나지는 않았습니다. BOJ에서 2000문제 정도 풀었을 때부터 “저 사람이 나 만큼 문제를 풀면 나보다 잘하겠지?” 라는 상상(고민?)을 자주 했었습니다. 확실히 아니라고 확신할 수 있는 사람이 지금까지 한 명도 없었지만, 대회에서는 결국 꽤 좋은 성적을 거두었던 것을 보면, 머리 외에도 공부량이 꽤 중요하게 작용하지 않았나 싶습니다. 그래도 남들보다 비교적 일찍 시작했고, 고등학생 때 알고리즘을 공부하기에 꽤 좋은 환경이었으며, 매번 운과 타이밍이 따라준 덕분에 여기까지 올 수 있었습니다.
그때는 1000번째 문제에 큰 의미를 부여하면서 그 당시에 여러모로 가장 유명(?)했던 문제인 하이퍼 토마토(link)를 풀었습니다. 지금 생각해 보면 구데기컵이 딱 2회까지만 열린 시점에 1000번째 문제를 풀었다는 게 참 다행인 것 같습니다. 2020년 이후에 구데기컵에 나온 문제들을 생각해 보면…
하이퍼 토마토를 처음 풀 때 작성한 코드(link)와 최근에 작성한 코드(link)를 비교해 보면 여러모로 많이 발전한 게 느껴집니다. 별다른 알고리즘이 필요 없는 구현 문제라고 하더라도, 있는 그대로 구현하지 않고 나름의 “설계”를 할 수 있게 되었습니다. 6년 전에 쓰던 fastio 코드는 어디에서 가져온 것이었을까요? 지금은 fastio 조차도 더 간결한 구현을 사용하고 있습니다.
그때는 1페이지 진입이 저에게 있어서 중요한 마일스톤 중 하나였고, 여름학교 입소식이 있었던 2019년 7월 29일 새벽에 101등과 1문제 차이로 100등을 달성했던 것으로 기억합니다. 2019년 6월에 solved.ac 가 만들어진 이후로 1페이지 커트라인이 그때와는 비교가 안 될 정도로 많이 올라서 지금은 4160문제를 풀어야 하는데, 이것도 타이밍이 참 좋았던 것 같습니다.
이번에도 1000번째 다이아몬드 문제에 의미를 부여하기 위해 어떤 문제를 풀지 고민했는데, 아무래도 올해 서울 리저널에 출제한 Fair Problemset(link)보다 더 적합한 문제를 찾기는 어려울 것 같아서 몇 달 전에 작성해 놓은 코드를 그대로 제출했습니다. 고등학생 때 가장 많은 시간을 들여서 준비했던 KOI, NYPC, 계절학교에 고등학교 졸업 후에도 계속 관여하고 있는 것처럼, 대학생 때 가장 열심히 준비했던 ICPC에도 졸업 후에 어떻게든 기여하고 싶다는 생각을 꽤 오래 했었습니다. 운이 좋게도 졸업 직후에 연이 닿아서 문제를 출제하게 되었는데, 앞으로도 꾸준히 문제를 낼 수 있으면 좋겠습니다.
BOJ 1000솔브 글 외에 고등학생 때 작성한 다른 글도 조금 읽어보았습니다. 글에 적혀있지는 않지만, 그때 어떤 마음가짐으로 어떻게 공부했는지가 새록새록 떠오릅니다. 사실 고등학생 때는 제가 열심히 공부했다고 생각하지 않았습니다. 아무리 특성화고등학교를 다녔다고 해도, 대한민국에서 고등학생으로 살아간다면 여러 요인으로 인해 “열심히”의 기준이 학교 공부가 되기 마련입니다. 전 그저 문제를 푸는 게 재미있었고, 이것만 해도 서울에 있는 대학교에 진학하는 데에는 문제가 없을 것 같아서 게임 대신 한다는 생각으로 문제를 풀었습니다. 처음으로 제가 열심히 공부했다는 것을 자각했을 때는 대학교 3학년 정도였습니다. 이렇게 될 줄 알았으면 고등학생 때 스트레스 좀 덜 받으면서 문제를 풀 걸 그랬습니다.
옛날 생각하다 보니 글이 길어졌습니다. 고등학생 때 작성한 글 쭉 둘러보니까 너무 어린 티가 많이 나서 밀어버릴지 잠시 고민했지만, 그래도 행복했던 추억이기도 하고 누군가에게는 도움이 될 것 같아서 남겨두려고 합니다. 사실 쓰고 싶은 내용이 더 있었는데, 이건 곧 작성할 2025년 회고에 마저 작성하겠습니다.
언제까지 문제를 풀고 PS 커뮤니티에 남아있을지는 모르겠지만, 이 취미를 유지하는 동안에는 계속 꾸준히 해 보려고 합니다.
]]>문제 지문과 공식 풀이는 NYPC 아카이브에서 확인할 수 있습니다. 채점은 BIKO에서 받을 수 있습니다.
제가 생각하는 난이도(solved.ac 기준)는 다음과 같습니다. 1214 부문 3번(돌 무더기)과 5번(마방진 만들기) 문제는 사람마다 편차가 클 것 같습니다. 1519 5번(편집 거리) 문제 사실상 두 가지 문제가 합쳐진 문제이므로 따로 구분해서 매겼습니다.
1214 2번 = 1519 1번, 1214 4번 = 1519 2번입니다.
PC 화면으로 보는 분들은 우측 사이드바를 이용해 원하는 문제로 빠르게 이동할 수 있습니다.
문자열을 앞에서부터 차례대로 보면서, 뺄셈 기호의 수가 덧셈 기호의 수보다 커질 때마다 뺄셈 기호 하나를 덧셈 기호로 바꾸는 전략이 성립합니다.
#include <bits/stdc++.h>
using namespace std;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int N, X=0, R=0; string S; cin >> N >> S;
for(auto c : S){
X += c == '+' ? 1 : -1;
if(X < 0) R++, X += 2;
}
cout << R;
}
그래프를 적절히 구성한 뒤, BFS/DFS 등을 이용해 컴포넌트의 크기를 구하면 됩니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 72;
string S = "RGBY";
int f(int i, int j, int k){ return i * 12 + j * 2 + k; }
int sq(int x){ return x * x; }
int A[N], B[N], C[N];
vector<int> G[N];
void AddEdge(int u, int v){ G[u].push_back(v); G[v].push_back(u); }
int DFS(int v, int *arr, int chk){
int res = 1; C[v] = chk;
for(auto i : G[v]) if(arr[v] == arr[i] && C[i] != chk) res += DFS(i, arr, chk);
return res;
}
void Solve(){
memset(A, -1, sizeof A);
memset(B, -1, sizeof B);
memset(C, 0, sizeof C);
int n; cin >> n;
for(int i=1; i<=n; i++){
string a, b; cin >> a >> b;
int pos = f(a[0]-'a', a[1]-'1', a[2]=='+');
A[pos] = b[1] - '1'; B[pos] = S.find(b[0]);
}
int x = 0, y = 0;
for(int i=0; i<72; i++) if(A[i] != -1 && C[i] != 1) x += sq(DFS(i, A, 1));
for(int i=0; i<72; i++) if(B[i] != -1 && C[i] != 2) y += sq(DFS(i, B, 2));
cout << x << " " << y << "\n";
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int TC; cin >> TC;
for(int i=0; i<6; i++) for(int j=0; j<6; j++) AddEdge(f(i,j,0), f(i,j,1));
for(int i=1; i<6; i++) for(int j=0; j<6; j++) AddEdge(f(i,j,0), f(i-1,j,1));
for(int i=0; i<6; i++) for(int j=1; j<6; j++) AddEdge(f(i,j,0), f(i,j-1,1));
for(int tc=1; tc<=TC; tc++) Solve();
}
가장 먼저 떠오르는 풀이는 각 무더기에 있는 돌의 개수를 인자로 하는 동적 계획법입니다. 아래와 같이 구현하면 $N = \max(a,b,c)$일 때 상태 공간의 크기가 $O(N^3)$이고, 각 상태의 답을 $O(N)$에 구하는 $O(N^4)$ 풀이를 얻을 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
int D[111][111][111];
int f(array<int,3> a){
sort(a.begin(), a.end());
int &res = D[a[0]][a[1]][a[2]];
if(res != -1) return res;
if(a[2] == 0) return res = 0;
res = 0;
for(int take=0; take<3; take++){
for(int split=0; split<3; split++){
if(take == split) continue;
for(int i=1; i<a[split]; i++){
auto nxt = a;
nxt[take] = i; nxt[split] -= i;
if(!f(nxt)) res = 1;
}
}
}
return res;
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int TC; cin >> TC;
memset(D, -1, sizeof D);
for(int tc=1; tc<=TC; tc++){
int a, b, c; cin >> a >> b >> c;
cout << (f({a, b, c}) ? "first" : "second") << "\n";
}
}
위 풀이를 이용해 작은 범위에서 답을 구한 뒤, $D(a, b, c) = 0$인 $(a, b, c)$ 튜플을 모두 출력해놓고 열심히 쳐다보면 규칙을 찾을 수 있습니다. $g(x)$를 $x$에 2가 곱해진 횟수라고 정의합시다. $D(a, b, c) = 0$일 필요충분조건은 $g(a) = g(b) = g(c)$임을 관찰하면 문제를 해결할 수 있습니다.
$g(a) = g(b) = g(c)$인 위치가 패배하는 위치라는 것은 다음과 같이 증명할 수 있습니다.
돌이 $x$개 있는 더미가 있고, $g(x) = t$라고 합시다. $0 \le s < t$인 모든 정수 $s$에 대해, $g(y) = g(z) = s$가 성립하도록 크기가 $x$인 더미를 크기가 $y=2^s$인 더미와 $z=x-2^s$인 더미인 나눌 수 있습니다. 반대로, $s \ge t$이면 $g(y) = g(z) = s$가 되도록 더미를 나눌 수 없습니다.
즉, 세 더미의 $g(a) = g(b) = g(c)$라면 플레이어가 어떤 행동을 하더라도 $\min(g(a), g(b), g(c))$가 감소할 수밖에 없습니다. 반대로, 세 더미의 $g$값 중 하나라도 다르다면, 플레이어는 항상 세 더미의 $g$값이 같아지도록 만들 수 있습니다. 또한, 더이상 행동을 할 수 없는 상태($a,b,c \le 1$)는 $g(a) = g(b) = g(c) = 0$입니다.
따라서 세 더미의 $g$값이 같은 상태로 게임을 시작한다면, 후공이 매번 세 더미의 $g$값이 같은 상태를 되돌려주므로 선공이 질 수밖에 없습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int TC; cin >> TC;
for(int tc=1; tc<=TC; tc++){
ll a, b, c; cin >> a >> b >> c;
a = __builtin_ctzll(a);
b = __builtin_ctzll(b);
c = __builtin_ctzll(c);
cout << (a == b && b == c ? "second" : "first") << "\n";
}
}
$v$를 루트로 하는 서브트리의 각 정점에 개미를 정확히 한 마리씩 배치하고 남은 개미의 수를 $S[v]$라고 정의합시다. 이는 서브트리에 속한 정점들의 $A[i] - 1$ 값을 더하면 구할 수 있고, 개미가 부족하면 $S[v]$는 음수가 됩니다.
각 간선을 따라 이동하는 개미의 수에 집중합시다. 편의상 $v$의 부모 정점을 $p(v)$라고 부를 것입니다.
만약 $S = N$이라면 모든 정점에 정확히 한 마리의 개미가 배정되어야 합니다. 따라서 $S[v] > 0$인 서브트리에서는 $v$에서 $p(v)$로 $S[v]$마리의 개미가 이동해야 합니다. 반대로, $S[v] < 0$인 서브트리에서는 $p(v)$에서 $v$로 $-S[v]$마리의 개미가 이동해야 합니다. 따라서 $S = N$이면 정답은 $\sum \vert S[v] \vert$입니다.
$S = N+1$이라면 정확히 한 정점에만 두 마리의 개미가 들어가야 합니다. 개미가 두 마리 들어가는 정점을 $x$라고 하면, 간선 $(p(v), v)$ 를 통과하는 개미의 수는 다음과 같이 계산할 수 있습니다.
즉, 루트에서 $x$로 가는 경로 위의 간선을 통과하는 개미의 수만 바뀌고, 다른 간선들의 값은 바뀌지 않습니다. 따라서 개미 한 마리를 $x$로 보내는 비용은 $D(x) = D(p(x)) + \vert S[x] - 1 \vert - \vert S[x] \vert$을 이용해 계산할 수 있습니다. 비용을 최소화하는 것이 목적으므로, 문제의 정답은 $\sum \vert S_v \vert + \min D_v$입니다.
마지막으로 $S = N+2$인 상황을 생각합시다. 정확히 두 정점에 두 마리의 개미가 들어가야 합니다. 개미가 두 마리 들어가는 두 정점을 각각 $x, y$라고 하고, $x$와 $y$의 LCA를 $l$이라고 하면, 간선 $(p(v), v)$를 통과하는 개미의 수는 다음과 같이 계산할 수 있습니다.
$f(v) = \vert S[v] - 1 \vert - \vert S[v] \vert$, $g(v) = \vert S[v] - 2 \vert - \vert S[v] \vert$라고 정의하고, $D_f(x)$를 루트부터 $x$까지 $f$의 합, $D_g(x)$를 루트부터 $x$까지 $g$의 합이라고 합시다. 개미가 두 마리 들어갈 정점을 각각 $x, y$라고 하면, 비용은 $\sum \vert S[v] \vert$에서 $D_f(x) + D_f(y) - 2D_f(l) + D_g(l)$ 만큼 증가합니다. 따라서 이 식의 결과로 가능한 최솟값을 구하면 문제를 해결할 수 있습니다. 이는 트리 DP를 이용해 트리의 지름을 구하는 것처럼, 두 정점의 LCA를 고정한 다음 가장 작은 두 자식 정점의 값을 가져오는 방식으로 $O(N)$ 시간에 계산할 수 있습니다.
따라서 $S = N, N+1, N+2$인 문제를 모두 $O(N)$ 시간에 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr ll INF = 0x3f3f3f3f3f3f3f3f;
ll N, A[505050], S[505050], Df[505050], Dg[505050], T;
vector<int> G[505050];
void DFS0(int v, int b=-1){
S[v] = A[v] - 1;
for(auto i : G[v]) if(i != b) DFS0(i, v), S[v] += S[i];
}
void DFS1(int v, int b=-1){
Df[v] = (b != -1 ? Df[b] : 0) + abs(S[v]-1) - abs(S[v]);
Dg[v] = (b != -1 ? Dg[b] : 0) + abs(S[v]-2) - abs(S[v]);
for(auto i : G[v]) if(i != b) DFS1(i, v);
}
ll DFS2(ll &res, int v, int b=-1){
ll mn1 = Df[v], mn2 = INF;
for(auto i : G[v]){
if(i == b) continue;
ll now = DFS2(res, i, v);
if(now < mn1) mn2 = mn1, mn1 = now;
else if(now < mn2) mn2 = now;
}
res = min(res, mn1 + mn2 - 2*Df[v] + Dg[v]);
return mn1;
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1,u,v; i<N; i++) cin >> u >> v, G[u].push_back(v), G[v].push_back(u);
T = accumulate(A+1, A+N+1, 0LL);
assert(N <= T && T <= N + 2);
DFS0(1);
ll move = accumulate(S+1, S+N+1, 0LL, [](ll a, ll b){ return a + abs(b); });
if(T == N){ cout << move; return 0; }
DFS1(1);
ll mn = *min_element(Df+1, Df+N+1);
if(T == N + 1){ cout << move + mn; return 0; }
ll pick_two = INF;
DFS2(pick_two, 1);
if(T == N + 2){ cout << move + pick_two; return 0; }
assert(false);
}
$N \le 3$일 때는 가능한 $O(N^2!)$가지 조합을 시도하면 되므로 $N = 4$인 경우만 생각해도 충분합니다.
다항 시간에 풀 수 없어보이므로 가지치기를 동반한 완전 탐색을 시도해야 할 텐데, 잘 생각해 보면 탐색하지 않고도 값을 확정지을 수 있는 칸이 존재한다는 것을 알 수 있습니다. 구체적으로,
따라서 16개의 칸 중 8칸의 값만 정하면 다른 8칸의 값은 탐색할 필요가 없습니다. 따라서 탐색해야 하는 경우의 수를 $16!/8! = 518\,918\,400$ 으로 줄일 수 있고, 적절한 가지치기를 동반해 제한 시간 안에 문제를 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int INF = 0x3f3f3f3f;
int N, A[16], B[4][4], C[111], R, S;
bool Check(){
for(int i=0; i<N; i++){
int x = 0, y = 0;
for(int j=0; j<N; j++) x += B[i][j];
for(int j=0; j<N; j++) y += B[j][i];
if(x != S || y != S) return false;
}
int x = 0, y = 0;
for(int i=0; i<N; i++) x += B[i][i], y += B[i][N-i-1];
return x == S && y == S;
}
void SolveSmall(){
do{
for(int i=0; i<N*N; i++) B[i/N][i%N] = A[i];
R += Check();
}while(next_permutation(A, A+N*N));
}
vector<pair<int,int>> Pos = {
{0, 0}, {0, 1}, {0, 2}, {0, 3},
{1, 0}, {1, 1}, {1, 2}, {1, 3},
{2, 1}, {2, 2},
{3, 0}, {3, 1}, {3, 2}, {3, 3},
{2, 0}, {2, 3}
};
bool Feasible(int x){ return 0 <= x && x <= 100 && C[x] > 0; }
int Calc(int r, int c){
if(r <= 1 && c == 3) return S - B[r][0] - B[r][1] - B[r][2];
if(r == 3 && c == 0) return S - B[0][3] - B[1][2] - B[2][1];
if(r == 3 && c == 3) return S - B[0][0] - B[1][1] - B[2][2];
if(r == 3 && (c == 1 || c == 2)) return S - B[0][c] - B[1][c] - B[2][c];
if(r == 2 && (c == 0 || c == 3)) return S - B[0][c] - B[1][c] - B[3][c];
return INF;
}
void Solve(int idx){
if(idx == Pos.size()){ R += Check(); return; }
auto [r,c] = Pos[idx];
auto auto_fill = Calc(r, c);
if(auto_fill != INF){
if(Feasible(auto_fill)){
B[r][c] = auto_fill; C[auto_fill]--;
Solve(idx+1);
C[auto_fill]++;
}
return;
}
for(int i=0; i<16; i++){
if(i > 0 && A[i-1] == A[i] || !Feasible(A[i])) continue;
B[r][c] = A[i]; C[A[i]]--;
Solve(idx+1);
C[A[i]]++;
}
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=0; i<N*N; i++) cin >> A[i];
for(int i=0; i<N*N; i++) C[A[i]]++;
sort(A, A+N*N);
S = accumulate(A, A+N*N, 0);
if(S % N != 0){ cout << 0; return 0; }
S /= N;
if(N <= 3){ SolveSmall(); cout << R; return 0; }
Solve(0);
cout << R;
}
트리 위에 원을 2개 그린 뒤, 두 원의 교집합에 속한 정점의 값을 업데이트하는 쿼리를 처리해야 합니다. 트리에서 원의 교집합이 어떤 형태인지를 먼저 관찰해 봅시다.
두 원 $C_1(v_1, r_1)$, $C_2(v_2, r_2)$가 있다고 합시다. 만약 $v_1$과 $v_2$ 사이의 거리 $d$가 $r_1+r_2$보다 크다면 교집합은 공집합입니다. 그렇지 않은 경우, 두 원의 교집합은 $v_1$에서 $v_2$ 방향으로 $x = \frac{r_1+(d-r_2)}{2}$ 만큼 이동한 지점이 중심이고, 반지름이 $r_2 - x$인 원이 됨을 알 수 있습니다. 즉, 정점을 원의 중심으로 하는 두 원의 교집합은, 정점 또는 간선의 중앙을 중심으로 하는 원입니다.
따라서 간선의 중앙에 정점을 하나씩 더 끼워넣은 크기가 $2N-1$인 트리를 만들면, 어떤 정점 $v$와의 거리가 $d$ 이하인 모든 정점의 값을 업데이트하는 문제로 바꿀 수 있습니다. 센트로이드 트리를 이용하면 사용하는 자료구조에 따라 매번 $O(\log N)$ 또는 $O(\log^2 N)$ 시간에 업데이트할 수 있습니다.
이후 단계는 단순한 다익스트라 알고리즘으로, $O(N \log N)$ 시간에 완료할 수 있습니다. 따라서 전체 시간 복잡도는 $O((N+M) \log N)$ 또는 $O(N \log N + M \log^2 N)$이며, 두 풀이 모두 제한 시간 안에 문제를 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int MAX_N = 101010;
constexpr int INF = 0x3f3f3f3f;
struct Circle{
int v, r;
Circle() : v(-1), r(0) {}
Circle(int v, int r) : v(v), r(r) {}
bool empty() const { return v == -1; }
};
int N, M, K, P[22][MAX_N*2], D[MAX_N*2];
vector<int> G[MAX_N*2], T[MAX_N*2];
int R[MAX_N*2];
void DFS(int v, int b=-1){
for(auto i : G[v]) if(i != b) D[i] = D[v] + 1, P[0][i] = v, DFS(i, v);
}
int LCA(int u, int v){
if(D[u] < D[v]) swap(u, v);
for(int i=0, k=D[u]-D[v]; k; i++, k/=2) if(k & 1) u = P[i][u];
if(u == v) return u;
for(int i=21; i>=0; i--) if(P[i][u] != P[i][v]) u = P[i][u], v = P[i][v];
return P[0][u];
}
int Dist(int u, int v){ return D[u] + D[v] - 2 * D[LCA(u, v)]; }
int Kth(int v, int k){
for(int i=0; k; i++, k/=2) if(k & 1) v = P[i][v];
return v;
}
Circle operator + (const Circle &a, const Circle &b){
if(a.empty() || b.empty()) return Circle();
int dist = Dist(a.v, b.v);
if(a.r + b.r < dist) return Circle();
int st = max(-a.r, dist - b.r);
int ed = min(+a.r, dist + b.r);
int md = (st + ed) / 2, lca = LCA(a.v, b.v);
if(D[a.v] - D[lca] < md) return Circle(Kth(b.v, dist - md), ed - md);
else return Circle(Kth(a.v, md), ed - md);
}
// Centroid tree
int S[MAX_N*2], U[MAX_N*2], CP[MAX_N*2], CD[MAX_N*2], SubDist[22][MAX_N*2];
vector<pair<int,int>> ST[MAX_N*2]; // subtree info: {dist, vertex}
int GetSize(int v, int b=-1){
S[v] = 1;
for(auto i : G[v]) if(i != b && !U[i]) S[v] += GetSize(i, v);
return S[v];
}
int GetCent(int v, int sz, int b=-1){
for(auto i : G[v]) if(i != b && !U[i] && S[i] * 2 > sz) return GetCent(i, sz, v);
return v;
}
void Gather(int root, int v, int b=-1, int d=0){
ST[root].emplace_back(d, v); SubDist[CD[root]][v] = d;
for(auto i : G[v]) if(i != b && !U[i]) Gather(root, i, v, d+1);
}
int Build(int v=1, int lv=0){
v = GetCent(v, GetSize(v)); U[v] = 1; CD[v] = lv;
Gather(v, v);
sort(ST[v].begin(), ST[v].end(), greater<>());
for(auto i : G[v]) if(!U[i]) CP[Build(i, lv+1)] = v;
return v;
}
void Update(int v, int r, int t){
// Circle(v, r) <- t
for(int x=v; x; x=CP[x]){
int limit = r - SubDist[CD[x]][v];
while(!ST[x].empty() && ST[x].back().first <= limit){
auto y = ST[x].back().second;
R[y] = min(R[y], t);
ST[x].pop_back();
}
}
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> M >> K;
for(int i=1; i<N; i++){
int u, v; cin >> u >> v;
G[u].push_back(N+i); G[N+i].push_back(u);
G[v].push_back(N+i); G[N+i].push_back(v);
T[u].push_back(v); T[v].push_back(u);
}
N = N * 2 - 1; DFS(1);
for(int i=1; i<22; i++) for(int j=1; j<=N; j++) P[i][j] = P[i-1][P[i-1][j]];
Build();
memset(R, 0x3f, sizeof R);
vector<pair<int, Circle>> V;
for(int i=1; i<=M; i++){
int t, u, v, ru, rv;
cin >> t >> u >> v >> ru >> rv;
Circle c = Circle(u, ru*2) + Circle(v, rv*2);
if(!c.empty()) V.emplace_back(t, c);
}
sort(V.begin(), V.end(), [](auto a, auto b){ return a.first < b.first; });
for(auto [t,c] : V) Update(c.v, c.r, t);
N = (N + 1) / 2;
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> Q;
for(int i=1; i<=N; i++) if(R[i] < INF) Q.emplace(R[i], i);
while(!Q.empty()){
auto [c,v] = Q.top(); Q.pop();
if(c == R[v]) for(auto i : T[v]) if(R[i] > c + K) Q.emplace(R[i]=c+K, i);
}
for(int i=1; i<=N; i++) cout << (R[i] < INF ? R[i] : -1) << "\n";
}
이득을 최대화하기 위해서는 $i$번째 물건을 산 다음, $i, i+1, \cdots, N$번째 날 중 물건이 가장 비쌀 때 팔면 됩니다. 따라서 $M_i = \max_{i \le k \le N} P_k$라고 정의하면, 문제의 답은 $\mathcal{F}(P) = \sum M_i - P_i$가 됩니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, A[8080], M[8080], R;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=N; i>=1; i--) M[i] = max(M[i+1], A[i]);
for(int i=1; i<=N; i++) R += M[i] - A[i];
cout << R;
}
하지만 다르게도 생각해 볼 수 있습니다.
어떤 정수 $h$에 대해, 가격이 $h$ 이하일 때 구매한 뒤에 $h$ 초과일 때 판매해서 수익을 얻는 횟수를 생각해 봅시다. 구체적으로, $1 \le P_i \le h$이면서 $i < j$ 이고 $P_j > h$인 $j$가 존재하는 $i$의 개수를 $f_h(P)$라고 정의하면, $\mathcal{F}(P) = \sum_{h=1}^{N-1} f_h(P)$가 성립합니다.
$f_h(P)$는 $h$ 초과가 등장하는 마지막 위치보다 앞에 있는 $1 \le P_i \le h$인 개수라고 생각할 수 있습니다. 계산하기 쉽게 만들기 위해 $g_h(P) :=$ $h$ 초과가 등장하는 마지막 인덱스보다 뒤에 있는 $1 \le P_i \le h$ 의 개수를 정의합시다. $f_h(P) = h - g_h(P)$가 성립하며, $g_h(P)$는 $h$가 증가하는 순서대로 보면 펜윅 트리 등을 이용해 $g_1(P), g_2(P), \cdots, g_{N-1}(P)$를 모두 $O(N \log N)$ 시간에 구할 수 있습니다.
따라서 $\mathcal{F}(P) = \sum_{h=1}^{N-1} f_h(P) = \sum_{h=1}^{N-1} h - g_h(P) = N(N-1)/2 - \sum_{h=1}^{N-1} g_h(P)$도 $O(N \log N)$ 시간에 구할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, A[8080], R;
int T[8080]; // fenwick tree
void Add(int x, int v){ for(x+=3; x<8080; x+=x&-x) T[x] += v; }
int Get(int x){ int r = 0; for(x+=3; x; x-=x&-x) r += T[x]; return r; }
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
int pos = N;
for(int h=1; h<N; h++){
while(A[pos] <= h) Add(A[pos], 1), pos--;
R += h - Get(h);
}
cout << R;
}
아래와 같이 펜윅 트리 없이 $O(N^2)$에 계산할 수도 있습니다. 뒤에서는 이 방법을 발전시켜서 전체 문제를 해결할 것입니다.
#include <bits/stdc++.h>
using namespace std;
int N, A[8080];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
int sum = 0;
for(int h=1; h<N; h++){
for(int p=N; p>=1 && A[p]<=h; p--) sum++;
}
cout << N * (N - 1) / 2 - sum;
}
설명의 편의를 위해 수열 전체에 있는 물음표의 개수를 $K$라고 합시다.
$\sum_P \mathcal{F}(P) = \sum_{h=1}^{N-1} \sum_P h - g_h(P)$ 를 계산해야 합니다. 식을 정리하면 $\sum_{h=1}^{N-1} K!h - \sum_P g_h(P) = K!N(N-1)/2 - \sum_{h=1}^{N-1} \sum_P g_h(P)$ 가 되므로, $\sum_{h=1}^{N-1} \sum_P g_h(P)$만 빠르게 계산하면 됩니다. 마찬가지로 $h$가 증가하는 순서대로 계산할 것입니다.
$h$와 $p$를 고정했을 때, $p$ 이후에 있는 모든 수가 $h$ 이하인 순열의 경우의 수를 세는 방법에 대해 생각해 봅시다. $p$ 이후애 있는 물음표의 개수를 $K_p$, 물음표 자리에 들어갈 수 있는($P$에 등장하지 않은) $h$ 이하인 수의 개수를 $L_h$라고 합시다.
$p$ 이후에 있는 모든 수가 $h$ 이하인 순열을 만들기 위해서는, 우선 $L_h$개의 수 중 $K_p$개를 골라서 배치한 다음, 남은 $K-K_p$개의 수를 남은 자리에 자유롭게 배치하면 됩니다. 따라서 $L_h! / (L_h - K_p)! \times (K-K_p)!$이 됨을 알 수 있습니다.
따라서 모든 $p, h$에 대해 $L_h! / (L_h - K_p)! \times (K-K_p)!$를 더해서 $\sum_{h=1}^{N-1} \sum_P g_h(P)$를 구할 수 있으며, $O(N^2)$ 시간에 계산할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr ll MOD = 998244353;
ll Pow(ll a, ll b){
ll res = 1;
for(; b; b>>=1, a=a*a%MOD) if(b & 1) res = res * a % MOD;
return res;
}
ll N, K, A[8080], C[8080], R;
ll Fac[8080], Inv[8080];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=N; i++) C[A[i]] = 1;
K = count(A+1, A+N+1, 0);
Fac[0] = 1;
for(int i=1; i<=N; i++) Fac[i] = Fac[i-1] * i % MOD;
Inv[N] = Pow(Fac[N], MOD-2);
for(int i=N-1; i>=0; i--) Inv[i] = Inv[i+1] * (i+1) % MOD;
for(int h=1; h<N; h++){
int low_h = 0, empty = 0;
for(int i=1; i<=h; i++) low_h += !C[i];
for(int p=N; p>=1 && A[p]<=h; p--){
empty += !A[p];
if(low_h - empty < 0) break;
ll now = Fac[low_h] * Inv[low_h-empty] % MOD * Fac[K-empty] % MOD;
R = (R + now) % MOD;
}
}
ll tmp = N*(N-1)/2 % MOD * Fac[K] % MOD;
cout << (tmp - R + MOD) % MOD;
}
두 가지 문제가 합쳐진 문제입니다.
첫 번째 문제는 $K$가 작다는 점을 이용해 $O(NK^2)$ 시간에 푸는 문제, 두 번째 문제는 $O(NM)$ 시간에 푸는 문제입니다.
$T$의 모든 접미사와 $P$의 edit distance table(D table)를 구하면 문제를 해결할 수 있습니다. D table을 한 번 구하는 데 $O(NM)$ 만큼 걸리므로 $O(N^2M)$ 시간에 문제를 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
int D[1010][111], R[111];
void Solve(const string &a, const string &b){
int n = a.size(), m = b.size();
for(int i=0; i<=n; i++) D[i][0] = i;
for(int j=0; j<=m; j++) D[0][j] = j;
for(int i=1; i<=n; i++){
for(int j=1; j<=m; j++){
D[i][j] = min({ D[i-1][j] + 1, D[i][j-1] + 1, D[i-1][j-1] + (a[i-1] != b[j-1]) });
}
}
for(int i=1; i<=n; i++) R[D[i][m]]++;
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int N, M, K; string S, P;
cin >> N >> M >> K >> S >> P;
for(int i=0; i<N; i++) Solve(S.substr(i), P);
for(int i=0; i<=K; i++) cout << R[i] << "\n";
}
$K$ 범위 제한을 이용하면, $S$의 접미사를 볼 때 첫 $M+K$글자만 봐도 된다는 사실을 알 수 있습니다. 이를 이용하면 편집 거리가 $K$ 이하인 D table의 일부를 구하는 데 $O(M(M+K))$ 시간이 걸리므로, $O(NM^2)$ 시간에 문제를 해결할 수 있습니다. 위 코드의 7번째 줄 뒤에 n = min(n, m+K); 만 추가하면 됩니다.
$P$와의 편집 거리가 $K$ 이하인 문자열의 길이는 항상 $M-K$ 이상, $M+K$ 이하입니다. 따라서 D table에서 주대각선과 $K$ 이하 만큼 떨어진 칸들만 계산해도 문제를 해결할 수 있습니다. 이 경우 D table을 한 번 구하는 데 $O(MK)$ 시간이 걸리므로, 전체 시간 복잡도는 $O(NMK)$가 됩니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int INF = 0x3f3f3f3f;
int D[1010][3030], R[3030];
void Clear(int n, int m, int k){
for(int j=0; j<=m; j++){
for(int i=j-k; i<=j+k; i++) if(0 <= i && i <= n) D[j][i] = INF;
for(int i=-k; i<=k; i++) if(0 <= j+k && j+k <= n) D[j][j+k] = INF;
}
}
void Solve(const string &a, const string &b, int k){
int n = a.size(), m = b.size();
for(int i=0; i<=k; i++) D[0][i] = i;
for(int j=1; j<=m; j++){
for(int i=j-k; i<=j+k; i++){
if(i < 0 || i > n) continue;
if(i == 0){ D[j][0] = j; continue; }
D[j][i] = min({ D[j][i-1] + 1, D[j-1][i] + 1, D[j-1][i-1] + (b[j-1] != a[i-1]) });
}
}
for(int i=m-k; i<=m+k; i++) if(1 <= i && i <= n) R[D[m][i]]++;
Clear(n, m, k);
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int N, M, K; string S, P;
cin >> N >> M >> K >> S >> P;
memset(D, 0x3f, sizeof D);
for(int i=0; i<N; i++) Solve(S.substr(i), P, K);
for(int i=0; i<=K; i++) cout << R[i] << "\n";
}
D table에서 오른쪽 아래로 향하는 대각선을 보면, 항상 값이 단조증가한다는 것을 알 수 있습니다. 더 나아가서, 대각선 위에서 인접한 두 칸은 항상 0 또는 1 만큼 차이납니다. 따라서, 각 대각선마다 값이 $k$에서 $k+1$로 바뀌는 지점을 빠르게 찾을 수 있다면, 그러한 연산을 $O(NK^2)$번 수행해서 $K$ 이하인 모든 D table의 값을 알 수 있습니다.
D table에서 대각선 방향으로 같은 값이 많이 뻗어나가는지를 구하면, 값이 1 만큼 증가하는 시점을 포착할 수 있습니다. 구체적으로, $i - j$가 $s$로 일정한 칸들 중, $D(i, j) = k-1$인 마지막 칸을 $(i_{s,k-1}, j_{s,k-1})$이라고 하면, $(i_{s,k}, j_{s,k})$는 아래 세 가지 경우를 계산해서 구할 수 있습니다.
대각선을 타고 내려간다는 것을 다르게 이야기하면 $A_i = B_j$가 성립할 동안 $i$와 $j$를 계속 증가시키겠다는 뜻이고, 이는 $A[i\cdots]$와 $B[j\cdots]$의 prefix가 얼마나 많이 일치하는지, 즉 longest common prefix를 구한다는 것을 의미합니다. 따라서 $A\sharp B$의 접미사 배열와 LCP 배열을 미리 전처리해 두면, 스파스 테이블 등을 이용해 $O(1)$ 시간에 대각선을 얼마나 타고 내려갈 수 있는지를 구할 수 있습니다.
각 대각선마다 DP 값의 변화를 매번 $O(1)$ 시간에 구할 수 있으므로, 한 대각선의 DP 값을 총 $O(K)$ 시간에 구할 수 있습니다. 대각선은 $-K \le i-j \le K$ 범위에서 총 $2K+1$개를 관리해야 하므로, $O(K^2)$ 시간에 $T$의 suffix에 대한 문제를 해결할 수 있으며, 전체 문제는 $O(NK^2)$ 시간에 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
tuple<vector<int>, vector<int>, vector<int>> SuffixArray(const string &s){
int n = s.size(), m = max(n, 256);
vector<int> sa(n), lcp(n), pos(n), tmp(n), cnt(m);
auto counting_sort = [&](){
fill(cnt.begin(), cnt.end(), 0);
for(int i=0; i<n; i++) cnt[pos[i]]++;
partial_sum(cnt.begin(), cnt.end(), cnt.begin());
for(int i=n-1; i>=0; i--) sa[--cnt[pos[tmp[i]]]] = tmp[i];
};
for(int i=0; i<n; i++) sa[i] = i, pos[i] = s[i], tmp[i] = i;
counting_sort();
for(int k=1; ; k<<=1){
int p = 0; for(int i=n-k; i<n; i++) tmp[p++] = i;
for(int i=0; i<n; i++) if(sa[i] >= k) tmp[p++] = sa[i] - k;
counting_sort(); tmp[sa[0]] = 0;
for(int i=1; i<n; i++){
tmp[sa[i]] = tmp[sa[i-1]];
if(sa[i-1]+k < n && sa[i]+k < n && pos[sa[i-1]] == pos[sa[i]] && pos[sa[i-1]+k] == pos[sa[i]+k]) continue;
tmp[sa[i]] += 1;
}
swap(pos, tmp);
if(pos[sa.back()] + 1 == n) break;
}
for(int i=0, j=0; i<n; i++, j=max(j-1,0)){
if(pos[i] == 0) continue;
while(sa[pos[i]-1]+j < n && sa[pos[i]]+j < n && s[sa[pos[i]-1]+j] == s[sa[pos[i]]+j]) j++;
lcp[pos[i]] = j;
}
return {sa, lcp, pos};
}
struct sparse_table{
vector<vector<int>> v;
sparse_table() = default;
sparse_table(vector<int> a){
int n = a.size(), lg = __lg(n*2-1);
v = vector<vector<int>>(lg+1, vector<int>(n));
for(int i=0; i<n; i++) v[0][i] = a[i];
for(int i=1; i<=lg; i++) for(int j=0; j+(1<<i)-1<n; j++) v[i][j] = min(v[i-1][j], v[i-1][j+(1<<(i-1))]);
}
int get(int l, int r) const {
int k = __lg(r-l+1);
return min(v[k][l], v[k][r-(1<<k)+1]);
}
};
vector<vector<int>> GetDP(string a, string b){
int n = a.size(), m = b.size();
vector<vector<int>> dp(n+1, vector<int>(m+1));
for(int i=0; i<=n; i++) dp[i][0] = i;
for(int j=0; j<=m; j++) dp[0][j] = j;
for(int i=1; i<=n; i++){
for(int j=1; j<=m; j++){
dp[i][j] = min({dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + (a[i-1] != b[j-1])});
}
}
return dp;
}
int N, M, K, Answer[1010];
string S, T;
vector<int> SA, LCP, Pos;
sparse_table RMQ;
int GetLCP(int i, int j){
// lcp(S[i..N-1], T[j..M-1]
int u = Pos[i], v = Pos[N+1+j];
if(u > v) swap(u, v);
return RMQ.get(u+1, v);
}
bool Bound(int s, int i, int j){
return 0 <= i && i <= N - s && 0 <= j && j <= M;
}
// DP 테이블에서 (i - j)가 같은 대각선 관리
// End[sub][value] : (i - j) = sub인 대각선에서 value가 등장하는 마지막 열 번호
// Use[sub] : (i - j) = sub인 대각선에서 값이 채워진 마지막 열 번호(= max End[sub][value])
// -K <= sub <= K, 0 <= value <= K
int End[2020][1010], Use[2020];
void Clear(){
for(int i=0; i<=K+K; i++) memset(End[i], -1, sizeof(End[0][0]) * (K+1));
memset(Use, -1, sizeof(Use[0]) * (2*K+1));
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> M >> K >> S >> T;
tie(SA,LCP,Pos) = SuffixArray(S + "#" + T);
RMQ = sparse_table(LCP);
for(int s=0; s<N; s++){
// get edit distance between S[s..N-1] and T[0..M-1]
Clear();
for(int value=0; value<=K; value++){
// i - j = sub, i = sub + j
for(int sub=-value; sub<=value; sub++){
int st_i, st_j = -1;
if(sub == value || sub == -value) st_j = max(st_j, -min(sub,0));
if(value > 0 && End[sub+K][value-1] != -1){
// 현재 대각선에서 value-1의 끝점이 (i, j)이면 (i+1, j+1)부터 시작
st_j = max(st_j, End[sub+K][value-1]+1);
}
if(value > 0 && abs(sub-1) <= K && End[sub-1+K][value-1] != -1){
// 위 대각선에서 value-1의 끝점이 (i, j)이면 (i+1, j)부터 시작
st_j = max(st_j, End[sub-1+K][value-1]);
}
if(value > 0 && abs(sub+1) <= K && End[sub+1+K][value-1] != -1){
// 아래 대각선에서 value-1의 끝점이 (i, j)이면 (j, j+1)부터 시작
st_j = max(st_j, End[sub+1+K][value-1]+1);
}
st_i = sub + st_j;
if(st_j == -1 || st_j <= Use[sub+K]) continue;
if(!Bound(s, st_i, st_j)){
// 위 또는 왼쪽 대각선에서 (i+1, j) (i, j+1) 넘겨줄 때, 범위를 벗어나는 경우가 있음
// 이 경우에는 (st_i - 1, st_j - 1)까지 쭉 value로 이어진다고 생각할 수 있음
if(st_j - 1 > Use[sub+K]) End[sub+K][value] = Use[sub+K] = st_j - 1;
}
else if(st_i == N - s || st_j == M){
// 이 상황에서 LCP 구하면 런타임 에러, 경계에 있으므로 자기 자신이 끝점이 됨
if(st_j > Use[sub+K]) End[sub+K][value] = Use[sub+K] = st_j;
}
else{
// DP(i, j) <- DP(i-1, j-1)을 얼마나 많이 연속해서 사용할 수 있는지 LCP 배열로 구함
int len = GetLCP(s + st_i, st_j);
int ed_i = st_i + len, ed_j = st_j + len;
End[sub+K][value] = Use[sub+K] = ed_j;
}
}
}
for(int sub=-K; sub<=K; sub++){
int j = M, i = sub + j;
if(i == 0 || !Bound(s, i, j)) continue;
for(int value=0; value<=K; value++) if(End[sub+K][value] == j) Answer[value]++;
}
}
for(int i=0; i<=K; i++) cout << Answer[i] << " ";
}
$T[i\cdots]$와 $P$의 D table에서 $T[i+1\cdots]$와 $P$의 D table로 전이하는 것은, 최악의 경우 $O(NM)$개의 칸의 값이 바뀔 수 있으므로 $O(NM)$보다 빠르게 할 수 없습니다. 따라서 D table의 정보를 보존하는 또다른 테이블을 만들어서 관리해야 합니다.
풀이 준비 중…
]]>문제 지문과 공식 풀이는 NYPC 아카이브에서 확인할 수 있습니다. 채점은 BIKO에서 받을 수 있습니다.
제가 생각하는 난이도(solved.ac 기준)는 다음과 같습니다.
PC 화면으로 보는 분들은 우측 사이드바를 이용해 원하는 문제로 빠르게 이동할 수 있습니다.
버튼을 $t$번 누른 뒤에 실패하게 되는 경우의 수를 $f(t)$라고 합시다. 문제의 정답은 $K + \sum_{t=1}^{K} t \times f(t)$ 입니다.
$t = 1$일 때는 최악의 경우 $N$번째 시도에서 올바른 버튼을 찾을 수 있으므로 $f(1) = N-1$입니다. $t = 2$일 때는 버튼 하나를 후보에서 제거할 수 있으므로 최대 $N-2$번 실패할 수 있고, 마찬가지로 $t = 3$일 때는 최대 $N-3$번 실패할 수 있습니다. 즉, $f(t) = N - t$입니다.
따라서 $K + \sum_{t=1}^{K} t \times (N-t)$를 출력하면 문제를 해결할 수 있습니다. $K+\frac{NK(K+1)}{2}-\frac{K(K+1)(2K+1)}{6}$을 출력하면 반복문을 사용하지 않고 해결할 수도 있습니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
ll N, K, R=0; cin >> N >> K;
for(int i=1; i<=K; i++) R += i * (N - i);
cout << R + K;
}
두 사람의 플레이 가능 시간을 각각 $A_i, A_j$라고 하면, 파티 시너지는 $A_i + A_j - \vert A_i - A_j \vert$, 던전 클리어 횟수는 $\lfloor \frac{\min(A_i,A_j)}{M} \rfloor$입니다. 잘 생각해 보면 파티 시너지는 $2\times\min(A_i,A_j)$로 계산할 수 있으므로, 최종 스코어를 최대화하기 위해서는 $\min(A_i,A_j)$를 되도록 크게 만들어야 한다는 사실을 알 수 있습니다.
$N$명의 사람을 $A_i$ 오름차순으로 정렬한 뒤, 인접한 두 사람을 같은 팀으로 만들면 최종 스코어의 합을 최대화할 수 있습니다. 전체 시간 복잡도는 $O(N \log N)$입니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, M, A[202020], R;
ll f(ll x, ll y){
ll synergy = 2 * min(x, y);
ll clear = min(x, y) / M;
return synergy * clear;
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> M; N *= 2;
for(int i=1; i<=N; i++) cin >> A[i];
sort(A+1, A+N+1);
for(int i=2; i<=N; i+=2) R += f(A[i-1], A[i]);
cout << R;
}
공차가 $d$인 등차수열 $\lbrace A_1,A_2, \cdots, A_N\rbrace$은 $A_i$에서 $i\cdot d$를 빼면 모두 같은 값이 됩니다. 이 성질을 이용해서 문제의 풀이를 찾아봅시다.
주어진 수열을 잘 조작해서 공차가 1인 등차수열을 만드는 것이 목표고, 수열의 각 항에 1 또는 -1을 최대 한 번 더할 수 있습니다. 이는 $A_i - i$에 1 또는 -1을 최대 한 번 더해서 모든 원소의 값이 같아지도록 만드는 문제입니다.
항상 공차가 1인 등차수열을 만들 수 있는 경우만 주어진다고 했으므로 $A_i - i$의 최솟값과 최댓값의 차는 2 이하입니다. 따라서 차가 0일 때, 1일 때, 2일 때로 나누어 문제를 해결하면 됩니다.
#include <bits/stdc++.h>
using namespace std;
int N, A[101010];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=N; i++) A[i] -= i;
int mn = *min_element(A+1, A+N+1), c1 = count(A+1, A+N+1, mn);
int mx = *max_element(A+1, A+N+1), c2 = count(A+1, A+N+1, mx);
if(mn == mx) cout << 0;
else if(mx - mn == 1) cout << min(c1, c2);
else /*mx - mn == 2*/ cout << c1 + c2;
}
단순 구현 문제입니다.
#include <bits/stdc++.h>
using namespace std;
int N, L;
string A[11], S;
vector<string> R;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> L;
for(int i=1; i<=N; i++) cin >> A[i];
cin >> S;
for(int i=0; i<S.size(); i++){
int match = -1;
for(int j=1; j<=N; j++){
if(S.substr(i, A[j].size() + 2) == ":" + A[j] + ":") match = j;
}
if(match == -1) R.push_back(string(1, S[i]));
else R.push_back("[" + A[match] + "]"), i += A[match].size() + 1;
}
for(int i=0; i<R.size(); i++){
cout << R[i];
if((i + 1) % L == 0) cout << "\n";
}
}
$0\rightarrow 1\rightarrow 0 \rightarrow \cdots$와 $1\rightarrow 0\rightarrow 1\rightarrow \cdots$ 패턴으로 섬을 방문하는 두 가지 방법에서의 방호복 교체 횟수를 각각 구한 뒤 둘 중 더 작은 값을 출력하면 됩니다.
각 방호복마다 갈 수 있는 모든 섬을 방문해야 하는데, 일부 섬들 사이에 DAG 형태의 선후 관계가 주어져 있습니다. 어떤 섬에 갈 수 있다는 것은 정점의 in degree가 0이라는 것을 의미하므로, 위상 정렬을 할 때와 비슷하게 각 정점의 in degree를 관리하면서, 매번 방문할 수 있는 섬을 모두 방문하는 식으로 구현하면 됩니다. 시간 복잡도는 위상 정렬과 동일하게 $O(N+M)$입니다.
#include <bits/stdc++.h>
using namespace std;
int N, M, A[202020], In[202020];
vector<int> G[202020];
int f(int flag){
memset(In, 0, sizeof In);
for(int i=1; i<=N; i++) for(auto j : G[i]) In[j]++;
int res = 0; queue<int> que[2];
for(int i=1; i<=N; i++) if(!In[i]) que[A[i]].push(i);
while(true){
while(!que[flag].empty()){
int v = que[flag].front(); que[flag].pop();
for(auto i : G[v]) if(!--In[i]) que[A[i]].push(i);
}
if(que[0].empty() && que[1].empty()) break;
else res++, flag ^= 1;
}
return res;
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> M;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1,s,e; i<=M; i++) cin >> s >> e, G[s].push_back(e);
cout << min(f(0), f(1));
}
$A$에서의 시작 위치가 $i$, $B$에서의 시작 위치가 $j$인 두 부분 수열에서 값이 매칭되기 위해서는 $A_{i+t} = B_{j+t}$인 두 원소가 존재해야 합니다. 반대로 이야기하면, 인덱스 차이가 $d = (i+t) - (j+t) = i - j$이면서 값이 같은 모든 원소는 시작 위치가 각각 $i, j$인 부분 수열에서 매칭됩니다.
결국 이 문제는 값이 같고 인덱스 차이가 $d$인 원소 쌍의 수를 모든 $-M < d < N$에 대해 구하는 것으로 해결할 수 있습니다. 일반적으로는 빠르게 풀기 어려운 문제(Convolution)지만, 문제 조건에 의해 두 수열 $A, B$를 통틀어서 같은 값은 최대 3번 등장하므로, $O(N+M)$ 시간에 모든 쌍을 찾을 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int SZ = 1'000'000;
int N, M, A[202020], B[202020], C[404040];
vector<pair<int,int>> P[SZ+1];
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> M;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=1; i<=M; i++) cin >> B[i];
for(int i=1; i<=N; i++) P[A[i]].emplace_back(1, i);
for(int i=1; i<=M; i++) P[B[i]].emplace_back(2, i);
for(int i=1; i<=SZ; i++){
for(int j=0; j<P[i].size(); j++){
for(int k=j+1; k<P[i].size(); k++){
if(P[i][j].first != P[i][k].first) C[P[i][j].second-P[i][k].second+202020]++;
}
}
}
cout << *max_element(C, C+404040);
}
$i$번째 아이템을 선택하면 기본적으로 점수가 $A_i = s_iw_S + d_iw_D + h_iw_H$ 만큼 상승합니다. 만약 장착 부위와 장비가 속한 세트가 모두 같은 장비가 여러 개 있다면, $A_i$가 가장 큰 장비 하나만 남기고 나머지를 지워도 답은 변하지 않습니다.
서로 다른 세트끼리는 점수에 영향을 주지 않으므로, 캐릭터의 최종 스탯 점수는 각 세트마다 얻는 점수의 합으로 계산할 수 있습니다. 따라서 다음과 같은 DP를 생각해 볼 수 있습니다.
점화식은 $D(i, bit) = \max_{sub \subset bit} D(i-1, bit\setminus sub) + \text{SetScore}(i, sub)$를 이용해 계산할 수 있습니다. 9개의 원소로 구성된 총 $2^9$개의 집합의 부분 집합을 순회하는 데 걸리는 시간은 $O(3^9)$입니다(이항 정리). 따라서 전체 시간 복잡도는 $O(N + M\times 3^9)$입니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int nINF = 0xc0c0c0c0;
int W[3];
int N, Slot[555], A[555][3], Set[555];
int M, B[222][10][3];
int BestIndex[222][9], BestScore[222][9]; // [set][slot] = id
int SetScore[222][1<<9], D[222][1<<9], P[222][1<<9];
void Input(){
map<string, int> PART;
PART["무기"] = 0; PART["장갑"] = 1; PART["상의"] = 2;
PART["하의"] = 3; PART["신발"] = 4; PART["벨트"] = 5;
PART["목걸이"] = 6; PART["모자"] = 7; PART["반지"] = 8;
for(int i=0; i<3; i++) cin >> W[i];
cin >> N;
vector<string> set_name(N+1);
for(int i=1; i<=N; i++){
string slot, name;
cin >> slot >> name >> A[i][0] >> A[i][1] >> A[i][2] >> set_name[i];
Slot[i] = PART[slot];
}
cin >> M;
map<string, int> set_id;
for(int i=1; i<=M; i++){
string name; cin >> name; set_id[name] = i;
for(auto j : {3, 5, 7, 9}) for(int k=0; k<3; k++) cin >> B[i][j][k];
for(int j=1; j<10; j++) for(int k=0; k<3; k++) B[i][j][k] += B[i][j-1][k];
}
for(int i=1; i<=N; i++) Set[i] = set_id[set_name[i]];
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
Input();
memset(BestScore, 0xc0, sizeof BestScore);
for(int i=1; i<=N; i++){
int now = 0;
for(int t=0; t<3; t++) now += W[t] * A[i][t];
if(now > BestScore[Set[i]][Slot[i]]) BestIndex[Set[i]][Slot[i]] = i, BestScore[Set[i]][Slot[i]] = now;
}
for(int i=1; i<=M; i++){
for(int bit=0; bit<(1<<9); bit++){
bool flag = true;
for(int j=0; j<9; j++) if((bit >> j & 1) && BestScore[i][j] == nINF) flag = false;
if(!flag){ SetScore[i][bit] = -1e9; continue; }
int cnt = __builtin_popcount(bit);
for(int t=0; t<3; t++) SetScore[i][bit] += W[t] * B[i][cnt][t];
for(int j=0; j<9; j++) if(bit >> j & 1) SetScore[i][bit] += BestScore[i][j];
}
}
memset(D, 0xc0, sizeof D); D[0][0] = 0;
for(int i=1; i<=M; i++){
for(int bit=0; bit<(1<<9); bit++){
D[i][bit] = D[i-1][bit]; P[i][bit] = 0;
for(int sub=bit; sub; sub=(sub-1)&bit){
int now = D[i-1][bit^sub] + SetScore[i][sub];
if(now > D[i][bit]) D[i][bit] = now, P[i][bit] = sub;
}
}
}
int bit = max_element(D[M], D[M]+(1<<9)) - D[M], res[9] = {0};
cout << D[M][bit] << "\n";
for(int i=M, j=bit; i>=1; j^=P[i--][j]){
int now = P[i][j];
for(int k=0; k<9; k++) if(now >> k & 1) res[k] = BestIndex[i][k];
}
for(int i=0; i<9; i++) cout << res[i] << "\n";
}
$K \le 15$ 정도라면 TSP와 비슷하게 지금까지 획득한 아이템 집합을 비트 연산으로 관리하면서 BFS를 돌리면 $O(N^32^K)$ 정도에 문제를 해결할 수 있습니다. 또한 TSP를 생각했다면 $K > 15$일 때 일반적인 방법으로 풀기 어렵다는 것을 눈치챌 수 있을 것입니다.
$K \le 30$인 데이터에서도 만점을 받는 방법은 여러가지가 있는데, 가장 간단한 방법은 다음과 같습니다.
시뮬레이터를 통해 미로를 보면, 일자로 쭉 뻗은 경로가 그렇게 많지 않고, 경로 자체가 길지도 않습니다. 최대 길이가 약 20 정도이기 때문에 아이템은 그것의 절반인 10개 정도만 사용해도 될 것이라 추측할 수 있고, 실제로 모든 데이터에서 아이템을 10개 이하로 사용하는 최적해가 존재합니다. 이러한 추측을 했다면, 단순히 아이템을 무작위로 10개 선택한 뒤 $K = 10$인 문제를 해결하는 서브루틴을 수백 번 반복하는 것으로 최적해를 찾을 수 있습니다.
힐 클라이밍, 유전 알고리즘, 담금질 기법 등의 방법도 당연히 가능합니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int di[] = {1, -1, 0, 0};
constexpr int dj[] = {0, 0, 1, -1};
const string DIR = "SWDA";
int Sign(int v){ return (v > 0) - (v < 0); }
pair<char, int> GetMove(pair<int,int> a, pair<int,int> b){
for(int k=0; k<4; k++){
int r = b.first - a.first, c = b.second - a.second;
if(Sign(r) == di[k] && Sign(c) == dj[k]){
return {DIR[k], abs(r) + abs(c)};
}
}
assert(false);
}
struct Game{
vector<vector<char>> a;
int n, m, si, sj, ei, ej;
vector<vector<int>> id;
vector<pair<int,int>> item;
Game(const vector<vector<char>> &input) : a(input) {
n = a.size(); m = a[0].size();
id = vector<vector<int>>(n, vector<int>(m));
for(int i=0; i<n; i++){
for(int j=0; j<m; j++){
if(a[i][j] == 'S') si = i, sj = j, a[i][j] = '.';
if(a[i][j] == 'T') ei = i, ej = j, a[i][j] = '.';
if(a[i][j] == '@') id[i][j] = 1 << item.size(), item.emplace_back(i, j);
}
}
}
bool bound(int i, int j) const {
return 0 <= i && i < n && 0 <= j && j < m;
}
vector<pair<char,int>> solve() const {
vector dst(n, vector(m, vector(1<<item.size(), -1)));
vector prv(n, vector(m, vector(1<<item.size(), make_tuple(0,0,0))));
int dest = -1;
queue<tuple<int,int,int>> que;
que.emplace(si, sj, 0); dst[si][sj][0] = 0;
while(!que.empty()){
auto [i,j,bit] = que.front(); que.pop();
int pop = __builtin_popcount(bit);
if(i == ei && j == ej){ dest = bit; break; }
for(int k=0; k<4; k++){
for(int d=1; d<=pop+1; d++){
int r = i + di[k] * d;
int c = j + dj[k] * d;
if(!bound(r, c) || a[r][c] == '#') break;
int f = bit | id[r][c];
if(dst[r][c][f] == -1){
dst[r][c][f] = dst[i][j][bit] + 1;
prv[r][c][f] = {i, j, bit};
que.emplace(r, c, f);
}
}
}
}
vector<pair<int,int>> pos;
for(int r=ei, c=ej, bit=dest, d=dst[r][c][bit]; d>=0; d--){
pos.emplace_back(r, c);
if(d > 0) tie(r,c,bit) = prv[r][c][bit];
}
reverse(pos.begin(), pos.end());
vector<pair<char,int>> res;
for(int i=1; i<pos.size(); i++) res.push_back(GetMove(pos[i-1], pos[i]));
return res;
}
};
vector<vector<char>> Input(){
int n, m, k; cin >> n >> m >> k;
vector<vector<char>> a(n, vector<char>(m));
for(int i=0; i<n; i++) for(int j=0; j<m; j++) cin >> a[i][j];
return a;
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
auto A = Input();
Game G{A};
if(G.item.size() <= 15){
auto R = G.solve();
cout << R.size() << "\n";
for(auto [dir,dst] : R) cout << dir << " " << dst << "\n";
return 0;
}
mt19937 rng(0x917917);
auto V = G.item;
vector<pair<char,int>> R;
for(int iter=0; iter<300; iter++){
shuffle(V.begin(), V.end(), rng);
auto B = A;
for(int i=10; i<V.size(); i++) B[V[i].first][V[i].second] = '.';
auto vec = Game(B).solve();
if(R.empty() || vec.size() < R.size()) R = vec;
}
cout << R.size() << "\n";
for(auto [dir,dst] : R) cout << dir << " " << dst << "\n";
}
풀이 준비 중
편의상 입력으로 주어진 $K$ 대신 $K+1$을 $K$로 사용합시다. 상대 좌표가 $(-2K, 0), (-K, -K), (-K, 0), (0, 0)$인 칸은 작살을 한 번 사용하여 공격할 수 있습니다. 이런 식으로 한 개의 작살로 공격할 수 있는 칸들끼리 서로 모은 새로운 보드를 만든다면, 새로운 보드마다 독립적으로 문제를 해결한 뒤에 합치는 것으로 전체 문제를 풀 수 있음을 알 수 있습니다. 구체적으로, $i_1 \equiv i_2 \pmod K, j_1 \equiv j_2 \pmod K, (i_1+j_1) \equiv (i_2+j_2) \pmod{2K}$인 칸끼리 모은 뒤에 해결하면 됩니다.
이 문제는 set cover 문제로 모델링할 수 있고, 일반적으로 set cover 문제는 빠르게 해결하는 것이 매우 어렵습니다(NP-Hard). NP-Hard 문제에는 NP-Hard에 알맞는 풀이를 찾아야 한다는 것을 머리 속에 넣어두고 생각을 전개해 봅시다.
설명과 구현의 편의를 위해 상대 좌표가 $(-2K, 0), (-K, -K), (-K, 0), (0, 0)$인 칸들을 상대 좌표가 $(-1, -1), (-1, 0), (0, -1), (1, 1)$이 되도록 격자를 잘 회전하고 압축합시다. 또한 이들을 공격할 수 있는 작살의 좌표 $(-K, 0)$은 구현의 편의를 위해 $(1, 1)$으로 옮겨줍시다. 이제 이 문제는 $2\times 2$ 크기의 정사각형을 이용해 모든 물고기를 덮는 문제로 바뀌었습니다. 또한 $K \ge 2$이므로 새로 만든 격자의 한 변의 길이는 $\lceil 25/2\rceil$ 이하가 되었습니다. 이런 상황은 비트 DP를 사용하기 매우 좋은 조건입니다. 따라서 격자를 잘 분리하고 변환했다면, 그다음에는 비트 DP와 역추적만 잘 구현하면 문제를 해결할 수 있습니다.
분리된 격자의 크기를 $M\times M$이라고 하면, 비트 DP와 역추적은 $O(M^2 2^M)$ 시간에 수행할 수 있고, $M \approx 13$ 정도로 작기 때문에 제한 시간 내에 문제를 해결할 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
// bit & 1 => possible to use harpoon
// bit & 2 => there is a fish
const string X = "x.@O";
struct Pos{
int i, j;
Pos() : i(-1), j(-1) {}
Pos(int i, int j) : i(i), j(j) {}
};
int N, K, A[25][25], U[25][25], R[25][25];
bool Bound(int i, int j){ return 0 <= i && i < N && 0 <= j && j < N; }
// 상대 좌표가 {(-2K,0), (-K,-K), (-K,K), (0,0)}인 것을
// 상대 좌표가 {(-1,-1), (-1,0), (0,-1), (1,1)}이 되도록 변환
vector<vector<Pos>> Convert(int si, int sj){
auto conv = [&](int i, int j) -> pair<int,int> {
int add = (i - si) / K, sub = (j - sj) / K;
int r = (add + sub) / 2, c = (add - sub) / 2;
return {r, c};
};
auto origin = [&](int r, int c) -> pair<int,int> {
int add = r + c, sub = r - c;
return {add * K + si, sub * K + sj};
};
vector<pair<int,int>> pos;
int r1 = 0, r2 = 0, c1 = 0, c2 = 0;
for(int i=0; i<N; i++){
for(int j=0; j<N; j++){
int di = i - si, dj = j - sj;
if(di % K != 0 || dj % K != 0 || (di + dj) % (2*K) != 0) continue;
U[i][j] = 1;
auto [r,c] = conv(i, j);
pos.emplace_back(r, c);
r1 = min(r1, r); r2 = max(r2, r);
c1 = min(c1, c); c2 = max(c2, c);
}
}
int row = r2 - r1 + 2, col = c2 - c1 + 2; // +1 buffer
vector<vector<Pos>> coord(row, vector<Pos>(col));
// (si, sj) -> (0, -c1)
for(int r=0; r<row; r++){
for(int c=0; c<col; c++){
auto [i,j] = origin(r, c+c1);
coord[r][c] = {i, j};
}
}
return coord;
}
void Solve(int si, int sj){
auto pos = Convert(si, sj);
int n = pos.size(), m = pos[0].size();
vector fish(n, vector(m, 0));
vector item(n, vector(m, 0));
vector item_pos(n, vector(m, Pos()));
for(int r=0; r<n; r++){
for(int c=0; c<m; c++){
auto [i,j] = pos[r][c];
item[r][c] = Bound(i-K, j) && (A[i-K][j] & 1);
fish[r][c] = Bound(i, j) && (A[i][j] & 2);
item_pos[r][c] = {i-K, j};
}
}
int full = (1 << (m+1)) - 1;
vector dp(n, vector(m, vector(full+1, make_pair(-1,-1)))); // {물고기, -1 * 작살}
vector prv(n, vector(m, vector(full+1, make_pair(0,0)))); // {이전 비트, 작살 사용함?}
auto upd = [&](int i, int j, int k, pair<int,int> dp_v, pair<int,int> prv_v){
if(dp_v > dp[i][j][k]) dp[i][j][k] = dp_v, prv[i][j][k] = prv_v;
};
auto prv_coord = [&](int i, int j) -> pair<int,int> {
return {i - !j, j ? j-1 : m-1};
};
dp[0][0][0] = {0, 0};
if(item[0][0]) dp[0][0][1] = {fish[0][0], -1}, prv[0][0][1] = {0, 1};
for(int i=0; i<n; i++){
for(int j=0; j<m; j++){
if(i == 0 && j == 0) continue;
auto [pi,pj] = prv_coord(i, j);
for(int bit=0; bit<=full; bit++){
upd(i, j, (bit<<1)&full, dp[pi][pj][bit], {bit, 0});
if(!item[i][j]) continue;
int cnt = 0, nxt_bit = (bit << 1) & full;
if(i && j && fish[i-1][j-1] && (~bit >> m & 1)) cnt++;
if(i && fish[i-1][j] && (~bit >> m-1 & 1)) cnt++, nxt_bit |= 1 << m;
if(j && fish[i][j-1] && (~bit >> 0 & 1)) cnt++, nxt_bit |= 1 << 1;
if(fish[i][j]) cnt++, nxt_bit |= 1;
auto nxt_state = dp[pi][pj][bit];
nxt_state.first += cnt; nxt_state.second -= 1;
upd(i, j, nxt_bit, nxt_state, {bit, 1});
}
}
}
int i = n - 1, j = m - 1;
int k = max_element(dp[i][j].begin(), dp[i][j].end()) - dp[i][j].begin();
while(i >= 0){
if(prv[i][j][k].second) R[item_pos[i][j].i][item_pos[i][j].j] = 1;
k = prv[i][j][k].first; tie(i,j) = prv_coord(i, j);
}
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> K; K++;
for(int i=0; i<N; i++) for(int j=0; j<N; j++){ char c; cin >> c; A[i][j] = X.find(c); }
for(int i=0; i<N; i++) for(int j=0; j<N; j++) if(!U[i][j]) Solve(i, j);
for(int i=0; i<N; i++){
for(int j=0; j<N; j++) cout << ".v"[R[i][j]];
cout << "\n";
}
}
입력으로 주어진 $N$개의 수를 오름차순으로 정렬한 뒤 차례대로 $A_1, A_2, \cdots, A_N$이라고 합시다. 길이가 $K$인 구간 $[i, i+K-1]$을 모두 같은 값을 만들기 위한 최소 비용을 모든 $1 \le i \le N-K+1$에 찾는 방식으로 문제를 해결할 것입니다.
$A_i, A_{i+1}, \cdots, A_{i+K-1}$을 모두 어떤 값 $X$로 만들기 위해 필요한 연산의 횟수는 $f_i(X) = \sum_{k=i}^{i+K-1} \vert A_i - X \vert$이고, $X$가 $A_i, A_{i+1}, \cdots, A_{i+K-1}$의 중앙값일 때 $f_i(X)$가 최소가 됨이 잘 알려져 있습니다. 따라서 누적 합 배열을 이용하면 구간마다 상수 시간에 $f_i(X)$의 최솟값을 구할 수 있습니다.
입력으로 주어진 수를 정렬하는 데 $O(N \log N)$, 누적 합 배열을 만드는 데 $O(N)$, 구간마다 필요한 최소 연산 횟수를 구하는 데 $O(N)$ 만큼이 연산이 필요하므로 전체 시간 복잡도는 $O(N \log N)$입니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll N, K, A[303030], S[303030], R=LLONG_MAX;
ll Sum(int l, int r){ return l <= r ? S[r] - S[l-1] : 0; }
ll Solve(int l, int r){
ll res = 0;
int m = (l + r) / 2;
if(l <= m) res += A[m] * (m-l+1) - Sum(l, m);
if(m+1 <= r) res += Sum(m+1, r) - A[m] * (r-m);
return res;
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> K;
for(int i=1; i<=N; i++) cin >> A[i];
sort(A+1, A+N+1);
partial_sum(A+1, A+N+1, S+1);
for(int i=K; i<=N; i++) R = min(R, Solve(i-K+1, i));
cout << R;
}
$D(i, j) :=$ $i$번째 음표까지 연주했고, $i$번째 음표를 연주한 시각이 $j$일 때의 최소 오차라고 정의합시다.
점화식은 $D(i, j) = \min_{L_{i-1} \le k \le \min(R_{i-1}, j-x)} D(i-1, k) + \vert j - P_i \vert$ (단, $L_i \le j \le R_i$)와 같이 나타낼 수 있으며, naive하게 계산하면 $O(NX^2)$이 되어 서브태스크 2를 해결할 수 있습니다.
$\min D(i-1, k)$를 구하는 것은 사실 $D(i-1, \ast)$의 시작점이 $L_{i-1}$인 prefix min을 구하는 것과 같습니다. 따라서 prefix min을 전처리하거나 $j$를 증가시키면서 누적 최솟값을 관리하는 것을 통해 $O(NX)$로 최적화할 수 있고, 서브태스크 3을 해결할 수 있습니다.
모든 $L_i \le j \le R_i$를 고려하는 대신 $L_i, T_i, R_i$만 확인해도 된다는 사실을 이용하면 좌표 압축을 통해 DP 테이블의 크기를 $3N^2$으로 줄일 수 있습니다. 이 경우 시간 복잡도는 $O(N^2)$이 되어 100점을 받을 수 있습니다.
$L_i, T_i, R_i$ 대신 $L_i - ix, T_i - ix, R_i - ix$를 사용하면 $D(i, j) = \min_{k \le j} + \vert j - P_i \vert$처럼 되어 최솟값을 구하는 구간의 형태가 간단해 집니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr ll INF = 0x3f3f3f3f3f3f3f3f;
ll N, X, L[2020], P[2020], R[2020], D[2020][6060], Prv[2020][6060];
vector<ll> C;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> X;
for(int i=1; i<=N; i++) cin >> L[i] >> P[i] >> R[i];
for(int i=1; i<=N; i++) L[i] -= i * X, P[i] -= i * X, R[i] -= i * X;
for(ll* arr : {L, P, R}) C.insert(C.end(), arr+1, arr+N+1);
sort(C.begin(), C.end()); C.erase(unique(C.begin(), C.end()), C.end());
for(ll* arr : {L, P, R}) for(int i=1; i<=N; i++) arr[i] = lower_bound(C.begin(), C.end(), arr[i]) - C.begin();
// D[i][j] = min_{k<=j} D[i-1][k] + abs(j - P[i])
memset(D, 0x3f, sizeof D);
memset(Prv, -1, sizeof Prv);
for(int i=L[1]; i<=R[1]; i++) D[1][i] = abs(C[i] - C[P[1]]);
for(int i=2; i<=N; i++){
ll mn = INF, pos = 0;
for(int j=0; j<L[i]; j++) if(D[i-1][j] < mn) mn = D[i-1][j], pos = j;
for(int j=L[i]; j<=R[i]; j++){
if(D[i-1][j] < mn) mn = D[i-1][j], pos = j;
D[i][j] = mn + abs(C[j] - C[P[i]]); Prv[i][j] = pos;
}
}
int pos = min_element(D[N], D[N]+C.size()) - D[N];
if(D[N][pos] == INF){ cout << -1; return 0; }
vector<ll> res;
for(int i=N, j=pos; i>=1; j=Prv[i--][j]) res.push_back(C[j] + i * X);
reverse(res.begin(), res.end());
cout << D[N][pos] << "\n";
for(auto i : res) cout << i << " ";
}
모든 점 $(X, Y)$에 대해, 파란 점 $(x_b, y_b) \in B$는 $x_b \le X, y_b \le Y$, 빨간 점 $(x_r, y_r) \in R$은 $x_r > X, y_r > Y$가 되도록 만드는 최소 이동 횟수를 구하는 방식으로 접근합시다. 만약 한 번에 여러 개의 점을 이동시킬 수 있다면 $(X, Y)$에서의 정답은 $N$에서 (이동하지 않아도 되는 점의 개수)를 뺀 값, 즉 $N - #\lbrace (x_b,y_b) \in B; x_b \le X, y_b \le Y\rbrace - #\lbrace(x_r,y_r) \in R; x_r > X, y_r > Y\rbrace$ 입니다.
하지만 이 문제는 점을 한 번에 하나씩만 이동해야 합니다. 이 경우에는 $(X, Y)$의 좌하단 영역과 우상단 영역에 모두 빈 칸이 없을 때 정답이 1 만큼 증가하고, 그렇지 않은 경우에는 점을 여러 개 이동시킬 수 있는 문제에서의 정답과 같습니다.
$(X, Y)$를 $X$좌표가 증가하는 순서대로 보는 형태의 스위핑을 이용해 문제를 해결합시다. 각각의 $Y$좌표마다 분할점을 $(X, Y)$로 했을 때 옮기지 않아도 되는 점의 개수를 관리하면, 구간 최댓값 쿼리를 이용해 문제를 해결할 수 있습니다. 따라서 $Y$좌표마다 옮기지 않아도 되는 점의 개수를 세그먼트 트리로 관리합니다.
구체적으로, $X = -\infty$일 때는 $Y < y_r$이면 빨간 점 $(x_r, y_r)$을 옮기지 않아도 됩니다. 따라서 초기에 모든 빨간 점 $(x_r, y_r)$에 대해, $[1, y_r-1]$ 구간에 1씩 더한 상태로 시작합시다.
이후 $X$가 증가함에 따라 점의 정보를 갱신해야 합니다. $X = k$에서 $X = k+1$로 이동하면 $x_r = k+1$인 빨간 점은 모두 이동이 필요하며, $x_b = k+1$인 파란 점은 $Y \ge y_b$일 때 이동하지 않아도 됩니다. 따라서 $x_r = k+1$인 빨간 점이 있다면 $[1, y_r-1]$ 구간에 1을 빼고, $x_b = k+1$인 파란 점이 있다면 $[y_b, M]$ 구간에 1을 더해야 합니다.
이런 식으로 세그먼트 트리에 정보를 올바르게 저장했다면, 이제 고정된 $X$에 대해 가능한 $Y$에서의 최댓값을 구할 차례입니다. 파란 점이 있을 수 있는 $x$좌표 구간의 길이는 $X$이고, 파란 점이 있을 수 있는 $x$좌표 구간의 길이는 $M-x$입니다. 따라서 파란 점과 빨간 점의 개수를 각각 $C_b, C_r$이라고 하면 $\lceil C_b / X \rceil \le Y \le M - \lceil C_r / (M-X) \rceil$ 인 $Y$만 고려해야 합니다. 만약 이러한 $Y$가 존재하지 않으면 주어진 $X$에서 답이 존재하지 않는 것이고, $Y$가 존재하면 구간 최댓값 쿼리를 이용해 답을 구하면 됩니다.
이때 앞에서 이야기한 예외를 신경써야 하는데, $(X, Y)$의 좌하단 영역과 우상단 영역에 모두 빈 칸이 없는 경우를 잘 판별해야 합니다. 이는 처리하는 방법은 아래 코드의 63-65번째 줄을 참고하시길 바랍니다.
#include <bits/stdc++.h>
using namespace std;
constexpr int SZ = 1 << 20;
int T[SZ<<1], L[SZ<<1];
void Push(int node, int s, int e){
if(!L[node]) return;
T[node] += L[node];
if(s != e) L[node<<1] += L[node], L[node<<1|1] += L[node];
L[node] = 0;
}
void Add(int l, int r, int v, int node=1, int s=0, int e=SZ-1){
Push(node, s, e);
if(r < s || e < l) return;
if(l <= s && e <= r){ L[node] += v; Push(node, s, e); return; }
int m = (s + e) / 2;
Add(l, r, v, node<<1, s, m); Add(l, r, v, node<<1|1, m+1, e);
T[node] = max(T[node<<1], T[node<<1|1]);
}
int Get(int l, int r, int node=1, int s=0, int e=SZ-1){
Push(node, s, e);
if(r < s || e < l) return 0;
if(l <= s && e <= r) return T[node];
int m = (s + e) / 2;
return max(Get(l, r, node<<1, s, m), Get(l, r, node<<1|1, m+1, e));
}
int N, M;
vector<array<int,3>> A;
vector<int> C, Max, Min;
void Compress(){
for(const auto &[x,y,c] : A) for(int j=y-1; j<=y+1; j++) if(1 <= j && j <= M) C.push_back(j);
sort(C.begin(), C.end()); C.erase(unique(C.begin(), C.end()), C.end());
for(auto &[x,y,c] : A) y = lower_bound(C.begin(), C.end(), y) - C.begin();
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> M; A.resize(N);
for(auto &[x,y,c] : A){ char t; cin >> x >> y >> t; c = t == 'R'; }
Compress();
sort(A.begin(), A.end());
int res = 1e9, total_blue = 0, total_red = 0;
for(const auto &[x,y,c] : A) (c ? total_red : total_blue)++;
for(const auto &[x,y,c] : A) if(c) Add(0, y-1, 1);
Max.resize(N); Min.resize(N, M+1);
for(int i=0; i<N; i++) Max[i] = Min[i] = C[A[i][1]];
for(int i=1; i<N; i++) Max[i] = max(Max[i-1], Max[i]);
for(int i=N-2; i>=0; i--) Min[i] = min(Min[i+1], Min[i]);
auto solve = [&](int x, int pre_mx, int suf_mn){
if(x == 0 || x == M) return;
int y_blue = (total_blue + x - 1) / x;
int y_red = (total_red + M-x - 1) / (M-x);
if(y_blue + y_red > M) return;
int lo = lower_bound(C.begin(), C.end(), y_blue) - C.begin();
int hi = upper_bound(C.begin(), C.end(), M-y_red) - C.begin() - 1;
int fix = Get(lo, hi), full = 0;
if(fix < N && y_blue + y_red == M){
if(y_blue * x == total_blue && y_red * (M-x) == total_red && pre_mx == y_blue && suf_mn == y_blue + 1) full = 1;
}
res = min(res, N - fix + full);
};
for(int i=0, j=0; i<N; i=j){
solve(A[i][0]-1, i-1 >= 0 ? Max[i-1] : 0, Min[i]);
while(j < A.size() && A[i][0] == A[j][0]) j++;
for(int k=i; k<j; k++){
if(A[k][2]) Add(0, A[k][1]-1, -1);
else Add(A[k][1], C.size()-1, +1);
}
solve(A[i][0], Max[j-1], j < N ? Min[j] : M+1);
}
cout << (res < 1e9 ? res : -1);
}
2개의 점이 band의 한쪽 경계 위에 있는 최적해가 항상 존재함을 관찰합시다. 그러면 이 문제는 임의의 두 점을 연결하는 직선의 왼쪽(또는 오른쪽)에서, 직선과 거리가 $L$ 이하인 점의 개수를 세는 문제라고 생각할 수 있습니다.
이와 같은 문제를 해결하기 위해서는, 일단 주어진 기울기에 대해서 점들을 정렬한 뒤에 이분 탐색을 이용해 거리가 $L$ 이하인 점의 개수를 구해야 합니다. 불도저 트릭을 이용하면 가능한 $N(N-1)/2$가지 기울기에서의 정렬 순서를 $O(N^2 log N)$에 모두 구할 수 있고, 이후 이분 탐색을 이용해 각 기울기에 대해 $O(\log N)$에 답을 구할 수 있습니다. 따라서 전체 시간 복잡도는 $O(N^2 \log N)$입니다.
한 가지 조심해야 하는 점은, 실수 오차에 굉장히 민감한 문제입니다. $N = 3$에서도 double과 long double을 모두 터뜨리는 데이터를 만들 수 있으므로, 정수 타입만 이용해서 모든 계산을 수행해야 합니다. 구체적으로, 기울기의 법선 벡터 $\overrightarrow{n}(x, y)$와 그 벡터의 길이 $d = \sqrt{x^2+y^2}$가 주어지면, $\frac{\vert n \cdot p_i - n \cdot p_j \vert}{d} \le L$를 이용해 두 점 $p_i, p_j$의 거리가 $L$ 이하인지 판별할 수 있습니다. $d > 0$이므로 $d$를 오른쪽으로 넘긴 뒤 양변을 제곱하면 정수 타입만 이용해서 거리를 비교할 수 있습니다. 이때 계산 과정에서 등장하는 수의 크기가 좌표 범위의 네제곱 스케일까지 커질 수 있으므로 __int128_t와 같은 128비트 정수 자료형을 사용해야 합니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using LL = __int128_t;
struct point{
ll x, y; point() = default;
point(ll x, ll y) : x(x), y(y) {}
ll len_sq() const { return x * x + y * y; }
point rot90() const { return point(-y, x); }
bool operator < (const point &p) const { return tie(x,y) < tie(p.x,p.y); }
bool operator == (const point &p) const { return tie(x,y) == tie(p.x,p.y); }
point operator + (const point &p) const { return {x + p.x, y + p.y}; }
point operator - (const point &p) const { return {x - p.x, y - p.y}; }
ll operator * (const point &p) const { return x * p.x + y * p.y; }
ll operator / (const point &p) const { return x * p.y - y * p.x; }
friend istream& operator >> (istream &in, point &p){ return in >> p.x >> p.y; }
};
struct segment{
ll i, j, dx, dy; // dx >= 0
segment(int i, int j, const point &pi, const point &pj) : i(i), j(j), dx(pj.x-pi.x), dy(pj.y-pi.y) { norm(); }
void norm(){ ll g = __gcd(dx, abs(dy)); dx /= g; dy /= g; }
bool operator < (const segment &l) const {
return make_tuple(dy * l.dx, i, j) < make_tuple(dx * l.dy, l.i, l.j);
}
bool operator == (const segment &l) const {
return dy * l.dx == dx * l.dy;
}
};
ll N, L, P[1515], In[1515];
point A[1515];
LL FloorSqrt(LL x){
if(x == 0) return 0;
LL v = sqrtl(x);
while((v+1) * (v+1) <= x) v++;
while(v * v > x) v--;
return v;
}
void Solve(){
cin >> N >> L;
for(int i=1; i<=N; i++) cin >> A[i];
sort(A+1, A+N+1); iota(P+1, P+N+1, 1);
if(N == 1){ cout << "1\n"; return; }
vector<segment> V; V.reserve(N*(N-1)/2);
for(int i=1; i<=N; i++) for(int j=i+1; j<=N; j++) V.emplace_back(i, j, A[i], A[j]);
sort(V.begin(), V.end());
int res = 1;
for(int i=0, j=0; i<V.size(); i=j){
while(j < V.size() && V[i] == V[j]) j++;
for(int k=i; k<j; k++){
int u = V[k].i, v = V[k].j; // point id, index -> P[id]
swap(P[u], P[v]); swap(A[P[u]], A[P[v]]);
if(P[u] > P[v]) swap(u, v);
point normal = (A[P[u]] - A[P[v]]).rot90();
ll limit = FloorSqrt(LL(normal.len_sq()) * L * L);
int l = 1, r = P[v];
while(l < r){
int m = (l + r) / 2;
if(normal * A[P[v]] - normal * A[m] <= limit) r = m;
else l = m + 1;
}
res = max<int>(res, P[v] - r + 1);
l = P[u]; r = N;
while(l < r){
int m = (l + r + 1) / 2;
if(normal * A[m] - normal * A[P[u]] <= limit) l = m;
else r = m - 1;
}
res = max<int>(res, l - P[u] + 1);
}
}
cout << res << "\n";
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int TC; cin >> TC;
for(int tc=1; tc<=TC; tc++) Solve();
}
$A_i$를 첫 번째 위치로 옮길 때, $N$번째 위치에 올 수 있는 최댓값을 구하는 식으로 문제를 해결합니다.
$A_i$를 첫 번째 위치로 옮기는 과정에서 $i-1$번의 교환이 필요하고, 이후 $A_j$ (단, $i \ne j$)를 $N$번째 위치로 옮길 때 $N-j-[j<i]$번의 교환이 필요합니다. 이때 $[\text{condition}]$은 아이버슨 괄호로, condition이 참이면 1, 거짓이면 0을 의미합니다. 따라서 $N-j-[j<i] \le K-(i-1)$을 만족하는 모든 $j$들 중 $A_j$의 최댓값을 구하면 문제를 해결할 수 있습니다.
$A_i$를 맨 앞으로 옮긴 뒤에 남은 교환 횟수인 $K-(i-1)$을 $r$이라는 문자로 치환한 뒤에 식을 정리하면, $r < N-i$ 이면 $A[N-r\cdots N]$의 최댓값, $r \ge N-i$ 이면 $A[N-rem-1\cdots N]$의 최댓값을 구하면 된다는 것을 알 수 있습니다. 주어진 수열 $A$의 suffix max를 전처리하면 $O(N)$ 시간에 답을 구할 수 있습니다.
똑같은 값이 첫 번째와 $N$번째에 모두 들어가게 되는 경우를 제외해야 하지 않냐는 의문이 생길 수 있는데, 그러한 동작을 하기 위해서는 $K \ge N-1$이어야 하고, $K \ge N-1$이면 정답이 항상 0 초과이기 때문에 따로 예외 처리를 하지 않더라도 계산 결과에 영향을 주지 않습니다.
#include <bits/stdc++.h>
using namespace std;
int N, K, A[202020], Max[202020], R=-1e9;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
cin >> N >> K;
for(int i=1; i<=N; i++) cin >> A[i];
for(int i=N; i>=1; i--) Max[i] = max(Max[i+1], A[i]);
for(int i=1; i<=N; i++){
int rem = K - i + 1;
if(rem < 0) continue;
int st = rem < N - i ? N - rem : N - rem - 1;
R = max(R, Max[max(1,st)] - A[i]);
}
cout << R;
}
모든 정점의 자손의 수의 합은 모든 정점의 조상의 수의 합과 같습니다. 즉, 서브 트리 크기의 합을 구하는 대신 모든 정점의 높이의 합을 구하는 문제로 생각해도 괜찮습니다. 따라서 모든 $h$에 대해 높이가 $h$인 정점의 개수를 구하는 것으로 문제를 해결할 수 있습니다.
시간 복잡도는 트리의 높이에 비례하는데, $K \ge 2$이면 트리의 높이가 $O(\log_K N)$이 되어 괜찮지만 $K = 1$일 때가 문제입니다. $K = 1$일 때는 답이 $N(N+1)/2$임을 쉽게 알 수 있으므로 이 경우만 예외 처리하면 문제를 풀 수 있습니다. $P$가 짝수가 될 수 있음에 유의해야 합니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using L = __uint128_t;
void Solve(){
ll N, K, P, R=0; cin >> N >> K >> P;
if(K == 1){ cout << ll(L(N)*(N+1)/2 % P) << "\n"; return; }
ll div_guard = N / K;
for(ll h=1, v=1; N; h++, v=(v<=div_guard?v*K:N)){
ll now = min(N, v); N -= now;
R = (R + now % P * h) % P;
}
cout << R << "\n";
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int TC; cin >> TC;
for(int tc=1; tc<=TC; tc++) Solve();
}
가장 마지막에 사용하는 로봇은 $K_i \ge 0$을 만족해야 하고, 뒤에서 두 번째로 사용하는 로봇은 $K_i \ge 1$을 만족해야 합니다. 비슷하게, 뒤에서 $t$번째로 사용하는 로봇은 $K_i \ge t-1$을 만족해야 합니다. 이를 이용하면 $K_i$가 단조감소하도록 로봇을 사용하는 최적해가 항상 존재함을 증명할 수 있습니다. 이제부터는 $K_i$가 단조감소하는 해만 고려하겠습니다.
위에서 본 부등식을 생각해 보면, $K_i \ge N$인 로봇을 최대 1개, $K_i \ge N-1$인 로봇을 최대 1개, $\cdots$ , $K_i \ge 0$인 로봇을 최대 1개 선택하는 것으로 유효한 해를 만들 수 있고, 유효한 해는 항상 이런 식으로 만들 수 있다는 것을 알 수 있습니다. 따라서 $K_i$가 큰 로봇부터 차례대로 보면서 매번 아직 선택하지 않은 로봇 중 $D_i$가 가장 큰 로봇을 선택하는 그리디 전략이 성립하고, 우선순위 큐 등을 이용해 $O(N \log N)$ 시간에 문제를 해결할 수 있습니다.
이러한 그리디 전략이 성립하는 이유와, $K_i$가 작은 로봇부터 보면 안 되는 이유를 고민해 보면 좋을 것입니다.
로봇의 집합을 $S$, 유효한 해를 구성하는 로봇 집합들의 집합을 $I$라고 정의하면 $M = (S, I)$는 매트로이드 구조를 이룬다는 것을 이용한 풀이도 있습니다. 단, independence oracle을 $O(N)$보다 빠르게 구현해야 100점을 받을 수 있습니다.
#include <bits/stdc++.h>
using namespace std;
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);
int N, R=0; cin >> N;
vector<pair<int,int>> A(N);
for(auto &[a,b] : A) cin >> a >> b;
sort(A.begin(), A.end(), greater<>());
priority_queue<int> Q;
for(int k=N, i=0; k>=0; k--){
while(i < N && A[i].first == k) Q.push(A[i++].second);
if(!Q.empty()) R += Q.top(), Q.pop();
}
cout << R;
}
레벨이 $i$인 장비를 $N$으로 만들기 위해 필요한 비용의 기댓값을 $E(i)$라고 합시다. $E(N) = 0$이고, $E(0)$을 최소화해야 합니다.
현재 레벨이 $i$일 때 $i \rightarrow d$ 하락 방지 쿠폰을 구매하는 상황을 생각해 봅시다. 레벨이 $j$가 되는 이벤트가 발생했을 때 실제로 도달하게 되는 레벨을 $next(i,j,d)$라고 정의하면, $E(i)$는 다음과 같은 부등식이 성립함을 알 수 있습니다. 또한, 최적 전략에 해당하는 $d$에서 등호가 성립합니다. (단, $-1 \le d < i$; $D(i, -1) = 0$)
\[E(i) \le C_i + D(i, d) + \sum_{j=0}^N P_{i,j}E(next(i,j,d))\] \[E(i) - \sum_{j=0}^N P_{i,j}E(next(i,j,d)) \le C_i + D(i, d)\]즉, $A_{t,0}E(0) + A_{t,1}E(1) + \cdots + a_{t,N-1}E(N-1) \le B_t$ 꼴의 부등식이 여러 개 주어지면, 모든 부등식이 참이 되도록 할 때 가능한 $E(0)$의 최댓값을 구하는 것으로 문제를 해결할 수 있습니다. (단, $E(i) \ge 0$; $E(N) = 0$)
이러한 형태의 문제를 선형 계획법(Linear Programming)이라고 부르고, Problem Solving 범위에서는 Simplex method를 이용해 해결하는 방법이 널리 알려져 있습니다. 따라서 Simplex method 구현체를 이용하거나, NYPC 채점 시스템에서 지원되는 SciPy의 scipy.optimize.linprog를 이용하면 문제를 해결할 수 있습니다.
from scipy.optimize import linprog
def next(i, j, d):
if d == -1: return j
return j if j > i else max(j, d+1)
def solve():
N = int(input())
C = list(map(int, input().split()))
D = [[]] + [list(map(int, input().split())) for _ in range(N-1)]
P = [list(map(int, input().split())) for _ in range(N)]
P = [[p / 1_000_000 for p in row] for row in P]
a, b, c = [], [], [-1] + [0] * (N-1)
for i in range(N):
for d in range(-1, i):
coeff = [0] * (N+1)
coeff[i] = 1
for j in range(N+1): coeff[next(i,j,d)] -= P[i][j]
a.append(coeff[:N])
b.append(C[i] + (D[i][d] if d >= 0 else 0))
bounds = [(0, None) for _ in range(N)]
res = linprog(c=c, A_ub=a, b_ub=b, bounds=bounds)
print('%.20f' % -res.fun)
for _ in range(int(input())): solve()
#include <bits/stdc++.h>
using namespace std;
using ld = double;
// Solves the canonical form: maximize c^T x, subject to ax <= b and x >= 0.
template<class T> // T must be of floating type
struct linear_programming_solver_simplex{
int m, n; vector<int> nn, bb; vector<vector<T>> mat;
static constexpr T eps = 1e-8, inf = numeric_limits<T>::max();
linear_programming_solver_simplex(
const vector<vector<T>> &a, const vector<T> &b, const vector<T> &c
) : m(b.size()), n(c.size()), nn(n+1), bb(m), mat(m+2, vector<T>(n+2)){
for(int i=0; i<m; i++) for(int j=0; j<n; j++) mat[i][j] = a[i][j];
for(int i=0; i<m; i++) bb[i] = n + i, mat[i][n] = -1, mat[i][n + 1] = b[i];
for(int j=0; j<n; j++) nn[j] = j, mat[m][j] = -c[j];
nn[n] = -1; mat[m + 1][n] = 1;
}
void pivot(int r, int s){
T *a = mat[r].data(), inv = 1 / a[s];
for(int i=0; i<m+2; i++){
if(i != r && abs(mat[i][s]) > eps) {
T *b = mat[i].data(), inv2 = b[s] * inv;
for(int j=0; j<n+2; j++) b[j] -= a[j] * inv2;
b[s] = a[s] * inv2;
}
}
for(int j=0; j<n+2; j++) if(j != s) mat[r][j] *= inv;
for(int i=0; i<m+2; i++) if(i != r) mat[i][s] *= -inv;
mat[r][s] = inv; swap(bb[r], nn[s]);
}
bool simplex(int phase){
for(auto x=m+phase-1; ; ){
int s = -1, r = -1;
for(auto j=0; j<n+1; j++) if(nn[j] != -phase) if(s == -1 || pair(mat[x][j], nn[j]) < pair(mat[x][s], nn[s])) s = j;
if(mat[x][s] >= -eps) return true;
for(auto i=0; i<m; i++){
if(mat[i][s] <= eps) continue;
if(r == -1 || pair(mat[i][n + 1] / mat[i][s], bb[i]) < pair(mat[r][n + 1] / mat[r][s], bb[r])) r = i;
}
if(r == -1) return false;
pivot(r, s);
}
}
// Returns -inf if no solution, {inf, a vector satisfying the constraints}
// if there are abritrarily good solutions, or {maximum c^T x, x} otherwise.
// O(n m (# of pivots)), O(2 ^ n) in general.
pair<T, vector<T>> solve(){
int r = 0;
for(int i=1; i<m; i++) if(mat[i][n+1] < mat[r][n+1]) r = i;
if(mat[r][n+1] < -eps){
pivot(r, n);
if(!simplex(2) || mat[m+1][n+1] < -eps) return {-inf, {}};
for(int i=0; i<m; i++){
if(bb[i] == -1){
int s = 0;
for(int j=1; j<n+1; j++) if(s == -1 || pair(mat[i][j], nn[j]) < pair(mat[i][s], nn[s])) s = j;
pivot(i, s);
}
}
}
bool ok = simplex(1);
vector<T> x(n);
for(int i=0; i<m; i++) if(bb[i] < n) x[bb[i]] = mat[i][n + 1];
return {ok ? mat[m][n + 1] : inf, x};
}
};
int N, C[22], D[22][22];
ld P[22][22];
void Solve(){
cin >> N;
for(int i=0; i<N; i++) cin >> C[i];
for(int i=1; i<N; i++) for(int j=0; j<i; j++) cin >> D[i][j];
for(int i=0; i<N; i++) for(int j=0; j<=N; j++) cin >> P[i][j];
for(int i=0; i<N; i++) for(int j=0; j<=N; j++) P[i][j] /= 1e6;
vector<vector<ld>> a;
vector<ld> b, c;
// next(i, j, d) := j > i ? j : max(j, d+1)
// X[i] <= C[i] + D[i][d] + sum_{j=0}^{N} P[i][j] X[next(i,j,d)] (-1 <= d < i)
// X[i] - sum P[i][j] X[next(i,j,d)] <= C[i] + D[i][d]
c.resize(N); c[0] = 1;
auto f = [](int i, int j, int d) -> int {
if(d == -1) return j;
else return j > i ? j : max(j, d+1);
};
for(int i=0; i<N; i++){
for(int d=-1; d<i; d++){
vector<ld> coeff(N);
coeff[i] = 1;
for(int j=0; j<=N; j++) if(f(i,j,d) < N) coeff[f(i,j,d)] -= P[i][j];
a.push_back(coeff);
b.push_back(C[i] + (d >= 0 ? D[i][d] : 0));
}
}
linear_programming_solver_simplex<ld> solver(a, b, c);
cout << fixed << setprecision(20) << solver.solve().first << "\n";
}
int main(){
ios_base::sync_with_stdio(false); cin.tie(nullptr);int TC; cin >> TC;
for(int tc=1; tc<=TC; tc++) Solve();
}
올해도 어김없이 대회를 열었습니다.
2019년 말 ‘Good Bye, BOJ 2019!’로 시작된 이 대회는 매년 연말연시에 개회(하는 것을 목표로)하는 행사입니다. 대회 운영 방식은 일단 9~10월에 사람을 모은 뒤, 한 명이 총대를 메고 행사 개최를 성사시키면, 나머지 사람들이 열심히 문제를 만들어서 대회를 성공적으로 마무리한 뒤, “내년에는 절대 하지 말자”라는 다짐과 함께 해산하는 식입니다. 대회 총대의 막중한 책임은 주로 leejseo와 ryute, 또는 당해 전대프연 회장이 번갈아 가면서 짊어지고 있습니다.
올해도 일단 사람을 불러 모은 뒤 나머지는 leejseo와 ryute가 준비할 것이라 믿고 별로 신경 쓰지 않고 있었습니다. 그러나 경기 침체로 인해 후원사 섭외에 어려움이 생겼고, 대회 개최가 불투명해졌습니다. 언젠가는 해결될 것이라 생각하고 기다렸지만 12월이 되어도 소식이 없자, 앞으로의 계획에 대한 진지한 고민에 빠졌습니다. 2019년처럼 소규모로 대회를 열고 친한 사람들끼리 뒷풀이 겸 신년회를 여는 방안이 가장 유력했습니다만…
극적으로 12월 말에 스타트링크에서 대회 후원 의사를 밝혀주셨습니다. 즉시 문제를 모으기 시작했고, 1월 중순부터는 본격적인 대회 준비에 돌입했습니다. 이 시점부터는 대회 개최에 대한 불안감은 사라졌습니다. 대회에 낼 문제 아이디어도 모두 모였고, 운영진 모두 크고 작은 대회를 이끌어 본 경험이 있었기에 문제 작업과 행정 처리 모두 원활하게 진행될 것이라 확신했습니다.
다른 건 별로 걱정이 안 됐지만, 대회 일정과 장소, 그리고 오프라인 대회 초청 방법 때문에 고민이 많았었습니다.
‘Hello, BOJ’라는 이름에 걸맞게 1월 개최가 이상적이나, 오프라인 대회를 2주 만에 준비하는 것은 현실적으로 불가능했습니다. 게다가 2월 24일부터는 일부 학교(특히 카이스트)가 개강하므로, 2월 9일, 16일, 23일을 후보로 잡고 장소 대관이 가능한 가장 늦은 날짜로 결정하기로 했습니다. 대회 장소 역시 여러 후보를 검토했는데, 다행히도 작년 ‘Hello, BOJ 2024!’를 통해 인연을 맺은 LG전자에서 다시 한번 장소를 후원해 주시겠다고 했습니다. 덕분에 장소 확보와 대관 비용에 대한 걱정을 모두 덜 수 있었습니다.
지난 2년간은 온라인 대회인 ‘Good Bye, BOJ’ 대회를 통해 오프라인 대회 ‘Hello, BOJ’의 참가자를 선발했으나, 이번에는 1월부터 준비를 시작했기에 새로운 선발 방식이 필요했습니다. 이에 관한 내용은 뒤에서 다시 설명하겠습니다.
첫 대회를 운영할 당시 고등학생이었던 제가 어느새 대학을 졸업하고 직장인이 되었습니다. 당연히 저만 변한 것은 아니고, 함께 대회를 운영하던 친구들도 직장인이나 대학원생이 되면서 PS와의 거리가 멀어졌으며, 대회 운영에 투자할 수 있는 시간도 줄어들었습니다. 이러한 상황에서 한 사람이 모든 책임을 지는 방식은 더 이상 지속 가능하지 않다고 판단했습니다. 따라서 저와 leejseo, ryute가 역할을 분담하기로 했습니다.
후원사(LG전자, 삼성전자 등) 연락은 leejseo가, 스타트링크와의 소통과 예산 관리 ryute가 맡았으며, 저는 행사 기획, 예산안 작성, 각종 플랫폼(드라이브/폴리곤/오버리프) 관리 등 다양한 실무를 담당했습니다. 돌이켜보니 ryute는 제가 제안한 기획안과 예산 편성을 검토하고 최종 승인하는 중요한 역할도 함께 수행했습니다.
이번 대회부터는 Overleaf 서버도 관리해야 했습니다. 2024년 11월부터 Overleaf가 무료 플랜의 프로젝트 공유 인원 한도를 대폭 축소하여 더 이상 대회에서 사용할 수 없게 되었기 때문입니다. 그래서 UCPC 2020 때처럼 self-host Overleaf를 운영하기로 했고, 제가 서버 구축 및 관리를 담당하게 되었습니다.
문제 관리는 의도적으로 맡지 않았습니다. 저는 별다른 어려움 없이 해결할 수 있는 문제(플래티넘 중위권 이하)로만 구성된 대회라면 혼자서도 완벽하게 관리할 자신이 있지만, 그보다 높은 난이도의 문제가 포함되면 퍼포먼스가 급격하게 떨어집니다. 따라서 저보다 실력이 뛰어난 분들을 믿고, 저는 ryute와 함께 중요한 의사결정이 필요할 때만 참여하거나 가끔씩 진행 상황을 확인하는 정도로만 관여했습니다.
대회 준비 초기에 가장 많이 고민했던 부분입니다. 오랜 고민 끝에 다음과 같은 핵심 원칙을 세우고, 이를 충족하면서도 참가자들이 납득할 수 있는 기준을 마련하고자 했습니다.
BOJ 대회의 정체성을 지키고 사칭의 가능성을 배제하기 위해 Codeforces 나 AtCoder 같은 외부 플랫폼은 배제하기로 했습니다. 이 때문에 첫 번째 원칙을 충족하는 기준을 마련하는 것이 특히 어려웠습니다. 논의 과정에서 여러 의견이 나왔고, 최종적으로는 다음과 같이 결정했습니다.
대회 종료 후 참가자 피드백(31명 응답)은 다음과 같습니다.
| 별점 (평균 4.26) | 1점 | 2점 | 3점 | 4점 | 5점 |
|---|---|---|---|---|---|
| 인원 (총 31명) | 1명 (3.2%) | 0명 (0.0%) | 3명 (9.7%) | 13명 (41.9%) | 14명 (45.2%) |
| D1 이상 | 골드 500/전체 2000문제 | 다이아몬드 top 15 | 플래티넘 top 15 | 골드 top 12 | |
|---|---|---|---|---|---|
| 만족 | 21 | 24 | 19 | 19 | 17 |
| 불만족 | 6 | 5 | 7 | 5 | 4 |
의견 수렴 결과, 1-2번에 비해 3-5번의 만족도가 낮게 나왔습니다. 또한 3-5번의 커트라인이 예산보다 낮았던 점을 고려하면, 향후 유사한 방식으로 참가자를 선정한다면 인원을 줄이는 것도 좋아 보입니다.
2018년에 논란이 많았던 한 알고리즘 대회에서 주최 측이 “대회가 아닌 축제다”라는 이야기를 했었습니다. 저를 비롯한 알고리즘을 공부하는 사람들은 이 말을 매우 싫어했는데, 대회 자체에 오류도 많았고 진행 과정에서도 잡음이 많았기 때문입니다. 이런 상황에서 “우리는 대회가 아니라 축제를 지향한다”는 말은, 이 분야를 공부하는 사람들뿐만 아니라 분야 자체를 모욕하는 것으로 들렸기 때문입니다.
그렇지만 저 역시도 3-5시간 동안 문제만 풀다가 집에 가는 행사보다는, 다양한 재미있는 이벤트를 함께 열어 모두가 즐길 수 있는 행사를 만들어보고 싶다는 생각을 5년 전부터 해왔습니다. 전국에서 알고리즘을 공부하는 사람들이 100명가량 모이는 흔치 않은 기회인 만큼, 하루 종일 함께 즐기다 간다면 더 좋을 테니까요. 예전에 감명 깊게 읽었던 koosaga님의 AtCoder Code Festival 2017 후기(링크)나, 작년에 경험했던 3번의 해외 ICPC 대회(2024 ICPC Asia Pacific Championship, 2023/2024 ICPC World Finals)를 떠올리며, 알고리즘을 열심히 공부한 (대체로 내성적인) 사람들도 충분히 즐길 수 있는 콘텐츠를 기획할 수 있겠다는 확신이 들었습니다.
UCPC와 Hello BOJ 대회는 전통적으로 대회 종료 후 출제자가 각 문제의 풀이를 설명하는 시간을 갖습니다. 하지만 대회가 끝난 뒤 지친 참가자들에게 별로 궁금하지 않을 수도 있는 풀이를 강제로 듣게 하는 것은 고역일 수 있다는 생각을 늘 해왔습니다. 마침 이번에는 출제자가 한 명만 참석했기 때문에 풀이를 진행하지 않을 명분이 생겼고, 이로 따라 생긴 시간을 다른 이벤트로 채우기로 했습니다.
대회 시간을 13:30부터 16:30으로 정한 이유는 여러 가지가 있지만, 가장 중요한 이유는 참가자들에게 식사를 제공할 비용을 줄이기 위해서였습니다. 점심과 저녁 시간 사이 애매한 시간에 대회를 열면 식사 제공을 생략할 수 있기 때문입니다. 대회 앞뒤로 다양한 이벤트를 추가해 식사 시간을 포함하게 되면 대회를 13:00-16:30으로 잡은 이유가 퇴색되기 때문에, 짧은 시간 동안 재미있게 즐길 수 있는 콘텐츠를 준비하기 위해 더욱 신경 썼습니다.
대회 참가자 등록 마감과 시작 사이의 간격이 30분밖에 되지 않고, 대부분의 참가자는 이 시간 동안 오랜만에 만난 사람들과 대화를 나누기 때문에 대회 시작 전 이벤트를 진행하는 것은 어려웠습니다. 따라서 이벤트를 진행할 수 있는 타이밍은 대회 도중과 대회 종료 후뿐입니다. 대회 중에는 더 이상 풀 문제가 없는 참가자를 위해 자리에서 조용히 진행할 수 있는 작은 이벤트를, 대회 종료 후에는 시끌벅적하면서도 내성적인 사람들도 편하게 참여할 수 있는 이벤트를 목표로 준비했습니다.
마침 이번 대회는 로고가 없었고, 준비 기간도 짧아 문제지 표지를 디자인할 시간도 없었습니다. 그래서 문제지 표지 일부를 비워두고, 참가자들이 직접 로고를 그려넣어 응모하는 이벤트를 기획했습니다. BOJ 알파벳을 이용해 캐릭터를 만든 작품, 오토마타를 활용해 ‘Hello BOJ 2025’를 표현한 작품, 체스판과 Knight Tour를 이용해 ‘Hello BOJ 2025’를 구현한 작품 등 흥미로운 결과물이 많이 나왔습니다.
대회 종료 후에는 Kahoot!을 활용한 퀴즈를 진행했습니다. 이는 예전에 친구들과 그림을 보고 어떤 문제인지 맞히는 놀이에서 아이디어를 얻은 것으로, 그림 퀴즈 외에도 BOJ와 PS 분야 전반에 관한 문제를 포함했습니다. 퀴즈 문제와 코멘트는 뒤에서 확인할 수 있습니다.
대회 종료 후 이벤트에 관한 참가자 피드백(31명 응답)은 다음과 같았습니다.
| 별점 | 1점 | 2점 | 3점 | 4점 | 5점 |
|---|---|---|---|---|---|
| 로고 (평균 4.42) | 0명 (0.0%) | 0명 (0.0%) | 3명 (9.7%) | 12명 (38.7%) | 16명 (51.6%) |
| 퀴즈 (평균 5.52) | 0명 (0.0%) | 1명 (3.2%) | 4명 (12.9%) | 4명 (12.9%) | 22명 (71.0%) |
‘대회 중에 로고 이벤트 외에도 조금 더 긴 호흡으로 재미있게 즐길 컨텐츠가 있으면 좋겠다’, ‘PS 과몰입자가 아닌 사람에게는 퀴즈 문제가 어렵다’, ‘대회 직후 풀이를 어떤 형태로든 공개하면 좋을 것 같다’ 라는 의견도 있었습니다.






원래는 첫 번째 문제에서 ICPC 로고를 보여준 다음, 두 번째 문제에서 Codeforces 로고를 보여주면서 ICPC 색깔 순서를 물어보고, 세 번째 문제에서 AtCoder 로고를 보여주면서 Codeforces 색깔 순서를 물어보려고 했었습니다. 욕 많이 먹을 것 같아서 실천하지는 않았습니다.
다행히 올해도 큰 문제 없이 성공적으로 대회를 마무리 지었습니다. 매번 하는 이야기지만, 2019년과 2020년에 친구들과 별생각 없이 열었던 대회가 너무 커져 버려서 2021년부터 매년 작지 않은 부담을 느끼며 대회를 준비하고 있습니다. 빅뱅의 지드래곤이 무대를 준비하면서 했던 말처럼, ‘잘해도 본전’이라는 말이 딱 어울리는 것 같습니다. 다행히 한 번도 빠지지 않고 매년 대회를 같이 준비하는 ryute와 leejseo, 그리고 대회 운영과 문제 작업을 담당해 주시는 많은 분들 덕분에 항상 멋진 대회를 개최하고 있습니다. 정말 감사합니다. 대회를 후원해 주신 스타트링크, 솔브드, 삼성전자 소프트웨어멤버십, LG전자에도 이 자리를 빌려 감사의 말씀을 전합니다.
이 대회를 계속 운영할지, 아니면 놓아줄지는 항상 고민하는 주제입니다. 작년 3월에 두 번째 ICPC World Finals 진출을 확정 지은 이후로 PS와 점점 멀어지고 있으며, 직장에 들어오고 대학을 졸업한 이후로는 더 빠른 속도로 멀어지고 있습니다. 아마 예전(2019-2023년)처럼 열정적으로 PS를 연습할 일은 영원히 없지 않을까 싶습니다. 글을 올려다 보니 문제 이야기가 하나도 없는 게 PS와 멀어지고 있는 게 실감이 나네요.
그럼에도 불구하고 PS는 정말 좋은 취미이며, 친구들과 대회를 열고 참가자들이 즐거워하는 모습을 보는 것은 제가 가장 큰 보람을 느끼는 일 중 하나입니다. 앞으로 반년 동안 잘 고민하고 올해 9월에 결정하려고 합니다. 그런데 아마 하지 않을까요? 잘 모르겠습니다.
대회에 참가해 주신 분들과 길고 재미없는 이 글을 끝까지 읽어주신 분들께 감사드립니다.
]]>아직도 고등학생 시절과 똑같은 삶을 살고 있는데 어느새 대학교 졸업이 코앞으로 다가와 버린 만 22세 대학교 4학년 직장인의 이야기입니다. 지난 몇 년 동안 연말정산 글 분량의 대부분을 PS로 채웠는데, 올해라고 크게 다르진 않습니다. 4학년인 만큼 대학교 생활의 전반적인 리뷰와 취업 관련 내용이 조금 추가되었습니다.
2020년 11월에 대학교 합격을 확정지은 뒤, 대학교에 다니는 동안 시기 별로 이루고 싶은 목표와 나름의 계획을 세웠었습니다.
간단히 요약하면, 2학년 때까지 PS로 얻을 수 있는 이득을 모두 얻고 취업의 하한선을 끌어올린 뒤, 남은 시간 동안 제가 미처 경험해 보지 않은 분야를 살펴보면서 진로를 정하고 싶다는 계획을 세웠었습니다. 하지만 현실은…
연구와 맞지 않는다는 것을 1학년 1학기에 깨달은 것은 계획보다 빨랐지만 오히려 좋은 흐름이라고 생각하고, SCPC에서 상을 받지 못한 것은 앞으로도 기회가 많이 남았으므로 별로 문제가 되지 않습니다. 하지만 ICPC World Finals에 진출한 것이 2학년 때 PS를 접겠다는 계획에 제동을 걸었습니다.
사실 저 말고도 이 대회 때문에 인생의 계획이 꼬인 사람은 많습니다. 지난 몇 년 동안 COVID-19 때문에 WF 개최가 1년 정도 밀려있었고, ICPC HQ는 2022 WF와 2023 WF를 2023년 11월에 이집트에서 동시 개최하겠다는 결단을 내렸습니다. 그리고 그 대회는 이스라엘-하마스 전쟁 때문에 2024년 4월로 연기되었고, 이에 따라 2021년과 2022년 ICPC 리저널 대회를 치른 사람들은 2024년 4월까지 PS도 못 접고 군대도 못 가고 대회 개최만 기다리게 되었습니다.
아무튼 PS를 못 접게 된 저는 “이왕 이렇게 된 거 WF 한 번 더 가자”는 마인드로 휴학 계획을 철회하고 2023년에도, 2024년에도 PS를 하게 됩니다.
2023년에는 2022년보다 훨씬 강한 팀으로 ICPC에 참가해 순조롭게 WF 티켓을 따냈습니다. 하지만 졸업을 1년 정도밖에 남겨두지 않은 시점에서 SCPC 수상에 한 번 더 실패하면서 처음으로 졸업 전에 취업할 수 있을지 진지하게 고민하게 되었습니다. PS로 취업을 날로 먹겠다는 생각을 버리고 진로를 고민하려고 했으나 할 줄 아는 게 없어서 고민하고 있던 와중에, 다행히 네이버에서 자료구조를 잘하는 인턴을 구한다는 공고를 보고 지원해 합격하게 되어 취업 걱정을 어느 정도 덜게 되었습니다.
2024년에는 애증의 대회인 ICPC World Finals에 2번 참가했고, 대회 참가 4번째 만에 드디어 SCPC에서도 상을 받았습니다. 이미 다른 회사에 합격한 상태여서 수상의 기쁨은 과거의 제 간절함에 비해 덜했지만, 그래도 8년 동안의 알고리즘 대회 여정에서 유종의 미를 거두었다는 점에서 참 기뻤습니다.
이 밖에도 대학교에 다니면서 꼭 이루고 싶은 목표로 “선린인터넷고등학교 나정휘보다 숭실대학교 나정휘를 유명하게 만들기”가 있었습니다. 고등학생 때부터 백준 랭커에 블로그도 유명하고 UCPC 출제도 하는 등 PS 판에서는 어느 정도 인지도가 있었습니다. 하지만 과거에만 머물러 있지 않고 더욱 발전하는 사람이 되고 싶었고, 그때보다 더 좋은 성과를 거두고 싶었기에 저런 목표를 세웠습니다. 그리고 그 목표는 다행히 성공적으로 이룬 것 같습니다.
졸업 전에 어떻게든 취업하는 것을 제외하면 계획대로 된 것이 하나도 없지만 목표했던 바를 졸업 전에 모두 이루었다는 점에서, 그리고 인턴 때와 지금 모두 회사에서 모두 분산 시스템을 다루는 점에서, 제 대학 생활은 eventually consistency 정도의 단어로 요약할 수 있을 것 같습니다.
너무 길어서 다른 글로 분리했습니다. (링크)
올해도 여러 대회의 문제 출제/검수에 관여했습니다.
제 손을 거친 많고 많은 문제 중, 저에게 가장 뜻깊은 문제는 2024 KOI 2차 중3/고2 XOR 최대 입니다. 고등학생 때 KOI 고등부 2번을 안정적으로 풀 수 있는 실력을 갖추기 위해 정말 많은 노력을 들였지만 실패했는데, 그런 고등부 2번에 아이디어부터 세팅까지 모두 제 손으로 작업했다는 것이 감격스러웠습니다. 그전까지는 다른 사람들이 만든 문제를 세팅하기만 해서 KOI 홈페이지에 이름이 올라가진 않았는데, 드디어 홈페이지(링크)에 문제를 올릴 수 있게 되었습니다.
제가 만든 문제에 대한 몇 가지 코멘트를 덧붙이자면…
2024 KOI 2차 중3/고2 XOR 최대 문제는 2년 전에 한 코드포스 문제(링크)를 잘못 읽은 채로 풀어서 나온 문제입니다. 처음에는 랜덤 데이터에서 $O(N)$에 해결하는 풀이만 있었지만, 문제 아이디어 발전 과정에서 suffix array를 이용한 $O(N \log N)$ 풀이가 나왔고, 이후에 별다른 알고리즘을 사용하지 않는 $O(N)$ 풀이가 나와서 대회에 나올 수 있게 되었습니다.
2024 LGCPC 예선 A. 고장난 계산기 문제는 이것을 보고 만든 문제입니다. 이를 이용하면 파이썬 코드 2~3줄 정도(링크)로 간단하게 풀 수 있지만, 직접 수식 파싱을 하는 분들이 많아서 안타까웠습니다. 정답 코드와는 다르게, 체커는 약 190줄(링크)입니다. recursive descent parser를 이용해 수식이 올바른지 확인한 뒤, 나올 수 있는 값은 DP를 이용해 구하는 방식입니다.
Hello, BOJ 2024! B. 2024는 무엇이 특별할까? 문제는 원래 숭실대학교 교내 대회에 내려고 했지만, Hello BOJ에 문제가 부족해서, 그리고 교내 대회에 내기에는 너무 좋은 문제라서 Hello BOJ에 냈습니다. 처음에는 $K = 1$인 문제의 풀이를 만들었는데, 조금 더 고민해 보니 어렵지 않게 $K > 1$로 확장할 수 있어서 $K \le 10^{18}$ 버전으로 출제했습니다.
2024 숭실대 교내 대회 B. 팀명 정하기 2 문제는 2020-2023 숭실대학교 ICPC 수상 팀 이름을 이용해 만든 문제, 2024 숭실대 교내 대회 C. 온데간데없을뿐더러 문제는 제목과 난이도를 먼저 정하고 만든 문제입니다. 2024 숭실대 교내 대회 F. 피보나치 기념품 문제는 NP-Complete 문제인 subset sum 문제에 조건을 추가해서 다항 시간에 풀리도록 만든 문제입니다. 예상했던 것보다 많이 풀려서 놀랐습니다.
이 밖에도 용돈을 벌기 위해 문제를 몇 개 출제하기도 했습니다.
10년 동안 안 하던 해외여행을 ICPC 덕분에 몰아서 했습니다. 2월에 베트남 하노이(2024 ICPC Asia Pacific Championship), 3월에 이집트 룩소르(2023 ICPC World Finals), 9월에 카자흐스탄 아스타나(2024 ICPC World Finals)까지… 참 힘들었습니다.
2024 Asia Pacific Championship 후기는 (링크)에서 볼 수 있습니다.
2023 ICPC World Finals는 팀원 2명이 모두 PS를 접은 사람이라서 (포스텍 팀에게는 미안하지만) 아무런 기대를 하지 않고 참가했고, 이집트 유적지만 열심히 구경하다가 왔습니다. 대회가 룩소르에서 열려서 피라미드는 보지 못했지만, 여러 신전과 왕가의 계곡(valley of the kings)에 있는 여러 파라오(람세스 6세, 투탕카멘, 세티 1세)의 무덤 등을 봤습니다. 분명 이집트 갔다가 돌아온 직후에는 싫었던 기억(특히 날씨)만 가득했었던 것 같은데, 반년 넘게 지난 지금 다시 돌아보면 안 좋았던 것들은 전혀 기억나지 않고, 관광지 구경하면서 신기해했던 것만 생각납니다.
2024 ICPC World Finals는 운이 아주 좋으면 동메달 끝자락을 노려볼 수도 있다는 약간의 희망을 품고 출발했지만, 메달의 벽은 너무 높았습니다. 한 문제 정도는 더 풀 수 있었을 것 같은데, 그보다 더 위는 아무리 해도 못 갈 것 같습니다. 제가 마지막에 한 문제를 못 푼 채로 끝나서 완전히 만족할 만한 결과라고 생각하진 않지만, 그래도 마지막 ICPC를 기분 좋게 끝내서 다행이라고 생각합니다.
월드 파이널 2개 후기 언제 올리지…
이 밖에도 글 초반에 언급했듯이 SCPC에서 4번의 도전 끝에 5등상을 받았고, UCPC는 2018년 계절학교에서 만난 친구들과 함께 팀을 만들어서 재미있게 쳤습니다. UCPC는 제가 조금만 더 침착했으면 상 받을 수 있었을 것 같은데, 팀을 꾸릴 당시에는 수상 생각 없이 즐겜용으로 만든 팀이었어서 크게 아쉽지는 않습니다.
올해는 계절학교 코치 대신 교수님 12명과 함께 유일한 학부생 신분의 강사로 참여했습니다. 여름학교에서 동적 계획법과 오토마타를 주제로 강의했고, 강의 자료는 (여기)에서 볼 수 있습니다. 국가대표 합숙 교육에 코치로 참가해서 약 2주 동안 카이스트 기숙사에서 생활하기도 했습니다.
solved.ac 에서는 프로 플랜을 구독하면 그날 푼 가장 높은 난이도의 문제로 스트릭 색을 칠할 수 있습니다. 작년 9월부터 약 15개월 동안 모든 다이아몬드(파란색)를 하나의 connected component로 관리하고 있습니다. 학교에 다닐 때는 금요일과 토요일이 가장 시간이 많아서 그때 다이아몬드 문제를 풀었는데, 회사에 들어간 이후로는 회사에서 주말 역할을 하는 일요일과 월요일에 풀고 있습니다. 입사 3주 전부터 아래에 있던 파란색 덩어리를 위로 끌어올리느라 고생을 좀 했던 게 기억납니다.
지금 와서 돌아보면 2022년 서울 리저널 이후로는 팀 연습을 제외하면 Competitive Programming을 위한 공부를 아예 하지 않고 있습니다. 제가 주로 공부하는 방식으로는 더 실력을 올리지 못할 것 같다는 느낌을 받은 것이 가장 큰 이유인 것 같습니다. 공부 방식을 바꾼다면 더 올릴 수 있겠지만, ICPC에서 제가 맡은 역할은 충분히 잘하고 있어서 굳이 바꿀 필요를 느끼지 못했습니다. 개인 대회, 특히 SCPC에서 성적이 안 좋은 것도 실력보다는 대회 전략의 문제라고 생각해서, SCPC 4~5등상 받는 것을 목표로 한다면 이대로 괜찮을 것 같았습니다. 실제로 3년 동안 SCPC에서 상을 받지 못하다가 올해 상 받은 것도 전략을 잘 세우고 그 전략대로 침착하게 행동해서 받은 거지, 실력이 올라서 받은 건 아니라고 생각합니다. 문제 푸는 실력만 본다면 오히려 2022년 SCPC 본선 당시가 더 잘했던 것 같습니다. 2024 ICPC World Finals가 끝나고 난 뒤에는 잠시 후회를 하긴 했지만…
1학기에는 졸업을 위해 필요한 20학점을 편하게 채우기 위해 온라인 과목 위주로 들었고, 2학기에는 수강 신청에서 무려 0과목을 신청해서 쓸 내용이 없습니다.
아는 사람들은 알겠지만, 저는 2021년 11월 초부터 머리를 길러서 2024년에 와서는 꽤 긴 머리카락을 달고 다녔습니다(영상1, 영상2, 영상3). 회사 입사를 2주 앞둔 9월 말에, 35개월 만에 머리를 잘랐습니다. 원래는 2023년 11월과 2024년 9월에 각각 ICPC World Finals가 있으니 긴 머리와 짧은 머리로 한 번씩 가서 사진을 남기면 좋겠다고 생각해서 2024년 봄 정도에 자를 생각이었습니다. 하지만 2023년 11월에 예정되어 있던 대회는 2024년 4월로 연기되었고, 이집트에 갔다 온 뒤로는 학교 시험, 취업 준비, 대회 운영 등으로 9월까지 정말 정신없는 순간의 연속이어서 머리 자를 생각을 못 하고 있었습니다.
알고리즘 대회도 다 끝나서 숨을 돌릴 틈이 생겼고, 마침 회사도 들어가니까 딱 적절한 시기라고 생각해서 9월 말에 자르게 되었습니다. 회사 때문에 머리 잘랐냐고 물어보는 분들이 꽤 있는데 그런 건 아닙니다. 9월 말에 머리 자른 이후로 지금까지 또 안 자르고 버티고 있습니다.
잘라낸 머리는 40cm 이상이었고, 대한민국사회공헌재단(링크)을 통해 기부했습니다. 어린 암 환자들에게 많은 도움이 되기를 바랍니다.
4학년이라 학교에서 시간을 많이 보내지 않아서 쓸 내용이 없고, 인턴십이나 취업 관련 내용은 다른 글로 분리해서 그런지 평소보다 양이 많이 적습니다.
내년 목표를 세울 차례입니다.
지난 4년 동안 작성한 팀노트 코드는 한정된 공간 안에 최대한 많은 내용을 담기 위해서 과도하게 압축해 놓은 상태이고, 전역 변수를 활용한 코드도 있어서 객체를 여러 개 생성하지 못하거나 함수를 여러 번 호출하면 결과가 달라질 수도 있습니다. 이제는 팀노트를 볼 일이 없으니, 지금보다 더 편하게 사용할 수 있는 라이브러리를 다시 만들어 보고 싶습니다.
고등학생 때 제가 가장 시간을 많이 들였던 일은 KOI와 NYPC 같은 대회에 참가하고, 국제정보올림피아드 계절학교에서 만난 친구들과 함께 공부하는 것이었습니다. 고등학교를 졸업한 뒤에도 이것들을 놓고 싶지 않아서 제가 직접 KOI, NYPC, 계절학교에 기여하고 싶다는 생각을 항상 했었고, 어떻게 인연이 닿아서 셋 모두에 많이 관여하는 사람이 되었습니다. 비슷하게, 저는 대학생으로 지내는 동안 ICPC 준비에 가장 많은 시간을 쏟았고, 아직은 ICPC 커뮤니티를 떠나고 싶지 않습니다. 따라서 서울 리저널 대회나 아시아 태평양 지역 대회, 아니면 BAPC와 같은 유럽 지역의 대회 등 어떤 방식으로라도 ICPC에 기여하고 싶어서 방법을 알아보려고 합니다.
위에서 PS 이야기만 한참 늘어놓았지만, 이제 제 신분은 직장인입니다. 아직은 일을 효율적으로 하지 못한다고 생각해서, 내년에는 생활 패턴을 교정하고 주변 환경을 정리해서 일을 효율적으로, 그리고 잘 집중해서 할 수 있는 환경을 만들려고 합니다.
어느새 졸업이 코앞으로 다가왔습니다. 지난 몇 년 동안 항상 명확한 목표가 있어서 그 목표를 향해 달렸었는데, 이제는 새로운 목표를 찾아야 할 때가 되었습니다. 앞으로 어떤 일이 펼쳐질지 기대가 되면서 두렵기도 합니다. 직장인의 삶은 어떤지 잘 체험해 보고 2025년 정산으로 돌아오겠습니다.
끝!
]]>