이전 글에서 다루었던 트라이 자료구조를 활용하여 해결할 수 있는 문제를 살펴본다.

 

1. 개미굴(https://www.acmicpc.net/problem/14725)

 탐사용 개미로봇은 트리 형태를 이루는 개미굴을 입구(루트)부분에서 시작하여 아래로 내려가며 탐색을 진행하고 더이상 내려갈 수 없어지면 지금까지 거쳐온 방의 정보를 전송하고 작동을 중지한다.  개미로봇들이 보낸 정보를 토대로 개미굴의 형태를 그려내는 문제.

 

 단순히 개미로봇이 보낸 정보들을 트라이에 삽입한 후 트라이의를 출력형식에 맞춰 문자열화하는 것으로 간단하게 해결 가능한 문제이다.  문제를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


# 트라이의 노드 클래스
# data(키 값), child(자식노드 목록), finished(데이터의 마지막 노드인지 여부)의 세 필드값을 가진다.
class Node:
    # 생성자
    def __init__(self, data=None, finished=False):
        self.data = data
        self.finished = finished
        self.child = {}

    # 노드를 문자열로 표현
    def __str__(self, depth=0):
        res = [str(self.data)]
        for c in self.child.values():
            res.append('--' * depth + c.__str__(depth + 1))
        return '\n'.join(res)


# 트라이 클래스
class Trie:
    # 생성시 빈 노드를 생성하여 루트노드로 설정
    def __init__(self):
        self.root = Node()

    # 삽입 메소드
    def add(self, e):
        # 루트 노드에서 탐색 시작
        cur = self.root

        # 데이터의 각 요소를 키 값으로 하는 노드를 타고내려간다.
        for i in e:
            # 현재 노드의 자식노드 중에
            # 다음 요소를 키 값으로 하는 노드가 존재하지 않는다면 노드를 생성
            # 현재 노드의 자식노드 리스트에 삽입한다.
            if i not in cur.child:
                cur.child[i] = Node(i)
            cur = cur.child[i]
        # 현재 노드가 데이터의 마지막 요소를 키값으로 하는 노드임을 표시
        cur.finished = True

    # 탐색 메소드
    def search(self, key):
        # 루트 노드에서 탐색 시작
        cur = self.root

        # 데이터의 각 요소를 키 값으로 하는 노드를 타고내려간다.
        for c in key:
            # 현재 노드의 자식노드 중에
            # 다음 요소를 키 값으로 하는 노드가 존재하지 않는다면 노드를 생성
            # 찾으려는 데이터가 존재하지 않음을 결과로 반환한다.
            if c in cur.child:
                cur = cur.child[c]
            else:
                return None
        # 탐색이 종료됐을 때, 마지막 노드가 데이터의 마지막 요소를 키 값으로 하는 노드로
        # 표시되어있다면 탐색 성공.  그렇지 않다면 데이터가 존재하지 않음을 결과로 반환한다.
        return cur.data if cur.finished else None

    # 트라이를 문자열로 표현
    def __str__(self):
        return '\n'.join([c.__str__(1) for c in self.root.child.values()])


def sol14725():
    # 로봇개미의 수
    n = int(input())

    # 트라이 인스턴스
    t = Trie()

    # 로봇개미가 가져온 정보를 트라이에 삽입
    for e in sorted([input().split()[1:] for _ in range(n)]):
        t.add(e)

    # 트라이 인스턴스를 문자열화 하여 반환
    return str(t)

 

 

2. 문자열 집합(https://www.acmicpc.net/problem/14425)

 N개의 문자열로 이루어진 집합 S 와 M개의 문자열이 주어졌을 때, M개중 몇 개의 문자열이 S에 속해있는지 구하는 문제.  사실 Python의 경우 set 등을 활용하여 더 빠르고 쉽게 해결할 수 있지만 연습을 위해 트라이를 사용하여 풀어보았다.  코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


# 트라이의 노드 클래스
# data(키 값), child(자식노드 목록), finished(데이터의 마지막 노드인지 여부)의 세 필드값을 가진다.
class Node:
    def __init__(self, data=None, finished=False):
        self.data = data
        self.finished = finished
        self.child = {}


# 트라이 클래스
class Trie:
    # 생성시 빈 노드를 생성하여 루트노드로 설정
    def __init__(self):
        self.root = Node()

    # 삽입 메소드
    def add(self, e):
        # 루트 노드에서 탐색 시작
        cur = self.root

        # 데이터의 각 요소를 키 값으로 하는 노드를 타고내려간다.
        for i in e:
            # 현재 노드의 자식노드 중에
            # 다음 요소를 키 값으로 하는 노드가 존재하지 않는다면 노드를 생성
            # 현재 노드의 자식노드 리스트에 삽입한다.
            if i not in cur.child:
                cur.child[i] = Node(i)
            cur = cur.child[i]
        # 현재 노드가 데이터의 마지막 요소를 키값으로 하는 노드임을 표시
        cur.finished = True

    # 탐색 메소드
    def search(self, key):
        # 루트 노드에서 탐색 시작
        cur = self.root

        # 데이터의 각 요소를 키 값으로 하는 노드를 타고내려간다.
        for c in key:
            # 현재 노드의 자식노드 중에
            # 다음 요소를 키 값으로 하는 노드가 존재하지 않는다면 노드를 생성
            # 찾으려는 데이터가 존재하지 않음을 결과로 반환한다.
            if c in cur.child:
                cur = cur.child[c]
            else:
                return None
        # 탐색이 종료됐을 때, 마지막 노드가 데이터의 마지막 요소를 키 값으로 하는 노드로
        # 표시되어있다면 탐색 성공.  그렇지 않다면 데이터가 존재하지 않음을 결과로 반환한다.
        return cur.data if cur.finished else None


def sol14425():
    # 집합에 속한 문자열의 갯수 N, 검사할 문자열의 갯수 M
    n, m = map(int, input().split())

    # 입력된 모든 문자열
    strings = sys.stdin.read().split()

    # N개의 문자열을 트라이에 삽입
    t = Trie()
    for i in range(n):
        t.add(strings[i])

    # M개의 문자열을 모두 탐색하여 몇 개의 문자열이 트라이에 존재하는지 센다
    cnt = 0
    for i in range(n, n + m):
        cnt += (1 if t.search(strings[i]) else 0)

    # 결과반환
    return cnt

단순히 N개의 문자열을 모두 트라이에 넣고 M개의 문자열에 대해 탐색을 진행하는 것으로 해결한 풀이이다.  시간복잡도는 1<=L(문자열 최대길이)<=500,  1<=N<=10,000, 1<=M<=10,000 일 때  O(L(N+M)) 의 시간복잡도를 보이기에 PyPy3으로만 AC를 받을 수 있었다.  Python3으로는 추후에 좀더 최적화를 거쳐 다시 제출해보기로 한다.

 

 

3. 휴대폰 자판(https://www.acmicpc.net/problem/5670)

 자동완성 기능을 가진 휴대폰 자판으로 단어들을 타이핑할 때 필요한 타이핑 횟수의 평균값을 구하는 문제. 사전에 속해있는 단어의 갯수 N이 최대 10만이며 각 케이스에 주어지는 단어 길이의 총합도 100만으로 상당히 많은 양의 데이터를 처리해야하기 때문에 모든 단어마다 실제로 시뮬레이션을 돌려보는것은 시간초과가 발생할 것이다. 

 

각 단어가 삽입될 때, 거쳐간 노드의 카운트를 1씩 증가시키는 것으로 해당 노드가 몇개의 단어에 포함되어있는지 파악하고, dfs로 모든노드를 탐색하며 눌리지 않을 버튼을 키값으로 하는 노드를 제외한 모든 노드의 카운트값을 더하는 것으로 눌러야할 버튼 수 의 총합을 구할 수 있다.  문제를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


# 트라이의 노드 클래스
# data(키 값), child(자식노드 목록), finished(데이터의 마지막 노드인지 여부), cnt(거쳐간 단어의 수)의 네 개의 필드값을 가진다.
class Node:
    def __init__(self, data=None, finished=False):
        self.data = data
        self.finished = finished
        self.child = {}
        self.cnt = 0


# 트라이 클래스
class Trie:
    # 생성시 빈 노드를 생성하여 루트노드로 설정
    def __init__(self):
        self.root = Node(finished=True)

    # 삽입 메소드
    def add(self, e):
        # 루트 노드에서 탐색 시작
        cur = self.root

        # 데이터의 각 요소를 키 값으로 하는 노드를 타고내려간다.
        for i in e:
            # 현재 노드의 자식노드 중에
            # 다음 요소를 키 값으로 하는 노드가 존재하지 않는다면 노드를 생성
            # 현재 노드의 자식노드 리스트에 삽입한다.
            if i not in cur.child:
                cur.child[i] = Node(i)

            # 해당 노드를 거쳐간 문자열의 수를 1 증가시킨다.
            cur.child[i].cnt += 1

            cur = cur.child[i]
        # 현재 노드가 데이터의 마지막 노드임을 표시
        cur.finished = True

    # 트라이의 루트노드를 반환
    def get_root(self):
        return self.root


def sol5670():
    # 케이스의 정답 목록을 저장할 리스트
    answer = []

    for n in sys.stdin:
        # 케이스별 영단어의 갯수
        n = int(n)

        # 단어 리스트
        words = [input().strip() for _ in range(n)]

        # 트라이에 모든 단어를 삽입
        t = Trie()
        for word in words:
            t.add(word)

        # 루트노드의 모든 자식노드들에 대해 dfs 를 실행, 그 결과를 모두 더한다
        # res 값은 모든 영단어를 타이핑하기 위해 버튼을 눌러야할 횟수의 총합
        res = 0
        root = t.get_root()
        for cur in root.child.values():
            res += dfs(root, cur)

        # 평균값을 소숫점 셋째자리에서 반올림하여 answer 리스트에 삽입
        answer.append('%.2f' % (res / n))

    # 출력형식에 맞춰 정답리스트 반환
    return '\n'.join(answer)


# 탐색 함수
def dfs(parent, node):
    # 버튼을 눌러야 할 횟수
    res = 0

    # 현재 노드가 부모노드의 유일한 자식노드이거나
    # 부모노드가 데이터의 마지막 노드로 표시되어있을 경우
    # 자동완성이 되지 않기 때문에 버튼을 눌러야함
    if parent.finished or len(parent.child) > 1:
        res += node.cnt

    # 자식노드에 대해 탐색함수 재귀호출하여 결과값을 res 에 가산
    for c in node.child.values():
        res += dfs(node, c)

    # 버튼을 눌러야 할 횟수 반환
    return res

약간의 최적화가 필요하긴 했지만 아슬아슬하게 Python3 으로도 AC를 받을 수 있었다.   

 

 문자열을 보다 효율적으로 탐색할 수 있도록 저장하는 자료구조인 트라이(Trie)에 대해 알아본다.

 

 

1. 개요

 데이터의 빠른 탐색을 위한 자료구조라고 하면 보통 이진탐색트리나 B트리, 해시테이블 등을 떠올릴 것이다.  이러한 자료구조들의 삽입/탐색 연산은 데이터의 갯수(N)에 영향을 받는다.  이번에 알아볼 트라이(Trie)는 문자열을 저장하고 효율적으로 탐색하기 위한 자료구조로, 특이하게도 데이터의 갯수가 아닌 삽입하거나 탐색할 데이터(문자열)의 크기에 영향을 받는다. 

 

2. 삽입 연산

 예시로 트라이에 'hello' 라는 데이터를 삽입해보자.

 

트라이의 초기상태

먼저 루트노드의 자식노드중 키 값이 'h' 인 노드를 찾는다 현재 트라이는 비어있기 때문에 루트노드의 자식 중에는 'h'를 키값으로 가진 노드는 존재하지 않는다.  그러면 'h'를 키 값으로 하는 노드를 생성하여 루트노드의 자식노드로 집어넣는다.

 

'hello' 삽입과정

이제 새로 집어넣은 'h'를 키 값으로 하는 노드로 위치를 옮겨, 그 노드의 자식노드중 다음 문자인 'e'를 키값으로 하는 노드를 찾는다.  이번에도 'h' 노드의 자식노드는 비어있기 때문에 노드를 새로 만들어서 추가한다. 이것을 다섯글자 모두 삽입할 때 까지 반복한다.

 

'hello' 삽입완료

이것으로 'hello' 의 삽입이 완료되었다.  다음은 트라이에 'head' 를 집어넣어보자. 루트노드에서 시작하여 글자를 하나하나 비교해나간다.  이번에는 첫 글자 h가 루트노드의 자식노드에 존재한다. 해당노드로 위치를 옮긴다. 

 

다음은 'e'를 찾는다. 이번에도 'h' 노드의 자식노드에 'e'가 존재한다. 또다시 위치를 옮긴다.

 

이번에는 'e' 노드의 자식노드에 'a' 가 존재하지 않는다.  'a' 노드를 생성하여 자식노드에 삽입하고 삽입한 노드로 위치를 이동한다.

 

마지막으로 'd'가 'a' 노드의 자식노드에 존재하지 않기 때문에 노드를 생성하여 집어넣어준다.

 

 이것으로 'head'의 삽입이 완료되었다.  

 

그림으로만 살펴봐도 알겠지만 삽입과정이 굉장히 단순하다.  루트노드에서 시작하여 자식노드에서 데이터를 이루는 요소를 순차적으로 탐색해나가다가 존재하지 않는다면 생성하여 삽입해주는 방식이다.  물론 삽입 연산의 시간복잡도는 길이 L 인 문자열에 대해 O(L) 이다. 

 

 

3. 탐색

 탐색 알고리즘도 삽입과 거의 동일한 방식으로 이뤄진다.  다만 삽입 연산에서는 자식노드에 원하는 문자를 키 값으로 가진 노드가 없다면 만들어서 삽입하였지만 탐색의 경우 탐색에 실패했다는 결과가 바로 반환된다. 

 삽입 연산에서와 달리 일부 노드에 *표시가 있는 것을 확인할 수 있다. 이는 한 데이터의 끝을 표현한다.  데이터를 삽입할 때, 마지막 요소를 삽입할 경우 이 노드에서 끝나는 데이터가 존재함을 표시해주는 것으로 삽입하지 않은 데이터를 존재하는 것으로 오인할 위험을 미연에 방지하는 것이다.  예를들어 위 트라이에서 'hell'을 탐색해보자. 

 

이와 같이 'h', 'e', 'l', 'l' 네 글자 모두 탐색에 성공하였다.  하지만 'hell' 이라는 문자열은 삽입된 적이 없으며 단지 'hello'가 삽입되었기 때문에 탐색이 가능했을 뿐이다.  만약 데이터의 끝을 표시하지 않았다면 이 상황에 hell이 정말 들어있는 것인지 아닌지 구분할 방법이 없지만 *로 데이터의 마지막 노드에는 표시를 해두었기 때문에 'hell'이라는 문자열이 존재하지 않는다는 사실을 알 수 있다.

 

 

4. 구현

Python으로 트라이를 간단하게 구현한 코드는 다음과 같다. 

# 트라이의 노드 클래스
# data(키 값), child(자식노드 목록), finished(데이터의 마지막 노드인지 여부)의 세 필드값을 가진다.
class Node:
    def __init__(self, data=None, finished=False):
        self.data = data
        self.finished = finished
        self.child = {}


# 트라이 클래스
class Trie:
    # 생성시 빈 노드를 생성하여 루트노드로 설정
    def __init__(self):
        self.root = Node()

    # 삽입 메소드
    def add(self, e):
        # 루트 노드에서 탐색 시작
        cur = self.root
        
        # 데이터의 각 요소를 키 값으로 하는 노드를 타고내려간다.
        for i in e:
            # 현재 노드의 자식노드 중에
            # 다음 요소를 키 값으로 하는 노드가 존재하지 않는다면 노드를 생성
            # 현재 노드의 자식노드 리스트에 삽입한다.
            if i not in cur.child:
                cur.child[i] = Node(i)
            cur = cur.child[i]
        # 현재 노드가 데이터의 마지막 요소를 키값으로 하는 노드임을 표시
        cur.finished = True

    # 탐색 메소드
    def search(self, key):
        # 루트 노드에서 탐색 시작
        cur = self.root
        
        # 데이터의 각 요소를 키 값으로 하는 노드를 타고내려간다.
        for c in key:
            # 현재 노드의 자식노드 중에
            # 다음 요소를 키 값으로 하는 노드가 존재하지 않는다면 노드를 생성
            # 찾으려는 데이터가 존재하지 않음을 결과로 반환한다.
            if c in cur.child:
                cur = cur.child[c]
            else:
                return None
        # 탐색이 종료됐을 때, 마지막 노드가 데이터의 마지막 요소를 키 값으로 하는 노드로
        # 표시되어있다면 탐색 성공.  그렇지 않다면 데이터가 존재하지 않음을 결과로 반환한다.
        return cur.data if cur.finished else None

 

 저번 글에서 알아봤던 KMP 알고리즘을 활용하여 풀 수 있는 문제들에 대해 알아본다.

 

1. 문자열 제곱(https://www.acmicpc.net/problem/4354)

 문자열 a의 n 제곱을 문자열 a를 n번 이어붙이는 연산으로 정의하고(ex)  'abc'^3 == 'abcabcabc') 문자열 s가 주어졌을 때,  s 를 a^n 을 만족하는 가장 큰 n을 구하는 문제.  

 

 KMP 알고리즘 전체가 아니라 KMP 알고리즘에서 사용했던 실패함수, 즉 LPS를 구하는 알고리즘을 활용하여 풀 수 있는 문제이다.  문제로 주어진 각 케이스들은 문자열 s 의 lps값에 따라 다음의 세 가지 경우로 나눌 수 있다.

 

 

# case 1) len(lps)*2 < len(s)

len(lps) * 2(양 끝의 lps 길이를 더한 값)가 문자열의 길이보다 짧을 경우, 해당 문자열은 어떤 문자열의 반복으로 나타낼 수 없다. lps에 속하지 않은 문자열을 표현할 방법이 없기 때문이다.  이 경우 s = s ^ 1 로 밖에 나타낼 수 없기 때문에 답은 1이 된다.

 

# case 2) len(lps)*2 == len(s)

len(lps) * 2 가 문자열의 길이와 같을 경우, 해당 문자열은 lps ^ 2 로만 나타낼 수 있다.  n이 더 커지려면 lps 자체도 문자열의 반복으로 이루어져야 하는데, 그렇게 될 경우 당연히 lps는 더 길어지기 때문에 그런 경우는 있을 수 없다. 답은 2가 된다.

 

# case 3) len(lps)*2 > len(s) 

이 경우는 또다시 두 가지 경우로 나뉘어진다.  두 LPS가 겹친 부분의 문자열을 v, lps에서 공통부분을 뺀 문자열을 u 라고 하자.  이때 len(v)가 len(u)의 배수가 된다면 s = u ^ ((v // u) + 2) 로 나타낼 수 있게된다. 이 경우에 답은 v // u + 2 가 된다.

 

만약 len(v) 가 len(u) 의 배수가 아니라면 문자열 s는 반복되는 문자열로 나타낼 수 없다.  s = s ^ 1 로 밖에 나타낼 수 없기 때문에 이 경우에 답은 1이 된다.

 

 

이 사실을 토대로 문제를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.read


def sol4354():
    # 각 케이스의 정답을 저장할 리스트
    answer = []
    
    # 케이스의 문자열 s
    for s in input().splitlines():
        # s가 '.'이라면 종료
        if s == '.':
            break
            
        # lps 테이블 전처리
        m = len(s)
        dp = [0]*m
        i = 0
        for j in range(1, m):
            while i > 0 and s[i] != s[j]:
                i = dp[i-1]
            if s[i] == s[j]:
                i += 1
                dp[j] = i
        
        # 공통부분 v
        v = dp[-1] * 2 - m
        
        # 공통부분의 길이가 0보다 작은 경우
        # lps * 2 < m 이기 때문에 답은 1
        if v < 0:
            answer.append(1)
            
        # 공통부분의 길이가 0인 경우
        # lps * 2 == m 이기 때문에 답은 2
        elif v == 0:
            answer.append(2)
            
        # 공통부분의 길이가 1 이상인 경우
        else:
            # lps에서 공통부분을 뺀 길이
            u = dp[-1] - v
            
            # v가 u의 배수가 아니라면 
            # 반복되는 문자열로 나타낼 수 없기 때문에 답은 1
            if v % u:
                answer.append(1)
                
            # v가 u의 배수라면 
            # 양 끝의 u와 중간의 v//u 개의 u를 이어붙인 형태이기 때문에
            # u ^ (v//u + 2) 로 나타낼 수 있음.
            # 답은 v//u + 2
            else:
                answer.append(v//u+2)
                
    # 정답 리스트를 출력형식에 맞춰 반환
    return '\n'.join(map(str, answer))

 

 

2. 광고(https://www.acmicpc.net/problem/1305)

 길이 L의 전광판에 길이 N의 광고문구를 무한히 이어붙인 문자열이 표시될 때,  광고문구가 될수있는 가장 짧은 문자열의 길이를 구하는 문제.  예를들어 길이가 6인 광고판에 aabaaa가 표시된 경우 원래 광고로 추정 가능한 가장 짧은 것은 'aaba', 'aaab', 'baaa', 'abaa' 등이다.

 

 이 문제 또한 KMP 알고리즘의 실패함수를 활용하여 해결할 수 있다. 전광판에 표시된 문자열이 광고를 무한히 이어붙인 형태라면 문자열 내에는 광고의 시작부분이 반복되는 구간이 존재한다.  이를 문자열의 lps라고 생각하면 나머지는 간단하다.  예시로 든 'aabaaa' 의 경우, lps 는 'aa' 가 된다.  그러면 'aabaaa' 에서 suffix에 해당하는 'aa' 는 광고가 이어붙여진 부분이라고 생각할 수 있다. 이렇게 생각할 경우 이 광고의 원래 형태는 'aaba' + 'aaba' + 'aaba' ... 가 된다.  그리고 이것이 가능한 광고의 가장 짧은 형태이다.   즉, 전광판의 길이 L과 문자열 s가 주어졌을 때,  가능한 광고 길이의 최솟값은 L - len(lps) 가 된다.  이 사실을 토대로 문제를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol1305():
    # 전광판의 크기
    L = int(input())
    
    # 전광판에 표시된 문자열
    adv = input().rstrip()
    
    # LPS 테이블 전처리
    lps = [0] * L
    i = 0
    for j in range(1, L):
        while i > 0 and adv[i] != adv[j]:
            i = lps[i-1]
        if adv[i] == adv[j]:
            i += 1
            lps[j] = i
           
    # L - len(lps) 반환
    return L-lps[-1]

 

 

3. 시계 사진들(https://www.acmicpc.net/problem/10266)

 동일한 길이의 바늘 n 개를 가진 두 시계의 각 바늘이 9시방향과 이루는 각도가 순서없이 주어졌을 때, 두 시계를 회전시켜 같은 시간을 가리키도록 할 수 있는지 구하는 문제.  각도의 단위는 1/1000도(degree)이다.

 

 문제해결의 가장 중요한 아이디어는 바늘을 건드리지않고 두 시계를 회전시켜 같은 시간을 가리키도록 할 수 있으려면 두 시계의 인접한 바늘들이 서로 같은 각도만큼 떨어져있어야 한다는 것이다. 문제를 해결한 절차는 다음과 같다.

 

① 두 시계의 시계바늘의 각도들을 오름차순으로 정렬한다.

 

② 각 시계의 인접한 바늘끼리의 각도의 차를 구한다.(A, B)  이 때, 마지막 각도와 첫 번째 각도의 차도 구해야함에

    유의해야 하며, 마지막 바늘에서 시작하여 첫 바늘로 가는 각도이기 때문에 An = D1 - (Dn - 360000) 와 같이

    계산해야한다.

 

③ 시계를 회전시키는 것으로 같은 시각을 가리키게 할 수 있으려면 인접한 바늘의 각도차 리스트 A, B중 한쪽을

    회전시켜가며 비교해야한다. 

 위 그림의 두 시계를 회전하여 같은 시각을 가리키도록 만들 수 있는지 확인하기 위해 인접한 바늘들의 각도차 리스트

A = [A1, A2, A3, A4, A5, A6] 와 B = [B1, B2, B3, B4, B5, B6] 를 비교해야한다. 그런데 A나 B 한쪽을 회전시켜가며 비교하는 것은 O(N^2) 의 시간복잡도를 가지기 때문에 N의 최대 크기가 20만인 이 문제를 1초안에 해결할 수 없다. 하지만 KMP 알고리즘을 활용하면 이 문제를 간단히 해결할 수 있다.

 

④ 리스트 A를 회전시킨 결과 B와 일치하는 경우가 존재한다면 A를 두번 이어붙인 리스트에 B와 일치하는 구간이

    존재한다고도 생각할 수 있다. 즉, 리스트 A를 두번 이어붙인 리스트에서 리스트 B가 등장하는지 KMP 알고리즘으로

    검사한다면 O(N)의 시간복잡도로 이 작업을 해결할 수 있다.

 

위 사실을 토대로 문제를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol10266():
    # 시계의 바늘 갯수
    n = int(input())
    
    # 두 시계의 바늘의 각도를 오름차순 정렬
    x = sorted(map(int, input().split()))
    y = sorted(map(int, input().split()))

    # 인접한 바늘끼리의 각도차 리스트를 구함
    a = ([x[(i + 1)] - x[i] for i in range(n - 1)] + [x[0] - (x[-1] - 360000)]) * 2
    b = [y[(i + 1)] - y[i] for i in range(n - 1)] + [y[0] - (y[-1] - 360000)]
    n, m = 2 * n, n

    # LPS 테이블 전처리
    lps = [0] * (m + 1)
    i = 0
    for j in range(1, m):
        while i > 0 and b[i] != b[j]:
            i = lps[i - 1]
        if b[i] == b[j]:
            i += 1
            lps[j] = i

    # KMP 알고리즘으로 문자열 탐색 시작
    i, j = 0, 0
    while i < n:
        if a[i] == b[j]:
            i += 1
            j += 1
            # 탐색에 성공했다면 같은 시각을 나타내도록 할 수 있음
            # 'possibe' 반환
            if j == m:
                return 'possible'
        else:
            if j == 0:
                i += 1
            j = lps[j - 1]
    
    # 모든 작업을 마칠동안 탐색에 성공하지 못했다면
    # 같은시각을 나타내도록 할 수 없음 
    # 'impossible' 반환
    return 'impossible'

 

KMP 알고리즘을 통한 문자열 검색 자체도 활용도가 높지만 실패함수도 활용도가 상당히 높다는걸 알 수 있었다.

필요하다면 언제든지 구현할 수 있도록 기억해두는 것이 좋겠다.

 문서 편집기나 뷰어 등에서 탐색 기능을 사용하면 문서내의 모든 문자열 속에서 찾으려는 문자열의 갯수와 위치를 보여준다.  전체 문자열의 길이가 N, 찾으려는 문자열의 길이가 M 일때, 단순히 시작위치를 0부터 1씩 옮겨가며 M개의 문자를 비교한다면 시간복잡도는 (N-M+1) * M, 즉 O(NM) 이 된다.   하지만 사실 이보다 더 빠르게 문자열을 탐색할 수 있는 방법이 있다.  이번에는 문자열 속에서 다른 문자열 P의 갯수와 위치를 찾는 KMP 알고리즘을 알아본다.

 

1. KMP 알고리즘의 개요

 KMP 알고리즘의 핵심은 쓸모없는 중간과정을 건너뛰는 것으로 연산의 횟수를 획기적으로 줄이는 것이다.

예를들어 T = 'ABCDABCDABDE' 이고 P = 'ABCDABD일 때, T에 P가 나타나는 횟수와 그 위치들을 찾아보자.

 

먼저 시작 위치를 0 번째 위치로 하고 T와 P를 순차적으로 비교하면 다음과 같은 결과를 얻을 수 있다.

T A B C D A B C D A B D E
P A B C D A B D          
  O O O O O O X          

 5 번째 문자까지는 일치했지만 6 번째 문자에서 일치하지 않은 것을 확인할 수 있다. 단순한 방법이라면 여기서 탐색 위치를 0으로 옮겨 또다시 M번의 탐색을 해야한다.  하지만 우리는 사실 이 결과에서 6 번째 문자가 일치하지 않았다는 점 보다도 5 번째 문자까지는 일치했다는 점을 활용해야한다

 

 일치한 부분까지의 문자열은 ABCDAB 이다.  여기서 양 끝에서 반복적으로 나타나는 가장 긴 문자열 AB에 주목하자. 이러한 문자열을 문자열의 접두사/접미사(prefix/suffix)라 한다. AB는 문자열 P의 시작부분이며 동시에 T와 일치했던 부분의 끝부분이기도 하다.  이 구간에서 AB가 다시한번 나타나는 4번째 위치 이전까지는 시작위치로 잡고 탐색하더라도 일치할 가능성이 없다.  즉, 다음 탐색의 시작위치를 곧바로 AB가 다시한번 나타나는 구간으로 건너뛸 수 있다는 것이다.  건너뛴 다음의 모습은 다음과 같다.

 

T A B C D A B C D A B D E
P         A B C D A B D  
          O O O O O O O  

물론 여기서도 P[0] 부터 다시 비교할이유는 없다.  우린 이미 AB까지는 일치함을 알고있기 때문이다. 즉 다음에 탐색해야할 P의 요소는 P[2] 가 되며 T의 요소는 이전에 불일치가 발생한 부분인 T[6]이 된다.

 

이를 일반화한 절차는 다음과 같다.

 

① 길이가 각각 N, M 인 문자열 T와 P에 대해 탐색을 진행, 리스트 L에 문자열 P가 등장하는 모든 위치를 담는다.

 

② T와 P의 초기 인덱스 i, j는 각각 0으로 초기화

 

③ T[i]와 P[j] 가 같다면 두 인덱스를 각각 +1 

    => 만약 그 결과 j가 M이 됐다면(모든 문자가 일치했다면) 찾아낸 구간의 시작위치(i - M)를 리스트 L에 append

    => j를 P[0~j-1] 구간의 접두사/접미사의 길이로 변경. 반복되는 부분을 생략하고

        그 다음 요소부터 검사하기 위함

 

④ T[i]와 P[j] 가 다르다면

    => 만약 j가 0이라면(P문자열의 시작부터 일치하지 않았다면) T[i] 로 시작하는 문자열과는 일치할 수 없기 때문에

         i += 1

    => j를 P[0~j-1] 구간의 양 끝의 가장 긴 반복문자열(위 예시 기준 AB) 의 길이로 설정. 반복되는 부분을 생략하고

        그 다음 요소부터 검사하기 위함

 

⑤ i의 인덱스가 N 이상일 경우 더이상 탐색할 수 없기 때문에 작업 종료

 

⑥ 리스트 L에 담긴 숫자들이 문자열 P가 등장한 모든 인덱스를 나타내며 L의 길이가 P가 나타난 횟수가 된다.

 

 

2. LPS(Longest Proper prefix and Suffix) 테이블

 KMP 알고리즘의 각 단계는 기본적으로 i가 움직이거나 문자열 P의 비교 시작 위치가 움직이기 때문에 O(N+M)의 시간복잡도를 보인다.  다만 여기에는 하나의 전제가 필요하다.  바로 j를 P[1~j-1] 의 접두사/접미사 길이로 변경해주는 작업이 O(1)이어야 한다는 것이다.  물론 정말로 O(1)로 이 작업을 수행할 수는 없지만, 알고리즘을 수행하는 반복문 밖에서 O(M)의 시간복잡도로 LPS테이블을 구해두는 전처리를 하는 것으로 반복문 내에서는 O(1)의 시간복잡도를 보일 수 있다. LPS 테이블을 구하는 절차는 다음과 같다.

 

① 먼저 dp = [0] * (m+1) 으로  LPS 테이블 초기화

② i를 P 문자열의 첫 위치 0으로 초기화

③ P문자열의 j 번째 요소(j=1~m-1) 비교 (④~⑤ 반복)

④ P[i] == P[j] 라면 i를 1 증가시키고 dp[j] = i  (반복구간의 길이가 증가)

⑤ P[i] != P[j] 일 경우

    => 1) i가 0이라면 애초에 반복구간이 아직 존재하지 않기 때문에 단순히 pass

    => 2) i가 0보다 크다면 i를 0이되거나 P[i] == P[j] 가 될 때까지 이전에 P[j]와 일치했던 구간으로 이동 (i = dp[i-1])

⑥ 작업이 모두 끝나면 dp[k] 는 P[1~k] 구간의 LPS값이 된다. 

 

 

3. 구현

 지금까지의 내용을 토대로 실제로 KMP 알고리즘으로 문자열 탐색 문제(https://www.acmicpc.net/problem/1786)를 해결한 코드는 다음과 같다.  

 

import sys

input = sys.stdin.readline


def sol1786():
    # 전체 문자열 T
    T = input().replace('\n', '')
    
    # 찾을 문자열 P
    P = input().replace('\n', '')
    
    # 문자열 T, P의 길이 n, m
    n, m = len(T), len(P)

    # LPS 테이블
    dp = [0] * (m + 1)
    
    # P의 첫 위치이자 suffix의 길이
    i = 0
    
    # dp[j] 는 P[:j+1]의 LPS값
    for j in range(1, m):
        # 만약 P[i]와 P[j] 가 다르고 0보다 크다면
        # P[i]==P[j]가 될 때까지 i를 이전에 suffix가 이어졌던 부분까지 이동
        while i > 0 and P[i] != P[j]:
            i = dp[i-1]
            
        # P[i]와 P[j]가 같다면 suffix의 길이를 1 증가
        # dp[j] 에 suffix의 길이를 저장
        if P[i] == P[j]:
            i += 1
            dp[j] = i

    # 문자열 P가 발견된 위치를 모두 저장할 리스트
    answer = []
    
    # 문자열 T, P의 탐색 위치
    i, j = 0, 0
    
    # i가 문자열 T의 길이에 도달하기 전 까지 반복
    while i < n:
        # T[i]와 P[j] 가 같다면 두 인덱스 모두 1씩 증가
        if T[i] == P[j]:
            i += 1
            j += 1
            
            # 인덱스가 증가한 결과 j가 m에 도달했다면
            # P와 일치하는 문자열을 발견한 것이기 때문에 answer에 append
            # j는 LPS 테이블을 참조하여 다음 탐색 위치(dp[j-1])로 이동
            # 다음 탐색에서는 이미 일치할 것을 알고있는 부분을 생략하고 탐색
            if j == m:
                answer.append(i-m+1)
                j = dp[j-1]
                
        # T[i] 와 P[j] 가 다르다면
        else:
            # 만약 P[0] 부터 일치하지 않았다면 T[i]와의 비교는 무의미하기 때문에 i += 1
            if j == 0:
                i += 1
                
            # j는 LPS 테이블을 참조하여 다음 탐색 위치로 이동
            j = dp[j - 1]
            
    # 문자열 P가 등장한 횟수와 위치를 출력형식에 맞춰 반환
    return '\n'.join(map(str, [len(answer), ' '.join(map(str, answer))]))

 

 

※ LPS 테이블 주의사항

 LPS 테이블을 생성할 때, 처음에는 P[i] != P[j] 일 경우 i를 0으로 초기화후 다시 비교하는 방식을 사용했지만 오답이 나왔다. 이 방식의 문제는 aacaaa 와 같은 문자열에 대해 LPS 테이블을 생성할 때 발생한다. 위 코드처럼 정상적인 방식으로 LPS 테이블을 생성할 경우 0 1 0 1 2 2 가 된다.  하지만 P[i] != P[j] 일 때 i를 0으로 초기화하고 다시 비교할 경우, aacaa 까지는 0 1 0 1 2 로 동일하지만, i가 2인 상태에서 P[2] != P[5] 로 i가 0으로 초기화되어 다시 비교하면 P[0] == P[5]로 dp[5]가 1이 되어버린다.  잘못 구현한 LPS 테이블을 구하는 코드는 다음과 같다.

dp = [0] * (m + 1)
i = 0
for j in range(1, m):
    if P[i] == P[j]:
        dp[j] = dp[j-1] + 1
        i += 1
    else:
        if P[j] == P[0]:
            dp[j] = i = 1
        else:
            i = 0

 

 동적 계획법을 적용하여 경우의 수를 구하는 문제를 알아본다.

 

1. RGB거리 2(https://www.acmicpc.net/problem/17404)

 n 개의 집이 직선상에 번호순서대로 있고 각 집을 빨강, 초록, 파랑의 색으로 칠하기 위해 필요한 비용이 주어졌을 때, 인접한 집 끼리는 색이 같지 않도록 칠하는 경우의 수를 구하는 문제.  단, 1번 집과 n번 집의 색도 서로 달라야한다.

 

동적계획법을 적용한 완전탐색 문제이다. 집을 순차적으로 방문하여 이전 집과 겹치지 않는 색상 중 어느 색상으로 칠해야 비용을 최소화 할 수 있는지 재귀적으로 구해나가면 간단하게 해결할 수 있다. 주의할 점은 마지막 집이 첫 번째 집과도 색이 겹쳐선 안된다는 조건이 있기에 첫 번째 집의 색상을 저장해두고 마지막 집의 색상을 정할 때 반영해야한다는 것이다.  문제를 해결한 코드는 다음과 같다.

 

import sys

sys.setrecursionlimit(100000)
input = sys.stdin.readline
INF = 10**9


def sol17404():
    # 집의 갯수
    n = int(input())
    
    # 각 집을 빨강, 초록, 파랑으로 칠하기 위한 비용
    cost = [list(map(int, input().split())) for _ in range(n)]
    
    # dp[i][j] 는 i 번째 집의 색이 j 일 때 최소 누적비용
    dp = [[0] * 3 for _ in range(n)]
    
    # 완전탐색 함수 - 이전집의 색과 현재 집의 번호를 인자로 받는다
    def dfs(pre, cur):
        # 첫 번째 집의 색
        nonlocal f
        
        # 마지막 집까지 모두 칠했다면 더이상 비용은 들지않는다.
        if cur == n:
            return 0
            
        # 현재 집을 이전 집과(마지막 집의 경우 첫 번째 집과도) 색이 겹치지 않도록 칠할 때
        # 그 비용을 최소화시킬 수 있는 경우를 구한다
        res = INF
        for c in range(3):
            if (c == pre) or (cur == n-1 and c == f):
                continue
            if not dp[cur][c]:
                dp[cur][c] = dfs(c, cur+1) + cost[cur][c]
            res = min(res, dp[cur][c])
        
        # 현재 집의 색에 따른 총 비용 중 최솟값
        return res
    
    # 첫 번째 집의 색에 따라 세 번 탐색하여 최소비용을 구함
    answer = INF
    for f in range(3):
        dp = [[0] * 3 for _ in range(n)]
        answer = min(answer, dfs(f, 1)+cost[0][f])
        
    # 최소비용 반환
    return answer

 

 

2. 색상환(https://www.acmicpc.net/problem/2482)

 n 개의 색상이 있는 색상환에서 k개의 색을 서로 인접하지 않도록 고르는 경우의 수를 구하는 문제.  동적계획법을 활용한 풀이와 조합을 활용한 풀이 두 가지 방법이 있다. 

 

1) 동적 계획법을 활용한 풀이

 양 끝이 인접해있는 원형임을 일단 생각하지 않고 선형의 리스트에서 인접하지 않도록 k개를 뽑는 경우를 생각해보자.

dp[i][j] 가 i 번째 까지의 색상리스트에서 j 개의 색을 인접하지 않도록 뽑는 경우의 수라고 할 때, 다음과 같은 점화식이 성립한다.

dp[i][j] = dp[i-1][j] + dp[i-2][j-1]

위와 같은 점화식이 성립하는 이유는 현재 보고있는 i 번째 색을 선택할지, 선택하지 않을지 여부에 따라 두 가지 경우의 수로 나뉘기 때문이다.   

 

① i 번째 색을 선택할 경우 이전의 색은 선택될 수 없다. 또한 이번 색을 선택함으로서 j개의 선택이 완료되어야

   하기 때문에 그 전까지 j-1 개의 선택이 완료된 상태여야 한다. (dp[i-2][j-1])

 

② i 번째 색을 선택하지 않을 경우 이전의 색도 선택될 수 있으며, 이번에 색을 선택하지 않기 때문에 그 전까지

   이미 j 개의 선택이 모두 완료된 상태여야 한다. (dp[i-1][j-2])

 

 

이제 첫 번째 색과 마지막 색이 인접해서는 안된다는 조건을 처리해야한다.

 

① 마지막 색을 선택할 경우, 자기 자신과 좌우로 두 개의 인접한 색을 제외한 범위에서

    k-1개의 색을 선택한 상태여야 한다. (dp[n-3][k-1])

 

② 마지막 색을 선택하지 않을 경우, 그 전까지 j-1 개의 선택을 완료한 상태여야 한다. (dp[n-1][k])

 

이를 구현한 코드는 다음과 같다.

 

import sys

input = sys.stdin.read
mod = 1000000003


def sol2482():
    # 색상의 갯수와 골라야할 색상의 수
    n, k = map(int, input().split())
    
    # dp[i][j] 는 i번까지의 색상리스트중 j개를 고르는 경우의 수
    dp = [[0] * (k + 1) for _ in range(n + 1)]
    
    # 0개를 고르는 경우는 항상 1이며 1개를 고르는 경우는 항상 색상의 갯수와 같다.
    for i in range(n + 1):
        dp[i][0] = 1
        dp[i][1] = i
	
    # 점화식에 따라 dp를 채워나간다
    for i in range(2, n + 1):
        for j in range(2, k + 1):
            dp[i][j] = (dp[i - 2][j - 1] + dp[i - 1][j]) % mod

    # 양 끝이 인접할 경우를 고려하여 경우의 수를 구하여 반환한다
    return (dp[n - 3][k - 1] + dp[n - 1][k]) % mod

 

2) 조합을 활용한 풀이

 n 개의 색상에서 인접하지 않도록 색상을 최대한 많이 고르는 경우는 한칸씩 띄어가며 선택하는 것이다.  즉, 고르려는 색상의 갯수 k 가 n의 절반을 넘을 경우 인접하지 않도록 색상을 고르는 것은 불가능하다. 

if k > n//2:
    return 0

k가 n의 절반 이하라면 경우의 수는 한 개이상 존재한다. 이 때, 그 경우의 수를 다음과 같이 생각해볼 수 있다.

n개의 색상칸에 선택할 색상 k 개를 제외한 선택하지 않을 n-k 개의 색을 먼저 나열해놨다고 할 때(녹색칸), 위 그림과 같이 선택하지 않은 칸을 사이에 두고 슬롯이 n-k+1 개 생긴다.(하얀칸)  선택받은 k개의 색상을 슬롯에 넣는 경우의 수를 생각하면 combination(n-k+1, k) 가 된다.  그런데 이 표는 편의상 리스트형태로 표현했을 뿐 환형이기 때문에 만약 k개의 슬롯을 선택했을 때 양 끝이 모두 선택된다면 양 끝이 맞닿는 문제가 발생한다. 그 경우의 수는 양 끝의 슬롯을 이미 선택했다고 가정하고 남은 n-k-1 개의 슬롯에서 k-2 개의 슬롯을 고르는 경우의 수, 즉 combination(n-k-1, k-2) 가 된다.

둘의 차를 구하면 k개의 색상이 서로 인접하지 않도록 선택하는 모든 경우의 수를 구할 수 있다. 이를 구현한 코드는 다음과 같다.

 

import sys

input = sys.stdin.read
mod = 1000000003


def sol2482():
    # 색상의 총 수 n, 골라야할 색상의 수 k
    n, k = map(int, input().split())
    
    # 골라야할 색상의 수가 총 색상 수의 절반을 넘기면 인접하지 않도록 고르는것은 불가능
    if k > n//2:
        return 0
        
    # 1개의 색을 고르는 경우의 수는 총 색상 수와 같음
    if k == 1:
        return n
        
    # n-k개의 고르지 않은 수의 사이에 존재하는 슬롯 n-k+1 개에서 k개의 슬롯을 골라 수를 삽입
    # 양 끝 슬롯을 함께고르는 경우를 배제하기 위해 양 끝 슬롯을 제외한 n-k-1 개의 슬롯에서 
    # k-2개의 슬롯을 고르는 경우의 수를 감산
    return (bc(n-k+1, k) - bc(n-k-1, k-2)) % mod
    
  
# 이항계수를 반환하는 함수
def bc(a, b):
    b = min(b, a-b)
    u, v = 1, 1
    for i in range(b):
        u *= (a - i)
        v *= (1 + i)
    return u//v

이러한 풀이는 실전에서 떠올릴 수 있다면 좋겠지만 너무 몰입하다간 문제해결에 과하게 시간을 낭비할 수 있기 때문에 조금 시도해보다가 답이 보이지 않는다면 과감하게 포기하고 다른 해결책을 모색해보는 것도 좋을 것 같다.

 

 이번에는 이전에 다룬 바 있는 비트마스크 기법을 동적 계획법에 적용하여 몇 가지 문제를 해결해본다.

 

1. 할 일 정하기 1 (https://www.acmicpc.net/problem/1311)

 N명의 사람과 N개의 일이 있으며 i번째 사람이 j번째 일을 했을 때의 비용이 D[i][j] 라고 할 때, 한명의 사람이 하나의 일을 담당하도록 분배하되 총 처리비용이 최소값이 되도록 하는 경우를 찾는 문제

 

 가장 먼저 떠오르는 해법은 완전탐색이다. 각 일마다 일을 맡지 않은 사람중 하나를 할당한 뒤 그 사람은 일을 맡았음을 표시하고 다음 일로 넘어가는 방식으로 완전탐색을 진행하면 답을 쉽게 찾을 수 있다.  하지만 이 방식을 그대로 사용하면 O(N!) 의 시간복잡도를 보여, N의 크기가 최대 20까지 늘어나는 이 문제에서는 당연히 시간초과가 발생한다.

 

 이를 해결하기 위해서는 메모이제이션을 통해 중복연산을 방지해야한다. k가 이번 단계에서 해결해야할 문제번호, visited가 각 사람들이 일을 맡고있는지 여부를 나타낸 배열이라고 할 때, dp[k][visited]는 사람들의 상태가 visited 와 같고 k번째 문제를 해결해야하는 시점에서 앞으로 모든 일을 처리하기 위해 필요한 비용의 최솟값이 되어야한다. 

 

문제는 visited의 취급이다. visited로 배열, 리스트 등의 자료구조를 사용하기에는 메모이제이션에 어려움이 많다.  예를 들어 Python의 경우 딕셔너리를 사용하려고 해도 리스트는 mutable한 자료구조이기 때문에 딕셔너리의 키 값으로 사용할 수 없다.  immutable한 자료구조인 튜플을 사용하기엔 visited를 변경할때 마다 전체를 새로 만들어야하는 오버헤드가 발생한다.  리스트를 문자열화하여 키 값으로 사용하는 것도 오버헤드가 크기 때문에 어렵다.  여기서 비트마스크를 사용하면 n이 20일 경우 0 이상 2^20 이하의 정수로 visited를 표현할 수 있기 때문에 모든 문제를 해결할 수 있다.  비트마스크를 적용하여 이 문제를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol1311():
    # 일과 사람의 수
    n = int(input())
    
    # c[i][j] 는 i 번째 사람이 j 번째 일을 할 경우 드는 비용
    c = [list(map(int, input().split())) for _ in range(n)]
    
    # dp[t][visit] 는 t 번째 문제를 풀 차례이며 사람들의 상태(일을 맡았는지 아닌지)가
    # visited일 때 모든 일을 처리하기 위해 앞으로 필요한 비용의 최솟값
    dp = [[-1]*(1<<n) for _ in range(n)]
    
    # 완전탐색 함수
    def dfs(t, visit):
        # 모든 문제를 해결했다면 앞으로 필요한 비용은 0
        if t == n:
            return 0
            
        # 아직 계산한 적이 없는 단계일 경우
        if dp[t][visit] == -1:
            # 일을 맡지 않은 사람 하나를 택해 다음단계로 넘어가는 경우 중 
            # t번째 일의 처리비용과 남은 일의 처리비용의 합의 최솟값이 dp[t][visit] 가 된다.
            dp[t][visit] = min([dfs(t+1, visit|(1<<p)) + c[p][t] for p in range(n) if not (visit & 1<<p)])
        
        # 남은 문제를 해결하기 위한 최소비용 반환
        return dp[t][visit]
        
    # 첫 번째 문제를 해결해야 하고 아무도 일을 맡지 않은 상황에서부터 탐색하여 최소비용을 반환
    return dfs(0, 0)

이 코드의 경우 일반적인 언어라면 AC를 받을 수 있는 풀이지만 Python3의 경우 시간초과가 발생한다.  PyPy3으로 제출할 경우에는 AC를 받을 수 있었다.  이 문제는 동적계획법보다 효율적인 해법(헝가리안 알고리즘)이 존재하지만 그에 대해서는 나중에 다루기로 한다.

 

 

2. 외판원 순회(https://www.acmicpc.net/problem/2098)

 1번부터 N번까지의 도시가 있고 도시 사이에 길이 있을 때(길이 없을 수도 있음), 한 도시에서 출발하여 모든 도시를 방문한 뒤 처음 도시로 돌아오기 위한 최단경로를 구하는 문제. 단, 출발한 도시로 돌아올 때를 제외하고는 같은 도시를 두 번 방문할 수 없다.

 

 1번 문제와 거의 유사하게 해결 가능한 문제이다. 현재 위치 cur, 도시의 방문여부 visited 에 대해 dp[cur][visit]은 현재  cur 도시에 있고 방문 상태가 visited 와 같을 때 모든 도시를 거쳐 처음 도시로 돌아가기 위해 필요한 비용의 최솟값이 되도록 한다.  1번 문제와 다른 점은 모든 도시를 방문한 뒤 처음 도시로 돌아가는 최소비용을 구하는 것이기 때문에 모든 도시를 방문한 상태에서 dfs(x, visited)가 0을 반환하는 것이 아니라 x에서 처음 도시로 가는 비용을 반환해야한다는 점이다. 이를 구현한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline
INF = 10 ** 9


def sol2098():
    # 도시의 갯수
    n = int(input())
    
    # 각 도시로부터 다른 도시로 가는데 드는 비용
    # 0일 경우 갈 수 없음을 의미하기 때문에 비용을 INF로 함
    w = [list(map(int, input().split())) for _ in range(n)]
    for i in range(n):
        for j in range(n):
            if w[i][j] == 0: w[i][j] = INF

	# dp[i][visited] 는 현재 i번째 도시에 있고 도시의 방문상태가 visited 일 때
    # 남은 도시를 모두 방문한 뒤 처음 도시로 돌아가는데 드는 비용의 최솟값이 된다.
    dp = [[-1] * (1 << n) for _ in range(n)]
    
    # 모든 도시를 방문한 상태의 visited값
    complete = (1 << n) - 1
    
    # 완전탐색 함수
    def dfs(cur, visited):
        # 만약 모든 도시를 방문했다면
        # 현재 위치에서 처음 도시로 가는 데 드는 비용을 반환
        if visited == complete:
            return w[cur][0]
            
        # 아직 계산하지 않은 경우라면
        if dp[cur][visited] == -1:
            # 다음 도시로 가는거리 w[cur][i] 와 다음 단계dfs(i, visited|(1<<i))의 합 중
            # 가장 작은 값이 dp[cur][visited] 가 된다.
            res = INF
            for i in range(1, n):
                check = 1 << i
                if w[cur][i] and not (visited & check):
                    res = min(res, dfs(i, visited|check) + w[cur][i])
            dp[cur][visited] = res
            
        # 현재 cur 도시에 있고 방문 상태가 visited일 때 
        # 모든 도시를 거쳐 처음 도시로 돌아가기 위한 최소비용을 반환
        return dp[cur][visited]
	
    # 어느 점을 시작점으로 잡더라도 외판원 문제의 정답은 같다
    # 만약 한 점에서 순회경로를 찾지 못한다면 방문할 수 없는 점이 존재한다는 의미이기 때문에
    # 그 그래프에서는 순회경로가 존재하지 않는다
    return dfs(0, 1)

외판원의 순회 문제는 유명한 NP문제 중 하나로 동적계획법을 적용하더라도 그다지 빠른 속도를 기대하긴 어렵기에, 보통은 최적해를 찾는 대신 그리디 알고리즘으로 최적해에 근사한 값을 찾아 활용한다고 한다.  이 문제에서는 N의 크기가 16으로 제한적이기 때문에 동적계획법으로도 AC를 받을 수 있었다. 

 

 

3. 박성원 (https://www.acmicpc.net/problem/1086)

 서로 다른 정수로 이루어진 집합이 주어졌을 때, 집합의 순열을 이어붙여 만든 정수가 k로 나누어 떨어질 확률을 구하는 문제. 실제로 순열을 구하려고하면 O(N!)이라는 엄청난 시간복잡도를 보이기 때문에 당연히 시간초과가 발생한다. 어느 숫자들을 합쳤는지 비트마스크로 표현하여 동적계획법으로 접근해야한다.  여기서 중요한 포인트는 동적계획법으로 어떤 정보를 저장하느냐이다. 

 

 12와 35를 7로 나누는 경우를 생각해보자.  1235를 7로 나눈 나머지는 3이다.   35 부분을 건드리지 않는 7의 배수 700으로 1200을 나누면 나머지는 500이 된다. 남은 535는 결국 1235에서 7의 배수를 뺀 것이기 때문에 7로 나눈 나머지는 1235와 같다.  그리고 1200을 700으로 나눈것은 결국 붙이는 두 수중 앞쪽의 12를 7로 나눈 것과 같다.  즉, 두 수 a와 b를 이어붙여 ab 로 만들고 이를 k로 나눈 나머지는 a를 k로 나눈 나머지 c와 b를 이어붙여 cb로 만든 뒤 k로 나눈 나머지와 같다.

 

위 사실에 근거하여, 비트마스크 i로 어느 숫자들을 골랐는지 표현하고, 그 숫자들로 이루어진 순열에서 만들어진 수를 k로 나눈 나머지가 j인 경우의 수가 dp[i][j] 가 되도록 하면, dp[i][j] 로부터 다음에 이어질 숫자를 골라 나머지와 붙인다음 또다시 나머지를 구하는 것으로 dp 배열을 채워나갈 수 있다. 이를 구현한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol1086():
    # 집합의 크기(수의 갯수)
    n = int(input())
    
    # 집합 s
    s = [int(input()) for _ in range(n)]
    
    # 만들어진 수를 나눌 수 k
    k = int(input())
    
    # m[i][j] 는 j 와 i번째 수를 이어붙인 숫자를 k로 나눈 나머지
    m = [[((j * (10 ** len(str(s[i])))) % k + s[i] % k) % k for j in range(k)] for i in range(n)]

	# dp[i][j] 는 수의 선택상태가 i와 같을 때 순열로 만들어지는 수를 k로 나눈 나머지가 j인 경우의 수
    dp = [[0] * k for _ in range(1 << n)]
    
    # 아무것도 고르지 않았을 때 나머지가 0인 경우의 수를 1로 초기화
    dp[0][0] = 1
    
    # 상태전이 시작
    for i in range((1 << n) - 1):
        for j in range(k):
            # 현재 상태에서 나머지가 j인 경우의 수가 0이라면 이로부터 파생되는 경우의 수도 0
            # 더이상 진행할 필요가 없음
            if dp[i][j] == 0:
                continue
                
            # 아직 고르지 않은 수에 대해
            for b in range(n):
                check = 1 << b
                if not (i & check):
                    # 나머지 j와 이 수를 합친 수를 k로 나눈 나머지는 m[b][j]
                    # 현재 상태의 경우의수가 그대로 dp[i | check][m[b][j]]로 전이되기 때문에
                    # 현자 상태의 경우의 수를 더해줌
                    dp[i | check][m[b][j]] += dp[i][j]

    # dp[(1<<n)-1] 까지 전이가 종료되고나면
    # 주어진 집합 전체의 순열로 이루어진 수를 k로 나눈 나머지가 
    # 각각 0 ~ k-1 인 경우의 수를 모두 얻을 수 있다.
    # 그 경우의 수를 모두 더한것이 전체 경우의 수 (w)
    # 그 중 나머지가 0인 것이 k로 나누어 떨어지는 경우의 수 (v)
    v = dp[-1][0]
    w = sum(dp[-1])
    
    # 구하려는 것은 v / w 를 기약분수 형태로 나타낸 문자열
    # 기약분수로 나타내기 위해서는 v와 w의 최대공약수르 둘을 나누어준 뒤 출력형식에 맞춰 반환한다.
    g = gcd(v, w)
    return f'{v // g}/{w // g}'


# 유클리드 호제법에 따라 최대공약수를 구하는 함수
def gcd(a, b):
    if a < b:
        a, b = b, a
    while b:
        a, b = b, a % b
    return a

동적계획법을 어떻게 적용할지 찾아내기가 굉장히 까다롭고 두 수를 이어붙일 때 앞쪽 수의 나머지를 이용한다는 발상 또한 필요해서 상당히 어려운 문제였다.  비트마스크를 통한 동적계획법으로 상태전이를 표현하는 기법이나 두 수를 이어붙인 수의 나머지에 대한 발상 등은 활용될 여지가 충분해보이니 기억해둘 필요가 있는 문제인 것 같다.

 냅색(Knapsack) 알고리즘을 활용하여 해결할 수 있는 문제들을 알아본다.

 

1. 양팔저울(https://www.acmicpc.net/problem/2629)

 양팔저울과 무게추가 주어졌을 때, 각 구슬의 무게를 확인 가능한지 구하는 문제이다.  결국 양팔저울에 주어진 무게추를 올려 만들 수 있는 무게를 구하는 문제라고 볼 수 있다.

 

 냅색 문제를 풀때 사용했던 방식으로 동적 계획법을 적용하여 문제를 해결할 수 있다. 절차는 다음과 같다.

 

① 먼저 만들 수 있는 무게의 집합 s에 0을 넣고 시작한다.(아무것도 넣지 않은 경우) 

② 각 무게추마다 무게의 집합에 있는 만들 수 있는 무게들과의 합과 차를 집합에 추가한다. (중복값은 무시된다.)

③ 모든 무게추에 대해 ②의 작업이 끝나면 집합 s에는 주어진 무게추로 만들 수 있는 모든 무게가 들어있다.

④ 각 구슬에 대해 구슬의 무게가 집합 s에 들어있는지 확인하여 답을 반환한다.

 

이를 구현한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol2629():
    # 무게추의 갯수
    n = int(input())
    
    # 무게추의 무게 리스트
    weights = list(map(int, input().split()))
    
    # 구슬의 갯수
    m = int(input())
    
    # 구슬의 무게 리스트
    marbles = list(map(int, input().split()))

    # 만들 수 있는 무게 집합
    s = {0}
    
    # 각 무게추에 대해 해당 무게추가 추가되어 만들 수 있게되는 무게를 집합에 추가
    # set 자료구조의 특성상 중복값은 자동으로 무시된다.
    for w in weights:
        u = set()
        for v in s:
            u.add(v + w)
            u.add(abs(v - w))
        s |= u
        
    # 각 구슬의 무게를 무게추로 확인할 수 있는지 여부를 반환
    return ' '.join([('Y' if marble in s else 'N') for marble in marbles])

 

 

2.앱(https://www.acmicpc.net/problem/7579)

 실행 중인 n개의 앱(A1, A2, ... , An)은 각각 차지하는 메모리(m1, m2, ..., mn)와 종료에 드는 비용(c1, c2, ..., c3)을 가진다.  메모리를 M만큼 확보하기 위해 앱을 비활성화 시켜야할 경우, 필요한 최소 비용을 구하는 문제이다.

 

 말은 조금 바뀌었지만 결국 냅색 문제와 완전히 동일하다.  다만 주의할 점은 종료에 드는 비용은 그 범위가 1이상 100 이하로 작지만 앱이 차지하는 메모리의 크기는 1이상 10,000,000 이하로 굉장히 크다.  앱을 종료하여 메모리를 확보할 수 있는 경우의 수를 관리할 때, 메모리와 비용 어느것을 키 값으로 사용하더라도 문제는 해결할 수 있지만, 값의 범위를 생각하면 당연하게도 비용쪽을 키 값으로 해야 메모리/시간면에서 효율적인 코드가 될 것이다.  문제를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol7579():
    # 앱의 갯수 n,  확보해야할 메모리 크기 m
    n, m = map(int, input().split())
    
    # 각 앱이 차지하는 메모리 크기
    memory = list(map(int, input().split()))
    
    # 각 앱을 종료하는데 들어가는 비용
    cost = list(map(int, input().split()))
    
    # 앱을 종료하는데 드는 비용 총합 : 확보할 수 있는 메모리 총량
    dp = {0:0}
    
    # m 만큼의 메모리를 확보하기 위해 필요한 최소비용
    answer = float('inf')
    
    # 각 앱을 종료하는 것으로 확보할 수 있는 메모리 총량을 딕셔너리에 추가
    for app in range(n):
        u = {}
        for co, mem in dp.items():
            co += cost[app]
            mem += memory[app]
            if mem >= m:
                answer = min(answer, co)
            elif dp.get(co, 0) < mem:
                u[co] = mem
        dp.update(u)
        
    # 필요한 최소비용을 반환
    return answer

 

 

냅색 문제의 응용 문제들은 제법 자주 등장한다.  방법만 알고있다면 어렵지 않게 풀 수 있는 유형이니 잘 알아두고 넘어가도록 하자.

 

 

 

 저번 글에 이어서 동적 계획법을 활용하여 풀 수 있는 문제의 유형을 조금 더 알아본다.

 

1. 내리막 길(https://www.acmicpc.net/problem/1520)

 2차원 배열의 형태로 지도가 주어지고 각 칸마다 그 지점의 높이가 10000 이하의 자연수로 주어졌을 때, 가장 왼쪽 위 칸에서 시작하여 가장 오른쪽 아래 칸으로 내리막길을 통해서만 이동하는 경로의 수를 구하는 문제이다. 풀이자체는 보자마자 떠올릴 수 있을정도로 간단한 완전탐색 문제이다.  다만 당연하게도 그냥 완전탐색을 실행하면 시간초과가 발생하기 때문에 동적 계획법을 적용하여 문제를 해결한다. 

 

 탐색은 가장 왼쪽 위나 가장 오른쪽 아래중 어느쪽에서 시작해도 좋다. 여기서는 가장 오른쪽 아래에서부터 시작하기로 한다. dfs(r, c) 가 가장 왼쪽 위, 즉 Map[0][0] 에서 시작하여 Map[r][c] 에 도달할 수 있는 경로의 수를 의미한다고 할 때, dfs(r, c) 는 인접한 네 칸 중 현재 지점보다 높이가 높은 칸들의 경로의 수의 합이된다.  이를 Python 코드로 표현하면 다음과 같다.

 

def dfs(r, c):
    if r == c == 0:
        return 1
        
    res = 0
    for nr, nc in [(r - 1, c), (r + 1, c), (r, c - 1), (r, c + 1)]:
        if 0 <= nr < m and 0 <= nc < n and Map[nr][nc] > Map[r][c]:
            res += dfs(nr, nc)
    return res

하지만 위와 같이 실행하면 같은 계산을 매번 반복해야하기 때문에 동적 계획법을 적용시켜야한다.  dfs(r, c)를 한번 계산하고나면 dp[r][c] 에 저장하여 다시 계산하지 않도록 한 코드는 다음과 같다.

 

def dfs(r, c):
    if dp[r][c] != -1:
        return dp[r][c]
        
    dp[r][c] = 0
    for nr, nc in [(r - 1, c), (r + 1, c), (r, c - 1), (r, c + 1)]:
        if 0 <= nr < m and 0 <= nc < n and Map[nr][nc] > Map[r][c]:
            dp[r][c] += dfs(nr, nc)
    return dp[r][c]

이차원배열 dp는 -1로 초기화하여 방문여부를 알 수 있도록 하고 dp[0][0]은 시작위치이기 때문에 1로 초기화해준 상태에서 실행되는 것이 전제인 함수이다.  전체 코드는 다음과같다.

 

import sys
sys.setrecursionlimit(10000)

input = sys.stdin.readline


def sol1520():
    # 세로, 가로의 크기
    m, n = map(int, input().split())
    
    # 각 지점의 높이 데이터
    Map = [list(map(int, input().split())) for _ in range(m)]
    
    # dp[r][c] 는 Map[0][0] 에서 Map[r][c] 로 이동할 수 있는 경로의 수
    # 방문여부를 나타내기 위해 값은 모두 -1로 초기화
    dp = [[-1] * n for _ in range(m)]

	# 경로 탐색 함수
    def dfs(r, c):
        # 이미 계산된 값이라면 계산해둔 값을 반환
        if dp[r][c] != -1:
            return dp[r][c]
        
        # 상하좌우 인접칸중  현 지점보다 높은 지점까지의 경로 수를 모두 더한여 dp[r][c]에 저장한다
        dp[r][c] = 0
        for nr, nc in [(r - 1, c), (r + 1, c), (r, c - 1), (r, c + 1)]:
            if 0 <= nr < m and 0 <= nc < n and Map[nr][nc] > Map[r][c]:
                dp[r][c] += dfs(nr, nc)
                
        # 결과값을 반환한다.
        return dp[r][c]

	# 시작지점의 경로 수는 1로 초기화
    dp[0][0] = 1
    
    # 가장 오른쪽 아래 칸의 경로 수를 탐색하여 결과를 반환
    return dfs(m-1, n-1)

 

 

 

2. 팰린드롬?(https://www.acmicpc.net/problem/10942)

 임의의 수열 seq가 주어졌을때, 수열의 시작점과 끝점인 s, e로 구성된 질의에 대해 seq[s:e+1] 이 팰린드롬을 이루는지 여부를 구하는 문제이다. (팰린드롬이란 앞으로 읽어도 뒤로 읽어도 같은 결과가 나오는 수열이나 문자열 등을 의미한다.)  당연하게도 모든 질의에 대해 팰린드롬 여부를 일일히 검사했다가는 O(NM)의 시간복잡도가 되는데,  M의 크기 상한이 1,000,000이기 때문에 시간초과가 발생한다.  이 또한 동적 계획법을 적용시켜야 한다.

 

 점화식을 찾는게 생각보다 간단한 문제이다. s부터 e까지의 수열이 팰린드롬을 이루기 위한 조건은 다음과 같다.

 

① seq[s] 와 seq[e] 의 값이 같다. => 앞뒤가 같아야 하기 때문에 첫 숫자와 끝 숫자는 당연히 같을 수 밖에 없다

② seq[s+1:e] 은 팰린드롬을 이뤄야한다. => 앞뒤로 숫자 하나씩을 떼더라도 앞뒤가 같은 상태는 유지되어야 한다.

 

이를 Python 코드로 표현한 코드는 다음과 같다.

 

# s부터 e까지의 수열이 팰린드롬인지 여부를 반환한다. (팰린드롬이라면 1, 아니라면 0)
def isPalindrome(s, e):
    if s==e:
        return 1
    if e-s==1:
        if seq[s]==seq[e]:
            return 1
        else:
            return 0
    return 1 if seq[s] == seq[e] and isPalindrome(s+1, e-1) else 0

s와 e가 같은 경우, 즉 길이 1의 수열이라면 항상 팰린드롬을 이루며, s와 e의 차가 1인 경우, 즉 길이 2의 수열이라면 seq[s] 와 seq[e] 가 같다면 팰린드롬을 이루며 그렇지 않다면 팰린드롬을 이루지 않는다.  그 외의 경우 위에서 찾은 점화식에 따라 재귀호출을 통해 답을 찾을 수 있다.  여기에 동적 계획법을 적용한 코드는 다음과 같다.

 

# 방문여부 판별을 위해 dp 리스트 -1로 초기화
dp = [[-1]*n for _ in range(n)]

# 길이 1의 수열은 항상 팰린드롬
# 길이 2의 수열은 양 끝 값이 같다면 팰린드롬, 다르다면 팰린드롬이 아니다.
for i in range(n):
    dp[i][i] = 1
    if i < n-1:
        dp[i][i+1] = 1 if seq[i] == seq[i+1] else 0
            
# s부터 e까지의 수열이 팰린드롬인지 여부를 반환한다. (팰린드롬이라면 1, 아니라면 0)
def isPalindrome(s, e):
    # 아직 계산하지 않은 값이라면 점화식에 따라 계산
    if dp[s][e] < 0:
        dp[s][e] = 1 if seq[s] == seq[e] and isPalindrome(s+1, e-1) else 0
        
    # 결과값 반환
    return dp[s][e]

이제 중복연산은 미리 저장해둔 dp값을 반환하여 효율적으로 문제를 해결할 수 있다.  전체 코드는 다음과 같다.

 

import sys
sys.setrecursionlimit(100000)

input = sys.stdin.readline


def sol10942():
    # 수열의 크기
    n = int(input())
    
    # 수열
    seq = list(map(int, input().split()))
    
    # 각 질의에 대한 정답
    answer = []
    
    # dp[s][e] 는 s부터 e까지의 수열이 팰린드롬인지 여부를 저장 (팰린드롬이라면 1, 아니라면 0)
    dp = [[-1]*n for _ in range(n)]
    
    # 길이 1의 수열은 항상 팰린드롬
    # 길이 2의 수열은 양 끝 값이 같다면 팰린드롬, 다르다면 팰린드롬이 아니다.
    for i in range(n):
        dp[i][i] = 1
        if i < n-1:
            dp[i][i+1] = 1 if seq[i] == seq[i+1] else 0
            
    # 팰린드롬 여부를 반환하는 함수
    def isPalindrome(s, e):
        # 아직 계산하지 않은 값이라면 점화식에 따라 계산
        # 양 끝 값이 서로 같고 양 끝 값을 제외한 부분이 팰린드롬이라면 자신도 팰린드롬이다.
        if dp[s][e] < 0:
            dp[s][e] = 1 if seq[s] == seq[e] and isPalindrome(s+1, e-1) else 0
        return dp[s][e]
    
    # 각 질의에 대해 isPalindrome 함수를 호출하여 정답을 answer 리스트에 저장
    for m in range(int(input())):
        s, e = map(int, input().split())
        answer.append(isPalindrome(s-1, e-1))
        
    # 출력 조건에 맞춰 정답 리스트 반환
    return '\n'.join(map(str, answer))

 

오늘 풀어본 문제들의 경우 dp[i][j] 를 계산하기 위해 그 이전까지의 dp값을 모두 계산할 필요는 없기 때문에 반복문을 사용하 bottom-up 방식의 동적 계획법을 구현하는 대신 재귀를 활용한 top-down 방식의 동적 계획법을 구현하여 문제를 해결했다.  물론 이런 경우에도 bottom-up 방식의 동적 계획법으로 구현한 풀이는 여전히 잘 동작하지만 불필요한 계산이 들어가 오히려 재귀를 사용한 top-down 방식의 동적계획법에 비해 비효율적일 수 있으며 top-down 방식은 직관적이고 구현도 간단하기 때문에 실전에서 빠르게 문제를 해결하는데 유용하다. 문제 유형에 따라 적절한 방식을 채택하여 해결하는 것이 좋겠다.

 

 지난번 동적 계획법 1에서도 언급했듯이 동적 계획법은 특정 문제를 풀기위한 알고리즘이 아닌 문제를 효율적으로 풀기 위한 방법론이기 때문에 그 난이도가 문제마다 천차만별이며 적용하는 방법 또한 굉장히 다양하다.  그렇기에 문제에 따라서는 동적 계획법 문제임을 알고도 해법을 찾지 못하는 경우도 많다.  이번에는 동적 계획법을 사용하여 해결 가능한 조금 더 어려운 문제들을 풀어본다.

 

 

1. 파일 합치기(https://www.acmicpc.net/problem/11066)

 소설을 구성하는 파일의 크기가 챕터 순으로 주어지고 두 파일을 합쳐 하나의 파일로 만드는 작업의 비용이 두 파일의 크기의 합이라고 할 때, 챕터 순서를 유지하면서 파일을 모두 합치기 위해 필요한 최소비용을 구하는 문제이다. 단순히 가장 작은 두 페이지를 합쳐나가는 방식이라면 heap을 사용하여 간단하게 구현할 수 있지만, 이 문제의 경우 챕터의 순서를 유지해야하나는 조건이 있기 때문에 인접한 두 파일만을 합칠 수 있다.  이 문제는 동적 계획법으로 접근해야한다.

 

 일반적으로 문제해결에 동적 계획법을 적용할 때, 가장 중요한 것은 점화식을 찾는 것이다. i번부터 j번까지의 파일을 하나로 합친 값을 dp[i][n] 이라고 하자.  파일은 두 개씩만 합칠 수 있기 때문에, dp[i][j]은 i번부터 k번까지의 파일, k+1번부터 j번까지의 파일을 각각 합친 두 파일을 합친 경우 중 최솟값에 두 파일의 크기를 합친 값, 즉 i번부터 j번 까지의 파일 크기의 합을 더한 것과 같다고 생각할 수 있다. 

 

예를들어 1번부터 5번 파일까지의 크기가 주어졌을 때, dp[1][5] 를 구하는 경우의 수는 다음 그림과 같다.

k = 1
k = 2
k
k = 4

 위 네 가지 경우 중 최솟값이 1번부터 5번까지의 파일을 합치는 데 드는 최소비용이 된다. 이 점화식을 Python 코드로 표현하면 다음과 같다.

dp[i][j] = min([dp[i][k] + dp[k+1][j] for k in range(i, j)]) + sum(filesize[i:j+1])

 

점화식을 찾아냈으면 나머지는 간단하다.  dp 배열의 초기값을 설정한 뒤 반복문으로 나머지를 채워나가면 답을 찾을 수 있다. 이를 구현한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol11066():
    # 각 케이스의 답변을 저장할 리스트
    answer = []
    for T in range(int(input())):
        # 파일 갯수
        K = int(input())

        # 파일 크기 리스트
        filesize = list(map(int, input().split()))

        # dp[i][j] 는 i부터 j 까지의 파일을 합치는데 드는 최소비용
        # dp[i][i] 는 파일 갯수가 한 개이기 떄문에 합치는 비용 0 - 초기화 별도로 필요없음
        dp = [[0] * K for _ in range(K)]

        # acc[i] 는 인덱스 i 까지의 파일 크기의 합
        # i부터 j 까지의 파일 크기의 합을 구할 경우 acc[j] - acc[i-1] 로 구할 수 있음
        # i가 0일 경우를 대비하여 크기를 K+1로 함
        acc = [0] * (K + 1)
        acc[0] = filesize[0]
        for i in range(1, K):
            acc[i] = acc[i - 1] + filesize[i]

        # i와 j의 차
        for margin in range(1, K):
            # 시작점 i
            # i + margin 이 K를 넘지 않아야 하기 때문에 K-margin-1 까지
            for i in range(K - margin):
                # 끝점 j
                j = i + margin

                # i부터 j 까지의 파일을 두 개의 파일까지 합치기 위한 최소비용은 dp[i][m] + dp[m+1][j] 의 최솟값
                # 두 파일을 하나의 파일로 합치기 위해 필요한 비용은 i부터 j 까지의 파일의 크기의 합
                dp[i][j] = min([dp[i][m] + dp[m + 1][j] for m in range(i, j)]) + acc[j] - acc[i - 1]

        # answer 리스트에 정답 저장
        answer.append(dp[0][-1])

    # 출력 형식에 맞춰 정답 리스트 반환
    return '\n'.join(map(str, answer))

위 코드는 O(K^3)의 시간복잡도로 문제를 해결할 수 있다.  이를 추가적으로 최적화하는 특수한 방법도 있지만 아직은 다루지 않는다.  

 

 

 

2. 행렬 곱셈 순서(https://www.acmicpc.net/problem/11049)

 크기가 NxM 인 행렬 A와  MxK 인 행렬 B를 곱하기 위해 필요한 곱셈 연산의 수는 총 NxMxK 번일 때,  행렬 N개의 크기가 곱할 순서대로 주어졌을 때, 곱셈연산 수의 최솟값을 구하는 문제.

 

import sys

input = sys.stdin.readline


def sol11049():
    # 곱할 행렬의 갯수
    n = int(input())
    
    # 행렬의 크기 리스트
    mats = [tuple(map(int, input().split())) for _ in range(n)]
    
    # dp[i][j] 는 i 행렬부터 j 행렬 까지 곱하기 위한 곱셈 연산 수의 최솟값
    dp = [[0]*n for _ in range(n)]
    
    # i와 j의 차
    for margin in range(1, n):
    	# 시작점 i
        for i in range(n-margin):
        	# 끝점 j
            j = i + margin
            
            # 행렬 i 부터 j 까지 곱하는데 필요한 곱셈 연산 수 의 최솟값
            dp[i][j] = min([dp[i][k] + dp[k+1][j] + mats[i][0] * mats[k][1] * mats[j][1] for k in range(i, j)]) 
    
    # 모든 행렬을 곱하는데 필요한 곱셈 연산 수의 최솟값 반환
    return dp[0][-1]

 1번의 파일 합치기와 거의 동일한 방식으로 접근하면 간단하게 해결 가능한 문제이다. 

  비트 마스크(bit mask)는 이름대로 비트연산을 활용하여 어떤 상태정보를 관리하는 테크닉이다. 

 

 예를 들어 반에 n명의 학생이 있고 각 학생마다 수학여행비를 냈는지 내지 않았는지를 관리해야한다고 하자. 이를 데이터로 표현하는 방법은 여러가지가 있지만 흔히 사용되는 방법은 평범하게 배열을 사용하는 것이다.

submited = [False] * n

# k번 학생이 제출했음을 표시할 때
submited[k] = True

# k번 학생이 제출하지 않았다고 변경할 때
submited[k] = False

# 모두 제출한 것으로 처리할 때
submited = [True] * n

# 아무도 제출하지 않은 것으로 처리할 때
submited = [False] * n

이 방법은 매우 간단하고 실제로 사용하기에도 나쁘진 않지만 명확한 한계가 있다.  만약 모두가 수학여행비를 제출했다거나 아무도 제출하지 않은 상태로 업데이트하려면 일일히 순회하며 바꿔주거나 아예 새로 배열을 생성해야하는데, 이는 O(N)의 시간을 필요로 한다.  

 

 비트 마스크를 사용하면 위와 같은 문제를 해결할 수 있다.  이번엔 수학여행비의 제출여부를 비트마스크로 표현해보자

submited = 0

# k번 학생이 제출했음을 표시할 때
submited |= 1 << k

# k번 학생이 제출하지 않았다고 변경할 때
submited &= ~(1 << k)

# 모두 제출한 것으로 처리할 때
submited = (1 << n) - 1

# 아무도 제출하지 않은 것으로 처리할 때
submited = 0

비트마스크를 사용하여 상태를 관리할 경우 모든 작업이 O(1)로 끝나는 것을 확인할 수 있다.  또한 submited가 차지하는 메모리도 상당히 절약된다.  비트마스크를 사용하여 간단한 문제(https://www.acmicpc.net/problem/11723)를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol11723():
    # 집합
    s = 0
    for _ in range(int(input())):
        # 명령어
        cmd = input().split()
        
        # 명령어에 인자가 없을 경우 - all 또는 empty
        if len(cmd) == 1:
			# all 명령어
            # s의 모든 비트값을 1로 초기화
            if cmd[0][0] == 'a':
                s = (1 << 21) - 1
                
            # empty 명령어
            # s를 0으로 초기화
            else:
                s = 0
               
        # 명령어에 인자가 존재하는 경우 - add, remove, toggle, check
        else:
            # 인자값의 비트마스크
            e = 1 << int(cmd[1])
            
            # add 명령어
            # 인자값의 비트마스크와 집합의 비트마스크를 bitwise-or 연산
            if cmd[0][0] == 'a':
                s |= e
                
            # remove 연산
            # 인자값의 비트마스크에 bitwise-not을 취한 값과 집합의 비트마스크를 bitwise-and 연산
            elif cmd[0][0] == 'r':
                s &= ~e
                   
            # toggle 연산
            # 인자값의 비트마스크와 집합의 비트마스크를 집합의 비트마스크와 bitwise-xor 연산 
            elif cmd[0][0] == 't':
                s ^= e
                
            # 인자값의 비트마스크와 집합의 비트마스크를 bitwise-and 연산한 결과가 0이라면 0
            # 0이 아니라면 1을 출력
            else:
                sys.stdout.write('1\n' if s & e else '0\n')

비트 마스크 기법은 그것 자체로 특별한 알고리즘은 아니지만 동적계획법이나 기타 여러 알고리즘에서 최적화에 도움을 주는 기법이기에 잘 알아두고 넘어가는 것이 좋다.

+ Recent posts