그래프 = 정점(vertex/node) + 간선(edge)로 이루어진 자료구조
무방향 그래프(undirected graph)
방향 그래프(directed graph) : 간선에 방향성이 있는 그래프
완전 그래프(Complete graph) : 두 정점 쌍이 간선으로 연결된 그래프( = 모든 정점이 완전히 모든 경우의 간선으로 연결된 그래프)
연결 그래프(Connected graph) : 임의의 두 정점 사이에 경로가 항상 존재하는 그래프 ( = 모든 정점이 연결은 되있되, 모든 경우의 간선으로 연결된 그래프는 아니다.)
인접행렬(adjacency matrix) : 행렬에다 0과 1을 표시함으로써 두 정점 사이의 인접 여부를 표현하는 방법
단순 무방향 그래프
=> 연결된 두 정점에는 1을 , 연결되지 않은 두 정점에는 0을 준다
정점 2와 3이 연결되어 있다면 (2,3) 과 (3,2) 에다 1을 할당해준다
단순 방향 그래프
=> (2,3)이 1이라는 의미 : 2에서 3으로 가는 간선이 있다는 의미
단순 그래프가 아닌 경우
int adj_matirx[10][10] = {};
int v, e; // 정점 개수, 간선 개수
cin >> v >> e; // 5,7 입력받음
for(int i = 0; i < e; i++){
int u, v;
cin >> u >> v; // 3,1 이 입력되면 3->1 로 가는 간선이 있다는 뜻
adj_matrix[u][v] = 1;
}
2.무방향 그래프
int adj_matrix[10][10] = {};
int v, e;
cin >> v >> e;
for(int i=0; i<e; i++){
int u, v;
cin >> u >> v; // 3,1 이 입력되면 3과 1이 이어져있다는 뜻
adj_matrix[u][v] = 1;
adj_matrix[v][u] = 1;
}
V개의 리스트를 만들어 각 리스트에 자신과 연결된 정점을 넣는 방식
정점이 많고 간선은 상대적으로 적은 상황에서 공간을 절약할 수 있는 방식
인접 행렬에서는 V by V 크기의 배열을 선언했어야 하니 O(V^2)의 공간이 필요했는데, 인접 리스트에서는 O(V+E) 의 공간이 필요.
adj[i] : 정점 i와 연결된 정점들의 목록을 가지고 있는 벡터
방향 그래프인지, 무방향 그래프인지에 따라서 adj[u] 에만 값을 넣으지, 아니면 adj[u] 와 adj[v] 에 모두 넣을지 결정
vector<int> adj[10];
int v, e;
cin >> v >> e;
for(int i=0; i < e; i++){
int u, v;
cin >> u >> v;
adj[u].push_back(v);
}
vector<int> adj[10];
int v, e;
cin >> v >> e;
for(int i=0; i < e; i++){
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
int edge[10][2];
int deg[10]; // 각 정점의 outdegree
int* adj[10];
int idx[10]; // adj[i]에서 새로운 정점을 추가할 때의 위치
int main(){
int v, e;
for(int i=0; i<e; i++){
cin >> edge[i][0] >> edge[i][1];
deg[edge[i][0]]++;
}
for(int i=1; i <= v; i++)
adj[i] = new int[deg[i]];
for(int i=0; i<e; i++){
int u = edge[i][0];
int v = edge[i][1];
adj[u][idx[u]] =v;
idx[u]++;
}
}
인접행렬
인접 리스트
공간 복잡도 : O(V+E)
정점 u,v가 연결되어있는지 확인하는 시간 : O(min(deg(u), deg(v))
정점 v와 연결된 모든 정점을 확인하는 시간 : O(deg(v))
효율적인 상황 : 특정 정점에 연결된 모든 정점을 자주 확인할 때 / E가 V^2보다 훨씬 작을 때
일반적인 문제에서 정점 u,v 가 연결되어 있는지를 반복적으로 확인해야야 하는 경우는 잘 없다.
BFS, DFS 등 여러 경로 알고리즘 => 특정 정점에 연결된 모든 정점을 확인하는 작업이 반복적으로 등장함.
(입력이 인접 행렬 느낌으로 주어지거나, V가 많이 작아서 구현의 편의를 챙기고자 하거나, 플로이드 알고리즘 등은 인접 행렬로 구현)
너비 우선 탐색
모든 노드를 방문하기 위한 알고리즘
구현 방법 : 1. 그래프(인접 행렬, 인접 리스트) // 2. 다차원 배열
다차원 배열에서는 상하좌우로 인접한 칸을 확인했던 반면에, 그래프에서는 간선으로 연결된 정점을 본다는 차이만 있다.
1.시작하는 정점을 큐에 넣고 방문했다는 표시를 남김
2.큐에서 정점을 꺼내어 그 정점과 연결된 모든 정점들에 대해 3번을 진행
3.해당 정점을 이전에 방문했다면 아무것도 하지않고, 처음으로 방문했다면 방문했다는 표시를 남기고 해당 칸을 큐에 삽입
4.큐가 빌 때까지 2번을 반복
1.인접 행렬 : O(V^2)
2.인접 리스트 : O(V+E)
각 간선은 2번씩 등장한다.
=> 따라서 모든 정점들에 대해 그 정점과 연결된 모든 정점을 살펴보는 과정은 2E번 혹은 E번의 탐색 과정이 필요하게 되서 O(V+E) 가 걸린다.
다차원 배열에서의 BFS는 그냥 간선이 각 칸마다 최대 4개가 있는 상황이였다 보니 그냥 시간복잡도가 칸 수에 비례한다고 쉽게 생각했었다.
vector<int> adj[10]; // 인접 리스트
bool vis[10]; // 이전에 방문했는지 여부를 체크하는 리스트
void BFS(){
queue<int> q;
q.push(1); // 정점 1을 먼저 최초로 방문
vis[1] = true; // 1을 방문했다고 체크해줌
// 정점 1에서부터 시작해서 이와 인접한 정점으로 범위를 점점 뻗어가며 탐색
while(!q.empty()){
int cur = q.front(); // 큐의 맨앞 데이터값 출력 후 삭제
q.pop();
cout << cur << ' ';
for(auto nxt : adj[cur]){ // 큐 맨앞에서 추출한 정점 cur에 대해 인접 리스트를 탐색
// 인접 리스트를 탐색하면서 정점 cur와 인접해 있는 정점들에 대해 방문 여부를 체크한다.
if(vis[nxt]) // 이전에 방문한 정점이면 건너뜀
continue;
q.push(nxt); // 처음 방문하는 정점이면 큐에 push
vis[nxt] = true; // 방문 처리함
}
시작점에서 다른 모든 점으로의 최단 경로를 찾을 때 BFS 를 이용할 수 있다.
- BFS 과정중에서 현재 보는 정점으로부터 추가되는 인접한 정점은 시작점으로부터 1만큼 더 떨어져있다.
- 즉, 모든 간선의 길이가 동일한 상황에서 두 지점 사이의 거리를 찾는 문제는 BFS 로 해결 가능
- 하지만 모든 간선의 길이가 다르다면 플로이드나 다익스트라와 같은 다른 알고리즘을 사용해야 한다.
vector<int> adj[10]; // 인접 리스트
int dist[10]; // 방문여부체크 => -1 : 아직 방문x , 0 이상의 값 : 시작점으로 부터의 거리 값
void BFS(){
fill(dist, dist+10, -1); // 배열 dist를 -1로 초기화
// 이후에 dist 배열이 -1인지 아닌지를 가지고 방문 여부를 판단
queue<int> q;
q.push(1);
dist[1] = 0; // 방문 했다고 체크
while(!q.empty()){
int cur = q.front();
q.pop();
for(auto nxt : adj[i]){
if(dist[nxt] != -1) // 이미 방문한 정점이라면 pass
continue;
q.push(nxt);
dist[nxt] = dist[cur] + 1; // 시작점으로부터의 거리값 1 증가
}
}
}
연결 그래프가 아닌 곳에서 순회를 하면 모든 정점을 다 순회하지 못한다.
for문을 돌려서 아직 방문하지 않은 정점을 찾아서 그 정점을 시작 정점으로 잡게끔 코드를 구성할 것!
vector<int> adj[10];
bool vis[10];
int v = 9; // 정점의 수
void BFS(){ // 아직 방문안한 정점을 찾아서 시작 정점으로 설정
queue<int> q;
for(int i=1; i <= v; i++){
if(vis[i]) // while 문에서 본격적인 BFS 탐색을 진행하기 전에, 다차원 배열에서 새로운 구역을 찾았듯이 끊어진 새로운 그래프 구역을 찾아낸다.
continue;
q.push(i);
vis[i] = true;
while(!q.empty()){
int cur = q.front(); // 큐 맨앞 정점 삭제후 출력
q.pop();
cout << cur << ' ';
for(auto next : adj[cur]){
if(vis[nxt]) // 이전에 방문한 정점이면 건너뜀
continue;
q.push(nxt); // 처음 방문하는 정점이면 큐에 push
vis[nxt] = true; // 방문 처리함
}
}
}
}
스택으로 구현
시작하는 정점을 스택에 넣고 방문했다는 표시를 남김
스택에서 정점을 꺼내어 그 정점과 연결된 모든 정점들에 대해 3번을 진행
해당 정점을 이전에 방문했다면 아무것도 하지 않고,
처음으로 방문했다면 방문했다는 표시를 남기고 해당 칸을 스택에 삽입
스택이 빌 때 까지 2번을 반복
vector<int> adj[10];
bool vis[10];
boid DFS(){
stack<int> s;
s.push(1);
vis[1] = true;
while(!s.empty()){
int cur = s.top();
s.pop();
cout << cur << ' ';
for(auto nxt : adj[cur]){
if(vis[nxt])
continue;
s.push(nxt);
vis[nxt] = true;
}
}
}
재귀를 사용해 DFS를 구현할 수 있다.
=> 재귀적으로 함수를 호출하는 상황 자체가 가장 늦게 호출된 함수를 먼저 처리하는 LIFO, 즉 자연스럽게 스택을 이용하는 모양새가 되어서 그렇다고 생각할 수 있다.
스택 메모리에 제한이 작은 경우에는 재귀 구현에 문제가 발생함.
(백준,프로그래머스 에서는 스택 메모리 제한 없어서 맘대로 구현해도됨
SW Expert Academy 에서는 스택 메모리가 1MB로 제한)
vector<int> adj[10];
bool vis[10];
void DFS(int cur){
vis[cur] = true;
cout << cur << ' ';
for(auto nxt : adj[cur]){
if(vis[nxt])
continue;
DFS(nxt);
}
}
재귀적인 방식으로 DFS를 돌면 1,2,4,3 순으로 방문한다.
갈 수 있는 곳이 여러 개라면 번호가 작은 것 부터 방문한다고 할 때
DFS(1) 은 DFS(2) 를 부르고, DFS(2) 는 DFS(4) 를 부르고, DFS(4) 는 DFS(3) 을 부른다.
맨 처음 1번 정점을 스택에서 뽑았을 때 이웃한 2,3,4번 정점에 모두 방문했다는 기록을 남겨놓고 다음 정점으로 넘어감.
이 떄문에 1 2 3 4 순으로 방문을 한다.
재귀적인 방식은 실제 방문을 할때 방문 표시를 남기는 반면에,
비재귀적인 방식(스택 활용방식)은 방문하기 전에 아직 방문하지 않는 곳을 발견하면 그때 방문 표시를 남기기 때문에 동작 방식의 차이가 있다.
우리가 통상적으로 생각하는 DFS는 재귀 DFS가 동작하는 방식과 일치한다.
=> 즉, 비재귀(스택) DFS는 순회를 잘 수행하지만, 엄밀히 말해 우리가 관념적으로 생각하는 DFS 방문 순서와 다르다.
DFS 고유한 성질을 사용해 문제를 해결하는 경우는 비재귀 코드를 이용하지 말것!
이전 코드에서는 nxt를 스택에 넣은 직후에 바로 방문 여부, 즉 vis[nxt] 를 true 로 만들어 스택에 각 정점이 무조건 1번씩만 만들었다.
반면 이 코드는 스택에 넣을 때 방문 표시를 남기는게 아니라, 스택에서 값을 뽑은 후에 방문 여부를 true 로 만들도록 했다.
이렇게 수정하면 우리가 관념적으로 생각하는 DFS 동작 (방문순서 동작) 과 일차한다.
스택 자체에는 각 정점이 무조건 1번씩 들어가지는 않고 여러 번 들어갈 수도 있지만, " if(visited[cur]) " 처리로 인해 결국 연결되니 정점을 보는 작업은 각 정점마다 최대 1번씩만 하기 떄문에 시간복잡도는 동일하게 O(V+E) 이다.
vector<int> adj[10];
bool visited[10];
void DFS(){
stack<int> s;
s.push(1);
while(!s.empty()){
int cur = s.top();
s.pop();
if(visited[cur])
continue;
visited[cur] = true;
cout << cur << ' ';
for(auto nxt : adj[cur])
if(visited[nxt])
continue;
s.push(nxt);
}