SOJ ONLINE JUDGE

문제 94

세 명이 모이면 한 명은 세종대생이다 2

내 상태
미제출
난이도
94번 문제 난이도 보기
Diamond II
출제자
pizzaroot
시간 제한
1000 ms
메모리 제한
512 MB
94번 문제 태그 보기
다이나믹 프로그래밍함수 개형을 이용한 최적화

세종이는 "세 명이 모이면 한 명은 세종대생이다."라는 말을 좋아한다. 세종이는 이 말이 사실인지 확인하기 위해 $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))$이다.

제출

편집기에서 나가려면 Esc를 누른 뒤 Tab 또는 Shift와 Tab을 누르세요.