Blind Search란, 현재 상태에서 목표 상태까지의 Step 개수(Path Cost)를 모르는 것을 의미한다. 다시 말해, Blind Search에서는 현재 상태에서 목표 상태까지 얼마나 남았는지 보여주는 함수인 H(n)을 계산하지 않는다. 이러한 특성으로 인해 최적 해에 도달하기 어렵다는 단점이 존재한다.
Blind Search는 경험적으로 분석을 할 수 없기에 Goal State로 탐색이 진행될 때 각 Search들만의 규칙을 가지고 탐색하긴 하지만, 현재 State에서 Goal까지의 Path Cost를 구할 수 없기에 모든 방향으로 뻗어나가고, 무한 루프에 빠질 가능성이 존재한다.
Blind Search의 예시에는, Breath-First Search (너비 우선 탐색), Uniform-Cost Search (일정 비용 탐색), Depth-First Search (깊이 우선 탐색), Depth-Limited Search (깊이 제한 선택), Iterative Deepening Search (점차적 깊이 제한 탐색), Bidirectional Search (양방향 탐색) 등이 존재한다.
Heuristic Search란, 어떤 상태가 목표 상태로 가는 데 적합한지를 아는 것을 의미한다. 이는 맹목적 탐색의 단점을 극복하기 위해 탄생한 탐색법으로, Solution을 좀 더 효율적으로 찾을 수 있다는 장점이 존재하며, 대체로 Uninformed Search보다 효율적이다.
Best-First Search, A* Search, Hill Climbing이 Heuristic Search의 대표적인 예시이며, 이때, Best-First Search가 가장 기본적인 접근 방법이며, 대다수의 경험적 탐색 알고리즘의 기반이 된다.
** 맹목적 탐색과 경험적 탐색은 여행에서 길을 찾는 것과 고향에서 길을 찾는 것의 차이로 이해할 수 있음. (여행 - 지도 앱을 통해 길을 따라감 / 고향 - 어떤 버스가 빠르고, 어디서 환승하는 게 더 나은지에 대한 판단이 가능함)
from collections import deque
import math
import time
import matplotlib.pyplot as plt
class State:
__slots__ = {'board', 'depth', 'goal', 'parent', 'move'}
def __init__(self, board, goal, depth=0, parent=None, move=None):
# 왜 튜플로 묶이는지를 이해하기 위해선 자료형에 대한 이해 필요
# -> 튜플은 "수정 불가능한 순서 있는 집합"
# 탐색 과정에서 여러 노드들이 같은 리스트 객체를 공유하면, 하나를 수정했을 때 다른 것도 바뀌는 문제가 발생할 수 있기에 tuple을 사용함.
# 또한, 해시를 활용하기 위해선 불변성이 필요한데, 리스트나 딕셔너리 같은 변경 가능한 객체들은 Hashable하지 않으므로 수정 불가능한 tuple을 사용함.
self.board = tuple(board) # 길이 9 리스트 (현재 퍼즐 상태)
self.depth = depth # 시작으로부터의 깊이, g(n)
self.goal = tuple(goal) # 목표 상태
self.parent = parent # 경로 복원을 위한 부모 포인터
self.move = move # 현재 상태를 만들게 한 이동
# 해시 -> 어떤 데이터를 숫자(정수)로 반환한 값으로, 객체를 set, dict과 같은 자료구조에서 빠르게 찾을 수 있도록 하기 위해 사용함.
# set과 dict는 내부적으로 해시 테이블(Hash Table) 구조로 되어 있고, 각 객체는 hash() 함수를 통해 정수 해시값을 얻고 그 값을 테이블의 인덱스(버킷 위치) 로 사용해 저장함.
# 이로 인해 비교 대상이 매우 적어져 시간 복잡도가 줄어듦.
# 여기서는 State 객체를 visited 집합에 넣기 위해 해시값을 사용함. (중복 체크 빠르게 하고, State 객체 해시 가능하게 만들기 위해)
def __hash__(self):
return hash(self.board)
def __eq__(self, other):
return isinstance(other, State) and self.board == other.board
def __lt__(self, other):
return self.depth < other.depth
def is_goal(self):
return self.board == self.goal
def index0(self):
return self.board.index(0) # 보드에서 값이 값이 0인 칸 인덱스 번호 반환
def expand(self):
i = self.index0()
# divmod(a, b) -> (몫, 나머지) 튜플 반환
# 여기서는 divmod(index0, 3)이므로 보드에서 값이 0인 칸 인덱스 번호가 몇 열에 있는지 알 수 있음.
# (ex) i = 4라면, divmod의 결과값은 (1, 1)이고, 다음과 같이 인덱스 값이 대응됨.
# 즉, 이를 통해 값이 0인 퍼즐 위치를 찾아낼 수 있음.
# 0 1 2 → (0,0) (0,1) (0,2)
# 3 4 5 → (1,0) (1,1) (1,2)
# 6 7 8 → (2,0) (2,1) (2,2)
r, c = divmod(i, 3)
children = []
def swap_and_make(nr, nc, move): # nc, nr -> 0을 옮길 위치의 좌표
j = nr * 3 + nc # 2차원 좌표를 1차원 인덱스로 변환함 (ex. (2,1) -> 7)
new_board = list(self.board) # 현재 board는 튜플이라 불변하므로 list로 만들기
new_board[i], new_board[j] = new_board[j], new_board[i] # 위치 옮기고
return State(new_board, self.goal, self.depth+1, self, move) # 새로운 상태로 State 객체 생성
if c > 0:
children.append(swap_and_make(r, c-1, 'L')) # 왼쪽
if r > 0:
children.append(swap_and_make(r-1, c, 'U')) # 위
if c < 2:
children.append(swap_and_make(r, c+1, 'R')) # 오른쪽
if r < 2:
children.append(swap_and_make(r+1, c, 'D')) # 아래
return children
def pretty(self):
b = list(self.board)
return (f"{b[0:3]}\n{b[3:6]}\n{b[6:9]}\n")
# 탐색 끝난 뒤 부모 포인터 따라가면서 목표 상태까지 온 경로(탐색 경로) 복원
def reconstruct_path(s: State):
path = []
while s is not None:
path.append(s) # 현재 상태 path 추가
s = s.parent # 부모 상태로 이동 (이걸 부모가 없는 시작점까지 반복)
path.reverse() # 순서 뒤집기 (자식부터 거꾸로 추가했으니까)
return path
def print_path(path):
for i, st in enumerate(path):
print(f"Step {i} (move={st.move})\n{st.pretty()}")
List, Dictionary, Set, Tuple의 정리
- List : 파이썬에서의 배열 표현으로, 크기 조정/서로 다른 데이터 타입 가능
- modifiable, resizeable, different data type
- Dictionary : (key, value) 쌍으로 이루어진 표현
- 존재하지 않는 키 값 사용 시 keyError가 발생하므로 get() 함수 활용
- 값 삭제 : del 변수[key]
- key, value unpacking : 변수.items()
- Set : 고유한 원소들의 순서 없는 집합
- add(), remove() 활용
- distinct(고유한), Unordered
- Tuple : 수정 불가능한 순서 있는 집합으로 리스트와 비슷하나 딕셔너리의 키, Set의 element와 같은 역할
- immatable(수정 불가능한), Ordered list
Hashable하려면 값이 불변해야 하는 이유
- 해시는 해시값을 키로 사용해 데이터를 저장하는데, 해시 값이 변하면 그 객체의 저장 위치가 변하거나 위치를 잘못 참조해 데이터 무결성이 깨질 수 있음. (https://velog.io/@naringst/python-hashable)
def bfs(start_board, goal_board):
start = State(start_board, goal_board) # 시작 상태 생성
if start.is_goal(): # 시작 상태가 목표 상태면 그대로 반환
return reconstruct_path(start), 0, 0 # path, expanded, t1 - t0
q = deque([start])
visited = {start} # 방문한 상태
expanded = 0 # 확장한 노드
t0 = time.time()
while q:
cur = q.popleft() # 큐에서 맨 앞 노드 꺼냄
expanded += 1
for child in cur.expand(): # 가능한 모든 자식 상태 생성
if child in visited: # 현재 상태에서 나온 자식 이미 봤으면 넘기기
continue
if child.is_goal(): # 목표 상태 도달
t1 = time.time()
path = reconstruct_path(child)
return path, expanded, t1 - t0 # 리턴
visited.add(child)
q.append(child)
return None, expanded, time.time() - t0 # 목표 상태에 도달하지 못함 -> 시작 노드 = 목표 노드이므로 기존 Path가 없음 -> None
deque(Double-Ended Queue)를 사용한 큐 관리
- 양쪽에서 요소를 추가하거나 제거 가능한 데이터 구조로, '양방향 큐'라고도 한다.
- 스택처럼 사용할 수도 있고, 큐 처럼 사용할 수도 있다.
- 일반적인 리스트(list)가 이러한 연산에 O(n)이 소요되는 데 반해, deque는 O(1)로 접근하기 때문에 리스트보다 빠른 추가/제거 연산이 가능하며, push/pop 연산이 빈번한 알고리즘에서 리스트보다 월등한 속도를 자랑하기 때문에 자주 사용된다.
- deque는 스레딩과 무관하며, 기본적으로 스레드 안전하지 않다. 그러나 리스트보다 빠른 큐 작업을 제공하며, 회전(Rotation) 및 슬라이스(Slicing) 같은 추가적인 기능도 지원하기 때문에 주로 사용한다.
- 주요 메서드
- append(item) : 끝에 요소 추가
- appendleft(item) : 앞에 요소 추가
- remove(item) : 특정 요소 제거
- rotate(num) : 요소를 num만큼 회전해 앞뒤를 변경 (양수면 오른쪽 회전, 음수면 왼쪽 회전)
def dfs(node:State, limit, visited, expanded):
if node.is_goal(): # 현재 노드 == 목표 상태
return reconstruct_path(node), expanded # (path, expanded)
if limit == 0:
return None, expanded # 목표 도달도 전에 깊이 제한 도달 & 깊이 제한이 0이라 루트 노드 이상 탐색 X
visited.add(node) # 노드 방문 기록
for child in node.expand(): # 자식 탐색
if child in visited:
continue
expanded += 1
res, expanded = dfs(child, limit - 1, visited, expanded) # 자식 상태에 대해 재귀적 실행
if res is not None: # res -> reconstruct_path(node)로 반환받은 값
return res, expanded
return None, expanded # 목표 상태에 도달 못함
# 깊이 제한 탐색 : 특정 깊이 이상으로 들어가지 못하게 하는 dfs
def dfs_with_depth_limit(start_board, goal_board, limit=20):
start = State(start_board, goal_board)
t0 = time.time()
path, expanded = dfs(start, limit, set(), 0) # set() -> visited 집합
return path, expanded, time.time() - t0
def iddfs(start_board, goal_board, max_depth=30):
start = State(start_board, goal_board)
total_expanded = 0 # 탐색 과정에서 확장한 전체 노드 수
t0 = time.time()
# max_depth까지 하나씩 늘려가면서 DFS 실행
for depth in range(max_depth + 1):
path, expanded = dfs(start, depth, set(), 0)
total_expanded += expanded
if path is not None: # goal 도달
return path, total_expanded, time.time() - t0
return None, total_expanded, time.time() - t0
def h_misplaced(board, goal):
return sum(1 for i in range(9) if board[i] != 0 and board[i] != goal[i]) # 잘못 놓인 타일 수 반환
def h_manhattan(board, goal):
pos = {v: i for i, v in enumerate(goal)} # 목표 상태 위치 인덱스 딕셔너리 형태 저장
dist = 0 # 전체 맨해튼 거리 합
for i, v in enumerate(board):
if v == 0: # 0은 빈칸이니까 무시
continue
gi = pos[v] # 현재 값 v가 목표 상태에서 어디에 있어야 하는지 확인
r1, c1 = divmod(i, 3) # 현재 위치
r2, c2 = divmod(gi, 3) # 목표 위치
dist += abs(r1 - r2) + abs(c1 - c2) # h(n) 거리 계산
return dist
A* Search는 지금까지 온 거리 + 남은 거리를 합산해 총 예상 비용이 낮은 후보부터 탐색해 나가는 알고리즘을 의미하며, A* 알고리즘이 Optimal하고 Complete하게 동작하려면, 휴리스틱 조건을 만족해야 한다.
heapq
- 순위가 가장 높은 자료(data)를 가장 먼저 꺼내는 우선순위 큐를 구현한 모듈 (https://docs.python.org/3/library/heapq.html)
import heapq
def astar(start_board, goal_board, h_func=h_manhattan):
start = State(start_board, goal_board)
if start.is_goal(): # 시작 상태 = 목표 상태
return reconstruct_path(start), 0, 0
open_heap = [] # 우선 순위 큐 (F 값 작은 순서대로 탐색해 나가야 하니까)
gscore = {start.board: 0}
f0 = h_func(start.board, start.goal) # 시작 상태의 f(n) -> 0 + h(start)
heapq.heappush(open_heap, (f0, 0, start)) # 우선순위 큐에 시작 상태 저장, g = 0
closed = set() # set은 중복 방지 & 빠른 탐색(해시 기반 구조)을 위해 사용됨.
expanded = 0
t0 = time.time()
while open_heap:
f, g, cur = heapq.heappop(open_heap) # F(n) 값이 가장 작은 상태 pop
if cur.board in closed: # 이미 처리된 것들 (visited 처리)
continue
if cur.is_goal(): # 목표 상태 도달
t1 = time.time()
return reconstruct_path(cur), expanded, t1-t0
closed.add(cur.board)
expanded += 1
for child in cur.expand(): # 현재 상태에서 이동할 수 있는 모든 자식 상태 생성
tentative_g = g+1 # 자식 G(n)
# tentative_g -> 시작 노드에서 현재 노드까지 걸린 Path Cost, gscore.get(child.board, math.inf) -> 지금까지 발견된 child 최단 경로 g
if child.board in closed and tentative_g >= gscore.get(child.board, math.inf): # 닫힌 경로 + 경로 별로
continue
if tentative_g < gscore.get(child.board, math.inf): # 더 짧은 경로 찾음
gscore[child.board] = tentative_g # gscore 갱신 (G(n))
fchild = tentative_g + h_func(child.board, child.goal) # F(n) = H(n) + G(n)
heapq.heappush(open_heap, (fchild, tentative_g, child)) # open_heap에 넣음 (F(n), G(n), 현재 상태)
return None, expanded, time.time() - t0
START = [2, 8, 3, 1, 6 , 4, 7, 0, 5]
GOAL = [1, 2, 3, 8, 0, 4, 7, 6, 5]
def run_all(start=START, goal=GOAL):
results = {}
# BFS
path, expd, t = bfs(start, goal)
results['BFS'] = (len(path)-1 if path else None, expd, t)
# DFS (depth-limited) - 무한 상태 공간에서의 깊이 우선 탐색은 무한 경로를 따라 끝없이 나가므로 완결적이지 않음 (expanded = 5369)
path, expd, t = dfs_with_depth_limit(start, goal, limit=20)
results['DFS(d<=20)'] = (len(path)-1 if path else None, expd, t)
# IDDFS - 노드를 여러 번 방문할 가능성이 있어 비효율적일 수 있음 (expanded = 91)
path, expd, t = iddfs(start, goal, max_depth=30)
results['IDDFS'] = (len(path)-1 if path else None, expd, t)
# misplaced와 manhattan은 모두 휴리스틱 함수 H(n)로 사용될 수 있으며,
# 어떤 휴리스틱 함수를 넘기느냐에 따라 탐색 효율성이 다르다는 것을 결과를 통해 확인 가능함.
# manhattan -> 계산은 조금 더 복잡하지만 정보량이 많음.
# 그럼에도 두 A* 모두 완결성과 최적성은 동일하게 유지됨.
# A* (misplaced)
path, expd, t = astar(start, goal, h_func=h_misplaced)
results['A*(misplaced)'] = (len(path)-1 if path else None, expd, t)
# A* (manhattan)
path, expd, t = astar(start, goal, h_func=h_manhattan)
results['A*(manhattan)'] = (len(path)-1 if path else None, expd, t)
return results
res = run_all()
for k, v in res.items():
print(f"{k:16s} | path_len={v[0]} expanded={v[1]} time={v[2]:.4f}s")
BFS | path_len=5 expanded=26 time=0.0002s
DFS(d<=20) | path_len=17 expanded=5369 time=0.0167s
IDDFS | path_len=5 expanded=91 time=0.0002s
A*(misplaced) | path_len=5 expanded=6 time=0.0001s
A*(manhattan) | path_len=5 expanded=5 time=0.0001s