https://www.acmicpc.net/problem/10067
10067번: 수열 나누기
문제 은기는 음이 아닌 정수 n개로 이루어진 수열을 이용해 시간을 떼우고 있다. 은기는 수열을 총 k+1개로 나누어야 하고, 각 부분은 비어있지 않아야 한다. 수열을 k+1개로 나누러면, 아래와 같��
www.acmicpc.net
이 문제도 몇일전에 포스트 했던 APIO 2010 특공대 문제와 비슷한 CHT (Convex Hull Technique) 응용 문제다.
(물론 이 문제는 DnC (Divide and Conquer) optimization 으로도 풀리나 CHT 를 활용하는 것이 더 빠르다)
CHT 에 관해서는 이미 APIO 2010 특공대 포스트에서 설명했으니 독자가 CHT 에 대해 익숙하다고 가정하고 설명하겠다. 만약 CHT 가 익숙하지 않다면 APIO 2010 특공대 포스트를 다시 참고하길 바란다.
1) $O(N^2K)$ 해법
아래와 같이 점화식을 정의해보자
$D[k][i] = $ 수열을 $k$ 개 분할하고 그 마지막 분할 위치가 $i$ 일때 최대 점수
그렇다면 $D[k][i]$는 아래와 같이 정의될 수 있다.
$$D[k][i] = max_{j}\{D[k-1][j] + (S[N]-S[i]) * (S[i] - S[j]) \}$$
여기서 $(S[N]-S[i]) * (S[i] - S[j])$ 는 $k - 1$번째 분할이 $j$번째 위치에서 일어났고, $k$번째 분할을 $i$번째에서 할 때 추가로 얻는 점수이다.
위 점화식은 $k-1$번 수열을 분할했을때의 모든 경우의수를 고려함으로써 $k$번째 분할을 $i$번째에서 할 때의 최대 점수를 구한다.
2) $O(NK)$ 해법
하지만 위 해법은 너무 느리다.
이미 $N$이 최대 10만이므로 10만 제곱은 이미 2초를 아득히 넘어버릴 것이다.
따라서, 우리는 $N$에 대해 선형시간에 가까운 알고리즘을 고안하여야 한다.
하지만 위 $O(N^2K)$ 해법의 점화식의 형태를 잘 살펴보면 CHT 를 적용하기 쉬운 형태임을 알 수 있다.
위에서 주어진 점화식은 아래와 같이 고쳐쓸 수 있다.
$$D[k][i] = max_{j}\{D[k-1][j] + (S[N]-S[i]) * (S[i] - S[j]) \}$$
$$= max_{j}\{D[k-1][j] + S[N]S[i] - S[N]S[j] + S[i]^2 + S[i]S[j] \}$$
이는 $j$에 대한 최적화 문제이므로 $j$에 종속적이지 않은 항들은 괄호 밖으로 빼서 상수 취급 할 수 있다.
$$= max_{j}\{D[k-1][j] - S[N]S[j] + S[i]S[j] \} + S[N]S[i] + S[i]^2 $$
이제 괄호안에 있는 항 $D[k-1][j] - S[N]S[j] + S[i]S[j]$을 고려해보자.
$x = S[i]$로 치환하면
$S[j]x + (D[k-1][j] - S[N]S[j])$ 가 되며 이는 $x = S[i]$에 대한 일차함수 (직선, 선분) 이다.
또한 $a_i$가 음이 아닌 정수 이므로, $S[j]$는 $j$가 증가할수록 단조증가한다 (monotonically increasing).
따라서, 우리는 Li-Chao Tree 나 이분검색 없이 직선을 상수시간안에 관리 할 수 있다.
특공대 문제와 마찬가지로 덱 (deque)을 이용하여 선분의 후보들을 관리할 것이다.
덱에 들어 있는 선분들의 기울기가 증가하는 순으로 들어가기 때문에 현재 $x = S[i]$에 대하여 덱의 맨 앞에 들어 있는 직선 $i$와 직선 $j$ $(i < j)$의 교점이 $x$에 있거나 $x$보다 전에 있다면, 직선 $j$는 직선 $i$보다 좋고 앞으로도 더 좋을것이다. 따라서 직선 $i$를 제거한다. 이는 아래의 코드와 같이 구현 될 수 있다.
inline bool canSkip(int k, int i, int j, int threshold) {
return (D[K(k-1)][i] - D[K(k-1)][j] + S[N] * (S[j] - S[i]))
<= threshold * (S[j] - S[i]);
}
// 현재 S[i] 에서 사용할 수 있는 최적의 선분을 얻는다.
while (candid.size() > 1 && canSkip(k, candid[0], candid[1], S[i]))
candid.pop_front();
응? 특공대 문제에서는 교점을 구하지 않았나? 싶을것이다. 사실,
(D[K(k-1)][i] - D[K(k-1)][j] + S[N] * (S[j] - S[i])) / (S[j]-S[i])
가 교점이다. 하지만 특공대에서는 각 원소가 0보다 컸지만, 이번 문제에서는 각 원소가 0일 수도 있다. 그 말은, $S[i] = S[j]$일 수 있으며 이는 0으로 나누는 오류를 범하면서 런타임 에러가 날 것이다.
이를 해결하기 위해서는 나눗셈 기호를 없애주면 된다. 우리가 원하는 것은 교점이 $S[i]$보다 작거나 같은지 아닌지 하는 정보이다. 따라서, 나눗셈 항을 부등식에 양변에 곱하므로써
(D[K(k-1)][i] - D[K(k-1)][j] + S[N] * (S[j] - S[i])) <= threshold * (S[j] - S[i])
를 구할 수 있다.
특공대와 마찬가지로 $i$번째 선분을 추가함으로써 필요없이지는 선분 후보들을 뒤에서 제거해야한다.
하지만 위에서 말한 0으로 나눌 수 있는 문제 때문에 식을 이항하여 아래와 같은 함수를 정의하였다.
// intersect(k, i, j) <= intersect(k, j, l) 인지 체크.
inline bool canRemoveLastCandidate(int k, int i, int j, int l) {
return (D[K(k-1)][i] - D[K(k-1)][j] + S[N] * (S[j] - S[i])) * (S[l] - S[j])
<= (D[K(k-1)][j] - D[K(k-1)][l] + S[N] * (S[l] - S[j])) * (S[j] - S[i]);
}
여기서 $j$는 선분 후보에 있는 마지막 후보이고, $l$은 선분후보중 뒤에서 두번째 후보이다. 즉, $i > j > l$이다.
만약 $i$번째 선분과 $j$번째 선분의 교점이 $j$번째 선분과 $l$번째 번째 선분의 교점보다 전에 있다면, $j$번째 선분은 의미가 없다 ($i$번째 선분은 $j$번째 선분보다 기울기가 크므로, $i$번째 선분이 먼저 나타나면 $j$번째 선분은 절때 $i$번째 선분의 값보다 클 수 없다). 따라서, 이럴경우 제거해주면 된다.
이렇게 CHT 를 적용하므로써 $O(NK)$만에 문제를 해결 할 수 있다.
코드는 아래와 같다.
#include <algorithm>
#include <cstring>
#include <deque>
#include <iostream>
#include <vector>
#define MAXN 100000
#define MAXK 200
#define K(k) ((k)&1)
#define END (candid.size() - 1)
#define SQ(x) ((x) * (x))
typedef long long ll;
using namespace std;
// 누적합 배열, 최적해를 저장하는 배열.
// long long (8바이트) 배열을 MAXK * MAXN 개 만큼 잡으면 128MB 를 넘어간다.
// 하지만 D[k][*] 를 구할 때 D[k-1][*] 만 필요하지 그 이외는 필요하지 않으므로 두개만 잡아도 된다.
ll S[MAXN + 1], D[2][MAXN + 1];
// 역추적 배열.
int OPT[MAXK + 1][MAXN + 1];
int N, K;
// intersect(k, i, j) < threshold 인지 체크.
// 만약 이 값이 true 라면 덱 (deque) 의 첫번째 선분보다 두번째 선분이 현재 위치에서 더 좋은 후보이므로 덱의 앞에서 pop 할 수 있다.
inline bool canSkip(int k, int i, int j, int threshold) {
return (D[K(k-1)][i] - D[K(k-1)][j] + S[N] * (S[j] - S[i]))
<= threshold * (S[j] - S[i]);
}
// intersect(k, i, j) <= intersect(k, j, l) 인지 체크.
inline bool canRemoveLastCandidate(int k, int i, int j, int l) {
return (D[K(k-1)][i] - D[K(k-1)][j] + S[N] * (S[j] - S[i])) * (S[l] - S[j])
<= (D[K(k-1)][j] - D[K(k-1)][l] + S[N] * (S[l] - S[j])) * (S[j] - S[i]);
}
// D[k][*] 를 구한다.
void dp(int k) {
deque<int> candid;
candid.push_back(k-1);
// k 번째 split 이라면 자를 수 있는 처음 위치가 최소 k 이다.
for (int i = k; i <= N; i++) {
// 현재 S[i] 에서 사용할 수 있는 최적의 선분을 얻는다.
while (candid.size() > 1 && canSkip(k, candid[0], candid[1], S[i]))
candid.pop_front();
int j = candid.front();
OPT[k][i] = j;
D[K(k)][i] = D[K(k-1)][j] + S[i] * S[j] - S[N] * S[j] + S[N] * S[i] - SQ(S[i]);
// i 번째 선분을 집어넣으므로써 필요가 없어지는 선분들을 뒤에서 제거한다.
while (candid.size() > 1
&& canRemoveLastCandidate(k, i, candid[END], candid[END-1]))
candid.pop_back();
candid.push_back(i);
}
}
// 어디서 나누면 최적의 값을 없는지 역추적한다.
vector<int> backtrace(int ind) {
vector<int> ret;
ret.reserve(K);
int k = K;
while(k > 0) {
ret.push_back(ind);
ind = OPT[k--][ind];
}
reverse(ret.begin(), ret.end());
return ret;
}
void solve() {
for (int k = 1; k <= K; k++) {
memset(D[K(k)], 0, sizeof(ll) * (MAXN + 1));
dp(k);
}
ll ret = 0; int ind = 1;
for (int i = 1; i <= N; i++) if (ret < D[K(K)][i]) ret = D[K(K)][i], ind = i;
cout << ret << '\n';
auto opt = backtrace(ind);
for (int x : opt) cout << x << ' ';
}
void input() {
cin>>N>>K;
for (int i = 1; i <= N; i++) cin>>S[i], S[i] += S[i-1];
}
int main() {
ios::sync_with_stdio(0); cin.tie(0);
input();
solve();
}
여기서 한가지 주목할점은 역추적을 위해 opt 라는 int 배열을 선언한걸 볼 수 있는데 int 배열은 MAXK * MAXN 개 만큼 선언해도 약 80MB 정도로 메모리 제한안에 들어온다.
'PS' 카테고리의 다른 글
[APIO 2010] 특공대 (Commando) 풀이 (0) | 2020.06.13 |
---|---|
[Google CodeJam 2008 Round 1A-C][BOJ 12727, 12728, 12925] n 제곱 계산 (0) | 2020.04.27 |