비선형적인 탐색 알고리즘
DFS(깊이우선탐색) : 현재 정점에서 갈 수 있는 점들까지 들어가면서 탐색 → 스택 또는 재귀함수
로 구현
BFS(너비우선탐색) : 현재 정점에 연결된 가까운 점들부터 탐색
→ 큐
를 이용하여 구현
DFS appendix - 백트래킹 Backtracking
후보를 구축해 나아가다 가능성이 없는 후보를 포기(Backtrack)해 정답을 찾아가는 범용 알고리즘
경로를 탐색하다가 더 갈 수 없으면 왔던 길을 되돌아가 다른 길을 찾는다.
갔다 되돌아오기를 반복 : 운이 좋으면 시행착오를 덜 거칠 수 있지만 최악의 경우에는 모든 경우를 다 거친 다음에 도착할 수 있다.
주로 재귀로 구현
시간복잡도
기본적으로 모든 노드를 탐색한다는 점에서 시간 복잡도는 동일하다.
N : 노드, E : 간선
👨🏻💻 인접 리스트 : $O(N+E)$ 인접 행렬 : $O(N^2)$일반적으로 간선의 크기가 에 비해 상대적으로 작기에 인접 리스트 방식이 효율적이다.
단순히 모든 정점을 방문하는 문제라면 DFS, BFS 두 가지 방법 아무거나 사용해도 문제 없다.
예를 들어 각 정점에 숫자가 있고 a부터 b까지 가는 경로를 구하는데 경로에서 특정 숫자가 있으면 안 된다는 문제 등 같은 경우는 DFS를 사용해야 한다. (BFS는 경로의 특징을 가지지 못함!!
)
미로 찾기 같은 최단 거리 문제는 BFS가 유리하다.
DFS의 경우 처음에 깊게 들어간 경로가 최단 거리가 아닐 가능성이 매우 높지만, BFS는 현재 노드에서 가까운 곳부터 검색하기 때문에 BFS가 유리함
Appendix)
DFS 재귀
DFS(G, v)
label v as discovered
for all directed edges from v to w that are in G.adjacentEdges(v) do
if vertex w is not labeled as discovered then
recursively call DFS(G, w)
DFS 스택
DFS-iterative(G, v)
let S be a stack
S.push(v)
while S is not empty do
v = S.pop()
if v is not labeled as discovered then
label v as discovered
for all edges from v to w in G.adjacentEdges(v) do
S.push(w)
BFS 큐
BFS(G, start_v)
let Q be a queue
label start_v as discovered
Q.enqueue(start_v)
while Q is not empty do
v := Q.dequeue()
if v is the goal then
return v
for all edges from v to w in G.adjacentEdges(v) do
if w is not labeled as discovered then
label w as discovered
w.parent := v
Q.enqueue(w)
정렬되어 있는 리스트에서 탐색 범위를 절반씩 좁혀가며 데이터를 탐색하는 방법이다.
핵심은 리스트가 먼저 정렬이 되어있어야 한다는 것이다.
→ 재귀와 반복문
으로 구현할 수 있다.
시간복잡도
시간이다. ( 시간임)
Python에서는 이진 탐색을 쉽게 구현하기 위해 bisect
라는 내장 모듈을 지원한다.
TIP
문제 조건이 10억
을 넘어가면 이분 탐색으로 풀 수 밖에 없다.
재귀 버전
BinarySearch(A[0..N-1], value, low, high) {
if (high < low)
return -1 // x가 배열에 없을 때
mid = (low + high) / 2 // 중간 요소 설정
if (A[mid] > value)
return BinarySearch(A, value, low, mid-1) // 오른쪽 배열 버리고 다시 이진 탐색 (recursive)
else if (A[mid] < value)
return BinarySearch(A, value, mid+1, high) // 왼쪽 배열 버리고 다시 이진 탐색 (recursive)
else
return mid // 위치값 리턴
}
반복문 버전
binarySearch(A[0..N-1], value) {
low = 0
high = N - 1 // 시작(low)과 끝(high)값 설정
while (low <= high) {
mid = (low + high) / 2 // 중간 요소 설정
if (A[mid] > value)
high = mid - 1 // 오른쪽 배열 버리고 다시 이진 탐색 (while문)
else if (A[mid] < value)
low = mid + 1 // 왼쪽 배열 버리고 다시 이진 탐색 (while문)
else
return mid // found k
}
return -1 // not found k
}
큰 문제를 작은 문제로 나누어 푸는 문제를 말한다.
분할 정복과 비슷하지만 차이점은 작은 문제의 반복이 일어나는지 안일어나는지
이다. 분할 정복은 큰 문제를 해결하기 어려워 작은 문제로 나누어푸는 방법이고 DP는 작은 부문들이 반복되는 것을 이용해 풀이하는 방법이다.
메모이제이션은 작은 문제들이 반복되고 이 결과값이 항상 같으므로 이를 저장해놓고 재사용을 하는 것을 Memoization
이라고 한다.
가장 대표적인 예시는 피보나치 수열
이다.
Bottom-up : 작은 문제부터 큰 문제로 구해 나아가는 방법
def fibonacci_bottom_up(n):
if n <= 1:
return n
a = 0
b = 1
for i in range(0, n-1):
next = a + b
a = b
b = next
return next
Top-down : 재귀 함수로 구현하는 경우가 대부분 이 방식이다. 큰 문제를 풀 때 작은 문제가 아직 풀리지 않았다면 그 때 작은 문제를 해결한다.
def fibonacci_top_down(n):
if memo[n] > 0:
return memo[n]
if n <= 1:
memo[n] = n
return memo[n]
else:
memo[n] = fibonacci_top_down(n - 1) + fibonacci_top_down(n - 2)
return memo[n]
Bottom-up vs Top-down?
둘을 단순 비교한다면 우위가 없다. 일반적으로 top-down 방식이 가독성이 좋고 bottom-up 방식의 경우 풀기 쉽지만 가독성이 떨어진다고 이야기를 한다. 그냥 편한 방식으로 풀자!
유형별 정리는 각자 하이퍼링크를 달아두었으니 참고하시길 바랍니다😁
재귀 방식은 별로 적합하지 않음