모든 경우의 수를 탐색하여 답을 찾아야 하는 완전 탐색 문제는 단순하지만 코딩 테스트 문제로 상당히 자주 출제된다.  특히 복잡한 규칙이 주어지고 그 규칙에 따라 완전탐색을 구현하여 해결하는 문제가 자주 보이는데 어렵지는 않지만 문제를 이해하는 데도 오래걸리고 구현과 디버깅에도 시간을 상당히 많이 소모하게 되기 때문에 많이 풀어서 익숙해질 필요가 있는 영역이다.  이번에는 완전탐색 중에도 DFS(깊이 우선 탐색)와 DFS 관련 문제들을 알아본다.

 

1. DFS(Depth First Search)

 DFS는 시작 노드에서부터 갈 수 있는 데 까지 계속해서 다음 노드로 이동하다가 더이상 이동할 수 있는 노드가 없다면 이동할 수 있는 노드를 찾을 떄 까지 이전 노드로 돌아가기를 반복하는 방식의 완전탐색 알고리즘이다. 그림으로 표현하자면 다음과 같다.

위와 같이 트리를 DFS로 방문할 경우 방문 순서는 1 -> 2 -> 4 -> 5 -> 3 -> 6 -> 7 이 된다.

 

본래 DFS는 스택으로 구현되는 탐색 알고리즘이지만 실제로 구현할 때는 대부분 직접 스택을 사용하여 구현하기보다는 재귀를 사용하여 구현하게 된다.  인접리스트 방식으로 그래프를 표현할 경우 모든 정점과 간선을 탐색하기에 O(|V|+|E|),  인접행렬 방식일 경우 O(|V|^2)의 시간복잡도를 보인다. DFS는 두 정점의 연결 여부를 확인하거나 서로 연결된 노드들을 하나의 부분집합으로 볼 때 부분집합의 갯수를 세는 등의 문제에 활용된다.

 

 

2. 두 정점의 연결여부 확인

 한 노드에서 시작하여 DFS를 호출하면 그 노드와 직/간접적으로 연결된 모든 노드를 탐색하기 때문에 두 정점이 연결되어있는지 확인 가능하다. 관련 문제(https://www.acmicpc.net/problem/2606)를 DFS를 활용하여 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol2606():
	# 정점의 수
    n = int(input())
    
    # 간선의 수
    m = int(input())
    
    # 인접리스트 그래프
    g = [[] for _ in range(n+1)]
    for i in range(m):
        s, e = map(int, input().split())
        g[s].append(e)
        g[e].append(s)
    
    # DFS 함수
    def dfs(node):
    	# 현재 노드의 모든 인접 노드중 아직 방문하지 않은 노드에 대해 
        # 방문처리 및 DFS함수 재귀호출
        for adj in g[node]:
            if not visit[adj]:
                visit[adj] = True
                dfs(adj)
                
    # 1번 컴퓨터부터 탐색 시작
    visit = [False]*(n+1) 
    visit[1] = True
    dfs(1)
    
    # 방문표시된 컴퓨터 수 - 1 (1번컴퓨터는 포함하지 않음)
    return visit.count(True)-1

 DFS를 사용하여 1번 컴퓨터와 연결된 정점의 갯수를 모두 찾아내는 문제였다.  간단하지만 DFS에 대한 기본적인 이해를 다지고 넘어가기엔 적당하다.

 

 

3. 연결된 부분집합의 갯수 세기

 연결그래프가 아닌 경우, 한 노드에서 시작하여 DFS를 실행하더라도 방문되지 않는 노드가 있을 수 있다.

이러한 경우 그 노드들에 대해서도 순차적으로 DFS를 호출하면 DFS의 호출 횟수(재귀호출 제외)로 연결된 부분집합의 갯수를 알아낼 수 있다.  관련 문제(https://www.acmicpc.net/problem/2667)를 DFS로 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol2667():
    n = int(input())
    land = [list(input().rstrip()) for _ in range(n)]
    
    # 깊이우선탐색 함수
    def dfs(r, c):
    	# 방문한 땅에 표시
        land[r][c] = 'x'
        
        # 단지에 포함된 집의 갯수 카운트
        cnt = 1
        
        # 상/하/좌/우 칸 중 집이 있고 아직 방문하지 않은 경우
        # 단지에 포함시키고 dfs 재귀호출
        for nr, nc in [(r-1, c), (r+1, c), (r, c-1), (r, c+1)]:
            if 0<=nr<n and 0<=nc<n and land[nr][nc]=='1':
                cnt += dfs(nr, nc)
                
        # 단지에 포함된 집의 수 반환
        return cnt
               
    # 지도를 순차적으로 순회하며 방문하지 않은 집마다 dfs 호출
    res = [0]
    for i in range(n):
        for j in range(n):
            if land[i][j] == '1':
                res.append(dfs(i, j))
    
    # 단지의 수와 단지에 포함된 집의 수를 오름차순정렬한 결과를 반환
    res.sort()
    res[0] = len(res) - 1
    return '\n'.join(map(str, res))


if __name__ == '__main__':
    print(sol2667())

dfs를 이러한 형태로 활용하는 문제는 굉장히 자주 등장한다.  다만 이 문제처럼 간단한 것 보다는 좀더 복잡한 경우가 대부분이다.  재귀를 활용하는 만큼 작은 실수로 예상치 못한 버그가 발생할 수 있으며 python의 경우 재귀가 그다지 효율적이지 못해 정상적으로 코드를 짜더라도 AC를 받지 못하는 문제도 종종 있기 때문에 보조언어로 C++ 등의 속도가 빠른 언어를 하나쯤 익혀둘 필요가 있다.

 동적 계획법은 어떤 문제를 보다 쉽게 해결하기 위해 작은 문제로 나누어 해결하는 방식의 알고리즘이다.  마찬가지로 큰 문제를 분할하여 해결하는 분할 정복(Divide and Conquer) 알고리즘과의 차이는 분할 정복과는 달리 동적 계획법의 분할된 문제들끼리는 중복되는 부분이 존재한다는 것이다. 

 

1. 메모이제이션(Memoization)

 피보나치 수는 동적 계획법을 설명할 때 굉장히 자주 언급되는 문제이다. n번째 피보나치 수를 f(n) 이라고 할 때, f(n) = f(n-1) + f(n-2) 이다.  이에 따라 5 번째 피보나치 수를 구한다고 할 때, 과정을 트리형태로 나타내면 다음과 같은 형태가 된다.

그림 1

보다시피 같은 피보나치 수를 구하는 연산이 여러번 중복되어있다.  고작 f(5)를 구하는데도 이정도의 중복연산이 발생한다면 n이 좀더 커지면 어떻게 될지는 짐작이 갈 것이다.  이를 해결하기 위해 한번 실행된 연산의 결과를 메모리에 저장해두고 재사용하는 기법을 사용하는데, 이를 메모이제이션(memoization)이라고 하며 동적 계획법을 핵심이라고 볼 수 있는 부분이다.  간단한 예시지만 동적 계획법을 활용한 문제풀이는 대체로 이와같이 점화식을 찾아 문제를 분할하여 해결하도록 구현한 뒤, 중복되는 연산은 한번만 실행하고 값을 메모리에 저장하여 재사용 하는 것으로 최적화한다는 흐름으로 이루어진다.

 

 

2. Top-Down 동적 계획법

 동적 계획법을 실제로 구현하는 형태중 하나로 함수의 재귀호출을 사용하는 top-down 방식이 있다.  피보나치 수를 예시로 들었을 때의 트리(그림 1)가 top-down의 형태를 가장 잘 나타낸다고 할 수 있을 것이다.  재귀함수를 다루는데 익숙하다면 구현이 편하고 가독성이 좋다는 장점이 있다. 하지만 재귀호출이라는 방식 자체의 한계로 속도 면에서 불이익이 있을 수 밖에 없다.  다만 간혹 n번째 값을 구하기 위해 n-1까지의 모든 값이 필요하지 않은 경우, top-down 형태의 구현이 불필요한 연산 횟수가 줄어들어 이후에 설명할 bottom-up 방식보다 더 나은 성능을 보이는 경우도 있어 어느쪽이 더 좋은지는 문제마다 다르다고 생각하는 것이 좋다. 

 

Top-down 방식으로 피보나치 수를 구하는 문제를 해결한 코드는 다음과 같다.

dp = {}


def fibo(n):
    # n이 1 이하일 경우 자기 자신을 반환 => f(0) = 0, f(1) = 1
    if n <= 1:
        return n

    # fibo(n-1)가 아직 계산되지 않았다면 계산하여 메모리에 저장
    if dp.get(n - 1, -1) == -1:
        dp[n - 1] = fibo(n - 1)

    # fibo(n-2)가 아직 계산되지 않았다면 계산하여 메모리에 저장
    if dp.get(n - 2, -1) == -1:
        dp[n - 2] = fibo(n - 2)

    # fibo(n) = fibo(n-1) + fibo(n-2)
    return dp[n - 1] + dp[n - 2]

   

 

3. Bottom-Up 동적 계획법

 동적  계획법을 실제로 구현하는 또다른 형태로 bottom-up 방식이 있다.  bottom-up 방식은 초기값 몇개를 구한 뒤 점화식을 토대로 계속해서 다음 값을 채워나가는 형태로 문제를 해결한다. 이미 계산된 값으로 다음값을 구해나가기 때문에 단순반복문으로 구현되며 초기값을 제대로 설정하고 점화식만 제대로 알아낸다면 놀라울정도로 간단하게 문제를 해결할 수 있고, 모든 경우를 따져봐야 답을 구할 수 있는 문제의 경우 재귀함수를 사용한 top-down 방식의 구현보다 속도가 빠르다.  다만 실제로 해결하려는 문제에 굳이 필요하지 않더라도 중간의 모든 값을 채워나가기 때문에 불필요한 연산이 추가적으로 발생할 수 있다는 단점도 존재한다.

 

bottom-up 방식으로 피보나치 수를 구하는 문제를 해결한 코드는 다음과 같다.

def fibo(n):
    # f(0) = 0, f(1) = 1
    fibo = [0, 1]

    # fibo(n) = fibo(n-1) + fibo(n-2)
    for i in range(2, n+1):
    	fibo.append(fibo[i-1] + fibo[i-2])
    
    return fibo[n]

 

 

4. 동적 계획법이 적용될 수 있는 경우

 동적 계획법은 특정 문제를 풀기위한 알고리즘이 아닌 문제를 해결하기위한 접근 방식, 방법론이기 때문에 그 활용도가 굉장히 높아 관련 문제도 다양하고 난이도 또한 문제에 따라 천차만별이다.  어떤 문제를 봤을 때, 동적계획법을 적용하여 해결 가능한지와 동적계획법을 어떻게 적용할 것인지를 빠르게 파악하는 것이 중요하다.  동적 계획법으로 풀 수 있는 문제들의 공통적인 특징 두가지에 대해 알아보자.

 

① 중복 문제의 발생

 동적 계획법은 문제를 작게 쪼갠 뒤 중복연산의 결과를 메모리에 저장해두고 재활용하는 것으로 쪼개진 문제들을 빠르게 해결하는 방법론이다.  달리 말하면 분할된 문제들이 서로 중복되는 부분이 없다면 동적 계획법이 적용될 여지는 없다.  이 경우는 분할 정복의 영역이 될 것이다.  이진 탐색 문제나 병합정렬 등이 이와 같은 경우이다. 

 

② 분할 가능 여부

 문제에 따라서는 부분문제로 나누어 최적해를 취한 것이 전체로 볼 때는 최적해가 아닌 경우도 존재한다.  그런 경우를 예시로 하나 살펴보자.

 

* A 에서 B로 가는 경로

  시간 요금
경로 1 3 15
경로 2 5 10

 

* B에서 C로 가는 경로

  시간 요금
경로 1 7 25
경로 2 4 37

A에서 B로, B에서 C로 가는 경로별로 걸리는 시간과 요금이 위와같이 주어졌을 때, A에서 B를 경유하여 C로 가야할 때, 비용이 50을 넘지 않으면서 가장 짧은 시간 내에 갈 수 있는 경로의 조합을 구해야 한다면 각 구간으로 문제를 나누어 더 짧은 시간이 걸리는 쪽을 고르던 더 적은 요금이 드는 쪽을 고르던 전체 문제의 최적해에는 도달하지 못한다.

 

이러한 문제가 발생하는 것은 부분 문제들이 서로 독립적이지 않기 때문이다.  비용이 50을 넘어선 안되기 때문에 A에서 B로 가는 경로를 하나 고를 경우 B에서 C로 가는 경로를 고를 때는 비용의 한도가 A에서 B로 가기위한 비용만큼 줄어든다. 즉, 한 부분 문제에서의 선택이 다른 부분 문제에 영향을 미친다.  이런 형태의 문제에서는 동적 계획법을 적용하기 어렵다.

 

 즉, 동적 계획법을 적용하기 위해서는 문제를 독립적인 부분문제들로 분할 가능해야하며 그 부분문제들 사이에 중복되는 부분이 있어야 한다 고 볼 수 있다.  다만 실전에서 문제를 봤을때 이런 부분들을 하나하나 계산해가며 동적 계획법을 적용할지 고민할 시간은 없기 때문에, 실제로 문제를 봤을때 동적 계획법의 적용 가능 여부를 판단할 수 있는 능력은 많은 문제를 풀어보며 경험으로 몸에 익히는 수 밖에 없을 것 같다.

 대기열(Queue)은 본래 먼저 삽입된 데이터가 먼저 나가는 FIFO 자료구조이다.  하지만 경우에 따라서 나중에 들어왔더라도 먼저 처리해야하는 데이터가 존재할 수도 있다.  이러한 경우에는 들어온 순서와는 관계없이 우선순위가 높은 것을 먼저 내보내는 우선순위 큐를 사용한다.  물론 매번 데이터를 삽입할 때 마다 우선순위 기준으로 정렬을 실행하는 비효율적인 방식을 사용할 수는 없으니 우선순위 큐에는 힙(heap)이 사용된다. 우선순위 큐와 힙은 그 기능이 완전히 동일하다고 봐도 좋다. 

 

1. 힙(Heap) 의 원리

 힙(heap)은 완전 이진트리형태의 자료구조이다.  삽입, 삭제시에 항상 느슨한 정렬상태, 즉 부모노드가 자식노드보다 항상 크거나(최대 힙) 항상 작은(최소 힙) 상태를 유지하도록 하여 언제든지 루트노드를 참조하면 들어있는 데이터중 최소/최대 값을 바로 알아낼 수 있다.  

 

힙의 구현은 주로 배열을 사용하여 이뤄진다.  트리라곤 해도 완전 이진트리 형태이기에 그쪽이 훨씬 구현이 쉽기 때문이다.  힙의 삽입, 삭제 절차와 이에따라 힙을 구현한 코드는 다음과 같다.  여기서는 최대힙을 기준으로 설명한다.

 

* 삽입

 ① 힙으로 쓸 배열의 맨 끝에 원소를 삽입한다.

 ② 삽입된 위치에서부터 부모노드를 탐색하여 부모노드가 존재하고 자신보다 값이 작다면 서로 위치를 바꾼다.

 ③ ② 과정을 더이상 위치변경이 발생하지 않을 때 까지 반복한다.

 

* 삭제

 ① 반환할 힙의 최댓값(루트노드 값)을 별도의 변수에 저장해둔다.

 ② 루트노드의 값에 힙의 맨 끝의 값을 덮어씌우고 맨 끝의 값은 제거한다.

 ③ 루트노드에서부터 자식노드를 탐색하여 자신보다 더 큰 자식노드가 있다면 서로 위치를 바꾼다.

     두 자식노드 모두 자신보다 크다면 둘 중 더 큰 쪽과 위치를 바꾼다.

 ④ ③ 과정을 더이상 위치변경이 발생하지 않을 때 까지 반복한다.

 ⑤ 미리 저장해둔 힙의 최댓값을 반환한다. 

def heappush(h, e):
    # 삽입할 원소를 힙의 맨 끝에 삽입
    h.append(e)
    
    # 삽입된 원소의 현재위치
    i = len(h) - 1
    
    # 삽입된 원소의 부모노드 
    p = i // 2 - 1 + i % 2
    
    # 삽입한 원소가 루트노드가 아닐 동안 반복
    while i > 0:
    	# 부모 노드
    	p = i // 2 - 1 + i % 2
        
    	# 만약 부모노드의 값이 삽입된 원소보다 작다면 삽입된 노드와 자리를 바꾼다
        # 계속해서 탐색하기 위해 삽입된 원소의 현재위치를 부모노드의 위치로 갱신
        if h[p] < h[i]:
            h[p], h[i] = h[i], h[p]
            i = p
            
        # 부모노드의 값이 삽입된 원소보다 크다면 더이상 탐색할 필요가 없음
        else:
            break


def heappop(h):
    # 비어있는 힙에서 값을 얻으려한다면 None 반환
    if not h:
        return None

    # 힙에 원소가 하나뿐이라면 pop한 결과 반환
    if len(h) == 1:
        return h.pop()

    # 반환할 루트노드 값을 저장
    res = h[0]
    
    # 힙의 마지막 값을 pop하여 루트노드에 저장
    h[0] = h.pop()
    
    # 현재 노드
    i = 0
    while True:
    	# 좌, 우 자식 노드
    	l, r = i * 2 + 1, i * 2 + 2
        
    	# 값을 바꿀 타겟 노드를 현재 노드로 설정
        t = i
        
        # 좌측 자식 노드가 존재하고 그 값이 타겟 노드의 값 보다 크다면
        # 좌측 자식 노드를 타겟 노드로 설정
        if l < len(h) and h[l] > h[t]:
            t = l
            
        # 우측 자식 노드가 존재하고 그 값이 타겟 노드의 값 보다 크다면
        # 우측 자식 노드를 타겟 노드로 설정
        if r < len(h) and h[r] > h[t]:
            t = r
            
        # 타겟 노드를 찾았다면 현재 노드와 서로 값을 바꾼다
        # 현재 노드를 타겟 노드로 갱신하고 좌우 자식노드를 
        if t != i:
            h[i], h[t] = h[t], h[i]
            i = t
        # 타겟 노드를 찾지 못했다면 더이상 탐색할 필요가 없음
        else:
            break
            
    # 미리 저장해둔 루트 노드 값 반환
    return res

 배열(리스트)를 인자로 받아 힙을 유지하도록 삽입/삭제하는 함수를 구현하는 방식으로 힙을 구현하였다.  힙의 삽입/삭제 연산은 O(logN)의 시간복잡도를 가지기에 삽입에 시간이 조금더 걸리긴 하지만 최댓값, 최솟값을 자주 구해야하는 경우 단순 배열에 삽입한 뒤 선형탐색을 하는 것 보다 훨씬 효율적이다.  삽입/삭제의 과정을 조금만 수정하면 최소힙, 절댓값 힙도 구현 가능하다. 

 

2. heapq 모듈

 힙의 구현이 그리 어려운 것은 아니지만 시간이 제한된 코딩테스트에서 힙을 직접 구현해가며 쓰는것은 시간낭비일 수 있다. python에서는 heapq 모듈을 제공하고 있어 굳이 직접 구현할 필요 없이 힙 삽입, 삭제, 변환 연산을 사용할 수 있다. 

 

* heapq.heappush(h, e)

 리스트 h에 힙 구조를 유지하도록 e를 삽입한다. 

 

* heapq.heappop(h)

 리스트 h에서 힙 구조를 유지하도록 최솟값을 제거하고 제거된 값을 반환한다.

 

* heapq.heapify(h)

 리스트 h를 힙 구조로 변환한다.

 

python의 heaqp 모듈은 기본적으로 최소 힙으로 구현되어있다.  최대 힙으로 사용할 경우 넣으려는 값에 음수를 취해주고 꺼낼 때도 마찬가지로 음수를 취하는 방식을 사용해야한다.

 

heapq 모듈을 사용하여 우선순위 큐 관련 문제(https://www.acmicpc.net/problem/1655)를 해결하는 절차와 이를 구현한 코드는 다음과 같다.

 

① 최소 힙 minq, 최대 힙 maxq를 각각 생성한다.

② 수열을 순회하며 각 수가 최소 힙의 최솟값 이상이라면 최소힙에, 그렇지 않다면 최대힙에 그 수를 삽입한다.

③ 최대 힙과 최소 힙의 크기차이가 1을 넘지 않도록 유지한다

④ 두 힙의 크기가 같다면 최대힙의 루트노드 값이, 다르다면 큰 쪽의 힙의 루트노드 값이 현 시점의 중간값이 된다.

import sys
from heapq import heappush, heappop


def sol1655():
    maxq, minq = [], []
    n, *nums = map(int, sys.stdin.read().split())
    answer = []
    for num in nums:
    	# 최대 힙이 비어있거나 수가 최대 힙의 최댓값 이상이라면 최대힙에 heappush
        if not maxq or num <= -maxq[0]:
            heappush(maxq, -num)
        # 그렇지 않다면 최소 힙에 heappush
        else:
            heappush(minq, num)
            
        diff = len(maxq) - len(minq)
        # 두 힙의 크기차이가 1보다 커지면 균형을 맞추고 크기차이를 0으로 세팅
        # maxq가 minq보다 2 크다면 maxq에서 원소 하나를 제거하여 minq에 넣어준다
        if diff == 2:
            heappush(minq, -heappop(maxq))
            diff = 0
        # minq가 maxq보다 2 크다면 minq에서 원소 하나를 제거하여 maxq에 넣어준다
        elif diff == -2:
            heappush(maxq, -heappop(minq))
            diff = 0

	# 두 힙의 크기차이가 없거나 maxq쪽이 크면 maxq의 최댓값이 중간값
        # 그렇지 않다면 minq의 최솟값이 중간값
        answer.append(-maxq[0] if diff >= 0 else minq[0])

    return '\n'.join(map(str, answer))

최대 힙의 최댓값이 최소 힙의 최솟값보다 항상 작으며 최대 힙과 최소 힙의 크기 차이가 1을 넘지않는다는 규칙을 유지하면서 수를 삽입할 경우 항상 두 힙의 길이가 같거나 최대힙이 크다면 최대힙의 최댓값이, 그렇지 않다면 최소힙의 최솟값이 그 시점의 중간값이 된다.

 이분 탐색은 N개의 데이터의 탐색을 로그시간으로 완료할 수 있어 코딩 테스트에서 시간복잡도를 개선하는데 굉장히 자주 사용되는 알고리즘이다.  이번에는 이분탐색을 활용하여 풀 수 있는 몇 가지 문제들을 살펴본다. 

 

1. 랜선 자르기(https://www.acmicpc.net/problem/1654)

 k개의 랜선의 길이와 필요한 랜선의 갯수 n이 주어졌을 때,  같은 길이의 n개의 랜선을 만들 수 있는 랜선의 최대길이를 구하는 문제이다. 해결하는 절차와 이를 구현한 코드는 다음과 같다.

 

① 랜선을 m만큼의 길이로 자른다고 가정한다.

② 길이는 정수이며 0으로 자르는것은 의미가 없기 때문에 m의 최솟값은 1이 된다.

③ k개의 랜선을 손실을 최소화하여 잘라도 n개의 선을 만들기 위해서는 m은 랜선의 길이를 모두 합한 값을

    n으로 나눈 것 보다 작거나 같기 때문에 m의 최댓값은 sum(lans) // n이 된다.

④ 1이상 sum(lans) // n 이하의 정수에서 이분탐색을 실시한다

⑤ 만약 랜선을 m만큼씩 잘랐을 때 n개보다 적다면 더 작게 잘라서 수를 늘리기 위해 e = m - 1

⑥ 만약 랜선을 m만큼씩 잘랐을 때 n개 이상이라면 최댓값을 찾기 위해 s = m + 1

⑦ 탐색이 종료된 시점에서 s는 마지막으로 n개의 랜선을 만드는데 성공한 m에 1을 더한 값이므로 문제의 답은 s -1

 

import sys

input = sys.stdin.read


def sol1654():
    k, n, *lans = map(int, input().split())
    
    # 2, 3
    s, e = 1, sum(lans)//n
    
    # 4
    while s <= e:
    	# 1
        m = (s + e) // 2
        # 5
        if sum([lan//m for lan in lans]) < n:
            e = m - 1
        # 6
        else:
            s = m + 1
    # 7
    return s-1

이런 식으로 이분 탐색을 활용하여 어떤 조건을 만족하는 최소/최대 값을 찾는 방식을 parametric search라고 한다.  최소/최대값을 찾으려는 값의 범위내에서 이분탐색을 실행하여 중간값에 대하여 조건의 만족여부를 검사한 뒤 조건을 만족한다면 좀더 최적의 값을 찾아내기 위한 방향으로 범위를 좁히고, 조건을 만족하지 못한다면 조건을 만족할 수 있는 방향으로 범위를 좁혀나가며 최적의 값을 탐색하는 것이다.  같은 방식이지만 좀더 어려운 문제로 백준의 1300번 문제(https://www.acmicpc.net/problem/1300)가 있지만 parametric search를 활용한다는 것만 염두에 두면 쉽게 해결 가능한 문제이니 따로 다루지는 않는다.

 

 

2. 가장 긴 증가하는 부부 수열 2(https://www.acmicpc.net/problem/12015)

수열 A의 크기 N과 수열 A가 주어졌을 때 가장 긴 증가하는 부분수열의 길이를 구하는 문제이다. 랜선 자르기 문제의 경우 문제 해결의 핵심이 이분탐색의 응용이었다면 이 문제에서는 문제를 해결하기 위한 알고리즘(LIS 알고리즘)을 최적화하기 위한 수단으로 사용된다.  문제를 해결하는 절차와 이를 구현한 코드는 다음과 같다.

 

① 비어있는 배열(리스트) lis 를 생성

② A의 원소 a에 순차적으로 접근

③ 만약 lis가 비어있거나 a가 lis의 마지막 값보다 크다면 증가수열의 길이가 늘어난 것으로 볼 수 있기에

    lis에 a를 append

④ 그렇지 않다면 이 수를 포함한 증가하는 부분 수열이 길이가 가장 길 수도 있기 때문에 lis 내의 원소 중에서

    a보다 크거나 같은 수 중에서 가장 작은 수를 대체

⑤ A의 순회가 끝난 후 lis의 길이가 최장 증가 수열의 길이가 된다.

 

import sys

input = sys.stdin.read


def sol12015():
    n, f, *seq = map(int, input().split())
    
    # 1
    lis = [f]
    
    # 2
    for num in seq:
    	# 3
        if num > lis[-1]:
            lis.append(num)
        # 4
        else:
            s, e = 0, len(lis)-1
            while s <= e:
                m = (s + e) // 2
                if lis[m] < num:
                    s = m + 1
                else:
                    e = m - 1
            lis[e+1] = num
    # 5
    return len(lis)

이 풀이의 4번 과정에서, 오름차순 정렬된 배열 lis에서 num 보다 크거나 같은 수 중 최솟값의 위치를 찾기 위해 이분탐색이 활용된 것을 볼 수 있다.  이분 탐색의 응용이긴 해도 parametric search의 형태였던 1번 문제와는 달리 여기서의 이분탐색은 정렬된 배열에 대해 실시하는 일반적인 이분 탐색이다.  

 

 

3. bisect 모듈

2번 문제를 직접 구현한 이분 탐색으로 풀 경우 시간초과는 발생하지 않지만 상당히 느리다. 또한 이분탐색의 구현이 어려운 편은 아니지만 이분 탐색이 필요할 때 마다 매번 직접 구현하는 것은 꽤나 귀찮고 시간을 잡아먹는 일이다. 파이썬에서는 bisect 모듈을 사용하여 직접 구현할 필요 없이 이분탐색을 실시할 수 있다. 

 

import sys
# bisect 모듈의 bisect_left 함수 import
from bisect import bisect_left

input = sys.stdin.read


def sol12015():
    n, f, *seq = map(int, input().split())
    lis = [f]
    for num in seq:
        if num > lis[-1]:
            lis.append(num)
        # lis에서 num이 들어갈 자리를 탐색
        else:
            lis[bisect_left(lis, num)] = num
    return len(lis)

bisect 모듈의 bisect_left, bisect_right 는 탐색할 리스트와 키가 될 값을 입력받아 해당 값을 리스트가 정렬된 상태를 유지하며 삽입하기 위한 인덱스를 반환한다.  배열에 키 값과 중복되는 값이 존재할 경우, bisect_left는 중복 값중 가장 앞쪽에 있는 값의 인덱스를 반환하며, bisect_right는 키 값보다 큰 값중 최솟값의 인덱스를 반환한다는 것이다.  여기서는 num 보다 크거나 같은 값중 최솟값을 찾아야 하기에 bisect_left를 사용하였다.  

 

위쪽이 bisect 모듈을 사용한 코드, 아래쪽은 직접 이분탐색을 구현 한 코드를 제출한 것이다.

bisect 모듈을 사용하면 코드 작성의 시간도 절약하고 편리할 뿐만 아니라 퍼포먼스 상에서도 이점이 있다.  탐색할 배열의 크기가 커질수록 bisect 모듈을 사용한 탐색이 대체로 더 좋은 성능을 보인다.

 분할 정복(Divide and Conquer)은 큰 문제를 작은 문제로 쪼개서 처리하는것으로 보다 효율적으로 문제를 해결하는 방식이다.  문제를 작게 쪼갠 후 쪼개진 문제들을 또 다시 작게 쪼개는 형태로 이루어지기 때문에 보통 재귀를 사용하여 구현하게 된다.  알고리즘 문제풀이에 굉장히 자주 사용되는 기법이기 때문에 관련 문제들을 풀어보며 잘 정리해보기로 한다.

 

1. 종이의 개수(https://www.acmicpc.net/problem/1780)

 n*n 행렬로 표현되는 종이가 한가지 색으로 이루어져있다면 종이를 그대로 사용, 그렇지 않다면 같은크기로 9등분하고 각 종이들에 대해 같은 작업을 반복한다고 할 때, 작업이 끝난 후 색깔별 종이의 갯수를 구하는 문제.  전형적인 분할 정복 문제의 유형이다.  문제에서 이미 해야할 일을 모두 알려줬기 때문에 재귀를 사용하여 그대로 구현하기만 하면 해결 가능하다. 이를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol1780():
    n = int(input())
    paper = [list(map(int, input().split())) for _ in range(n)]
    res = [0, 0, 0]

    def check(r, c, s):
        color = paper[r][c]
        # 1칸짜리 종이라면 해당 종이 색의 카운트를 1 증가 후 종이의 색을 반환
        if s == 1:
            res[color + 1] += 1
            return color

        g = s // 3
        cut = False
        # 종이를 9등분하여 재귀호출
        for i in range(r, r + s, g):
            for j in range(c, c + s, g):
                if check(i, j, g) != color:
                    cut = True

        # 쪼갠 종이들이 모두 같은 색이 아니었다면 2 반환
        if cut:
            return 2

        # 모두 같은 색이었다면 중복으로 더해진 갯수를 빼준 뒤 색(-1, 0, 1) 반환
        res[color + 1] -= 8
        return color

    check(0, 0, n)
    return '\n'.join(map(str, res))

 이 문제처럼 분할 정복을 어떻게 적용해야할지 대놓고 알려주는 문제의 경우 그것을 구현하는 것은 그리 어려운 일이 아니지만 분할 정복 문제의 경우 조금 잘못구현하면 시간초과가 발생할 정도로 시간제한이 빡빡한 경우가 많기때문에(특히 파이썬으로 푼다면 더욱) 구현의 연습자체도 어느정도 해둘 필요가 있다.  예를들어 위 코드에서도 문제대로면 종이가 모두 같은색인지 검증한 뒤 종이를 잘라야하지만 실제로 그렇게 했다간 K크기로 잘라진 종이마다 K^2 만큼의 시간을 추가로 사용하게 된다. 그렇기에 조각난 종이들이 반환한 결과를 보고 나눌 필요가 없었다면 나누지 않은 것으로 결과를 수정하는 방식을 취하였다.

 

 

2. 곱셈(https://www.acmicpc.net/problem/1629)

 자연수 a, b, c가 주어졌을 때 a의 b제곱을 c로 나눈 나머지를 구하는 매우 간단한 문제이다. 다만 a, b, c의 크기가 상당히 크고 제한시간도 짧기 때문에 단순히 a ** b % c 를 했다간 당연하게도 시간초과가 발생한다.  이 문제도 분할 정복 기법을 사용하여 중복연산을 줄이고 빠르게 해결 가능하다. 이를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol1629():
    a, b, mod = map(int, input().split())

    def pow(a, b):
        nonlocal mod
        # b가 1일경우 a를 반환
        if b == 1:
            return a % mod

        # a의 b//2 제곱의 제곱을 구한 뒤 % mod
        res = pow(a, b // 2) ** 2 % mod

        # 만약 b가 홀수라면 a를 한번 더 곱한 뒤 % mod
        if b % 2:
            res = (res * a) % mod

        return res

    return pow(a, b)

 이런 문제들은 1번의 문제처럼 분할정복을 어떻게 적용할지를 대놓고 알려주지 않는다.  물론 이 문제의 경우 한눈에 어떻게 적용해야할지 보이는 수준의 간단한 문제지만 어려운 문제일수록 분할정복으로 접근해야한다는 생각을 하고 어떻게 분할정복 기법을 적용해야할지 아이디어를 떠올리기가 쉽지않다.  

 

 

3. 행렬 제곱(https://www.acmicpc.net/problem/10830)

 2번 문제가 자연수의 제곱을 빠르게 구하는 것이었다면 이번에는 행렬의 제곱을 빠르게 구하는 문제이다.  당연하게도 분할정복 기법을 적용하는 아이디어는 2번 문제와 거의 동일하다. 이를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline
mod = 1000


def sol10830():
    n, b = map(int, input().split())
    mat = [list(map(int, input().split())) for _ in range(n)]
    
    # 행렬의 b제곱을 구한다
    mat = matsq(mat, b)
    
    # b가 1일 경우 matmul 함수를 거치지않아 각 원소에 나머지연산이 적용되지 않을 수 있음
    for i in range(n):
        for j in range(n):
            mat[i][j] %= mod
            
    return '\n'.join([' '.join(map(str, mat[i])) for i in range(n)])
   

# 행렬의 거듭제곱
def matsq(m, x):
	# x가 1이라면 m 반환
    if x == 1:
        return m
        
    # m^(x//2)의 제곱을 구한 뒤 x가 홀수라면 m을 한번 더 곱해준다
    res = matsq(m, x//2)
    res = matmul(res, res)
    if x % 2:
        res = matmul(res, m)
    return res
    

#행렬의 곱셈
def matmul(a, b):
    res = [[0] * len(a) for _ in range(len(b[0]))]
    for i in range(len(a)):
        for j in range(len(b[0])):
            for k in range(len(b)):
                res[i][j] += (a[i][k] * b[k][j])
            # 각 원소를 계산한 뒤 나머지연산 
            res[i][j] %= mod
    return res

행렬의 곱셈이나 거듭제곱은 다른 문제에도 종종 활용될 수 있어 기억해두면 도움이 될 수 있다.

 

 

4. 피보나치 수 6(https://www.acmicpc.net/problem/11444)

 피보나치 수열의 n번째 수를 구하는 문제이다.  동적계획법을 사용한 방법의 경우 O(N)의 시간으로 피보나치 수를 구할 수 있지만, 이 문제의 경우 n이 최대 1,000,000,000,000,000,000까지 올라갈 수 있기 때문에 더 빠른 알고리즘이 필요하다.  하지만 3번의 행렬의 거듭제곱을 활용하면 더 빠르게 피보나치 수를 구할 수 있다. 피보나치 수열의 점화식인 F(N) = F(N-1) + F(N-2)를 행렬의 형태로 나타내면 아래와 같은 형태가 된다

즉, 1 * 2 행렬의 초기값을 (F(0), F(1)) 로 잡고 2 * 2 행렬 ((0, 1), (1, 1)) 의 N-1 제곱을 곱해준 뒤 행렬의 두 번째 값을 취하면 F(N)을 구할 수 있다. 물론 N이 0 또는 1인 경우는 예외적으로 처리를 해둘 필요가 있다. 이 방식은 분할 정복 기법을 적용한 행렬의 거듭제곱이 O(logN)의 시간복잡도를 가지기 때문에 O(logN)의 시간으로 피보나치 수를 구할 수 있다. 이를 구현한 코드는 다음과 같다.

 

import sys

input = sys.stdin.read
mod = 1000000007


def sol11444():
    n = int(input())
    # n이 1 이하일 경우 자기자신을 반환 
    # F(0) = 0, F(1) = 1
    if n <= 1:
        return n
        
    # 초기 피보나치 행렬 (0, 1)에 ((0, 1), (1, 1))의 n-1 제곱을 곱해준다
    f = [[0, 1]]
    o = [[0, 1], [1, 1]]
    f = matmul(f, matsq(o, n - 1))

	# 계산이 끝난 후 피보나치 행렬의 두 번째 요소가 n번째 피보나치 수가 된다.
    return f[0][1]


# 행렬의 곱셈
def matmul(a, b):
    r, c, m = len(a), len(b[0]), len(b)
    res = [[0] * c for _ in range(r)]
    for i in range(r):
        for j in range(c):
            for k in range(m):
                res[i][j] = (res[i][j] + a[i][k] * b[k][j]) % mod
    return res


# 행렬의 거듭제곱
def matsq(m, x):
    if x == 1:
        return m
    res = matsq(m, x // 2)
    res = matmul(res, res)
    if x % 2:
        res = matmul(res, m)
    return res


if __name__ == '__main__':
    print(sol11444())

 

'코딩테스트 > 알고리즘' 카테고리의 다른 글

#16 우선순위 큐(Priority Queue)  (0) 2021.08.18
#15 이분 탐색(Binary Search)  (0) 2021.08.17
#13 큐(Queue) 활용  (0) 2021.08.14
#12 스택(Stack) 활용  (0) 2021.08.13
#11 정수론 - 이항 계수(Binomial Coefficient)  (0) 2021.08.12

 큐(Queue)는 마지막에 삽입한 데이터가 가장 먼저 제거되는 스택과는 달리 먼저 삽입된 데이터가 먼저 제거되는 선입선출(FIFO)식 자료구조이다.  이름 그대로 대기열의 구현에 사용되며 네트워크상에서 패킷의 전송이나 OS의 스케줄링 등에서도 큐가 사용된다.  큐가 사용되는 대표적인 문제는 BFS 문제이며 그 외에도 큐의 성질을 활용해야하는 문제나 큐의 변형 자료구조인 데크(Deque)나 우선순위 큐(Priority Queue) 등을 활용한 문제들도 있다.  이번에는  큐와 데크를 활용하여 해결할 수 있는 문제 두 가지를 알아본다.

 

 

1. 프린터 큐 (https://www.acmicpc.net/problem/1966)

 문서마다 중요도가 주어지고 큐에서 가장우선도가 높은 문서들은 모두 큐의 맨 뒤로 보내며 처음으로 만난 최고 우선도의 문서를 제거하여 출력하는 프린터 큐를 구현하는 문제.  중요도가 중복되지 않는다면 정렬로 끝날 간단한 문제지만 같은 우선도를 가진 문서가 존재할 수 있기 때문에 큐를 활용하여 해결해야한다. 이를 해결한 코드는 다음과 같다.

import sys

input = sys.stdin.readline


def sol1966():
    res = []
    # 테스트케이스 루프
    for _ in range(int(input())):
        n, m = map(int, input().split())
        
        # 문서의 중요도와 초기 인덱스를 묶어서 리스트형태로 저장
        docs = list(zip(map(int, input().split()), range(n)))
        
        # 프린트 시작
        for i in range(1, n+1):
        	# 가장 우선도가 높은 문서중 가장 앞에 있는 문서를 출력
            # 문서의 초기인덱스가 m과 같다면 res에 답을 append하고 다음 테스트케이스로
            p = max(docs, key=lambda x:x[0])
            if p[1] == m:
                res.append(i)
                break
               
            # 실제로는 출력할 문서가 나올때까지 모든 문서를 하나하나 큐의 맨 뒤로 보내야하지만
            # 빠르고 간결하게 처리하기 위해 리스트 슬라이싱을 사용하여 같은 결과를 낸다.
            # 출력할 문서를 기준으로 좌우를 슬라이싱한뒤 순서를 바꾸어 이어붙인다.
            pi = docs.index(p)
            docs = docs[pi+1:] + docs[:pi]
            
    return '\n'.join(map(str, res))


if __name__ == '__main__':
    print(sol1966())

 

 

2. AC (https://www.acmicpc.net/problem/5430)

 R은 배열을 뒤집는 연산, D는 배열의 첫 번째 숫자를 버리는 연산이라고 정의할 때, 초기 배열과 연산순서가 주어지면 모든 연산을 수행한 뒤 배열의 상태를 출력하는 문제.  배열을 실제로 뒤집는 방식으로는 당연하게도 시간초과가 발생한다.  배열을 뒤집는다는 것이 곧 D 연산으로 버릴 숫자의 위치가 첫 번째와 마지막 사이에서 바뀌는 것이란걸 알면 양방향 큐인 데크를 활용하여 간단하게 해결 가능하다.  이를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline


def sol5430():
    res = []
    for t in range(int(input())):
        cmd = input().rstrip()
        n = int(input())
        
        # D 연산의 횟수
        d = cmd.count('D')
        
        seq = input()
        
        # D 연산의 횟수가 수열의 크기보다 크면 에러
        if d > n:
            res.append('error')
        # 같다면 빈 배열
        elif d == n:
            res.append('[]')
            
        else:
            seq = list(seq[1:-2].split(','))
            # 두번 연속 뒤집기는 의미가 없음
            cmd = cmd.replace('RR', '')

            # 앞에 나온 R의 횟수가 짝수면 앞에서 버리기, 홀수면 뒤에서 버리기
            cmd = list(map(len, cmd.split('R')))
            seq = seq[sum(cmd[0::2]):n - sum(cmd[1::2])]
            if len(cmd) % 2 == 0:
                seq.reverse()
            res.append('[' + ','.join(seq) + ']')
    return '\n'.join(res)


if __name__ == '__main__':
    print(sol5430())

무의미한 뒤집기 연산을 제거하고 D연산의 횟수와 수열의 크기만으로 해결 가능한 케이스를 빠르게 처리하여 남는 것들만 실제로 연산들을 수행한다.  연속뒤집기를 제거하고나서 split('R')을 해주면 짝수번째 명령의 길이는 앞에서 버리는 D연산의 횟수, 홀수번째 명령의 길이는 뒤에서 버리는 D연산의 횟수가 된다. 이를 이용하면 일일히 뒤집기와 pop을 실행할 필요도없다.  실제로 데크를 사용하더라도 시간초과는 발생하지 않지만 이 방법을 사용하면 시간을 많이 단축할 수 있다.  물론 동작 원리는 결국 데크를 사용하는 것과 동일하다.

 스택(Stack)은 데이터를 아래에서 쌓아올리듯 삽입하고 맨 위에서 하나씩 꺼내는 후입선출(LIFO)식 자료구조이다.  매우 단순한 구조를 가지고있지만 OS의 인터럽트나 프로그램에서 함수의 호출 등 이전상태를 보존하고 다음단계를 진행한 뒤 돌아오는 형태의 많은 작업들에 활용되고있으며 그 특성을 이용하여 풀 수 있는 알고리즘 문제들도 있다.  이번에는 스택을 활용한 문제 3개를 정리해본다.

 

1. 균형잡힌 세상(https://www.acmicpc.net/problem/4949)

  소괄호와 대괄호, 영문알파벳, 공백으로 이루어진 문자열이 주어졌을 때, 문자열의 괄호가 모두 정상적으로 쌍을 이루고있는지 확인하는 문제.  괄호쌍을 확인하는 문제는 전형적인 스택 활용 문제중 하나이다.  여는 괄호는 스택에 push하고, 닫는 괄호가 나오면 쌍을 이룰 여는 괄호가 스택에 남아있는지 확인하여 pop 한다. 도중에 닫는 괄호와 쌍을 이룰 여는 괄호가 스택에 남아있지 않거나 모든 작업을 마친 후 여는 괄호가 스택에 남아있다면 괄호쌍이 맞지 않는 문자열임을 알 수 있다.  이 문제를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.read


def sol4949():
    open = {'(', '['}
    close = {')', ']'}
    type = {'(': 0, ')': 0, '[': 1, ']': 1}
    
    def pair_check(s):
        st = []
        for c in s:
        	# 여는 괄호일 경우 스택에 type 값(소괄호, 대괄호 구분)을 push
            if c in open:
                st.append(type[c])
                
            # 닫는 괄호일 경우 스택탑에 같은 타입값이 존재하는지 확인
            elif c in close:
            	# 없을 경우 'no' 반환
                if not st or st[-1] != type[c]:
                    st = [-1]
                    return 'no'
                # 있을 경우 스택 pop
                st.pop()
                
        # 모든 작업 종료 후 스택이 비어있지 않다면 'no' 반환
        if st:
            return 'no'
            
        # 그렇지 않다면 'yes' 반환
        return 'yes'

    return '\n'.join(map(pair_check, input().splitlines()[:-1]))


if __name__ == '__main__':
    print(sol4949())

 

 

 

2. 스택 수열(https://www.acmicpc.net/problem/1874)

  특정 수열을 만들기 위한 스택의 삽입, 삭제 연산 순서를 구하는 문제이다. 간단한 문제지만 스택의 동작을 이해하는데 어느정도 도움이 된다. 이를 해결한 코드는 다음과 같다.

import sys

input = sys.stdin.read


def sol1874():
    n, *seq = map(int, input().split())
    i = 1		# 스택에 삽입될 수
    st = []		# 스택
    res = []	# 연산 순서
    
    # 만들어야할 수열을 순회
    for num in seq:
    	# 해당 숫자가 스택에 들어갈 때 까지 push
        while i <= num:
            st.append(i)
            res.append('+')
            i += 1
           
        # 스택 탑의 값이 num과 같지 않다면 'NO' 반환
        if not st or st[-1] != num:
            return 'NO'
            
        # pop
        st.pop()
        res.append('-')
    return '\n'.join(res)


if __name__ == '__main__':
    print(sol1874())

 

 

3. 오큰수(https://www.acmicpc.net/problem/17298)

 주어진 수열의 각 수마다 자신보다 오른쪽에 있으면서 자신보다 큰 수중 가장 왼쪽에 있는 수를 찾는 문제이다.

단순히 자신보다 오른쪽부터 자신보다 큰 값이 나올때까지 선형탐색을 할 경우 O(N^2) 수준의 복잡도를 보이기 때문에 수열의 크기 N이 최대 100만까지 커질 수 있는 이 문제는 해결할 수 없다.  하지만 스택을 활용하면 간단하게 해결 가능한 문제이다.  수열의 수들을 스택에 순차적으로 push하되, push 전에 스택 탑이 자신보다 작은 수가 아닐 때까지 pop한 뒤 자신이 pop한 수의 오큰수가 되도록하면 모든 수의 오큰수를 구할 수 있다. 이 방법으로 문제를 해결한 코드는 다음과 같다.

import sys

input = sys.stdin.read


def sol17298():
    n, *seq = map(int, input().split())
    # 오큰수를 찾지 못한경우 -1을 출력해야하기 때문에 디폴트값을 -1로 초기화
    res = [-1] * n
    
    # 수열의 수들을 순회
    st = []
    for i in range(n):
    	# 자신보다 작은 수가 스택 탑인 동안 pop,  pop한 수의 오큰수는 자신이 된다.
        while st and st[-1][0] < seq[i]:
            res[st.pop()[1]] = seq[i]
         
        # 스택에 수열에서의 인덱스와 함께 push
        st.append((seq[i], i))
        
    return ' '.join(map(str, res))


if __name__ == '__main__':
    print(sol17298())

 

 스택은 DFS 등에도 사용 가능한 자료구조이지만 이런 경우 보통 실제로 스택을 사용하기보다는 함수의 재귀호출 형태로 구현하게 되기 때문에 그부분은 따로 다루지 않기로한다.

 

 

 순서를 고려하지 않고 n개의 물건중 k개를 고르는 경우의 수(조합)를 이항 계수라 한다.  코딩테스트에 이항계수를 구하라는 문제가 나오지는 않겠지만 이항계수를 구해야 해결 가능한 문제가 나올 수는 있기 때문에 이항계수를 구하는 방법들에 대해 한번 따로 정리를 해두기로 한다.

 

1. 이항계수의 수식에 따른 방법

 이항계수를 nCk 를 구하는 수식은 nCk = n!/(k! * (n-k)!)  이다.  n과 k가 입력됐을 때 단순히 이 수식대로 계산하더라도 상당히 빠른 속도로 이항계수를 구할 수 있다.  주의할 점이라면 C나 Java 등의 변수의 자료형에 따른 정수 표현의 한계가 있는 언어의 경우 숫자가 빠르게 커지면서 오버플로우가 발생할 수 있다는 점이다.  그 때문에 이항계수를 구해야하는 문제들은 보통 매우 큰 소수로 모듈러연산을 한 결과를 요구한다.  

 

이 방식으로 이항계수 문제(https://www.acmicpc.net/problem/11050)를 해결한 코드는 다음과 같다.

import sys

input = sys.stdin.readline


def sol11050():
    n, k = map(int, input().split())
    deno = nume = 1
    for i in range(1, k+1):
        deno *= (n-i+1)
        nume *= i
    return deno//nume


if __name__ == '__main__':
    print(sol11050())

 

 

 

2. 동적계획법을 사용한 방법

 이항계수 nCk 는 n개중 k개를 고르는 경우의 수이며 이는 임의의 한개를 고르지 않았다고 가정하고 n-1개중 다시 k개를 고르는 경우의 수와 한개를 이미 골랐다고 가정하고 n-1개중 나머지 k-1개를 고르는 경우의 수를 더한 것과 같다.

즉, nCk = n-1Ck + n-1Ck-1 이라는 점화식이 성립한다.  이 점화식을 기반으로 동적계획법을 사용하면 O(N^2)의 시간/공간 복잡도로 이항계수를 구할 수 있다.  1번 방법에 비해 빠르지는 않지만 중간중간 모듈러연산을 적용하여 오버플로우가 발생할 위험을 조기에 방지할 수 있다.

 

이 방식으로 이항계수 문제(https://www.acmicpc.net/problem/11051)를 해결한 코드는 다음과 같다.

import sys

input = sys.stdin.readline


def sol11051():
    n, k = map(int, input().split())
    c = [[0] * (n+1) for _ in range(n+1)]
    for i in range(n+1):
        c[i][0] = c[i][i] = 1
       
    for i in range(1, n+1):
        for j in range(1, i+1):
            c[i][j] = (c[i-1][j] + c[i-1][j-1]) % 10007
    return c[n][k]


if __name__ == '__main__':
    print(sol11051())

 

 

3. 페르마의 소정리를 이용한 방법

 여기서부터는 조금 복잡하다.  여기서도 마찬가지로 1번 방법에서 사용한 이항계수를 구하기 위한 수식에 페르마의 소정리를 적용하여 풀어나간다. 페르마의 소정리p가 소수일 때,  a^p 와 a 는 p로 나눈 나머지가 서로 같다는 정리이다. 수식의 전개는 아래와 같다.

 

nCk % p

= (n!/(k! * (n-k)!)) % p 

= (n! * (k! * (n-k)!) ^ -1) % p

= ((n! % p) * ((k! * (n-k)!)^-1) % p) % p

 

페르마의 소정리 a^p ≡ a (mod p) 에서 양 변에 a^-2를 곱해주면  a^(p-2) ≡ a^-1 (mod p) 이 된다.

∴ ((n! % p) * ((k! * (n-k)!)^-1) % p) % p

= ((n! % p) * ((k! * (n-k)!)^(p-2) % p) % p

= (n! * (k! * (n-k)!)^(p-2)) % p

 

즉, n! 과 (k! * (n-k)!)^(p-2)를 곱한 뒤 p의 나머지를 취하면 이항계수에 p의 나머지를 취한 것과 같은 값이 나온다.

 

 

※ 여기서 하나 더 문제가 있는데, p는 상당히 큰 소수인 경우가 대부분이기 때문에 이 방법을 사용하려면

   거듭제곱을 빠르게 구하는 법도 알아둘 필요가 있다.  n의 k제곱은 n의 k//2제곱의 제곱을 구한 뒤,

   k가 홀수일 경우 n을 한번 더 곱해준 값과 같다.

   ∴ n^k = (n^(k//2)) ^ 2 * n^(k%2) 

   이 방식을 재귀적으로 적용하여 n^k 를 구하면 시간복잡도가 O(K) 에서 O(logK)로 격감한다.

 

이 방식을 사용하여 이항 계수 문제(https://www.acmicpc.net/problem/11401)를 해결한 코드는 다음과 같다.

 

import sys

input = sys.stdin.readline
mod = 1000000007


def sol11401():
    n, k = map(int, input().split())
    if k > n//2:
        k = n-k
    A = fact(n)
    B = pow(fact(k) * fact(n - k), mod - 2)
    return A * B % mod


def pow(a, b):
    if b == 0:
        return 1
    res = pow(a, b // 2) ** 2 % mod
    if b % 2 == 1:
        res = (res * a) % mod
    return res
   
  
def fact(n):
    res = 1
    for i in range(1, n+1):
        res = (res * i) % mod
    return res


if __name__ == '__main__':
    print(sol11401())

위 코드에서는 설명한 수식을 그대로 구현하기 위해 위와같이 코드를 작성했지만 사실 n!, (k! * (n-k)!)^(p-2) 를 구하기보다는 n! / (n-k)! 과 (k!)^(p-2)를 구하는 편이 훨씬 빠르게 문제를 해결할 수 있다.  그 방식으로 작성한 코드는 다음과 같다.

import sys

input = sys.stdin.readline
mod = 1000000007


def sol11401():
    n, k = map(int, input().split())
    if k > n//2:
        k = n-k
    A = fact(n-k+1, n)
    B = pow(fact(1, k), mod - 2)
    return A * B % mod


def pow(a, b):
    if b == 0:
        return 1
    res = pow(a, b // 2) ** 2 % mod
    if b % 2:
        return res * a % mod
    return res
   
  
def fact(a, b):
    res = 1
    for i in range(a, b+1):
        res = (res * i) % mod
    return res


if __name__ == '__main__':
    print(sol11401())

 

 

 나머지에 관한 문제일 경우 모듈러 연산의 성질에 대해 알고있으면 쉽게 해결 가능한 문제들이 많다.

이번에는 모듈러 연산의 성질을 활용하여 문제를 해결해본다.

 

1. 모듈러연산의 덧셈, 뺄셈, 곱셈

(A + B) % C = ((A % C) + (B % C)) % C
(A - B) % C = ((A % C) - (B % C)) % C
(A * B) % C = ((A % C) * (B % C)) % C

 모듈러 연산의 덧셈, 뺄셈, 곱셈에는 분배법칙이 성립한다.  이 성질을 응용하여 수학적으로 해결 가능한 문제도 있으며, 매우 큰 수를 구하는 문제에서 모듈러연산을 취한 값을 반환할 것을 요구할 경우, 단순히 최종값을 구한 뒤 모듈러 연산을 하게되면 자료형의 표현범위에 한계가 있는 언어일 경우 오버플로우가 발생할 수 있으며 Python 처럼 자료형의 표현범위에 제한이 없더라도 메모리 초과 등의 문제가 발생할 수 있기때문에 모듈러 연산의 분배법칙을 이용하여 연산 중간중간에 모듈러 연산을 취해주는 것으로 문제를 해결할 수도 있다.

 

모듈러 연산의 분배법칙을 활용하여 문제(https://www.acmicpc.net/problem/2981)를 해결한 코드는 다음과 같다.

import sys

input = sys.stdin.read


def sol2981():
    n, *seq = map(int, input().split())
    g, *diff = [abs(seq[i]-seq[i-1]) for i in range(1, n)]
    for d in diff:
        g = gcd(g, d)
    res = {g}
    for i in range(2, int(g**.5)+1):
        if g % i == 0:
            res.add(i)
            res.add(g//i)
    return ' '.join(map(str, sorted(res)))


def gcd(a, b):
    if a < b:
        a, b = b, a
    while b != 0:
        a, b = b, a % b
    return a


if __name__ == '__main__':
    print(sol2981())

 모든 수를 나누었을 때 나머지가 같다는 것을 식으로 나타내면 n개의 수를 A1, A2, ... , An 이라 할 때, 

A1 % m = k

A2 % m = k

A3 % m = k

...

와 같은 형태가 된다.

이 때, 인접한 수들끼리 뺄셈을 취하면 Ax % m - A(x-1) % m = 0 과 같은 형태가 된다.  m은 1보다 크기 때문에,

(Ax % m - A(x-1) % m) % m = 0 이 성립한다.  분배법칙에 따라 이는 (Ax - A(x-1)) % m = 0 이 된다. 즉, m은 n개의 수들의 차들의 공약수중 2 이상인 수가 된다.  이를 구하기 위해 인접한 수들의 차를 모두 구한 뒤, 그 차들의 최대공약수를 구한다.  이후 최대공약수의 약수를 모두 구하여 오름차순 정렬한 것이 답이된다. 

 

 코딩테스트의 문제들을 풀다보면 어느정도의 수학적 지식을 요구하는 문제들이 종종 등장한다.  이번에는 그 중에도 최대공약수(GCD)와 최소공배수(LCM)에 대해 알아본다. 

 

1. 최대공약수(Greatest Common Divisor)

 어떤 두 수의 최대공약수는 두 수의 공통 약수중 가장 큰 것이다.  단순히 두 수중 작은쪽부터 시작해서 수를 점점 줄여나가며 처음으로 발견되는 공통 약수를 구하는 방법도 있지만 이 방법은 숫자의 크기가 크고 최대공약수가 작을수록 굉장히 많은 시간을 소요한다.   최대공약수를 보다 빠르고 간단하게 구하는 방법으로 유클리드 호제법이 있다. 

 

① 두 수중 큰 쪽을 A, 작은 쪽을 B라고 한다.

② A가 B로 나누어 떨어진다면 B가 두 수의 최대공약수이다.

③ 나누어 떨어지지 않는다면 A를 B로 나눈 나머지 C를 구한다

④ A = B,  B = C 로 한 뒤 ②번으로 돌아간다.

 

 

2. 최소공배수(Least Common Multiple)

 어떤 두 수의 최소공배수는 두 수의 공통 배수중 가장 작은 것이다.  최대공약수를 구하는 법을 안다면 최소공배수를 구하는 것은 매우 간단하다. 두 수를 곱한 뒤 최대공약수로 나누면 된다.  

 

 

이를 이용하여 최소공배수, 최대공약수를 구하는 문제(https://www.acmicpc.net/problem/2609)를 해결한 코드는 다음과 같다.

import sys


def sol2609():
    n, m = map(int, sys.stdin.read().split())
    # 1
    if n < m:
        n, m = m, n
    gcd = [n, m]
    # 2
    while gcd[1] != 0:
    	# 3, 4
        gcd = [gcd[1], gcd[0] % gcd[1]]
        
    print(gcd[0], n * m // gcd[0], sep='\n')
   

if __name__ == '__main__':
    sol2609()

 

+ Recent posts