https://www.acmicpc.net/problem/1605

 

1605번: 반복 부분문자열

알파벳 소문자로 이루어진 길이 L인 문자열이 있다. 이 문자열의 부분문자열 중, 적어도 한 번은 반복되는 (다시 말해서, 전체 문자열에서 두 번 이상 나타나는) 부분문자열을 '반복 부분문자열'이라고 부르자. 문자열이 주어지면, 가장 긴 '반복 부분문자열'의 길이를 구하는 프로그램을 작성하시오.

www.acmicpc.net

백준 1605번 문제 - 반복 부분 문자열 입니다.

 

Brute-force로 풀 시에는

모든 부분 문자열을 다 알아내면 O(N^2)이 나올 것이고, 각각을 다 비교하려면 O(N)이 걸리므로

 

총 O(N^3)이 되는데, N=20만이므로, 이 방법으로는 풀 수 없습니다.

 

대충 O(NlogN) 정도 이하의 방법이 필요합니다.

 

여기서 사용할 방법은 바이너리 서치 + 롤링 해시 입니다.

 

원래 문제는 여기서 나타나는 가장 긴 반복 부분 문자열의 길이를 구하는것인데요, 이를

다음 문제로 바꾸어서 봅시다.

 

T(L) => 길이 L인 반복 부분문자열이 존재하는지 아닌지를 확인하는 문제.

 

만약 길이 L인 반복 부분 문자열이 존재한다면 L-1 길이의 반복 부분 문자열도 존재할 것입니다.

간단한 예로 abcdefg 라는 문자열이 두번 나왔고, 이 길이가 7이면

abcdef라는 문자열도 2번 이상 나왔을 것이며, 이 길이는 6입니다.

 

이러한 성질을 이용하면 바이너리 서치를 할 수 있습니다. 즉 T(L)라는 문제를 logL번만 풀면 T(L) == True를 만족하는 최대 L을 구할 수 있지요.

 

반복 부분 문자열의 길이는 최대 L-1이 될 것이고, 최소 0이 될 것입니다.

원문이 a가 10개짜리인 문자열이라면, a가 9개 나온 부분 문자열이 2번 나온것이기 때문이죠.

 

이제 T(L)을 빠른 시간안에 푸는 방법을 생각해봅시다.

 

이는 롤링해시를 이용해서 풀 수 있습니다.

 

문자열 S의 길이가 L이고, 각각의 i번째 문자가 S[i]라고 할 때, 해당 문자열은 다음과 같은 방식으로 해시를 할 수 있습니다.

 

적당한 상수 p 값을 잡고

 

hash(S) = S[1] * p^(L-1) + S[2] * p^(L-2) + ... + S[L-1] * p^1 + S[L] * p^0

 

이때 각각의 S[i]는 영어 소문자이므로 26가지 글자를 가질 수 있으므로, p는 26보다는 큰 적당한 소수를 잡는게 좋겟죠.

저는 29를 사용했습니다.

 

그리고 이 hash 값을 적당한 소수 (저는 10007를 사용했습니다) 로 MOD 연산하여 hash table에 집어 넣습니다.

 

해당 문자열이 나타나는 첫번째 index를 넣어놓고, hash값이 같을 경우, 실제로 같은지 체크를 하는 식으로 롤링 해시를 구성해 보았습니다.

 

매번 저런식으로 hash값을 구하면 시간이 많이 걸리므로, 첫번째만 L만큼의 선형으로 구하고, 두번째 부터는 슬라이딩 윈도우 테크닉을 이용해서 추가되는 글자에 해시값만 추가하고, 떨어져나가는 글자의 해시값만 빼주는 식으로 O(1)로 구해버립니다.

 

아마 요런방식이 문자열 비교 알고리즘인 라빈-카프 알고리즘과 비슷할 것입니다. (저도 그렇다고만 들었고, 아직 라빈-카프 알고리즘을 공부하지 않아서 정확하겐 모르는 상태입니다.)

 

이런식으로 구성하면 해시 충돌이 많이 일어나지 않는다면 대충 O(L)만에 중복되는 문자열이 있는지를 확인할 수 있습니다. 즉 T(L)문제를 풀 수 있죠.

 

다른 사람들 코드를 보니, 이 방식 말고도 풀 수 있는 방법이 더 많은 것으로 보입니다. LCP(Longest Common Prefix) 라던지 등등 문자열과 관련된 다양한 테크닉들을 활용하더군요.

 

일단 제가 풀이한 코드를 첨부하겠습니다.

 

#include <bits/stdc++.h>
using namespace std;
#define endl '\n'
char str[200005];
const int p = 29;
const int MOD = 10007;
int Len;
int pexp[200005];

vector<int> hashTable[10007]; //시작 index를 갖는다.
void init();
bool real_match(int s1, int s2, int L) {
    for (int i = 0; i < L; i++) {
        if (str[s1 + i] != str[s2 + i]) {
            return false;
        }
    }    
    return true;
}
bool ok(int L) {
    init();
    //길이 L의 중복 부분문자열이 존재하는지를 리턴
    int hash = 0;
    for (int i = 0; i < L; i++) {
        hash *= p;
        hash += str[i] - 'a';
        hash %= MOD;
    }
    hashTable[hash].push_back(0);
    for (int i = L; i < Len; i++) {
        hash -= (str[i - L] - 'a') * pexp[L - 1] % MOD;
        hash = (hash + MOD) % MOD;
        hash *= p;
        hash += str[i] - 'a';
        hash %= MOD;
        if (hashTable[hash].size()) {
            for (int idx : hashTable[hash]) {
                if (real_match(idx, i - L + 1, L))
                    return true;
            }
        }
        hashTable[hash].push_back(i-L+1);
    }
    return false;
}
void solve() {
    scanf("%s", str);
    int l = 0, r = Len;
    while (l < r) {
        int m = (l + r + 1) / 2;
        if (ok(m)) l = m;
        else r = m - 1;
    }
    cout << l << endl;
}
void pre() {
    pexp[0] = 1;
    for (int i = 1; i <= 200000; i++) {
        pexp[i] = pexp[i - 1] * p % MOD;
    }
}
int main() {
    pre();
    cin >> Len;
    solve();
} 

void init() {
    for (int i = 0; i < MOD; i++) {
        hashTable[i].clear();
    }
}

 

https://www.acmicpc.net/problem/1006

 

1006번: 습격자 초라기

하나의 특수 소대로 인접한 두 영역을 커버할 수 있는 배치는 (2,10), (9,16), (4,5), (7,8), (13,14) 이다. 그리고 나머지 6개 구역은 각각 하나의 특수 소대로 커버할 수 있다. 그러므로 최소 11개 특수 소대를 침투시켜야 한다.

www.acmicpc.net

 

백준저지 1006번 문제 습격자 초라기 문제입니다. 악명높은 문제이죠?

1000번 부터 하나씩 문제를 풀려고 하는 PS 입문자를 접게 만드는 문제입니다.

 

다이나믹 프로그래밍으로 풀 수 있는 문제이며, 2xn 타일링, 스티커 문제와 유사한 점이 좀 있습니다.

 

점령해야 하는 공간이 2줄로 이루어져 있고, 최대 2개의 인접한 공간을 같이 점령할 수 있다는 점에 있어서 그렇죠

 

2x1짜리 블럭을 가로, 세로로 놓아서 2줄자리 칸을 채우는 2xn 타일링 문제 (https://www.acmicpc.net/problem/11726)

 

2줄짜리 스티커를 때되, 땔때마다 인접한 스티커는 못쓰게 되는 스티커 문제 (https://www.acmicpc.net/problem/9465)

와 유사합니다.

 

다만 조금 번거로운 부분은, 공간이 환형(동그란 형태)이므로 처음과 끝을 연결시켜줘야 한다는 점입니다.

 

처음과 끝부분이 가로로 겹치게 되면 조금 골치 아파 지죠.

 

그래서 경우를 4가지로 나누어서 각각 처리해줘야 합니다.

 

1) 처음과 끝에 걸치는 경우가 없는 경우

2) 위만 걸치는 경우

3) 아래만 걸치는 경우

4) 위아래 둘다 걸치는 경우

 

각각 걸치는 경우가 성립할 때, 걸치는 부분의 적 병력 수를 W로 바꾼 뒤 문제를 풀면, 다른 곳과는 겹쳐서 점령될 일은 없습니다.

걸치는 부분의 병력 수를 W로 바꾸어서 문제를 푼 뒤, 마지막 부분의 걸치는 부분은 점령하지 않았을 때의 dp 배열을 리턴하면 정답과 같습니다.

 

dp배열 정의는 다음과 같습니다.

 

a[x] => 0~x-1 열까지 모두 점령하고, x번째 열은 위 아래 둘다 점령한 경우

b[x] => 0~x-1 열까지 모두 점령하고, x번째 열은 위 만 점령한 경우

c[x] => 0~x-1 열까지 모두 점령하고, x번째 열은 아래 만 점령한 경우

 

개인적으로 문제 푸는데 코너케이스를 잘 찾지 못해서 매우 고생한 문제입니다.

다음 블로그의 풀이 코드를 조금 참고 했습니다(https://casterian.net/archives/1356)

 

해당 블로그에서는 "a[x] => x-1열까지만 위 아래 모두 점령한 경우" 로 정의하여 b, c DP 배열에 비해서 한칸 뒤까지만 점령한 것으로 정의를 하였는데

 

코너 케이스를 찾다 보니 왜 그렇게 했는지 약간 이해가 갔습니다.

 

제 코드를 보시면, 가로로 두개를 놓는 경우 i == 1인 경우 따로 예외 처리를 해 주었는데, 위 casterian 블로그 주인분이 작성한 코드식으로 정의를 하면 해당 부분을 따로 처리할 필요가 없어집니다.

 

해당 코너 케이스를 찾기 위해서 종만북에서 말하는 스캐폴딩 방법을 활용했습니다.

 

다른곳에서 사용하는 스캐폴딩이라는 단어 뜻은 조금 상이할 수 있으므로 좀더 첨언을 하자면,

랜덤하게 input 값을 생성해서, 정답 소스로 작성한 바이너리와 제가 작성한 오답 바이너리에 각각 input 값을 집어 넣고

output 값을 비교합니다. 

 

다른 output 값이 나올 때 까지 계속 반복하고, 다른 output을 만들어내는 input 파일을 토대로 디버깅을 해서 코너 케이스를 찾아내었습니다.

 

자세한 내용은 코드와 주석들을 확인해주시기 바랍니다.

 

#include <iostream>
#include <algorithm>
using namespace std;

int N, W;
int cost[2][10004];
int a[10004], b[10004], c[10004];
void expand() {
	if (cost[0][0] + cost[1][0] <= W) {
		a[0] = b[0] = c[0] = 1;
	}
	else {
		a[0] = 2;
		b[0] = c[0] = 1;
	}
	for (int i = 1; i < N; i++) {
		// i번째 칸에 놓는 방법을 해결한다.
		// i-1까지의 값은 최신임을 보장한다.

		//기존 열에서 두개 그대로 놓는 경우
		a[i] = a[i - 1] + 2;

		//하나만 달랑 놓는 경우
		b[i] = c[i] = a[i - 1] + 1;

		//위로 가로 하나 놓는 경우
		if (cost[0][i] + cost[0][i - 1] <= W) {
			b[i] = min(b[i], c[i - 1] + 1);
		}

		//아래로 가로 하나 놓는 경우
		if (cost[1][i] + cost[1][i - 1] <= W) {
			c[i] = min(c[i], b[i - 1] + 1);
		}

		//하나만 달랑 다시 놓는 경우
		a[i] = min(a[i], b[i] + 1);
		a[i] = min(a[i], c[i] + 1);
		
		//세로로 놓는 경우
		if (cost[0][i] + cost[1][i] <= W) {
			a[i] = min(a[i], a[i - 1] + 1);
		}
		//가로로 두개 놓는 경우
		if (cost[0][i] + cost[0][i - 1] <= W && cost[1][i] + cost[1][i - 1] <= W) {
			if (i >= 2)
				a[i] = min(a[i], a[i - 2] + 2);
			else
				a[i] = min(a[i], 2);
		}
	}

}
void solve() {
	cin >> N >> W;
	for (int i = 0; i < 2; i++) {
		for (int j = 0; j < N; j++) {
			cin >> cost[i][j];
		}
	}
	int ans;

	expand();
	//cout << "교차 없는 경우 : ";
	ans = a[N - 1];
	//cout << a[N - 1] << endl;

	if (N > 1) {
		//아래쪽 라인 교차 있는 경우
		if (cost[1][0] + cost[1][N - 1] <= W) { 
			int tmp1 = cost[1][0];
			int tmp2 = cost[1][N - 1];
			cost[1][0] = cost[1][N - 1] = W;
			expand();
			//cout << "아래 교차있는 경우 : ";
			//cout << b[N - 1] << endl;
			ans = min(ans, b[N - 1]);
			cost[1][0] = tmp1;
			cost[1][N - 1] = tmp2;
		}
		if (cost[0][0] + cost[0][N - 1] <= W) {
			int tmp1 = cost[0][0];
			int tmp2 = cost[0][N - 1];
			cost[0][0] = cost[0][N - 1] = W;
			expand();
			//cout << "위 교차 있는 경우 : ";
			//cout << a[N - 1] << endl;
			ans = min(ans, c[N - 1]);
			cost[0][0] = tmp1;
			cost[0][N - 1] = tmp2;
		}
		if (cost[0][0] + cost[0][N - 1] <= W && cost[1][0] + cost[1][N - 1] <= W) {
			int tmp1 = cost[0][0];
			int tmp2 = cost[0][N - 1];
			int tmp3 = cost[1][0];
			int tmp4 = cost[1][N - 1];
			cost[1][0] = cost[1][N - 1] = W;
			cost[0][0] = cost[0][N - 1] = W;
			expand();
			//cout << "위 아래 둘다 교차 있는 경우 : ";
			//cout << a[N - 2] << endl;
			ans = min(ans, a[N - 2]);
			cost[0][0] = tmp1;
			cost[0][N - 1] = tmp2;
			cost[1][0] = tmp3;
			cost[1][N - 1] = tmp4;

		}
	}
	cout << ans << endl;

}
int main() {
	int tc; cin >> tc;
	while (tc--) {
		solve();
	}
}

백준저지 9465번 스티커 문제입니다.

https://www.acmicpc.net/problem/9465

 

9465번: 스티커

문제 상근이의 여동생 상냥이는 문방구에서 스티커 2n개를 구매했다. 스티커는 그림 (a)와 같이 2행 n열로 배치되어 있다. 상냥이는 스티커를 이용해 책상을 꾸미려고 한다. 상냥이가 구매한 스티커의 품질은 매우 좋지 않다. 스티커 한 장을 떼면, 그 스티커와 변을 공유하는 스티커는 모두 찢어져서 사용할 수 없게 된다. 즉, 뗀 스티커의 왼쪽, 오른쪽, 위, 아래에 있는 스티커는 사용할 수 없게 된다. 모든 스티커를 붙일 수 없게된 상냥이는 각 스티커에 점

www.acmicpc.net

 

다이나믹 프로그래밍 기초 문제입니다.

 

스티커가 가로 2줄로 이루어져 있고, 하나를 때면 인접한 스티커를 고를 수 없게 됩니다.

최근에 위를 골랏느냐, 아래를 골랐느냐에 따라서 다음 고를 수 있는 상태가 바뀌게 되므로, 2차원 DP 배열로 풀 수 있습니다.

 

 

DP 식은

 

dp[x][status] -> x번째 열 이후 스티커를 다 처리했고, 해당 열의 스티커의 상태가 status가 되었을 때 최대 가치

 

대충 이러한 정의가 될 것입니다.

 

자세한 내용은 코드를 첨부합니다.

 

#include <bits/stdc++.h>
using namespace std;

#define UP      0 //arr에선 위에위치, dp에선 위에것만 선택 가능할 시
#define DOWN    1
#define BOTH    2


int dp[100000][3]; //시작 x좌표와 선택할 수 있는 상태
int arr[100000][2];
int n;
int solve(int len, int stat) {
    if (len < 0 or len >= n or stat < 0 or stat >= 3) return 0;
    if (dp[len][stat] != -1) return dp[len][stat];
    int ans = 0;
    if (stat != DOWN) { //위에걸 선택할 수 있는 상태이면(UP or BOTH)
        ans = max(ans, arr[len][UP] + solve(len+1, DOWN)); //위에걸 선택
        ans = max(ans, solve(len+1, BOTH)); //그냥 안선택
    }
    if (stat != UP) { //아래걸 선택할 수 있는 상태이면(DOWN or BOTH)
        ans = max(ans, arr[len][DOWN] + solve(len+1, UP)); //위에걸 선택
        ans = max(ans, solve(len+1, BOTH)); //그냥 안선택
    }
    return dp[len][stat] = ans;
}
int main() {
    int t;
    cin >> t;
    while(t--) {
        
        cin >> n;
        for (int i=0; i < n; i++) {
            cin >> arr[i][UP];
        }
        for (int i=0; i < n; i++) {
            cin >> arr[i][DOWN];
        }
        memset(dp, -1, sizeof(dp));
        cout << solve(0, BOTH) << '\n';
    }
	return 0;
}

+ Recent posts