DP 최적화 — 공간 압축, 비트마스크 DP, 분할 정복 최적화
기본 DP로 답은 구했는데, 메모리 초과이거나 시간 초과입니다. DP도 최적화가 필요할 때가 있습니다.
공간 최적화: 롤링 배열
문제
2차원 DP 테이블에서 현재 행이 바로 이전 행만 참조하는 경우, 전체 테이블을 유지할 필요가 없습니다.
기법: 2개 행만 유지
// 2차원 dp[n][m]을 2개 행으로 압축
// 이전 행(prev)과 현재 행(curr)만 사용
int[] prev = new int[m + 1];
int[] curr = new int[m + 1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
curr[j] = computeFromPrev(prev, curr, i, j);
}
// 행 스왑
int[] temp = prev;
prev = curr;
curr = temp;
Arrays.fill(curr, 0);
}
기법: 1개 행으로 압축
0/1 배낭처럼 참조 방향이 단순하면 1개 행으로도 가능합니다.
// 0/1 배낭: dp[i][w] = max(dp[i-1][w], dp[i-1][w-wt] + val)
// dp[i-1][w-wt]를 참조 → 작은 인덱스 → 역순 순회로 보존
int[] dp = new int[capacity + 1];
for (int i = 0; i < n; i++) {
for (int w = capacity; w >= weight[i]; w--) {
dp[w] = Math.max(dp[w], dp[w - weight[i]] + value[i]);
}
}
LCS도 공간 압축 가능
// O(mn) → O(min(m,n))
public static int lcsOptimized(String s1, String s2) {
if (s1.length() < s2.length()) {
String temp = s1; s1 = s2; s2 = temp;
}
int m = s1.length(), n = s2.length();
int[] prev = new int[n + 1];
int[] curr = new int[n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s1.charAt(i-1) == s2.charAt(j-1)) {
curr[j] = prev[j-1] + 1;
} else {
curr[j] = Math.max(prev[j], curr[j-1]);
}
}
int[] temp = prev;
prev = curr;
curr = temp;
Arrays.fill(curr, 0);
}
return prev[n];
}
비트마스크 DP
집합의 상태를 정수의 비트로 표현하는 기법입니다. n개 원소의 모든 부분 집합을 0부터 2^n - 1까지의 정수로 나타냅니다.
비트 연산 기본
int S = 0b1011; // {0, 1, 3} 집합 (0-indexed)
// i번째 원소 포함 여부
boolean has = (S & (1 << i)) != 0;
// i번째 원소 추가
S = S | (1 << i);
// i번째 원소 제거
S = S & ~(1 << i);
// 원소 개수
int count = Integer.bitCount(S);
// 부분 집합 순회
for (int sub = S; sub > 0; sub = (sub - 1) & S) {
// sub는 S의 부분 집합
}
TSP (외판원 문제)
n개 도시를 모두 방문하고 시작점으로 돌아오는 최소 비용 경로를 구합니다.
public static int tsp(int[][] dist) {
int n = dist.length;
int FULL = (1 << n) - 1; // 모든 도시 방문 상태
int[][] dp = new int[1 << n][n];
for (int[] row : dp) Arrays.fill(row, Integer.MAX_VALUE / 2);
dp[1][0] = 0; // 시작: 도시 0만 방문, 현재 도시 0
for (int mask = 1; mask <= FULL; mask++) {
for (int u = 0; u < n; u++) {
if ((mask & (1 << u)) == 0) continue; // u를 방문하지 않은 상태면 건너뜀
if (dp[mask][u] == Integer.MAX_VALUE / 2) continue;
for (int v = 0; v < n; v++) {
if ((mask & (1 << v)) != 0) continue; // v를 이미 방문했으면 건너뜀
int nextMask = mask | (1 << v);
dp[nextMask][v] = Math.min(
dp[nextMask][v],
dp[mask][u] + dist[u][v]
);
}
}
}
// 모든 도시 방문 후 시작점으로 돌아오기
int result = Integer.MAX_VALUE;
for (int u = 1; u < n; u++) {
result = Math.min(result, dp[FULL][u] + dist[u][0]);
}
return result;
}
시간: O(n² × 2^n), 공간: O(n × 2^n)
n이 20 이하일 때 사용 가능합니다 (2^20 ≈ 100만).
할당 문제 (Assignment Problem)
n명의 사람에게 n개의 일을 배정하는 최소 비용을 구하는 문제도 비트마스크 DP로 풀 수 있습니다.
// dp[mask] = mask에 해당하는 일들이 배정된 상태의 최소 비용
// bitCount(mask)번째 사람에게 일을 배정
public static int assignmentProblem(int[][] cost) {
int n = cost.length;
int[] dp = new int[1 << n];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for (int mask = 0; mask < (1 << n); mask++) {
int person = Integer.bitCount(mask); // 다음에 배정할 사람
if (person >= n) continue;
for (int job = 0; job < n; job++) {
if ((mask & (1 << job)) != 0) continue;
int nextMask = mask | (1 << job);
dp[nextMask] = Math.min(dp[nextMask],
dp[mask] + cost[person][job]);
}
}
return dp[(1 << n) - 1];
}
Knuth 최적화
구간 DP에서 분할점의 단조성을 이용하여 O(n³)을 O(n²)로 줄이는 기법입니다.
조건
dp[i][j] = min(dp[i][k] + dp[k][j]) + C[i][j] (i < k < j)
opt[i][j] = dp[i][j]의 최적 분할점 k
opt[i][j-1] ≤ opt[i][j] ≤ opt[i+1][j] (단조성)
이 단조성이 성립하면, 각 dp[i][j]에서 탐색하는 k의 범위가 줄어들어 전체가 O(n²)이 됩니다.
// Knuth 최적화 적용 예시
int[][] dp = new int[n + 1][n + 1];
int[][] opt = new int[n + 1][n + 1]; // 최적 분할점
// 기저
for (int i = 0; i < n; i++) opt[i][i] = i;
for (int len = 2; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
dp[i][j] = Integer.MAX_VALUE;
// 탐색 범위 제한!
for (int k = opt[i][j-1]; k <= opt[i+1][j]; k++) {
int cost = dp[i][k] + dp[k+1][j] + C(i, j);
if (cost < dp[i][j]) {
dp[i][j] = cost;
opt[i][j] = k;
}
}
}
}
분할 정복 최적화 (Divide and Conquer DP)
1차원 DP에서 전이가 단조성을 가질 때, 분할 정복으로 O(kn²)을 O(kn log n)으로 줄입니다.
조건
dp[i][j] = min(dp[i-1][k] + C[k+1][j]) (0 ≤ k < j)
opt[i][j] ≤ opt[i][j+1] (최적 분할점의 단조성)
구현 개요
static void solve(int i, int lo, int hi, int optLo, int optHi) {
if (lo > hi) return;
int mid = (lo + hi) / 2;
int bestK = optLo;
int bestVal = Integer.MAX_VALUE;
for (int k = optLo; k <= Math.min(mid - 1, optHi); k++) {
int val = dpPrev[k] + cost(k + 1, mid);
if (val < bestVal) {
bestVal = val;
bestK = k;
}
}
dpCurr[mid] = bestVal;
solve(i, lo, mid - 1, optLo, bestK);
solve(i, mid + 1, hi, bestK, optHi);
}
백엔드/실무에서의 연결
캐시 메모리 최적화
DP 테이블의 공간 압축은 캐시 효율성과도 연관됩니다. 1차원 배열로 압축하면 캐시 라인 활용이 좋아져 실제 성능도 개선됩니다.
배치 작업 스케줄링
서버 배치 작업을 K개의 시간대에 최적으로 배분하는 문제는 분할 정복 DP 최적화의 변형입니다.
네트워크 설계
TSP와 유사한 문제가 물류 배송 경로 최적화, 데이터센터 네트워크 설계 등에서 등장합니다.
권한 관리
사용자 권한 집합을 비트마스크로 표현하는 것 자체가 비트마스크 DP의 기초입니다.
주의할 점
공간 압축에서 참조 방향을 잘못 설정하는 실수
2D DP를 1D로 압축할 때, 이전 행(i-1)의 값을 참조해야 하는데 현재 행(i)의 갱신된 값을 참조하면 결과가 틀어집니다. 0/1 배낭의 역순 순회가 대표적인 예입니다.
비트마스크 DP에서 상태 수 폭발
n이 20이면 2^20 = 약 100만이라 가능하지만, n이 25면 2^25 = 약 3,300만으로 메모리가 부족할 수 있습니다. 비트마스크 DP의 실용적 한계는 n ≤ 20 정도입니다.
정리
| 최적화 기법 | 적용 조건 | 효과 |
|---|---|---|
| 롤링 배열 | 이전 행만 참조 | 공간 O(n×m) → O(m) |
| 1차원 압축 | 참조 방향이 단순 | 공간 O(n×m) → O(m) |
| 비트마스크 DP | 집합 상태 관리 (n ≤ 20) | 집합을 정수로 표현 |
| Knuth 최적화 | 분할점 단조성 | O(n³) → O(n²) |
| 분할 정복 최적화 | 전이 단조성 | O(kn²) → O(kn log n) |
기억할 포인트:
- ** 공간 압축 **의 핵심은 "현재 상태가 어떤 이전 상태만 참조하는가"를 파악하는 것입니다.
- 비트마스크 DP는 n ≤ 20 일 때 사용합니다. 2^20 ≈ 100만이므로 시간/공간 내 해결 가능합니다.
- 0/1 배낭의 역순 순회 와 완전 배낭의 정순 순회 차이를 확실히 이해하세요.
- 고급 최적화(Knuth, 분할 정복)는 단조성 조건이 성립하는지 확인이 필수입니다.