일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |
- 스프링 핵심 원리 - 기본편
- 트리
- Spring
- 그리디
- BinarySearch
- 개발자취업
- 코딩테스트준비
- 우선순위큐
- Java
- 네트워크 계층
- lower bound
- 데이터베이스
- 99클럽
- 백트래킹
- 정렬
- 자바
- 브루트포스
- DP
- 동적 프로그래밍
- Til
- 스프링
- 그래프
- DFS
- 항해99
- 프로그래머스
- 알고리즘
- 그래프 이론
- BFS
- 백준
- 완전탐색
- Today
- Total
AtraFelis's Develop Diary
[99클럽 코테 스터디 7일차 TIL / 백준] 1697 - 숨바꼭질 (JAVA) 본문
99클럽 코테스터디 7일차 TIL
KeyWord : BFS, DP, 최단 거리
문제
수빈이는 동생과 숨바꼭질을 하고 있다. 수빈이는 현재 점 N(0 ≤ N ≤ 100,000)에 있고, 동생은 점 K(0 ≤ K ≤ 100,000)에 있다. 수빈이는 걷거나 순간이동을 할 수 있다. 만약, 수빈이의 위치가 X일 때 걷는다면 1초 후에 X-1 또는 X+1로 이동하게 된다. 순간이동을 하는 경우에는 1초 후에 2*X의 위치로 이동하게 된다.
수빈이와 동생의 위치가 주어졌을 때, 수빈이가 동생을 찾을 수 있는 가장 빠른 시간이 몇 초 후인지 구하는 프로그램을 작성하시오.
입력
첫 번째 줄에 수빈이가 있는 위치 N과 동생이 있는 위치 K가 주어진다. N과 K는 정수이다.
출력
수빈이가 동생을 찾는 가장 빠른 시간을 출력한다.
풀이
어떤 방법으로 접근해야 해결할 수 있을지 고민되었던 문제이다. 적합하다고 생각되는 방법론이 꽤 많았기 때문이다.
- 이분탐색
- 그리디
- DFS
- BFS
- DP
괜찮을 것 같은 방법론은 이 정도였다. 이제 차례대로 검토해보자.
이분탐색?
범위가 $0$ ~ $100000$으로 정해져 있으므로 가능할 듯 싶었다.
lo는 수빈이와 동생의 위치가 같을 경우인 0, hi는 수빈이가 0, 동생이 100000일 때 수빈이가 무조건 1칸씩만 움직여서 이동할 경우인 100000으로 잡는다.
여기서 이분탐색으로 해를 구할 수 있을까? 이분탐색을 조금씩 진행해보자.
5 17
이렇게 입력이 주어졌을 때, 첫 mid는 11이 된다. 그런데 여기서 lo와 hi를 어떻게 움직여야 해를 구할 수 있을까?
그저 문제가 17까지의 최소 이동횟수를 구하는 것에서 mid까지의 최소 이동 횟수를 구하는 것이 되어버리는 상황이 만들어졌다.
결론. 이분탐색으로는 안 될 듯하다.
그리디?
그리디 알고리즘은 현재 주어진 조건에서 최선의 선택을 하여 문제의 해까지 도달하는 방법이다.
그렇다면 생각해보자.
수빈이의 시작 시점인 $5$에서 어떻게 움직여야 최선의 선택이 되는 걸까? 2배를 하면 가장 많이 이동되므로 이게 최선의 선택일까?
하지만, 5 → 10 → 9 → 18 → 17 이렇게, 가끔은 뒤로 움직인 후에 두 배를 하거나, 한 칸 앞으로 가는 것이 최선의 선택인 경우도 있는데?
결론. 현재 시점에서 무엇이 최선의 선택인지 알수 있는 방법이 없으므로 그리디도 안 될 듯하다.
DFS
일단 DFS로 풀려면 그래프여야 하지 않나? 그런 의문이 들 수도 있다.
먼저, 그래프에 대해서 생각해볼 필요가 있는데, 사실 그래프면 노드끼리 연결이 되어 있는가를 중점으로 생각하면 된다.
이 문제에서 노드는 위치일 것이고, 간선은 이동 방법이 될 것이다.
5 12
이런 입력이 들어왔을 때를 그래프로 표현하면 이렇게 된다.
즉, 이 문제도 그래프로 표현할 수 있다.
그렇다면 DFS를 이용하여 최단 거리를 찾을 수 있을까?
찾을 수는 있다. 하지만 모든 경우의 수를 탐색한 후에야 이것이 최단 거리인지 알 수 있다. 이 문제와 같은 경우에는 최대 100000까지 깊이가 쌓일 수 있으므로 메모리 초과가 나타나거나, 시간 초과가 나타날 것이다.
이 문제 기준으로 $O(3^N)$라는 시간 복잡도의 알고리즘이 만들어진다.
결론. 효율성 측면에서 DFS도 제외.
BFS
최단 거리 알고리즘에 대해 알고 있다면, 쉽게 떠올릴 수 있었을 것이다. 다익스트라 알고리즘, 벨만-포드 알고리즘과 같은 것도 기본적으로 BFS를 기반으로 하는 알고리즘이다.
하지만, 이 문제의 경우에는 가중치가 없는 그래프이므로 저런 알고리즘까지 갈 것 없이, 그냥 BFS만 사용하여 해결할 수 있다. (벨만-포드를 사용할 경우 오히려 효율적인 측면에서 손해를 보게 된다.)
위 예시를 다시 가져와보자.
5 12
여기서 BFS를 진행해보자. 단, 이미 방문했던 노드는 재방문하지 않는다. 또, 큐에 노드를 저장할 때, 이동횟수도 함께 저장을 해준다.
- 큐 [ ] : 이동 횟수 : 1
- 5 → 6
- 5 → 10
- 큐 [4, 10] : 현재 노드 = 6 / 이동횟수 : 2
- 6 → 5 : 5번 노드는 이미 방문했으므로, 큐에 추가하지 않는다.
- 6 → 7
- 6 → 12 (return 2)
벌써 최단 거리를 찾았다. 코드 상에서는 이때 이동 횟수를 리턴해주면 된다.
하지만 이게 최단 거리를 보장하는가에 의문이 들 수 있다. 이것에 대한 의문은 BFS가 노드를 방문하는 방법을 따라가면, BFS로 먼저 도달하는 경로가 최단거리를 보장한다는 것을 자연스럽게 알 수 있다.
코드로 구현하면 이렇게 된다.
private static int bfs(int start, int end) {
Queue<int[]> q = new LinkedList<>();
boolean[] visited = new boolean[100001];
int prev_pos = start;
int cnt = 0;
q.add(new int[]{prev_pos, cnt});
if(start >= end) {
return start - end;
}
while (true) {
int[] tmp = q.poll();
prev_pos = tmp[0];
visited[prev_pos] = true;
cnt = tmp[1] + 1;
int cur_pos;
int[] move = {prev_pos, 1, -1};
for (int i = 0; i < 3; i++) {
cur_pos = prev_pos + move[i];
// 가지치기
if (cur_pos > 100000 || cur_pos < 0 || visited[cur_pos]) {
continue;
}
q.add(new int[]{cur_pos, cnt});
if(cur_pos == end) {
return cnt;
}
}
}
}
하지만 유의할 점이 몇 가지 있다.
Q. 수빈이의 위치가 100000을 초과할 수 있는가?
문제의 조건 상으로는 넘을 수는 있다. 시작할 때 주어지는 위치가 0 이상 100000이하인 것이지, 이동할 때는 상관이 없으니까.
- 50001 → 100002 → 100001 → 100000
- 50001 → 50000 → 100000
하지만 어느 경우라도 앞으로 갔다가 뒤로 빼는 것보다는 이렇게 가는 것이 더 빠르다. 또 100000을 초과하는 경우를 포함해버리면, 경우의 수가 너무 많아져 효율성이 떨어지는 문제가 발생한다.
Q. 수빈이의 위치가 음수가 될 수 있는가?
문제의 조건 상으로는 넘을 수 있다. 하지만, 음수로 갈 필요가 없다는 것을 명심하자. -1로 갈 수는 있지만, -1로 갔을 경우 최단 거리가 나올 수는 없다.
-1에서 순간이동을 하면 -2가 될 것이고, -1에서 다시 1칸 앞으로 이동하는 건 별 의미 없는 이동이 되니까.
이 당연한 것을 언급하는 이유는, 탐색 도중 수빈이의 위치가 음수가 되는 경우가 발생하기 때문이다. 이 경우에는 큐에 들어가지 않도록 가지치기를 해주어야 한다.
if (cur_pos > 100000 || cur_pos < 0 || visited[cur_pos]) {
continue;
}
이 부분을 추가한 이유다. 효율성 때문도 있지만, visted 배열에서 Array Index Out of Bounds Exception 예외가 발생할 수 있기 때문이다.
결론. BFS로 가능하다.
DP?
DP는 충분히 가능성이 있어 보였다. 일단 범위 자체가 100,000으로 충분히 DP로 커버가 가능한 크기다.
수빈이의 위치부터 시작하여, 동생의 위치까지 각 위치마다 최소 이동 횟수를 기록하면서 나아가면 충분히 가능하지 않을까?
// end는 동생의 위치
// start은 수빈이의 시작 위치
int[] dp = new int[end+1]
int cnt = 0;
for (int i = start; i >= 0; i--) {
dp[i] = cnt++;
}
cnt=0;
for (int i = start; i <= end + 1; i++) {
dp[i] = cnt++;
}
일단 이렇게 시작할 수 있겠다.
- 수빈이가 자신의 시작 위치까지 갈 수 있는 최단 거리는 가만히 아무것도 안 하는 것이므로 0인 것은 자명하다.
- 다른 위치는 무조건 한 칸씩 이동하여 움직였을 때로 초기화해준다. 이때, 최소 이동 거리보다 작은 값이 들어갈 수 없다는 것 또한 자명하다.
이제 천천히 생각해보자.
dp[17]
로 이동할 수 있는 방법에는 무엇이 있을까?
16에서 한 칸 앞으로 이동, 18에서 한 칸 뒤로 이동. 17은 홀수이므로 이전 위치에서 순간이동으로 한 번에 도착할 수 있는 방법은 없다.
하지만, dp[9]
→ dp[18]
→ dp[17]
이렇게 두 번에 걸쳐 이동하는 것이 더 빠를 수도 있다. (이 경우가 18에서 한 칸 뒤로 이동하는 경우를 포함하고 있다는 것까지 생각하면 좋다.)
9 17
로 입력이 들어왔을 경우를 생각해보자.
따라서, 의사코드로 정리하면
dp[17] = Math.min(dp[16]+1, dp[9]+2, dp[17]);
이렇게 된다.
dp[16]
이라면?
15에서 한 칸 앞으로 이동, 17에서 한 칸 뒤로 이동, 8에서 순간이동.
dp[16] = min(dp[15]+1, dp[17]+1, dp[8]+1, dp[16]);
즉, 다음으로 이동해야할 위치가 짝수일 경우, 홀수일 경우를 분리하여 생각해야 한다.
이제 두 경우를 합쳐보자. 다음으로 이동해야 할 위치를 pos라고 했을 때, 최종 의사코드는 이렇게 될 것이다.
if(next_pos가 짝수 혹은 0이면)
dp[pos] = min(dp[pos-1]+1, dp[pos+1]+1, dp[pos/2]+1, dp[pos])
else
dp[pos] = min(dp[pos-1]+1, dp[(pos+1)/2]+1, dp[pos]);
이제 이대로 구현만 하면 된다.
private static int dp(int start, int end) {
if(start >= end) {
return start - end;
}
int[] dp = new int[end+2];
int cnt = 0;
for (int i = start; i >= 0; i--) {
dp[i] = cnt++;
}
cnt=0;
for (int i = start; i <= end + 1; i++) {
dp[i] = cnt++;
}
for (int i = start + 1; i <= end; i++) {
if (i % 2 == 0) {
dp[i] = Math.min(dp[i], dp[i / 2] + 1);
} else {
dp[i] = Math.min(dp[i], dp[(i + 1) / 2] + 2);
}
dp[i] = Math.min(dp[i], dp[i - 1] + 1);
}
return dp[end];
}
결론. DP로 가능.
코드
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.Queue;
import java.util.StringTokenizer;
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer stk = new StringTokenizer(br.readLine());
int n, m;
n = Integer.parseInt(stk.nextToken());
m = Integer.parseInt(stk.nextToken());
// int answer = dp(n, m);
int answer = bfs(n, m);
System.out.println(answer);
}
private static int bfs(int start, int end) {
Queue<int[]> q = new LinkedList<>();
boolean[] visited = new boolean[100001];
int prev_pos = start;
int cnt = 0;
q.add(new int[]{prev_pos, cnt});
if(start >= end) {
return start - end;
}
while (true) {
int[] tmp = q.poll();
prev_pos = tmp[0];
visited[prev_pos] = true;
cnt = tmp[1] + 1;
int cur_pos;
int[] move = {prev_pos, 1, -1};
for (int i = 0; i < 3; i++) {
cur_pos = prev_pos + move[i];
// 가지치기
if (cur_pos > 100000 || cur_pos < 0 || visited[cur_pos]) {
continue;
}
q.add(new int[]{cur_pos, cnt});
if(cur_pos == end) {
return cnt;
}
}
}
}
private static int dp(int start, int end) {
if(start >= end) {
return start - end;
}
int[] dp = new int[end+2];
int cnt = 0;
for (int i = start; i >= 0; i--) {
dp[i] = cnt++;
}
cnt=0;
for (int i = start; i <= end + 1; i++) {
dp[i] = cnt++;
}
for (int i = start + 1; i <= end; i++) {
if (i % 2 == 0) {
dp[i] = Math.min(dp[i], dp[i / 2] + 1);
} else {
dp[i] = Math.min(dp[i], dp[(i + 1) / 2] + 2);
}
dp[i] = Math.min(dp[i], dp[i - 1] + 1);
}
return dp[end];
}
}
성능 비교
- BFS : $O(n)$ : 실질적으로는 가지치기 코드 때문에 $O(end)$
- DP : $O(end)$ : 동생의 위치에 따라 반복 횟수가 달라지므로.
시간 복잡도는 똑같지만 DP가 더 빠른 것을 볼 수 있는데, 이것은 BFS에서는 큐를 사용하기 때문이다.
그렇다고는 해도 두 배 가까이 차이가 있을 줄은 몰랐다.
References
https://didu-story.tistory.com/441 : DP로 풀이하면서 참고했습니다. 제 글이 이해가 안 되었다면, 이 글을 참고하시는 것을 추천.
'Algorithm > 백준' 카테고리의 다른 글
[99클럽 코테 스터디 9일차 TIL / 백준] 1707 - 이분 그래프 (JAVA) (0) | 2025.01.23 |
---|---|
[99클럽 코테 스터디 8일차 TIL / 백준] 2667 - 단지번호붙이기 (JAVA) (0) | 2025.01.22 |
[99클럽 코테 스터디 6일차 TIL / 백준] 1260 - DFS와 BFS (JAVA) (0) | 2025.01.20 |
[99클럽 코테 스터디 5일차 TIL / 백준] 2470 - 두 용액 (JAVA) (0) | 2025.01.17 |
[99클럽 코테 스터디 4일차 TIL / 백준] 2343 - 기타 레슨 (JAVA) (0) | 2025.01.16 |