
탐색(Search)이란 여러 데이터 중에서 원하는 값을 찾는 작업을 의미한다.
예를 들어 리스트 안에서 특정 숫자를 찾거나, 전화번호부에서 이름을 검색하거나, 지도에서 목적지까지 가는 길을 찾는 것도 탐색에 해당한다.
컴퓨터 프로그램에서는 데이터가 많아질수록 원하는 정보를 빠르게 찾는 것이 중요하다. 이때 어떤 방법으로 데이터를 찾을지 정해 놓은 절차를 탐색 알고리즘이라고 한다.
탐색은 거의 모든 프로그램에서 사용된다. 데이터가 적을 때는 단순히 처음부터 끝까지 확인해도 괜찮지만, 데이터가 많아지면 탐색 방법에 따라 실행 속도가 크게 달라진다.
예를 들어 학생 10명 중 한 명을 찾는 것은 어렵지 않지만, 회원 100만 명 중 특정 사용자를 찾는다면 효율적인 탐색 방법이 필요하다. 따라서 탐색 알고리즘은 프로그램의 성능을 높이는 데 매우 중요한 역할을 한다.
선형 탐색은 데이터를 처음부터 끝까지 하나씩 확인하면서 원하는 값을 찾는 탐색 방법이다.
가장 단순한 탐색 알고리즘이며, 데이터가 정렬되어 있지 않아도 사용할 수 있다.
# linear_search
def linear_search(arr, value):
for idx in range(len(arr)):
if arr[idx] == value:
return idx
return -1
student_numbers = [202601, 202614, 202603, 202622, 202610]
target_number = 202622
result = linear_search(student_numbers, target_number)
if result != -1:
print(f"{target_number}번 학생은 출석부 {result + 1}번째에 있습니다.")
else:
print(f"{target_number}번 학생을 찾을 수 없습니다.")
202622번 학생은 출석부 4번째에 있습니다.
| 경우 | 시간 복잡도 | 설명 |
|---|---|---|
| 최선 | O(1) | 첫 번째 요소가 찾는 값인 경우 |
| 평균 | O(n) | 중간 정도 위치에서 찾는 경우 |
| 최악 | O(n) | 마지막에 있거나 없는 경우 |
이진 탐색은 정렬된 데이터에서 가운데 값을 기준으로 탐색 범위를 절반씩 줄여 가며 원하는 값을 찾는 탐색 방법이다.
선형 탐색은 앞에서부터 하나씩 확인하지만, 이진 탐색은 한 번 비교할 때마다 탐색할 데이터의 절반을 버릴 수 있다. 그래서 데이터가 많을수록 선형 탐색보다 훨씬 빠르다.
이진 탐색은 가운데 값을 기준으로 왼쪽 또는 오른쪽 중 한쪽을 버리면서 탐색한다.
이때 데이터가 정렬되어 있어야만 찾는 값이 어느 쪽에 있는지 판단할 수 있다.
예를 들어 오름차순 정렬된 데이터에서 찾는 값이 가운데 값보다 작다면, 찾는 값은 반드시 왼쪽에 있다. 하지만 데이터가 정렬되어 있지 않다면 왼쪽에 있는지 오른쪽에 있는지 알 수 없기 때문에 이진 탐색을 사용할 수 없다.
# binary_search.py
def binary_search(arr, value):
left = 0
right = len(arr) - 1
while(left <= right):
middle = (left + right) // 2
if(arr[middle] == value):
return middle
elif(arr[middle] > value):
right = middle - 1
else:
left = middle + 1
return -1
# binary_search 는 정렬된 배열에서만 사용가능
product_numbers = [1001, 1010, 1024, 1040, 1055, 1072, 1100, 1135]
target_product = 1072
result = binary_search(product_numbers, target_product)
if result != -1:
print(f"{target_product}번 상품은 {result}번 인덱스에 있습니다.")
else:
print(f"{target_product}번 상품을 찾을 수 없습니다.")
1072번 상품은 5번 인덱스에 있습니다.
선형 탐색은 처음부터 끝까지 하나씩 확인하지만, 이진 탐색은 가운데 값을 기준으로 탐색 범위를 계속 절반씩 줄인다.
따라서 데이터가 적을 때는 큰 차이가 없을 수 있지만, 데이터가 많아질수록 이진 탐색이 훨씬 빠르다.
| 경우 | 시간 복잡도 | 설명 |
|---|---|---|
| 최선 | O(1) | 가운데 값이 찾는 값인 경우 |
| 평균 | O(log n) | 탐색 범위를 절반씩 줄이는 경우 |
| 최악 | O(log n) | 끝까지 범위를 줄여야 하는 경우 |
| 비교 항목 | 선형 탐색(Linear Search) | 이진 탐색(Binary Search) |
|---|---|---|
| 탐색 방식 | 처음부터 끝까지 순서대로 확인 | 가운데 값을 기준으로 범위를 절반씩 줄임 |
| 속도 차이 | 데이터가 많으면 느림 | 데이터가 많을수록 빠름 |
| 시간 복잡도 | O(n) | O(log n) |
| 정렬 필요 여부 | 필요 없음 | 반드시 필요 |
| 구현 난이도 | 쉬움 | 선형 탐색보다 약간 복잡함 |
| 사용하기 좋은 상황 | 데이터가 적거나 정렬되어 있지 않은 경우 | 데이터가 정렬되어 있고 개수가 많은 경우 |
| 예시 | 작은 리스트에서 값 찾기 | 정렬된 회원 번호, 상품 번호 검색 |
DFS(Depth First Search)는 그래프나 트리에서 한 방향으로 갈 수 있는 곳까지 깊게 탐색한 뒤, 더 이상 갈 곳이 없으면 이전 위치로 돌아와 다른 경로를 탐색하는 방법이다.
DFS는 이름 그대로 깊이를 우선으로 탐색한다.
DFS는 가장 나중에 발견한 노드를 먼저 탐색하는 방식이다.
이 특징은 후입선출(LIFO, Last In First Out) 구조인 스택과 잘 맞는다.
DFS는 직접 스택을 사용해서 구현할 수도 있고, 재귀 함수를 사용해서 구현할 수도 있다. 재귀 함수는 내부적으로 함수 호출 스택을 사용하기 때문에 DFS와 잘 어울린다.
스택은 데이터를 위에 쌓고, 가장 위에 있는 데이터부터 꺼내는 구조이다.
push A push B push C
┌───┐ ┌───┐ ┌───┐
│ A │ │ B │ │ C │ ← 가장 나중에 들어온 C가 먼저 나감
└───┘ ├───┤ ├───┤
│ A │ │ B │
└───┘ ├───┤
│ A │
└───┘
pop 순서: C → B → A
DFS는 한 경로를 깊게 들어가다가 더 이상 갈 곳이 없으면 이전 위치로 돌아온다.
이때 최근에 방문한 노드부터 되돌아가므로 스택 구조와 잘 맞는다.
# DFS(Depth First Search)
def DFS(graph, start, visited = None):
if visited is None:
visited = []
visited.append(start)
for node in graph[start]:
if node not in visited:
DFS(graph, node, visited)
return visited
graph = dict()
graph['A'] = ['B', 'C']
graph['B'] = ['A', 'D']
graph['C'] = ['A', 'G', 'H', 'I']
graph['D'] = ['B', 'E', 'F']
graph['E'] = ['D']
graph['F'] = ['D']
graph['G'] = ['C']
graph['H'] = ['C']
graph['I'] = ['C', 'J']
graph['J'] = ['I']
visited = []
visited = DFS(graph, 'A', visited)
print(visited)
['A', 'B', 'D', 'E', 'F', 'C', 'G', 'H', 'I', 'J']
BFS(Breadth First Search)는 그래프나 트리에서 시작 노드와 가까운 노드부터 차례대로 탐색하는 방법이다.
DFS가 한 방향으로 깊게 들어가는 방식이라면, BFS는 현재 위치에서 가까운 노드들을 먼저 모두 확인한 뒤 다음 단계로 이동한다.
BFS는 먼저 발견한 노드를 먼저 탐색한다.
이 특징은 선입선출(FIFO, First In First Out) 구조인 큐와 잘 맞는다.
따라서 BFS를 구현할 때는 보통 큐를 사용한다. 파이썬에서는 collections.deque를 사용하면 큐를 효율적으로 구현할 수 있다.
큐는 한쪽으로 데이터를 넣고, 먼저 들어온 데이터부터 꺼내는 구조이다.
enqueue A enqueue B enqueue C
앞(front) 뒤(rear)
↓ ↓
┌───┐ ┌───┬───┐ ┌───┬───┬───┐
│ A │ │ A │ B │ │ A │ B │ C │
└───┘ └───┴───┘ └───┴───┴───┘
dequeue 순서: A → B → C
BFS는 시작점에서 가까운 노드부터 차례대로 탐색한다.
먼저 발견한 노드를 먼저 처리해야 하므로 큐 구조와 잘 맞는다.
# BFS(Breadth First Search)
from collections import deque
def BFS(graph, start, visited = None):
visited = []
queue = deque([start])
visited.append(start)
while queue:
node = queue.popleft()
for next_node in graph[node]:
if next_node not in visited:
visited.append(next_node)
queue.append(next_node)
return visited
graph = dict()
graph['A'] = ['B', 'C']
graph['B'] = ['A', 'D']
graph['C'] = ['A', 'G', 'H', 'I']
graph['D'] = ['B', 'E', 'F']
graph['E'] = ['D']
graph['F'] = ['D']
graph['G'] = ['C']
graph['H'] = ['C']
graph['I'] = ['C', 'J']
graph['J'] = ['I']
result = BFS(graph, "A")
print(result)
['A', 'B', 'C', 'D', 'G', 'H', 'I', 'E', 'F', 'J']
| 비교 항목 | DFS | BFS |
|---|---|---|
| 탐색 방식 | 한 방향으로 갈 수 있는 곳까지 깊게 탐색 | 가까운 노드부터 넓게 탐색 |
| 사용하는 자료구조 | 스택 또는 재귀 함수 | 큐 |
| 자료구조 특징 | 후입선출(LIFO) | 선입선출(FIFO) |
| 특징 | 깊은 경로를 먼저 확인 | 같은 거리의 노드를 먼저 확인 |
| 장점 | 구현이 간단하고 경로 탐색에 적합 | 최단 거리 탐색에 적합 |
| 단점 | 최단 경로를 보장하지 않음 | 메모리를 많이 사용할 수 있음 |
| 사용 예시 | 미로의 모든 경로 탐색, 백트래킹 | 최단 경로, 최소 이동 횟수 |
탐색 알고리즘은 여러 데이터 중 원하는 값을 찾기 위한 방법이다.
선형 탐색은 단순하고 정렬이 필요 없지만 데이터가 많으면 느리다. 이진 탐색은 정렬된 데이터에서 매우 빠르게 동작하지만, 반드시 정렬이 필요하다.
DFS와 BFS는 리스트에서 값을 찾는 탐색과 달리 그래프나 트리 구조를 탐색할 때 많이 사용된다. DFS는 깊게 탐색하고, BFS는 가까운 곳부터 넓게 탐색한다. 문제의 목적이 전체 경로 탐색인지, 최단 거리 탐색인지에 따라 적절한 알고리즘을 선택해야 한다.