문제 94
세 명이 모이면 한 명은 세종대생이다 2
세종이는 "세 명이 모이면 한 명은 세종대생이다."라는 말을 좋아한다. 세종이는 이 말이 사실인지 확인하기 위해 $N$명을 일렬로 세웠다. $N$명 중 연속된 $3$명을 어떻게 선택하더라도, 그중 적어도 한 명이 세종대생이 되도록 하기 위해 다음 연산을 최소 몇 번 수행해야 하는지 구해 보자.
- 인접한 두 사람의 위치를 서로 바꾼다.
일렬로 서 있는 $N$명을 문자열 $S$로 나타내며, $i$번째 위치에 있는 사람이 세종대생이면 $S_i$는 S이고, 그 외의 사람이면 O이다.
입력
첫 번째 줄에 정수 $N$이 주어진다. $(3 \le N \le 200\,000)$
두 번째 줄에 문자열 $S$가 주어진다. $(\lvert S \rvert=N)$
출력
첫 번째 줄에 연속된 $3$명 중 적어도 한 명이 세종대생이 되도록 하는 데 필요한 연산 횟수의 최솟값을 출력한다. 불가능하다면 -1을 출력한다.
예제 입력 1
10 SOSOOOOSOS
예제 출력 1
2
예제 입력 2
3 SSS
예제 출력 2
0
예제 입력 3
3 OOO
예제 출력 3
-1
공식 해설
$N\le200\,000$이므로 인접 교환을 직접 시뮬레이션하며 최솟값을 찾기는 어렵다. 최종 배치에서 각 S가 어디에 놓여야 하는지와 그 이동 비용으로 문제를 바꾼다.
교환 대신 최종 위치 정하기
S의 개수를 $K$, 원래 위치를 $p_1<\cdots<p_K$, 최종 위치를 $q_1<\cdots<q_K$라 하자. S끼리 순서를 바꿀 필요는 없으므로 필요한 교환 수는
\[
\sum_{i=1}^{K}\lvert p_i-q_i\rvert
\]
이다. 교환 한 번은 S 하나를 한 칸 옮기므로 이 값이 하한이고, 순서를 유지하며 O를 지나가도록 옮기면 달성할 수 있다.
양 끝에 가상의 S를 두어 $q_0=0$, $q_{K+1}=N+1$로 정하자. O가 세 번 연속 나오지 않을 조건은 모든 $i$에서
\[
1\le q_i-q_{i-1}\le3
\]
이다. O를 $K+1$개 틈에 두 개씩까지 넣을 수 있으므로 $N>3K+2$이면 불가능하다.
간격의 하한은 최적해에서 자동으로 성립한다
우선 $q_i-q_{i-1}\le3$만 남기고 최소 비용을 구한다고 하자. 최적해에 $q_i\ge q_{i+1}$인 내부 쌍이 있다면 $p_i<p_{i+1}$이므로 $q_i>p_i$ 또는 $q_{i+1}<p_{i+1}$이다.
전자에서는 $q_i$를 $1$ 줄이고, 후자에서는 $q_{i+1}$을 $1$ 늘리면 비용이 감소한다. 두 위치 사이 간격은 최대 $1$이 되고 반대쪽 인접 간격은 줄어들므로 상한 $3$을 위반하지 않는다. 이는 최적성에 모순이다.
$q_1\le0$이면 $q_1$을 $1$ 늘리고, $q_K\ge N+1$이면 $q_K$를 $1$ 줄이는 것도 비용을 줄이면서 상한을 지킨다. 따라서 완화한 문제의 최적해도 원래의 모든 간격 조건을 만족한다.
감소하는 수열에 대한 절댓값 비용으로 바꾸기
$z_i=q_i-3i$, $a_i=p_i-3i$로 놓으면 간격 상한은
\[
z_1\ge z_2\ge\cdots\ge z_K
\]
가 된다. 두 가상 끝점의 조건까지 포함하면 모든 $z_i$는 $B=N-3K-2$와 $0$ 사이에 있어야 한다.
$a_i$를 이 범위로 잘라 넣은 값을 $b_i=\min(0,\max(B,a_i))$라 하자. 범위 안의 $z_i$에 대해
\[
\lvert z_i-a_i\rvert=\lvert a_i-b_i\rvert+\lvert z_i-b_i\rvert
\]
이다. 따라서 $\sum_i\lvert a_i-b_i\rvert$를 기본 비용으로 더하고, $b_i$를 증가하지 않는 수열로 만드는 절댓값 비용을 최소화하면 된다.
모든 $b_i$가 이미 $[B,0]$ 안에 있으므로 이후에는 범위 제한을 따로 관리하지 않아도 된다. 범위 밖의 해를 구했더라도 각 원소를 이 범위로 잘라 넣으면 순서를 유지하면서 비용을 늘리지 않기 때문이다.
최소 힙 하나로 비용 계산하기
빈 최소 힙을 만들고 $b_i$를 앞에서부터 처리한다. 매번 $b_i$를 힙에 두 번 넣고 최솟값 $y$를 하나 꺼낸 뒤, 답에 $b_i-y$를 더한다. 여기에 앞의 기본 비용을 합한 값이 정답이다.
이 갱신의 의미를 확인하자. 첫 $i$개를 증가하지 않게 맞추되 마지막 값을 $x$ 이상으로 제한한 최소 비용을 $F_i(x)$라 두면
\[
F_i(x)=\min_{y\ge x}\bigl(F_{i-1}(y)+\lvert y-b_i\rvert\bigr)
\]
이다. $F_0(x)=0$이며, 이 함수는 항상
\[
F_i(x)=C_i+\sum_{h\in H_i}\max(0,x-h)
\]
꼴로 표현된다. 여기서 $H_i$를 힙에 저장하고 $C_i$를 누적 비용으로 관리한다.
$\lvert y-b_i\rvert=2\max(0,y-b_i)+b_i-y$이므로 기존 꺾임점들에 $b_i$ 두 개가 추가된다. 추가 후 가장 작은 꺾임점을 $m$이라 하면 함수는 $m$까지 감소하고 그 뒤에는 감소하지 않는다. $y\ge x$에서 최솟값을 취하는 것은 이 가장 작은 꺾임점 하나를 없애고 상수항에 $b_i-m$을 더하는 것과 같다. 이것이 위의 힙 연산이다.
최종 함수의 전역 최솟값은 $C_K$이다. 따라서 힙에서 구한 비용이 증가하지 않는 수열로 맞추는 최소 비용이며, 앞의 변환들에 따라 원래 교환 횟수의 최솟값도 얻는다.
$K=0$이면 $N\ge3$에서 불가능하다. 가능한 경우의 시간복잡도는 $O(N+K\log(K+1))$, 공간복잡도는 $O(K)$이다. 누적 비용은 여러 사람의 이동 거리의 합이므로 문자열의 길이보다 커질 수 있다.
구현 · 언어별 풀이
C++ 구현
최소 힙은 priority_queue<long long, vector<long long>, greater<long long>>으로 만든다. 각 $b_i$를 두 번 넣고 top()을 읽어 제거한 뒤 $b_i$와의 차를 비용에 더한다. 위치 변환, 기본 비용과 누적 비용은 long long으로 계산한다. $N>3K+2$인 경우는 힙을 만들기 전에 불가능으로 처리한다.
C 구현
S 위치는 1-based로 모으고 N>3*K+2이면 -1을 출력한다. a_i와 clip값은 int에 들어가지만 전체 비용은 long long이다. 최소 힙에 clip값을 두 번 넣고 한 번 빼는 갱신을 그대로 구현한다.
Python 구현
heapq 최소 힙을 사용한다. 각 b를 두 번 heappush하고 한 번 heappop하여 b-y를 답에 더한다. 위치는 1-based이며 clip으로 생긴 기본 비용도 더한다. Python int로 큰 교환 합을 저장한다.
불가능 조건을 통과한 뒤, positions에는 $1$부터 센 S의 위치를 둔다. 기본 비용을 먼저 더한 다음 힙으로 순서 보정 비용을 더한다.
import heapq
bound = n - 3 * len(positions) - 2
heap = []
answer = 0
for i, p in enumerate(positions, 1):
a = p - 3 * i
b = min(0, max(bound, a))
answer += abs(a - b)
heapq.heappush(heap, b)
heapq.heappush(heap, b)
answer += b - heapq.heappop(heap)
Java 구현
PriorityQueue<Integer>를 최소 힙으로 사용하고 비용 합은 long으로 둔다. b를 두 번 offer한 뒤 poll하고 차이를 더한다. 불가능 조건을 먼저 검사하고 Math.abs(a-b) 기본 비용을 빠뜨리지 않는다.
불가능 조건을 통과한 뒤, positions에는 $1$부터 센 S의 위치를 둔다. 두 번 삽입·한 번 삭제와 기본 이동 비용을 모두 반영한다.
PriorityQueue<Integer> heap = new PriorityQueue<>();
int bound = n - 3 * positions.size() - 2;
long answer = 0;
for (int i = 0; i < positions.size(); i++) {
int a = positions.get(i) - 3 * (i + 1);
int b = Math.min(0, Math.max(bound, a));
answer += Math.abs(a - b);
heap.offer(b);
heap.offer(b);
answer += b - heap.poll();
}
Rust 구현
BinaryHeap<Reverse<i64>>로 최소 힙을 만든다. b를 두 번 넣고 한 번 꺼낸 뒤 b-y를 i64 답에 더한다. clip과 위치 계산도 부호 있는 자료형으로 하여 음수 B를 표현한다.
불가능 조건을 통과한 뒤, positions에는 $1$부터 센 S의 위치를 둔다. Reverse를 씌워 가장 작은 값을 꺼낸다.
use std::cmp::Reverse;
use std::collections::BinaryHeap;
let bound = n as i64 - 3 * positions.len() as i64 - 2;
let mut heap = BinaryHeap::new();
let mut answer = 0i64;
for (i, &p) in positions.iter().enumerate() {
let a = p as i64 - 3 * (i as i64 + 1);
let b = a.clamp(bound, 0);
answer += (a - b).abs();
heap.push(Reverse(b));
heap.push(Reverse(b));
answer += b - heap.pop().unwrap().0;
}
JavaScript 구현
문자열에서 S의 위치를 $1$부터 세어 모은다. 불가능 조건 n > 3 * k + 2를 먼저 처리하고, 각 위치의 a = position - 3 * i를 $[B,0]$에 잘라 기본 비용을 더한다. 이때 $i$도 $1$부터 시작한다.
JS에는 표준 최소 힙이 없으므로 숫자 배열과 push·pop 함수로 이진 힙을 구현한다. 부모 값이 자식 값 이하가 되도록 삽입 시 위로 올리고, 최솟값을 제거한 뒤에는 마지막 값을 루트에 놓고 더 작은 자식과 교환하며 내려간다. 각 값 $b$를 두 번 넣고 최솟값을 한 번 꺼내 b - min을 답에 더한다. 같은 값의 꺾임점도 두 개 남겨야 하므로 집합으로 대체하지 않는다.
교환 비용은 최대 $NK\le4\cdot10^{10}$ 수준이어서 Number로 정확하지만 $32$비트를 넘는다. 답을 정수 배열에 넣지 않는다. 최종 배치 자체나 모든 교환을 복원할 필요는 없으며, 시간은 공통 풀이의 $O(N+K\log(K+1))$이다.