Lucian Log
Blog
Computer Science
Algorithm
DB
Network
OS
General
AI
Blockchain
Concurrency
ETC
Git
Infrastructure
AWS
Docker
Java-Ecosystem
JPA
Java
Spring
JavaScript-Ecosystem
JavaScript
Next.js
React
TypeScript
Python-Ecosystem
Django
FastAPI
Python
SQLAlchemy
Software Engineering
Architecture
Culture
Test
Home
Contact
Copyright © 2024 |
Yankos
Home
>
Computer Science
> Algorithm
Now Loading ...
Algorithm
투 포인터 (Two Pointers)
투 포인터 (Two Pointers) 알고리즘 투 포인터(Two Pointers) 알고리즘은 리스트에 순차적으로 접근해야 하는 경우에, 두 개의 점의 위치를 기록하면서 처리하는 방식을 말한다. 시작점과 끝점을 사용해 순차적으로 접근할 데이터의 범위를 표현할 수 있다. 투 포인터를 활용하면 유용한 다음 문제를 살펴보자. 위 문제는 전체 수열이 주어지면, 그 수열의 부분 수열 중 특정한 합을 가지는 연속하는 부분 수열을 찾는 문제이다. 이 문제를 해결하는 가장 심플한 방법은 완전탐색으로 각 인덱스마다 해당 인덱스로 시작하는 부분 연속 수열을 모두 찾아보는 방법이다. 다만, 이 완전탐색은 시간 복잡도가 O(N²)이 걸리므로 비효율적이다. 이 때, 투 포인터 알고리즘을 활용하면 선형 시간 복잡도 O(N)으로 이 문제를 처리할 수 있다. 알고리즘의 과정은 다음과 같다. 진행 과정을 구체적으로 살펴보자. 가장 먼저 시작점과 끝점이 첫 번째 원소의 인덱스를 가리키도록 초기화한다. 또한, 문제에서 원하는 부분합 M은 5이다. 현재 부분합은 1이고 M보다 작으므로 무시한다. 이전 부분합이 M보다 작았기 때문에, end를 1 증가시키고 새로운 부분합을 구한다. 구해진 부분합은 3이므로 무시한다. 이전 부분합이 역시 M보다 작았으므로, end를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 6이므로 무시한다. 이전 부분합이 M보다 컸으므로, start를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 5이므로 카운트를 센다. 이전 부분합이 M과 같았으므로, start를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 3이므로 무시한다. 이전 부분합이 M보다 작았으므로, end를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 5이므로 카운트를 센다. 이전 부분합이 M과 같았으므로, start를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 2이므로 무시한다. 이전 부분합이 M보다 작았으므로, end를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 7이므로 무시한다. 이전 부분합이 M보다 컸으므로, start를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 5이므로 카운트를 센다. 그리고 알고리즘은 마무리된다. 이러한 투 포인터 알고리즘을 파이썬으로 구현한 코드는 다음과 같다. n = 5 # 데이터의 개수 N m = 5 # 찾고자 하는 부분합 M data = [1, 2, 3, 2, 5] # 전체 수열 count = 0 interval_sum = 0 end = 0 # start를 차례대로 증가시키며 반복 for start in range(n): # end를 가능한 만큼 이동시키기 while interval_sum < m and end < n: interval_sum += data[end] end += 1 # 부분합이 m일 때 카운트 증가 if interval_sum == m: count += 1 interval_sum -= data[start] print(count) 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2021-03-08
소수 판별 알고리즘 - 에라토스테네스의 체
소수 (Prime Number) 판별 알고리즘 소수란 1보다 큰 자연수 중 1과 자기자신을 제외한 자연수로는 나누어떨어지지 않는 자연수를 말한다. 코딩 테스트에서는 어떠한 자연수가 소수인지 아닌지 판별해야 하는 문제가 자주 출제되므로 알고리즘을 기억해두면 좋다. 다음은 기본적인 소수 판별 알고리즘을 파이썬으로 구현한 것이다. # 소수 판별 함수 정의 (2이상의 자연수에 대하여) def is_prime_number(x): # 2부터 (x - 1)까지의 모든 수를 확인하며 for i in range(2, x): # x가 해당 수로 나누어떨어진다면 if x % i == 0: return False # 소수가 아님 return True # 소수임 print(is_prime_number(4)) print(is_prime_number(7)) 기본적인 소수 판별 알고리즘의 시간 복잡도는 O(N)이다. 2부터 N - 1까지의 모든 자연수에 대하여 차례차례로 연산을 수행하기 때문이다. 다만, 자연수의 범위가 10억과 같이 커진다면 연산 수행에 문제가 생기므로 시간복잡도를 개선할 필요성이 있다. 개선된 소수 판별 알고리즘 약수의 성질에서 시간 복잡도 개선의 단서를 찾을 수 있다. 어떤 한 수에 대한 모든 약수는 가운데 약수를 기준으로 곱셈 연산에 대해 대칭을 이룬다. 예를 들어, 16의 약수 1, 2, 4, 8, 16에서 2 X 8 = 16이고 8 X 2 = 16이다. 즉, 특정한 수에 대한 모든 약수를 찾을 때 가운데 약수(제곱근)까지만 확인하면 충분하다. 다음 코드는 이를 활용하여 소수 판별 알고리즘을 개선한 형태이다. # 소수 판별 함수 (2이상의 자연수에 대하여) def is_prime_number(x): # 2부터 x의 제곱근까지의 모든 수를 확인하며 for i in range(2, int(x ** 0.5) + 1): # x가 해당 수로 나누어 떨어진다면 if x % i == 0: return False # 소수가 아님 return True # 소수임 print(is_prime_number(4)) print(is_prime_number(7)) 이 경우 특정 수의 제곱근까지만 확인하는 과정이므로, 시간 복잡도는 O(√N)이 된다.(루트 N) 에라토스테네스의 체 알고리즘 지금까지 특정 수에 대하여 소수를 판별하는 과정을 살펴보았다. 더 나아가 만일 특정한 수의 범위가 주어지고 그 범위안의 존재하는 모든 소수를 찾아야 한다면 어떻게 해야할까? 이 상황에서는 다수의 자연수에 대하여 소수 여부를 판별하는 대표적 알고리즘인 에라토스테네스의 체를 사용할 수 있다. 에라토스테네스의 체 알고리즘의 동작 과정은 다음과 같다. 2번 단계에서는 남은 수 중에서 아직 처리하지 않은 가장 작은 소수 i(남은 수가 결국 소수)를 찾고, 3번 단계에서 i를 제외한 그 i의 배수를 모두 제거하는 과정을 반복한다. 다음은 N=26인 상황일 때의 동작과정이다. 에라토스테네스의 체 역시 약수의 성질을 적용할 수 있다. 예를 들어, 위 경우는 26의 대략적인 제곱근인 5까지만 확인하면 된다. 6부터는 배수가 5를 넘어갈 수 없고, 이미 앞에서 소수 2, 3, 5의 배수를 제거했기 때문이다. 따라서, √N까지의 자연수만 확인해도 동일한 결과를 얻을 수 있다. 다음은 에라토스테네스의 체 알고리즘을 파이썬 코드로 구현한 것이다. n = 1000 # 2부터 1000까지의 모든 수에 대하여 소수 판별 # 처음엔 모든 수를 소수(True)인 것으로 초기화(0, 1은 제외) array = [True for i in range(n + 1)] # 에라토스테네스의 체 알고리즘 수행 # 2부터 n의 제곱근까지의 모든 수를 확인하며 for i in range(2, int(n ** 0.5) + 1): if array[i] == True: # i를 제외한 i의 모든 배수를 지우기 j = 2 while i * j <= n: array[i * j] = False j += 1 # 모든 소수 출력 for i in range(2, n + 1): if array[i]: print(i, end=' ') 이러한 에라토스테네스의 체 알고리즘의 시간 복잡도는 O(NloglogN) 으로 선형시간에 가까울 정도로 매우 빠르므로, 다수의 소수를 찾는 문제에서 효율적이다. 다만, 각 자연수에 대한 소수 여부를 저장해야 하기 때문에 메모리가 많이 필요하다는 단점이 있다. 예를 들어, N이 10억인 경우 문제 해결이 어렵다. 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2021-03-03
기타 그래프 이론 - 최소 신장 트리 (MST, Minimum Spanning Tree)
신장 트리(Spanning Tree)란? 신장 트리(Spanning Tree)란 원본 그래프의 모든 노드를 포함하면서 사이클이 존재하지 않는 부분 그래프를 뜻한다. 위의 가운데 그림처럼 간선들이 모든 노드를 잇고 있지만, 사이클은 생기지 않는 부분 그래프가 신장 트리의 예시가 된다. 반면, 오른쪽 그림처럼 모든 노드를 잇지도 않고 사이클마저 생기는 것은 신장 트리에 해당되지 않는다. 이 개념을 트리라고 부르는 이유는 모든 노드가 포함되어 서로 연결되면서 사이클이 존재하지 않는다는 조건이 트리의 조건에 해당하기 때문이다. 이러한 트리의 특성으로 인해, 신장 트리가 가지는 총 간선의 개수는 노드의 개수 - 1이 된다. 최소 신장 트리(MST, Minimum Spanning Tree) 최소 신장 트리(MST, Minimum Spanning Tree)란 최소한의 비용으로 구성되는 신장 트리를 의미한다. 최소 신장 트리의 개념은 여러 문제 상황에서 유용할 수 있는데, 만일 N개의 도시가 있고 두 도시 사이에 도로를 놓아 전체 도시가 서로 연결될 수 있게 하는 경우 최소 신장 트리가 사용된다. 위 그림을 예시로 보면, 3개의 도시가 있는 상황에서 모든 도시를 최소 비용으로 연결하는 방법은 오른쪽 그림과 같다. 크루스칼 알고리즘 (Kruskal Algorithm) 크루스칼 알고리즘(Kruskal Algorithm)은 대표적인 최소 신장 트리 알고리즘들 중 하나이다. 그리디 알고리즘으로 분류되며 동작 과정은 다음과 같다. 요약하자면, 모든 간선을 최소 비용 순으로 하나씩 확인하여 사이클을 생성하지 않는 간선들만 최소 신장 트리에 포함시키는 것이다. 구체적인 예시로 더 살펴보자. 위와 같이 원본 그래프가 주어졌을 때, 먼저 간선을 오름차순으로 정렬하고 작업을 수행한다. 위 그림의 테이블은 가독성을 위주로 간선 정보가 나열되어 있기 때문에 혼돈하지 않도록 하자. 처음으로 가장 최소인 비용을 가지는 3, 4번 노드를 잇는 간선을 확인한다. 두 노드는 다른 집합에 속해 있어 사이클 생성이 불가능하므로 Union 함수를 호출해 같은 집합으로 만들어 최소 신장 트리에 포함한다. 다음으로 다음 최소 비용에 해당하는 4, 7번 노드를 잇는 간선을 확인한다. 두 노드 역시 다른 집합에 속해 사이클을 생성하지 않으므로, Union 함수로 최소 신장 트리에 포함한다. 다음 최소 비용에 해당하는 4, 6번 노드를 잇는 간선도 두 노드가 다른 집합에 속해 있으므로 Union 함수를 호출해 최소 신장 트리에 포함시킨다. 다음 최소 비용에 해당하는 6, 7번 노드를 잇는 간선을 확인한다. 6번과 7번 노드의 경우 같은 집합에 속해 있기 때문에, 사이클을 발생시킨다. 따라서, 최소 신장 트리에 해당 간선을 포함시키지 않고 무시한다. 다음 최소 비용인 1번과 2번 노드를 잇는 간선을 확인한다. 두 노드는 다른 집합에 속하므로 Union 함수를 호출하여 같은 집합으로 합쳐 최소 신장 트리에 포함한다. 다음 최소 비용에 해당하는 2번 6번 노드를 연결하는 간선도 서로 다른 집합에 속하므로 최소 신장트리에 포함시킨다. 다음 최소 비용에 해당하는 2번 노드와 3번 노드를 연결하는 간선은 두 노드가 같은 집합에 속하므로 무시한다. 다음 최소 비용에 해당하는 5번과 6번 노드를 잇는 간선은 두 노드가 서로 다른 집합에 속하므로, Union 함수를 호출하여 최소 신장트리에 포함시킨다. 마지막으로 1번과 5번 노드를 잇는 간선은 두 노드가 서로 같은 집합에 속해 있으므로 무시하도록 한다. 연산을 모두 수행하면 최종적으로 위와 같은 최소 신장 트리가 나온다. 이 최소 신장 트리의 모든 간선의 비용을 합하면, 해당 값이 최종 비용이 된다. 위의 과정을 파이썬 코드로 구현하면 다음과 같다. # input # 7 9 # 1 2 29 # 1 5 75 # 2 3 35 # 2 6 34 # 3 4 7 # 4 6 23 # 4 7 13 # 5 6 53 # 6 7 25 # 특정 원소가 속한 집합을 찾기 (Find 연산) def find_parent(parent, x): # 루트 노드를 찾을 때까지 재귀 호출 if parent[x] != x: parent[x] = find_parent(parent, parent[x]) return parent[x] # 두 원소가 속한 집합을 합치기 (Union 연산) def union_parent(parent, a, b): a = find_parent(parent, a) b = find_parent(parent, b) if a < b: parent[b] = a else: parent[a] = b # 노드의 개수와 간선(Union 연산)의 개수 입력 받기 v, e = map(int, input().split()) parent = [0] * (v + 1) # 부모 테이블 초기화하기 # 모든 간선을 담을 리스트와 최종 비용을 담을 변수 edges = [] result = 0 # 부모 테이블에서, 부모를 자기 자신으로 초기화 for i in range(1, v + 1): parent[i] = i # 모든 간선에 대한 정보를 입력 받기 for _ in range(e): a, b, cost = map(int, input().split()) # 비용순으로 정렬하기 위해서 튜플의 첫 번째 원소를 비용으로 설정 edges.append((cost, a, b)) # 간선을 비용순으로 정렬 edges.sort() # 간선을 하나씩 확인하며 for edge in edges: cost, a, b = edge # 사이클이 발생하지 않는 경우에만 집합에 포함 if find_parent(parent, a) != find_parent(parent, b): union_parent(parent, a, b) result += cost print(result) 크루스칼 알고리즘의 시간 복잡도는 Elog(E)이다. 이와 같은 시간복잡도를 가지는 이유는 크루스칼 알고리즘에서 가장 시간이 오래 걸리는 부분이 정렬을 수행하는 작업이며, E개의 간선을 정렬하기 때문이다. 내부에서 이뤄지는 서로소 집합 알고리즘의 시간 복잡도는 정렬 알고리즘의 시간 복잡도보다 작기 때문에 무시한다. 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2021-02-20
기타 그래프 이론 - 서로소 집합 (Disjoint Sets)
서로소 집합 (Disjoint Sets) 서로소 집합이란 공통 원소가 없는 두 집합을 의미한다. 예를 들어, {1, 2}, {3, 4}는 서로소 관계이지만, {1, 2}, {2, 3}은 2라는 공통된 원소가 존재하므로 서로소 관계가 아니다. 서로소 집합 자료구조 (Union Find 자료구조) 서로소 집합 자료구조(Union Find 자료구조)는 서로소 부분 집합들로 나누어진 원소들의 데이터를 처리하기 위한 자료구조이다. 서로소 집합 자료구조에는 두 가지 연산이 존재하는데, 두 개의 원소가 포함된 집합을 하나의 집합으로 합치는 합집합(Union) 연산과 특정한 원소가 속한 집합이 어떤 집합인지 알려주는 찾기(Find) 연산이 그것이다. 서로소 집합 자료구조의 동작 과정 1. 기본 동작 과정 (합치기 연산이 여러 개 주어졌을 경우) 합치기 연산이 여러 개 주어졌을 경우, 위와 같은 동작 과정을 거쳐 작업을 수행한다. 이를 구체적으로 살펴보자. 위와 같이 4개의 Union 연산이 주어졌을 상황을 가정해보자. 먼저 노드 개수만큼의 크기를 가지는 부모 노드를 표현하는 테이블을 생성하고, 테이블 내 각 노드의 부모노드를 자기자신으로 초기화한다. 테이블 생성 및 초기화가 끝나면, 첫 번째로 Union(1, 4) 연산을 처리한다. 이를 처리하기 위해 Union 연산의 인자 값으로 주어진 노드 1과 노드 4의 루트 노드를 찾는다. 여기서는 각자 자기자신이 루트 노드에 해당하므로, 1과 4 중 더 큰 번호에 해당하는 노드 4의 부모노드를 1번 노드로 설정한다. 일반적으로, 큰 번호 노드를 작은 번호 노드의 자식 노드로 설정하는 것이 관행이 있어서 이 규칙을 따라 예시를 진행하겠다. Union(1, 4) 연산이 끝나면, Union(2, 3) 연산을 진행한다. 노드 2와 노드 3에 대하여 루트 노드를 찾는데, 이번에도 자기자신이 루트 노드이고 3이 더 큰 번호 노드이므로 3번 노드의 부모 노드를 2번 노드로 설정한다. 다음으로 Union(2, 4) 연산을 위와 같은 방식으로 또 진행한다. 2번 노드의 루트 노드는 자기 자신이고, 4번 노드의 루트 노드는 1번 노드이다. 2번 노드가 1번 노드보다 큰 번호이므로, 1번 노드를 2번 노드의 부모 노드로 설정한다. 마지막으로 Union(5, 6) 연산을 똑같은 방법으로 수행한다. 각각의 노드의 루트 노드는 자기자신이고 6번 노드가 더 큰 번호이므로, 5번 노드는 6번 노드의 부모 노드로 설정된다. 이와 같은 서로소 집합 자료구조는 각 집합들간의 연결성을 통해 총 몇 개의 집합이 존재하는지를 손쉽게 확인할 수 있다는 장점이 있다. 위의 1, 2, 3, 4번 노드들은 하나의 루트 노드를 가지며 트리 구조 형태를 띈다. 이런 경우 1, 2, 3, 4번 노드들은 원소가 4개인 하나의 집합으로 파악할 수 있다. 또한 5, 6번 노드도 원소가 2개인 또 다른 집합으로서 존재한다. 결론적으로, 위 그래프에서는 총 2개의 집합(1, 2, 3, 4번 노드 집합과 5, 6번 노드 집합)이 존재하고, 그 2개의 집합은 서로소 관계를 가진다. 다만, 기본적인 형태의 서로소 집합 자료구조에서는 루트 노드에 즉시 접근할 수 없다는 단점도 동시에 가지고 있다. 루트 노드를 찾기 위해서는 부모 테이블에서 해당 노드의 부모 노드를 계속 확인하며 거슬러 올라가야만 한다. 위의 과정을 파이썬 코드로 구현하면 다음과 같다. # input # 6 4 # 1 4 # 2 3 # 2 4 # 5 6 # 특정 원소가 속한 집합을 찾기 (Find 연산) def find_parent(parent, x): # 루트 노드를 찾을 때까지 재귀 호출 if parent[x] != x: return find_parent(parent, parent[x]) return x # 두 원소가 속한 집합을 합치기 (Union 연산) def union_parent(parent, a, b): a = find_parent(parent, a) b = find_parent(parent, b) if a < b: parent[b] = a else: parent[a] = b # 노드의 개수와 간선(Union 연산)의 개수 입력 받기 v, e = map(int, input().split()) parent = [0] * (v + 1) # 부모 테이블 초기화하기 # 부모 테이블에서, 부모를 자기 자신으로 초기화 for i in range(1, v + 1): parent[i] = i # Union 연산을 각각 수행 for i in range(e): a, b = map(int, input().split()) union_parent(parent, a, b) # 각 원소가 속한 집합 출력하기 print('각 원소가 속한 집합: ', end='') for i in range(1, v + 1): print(find_parent(parent, i), end=' ') print() # 부모 테이블 내용 출력하기 print('부모 테이블: ', end='') for i in range(1, v + 1): print(parent[i], end=' ') 2. 기본 구현 방법의 개선 위의 기본적인 Union Find 구현 방법은 수행 시간 면에서 문제점이 있다. 합집합(Union) 연산이 편향되게 이루어지는 경우 찾기(Find) 함수가 비효율적으로 동작한다는 점이다. 위는 최악의 경우를 가정한 예시다. 위와 같이 Union 연산이 편향적으로 수행되면, 5번 노드에 대해서 찾기(Find) 함수를 수행할 시 모든 노드를 다 확인하여 1번 노드를 루트 노드로 반환하는 비효율적인 동작을 보인다. 이 때, 시간 복잡도는 O(V)다. 따라서 Find 함수를 개선하기 위해 경로 압축(Path Compression) 기법을 사용한다. 다음은 경로 압축 기법을 구현한 파이썬 코드인데, 이는 기본적인 Find 함수에 약간의 변형만으로 구현된다. # 특정 원소가 속한 집합을 찾기 def find_parent(parent, x): # 루트 노드가 아니라면, 루트 노드를 찾을 때까지 재귀적으로 호출 if parent[x] != x: parent[x] = find_parent(parent, parent[x]) return parent[x] 경로 압축 기법을 적용하면, 각 노드에 대하여 Find 함수를 호출한 이후에 해당 노드의 루트 노드가 바로 부모 노드가 된다. 위의 파이썬 코드를 사용하면 같은 예시에 대하여 위 그래프와 같이 모드 노드들이 자신의 루트 노드를 부모 노드로 가지는 결과를 보여준다. 시간 복잡도도 개선되는 모습을 보인다. 서로소 집합을 활용한 사이클 판별 서로소 집합은 무방향 그래프에서 사이클을 판별할 때 사용 가능하다. (방향이 있는 그래프에서는 DFS를 사용한다.) 서로소 집합을 사용한 사이클 판별 알고리즘의 과정은 다음과 같다. 이를 더 구체적으로 살펴보자. 처음에는 기존 서로소 집합 자료구조 구현과 같은 초기화 과정을 거친다. 각 노드에 대하여 부모 노드를 자기자신으로 설정한다. 그 다음, 1번 노드와 2번 노드를 연결하는 간선을 확인하여, 어떤 노드가 부모노드가 될 지 판단한다. 1번과 2번 노드의 부모 노드는 각자 자기자신이므로, 더 큰 번호에 해당하는 2번 노드의 부모 노드를 1번 노드로 설정한다. 다음은 1번 노드와 3번 노드를 잇는 간선을 확인한다. 1번 노드와 3번 노드도 각각의 부모 노드가 자기 자신이므로, 더 큰 번호에 해당하는 3번 노드의 부모 노드를 1번 노드로 설정한다. 끝으로 2번 노드와 3번 노드 사이의 간선을 확인한다. 2번 노드와 3번 노드 각각의 루트 노드는 1번 노드이므로, 이미 같은 집합에 속해 있음을 알고 사이클이 발생함을 파악할 수 있다. 서로소 집합을 사용한 사이클 판별 알고리즘의 파이썬 구현은 다음 코드와 같다. # input # 3 3 # 1 2 # 1 3 # 2 3 # 특정 원소가 속한 집합을 찾기 (Find 연산) def find_parent(parent, x): # 루트 노드를 찾을 때까지 재귀 호출 if parent[x] != x: parent[x] = find_parent(parent, parent[x]) return parent[x] # 두 원소가 속한 집합을 합치기 (Union 연산) def union_parent(parent, a, b): a = find_parent(parent, a) b = find_parent(parent, b) if a < b: parent[b] = a else: parent[a] = b # 노드의 개수와 간선(Union 연산)의 개수 입력 받기 v, e = map(int, input().split()) parent = [0] * (v + 1) # 부모 테이블 초기화하기 # 부모 테이블에서, 부모를 자기 자신으로 초기화 for i in range(1, v + 1): parent[i] = i cycle = False # 사이클 발생 여부 for i in range(e): a, b = map(int, input().split()) # 사이클이 발생한 경우 종료 if find_parent(parent, a) == find_parent(parent, b): cycle = True break # 사이클이 발생하지 않았다면 합집합(Union) 연산 수행 else: union_parent(parent, a, b) if cycle: print("사이클이 발생했습니다.") else: print("사이클이 발생하지 않았습니다.") 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2021-02-14
최단 경로 (Shortest Path) - 벨만 포드 (Bellman-Ford)
벨만 포드 알고리즘 (Bellman-Ford Algorithm) 1. 벨만 포드 알고리즘 개요 벨만 포드 알고리즘(Bellman-Ford Algorithm)은 다익스트라 알고리즘과 거의 유사하다. 다만, 다익스트라 알고리즘과 달리 벨만 포드 알고리즘은 음의 값을 가지는 간선을 포함하여 알고리즘을 수행할 수 있다는 점이 큰 차이점이다. 위와 같이 5번 노드에서 2번 노드로 가는 간선의 비용이 -2인 그래프가 있다. 이 경우, 음의 간선이 존재하지만 얼마든지 오른쪽 테이블처럼 최소 비용을 계산해낼 수 있다. 그러나 음의 간선의 순환이 포함되어 있는 경우 최소 비용을 계산하는데 어려움이 생길 수 있다. 위 그래프는 음의 간선 비용이 -4인데 이 값이 상당히 작기 때문에 ‘3번 노드 -> 5번 노드 -> 2번 노드’ 순으로 순환을 계속하는 것이 최소 비용을 구하는 과정이 되어버리고, 결국 비용을 마이너스 무한대로 무한히 줄이게 되는 상황을 확인할 수 있다. 이러한 상황을 타개하기 위해서는 벨만포드 알고리즘을 적용해야 한다. 일반적으로 최단 경로 문제는 다음과 같은 상황이 존재한다. 모든 간선이 양수인 경우 음수 간선이 포함된 경우 음수 간선의 순환이 있는 경우 음수 간선의 순환이 없는 경우 벨만 포드 알고리즘의 경우 음의 간선이 포함된 상황에서도 사용할 수 있고, 음수 간선의 순환을 감지할 수 있는 덕분에 음의 간선이 포함된 상황에서 최단 경로를 구할 때는 벨만 포드 알고리즘을 사용한다. 다만, 벨만 포드 알고리즘의 시간 복잡도는 O(VE) 로 다익스트라 알고리즘보다 느리기 때문에, 음의 간선이 포함되지 않은 상황이라면 다익스트라 알고리즘을 적용하는 것이 바람직하다. 2. 벨만 포드 알고리즘의 동작 과정 벨만 포드 알고리즘의 동작 과정은 다음과 같다. 전체적인 로직은 다익스트라 알고리즘과 유사하다. 여기서 두 가지 주목해볼 점이 있는데 먼저, 음수 간선의 순환을 체크하는 부분이다. 벨만 포드 알고리즘은 마지막에 3번 과정을 한 번 더 수행하므로써 음수 간선의 순환이 존재하는지 여부를 확인한다. 만일, 이 과정에서 테이블이 또 갱신된다면, 음수 간선의 순환이 존재한다고 판단한다. 또한, 3번의 과정을 N - 1번만큼 수행하는 부분에서 벨만 포드 알고리즘은 전체 간선을 모두 확인하는 반면, 다익스트라 알고리즘은 확인한 노드에 붙어 있는 간선만 체크한다는 점은 다르다. 여기서 알 수 있는 것은 벨만 포드 알고리즘이 다익스트라 알고리즘의 최적의 해(Optimal Solution)를 항상 포함한다는 것이다. 즉, 다익스트라는 간선의 비용이 양수인 상황에서만 적용 가능하지만, 벨만 포드 알고리즘은 다익스트라 알고리즘의 최적의 해를 보장하므로 시간은 오래걸리더라도 모든 상황에 적용 가능하다. 벨만 포드의 파이썬 구현 코드는 다음과 같다. import sys input = sys.stdin.readline INF = int(1e9) # 무한을 의미하는 값으로 10억 설정 def bf(start): # 시작 노드에 대해서 초기화 dist[start] = 0 # 전체 n번의 라운드(round)를 반복 for i in range(n): # 매 반복마다 "모든 간선"을 확인 for j in range(m): cur = edges[j][0] next_node = edges[j][1] cost = edges[j][2] # 현재 간선을 거쳐서 다른 노드로 이동하는 거리가 더 짧은 경우 if dist[cur] != INF and dist[next_node] > dist[cur] + cost: dist[next_node] = dist[cur] + cost # n번째 라운드에서도 값이 갱신된다면 음수 순환이 존재 if i == n - 1: return True return False # 노드의 개수, 간선의 개수를 입력받기 n, m = map(int, input().split()) # 모든 간선에 대한 정보를 담는 리스트 만들기 edges = [] # 최단 거리 테이블을 모두 무한으로 초기화 dist = [INF] * (n + 1) # 모든 간선 정보를 입력받기 for _ in range(m): a, b, c = map(int, input().split()) edges.append((a, b, c)) # 벨만 포드 알고리즘을 수행 negative_cycle = bf(1) if negative_cycle: print(-1) else: # 1번 노드를 제외한 다른 모든 노드로 가기 위한 최단 거리 출력 for i in range(2, n + 1): # 도달할 수 없는 경우, -1을 출력 if dist[i] == INF: print(-1) # 도달할 수 있는 경우, 최단 거리 출력 else: print(dist[i]) 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2021-02-11
최단 경로 (Shortest Path) - 플로이드 워셜 (Floyd-Warshall)
플로이드 워셜 알고리즘 (Floyd-Warshall Algorithm) 1. 플로이드 워셜 알고리즘 개요 플로이드 워셜 알고리즘(Floyd-Warshall Algorithm)은 최단 경로를 구하는 또 하나의 대표적 알고리즘이다. 다만, 다익스트라 알고리즘이 ‘한 지점에서 다른 특정 지점까지의 최단 경로를 구하는 경우’에 사용한다면, 플로이드 워셜 알고리즘은 ‘모든 지점에서 다른 모든 지점까지의 최단 경로를 모두 구하는 경우’에 사용한다. 플로이드 워셜은 다익스트라처럼 단계별로 거쳐가는 노드를 기준으로 알고리즘을 수행하지만 매 단계마다 방문하지 않은 노드 중에 최단 거리를 갖는 노드를 찾는 과정이 없다. 그리고 2차원 테이블에 모든 노드의 최단 거리 정보를 저장하며, 이를 점화식을 통해 갱신한다는 점에서 다이나믹 프로그래밍 유형에 속한다. 구현하는 것은 다익스트라 알고리즘에 비해 쉽지만, 시간복잡도가 O(N³)이므로 노드와 간선의 개수가 적은 상황에서만 사용할 수 있다. 플로이드 워셜 알고리즘의 점화식은 위와 같다. 각 단계마다 특정한 노드 k를 거쳐가는 경우를 확인하여, a에서 b로 가는 최단 거리보다 a에서 k를 거쳐 b로 가는 거리가 더 짧은지 검사한다. 그리고 둘 중 짧은 거리를 최단 거리로 갱신한다. 2. 플로이드 워셜 알고리즘 동작 과정 처음엔 각 노드마다 인접한 노드들과의 거리를 확인하여 최단 거리 테이블에 기록한다. 이 때, 최단 거리 테이블의 행은 출발 노드를, 열을 도착 노드를 의미한다. 그 이후 이중 반복문을 이용하여 모든 노드들에 대하여 1번 노드를 거쳐가는(k=1) 경우를 고려해 점화식을 수행한다. 1번 노드를 거쳐가는 케이스이므로 출발, 도착 노드가 1번 노드인 행, 열과 자기 자신으로 향하는 오른쪽 아래 방향 대각선 값들은 이번 단계 알고리즘 수행의 영향을 받지 않는다. k = 1인 단계에서 알고리즘을 수행하면 총 6가지 값이 갱신 대상이 되고 실제로 변경되는 값은 D24, D32이다. k=2인 단계에서도 k=1인 단계와 마찬가지로 총 6개의 갱신 대상이 존재한다. 이번 단계에서는 실제로 D13만 변경된다. k = 3 단계도 위 과정과 동일하며, D41, D42의 값이 실제로 변경된다. 끝으로 k = 4인 단계도 마찬가지의 과정을 수행하며, D13만 실제로 값이 변경하는 것을 끝으로 알고리즘이 종료된다. 이러한 알고리즘 수행은 삼중 반복문(k, 행, 열)을 통해 구현이 가능하다. # input # 4 # 7 # 1 2 4 # 1 4 6 # 2 1 3 # 2 3 7 # 3 1 5 # 3 4 4 # 4 3 2 # output # 0 4 8 6 # 3 0 7 9 # 5 9 0 4 # 7 11 2 0 INF = int(1e9) # 무한을 의미하는 값으로 10억 설정 # 노드 및 간선의 개수 입력 받기 n = int(input()) m = int(input()) # 2차원 리스트(인접 행렬 방식)를 생성하고 무한으로 초기화 graph = [[INF] * (n + 1) for _ in range(n + 1)] # 자기 자신으로 가는 비용은 0으로 초기화 for i in range(1, n + 1): for j in range(1, n + 1): if i == j: graph[i][j] = 0 # 각 간선에 대한 정보를 입력 받아 테이블을 초기화 for _ in range(m): # A에서 B로 가는 비용은 C라고 설정 a, b, c = map(int, input().split()) graph[a][b] = c # 점화식에 따라 플로이드 워셜 알고리즘을 수행 for k in range(1, n + 1): for i in range(1, n + 1): for j in range(1, n + 1): graph[i][j] = min(graph[i][j], graph[i][k] + graph[k][j]) # 수행된 결과를 출력 for i in range(1, n + 1): for j in range(1, n + 1): # 도달할 수 없는 경우, 무한(INFINITY)으로 출력 if graph[i][j] == INF: print("INFINITY", end=' ') # 도달할 수 있는 경우, 거리를 출력 else: print(graph[i][j], end=' ') print() 이렇게 구현한 플로이드 워셜 알고리즘은 거쳐가는 노드 k와 테이블 전체를 완전 탐색하는 연산을 고려하여 O(N³)의 시간 복잡도를 가진다. 따라서, 보통 최대 500개 이하의 노드라면 플로이드 워셜 알고리즘 수행이 가능하다고 판단할 수 있다. 500개라 하더라도 500 X 500 X 500은 1억을 넘어가므로 유의할 필요가 있다. 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2021-01-04
최단 경로 (Shortest Path) - 다익스트라 (Dijkstra Algorithm)
최단 경로 알고리즘 (Shortest Path Algorithm) 최단 경로 알고리즘(Shortest Path Algorithm)이란 가장 짧은 경로를 찾는 알고리즘을 말한다. 최단 경로를 찾는 것은 여러가지 상황이 존재할 수 있는데, 대표적으로 (1) 한 지점에서 다른 한 지점까지의 최단 경로를 찾는 상황 (2) 한 지점에서 다른 모든 지점까지의 최단 경로를 찾는 상황 (3) 모든 지점에서 다른 모든 지점까지의 최단 경로를 찾는 상황을 생각해 볼 수 있다. 최단 경로 알고리즘은 일반적으로 그래프 자료구조를 기반으로 해 진행된다. 각 지점은 노드(Node)로 나타내고, 지점 사이를 연결하는 도로는 간선(Edge)으로 표현한다. 예를 들어, 노드는 도시, 마을, 국가 등으로, 간선은 도로, 통로 등으로 표현할 수 있다. 다익스트라 알고리즘 (Dijkstra Algorithm) 1. 다익스트라 알고리즘 개요 다익스트라 알고리즘(Dijkstra Algorithm)은 대표적인 최단 경로 알고리즘 중 하나이다. 에츠허르 다익스트라(Edsger Wybe Dijkstra)가 고안한 알고리즘이어서 알고리즘 명에 창시자 이름을 그대로 사용했다. 다익스트라 알고리즘은 특정한 노드에서 출발해 다른 모든 노드로 가는 최단 경로를 계산한다. 이 때, 알고리즘에 사용되는 그래프는 음의 간선이 없어야 올바른 결과를 얻을 수 있다. 이러한 특징은 다익스트라 알고리즘이 인공위성 같은 실제 GPS 소프트웨어에서 기본 알고리즘으로 채택되는 이유 중 하나이다. 보통 현실 세계의 도로는 음의 간선으로 표현되지 않기 때문에, 다익스트라는 실제 세계에서 실용적으로 사용되기에 적합한 알고리즘이다. 또한, 이 알고리즘은 매 상황에서 가장 비용이 적은 노드를 선택하는 과정을 반복한다는 점에서 그리디 알고리즘으로 분류될 수 있다. 다만, 어떤 지점에서 다른 지점을 경유하여 특정 지점으로 가는 최단 경로는 경유한 지점을 중심으로 하는 또 다른 최단 경로들로 분할할 수 있다는 점에서, 최단 경로 알고리즘은 다이나믹 프로그래밍을 기반으로 한다고도 말할 수 있다. 다익스트라 알고리즘의 동작 과정은 다음과 같다. 1. 출발 노드를 설정한다. 2. 최단 거리 테이블을 초기화한다. (자기자신으로 향하는 비용은 0, 다른 모든 노드로 향하는 비용은 무한(inf)로 설정) 3. 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택한다. 4. 해당 노드를 거쳐 다른 노드로 가는 비용을 계산하여 최단 거리 테이블을 갱신한다. 5. 3번과 4번 과정을 반복한다. 특히, 3번을 통해 매 순간 변하지 않는 최단 경로를 정할 수 있다는 점은 다익스트라 알고리즘이 그리디 알고리즘으로 잘 동작하게 되는 근거가 된다. 그런데, 다익스트라 최단 경로 알고리즘을 동작시키면 각 노드에 대한 최단 경로가 아닌 단순한 최단 거리를 결과로 얻게 된다. 이름은 최단 경로 알고리즘이지만 최단 거리를 넘어서 진정한 최단 경로까지 출력하기 위해서는 별도의 로직을 추가할 필요가 있다. 다만, 코딩테스트 수준에서는 모든 노드에 대한 최단 거리 테이블만 구해도 충분히 문제를 해결할 수 있으므로, 이 글에서는 최단 거리를 구하는 것에 대해서만 살펴보기로 한다. 2. 다익스트라 알고리즘 동작 과정 다익스트라 알고리즘에서 최단 거리 테이블은 각 노드에 대해서 현재까지의 최단 거리 정보를 가지고 있다. 그리고 알고리즘 수행 과정에서 이미 기록된 최단 거리 정보보다 더 짧은 경로를 찾게 되면, 찾은 경로의 거리를 최단 거리 테이블에 갱신한다. 위 그림을 살펴보면, 현재까지 A로 가는 최단 거리가 8로 기록되어 있지만, 다음 탐색에서 B를 경유해 A로 가는 경로가 더 짧음을 확인했다면 해당 경로의 거리인 7을 최단 거리 테이블에 갱신해준다. (1) 간단한 구현 위 그림과 같이 간선에 방향성이 있는 그래프를 사용해 다익스트라 알고리즘의 더 구체적인 수행 과정을 살펴보자. 먼저, 출발 노드는 임의로 1번 노드로 설정하고 최단 거리 테이블을 초기화한다. 최단 거리 테이블에서 1번은 자기자신으로 향하므로 값을 0으로 설정하고 나머지 모든 노드는 무한 값을 설정해 진행한다. 다음으로, 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택한다. 여기서는 1번 노드의 최단거리가 가장 짧으므로 1번 노드를 선택하여 방문한다. 그리고 해당 노드를 거쳐 다른 노드로 가는 비용을 계산해 최단 거리 테이블에 갱신한다. 이 경우, 1번 노드에 인접한 노드는 2, 3, 4번 노드이고 각각에 대하여 비용을 계산한 값은 0+2, 0+5, 0+1가 되는데, 이는 모두 각 노드의 현재 값인 무한대보다 작으므로 최단 거리 테이블 속 해당 노드들에 이 값들을 기록한다. 그리고 다시 앞 과정을 반복한다. 이번엔 아직 방문하지 않은 노드들 중 가장 최단 거리가 짧은 노드가 4번 노드이므로 이 노드를 선택해 방문한다. 4번 노드에 인접한 노드를 확인해보면 3, 5번 노드가 있다. 3번 노드의 경우 새로 찾은 경로의 비용이 1+3이고 이는 현재 값 5보다 작으므로 최단 거리 테이블의 3번 노드 정보를 갱신한다. 5번 노드의 경우도 새로 찾은 경로의 비용이 1+1이고 이는 현재 값 무한대보다 작으므로 테이블의 정보를 갱신한다. 이번엔 방문하지 않은 노드들 중 최단 거리가 가장 짧은 노드로 2, 5번 노드 두 개가 있다. 일반적으로 숫자가 더 작은 노드를 먼저 처리하는 경향이 있어 여기선 2번 노드를 먼저 처리하기로 한다. 방문한 2번 노드에는 3, 4번 노드가 인접해 있다. 3번 노드의 경우 새로 찾은 경로의 비용이 2+3인데 현재 값이 4이므로 테이블을 갱신하지 않고 넘어간다. 4번 노드 역시 새로운 경로의 비용이 2+2인데 현재 값이 1이므로 테이블을 갱신하지 않는다. 그런데 사실 4번 노드는 이미 방문한 노드이기 때문에 비용의 대소 비교없이 갱신을 무시하고 넘어가는 방법을 사용할 수 있다. 이미 방문한 노드는 그 노드까지 가는 최단 경로가 확실히 정해진 것이어서 변동의 여지가 없기 때문이다. 다음 단계에서는 방문하지 않은 노드 중 5번 노드의 최단 거리가 가장 짧으므로 5번 노드를 방문한다. 5번 노드에 인접한 노드로는 3, 6번 노드가 있는데, 3번노드의 경우 새로 찾은 경로의 비용 2+1이 현재 값 4보다 작으므로 테이블을 갱신하고 6번의 경우도 새로 찾은 경로의 비용이 2+2여서 현재 값 무한대보다 작으므로 테이블을 갱신한다. 이번엔 방문하지 않은 노드 중 최단 거리가 가장 짧은 3번 노드를 방문해 처리한다. 3번 노드의 인접 노드로는 2, 6번 노드가 있다. 2번 노드는 새로 찾은 경로의 비용이 3+3이어서 현재 값 2보다 크고, 이미 방문한 노드여서 최단거리가 확정되어 있으므로 테이블을 갱신할 필요가 없다. 6번 노드도 새로 찾은 경로의 비용 3+5가 현재 값 4보다 크므로 역시 테이블을 갱신하지 않는다. 마지막으로, 방문하지 않은 마지막 6번 노드에 대해 처리한다. 사실, 다익스트라 알고리즘에서는 마지막 남은 하나의 노드에 대해서 처리할 필요가 없다. 이미 다른 모든 노드들에 대해 최단 거리가 확정되어서 기존 과정을 수행할 필요성이 사라지기 때문이다. 심지어 위 그래프의 6번 노드는 인접한 노드도 없어서 알고리즘 과정을 수행하지 않는다. 따라서, 별도의 과정 수행 없이 알고리즘이 종료된다. 다익스트라 알고리즘의 간단한 코드 구현은 아래와 같다. # input # 6 11 # 1 # 1 2 2 # 1 3 5 # 1 4 1 # 2 3 3 # 2 4 2 # 3 2 3 # 3 6 5 # 4 3 3 # 4 5 1 # 5 3 1 # 5 6 2 # output # 0 # 2 # 3 # 1 # 2 # 4 import sys input = sys.stdin.readline INF = int(1e9) # 무한을 의미하는 값으로 10억을 설정 # 노드의 개수, 간선의 개수를 입력 받기 n, m = map(int, input().split()) # 시작 노드 번호 입력 받기 start = int(input()) # 각 노드에 인접한 노드 정보를 담는 리스트 만들기 graph = [[] for _ in range(n + 1)] # 방문 여부를 체크하는 리스트 만들기 visited = [False] * (n + 1) # 최단 거리 테이블 값을 무한으로 초기화 distance = [INF] * (n + 1) # 모든 간선 정보를 입력 받기 for _ in range(m): a, b, c = map(int, input().split()) graph[a].append((b, c)) # a번 노드에서 b번 노드로 가는 비용이 c # 방문하지 않은 노드 중에서, 가장 최단 거리가 짧은 노드의 번호를 반환 def get_smallest_node(): min_value = INF index = 0 for i in range(1, n + 1): if distance[i] < min_value and not visited[i]: min_value = distance[i] index = i return index def dijkstra(start): # 시작 노드에 대해 초기화 distance[start] = 0 visited[start] = True for j in graph[start]: distance[j[0]] = j[1] # 시작 노드를 제외한 n - 1개의 노드에 대해 반복 for i in range(n - 1): # 현재 최단 거리가 가장 짧은 노드를 방문 now = get_smallest_node() visited[now] = True # 현재 노드와 인접한 다른 노드들 확인 for j in graph[now]: cost = distance[now] + j[1] # 현재 노드를 경유해 다른 노드로 이동하는 거리가 더 짧은 경우 if cost < distance[j[0]]: distance[j[0]] = cost # 다익스트라 알고리즘 수행 dijkstra(start) # 모든 노드에 대해 최단 거리 출력 for i in range(1, n + 1): # 도달할 수 없는 경우 무한(INFINITY)으로 출력 if distance[i] == INF: print("INFINITY") # 도달할 수 있는 경우 거리를 출력 else: print(distance[i]) 위와 같은 간단한 다익스트라 알고리즘 구현은 시간 복잡도가 O(V²)이다. 최단 거리가 가장 짧은 노드를 찾는 선형 탐색을 O(V)번 해야하고, 찾은 노드에 인접한 노드를 매번 확인해야 하기 때문이다. 따라서, 전체 노드 개수가 5000개 이하인 문제에 대해서는 큰 문제 없지만, 10000개를 넘어가는 문제에 대해서는 보다 개선된 다익스트라 알고리즘을 사용해야 한다. (2) 개선된 다익스트라 알고리즘 다익스트라 알고리즘을 개선하기 위해서는 우선순위 큐를 사용해야 한다. 우선순위 큐는 우선도가 높은 데이터를 먼저 처리하도록 설계된 자료구조이며 이를 구현하기 위해 보통 힙 자료구조를 사용한다. 힙을 사용하면 데이터의 삽입, 삭제에 logN의 시간 복잡도가 소요되며, 데이터를 우선도에 따라 NlogN 시간 복잡도로 정렬할 수 있다. 다익스트라 알고리즘을 개선하기 위해서 ‘단계마다 방문하지 않은 노드 중 최단 거리가 가장 짧은 노드를 선택’하는 과정에 힙 자료구조를 사용한다. 그리고 최단 거리가 가장 짧은 노드를 선택해야하므로 최소 힙을 사용해야 한다. 우선순위 큐로 구현한 다익스트라 알고리즘의 자세한 동작 과정을 살펴보자. 처음엔 원래의 다익스트라 알고리즘 구현과 동일하다. 이번에도 1번 노드를 출발 노드로 설정했으므로 1번 노드까지의 현재 최단 거리 값을 0으로 설정하고, 1번 노드에 대한 정보를 튜플 형태로 우선순위 큐에 넣는다. 이 때, 튜플의 첫 번째 원소를 거리로 설정하면 이 거리를 기준으로 거리가 더 작은 노드가 먼저 나올 수 있도록 큐가 구성된다. 이후 매 단계마다 우선순위 큐에 담긴 원소를 꺼내서 해당 노드에 대한 방문 여부를 확인하고 처리하는 과정을 반복한다. 이번 단계에서 큐로부터 꺼낸 원소는 아직 방문하지 않은 1번 노드이므로, 1번 노드를 방문 처리하고 인접한 노드에 대한 최단 거리 값을 갱신한다. 1번 노드와 인접한 노드는 2, 3, 4번 노드가 존재하고 각각 0+2, 0+5, 0+1의 최단 경로 값을 가지므로, 현재 테이블의 무한 값을 갱신하고 갱신한 노드를 우선순위 큐에 삽입한다. 여기서 큐에 삽입하는 노드는 최단 거리 값이 갱신된 노드만 해당된다는 점을 유의하자. 다음으로 우선순위 큐에서 다시 원소를 꺼내 방문 여부를 파악한다. 큐에서 꺼낸 4번 노드는 아직 방문하지 않은 노드이므로 방문처리한다. 그리고 인접한 3, 5번 노드의 최단 경로 값을 계산해 현재 값과 비교하고 테이블을 갱신한다. 3번 노드는 최단 경로 값이 1+3으로 현재 값 5보다 작고 5번 노드도 1+1로 현재 값 무한대보다 작으므로 두 노드 다 값을 갱신한다. 그 후 갱신한 두 노드를 우선순위 큐에 넣는다. 이 때, 우선순위 큐 속 노드들은 거리를 기준으로 오름차순 정렬되어 알고리즘 수행에 적합하게 재정렬됨을 확인할 수 있다. 이 다음도 위에서 살펴본 바와 마찬가지이다. 우선순위 큐에서 꺼낸 2번 노드를 방문처리하고 인접한 노드의 최단 거리 값을 계산한다. 이 경우는 계산된 최단 거리 값이 현재 값보다 크므로 따로 테이블을 갱신하지 않는다. 또한, 값이 갱신되지 않았기 때문에 우선순위 큐에도 노드들을 삽입하지 않는다. 다음으로 우선순위 큐에서 꺼낸 원소는 5번 노드이고 이를 방문하여 이와 인접한 노드들에 대해 최단 거리를 갱신한다. 이번에는 3, 6번 노드 모두 최단 거리가 갱신되고 우선순위 큐에 삽입된다. 이번에도 앞과 마찬가지로 우선순위 큐에서 원소를 꺼내고 3번 노드를 방문한다. 3번 노드의 인접 노드는 현재 값이 더 작기 때문에 따로 갱신되지 않고 우선순위 큐에 삽입되지 않는다. 다음 우선순위 큐에서 꺼낸 노드는 3번 노드이다. 3번 노드는 이미 방문한 적이 있으므로 처리하지 않고 넘어간다. 이 때, 따로 방문 여부를 체크할 리스트 테이블을 만들지 않고, 단순히 큐에서 꺼낸 노드의 거리 값과 최단 거리 테이블의 거리 값을 대소 비교해 큐에서 꺼낸 노드의 거리가 크면 무시하고 넘어가는 방법을 사용할 수 있다. 같은 방식으로 우선순위 큐에서 원소를 꺼내 확인한다. 나온 원소는 6번 노드이고 아직 방문하지 않았으므로 방문처리한다. 6번 노드는 인접한 노드가 없기 때문에 따로 갱신처리하지 않는다. 마지막으로 우선순위 큐에 남은 하나의 원소를 꺼낸다. 꺼낸 원소는 3번 노드이고 이미 방문한 적이 있기 때문에 다른 처리없이 넘어가며 알고리즘을 종료한다. 이 과정은 다른 시각에서 보면 기존의 테이블에 기록된 최단 거리 3보다 새로 꺼낸 최단 거리 5가 더 크기 때문에 넘어간다고 생각할 수도 있다. 개선된 다익스트라 알고리즘의 소스코드는 다음과 같다. # input # 6 11 # 1 # 1 2 2 # 1 3 5 # 1 4 1 # 2 3 3 # 2 4 2 # 3 2 3 # 3 6 5 # 4 3 3 # 4 5 1 # 5 3 1 # 5 6 2 # output # 0 # 2 # 3 # 1 # 2 # 4 import heapq import sys input = sys.stdin.readline INF = int(1e9) # 무한을 의미하는 값으로 10억을 설정 # 노드의 개수, 간선의 개수를 입력 받기 n, m = map(int, input().split()) # 시작 노드 번호 입력 받기 start = int(input()) # 각 노드에 인접한 노드 정보를 담는 리스트 만들기 graph = [[] for _ in range(n + 1)] # 최단 거리 테이블 값을 무한으로 초기화 distance = [INF] * (n + 1) # 모든 간선 정보를 입력 받기 for _ in range(m): a, b, c = map(int, input().split()) graph[a].append((b, c)) # a번 노드에서 b번 노드로 가는 비용이 c def dijkstra(start): q = [] # 시작 노드로 가는 최단 경로는 0으로 설정하고 큐에 삽입 heapq.heappush(q, (0, start)) distance[start] = 0 while q: # 큐가 빌 때까지 # 가장 최단 거리가 짧은 노드에 대한 정보 꺼내기 dist, now = heapq.heappop(q) # 현재 노드가 이미 처리된 적이 있는 노드라면 무시 if distance[now] < dist: continue # 현재 노드와 인접한 노드들을 확인 for i in graph[now]: cost = dist + i[1] # 현재 노드를 경유해 다른 노드로 가는 거리가 더 짧은 경우 if cost < distance[i[0]]: distance[i[0]] = cost heapq.heappush(q, (cost, i[0])) # 다익스트라 알고리즘 수행 dijkstra(start) # 모든 노드에 대해 최단 거리 출력 for i in range(1, n + 1): # 도달할 수 없는 경우 무한(INFINITY)으로 출력 if distance[i] == INF: print("INFINITY") # 도달할 수 있는 경우 거리를 출력 else: print(distance[i]) 위 알고리즘의 시간 복잡도는 O(ElogV) 로 앞의 간단히 구현된 다익스트라 알고리즘보다 훨씬 빠르다. 이러한 시간 복잡도는 직관적으로 와닿지 않을 수 있다. 하지만 잘 생각해보면 이를 이해할 수 있는데, 이미 방문한 노드의 경우 처리하지 않기 때문에 우선순위 큐에서 하나씩 노드를 꺼내 검사하는 반복문은 V이상의 횟수로 반복되지 않는다. 그리고 V번 반복하면서 실제 인접한 노드를 확인하는 작업은 간선의 개수 E만큼 수행된다. 따라서, 개선된 다익스트라 알고리즘은 이 E개의 원소를 우선순위 큐에 넣고 모두 빼내는 작업으로 단순화할 수 있고, 이는 시간 복잡도 O(ElogE)로 표현할 수 있다. 다만, 모든 노드가 다 연결되었다고 했을 때의 간선의 개수는 약 V²개이며 이는 E보다 항상 크다. 이를 log를 씌워서 생각해보면 V²은 log(V²) = 2log(V) = log(V), E는 log(E)가 되고 대소관계는 여전히 유지되어 log(V)는 log(E)보다 항상 크다. 따라서, O(ElogE)는 간단히 O(ElogV)로 표현할 수 있게 된다. 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2020-12-19
다이나믹 프로그래밍 (Dynamic Programming)
다이나믹 프로그래밍 (Dynamic Programming) 현대에서 컴퓨터를 사용해도 해결하기 어려운 문제는 최적의 해를 구하는데 매우 많은 시간을 요하거나 메모리 공간을 매우 많이 요구하는 문제들이다. 그런데 어떠한 문제는 메모리 공간을 조금 더 사용하면 연산 속도를 비약적으로 상승시킬 수 있는 방법이 있다. 메모리를 적절히 사용하여 수행 시간 효율을 비약적으로 상승시키는 방법을 다이나믹 프로그래밍(Dynamic Programming)이라고 하며 동적 계획법이라고도 부른다. 다이나믹 프로그래밍은 1. 큰 문제를 작게 나누고, 2. 같은 문제라면 한 번 씩만 풀어 문제를 효율적으로 해결하는 알고리즘이다. 즉, 다이나믹 프로그래밍은 다음의 두 조건을 갖췄을 때만 사용가능하다. 1. 최적 부분 구조 (Optimal Substructure): 큰 문제를 작은 문제로 나눌 수 있다. 2. 중복되는 부분 문제 (Overlapping Subproblem): 작은 문제에서 구한 정답은 그것을 포함하는 큰 문제에서도 동일하다. 다이나믹 프로그래밍으로 해결할 수 있는 대표적인 문제로 피보나치 수열이 있다. 피보나치 수열은 현재의 항을 이전 두 개 항의 합으로 설정해 끊임없이 이어지는 수열을 말한다. 피보나치 수열은 위와 같이 점화식 형태로 만들어 간결하게 표현할 수 있다. 점화식이란 인접한 항들 사이의 관계식을 말한다. 따라서, 위 식은 n번째 피보나치 수는 (n - 1)번째 피보나치 수와 (n - 2)번째 피보나치 수를 합한 것이고 단, 1번째 피보나치 수와 2번째 피보나치 수는 1임을 의미한다. 이러한 피보나치 수열은 단순히 재귀함수만으로도 표현할 수 있다. def fibonacci(n): if n == 1 or n == 2: return 1 return fibonacci(n - 1) + fibonacci(n - 2) print(fibonacci(4)) 하지만 재귀함수로만 구현한 경우, 시간복잡도가 O(2ⁿ)이 되어 n이 커질수록 수행 시간이 기하급수적으로 증가하기 때문에 심각한 문제가 발생한다. 이는 중복되는 부분 문제로 살펴볼 수 있는데, f(6)을 호출하는 예시에서는 f(2)가 총 5번 중복으로 계산되는 것을 알 수 있다. 이렇게 중복되는 부분을 또 다시 계산하는 비효율성을 다이나믹 프로그래밍으로 극복할 수 있다. 다이나믹 프로그래밍은 크게 재귀 함수를 이용하는 탑다운(Top-Down) 방식과 반복문을 이용하는 바텀업(Bottom-Up) 방식으로 나뉜다. 먼저, 메모이제이션(Memoization) 기법(탑다운 방식)을 사용해 피보나치 수열을 해결해보자. # 한 번 계산된 결과를 메모이제이션(Memoization)하기 위한 리스트 초기화 d = [0] * 100 # 피보나치 함수를 재귀함수로 구현 (탑다운 다이나믹 프로그래밍) def fibonacci(n): # 종료조건(1 혹은 2일 때 1을 반환) if n == 1 or n == 2: return 1 # 이미 계산한적 있는 문제라면 그대로 반환 if d[n]: return d[n] # 아직 계산하지 않은 문제라면 점화식에 따라서 피보나치 결과 반환 d[n] = fibonacci(n - 1) + fibonacci(n - 2) return d[n] print(fibonacci(99)) 메모이제이션이란 한 번 구한 결과를 메모리 공간에 기록해두고 같은 식을 다시 호출하면 기록한 결과를 그대로 사용하는 기법을 말한다. 캐싱(Caching)이라고도 부르는 이 방법은 특히 탑다운 방식을 이야기할 때 한정해서 쓰인다. (바텀업에서는 사용하지 않는 용어다.) 메모이제이션은 위의 코드처럼 한 번 구한 정보를 리스트에 저장하고 재귀적으로 다이나믹 프로그래밍을 수행하다가 같은 정보가 필요할 때, 구한 정답을 그대로 리스트에서 가져오도록 구현한다. 실제로 위 코드를 실행해보면 단순히 재귀로 구하는 것보다 훨씬 빠르게 답을 도출하는 것을 확인할 수 있다. 다음으로 다이나믹 프로그래밍의 전형적인 형태인 바텀업 방식으로 피보나치 수열을 구현해보자. # 앞서 계산된 결과를 저장하기 위한 DP 테이블 초기화 d = [0] * 100 # 첫 번째 피보나치 수와 두 번째 피보나치 수는 1 d[1] = 1 d[2] = 1 n = 99 # 피보나치 함수를 반복문으로 구현 (바텀업 다이나믹 프로그래밍) for i in range(3, n + 1): d[i] = d[i - 1] + d[i - 2] print(d[n]) 일반적으로 바텀업 방식은 탑다운 방식보다 성능이 좋아 다이나믹 프로그래밍의 전형적인 방법으로 알려져 있다. 바텀업 방식에서 사용되는 결과 저장용 리스트를 DP 테이블이라고 부르며, 이 DP 테이블을 이용해 반복적으로 피보나치 수열을 구현한다. 다이나믹 프로그래밍으로 f(6)을 호출하면 실질적으로 실행하는 것은 f(3), f(4), f(5), f(6)뿐이고 나머지는 기록한 정보를 가져오는 형태여서 큰 수행 속도 향상과 함께 O(N)의 시간 복잡도를 얻을 수 있다. 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2020-11-27
이진 탐색
순차 탐색 일반적으로 자주 사용되는 탐색으로, 앞에서부터 데이터를 하나씩 차례대로 확인하며 리스트 안에 있는 특정 데이터를 찾는 방법이다. 보통 정렬되지 않은 리스트에서 데이터를 찾을 때 사용한다. 충분한 시간이 있다면 데이터가 아무리 많아도 항상 원하는 데이터를 찾을 수 있는 것이 장점이다. 시간 복잡도는 최악의 경우 O(N)을 보장한다. # 순차 탐색 함수 구현 def sequential_search(target, array): for i in range(len(array)): if array[i] == target: return i + 1 # 현재 위치 반환 (인덱스이므로 1을 더함) array = [4, 5, 1, 3, 2] target = 3 print(sequential_search(target, array)) 4 이진 탐색 이진 탐색은 탐색 범위를 절반씩 좁혀가며 데이터를 탐색하는 방법이다. 순차 탐색과는 다르게 배열 내부의 데이터가 정렬된 상태여야만 사용 가능하다. 이진 탐색에는 탐색하고자하는 범위의 시작점, 끝점 그리고 중간점을 위치를 나타내는 변수로서 사용한다. 찾으려는 데이터와 중간점 위치에 있는 데이터를 반복적으로 비교해서 원하는 데이터를 찾는 것이 이진 탐색 과정이다. 한 번 확인할 때마다 확인하는 원소의 개수가 대략 절반씩 줄어든다는 점에서 시간 복잡도가 O(logN)이다. 1. 재귀함수를 이용한 이진 탐색 n = 10 target = 7 array = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19] def binary_search(array, target, start, end): if start > end: return None mid = (start + end) // 2 if array[mid] == target: return mid elif array[mid] > target: return binary_search(array, target, start, mid - 1) else: return binary_search(array, target, mid + 1, end) result = binary_search(array, target, 0, n - 1) if result == None: print("원소가 존재하지 않습니다.") else: print(result + 1) 4 2. 반복문을 이용한 이진 탐색 n = 10 target = 7 array = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19] def binary_search(array, target, start, end): while start <= end: mid = (start + end) // 2 # 찾은 경우 중간점 인덱스 반환 if array[mid] == target: return mid # 중간점의 값보다 찾고자 하는 값이 작은 경우 왼쪽 확인 elif array[mid] > target: end = mid - 1 # 중간점의 값보다 찾고자 하는 값이 큰 경우 오른쪽 확인 else: start = mid + 1 return None result = binary_search(array, target, 0, n - 1) if result == None: print("원소가 존재하지 않습니다.") else: print(result + 1) 4 파이썬 이진 탐색 라이브러리 bisect - bisect_left(array, x): 정렬된 순서를 유지하면서 배열 array에 x를 삽입할 가장 왼쪽 인덱스를 반환 - bisect_right(array, x): 정렬된 순서를 유지하면서 배열 array에 x를 삽입할 가장 오른쪽 인덱스를 반환 from bisect import bisect_left, bisect_right a = [1, 2, 4, 4, 8] x = 4 print(bisect_left(a, x)) # 정렬된 순서를 유지하면서 배열 a에 x를 삽입할 가장 왼쪽 인덱스를 반환 print(bisect_right(a, x)) # 정렬된 순서를 유지하면서 배열 a에 x를 삽입할 가장 오른쪽 인덱스를 반환 2 4 - 값이 특정 범위에 속하는 데이터 개수 구하기 from bisect import bisect_left, bisect_right # 값이 [left_value, right_value]인 데이터의 개수를 반환하는 함수 def count_by_range(a, left_value, right_value): right_index = bisect_right(a, right_value) left_index = bisect_left(a, left_value) return right_index - left_index a = [1, 2, 3, 3, 3, 3, 4, 4, 8, 9] # 값이 4인 데이터 개수 출력 print(count_by_range(a, 4, 4)) # 값이 [-1, 3] 범위에 있는 데이터 개수 출력 print(count_by_range(a, -1, 3)) 2 6 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2020-11-18
정렬 알고리즘
정렬(Sorting)이란? 데이터를 특정한 기준에 따라서 순서대로 나열하는 것을 의미한다. 선택 정렬 (Selection Sort) 데이터가 무작위로 여러 개 있을 때, 가장 작은 데이터를 선택해 앞으로 보내는 과정을 반복하는 정렬이다. 가장 작은 데이터를 선택해 맨 앞에 있는 데이터와 바꾸고, 다음으로 작은 데이터를 골라 앞에서 두 번째 데이터와 바꾸는 과정을 끝까지 반복해 데이터를 정렬한다. 선택 정렬을 파이썬으로 구현하면 다음과 같다. # 배열의 원소를 오름차순으로 정렬 array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8] for i in range(len(array)): min_index = i # 가장 작은 원소의 인덱스 for j in range(i+1, len(array)): if array[min_index] > array[j]: min_index = j array[i], array[min_index] = array[min_index], array[i] print(array) 선택 정렬은 다른 더 빠른 정렬 알고리즘들에 비해 비효율적인 면이 있다. 선택 정렬의 시간 복잡도는 이중 for문을 수행한다는 점에서 직관적으로 O(N²)임을 알 수 있다. 삽입 정렬 (Insertion Sort) 삽입 정렬은 특정 데이터를 적절한 위치에 삽입하여 정렬하는 알고리즘이다. 이는 특정 데이터의 앞까지 데이터들은 정렬되어 있다고 가정하고, 정렬된 데이터들 사이에서 적절한 위치를 골라 해당 데이터를 삽입하는 방식으로 진행된다. 삽입 정렬을 파이썬으로 구현하면 다음과 같다. array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8] for i in range(1, len(array)): for j in range(i, 0, -1): if array[j] < array[j - 1]: # 한 칸씩 왼쪽으로 이동 array[j], array[j - 1] = array[j - 1], array[j] else: # 자신보다 작은 데이터를 만나면 그 위치에서 멈춤 break print(array) 삽입 정렬은 선택 정렬에 비해 실행 시간의 측면에서 더 효율적인 알고리즘으로 알려져 있고, 특히 데이터가 거의 정렬되어 있을 때 매우 빠르게 동작하는 특징이 있다. 삽입 정렬의 시간 복잡도는 이중 for문이 사용된 점을 보고 O(N²)임을 알 수 있지만 최선의 경우 O(N)을 가진다. 데이터가 거의 정렬되어 있는 상황이라면, 퀵정렬 알고리즘보다도 빠르게 동작한다. 퀵 정렬 (Quick Sort) 퀵정렬은 일반적으로 가장 많이 사용되는 알고리즘이자 대부분의 프로그래밍 언어 정렬 라이브러리의 근간이 되는 알고리즘이다. 기준 데이터(Pivot, 피벗)를 설정하고 그 기준보다 큰 데이터와 작은 데이터의 위치를 교환한 후, 리스트를 반으로 나누는 방식(분할)을 반복해 정렬을 진행한다. 파이썬으로 이를 구현하면 다음과 같다. 여기서 피벗을 정하는 방식은 리스트의 첫 번째 데이터를 피벗으로 정하는 호어 분할(Hoare Partition)을 바탕으로 한다. array = [5, 7, 9, 0, 3, 1, 6, 2, 4, 8] def quick_sort(array, start, end): if start >= end: # 원소가 1개면 종료 return pivot = start left = start + 1 right = end # 엇갈릴 때까지 반복 while left <= right: # 피벗보다 큰 데이터를 찾을 때까지 반복 while left <= end and array[left] <= array[pivot]: left += 1 # 피벗보다 작은 데이터를 찾을 때까지 반복 while right > start and array[right] >= array[pivot]: right -= 1 if left > right: # 엇갈렸다면 작은 데이터와 피벗을 교체 array[right], array[pivot] = array[pivot], array[right] else: # 엇갈리지 않았다면 작은 데이터와 큰 데이터를 교체 array[left], array[right] = array[right], array[left] # 분할 이후 왼쪽 부분과 오른쪽 부분에서 각각 정렬 수행 quick_sort(array, start, right - 1) quick_sort(array, right + 1, end) quick_sort(array, 0, len(array) - 1) print(array) 퀵정렬의 평균 시간 복잡도는 O(NlogN)이다. 데이터를 절반씩 분할하며 진행한다고 가정하면, 기하급수적으로 분할 횟수가 감소함을 알 수 있다. 퀵정렬은 데이터가 무작위로 입력되는 경우 빠르게 동작할 가능성이 높지만, 이미 데이터가 정렬되어 있는 경우에는 최악의 경우 O(N²)의 시간 복잡도를 가지며 느리게 동작한다. 하지만, 대부분의 정렬 라이브러리는 피벗값 설정 로직을 추가해 최악의 경우에도 O(NlogN)의 시간 복잡도를 보장하므로 크게 신경쓰지 않아도 된다. 계수 정렬 (Count Sort) 계수 정렬 알고리즘은 특정 조건(모든 데이터가 0을 포함한 양의 정수로 표현될 수 있어야 함)에 부합해야 한다는 제약이 있지만, 조건이 갖춰지면 매우 빠르게 동작하는 정렬 알고리즘이다. 데이터의 모든 범위를 담을 수 있는 크기의 리스트를 선언해, 데이터를 직접 세어 리스트에 기록한 후 정렬한다. 그러므로 가장 큰 데이터와 가장 작은 데이터의 차이가 작을 때(1,000,000을 넘지 않을 때) 효과적으로 사용할 수 있다. # 모든 원소 값은 0보다 크거나 같음 array = [7, 5, 9, 0, 3, 1, 6, 2, 9, 1, 4, 8, 0, 5, 2] # 모든 원소 값이 0으로 초기화 된 모든 범위를 포함하는 리스트 생성 count = [0] * (max(array) + 1) for i in range(len(array)): count[array[i]] += 1 # 각 데이터에 해당하는 인덱스의 값 증가 # 리스트의 정보를 확인하여 그 값만큼 출력 반복 for i in range(len(count)): for j in range(count[i]): print(i, end=' ') 모든 데이터가 양의 정수(0을 포함한)로 표현될 수 있다면, 데이터의 개수가 N, 데이터 중 최댓값이 K일 때, 최악의 경우에도 O(N + K)의 시간 복잡도를 보장한다. 공간 복잡도 역시 O(N + K)이다. 또한, 데이터의 크기가 한정되어 있고 많이 중복되어 있을수록 유리하다. 정렬 알고리즘 비교 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2020-11-17
DFS(Depth-First Search) & BFS(Breadth-First Search)
그래프 탐색 하나의 자료구조로서 그래프(Graph)는 데이터와 데이터 사이의 관계를 잘 표현해주는 자료구조이다. 그래프는 기본적으로 데이터가 담기는 노드(Node)와 데이터 사이를 연결하는 간선(Edge)으로 이루어져있다. 노드는 정점(Vertex)이라고도 불린다. 그래프 탐색은 하나의 노드를 시작으로 다수의 노드를 방문하는 것을 말하며, 간선으로 연결되어 있는 두 노드는 서로 ‘인접’해 있다고 한다. DFS (Depth-First Search, 깊이 우선 탐색) DFS는 그래프의 깊은 부분을 우선적으로 탐색하는 알고리즘이다. 특정한 경로로 먼저 최대한 깊숙이 탐색한 후, 다시 돌아와 다른 경로를 탐색한다. DFS는 스택이나 재귀함수를 활용해 구현하며, 기본 순서는 다음과 같다. 탐색 시작 노드를 스택에 삽입하고 방문 처리한다. 스택의 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 인접 노드를 스택에 넣고 방문 처리한다. 방문하지 않은 인접 노드가 없으면 스택에서 최상단 노드를 꺼낸다. 2번의 과정을 더 이상 수행할 수 없을 때까지 반복한다. 위와 같은 그래프를 DFS로 탐색 시, 방문 순서는 1 - 2 - 7 - 6 - 8 - 3 - 4 - 5 이다. 파이썬으로 이를 구현하면 다음과 같다. def dfs(graph, v, visited): # 현재 노드 방문 visited[v] = True print(v, end=' ') # 현재 노드의 인접 노드를 재귀적으로 방문 for i in graph[v]: if not visited[i]: dfs(graph, i, visited) # 각 노드가 연결된 정보 표현 graph = [ [], [2, 3, 8], [1, 7], [1, 4, 5], [3, 5], [3, 4], [7], [6, 8], [1, 7] ] # 각 노드의 방문 정보 표현 visited = [False] * 9 # DFS 함수 호출 dfs(graph, 1, visited) BFS (Breadth-First Search, 너비 우선 탐색) BFS는 가까운 노드부터 탐색하는 알고리즘이다. BFS는 큐 자료구조를 활용해 구현하는 것이 일반적이며 다음과 같은 절차로 이루어진다. 탐색 시작 노드를 큐에 삽입하고 방문 처리를 한다. 큐에서 노드를 꺼내 해당 노드의 인접 노드 중에서 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리를 한다. 2번의 과정을 더 이상 수행할 수 없을 때까지 반복한다. 위 그래프를 BFS로 탐색하면 1 - 2 - 3 - 8 - 7 - 4 - 5 - 6 이다. 이를 파이썬으로 구현하면 다음과 같다. from collections import deque # BFS 메서드 정의 def bfs(graph, start, visited): queue = deque([start]) visited[start] = True # 큐가 빌 때까지 반복 while queue: # 큐에서 하나의 원소를 뽑아 출력 v = queue.popleft() print(v, end=' ') # 아직 방문하지 않은 인접 노드들을 큐에 삽입하고 방문 처리 for i in graph[v]: if not visited[i]: queue.append(i) visited[i] = True # 각 노드가 연결된 정보 표현 graph = [ [], [2, 3, 8], [1, 7], [1, 4, 5], [3, 5], [3, 4], [7], [6, 8], [1, 7] ] visited = [False] * 9 bfs(graph, 1, visited) Reference gimtommang11 자료구조 그래프 3. DFS & BFS 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2020-10-20
재귀 함수(Recursive Function)
자기 자신을 다시 호출하는 함수를 의미한다. 이는 어린 시절 수학 과목을 공부할 때 마주하는 프랙털(Fractal) 구조와 비슷하다. 프랙털 구조에서는 같은 모양의 도형이 무한히 반복되는 형태를 볼 수 있다. 같은 모양이 무한히 반복되는 삼각형 재귀 함수가 자기 자신을 호출하는 것도 무한히 반복되는 양상을 보인다. 따라서, 재귀 함수를 사용할 때는 항상 종료 조건을 명시해 함수의 끝을 만들어야 한다. 또한, 재귀 함수는 컴퓨터 내부 메인 메모리의 스택 공간에 적재된다. 이 말은 재귀 함수가 스택 자료구조와 내부적으로 동일함을 의미한다. 따라서, DFS와 같이 스택 자료구조를 사용해야 하는 알고리즘은 재귀 함수를 통해 간편히 구현할 수 있다. 다음은 파이썬으로 구현한 간단한 재귀 함수이다. def recursive_function(n): if n == 100: return print(n, "번째 재귀함수에서", n+1, "번째 재귀함수를 호출합니다.") recursive_function(n+1) print(n, "번째 재귀함수가 종료됩니다.") recursive_function(1) 위 재귀 함수는 100번째에 호출될 때 가장 나중에 호출되었던 함수들부터 모든 함수들이 하나씩 종료되는 스택과 같은 구조를 보인다. 본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서 ‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
Computer Science
· 2020-10-19
스택(Stack)과 큐(Queue)
스택(Stack) 스택 자료구조는 상자 쌓기와 비슷하다. 차곡 차곡 아래서부터 위로 상자를 쌓아 나가면, 상자를 뺄 때는 상자들이 무너지지않게 맨 위의 가장 나중에 쌓은 상자부터 빼야 한다. 스택도 마찬가지다. 가장 나중에 들어온 데이터를 가장 먼저 빼는 후입선출(LIFO: Last In First Out) 구조를 가진다. 상자 쌓기 게임 Dropping Box 파이썬에서는 별도의 라이브러리 없이 기본 리스트 자료형으로 스택을 쉽게 구현할 수 있다. # Stack으로 삽입(5) - 삽입(2) - 삽입(3) - 삽입(7) - 삭제() - 삽입(1) - 삽입(4) - 삭제() 구현 stack = [] stack.append(5) stack.append(2) stack.append(3) stack.append(7) stack.pop() stack.append(1) stack.append(4) stack.pop() # 가장 윗 부분부터 출력 print(stack[::-1]) 큐(Queue) 큐는 우리에게 상당히 익숙한 자료구조다. 흔히 얘기하는 선착순 혹은 놀이공원 대기줄처럼 먼저 도착한 데이터가 먼저 그 대기줄을 빠져나가게 된다. 이러한 구조를 선입선출(FIFO: First In First Out) 구조라고 한다. 이러한 큐는 구조적 속성상 ‘공정한 자료구조’라고도 불린다. 흔한 놀이공원의 대기 줄.jpg 큐를 파이썬으로 구현할 때는 collections 라이브러리의 deque 자료구조가 유용하다. 리스트 자료형보다 데이터의 삽입 및 삭제가 빨라 구현이 용이하다. # 삽입(5) - 삽입(2) - 삽입(3) - 삽입(7) - 삭제() - 삽입(1) - 삽입(4) - 삭제() from collections import deque queue = deque() queue.append(5) queue.append(2) queue.append(3) queue.append(7) queue.popleft() queue.append(1) queue.append(4) queue.popleft() print(queue) # 먼저 들어온 순서대로 출력 queue.reverse() print(queue) # 역순으로 출력
Computer Science
· 2020-10-18
<
>
Touch background to close