그래프를 탐색하기 위한 대표적인 두 가지 알고리즘
탐색 (Search)
- "많은 양의 데이터 중에서 원하는 데이터를 찾는 과정"
- 프로그래밍에서는 그래프, 트리 등의 자료구조 안에서 탐색을 하는 문제를 자주 다루는데, 대표적인 탐색 알고리즘으로 DFS와 BFS가 존재한다
- 위의 알고리즘들을 이해하려면 기본 자료구조인 스택과 큐에 대한 이해가 전제되어야 한다!
자료구조 (Data Structure)
- "데이터를 표현하고 관리하고 처리하기 위한 구조"
- 그중 스택과 큐는 자료구조의 기초 개념으로 다음의 두 핵심적인 함수로 구성된다
- 삽입(Push) : 데이터를 삽입한다
- 삭제(Pop) : 데이터를 삭제한다
- 물론 실제로 스택과 큐를 사용할 때는 삽입과 삭제 외에도 오버플로와 언더플로를 고민해야 한다
- 오버플로(Overflow) : 특정한 자료구조가 수용할 수 있는 데이터의 크기를 이미 가득 찬 상태에서 삽입 연산을 수행할 때 발생, 즉 저장 공간을 벗어나 데이터가 넘칠 때 발생한다!
- 언더플로(Underflow) : 특정한 자료구조에 데이터가 전혀 들어 있지 않은 상태에서 삭제 연산을 수행할 때 발생, 즉 데이터가 전혀 없는 상태에서 연산을 시도할 때 발생한다!
스택
- 스택은 박스 쌓기에 비유할 수 있다
- 박스는 아래에서부터 위로 차곡차곡 쌓으며, 아래에 있는 박스를 치우려면 위에 있는 박스를 먼저 내려야 한다
- 이렇나 구조를 선입후출(First In Last Out) 구조 또는 후입선출(Last In First Out) 구조라고 한다
- 삽입(5) - 삽입(2)- 삽입(3) - 삽입(7) - 삭제() - 삽입(1) - 삽입(4) - 삭제() 의 과정을 그림으로 나타내면 아래와 같다
- 위의 과정을 보면 스택에는 입출구가 한 개밖에 없어서 들어올 때도 오른쪽으로, 나갈 때도 오른쪽으로 나가게 된다
- 따라서 먼저 들어온 데이터일수록 안쪽으로 들어가기 때문에 가장 나중에 나오게 되는 것이다
- 이처럼 삭제 연산을 하면 가장 나중에 삽입한 원소가 먼저 삭제된다! -> 선입후출, 후입선출
이를 c++코드로 구현하면 아래와 같다!
#include <stack>
#include <iostream>
using namespace std;
int main()
{
stack<int> s;
s.push(5);
s.push(2);
s.push(3);
s.push(7);
s.pop();
s.push(1);
s.push(4);
s.pop();
while(!s.empty())
{
cout<<s.top()<<' ';
s.pop();
}
return 0;
}
#include <stack> 헤더파일을 사용하고, stack<int> s를 통해 스택을 선언한다
그리고 push()와 pop()을 이용하여 삽입, 삭제 연산을 수행한다
출력할 때는 s.empty()가 아니면 계속해서 s.top()으로 맨 위의 데이터를 출력하고, s.pop()으로 맨 위의 원소를 삭제한다
이 책에서는 파이썬 코드로 다루고 있기 때문에 파이썬 코드로 구현한 스택 코드도 확인해보자!
stack = []
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]) #가장 위에 있는 원소부터 출력
파이썬에서 스택을 사용할 때는 별도의 라이브러리를 사용할 필요가 없다!
기본 리스트 [ ]에서 append()와 pop()함수를 사용하면 스택 자료구조와 동일하게 동작한다
append() 함수는 리스트의 가장 뒤 쪽에 데이터를 삽입하고, pop() 함수는 리스트의 가장 뒤 쪽의 데이터를 꺼낸다
큐
- 큐(Queue)는 대기 줄에 비유할 수 있다
- 새치기 업이 줄을 서게 되면 나중에 온 사람일수록 나중에 들어간다
- 따라서 이는 공정한 자료구조라고 비유되며, 선입선출(First In First Out) 구조라고 한다
- 이 또한 코드로 구현하여 연산 과정을 확인해보자
- 삽입(5) - 삽입(2)- 삽입(3) - 삽입(7) - 삭제() - 삽입(1) - 삽입(4) - 삭제()
- 큐는 스택과 달리 입출구가 따로 구분되어 있다 -> 입구는 왼쪽, 출구는 오른쪽
- 따라서 데이터가 왼쪽으로 들어오면 가장 먼저 들어온 원소들을 오른쪽으로 밀린다
- 그리고 삭제 연산을 하면 오른쪽의 출구로 데이터를 빼내기 때문에 가장 먼저 들어온 원소가 나가게 된다
- 이를 선입선출(First In First Out) 구조라고 하는 것이다!
이제 이를 c++ 코드로 구현해서 확인해보자
#include <queue>
#include <iostream>
using namespace std;
int main()
{
queue<int> q;
q.push(5);
q.push(2);
q.push(3);
q.push(7);
q.pop();
q.push(1);
q.push(4);
q.pop();
while(!q.empty())
{
cout<<q.front()<<' ';
q.pop();
}
return 0;
}
#include <queue> 헤더파일을 선언하고, queue<int> q를 통해 큐를 선언한다.
그리고 삽입, 삭제 연산은 스택과 동일하게 push()와 pop()으로 수행한다.
그 후 q.empty()가 아닐 때까지 계속해서 출력하는데, q.front()로 가장 출구쪽에 가까운 원소부터 출력하고, q.pop()을 통해 데이터를 삭제한다
다음은 파이썬 코드이다
from collections import deque
queue = deque()
queue.append(5)
queue.append(2)
queue.append(3)
queue.append(7)
queue.pop()
queue.append(1)
queue.append(4)
queue.pop()
print(queue) #먼저 들어온 순서대로 출력
queue.reverse()
print(queue)
파이썬으로 큐를 구현할 때는 collections 모듈에서 제공하는 deque 자료구조를 활용한다
deque는 스택과 큐의 장점을 모두 채택한 것인데 데이터를 넣고 빼는 속도가 리스트 자료형에 비해 효율적이며, queue 라이브러리를 이용하는 것보다 더 간단하다
만약 deque 객체를 리스트 자료형으로 변경하고자 한다면 list() 메소드를 이용하면 된다
재귀함수
- DFS와 BFS를 구현하려면 재귀 함수에 대한 이해도 필요하다
- 재귀함수(Recursive Function)란 자기 자신을 다시 호출하는 함수를 의미한다
- 재귀함수는 프랙털(Fractal) 구조와 흡사하다
- 아래는 시에르핀스키의 삼각형으로, 삼각형 안에 삼각형이 무한히 존재하는 그림이다
- 이는 프랙털 구조의 대표적인 그림으로 실제로 이러한 프랙털 이미지를 출력하는 프로그램을 작성할 때는 재귀함수를 이용한다
재귀 함수의 종료 조건
- 재귀 함수를 사용할 때는 재귀 함수의 종료 조건을 꼭 명시해야 한다
- 종료 조건을 명시하지 않으면, 함수가 무한 호출될 수 있다
- 컴퓨터 내부에서 재귀 함수의 수행은 스택 자료구조를 이용한다
- 함수를 계속 호출했을 때 가장 마지막에 호출한 함수가 먼저 수행을 끝내야 그 앞의 함수 호출이 종료되기 때문이다
- 실제로 컴퓨터 구조상으로도 연속해서 호출되는 함수는 메인 메모리의 스택 공간에 적재되므로 재귀 함수는 스택 자료구조와 같다고 할 수 있다
- 재귀 함수 = 스택 자료구조
- 따라서 스택 자료구조를 활용해야 하는 상당수 알고리즘은 재귀 함수를 이용하여 간편하게 구현될 수 있다!
- DFS가 대표적인 예이다
- 재귀 함수를 이용하는 대표적인 예제로는 팩토리얼(Factorial) 문제가 있다
- 팩토리얼 함수는 n이 1 이하가 되었을 때 함수를 종료하는 재귀 함수의 형태로 구현할 수 있다
- 팩토리얼 예시를 통해 반복적 구현과 재귀적 구현 방식을 비교해보자!
#include <queue>
#include <iostream>
using namespace std;
int factorial_iterative(int n)
{
int result=1;
for(int i=1;i<=n;i++)
{
result*=i;
}
return result;
}
int factorial_recursive(int n)
{
if(n<=1) return 1;
return n*factorial_recursive(n-1);
}
int main()
{
int n=5;
cout<<factorial_iterative(n)<<'\n';
cout<<factorial_recursive(n);
return 0;
}
실행결과는 동일하게 나온다
하지만 재귀함수의 코드가 보다 간결한 것을 알 수 있다
왜냐하면 재귀 함수가 수학의 점화식(재귀식)을 그대로 소스코드로 옮겼기 때문이다
추가)
유클리드 호제법
유클리드 호제법은 재귀 함수를 통해 구현이 가능하다
이를 코드로 구현하면 아래와 같다
#include <iostream>
using namespace std;
int gcd(int a, int b)
{
if(a%b==0) return b;
return gcd(b, a%b);
}
int main()
{
int a=192, b=162;
int result = gcd(a,b);
cout<<result;
return 0;
}
결론
- 재귀 함수를 잘 활용하면 복잡한 알고리즘도 간결하게 작성할 수 있으나, 자칫하면 더 어려운 형태가 될 수 있으므로 신중하게 사용해야 한다
- 모든 재귀 함수는 반복문을 이용하여 동일한 기능을 구현할 수 있다
- 재귀 함수가 반복문보다 유리할 수도 불리할 수도 있기 때문에 잘 선택해서 사용해야 한다
- 컴퓨터가 함수를 연속적으로 호출하면 컴퓨터 메모리 내부의 스택 프레임에 쌓이기 때문에 스택을 사용해야 할 때 구현상 스택 라이브러리 대신에 재귀 함수를 이용하는 경우가 많다
'코딩테스트 > 이코테' 카테고리의 다른 글
[CH5 DFS/BFS] 탐색 알고리즘 DFS/BFS (2) (0) | 2021.12.21 |
---|---|
[CH5 DFS/BFS] 탐색 알고리즘 DFS/BFS (1) (0) | 2021.12.21 |
[CH4 구현] 게임 개발 (0) | 2021.12.09 |
[CH4 구현] 왕실의 나이트 (1) | 2021.12.08 |
[CH4 구현] 아이디어를 코드를 바꾸는 구현 3 (0) | 2021.12.07 |
댓글