2024년 3월 4일 알고리즘 문제풀이
문제
난이도
Lv.2
코드
일반적인 DP문제라고 생각했다. 조금 어려운 편이긴 하지만, 기존에 충분히 풀어봤던 유형이라 금방 풀 수 있었다. 도착지로 이동하는 마지막 방법을 점프라고 고정하고 생각했다.
1차
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
def solution(n):
# 3칸까지는 고려할 필요 없음
if n <= 3:
dp = [0, 1, 1, 2]
return dp[n]
# 그 이상일 때는 DP
# 가장 최대 건전지 사용량은 그곳까지 점프로만 도달했을 때이므로 모든 곳을 최댓값으로 초기화한다.
dp = []
for i in range(n + 1):
dp.append(i)
# 2,3일 때는 알맞게 초기화
dp[2] = 1
dp[3] = 2
# 4 이상일 때부터 DP
for i in range(4, n + 1):
# 반으로 나누었을 때 몫
k = i // 2
# 홀수여서 딱 절반인 곳이 존재하지 않을 때
if i % 2:
# i까지 도달 비용 = 몫까지 도달하는 비용(dp[j]) + 몫부터 i까지 점프하는 비용(i-j)
for j in range(k, i):
dp[i] = min(dp[i], dp[j] + i - j)
# 짝수여서 절반인 곳이 존재할 때
else:
# 절반인 곳에서 순간이동하면 도착할 수 있으므로 절반에 도착하는 것과 같은 값
dp[i] = min(dp[i], dp[k])
for j in range(k + 1, i):
dp[i] = min(dp[i], dp[j] + i - j)
return dp[n]
n칸 까지 갈 때, 절반인 n//2를 기준으로 생각하면 된다. 절반도 오지 못했을 떄 점프를 하는 것은 거기서 순간이동 하는 것보다 더 건전지 사용량이 소모될 것이기 때문이다. 예를 들어, 10 칸을 이동한다고 했을 때
- 4칸 + (6칸을 점프) = 10칸 도착
이라는 시나리오는 고려할 필요가 없다는 의미이다.
-
4칸 + (순간이동하여 8번째 칸으로 이동) = 8칸도착
-
8칸 + (2칸을 점프) = 10칸
목적지의 절반을 도달하지 못했을 때는 항상 이 경우가 존재하기 떄문에 반복에서 제외했다. 따라서 목적지의 절반 이상일 때만 고려했다. 하지만 딱 절반일 때가 존재할때는 특별한 분기로 처리했다. 딱 절반인 지점에서 순간이동하면 곧바로 도착하기 떄문에, 절반 까지의 건전지 소모량으로 도착할 수 있기 떄문이다.
정확도에선 모두 맞았으나, 효율성에서 모두 시간초과되어 60/100점 처리되었다.
2차
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
def solution2(n):
# 추가된 로직 : 2르 최대한 나눈다.
while True:
if not n % 2:
n //= 2
else:
break
if n <= 3:
dp = [0, 1, 1, 2]
return dp[n]
dp = []
for i in range(n + 1):
dp.append(i)
dp[2] = 1
dp[3] = 2
for i in range(4, n + 1):
k = i // 2
if i % 2:
for j in range(k, i):
dp[i] = min(dp[i], dp[j] + i - j)
else:
dp[i] = min(dp[i], dp[k])
for j in range(k + 1, i):
dp[i] = min(dp[i], dp[j] + i - j)
return dp[n]
효율성을 높이기 위해, 불필요한 경우를 최소화했다. 도착지점이 짝수일 때는, 그 절반지점까지 가는데 소모되는 비용과 동일하다. 순간이동으로 건전지 사용 없이 도착할 수 있기 때문이다. 따라서 목적지를 나눌 수 있을 때까지 2로 나누면 dp를 덜 실행해도 된다. 예를 들어, 100칸까지 가는 비용은 50칸까지, 25칸까지 가는 비용과 동일하다.
3차
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def solution3(n):
while True:
if not n % 2:
n //= 2
else:
break
if n <= 3:
dp = [0, 1, 1, 2]
return dp[n]
dp = []
for i in range(n + 1):
dp.append(i)
dp[2] = 1
dp[3] = 2
for i in range(4, n + 1):
# 변경된 로직
# 현재 구하는 지점이 홀수라면, 바로 전(짝수)에서 1칸 점프하면 된다.
if i % 2:
dp[i] = dp[i - 1] + 1
else:
# 현재 구하는 지점이 짝수라면, 2로 나눈 절반까지와 비용이 동일하다.
dp[i] = dp[i // 2]
return dp[n]
1차 시도에서 설명했지만, 탐색은 중간 이후의 지점만 하면 된다고 생각했었다. 문득, 문제가 풀리지 않아 고민한 끝에 두 가지 생각이 들었다.
- 최솟값을 탐색하는 과정에서도 분명 짝수를 만나지 않을까?
- 점프와 순간이동이 섞여있다면 어떤 규칙은 없을까?
예를 들어, 10칸까지 간다고 했을 때 탐색은
- 5칸까지 이동할 수 있는 최솟값(이 후는 순간이동을 하면 되기 때문에 점프를 하지 않는다)
-
6칸까지 이동할 수 있는 최솟값 + 4칸 점프하는 건전지 사용량
- 7칸까지 이동할 수 있는 최솟값 + 3칸 점프하는 건전지 사용량
- 8칸까지 이동할 수 있는 최솟값 + 2칸 점프하는 건전지 사용량
- 9칸까지 이동할 수 있는 최솟값 + 1칸 점프하는 건전지 사용량
중 하나를 고르게 된다. 6칸, 8칸은 짝수이므로 6칸까지 이동할 수 있는 최솟값은 3칸까지 이동할 수 있는 최솟값과 같다. 8칸까지 이동할 수 있는 최솟값은 4칸까지 이동할 수 있는 최솟값과 같으며 2칸까지 이동할 수 있는 최솟값과도 같게 된다. 여기서, 현재 방식으로 탐색하는 것은 정답이 아닐 것 같다는 생각이 들었다. 규칙성이 너무 흐릿하고, 혹여나 있다해도 너무 복잡해서 출제자가 원하는 바는 아닐거라는 생각이 들었다.
그래서 거꾸로 생각했다.
- n을 줄이기 위해, 가능할 때 까지 절반으로 나누어 홀수로 만들었다.
- 더이상 나누지 못하는 이유는 홀수기 때문이다.
- 딱 한칸만 옆으로 가면 짝수가 되서 다시 반으로 나눌 수 있다.
- 반복되면 언젠가 1에 도착하지 않을까?
건전지 사용을 최소로 하는데 가장 중요한 것은 점프를 최소화 해야한다. 방향을 거꾸로 보는게 좋다고 생각한 이유는, 순간이동과 점프를 고민하게 만드는 것은 도착지점을 지나가는 것 때문이었다. 거꾸로 점점 숫자가 작아진다면 1이 최종지점으로 정해져 있어서 이 걱정이 사라진다. 한 가지 신경써야 할 것은, 보통 DP에서 3(때론 5)까지는 일반적인 규칙성이 적용되지 않기 떄문에 무조건 1로 도착하기 보단 3이하에 도착하면 내가 계산한 수를 반환하도록 처리했다.
최종
1
2
3
4
5
6
7
8
9
10
11
def solution(n):
cnt = 0
while n > 3:
if not n % 2:
n //= 2
else:
cnt += 1
n -= 1
if n <= 3:
dp = [0, 1, 1, 2]
return dp[n] + cnt
맞춰놓고 신기했다. 이게 되네? 이걸 한번에 생각한 사람은 얼마나 천재일까…
다 풀고나니 정방향으로 생각하면 어떤 로직일지 고민해봤다.
- 매 칸에서,
- 만약 현 위치에서 순간이동 해도 도착지점을 지나치지 않는다면?
- 한 칸 점프하고 순간이동 했을 때도 도착지점을 지나지 않는다면
- 한 칸을 점프하고 순간이동한다(그냥 순간이동하는 것보다 1을 소모하고 2칸을 더 갈 수 있다.)
- 한 칸 점프하고 순간이동하면 도착지점을 지나게 된다.
- 그냥 순간이동한다.
- 한 칸 점프하고 순간이동 했을 때도 도착지점을 지나지 않는다면
- 만약 현 위치에서 순간이동 해도 도착지점을 지나치지 않는다면?
를 반복하면 될 것 같다. 그냥 역방향으로 생각하는게 속 편하겠다.