🚀 1. 다익스트라 최단 경로 알고리즘

  • 다익스트라(Dijkstra) 최단 경로 알고리즘은 그래프에서 여러 개의 노드가 있을 때, 특정한 노드에서 출발하여 다른 노드로 가는 각각의 최단 경로를 구해주는 알고리즘이다.
  • 다익스트라 최단 경로 알고리즘은 '음의 간선'이 없을 때 정상적으로 동작한다.
  • 음의 간선이란?
    - 0보다 작은 값을 가지는 간선
    - 현실 세계의 길(간선)은 음의 간선으로 표현되지 않으므로 다익스트라 알고리즘은 실제로 GPS 소프트웨어의 기본 알고리즘으로 채택되곤 한다.
  • 다익스트라 최단 경로 알고리즘은 기본적으로 그리디 알고리즘으로 분류된다.
  • 매번 '가장 비용이 적은 노드'를 선택해서 임의의 과정을 반복하기 때문이다.
  • 원리
    1. 출발 노드를 설정한다.
    2. 최단 거리 테이블을 초기화한다.
    3. 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택한다.
    4. 해당 노드를 거쳐 다른 노드로 가는 비용을 계산하여 최단 거리 테이블을 갱신한다.
    5. 위 과정에서 3.과 4.를 반복한다.
  • 다익스트라 알고리즘은 최단 경로를 구하는 과정에서 '각 노드에 대한 현재까지의 최단 거리' 정보를 항상 1차원 리스트에 저장하며 리스트를 계속 갱신한다는 특징이 있다.
  • 매번 현재 처리하고 있는 노드를 기준으로 주변 간선을 확인한다.
  • 나중에 현재 처리하고 있는 노드와 인접한 노드로 도달하는 더 짧은 경로를 찾으면 '더 짧은 경로도 있었네? 이제부터는 이 경로가 제일 짧은 경로야'라고 판단하는 것이다.

방법1. 간단한 다익스트라 알고리즘

  • 노드가 5000개 이하일 때 사용하자. 그 이상은 '개선된 다익스트라 알고리즘' 이용!

시간 복잡도 : O() (V는 노드의 개수)

방법

① 각 노드에 대한 최단 거리를 담는 1차원 리스트를 선언한다

② 이후 단계마다 '방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택'하기 위해 매 단계마다 1차원 리스트의 모든 원소를 확인 (순차 탐색)한다.

전체 소스코드

import sys
input = sys.stdin.readline
INF = int(1e9) # 무한을 의미하는 값으로 10억을 설정

# 노드의 개수, 간선의 개수를 입력받기
n, m = map(int, input().split())

# 시작 노드 번호를 입력받기
start = int(input())


# 각 노드에 연결되어 있는 노드에 대한 정보를 담는 리스트를 만들기
graph = [[] for i in range(n+1)]

# 방문한 적이 있는지 체크하는 목적의 리스트를 만들기
visited = [False] * (n+1)

# 최단 거리 테이블을 모두 무한으로 초기화
distance = [INF] * (n+1)

# 모든 간선 정보를 입력받기
for _ in range(m):
    a,b,c = map(int, input().split())
    # a번 노드에서 b번 노드로 가는 비용이 c라는 의미
    graph[a].append((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])

방법2. 개선된 다익스트라 알고리즘

  • 우선순위 큐 사용해서 문제 해결

우선순위 큐

  • 우선순위가 가장 높은 데이터를 가장 먼저 삭세하는 자료구조
  • heapq()를 사용하는걸 권장!
  • 최소 힙 (기본값) 일 때는 '값이 낮은 데이터가 먼저 삭제'되며,
  • 최대 힙일 때는 '값이 큰 데이터가 먼저 삭제'된다. (최소 힙에 -를 붙이면 됨)

시간 복잡도 : O(ElogV) (E : 간선의 개수, V : 노드의 개수)

전체 소스코드

import heapq
import sys
input = sys.stdin.readline
INF = int(1e9)

n,m = map(int, input().split())
graph = [[] for _ in range(n+1)]
distance = [INF] * (n+1)
start = int(input())

for i in range(m):
    a,b,c = map(int, input().split())
    graph[a].append((b,c))

def dijkstra(start):
    q = []
    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):
    if distance[i] == INF:
        print('INFINITY')
    else:
        print(distance[i])

🛸 2. 플로이드 워셜 알고리즘

특징다익스트라 알고리즘플로이드 워셜 알고리즘
목적 한 지점에서 다른 특정 지점까지의 최단 경로를 구하는 경우 모든 지점에서 다른 모든 지점가지의 최단 경로를 모두 구하는 경우
작동 방식 단계마다 최단 거리를 가지는 노드를 하나씩 반복적으로 선택 단계마다 '거쳐 가는 노드'를 기준으로 알고리즘을 수행. but 매번 방문하지 않은 노드 중에서 최단 거리를 갖는 노드를 찾을 필요가 없다
시간 복잡도 최대 O() 최소 O(ElogV) O()
최단거리 저장법 1차원 리스트 2차원 리스트에 '최단거리' 정보를 저장
특징 그리디 알고리즘 다이나믹 프로그래밍

 

 
반응형

1. Dynamic Proogramming , 동적 계획법

  • 메모리 공간을 약간 더 사용하면 연산 속도를 비약적으로 증가시킬 수 있는 방법
  • 연산 속도와 메모리 공간을 최대한 활용할 수 있는 효율적인 알고리즘
  • 특정한 문제를 완전 탐색 알고리즘으로 접근했을 때 시간이 매우 오래 걸린다면 해결하고자 하는 문제들의 중복 여부를 확인해보자!
  • 가능하다면 재귀 함수를 이용하는 탑다운 방식보다는 보텀업 방식으로 구현하는 것을 권장한다.

예시 1. 피보나치 수열

  • 파이썬에서는 이러한 수열을 리스트 자료형으로 처리한다.
  • 수열 자체가 여러 개의 수가 규칙에 따라서 배열된 형태를 의미하는 것이기 때문이다.
  • f(4)를 구하려면 다음과 같이 함수 f를 반복해서 호출한다.
  • f(2)와 f(1)은 항상 1이기 때문에 f(1)이나 f(2)를 만났을 때는 호출을 정지한다.

소스코드

# 피보나치 함수를 재귀 함수로 표현
def fibo(x):
	if x==1 or x==2:
    	return 1
    return fibo(x-1) + fibo(x-2)

print(fibo(4))
  • 소스코드를 이런 식으로 작성하면 심각한 문제가 발생한다.
  • f(n)함수에서 n이 커지면 커질수록 수행 시간이 기하급수적으로 늘어나기 때문!
  • 이 소스코드의 시간 복잡도는 O(2^n)의 지수 시간이 소요된다
  • 그림에서 f(3)은 총 3번 호출 되었다.
  • 즉, f(n)에서 n이 커지면 커질수록 반복해서 호출하는 수가 많아진다.

이처럼 피보나치 수열의 점화식을 재귀 함수를 사용해 만들 수는 있지만, 단순히 매번 계산하도록 하면 문제를 효율적으로 해결할 수 없다.

이러한 문제는 다이나믹 프로그래밍을 사용하면 효율적으로 해결할 수 있다.
다만 항상 다이나믹 프로그래밍을 사용할 수는 없으며, 다음 조건을 만족할 때 사용할 수 있다.

  1. 큰 문제를 작은 문제로 나눌 수 있다
  2. 작은 문제에서 구한 정답은 그것을 포함하는 큰 문제에서도 동일하다

피보나치 수열은 이러한 조건을 만족하는 대표 문제이다.

이 문제를 메모이제이션(Memoization) 기법을 사용해서 해결해보자.

메모이제이션
다이나믹 프로그래밍을 구현하는 방법 중 한 종류로, 한 번 구한 결과를 메모리 공간에 메모해두고 같은 식을 다시 호출하면 메모한 결과를 그대로 가져오는 기법
메모이제이션은 값을 저장하는 방법이므로 캐싱(Cashing)이라고도 한다.
Top-Down 방식에 국한되어 사용하는 표현.


DP 테이블
Bottom-Up 방식에서 사용되는 결과 저장용 리스트

한 번 구한 정보를 리스트에 저장하면 메모이제이션이 구현된다.
다이나믹 프로그래밍을 재귀적으로 수행하다가 같은 정보가 필요할 때는 이미 구현한 정답을 그대로 리스트에서 가져오면 된다.

소스코드

# 한 번 계산된 결과를 메모이제이션(Memoization)하기 위한 리스트 초기화
d = [0] * 100

# 피보나치 함수 (Fibonacci Function)를 재귀함수로 표현 (탑다운 다이나믹 프로그래밍)
def fibo(x):
	# 종료 조건(1 혹은 2일 때 1을 반환)
    if x==1 or x==2:
    	return 1
    
    # 이미 계산한 적 있는 문제라면 그대로 반환
    if d[x] != 0:
    	return d[x]
    
    # 아직 계산하지 않은 문제라면 점화식에 따라서 피보나치 결과 반환
    d[x] = fibo(x-1) + fibo(x-2)
    return d[x]

print(fibo(99))

파이썬 프로그램을 실행해보면 99번째 피보나치 수를 구하도록 했음에도 불구하고 금방 정답을 도출하는 것을 알 수 있다.


정리

1) 다이나믹 프로그래밍
큰 문제를 작게 나누고, 같은 문제라면 한 번씩만 풀어 문제를 효율적으로 해결하는 알고리즘 기법.

2) 퀵 정렬
정렬을 수행할 때 정렬할 리스트를 분할하며 전체적으로 정렬이 될 수 있도록해 분할 정복 알고리즘으로 분류된다

3) 다이나믹 프로그래밍과 퀵 정렬의 차이점
다이나믹 프로그래밍은 문제들이 서로 영향을 미친다.

퀵 정렬 예시

한 번 기준 원소(pivot)가 자리를 변경해서 자리를 잡게 되면 그 기준 원소의 위치는 더 이상 바뀌지 않고 그 피벗값을 다시 처리하는 부분 문제는 존재하지 않는다

다이나믹 프로그래믹 예시

한 번 해결했던 문제를 다시금 해결한다. 그렇기 때문에 이미 해결된 부분 문제에 대한 답을 저장해 놓고, 이 문제는 이미 해결이 됐던 것이니까 다시 해결할 필요가 없다고 반환한다.


  • 다이나믹 프로그래밍을 적용했을 때의 피보나치 수열 알고리즘의 시간 복잡도는 O(N)이다.
    왜냐하면 f(1)을 구한 다음 그 값이 f(2)를 푸는 데 사용되고, f(2)의 값이 f(3)을 푸는 데 사용되는 방식으로 이어지기 때문이다.
  • 실제로 호출되는 함수에 대해서만 확인해보면 다음과 같이 방문한다

호출되는 함수 확인 (Top-down 방식)

d = [0] * 100

def pibo(x):
	print('f(' + str(x) + ')', end = ' ')
	
    if x==1 or x==2:
    	return 1
    
    if d[x] != 0:
    	return d[x]
    
    d[x] = pibo(x-1) + pibo(x-2)
    return d[x]

pibo(6)

결과

f(6) f(5) f(4) f(3) f(2) f(1) f(2) f(3) f(4)

호출되는 함수 확인 (Bottom-Up 방식)

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])
 

 

반응형

🎈 순차 탐색

리스트 안에 있는 특정한 데이터를 찾기 위해 앞에서부터 데이터를 하나씩 차례로 확인하는 방법

# 순차 탐색 소스코드 구현
def sequential_search(n, target, array):
	# 각 원소를 하나씩 확인하며
    for i in range(n):
    	# 현재의 원소가 찾고자 하는 원소와 동일한 경우
        if array[i] == target:
        	return i + 1 # 현재의 위치 반환 (인덱스는 0부터 시작하므로 1 더하기)
print("생성할 원소 개수를 입력한 다음 한 칸 띄고 찾을 문자열을 입력하세요.")
input_data = input().split()
n = int(input_data[0]) # 원소의 개수
target = input_data[1] # 찾고자 하는 문자열

print("앞서 적은 원소 개수만큼 문자열을 입력하세요. 구분은 띄어쓰기 한 칸으로 합니다.")
array = input().split()

# 순차 탐색 수행 결과 출력
print(sequential_search(n, target, array))

데이터의 개수가 N개일 때 최대 N번의 비교 연산이 필요하므로 순차 탐색의 최악의 경우 시간 복잡도는 O(N)이다.

🧦 이진 탐색

배열 내부의 데이터가 정렬되어 있어야만 사용할 수 있는 알고리즘

  1. 위치를 나타내는 변수 3개를 사용한다 (시작점, 끝점, 중간점)
  2. 찾으려는 데이터와 중간점 위치에 있는 데이터를 반복적으로 비교해서 원하는 데이터를 찾는다

이진 탐색은 한 번 확인할 때마다 확인하는 원소의 개수가 절반씩 줄어든다는 점에서 시간 복잡도가 O(logN)이다.

* 이진 탐색을 구현하는 방법

  1. 재귀 함수를 이용하는 방법
# 이진 탐색 소스코드 구현 (재귀 함수)
def binary_search(array, target, start, end):
	if start > end:
    	return None
    mid = (start+end)//2
    # 찾은 경우 중간점 인덱스 반환
    if target == mid:
    	return mid
    # 중간점의 값보다 찾고자 하는 값이 작은 경우 왼쪽 확인
    elif array[target] < array[mid] :
   		return binary_search(array, target, start, mid-1)
    # 중간점의 값보다 찾고자 하는 값이 큰 경우 오른쪽 확인
    else:
    	return binary_search(array, target, mid+1, end)

# n(원소의 개수)과 target(찾고자 하는 문자열)을 입력받기
n, target = list(map(int, input().split())

# 이진 탐색 수행 결과 출력
result = binary_search(array, target, 0, n-1)
if result == None:
	print("원소가 존재하지 않습니다.")
else:
	print(result + 1)
  1. 반복문을 이용하는 방법
# 이진 탐색 소스코드 구현 (반복문)
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
    
# n(원소의 개수)과 target(찾고자 하는 문자열)을 입력받기
n, target = list(map(int, input().split()))
# 전체 원소 입력받기
array = list(map(int, input().split()))

# 이진 탐색 수행 결과 출력
result = binary_search(array, target, 0, n-1)
if result == None:
	print('원소가 존재하지 않습니다')
else:
	print(result+1)
  • 코딩 테스트에서 탐색 범위가 2000만을 넘어가면 이진 탐색으로 접근해보자!
  • 처리해야할 개수나 값이 1000만 이상으로 넘어가면 이진 탐색과 같이 O(logN)의 속도를 내는 알고리즘을 떠올려야 문제를 풀 수 있다!

이진 탐색 트리


1. 트리 자료구조 중 가장 간단한 형태
2. 왼쪽 자식 노드 < 부모 노드 < 오른쪽 자식 노드

📝 실전 문제

1. 부품 찾기

동빈이네 전자 매장에는 부품 N개가 있다. 각 부품은 정수 형태의 고유한 번호가 있다. 어느 날 손님이 M개 종류의 부품을 대량으로 구매하겠다며 당일 날 견적서를 요청했다. 동빈이는 때를 놓치지 않고 손님이 문의한 부품 M개 종류를 모두 확인해서 견적서를 작성해야 한다. 이때 가게 안에 부품이 모두 있는지 확인하는 프로그램을 작성해보자

예를 들어 가게의 부품이 총 5개일 때 부품 번호가 다음과 같다고 하자.

N = 5
[8, 3, 7, 9, 2]

손님은 총 3개의 부품이 있는지 확인 요청했는데 부품 번호는 다음과 같다.

M = 3
[5, 7, 9]

이때 손님이 요청한 부품 번호의 순서대로 부품을 확인해 부품이 있으면 yes를, 없으면 no를 출력한다. 구분은 공백으로 한다.

입력조건

  • 첫째 줄에 정수 N이 주어진다 (1 <= N <= 1,000,000)
  • 둘째 줄에는 공백으로 구분하여 N개의 정수가 주어진다. 이때 정수는 1보다 크고 1,000,000 이하이다.
  • 셋째 줄에는 정수 M이 주어진다.
  • 넷째 줄에는 공백으로 구부하여 M개의 정수가 주어진다. 이때 정수는 1보다 크고 1,000,000 이하이다.

출력조건

  • 첫째 줄에 공백으로 구분하여 각 부품이 존재하면 yes를, 없으면 no를 출력한다.

입력예시

5
8 3 7 9 2
3
5 7 9

출력예시

no yes yes

내 풀이

n = int(input())
array = list(map(int,input().split()))
array.sort()
m = int(input())
array2 = list(map(int,input().split()))
array2.sort()

def binary_search(start, end, target):
    while start <= end:
        mid = (start + end) // 2
        if target == array[mid]:
            return print('yes', end=' ')
        elif target > array[mid]:
            start = mid+1
        else:
            end = mid-1
    return print('no', end=' ')

for i in array2:
    binary_search(0, len(array), i)


2. 떡볶이 떡 만들기

오늘 동빈이는 여행 가신 부모님을 대신해서 떡집 일을 하기로 했다. 오늘은 떡볶이 떡을 만드는 날이다. 동빈이네 떡볶이 떡은 재밌게도 떡볶이 떡의 길이가 일정하지 않다. 대신에 한 봉지 안에 들어가는 떡의 총 길이는 절단기로 잘라서 맞춰준다.
절단기에 높이(H)를 지정하면 줄지어진 떡을 한 번에 절단한다. 높이가 H보다 긴 떡은 H 위의 부분이 잘릴 것이고, 낮은 떡은 잘리지 않는다.
예를 들어 높이가 19, 14, 10, 17cm인 떡이 나란히 있고 절단기 높이를 15cm로 지정하면 자른 뒤 떡의 높이는 15, 14, 10, 15cm가 될 것이다. 잘린 떡의 길이는 차례대로 4, 0, 0, 2cm이다. 손님은 6cm만큼의 길이를 가져간다.
손님이 왔을 때 요청한 총 길이가 M일 때 적어도 M만큼의 떡을 얻기 위해 절단기에 설정할 수 있는 높이의 최댓값을 구하는 프로그램을 작성하시오.

입력조건

  • 첫째 줄에 떡의 개수 N과 요청한 떡의 길이 M이 주어진다. (1 <= N <= 1,000,000, 1 <=M <= 2,000,000,000)
  • 둘째 줄에는 떡의 개별 높이가 주어진다. 떡 높이의 총합은 항상 M 이상이므로, 손님은 필요한 양만큼 떡을 사갈 수 있다. 높이는 10억보다 작거나 같은 양의 정수 또는 0이다.

출력조건

  • 적어도 M만큼의 떡을 집에 가져가기 위해 절단기에 설정할 수 있는 높이의 최대값을 출력한다.

입력예시

4 6
19 15 10 17

출력예시

15

내 풀이

n, m = map(int, input().split())
array = list(map(int, input().split()))

def binary_search(start, end, target):
    while start <= end:
        sum=0
        mid = (start + end) // 2
        for i in array:
            if i > mid:
                sum += i-mid
        if sum == target:
            return print(mid)
        elif sum < target:
            end = mid - 1
        else:
            start = mid + 1

array.sort()
binary_search(0, array[-1], m)
 
반응형

스택과 큐, 재귀 함수는 DFS와 BFS에서 가장 중요한 개념이라 이전 포스팅에서 미리 다뤘다. 이제부터 DFS/BFS 알고리즘을 살펴보겠다.

DFS

DFS는 Depth-First Search, 깊이 우선 탐색이라고도 부르며, 그래프에서 깊은 부분을 우선적으로 탐색하는 알고리즘이다. DFS를 설명하기 전에 먼저 그래프의 기본 구조를 알아야 한다.

그래프는 노드 간선으로 표현되며 이때 노드를 정점이라고도 말한다. 그래프 탐색이란 하나의 노드를 시작으로 다수의 노드를 방문하는 것을 말한다. 또한 두 노드가 간선으로 연결되어 있다면 '두 노드는 인접하다'라고 표현한다.

여기에서 갑자기 노드와 간선이라는 생소한 단어가 나와서 헷갈릴 수도 있는데, 일반적으로 그래프를 표현할 때 사용하는 단어들이다. 노드를 도시, 간선을 도로라고 생각해보자. A라는 도시(노드)에서 B라는 도시(노드)로 이동하기 위해서, A와 B를 연결하는 도로(간선)를 거친다고 이해하면 쉬울 것이다.

프로그래밍에서 그래프는 크게 2가지 방식으로 표현할 수 있는데 코딩 테스트에서는 이 두 방식 모두 필요하니 두 개념에 대해 바르게 알고 있도록 하자.

  • 인접 행렬 (Adjacency Matrix) : 2차원 배열로 그래프의 연결 관계를 표현하는 방식
  • 인접 리스트 (Adjacency List) : 리스트로 그래프의 연결 관계를 표현하는 방식

먼저 인접 행렬 방식은 2차원 배열에 각 노드가 연결된 형태를 기록하는 방식이다. 위와 같이 연결된 그래프를 인접 행렬로 표현할 때 파이썬에서는 2차원 리스트로 구현할 수 있다.

연결되어 있지 않은 노드끼리는 무한의 비용이라고 작성한다. 실제 코드에서는 논리적으로 정답이 될 수 없는 큰 값 중에서 999999999, 987654321 등의 값으로 초기화하는 경우가 많다. 이렇게 그래프를 인접 행렬 방식으로 처리할 때는 다음과 같이 데이터를 초기화한다.

코드

INF = 999999999 # 무한의 비용 선언

# 2차원 리스트를 이용해 인접 행렬 표현
graph = [
    [0, 7, 5],
    [7, 0, INF],
    [5, INF, 0]
]

print(graph)

결과

[[0, 7, 5], [7, 0, 999999999], [5, 999999999, 0]]

그렇다면 인접 리스트 방식에서는 데이터를 어떤 방식으로 저장할까? 인접 리스트 방식에서는 다음 그림처럼 모든 노드에 연결된 노드에 대한 정보를 차례대로 연결하여 저장한다.

인접 리스트는 '연결 리스트'라는 자료구조를 이용해 구현하는데, C++이나 자바와 같은 프로그래밍 언어에서는 별도로 연결 리스트 기능을 위한 표준 라이브러리를 제공한다. 반면에 파이썬은 기본 자료형인 리스트 자료형인 append()와 메소드를 제공하므로, 전통적인 프로그래밍 언어에서의 배열과 연결 리스트의 기능을 모두 기본으로 제공한다. 파이썬으로 인접 리스트를 이용해 그래프를 표현하고자 할 때에도 단순히 2차원 리스트를 이용하면 된다는 점만 기억하자.

다음은 예제 그래프를 인접 리스트 방식으로 처리할 때 데이터를 초기화한 코드이다.

코드

# 행(Row)이 3개인 2차원 리스트로 인접 리스트 표현
graph = [[] for _ in range(3)]

# 노드 0에 연결된 노드 정보 저장 (노드, 거리)
graph[0].append((1,7))
graph[0].append((2,5))

# 노드 1에 연결된 노드 정보 저장 (노드, 거리)
graph[1].append((0,7))

# 노드 2에 연결된 노드 정보 저장 (노드, 거리)
graph[2].append((0,5))

print(graph)

결과

[[(1, 7), (2, 5)], [(0, 7)], [(0, 5)]]

이 두 방식은 어떤 차이가 있을까? 코딩 테스트를 위해 학습하는 터라 메모리와 속도 측면에서 살표보겠다. 메모리 측면에서 보자면 인접 행렬 방식은 모든 관계를 저장하므로 노드 개수가 많을수록 메모리가 불필요하게 낭비된다. 반면에 인접 리스트 방식은 연결된 정보만을 저장하기 때문에 메모리를 효율적으로 사용한다. 하지만 이와 같은 속성 때문에 인접 리스트 방식은 인접 행렬 방식에 비해 특정한 두 노드가 연결되어 있는지에 대한 정보를 얻는 속도가 느리다. 인접 리스트 방식에서는 연결된 데이터를 하나씩 확인해야 하기 때문이다.

또 다른 예시로 한 그래프에서 노드 1과 노드 7이 연결되어 있는 상황을 생각해보자. 인접 행렬 방식에서는 graph[1][7]만 확인하면 된다. 반면에 인접 리스트 방식에서는 노드 1에 대한 인접 리스트를 앞에서부터 차례대로 확인해야 한다. 그러므로 특정한 노드와 연결된 모든 인접 노드를 순회해야 하는 경우, 인접 리스트 방식이 인접 행렬 방식에 비해 메모리 공간의 낭비가 적다. DFS는 탐색을 위해서 사용되는 탐색 알고리즘이라고 했는데 구체적으로 어떻게 동작할까? DFS는 깊이 우선 탐색 알고리즘이라고 했다. 이 알고리즘은 특정한 경로로 탐색하다가 특정한 상황에서 최대한 깊숙이 들어가서 노드를 방문한 후, 다시 돌아가 다른 경로로 탐색하는 알고리즘이다.

DFS는 스택 자료구조를 이용하여 구체적인 동작 과정은 다음과 같다.

  1. 탐색 시작 노드를 스택에 삽입하고 방문 처리를 한다.
  2. 스택의 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 인접 노드를 스택에 넣고 방문 처리를 한다. 방문하지 않은 인접 노드가 없으면 스택에서 최상단 노드를 꺼낸다.
  3. 2.번의 과정을 더 이상 수행할 수 없을 때까지 반복한다.

코드

# DFS 메서드 정의
def dfs(graph, v, visited):
    # 현재 노드를 방문 처리
    visited[v] = True
    print(v, end = ' ')
    # 현재 노드와 연결된 다른 노드를 재귀적으로 방문
    for i in graph[v]:
        if not visited[i]:
            dfs(graph, i, visited)
            
# 각 노드가 연결된 정보를 리스트 자료형으로 표현 (2차원 리스트)
graph = [
    [],
    [2, 3, 8],
    [1, 7],
    [1, 4, 5],
    [3, 5],
    [3, 4],
    [7],
    [2, 6, 8],
    [1, 7]
]

# 각 노드가 방문된 정보를 리스트 자료형으로 표현 (1차원 리스트)
visited = [False] * 9

# 정의된 DFS 함수 호출
dfs(graph, 1, visited)

결과

1 2 7 6 8 3 4 5 

BFS

BFS 알고리즘은 너비 우선 탐색이라는 의미를 가진다. 쉽게 말해 가까운 노드부터 탐색하는 알고리즘이다. DFS는 최대한 멀리 있는 노드를 우선적으로 탐색하는 방식으로 동작한다고 했는데, BFS는 그 반대다. 그렇다면 BFS는 실제로 어떤 방식으로 구현할 수 있을까? BFS 구현에서는 선입선출 방식인 큐 자료구조를 이용하는 것이 정석이다. 인접한 노드를 반복적으로 큐에 넣도록 알고리즘을 작성하면 자연스럽게 먼저 들어온 것이 먼저 나가게 되어, 가까운 노드부터 탐색을 진행하게 된다.

알고리즘의 정확한 동작 방식은 다음과 같다.

  1. 탐색 시작 노드를 큐에 삽입하고 방문 처리를 한다.
  2. 큐에서 노드를 꺼내 해당 노드의 인접 노드 중에서 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리를 한다.
  3. 2.번의 과정을 더 이상 수행할 수 없을 때까지 반복한다.

BFS는 큐 자료구조에 기초한다는 점에서 구현이 간단하다. 실제로 구현함에 있어 앞서 언급한 대로 deque 라이브러리를 사용하는 것이 좋으며 탐색을 수행함에 있어 O(N)의 시간이 소요된다. 일반적인 경우 실제 수행 시간은 DFS보다 좋은 편이라는 점까지만 추가로 기억하자.

코드

from collections import deque

# BFS 메서드 정의
def bfs(graph, start, visited):
    # 큐(Queue) 구현을 위해 deque 라이브러리 사용
    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

# 각 노드가 연결된 정보를 리스트 자료형으로 표현 (2차원 리스트)
graph = [
    [],
    [2, 3, 8],
    [1, 7],
    [1, 4, 5],
    [3, 5],
    [3, 4],
    [7],
    [2, 6, 8],
    [1, 7]
]

# 각 노드가 방문된 정보를 리스트 자료형으로 표현 (1차원 리스트)
visited = [False] * 9

# 정의된 BFS 함수 호출
bfs(graph, 1, visited)

결과

1 2 3 8 7 4 5 6 

DFS와 BFS 구현에 대해 알아보았는데, 간단히 정리하자면 다음 표와 같다. 더 다양한 방식으로 구현할 수 있지만 위에서 정리한 예제가 가장 간결한 방식이다.

구분DFSBFS
동작 원리 스택
구현 방법 재귀 함수 이용 큐 자료구조 이용

앞서 DFS와 BFS를 설명하는 데 전형적인 그래프 그림을 이용했는데 1차원 배열이나 2차원 배열 또한 그래프 형태로 생각하면 수월하게 문제를 풀 수 있다. 특히나 DFS와 BFS 문제 유형이 그러하다.

코딩 테스트 중 2차원 배열에서의 탐색 문제를 만나면 이렇게 그래프 형태로 바꿔서 생각하면 풀이 방법을 조금 더 쉽게 떠올릴 수 있다. 그러므로 코딩 테스트에서 탐색 문제를 보면 그래프 형태로 구현한 다음 풀이법을 고민하도록 하자.

반응형

1. 꼭 필요한 자료구조 기초

탐색이란 많은 양의 데이터 중에서 원하는 데이터를 찾는 과정을 의미한다. 프로그래밍에서는 그래프, 트리 등의 자료구조 안에서 탐색을 하는 문제를 자주 다룬다. 대표적인 탐색 알고리즘으로 DFS/BFS를 꼽을 수 있는데 이 두 알고리즘의 원리를 제대로 이해해야 코딩 테스트의 탐색 문제 유형을 풀 수 있다. 그런데 DFS와 BFS를 제대로 이해하려면 기본 자료구조인 스택과 큐에 대한 이해가 전제되어야 하므로 사전 학습으로 스택과 큐, 재귀 함수를 간단히 정리하고자 한다.

자료구조 데이터를 표현하고 관리하고 처리하기 위한 구조를 의미한다. 그중 스택과 큐는 자료구조의 기초 개념으로 다음의 두 핵심적인 함수로 구성된다.

  • 삽입(Push) : 데이터를 삽입한다.
  • 삭제(Pop) : 데이터를 삭제한다.

물론 실제로 스택과 큐를 사용할 때는 삽입과 삭제 외에도 오버플로와 언더플로를 고민해야 한다. 오버플로는 특정한 자료구조가 수용할 수 있는 데이터의 크기가 이미 가득 찬 상태에서 삽입 연산을 수행할 때 발생한다. 즉, 저장 공간을 벗어나 데이터가 넘쳐흐를 때 발생한다. 반면에 특정한 자료구조에 데이터가 전혀 들어 있지 않은 상태에서 삭제 연산을 수행하면 데이터가 전혀 없는 상태이므로 언더플로가 발생한다.

스택

스택은 박스 쌓기에 비유할 수 있다. 흔히 박스는 아래에서부터 위로 차곡차곡 쌓는다. 그리고 아래에 있는 박스를 치우기 위해서는 위에 있는 박스를 먼저 내려야한다. 이러한 구조를 선입후출 구조 또는 후입선출 구조라고 한다.

위 그림과 같이 가상의 스택을 하나 준비하여 일련의 연산을 수행해보자. 입구와 출구가 동일한 형태로 스택을 시각화할 수 있다. 이를 파이썬 코드로 표현하면 다음과 같다.

코드

stack = []

# 삽입(5) - 삽입(2) - 삽입(3) - 삽입(7) - 삭제() - 삽입(1) - 삽입(4) - 삭제()
stack.append(5)
stack.append(2)
stack.append(3)
stack.append(7)
stack.pop()
stack.append(1)
stack.append(4)
stack.pop()

print(stack) # 최하단 원소부터 출력
print(stack[::-1]) # 최상단 원소부터 출력

결과

[5, 2, 3, 1]
[1, 3, 2, 5]

파이썬에서 스택을 이용할 때에는 별도의 라이브러리를 사용할 필요가 없다. 기본 리스트에서 append()와 pop() 메서드를 이용하면 스택 자료구조와 동일하게 동작한다. append() 메서드는 리스트의 가장 뒤쪽에 데이터를 삽입하고, pop() 메서드는 리스트의 가장 뒤쪽에서 데이터를 꺼내기 때문이다.

는 대기줄에 비유할 수 있다. 우리가 흔히 놀이공원에 입장하기 위해 줄을 설 때, 먼저 온 사람이 먼저 들어가게 된다. 물론 새치기는 없다고 가정한다. 나중에 온 사람일수록 나중에 들어가기 때문에 흔히 '공정한' 자료구조라고 비유된다. 이러한 구조를 선입선출 구조라고 한다.

큐는 다음과 같이 입구와 출구가 모두 뚫려 있는 터널과 같은 형태로 시각화할 수 있다. 이를 파이썬 코드로 표현하면 다음과 같다.

코드

from collections import deque

# 큐(Queue) 구현을 위해 deque 라이브러리 사용
queue = deque()

# 삽입(5) - 삽입(2) - 삽입(3) - 삽입(7) - 삭제() - 삽입(1) - 삽입(4) - 삭제()
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)     # 나중에 들어온 원소부터 출력

결과

deque([3, 7, 1, 4])
deque([4, 1, 7, 3])

파이썬으로 큐를 구현할 때는 collections모듈에서 제공하는 deque 자료구조를 활용하자. deque는 스택과 큐의 장점을 모두 채택한 것인데 데이터를 넣고 빼는 속도가 리스트 자료형에 비해 효율적이며 queue 라이브러리를 이용하는 것보다 더 간단하다. 더불어 코딩 테스트에서는 collections 모듈과 같은 기본 라이브러리 사용을 허용하므로 안심하고 사용해도 괜찮다. 또한, deque 객체를 리스트 자료형으로 변경하고자 한다면 list() 메서드를 이용하자. 이 소스코드에서는 list(queue)를 하면 리스트 자료형이 반환된다.

재귀 함수

DFS와 BFS를 구현하려면 재귀 함수도 이해하고 있어야 한다. 재귀 함수 자기 자신을 다시 호출하는 함수를 의미한다. 가장 간단한 재귀 함수는 다음과 같다.

def recursive_function():
    print('재귀 함수를 호출합니다')
    return recursive_function()

recursive_function()

이 코드를 실행하면 '재귀 함수를 호출합니다'라는 문자열을 무한히 출력한다. 여기서 정의한 recursive_function()이 자기 자신을 계속해서 추가로 불러오기 때문이다. 물론 어느 정도 출력하다가 다음과 같은 오류 메시지를 출력하고 멈출 것이다.

RecursionError: maximum recursion depth exceeded while pickling an object

이 오류 메시지는 재귀의 최대 깊이를 초과했다는 내용이다. 보통 파이썬 인터프리터는 호출 횟수 제한이 있는데 이 한계를 벗어났기 때문이다. 따라서 무한대로 재귀 호출을 진행할 수는 없다 (애초에 무한한 재귀 호출을 요구하는 문제 또한 출제되지 않을 것이다.)

재귀 함수는 수학 시간에 한 번씩 언급되는 프랙털 구조와 흡사하다. 위는 시에르핀스키의 삼각형이다. 삼각형 안에 또 다른 삼각형이 무한히 존재하는 이 그림은 프랙털 구조의 대표적인 그림으로 실제로 이러한 프랙털 이미지를 출력하는 프로그램을 작성할 때에도 재귀 함수를 이용한다.

재귀 함수의 종료 조건

재귀 함수를 문제 풀이에서 사용할 때는 재귀 함수가 언제 끝날지, 종료 조건을 꼭 명시해야 한다. 자칫 종료 조건을 명시하지 않으면 함수가 무한 호출될 수 있다. 예를 들어 다음은 재귀 함수를 100번 호출하도록 작성한 코드이다. 재귀 함수 초반에 등장하는 if문이 종료 조건 역할을 수행한다.

def recursive_function(i):
    # 100번째 출력했을 때 종료되도록 종료 조건 명시
    if i == 100:
        return
    print(i, '번째 재귀 함수에서', i+1, '번째 재귀 함수를 호출합니다.')
    recursive_function(i+1)
    print(i, '번째 재귀 함수를 종료합니다.')

recursive_function(1)

컴퓨터 내부에서 재귀 함수의 수행은 스택 자료구조를 이용한다. 함수를 계속 호출했을 때 가장 마지막에 호출한 함수가 먼저 수행을 끝내야 그 앞의 함수 호출이 종료되기 때문이다. 컴퓨터의 구조 측면에서 보자면 연속해서 호출되는 함수는 메인 메모리의 스택 공간에 적재되므로 재귀 함수는 스택 자료구조와 같다는 말은 틀린 말이 아니다. 컴퓨터 구조는 이 포스팅 범위를 벗어나니 컴퓨터 구조 이야기는 잊고, 재귀 함수는 내부적으로 스택 자료구조와 동일하다는 것만 기억하자. 따라서 스택 자료구조를 활용해야 하는 상당수 알고리즘은 재귀 함수를 이용해서 간편하게 구현될 수 있다. DFS가 대표적인 예이다.

재귀 함수를 이용하는 대표적인 예재로는 팩토리얼 문제가 있다. 팩토리얼 기호는 느낌표(!)를 사용하며 n!은 1 × 2 × 3 × … × (n-1) × n을 의미한다. 수학적으로 0!과 1!의 값은 1로 같다는 성질을 이용하여 팩토리얼 함수는 n이 1 이하가 되었을 때 함수를 종료하는 재귀 함수의 형태로 구현할 수 있다.

팩토리얼을 반복적으로 구현한 방식과 재귀적으로 구현한 두 방식을 비교해보자.

코드

# 반복적으로 구현한 n!
def factorial_iterative(n):
    result = 1
    # 1부터 n까지의 수를 차례대로 곱하기
    for i in range(1, n+1):
        result *= i
    return result

# 재귀적으로 구현한 n!
def factorial_recursive(n):
    if n <= 1:   # n이 1 이하인 경우 1을 반환
        return 1
    # n! = n * (n-1)!를 그대로 코드로 작성하기
    return n * factorial_recursive(n-1)

# 각각의 방식으로 구현한 n! 출력 (n=5)
print('반복적으로 구현:', factorial_iterative(5))
print('재귀적으로 구현:', factorial_recursive(5))

결과

반복적으로 구현: 120
재귀적으로 구현: 120

실행 결과는 동일하다. 그렇다면 반복문 대신에 재귀 함수를 사용했을 때 얻을 수 있는 장점은 무엇일까?

위의 코드를 비교했을 때 재귀 함수의 코드가 더 간결한 것을 알 수 있다. 이렇게 간결해진 이유는 재귀 함수가 수학적 점화식(재귀식)을 그대로 소스코드로 옮겼기 때문이다. 수학에서 점화식은 특정한 함수를 자신보다 더 작은 변수에 대한 함수와의 관계로 표현한 것을 의미한다. 이 개념은 이후에 배울 '다이나믹 프로그래밍'으로 이어지기 때문에 중요하다.

팩토리얼을 수학적 점화식으로 표현해보면 다음과 같다.

  1. n이 0 혹은 1일 때 : factorial(n) = 1
  2. n이 1보다 클 때 : factorial(n) = n * factorial(n-1)

일반적으로 우리는 점화식에서 종료 조건을 찾을 수 있는데, 앞 예시에서 종료 조건은 'n이 0 혹은 1일 때'이다. 팩토리얼은 n이 양의 정수일 때만 유효하기 때문에 n이 1 이하인 경우 1을 반환할 수 있도록 재귀 함수를 작성해야 한다. n이 1 이하인 경우를 고려하지 않으면 재귀 함수가 무한히 반복되어 결과를 출력하지 못할 것이다. 또한 n의 값으로 음수가 들어왔을 때는 입력 범위 오류로, 오류 메시지를 띄우도록 코드를 작성할 수도 있다. 따라서 재귀 함수 내에서 특정 조건일 때 더 이상 재귀적으로 함수를 호출하지 않고 종료하도록 if문을 이용하여 꼭 종료 조건을 구현해주어야 한다.

반응형

+ Recent posts