자료구조 기초(파이썬 : Python)
탐색(Search)
많은 양의 데이터 중에서 원하는 데이터를 찾는 과정을 뜻한다.
프로그래밍에서는 그래프, 트리 등의 자료구조 안에서 탐색을 하는 문제를 자주 다룬다. 대표적인 탐색 알고리즘으로 DFS와 BFS를 꼽을 수 있다.
DFS와 BFS를 제대로 이해하려면 기본 자료구조인 스택과 큐에 대한 이해가 전제되어야 하므로 사전 학습으로 스택과 큐, 재귀 함수를 알아야 한다.
자료구조(Data Structure)
'데이터를 표현하고 관리하고 처리하기 위한 구조'를 의미한다. 그중 스택과 큐는 자료구조의 기초 개념으로 삽입과 삭제의 두 핵심함수로 구성된다.
- 삽입(Push) : 데이터 삽입
- 삭제(Pop) : 데이터를 삭제한다.
실제로 스택과 큐를 사용할 때는 삽입과 삭제 외에도 오버플로와 언더플로를 고민해야한다.
오버플로(overflow)는 특정한 자료구조가 수용할 수 있는 데이터의 크기를 이미 가득 찬 상태에서 삽입연산을 수행할 때 발생한다. 즉, 저장 공간을 벗어나 데이터가 넘쳐흐를 때 발생한다. 반면에 특정한 자료구조에 데이터가 전혀 들어 있지 않은 상태에서 삭제 연산을 수행하면 데이터가 없는 상태이므로 언터플로(underflow)가 발생한다.
스택(Stack)
스택은 박스 쌓기에 비유할 수 있다. 박스는 아래부터 위로 차곡차곡 쌓고, 아래있는 박스를 치우기 위해서는 위에서부터 박스를 치워야한다. 이런 구조를 선입후출 구조 또는 후입선출 구조라고 한다.
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( ) 메서드는 리스트의 가장 뒤쪽에서 데이터를 꺼낸다.
큐(Queue)
큐는 대기 줄에 비유할 수 있다. 줄을 설때 먼저 온사람이 먼저 들어가고, 나중에 온사람이 나중에 들어가는 이러한 구조를
선입선출 구조라고 한다.
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 모듈과 같은 기본 라이브러리 사용을 허용하므로 사용해도 괜찮다.)
재귀함수(Recursive Function)
DFS와 BFS를 구현하려면 재귀 함수도 이해하고 있어야 한다. 재귀함수란 '자기 자신을 다시 호출하는 함수'를 의미한다.
def recursive_funtion():
print('재귀 함수 호출')
recursive_funtion()
recursive_funtion()
위의 함수를 실행하면 '재귀 함수 호출'을 무한 반복출력한다. 자기 자신 recursive_function()을 계속해서 불러오기 때문이다.
재귀함수의 종료 조건
재귀함수를 문제 풀이에서 사용할 때는 재귀 함수가 언제 끝나는지 종료 조건을 꼭 명시해야 한다.
def recursive_funtion(i):
# 100번째 출력했을 때 종료되도록 종료 조건을 명시한다
if i == 100:
return
print(i, '번째 재귀 함수가', i+1, '번째 재귀 함수 호출')
recursive_funtion(i+1)
print(i, '번째 재귀함수 종료')
recursive_funtion(1)
<출력결과>
1 번째 재귀 함수가 2 번째 재귀 함수 호출
2 번째 재귀 함수가 3 번째 재귀 함수 호출
...
98 번째 재귀 함수가 99 번째 재귀 함수 호출
99 번째 재귀 함수가 100 번째 재귀 함수 호출
99 번째 재귀함수 종료
98 번째 재귀함수 종료
97 번째 재귀함수 종료
...
3 번째 재귀함수 종료
2 번째 재귀함수 종료
1 번째 재귀함수 종료
컴퓨터 내부에서 재귀 함수의 수행은 스택 자료구조를 이용한다. 함수를 계속 호출했을 때 가장 마지막에 호출한 함수가 먼저 수행을 끝내야 그 앞의 함수 호출이 종료되기 때문이다.
재귀 함수는 내부적으로 스택 자료구조와 동일하다는 것만 기억하자. 따라서 스택자료구조를 활용해야 하는 상당수 알고리즘은 재귀 함수를 이용해서 간편하게 구현될 수 있다. DFS가 대표적인 예이다.
재귀 함수를 이용하는 대표적 예제로는 팩토리얼(Factorial)문제가 있다.
#반복적으로 구현한 n!
def recursive_funtion(n):
result = 1
# 1부터 n까지의 수를 차례대로 곱하기
for i in range(1, n+1):
result *= i
return result
#재귀 적으로 구현한 n!
def factorial_recursive(n):
if n <= 1 :
return 1
# n! = n * (n -1)! 를 그대로 코드로 작성
return n * factorial_recursive(n -1)
print("반복적 : " , recursive_funtion(5))
print("재귀적 : " , factorial_recursive(5))
'''
<출력 결과>
반복적 : 120
재귀적 : 120
'''
반복문 보다 재귀 함수의 코드가 더 간결하다.
팩토리얼을 수학적 점화식으로 표현해보면 다음과 같다.
- n이 0 혹은 1 일때 : factorial(n) = 1
- n이 1 보다 클 때 : factorial(n) = n × factorial(n-1)
재귀 함수는 n이 1이하인 경우를 고려하지 않으면 무한 반복되므로 if문을 이용하여 꼭 종료 조건을 구현해주어야 한다.