이 포스팅에서는 현대자동차 Softeer의 기출문제 [HSAT 6회 정기 코딩 인증평가 기출] 염기서열 커버를 다루며, 처음 떠올렸던 단순 풀이와 그 한계, 그리고 이를 극복하기 위해 수정한 아이디어와 최종 코드에 대해 설명합니다.
문제 설명
생명 공학을 연구하는 찬홍이는 DNA 염기서열을 연구 중이다. DNA 염기서열은 4종류의 핵염기 a, c, g, t가 일자로 연결된 문자열이며, 일부 좋은 염기서열은 와일드 카드
.
를 포함하여 해당 위치에 어떤 염기도 들어갈 수 있음을 의미한다.목표: 주어진 좋은 염기서열 N개(길이 M)를 모두 커버할 수 있는 “초염기서열”들을 만들 때, 필요한 초염기서열의 최소 개수를 구하라.
입력 예제1
4 5 a..tt gc... a.g.t .c.ag
출력 예제1: 2
입력 예제2
4 1 a g c t
출력 예제2: 4
입력 예제3
4 4 a... .c.. ..g. ...t
출력 예제3: 1
문제를 처음 접했을 때, 각 위치별로 등장하는 실제 염기들을 set에 저장한 후, 가장 많은 후보가 나온 자리의 개수만큼 초염기서열이 필요하다는 단순 아이디어로 접근했습니다.
import sys
input = sys.stdin.readline
N, M = map(int, input().split())
record = [set() for _ in range(M)]
for _ in range(N):
word = input().rstrip()
for i, char in enumerate(word):
if char == '.': continue
record[i].add(char)
print(len(max(record, key=len)))
이 코드는 각 열(위치)마다 등장하는 실제 염기 후보들을 기록하여, 한 위치에서 허용되는 염기의 종류를 파악하는 데 목적이 있습니다.
즉, "각 열은 독립적"이라는 가정 하에, 후보의 종류가 가장 많은 열의 개수가 초염기서열을 구성할 때 필요한 최소 개수라고 생각한 것입니다.
문제의 핵심은 각 열을 독립적으로 판단하는 것이 아니라, 주어진 좋은 염기서열들 간의 상호 호환성을 고려해야 한다는 점입니다.
즉, 한 초염기서열이 여러 좋은 염기서열을 동시에 커버할 수 있는지 여부(예: 특정 위치에서 서로 다른 염기를 요구하는 경우의 충돌)를 전혀 반영하지 못했습니다.
테스트 결과, 이 단순 접근은 정답률이 약 40% 정도에 머물렀습니다.
문제 해결을 위해 다음과 같이 문제를 재분석하였습니다.
can_cover
) 미리 계산합니다.coverable
)을 구합니다.수정된 방법은 각 그룹 내의 염기서열들이 서로 호환되는지를 먼저 판단한 후, 커버 가능한 모든 집합을 이용하여 DP로 최소 그룹 수를 찾는 것입니다.
즉, 단순히 각 열의 후보 수를 따지는 대신, “어떤 부분 집합의 좋은 염기서열들은 한 초염기서열로 동시에 커버될 수 있는가?”를 체크하고, 이를 기반으로 전체 커버 문제를 해결합니다.
최종 코드는 다음과 같습니다.
import sys
input = sys.stdin.readline
N, M = map(int, input().split())
seqs = [input().rstrip() for _ in range(N)]
def can_cover(s1, s2):
for i in range(M):
# 두 문자열 모두 구체적 염기를 요구하는 경우 서로 같아야 함
if s1[i] != '.' and s2[i] != '.' and s1[i] != s2[i]:
return False
return True
# compared 배열: 모든 염기서열 쌍의 호환성 미리 계산
# compared[i][j]가 True이면, 염기서열 i와 j는 한 초염기서열로 커버 가능하다.
compared = [[False] * N for _ in range(N)]
for i in range(N):
for j in range(N):
compared[i][j] = can_cover(seqs[i], seqs[j])
# coverable 배열: mask로 표현되는 염기서열 집합이 한 초염기서열로 커버 가능한지 저장
# coverable[mask]가 True이면, mask에 포함된 모든 염기서열은 서로 호환되어 한 초염기서열로 커버할 수 있다.
coverable = [False] * (1 << N)
coverable[0] = True # 빈 집합은 의미상 커버 가능
for mask in range(1 << N):
indices = [i for i in range(N) if mask & (1 << i)]
ok = True
for i in range(len(indices)):
for j in range(i + 1, len(indices)):
if not compared[indices[i]][indices[j]]:
ok = False
break
if not ok:
break
coverable[mask] = ok
# dp 배열: dp[mask]는 mask에 해당하는 염기서열들을 커버하기 위해 필요한 최소 초염기서열의 개수
# 예를 들어, N=4에서 mask가 0b1011이면 0, 1, 3번 염기서열이 커버된 상태를 의미.
dp = [N for _ in range(1 << N)]
dp[0] = 0
for mask in range(1 << N):
remain = mask ^ ((1 << N) - 1) # 아직 커버되지 않은 염기서열, 즉 전체 집합에서 현재 mask를 뺀 나머지.
sub = remain
while sub:
# 현재 remain의 모든 부분 집합(sub)을 순회합니다.
# sub가 coverable하다는 것은, sub에 포함된 염기서열들이 한 초염기서열로 커버 가능한 그룹이라는 의미입니다.
if coverable[sub]:
next_mask = mask | sub # 기존에 커버된 염기서열(mask)에 sub에 해당하는 염기서열을 추가.
dp[next_mask] = min(dp[next_mask], dp[mask] + 1)
# (sub - 1) & remain : remain 내의 다음 부분 집합을 구하는 전형적인 방법입니다.
sub = (sub - 1) & remain
print(dp[-1])
compared
모든 좋은 염기서열 쌍의 호환성을 미리 계산한 2차원 배열입니다.
compared[i][j] = True
이면, 염기서열 i
와 j
가 한 초염기서열로 커버 가능하다는 뜻입니다.coverable
각 부분 집합(mask)으로 표현되는 좋은 염기서열 그룹이 한 개의 초염기서열로 모두 커버 가능한지 여부를 저장합니다.
coverable[mask] = True
이면, mask에 포함된 모든 염기서열들이 서로 호환되어 한 초염기서열로 커버할 수 있다는 의미입니다.dp
상태 압축(dynamic programming)을 사용하여, 현재까지 커버된 좋은 염기서열 집합(mask)에 대해 필요한 초염기서열의 최소 개수를 저장합니다.
mask = 0b1011
은 0, 1, 3번 염기서열이 커버된 상태를 나타냅니다. dp[mask]
는 해당 상태에 도달하기 위해 사용된 초염기서열의 최소 개수를 의미합니다.remain
의 의미다음 코드는 dp 상태를 갱신하는 부분입니다.
for mask in range(1 << N):
remain = mask ^ ((1 << N) - 1) # 아직 커버되지 않은 염기서열
sub = remain
while sub:
# sub가 coverable하다면, mask와 합쳐 새로운 상태로 전이
if coverable[sub]:
next_mask = mask | sub
dp[next_mask] = min(dp[next_mask], dp[mask] + 1)
sub = (sub - 1) & remain
remain
을 구할까?remain
은 전체 염기서열 집합에서 아직 커버되지 않은 염기서열을 추려내기 위해 계산합니다.remain
을 구함으로써, 현재 상태(mask)에서 추가해야 할 염기서열만 집중적으로 탐색할 수 있으며, 이를 통해 dp 상태를 올바르게 업데이트할 수 있습니다.mask와 remain
mask
에 해당하는 비트를 제외한, 아직 커버되지 않은 염기서열을 나타냅니다. 0b111
입니다. mask = 0b010
(두 번째 염기서열만 커버됨)라면,부분 집합(sub) 순회
remain
에 포함된 염기서열들 중에서, 한 초염기서열로 커버 가능한 그룹(sub)을 모두 탐색합니다.remain = 0b101
일 경우 가능한 sub는: sub = 0b101
sub = 0b100
sub = 0b001
dp 상태 전이 (업데이트)
현재 Mask (binary) | Remain (binary) | 선택한 sub (coverable) | 다음 Mask (mask | sub) | dp 업데이트 | 설명 |
---|---|---|---|---|---|
000 | 111 | 111 | 111 | dp[111] = dp[000] + 1 = 1 | 초기 상태(000)에서 모든 염기서열(111)을 한 번에 커버 |
000 | 111 | 101 | 101 | dp[101] = dp[000] + 1 = 1 | 초기 상태(000)에서 염기서열 0,2(101)을 커버 |
010 | 101 | 101 | 111 | dp[111] = min(dp[111], dp[010] + 1) = 2 | 상태 010에서 남은 염기서열 0,2(101)을 커버하여 전체(111) 도달 |
mask
에서, coverable한 부분 집합 sub
를 선택하면 새로운 상태 next_mask = mask | sub
가 됩니다.dp[next_mask]
는 dp[mask]
에 초염기서열 1개를 추가한 값과 비교하여 최솟값으로 갱신됩니다.mask = 0b000
(아무것도 커버하지 않음), 따라서 dp[0] = 0
sub = 0b111
가 coverable하다면,next_mask = 0b000 | 0b111 = 0b111
→ dp[0b111]
는 dp[0] + 1
로 1이 됩니다.mask = 0b111
)에 도달할 때까지 진행됩니다.이 과정은 현재 커버 상태(mask)에서 출발해, 아직 커버되지 않은 염기서열(remain) 중 한 번에 커버 가능한 그룹(sub)을 선택하여 전체 염기서열을 커버하는 최소 초염기서열 개수를 찾기 위한 핵심 전이 과정입니다.
초기 단순 접근은 각 열의 후보 수에만 의존하여 문제의 핵심인 서로 다른 좋은 염기서열 간의 호환성을 간과했습니다.
수정된 아이디어는 미리 호환성을 계산하고, 상태 압축 DP를 통해 최소 초염기서열 개수를 구하는 방식으로 문제의 복잡한 조건을 해결할 수 있었습니다.
문제 풀이 과정에서 부분 집합(마스크) 탐색과 상태 압축 DP를 경험할 수 있어 매우 유익한 문제였습니다. 여러분도 다양한 아이디어로 문제에 도전해보시기 바랍니다!
이상으로 Softeer "염기서열 커버" 문제 풀이 및 분석을 마칩니다. 질문이나 의견은 댓글로 남겨주세요!