Divide & Conquer
- 분할정복 방식의 대표적인 예 : merge sort
- 둘로 나눔 (나눈 문제들은 원래 문제와 같은 입려값을 가져야 함)
- 각각 정렬
- 합침
- 최댓값 찾기를 분할 정복 방식으로 구현해보자
- 배열 A를 두 부분 배열 A1, A2로 나눔
-> 나눌 때 부분 배열 각각이 다시 최대값 찾는 단계로 재귀호출될 것이라고 생각하기 - 그리고 원래 문제를 다시 생각해봄
-> A1의 최댓값, A2의 최댓값 둘 중 더 큰 값이 A의 최댓값이다. - 슈도코드 :
- 배열 A를 두 부분 배열 A1, A2로 나눔
max(a[0...n-1])
if n=1 return a[0]
mid <- n/2
m1 <- max(A[0...mid])
m2 <- max(A[mid+1...n-1]_
if m1 > m2
then return m1
else return m2
//참고 : 재귀호출의 시간 복잡도 : T(n) = 2T(n/2) + O(1)
// 재귀호출에서 사용되는 시간 (T로 표현. 인스턴스 사이즈가 n/2로 줄어듦 (리턴되기 전까지 n/2))
// + 재귀호출이 아닌 곳에서 사용되는 시간
- 이진탐색 또한 분할정복이다.
- 중간값을 k와 비교한 후 배열의 절반에 대해서만 탐색을 계속 함
- 즉 남은 n/2 크기의 부분 배열에 대한 subproblem을 풀면 문제 해결
- 탐색 관점에서 생각하면 한 번 비교하고 탐색 범위를 절반으로 줄이고 있음
- 원하는 답을 찾기 위해 탐색 범위를 줄여나가는 것이 골자
- 이진탐색의 경우는 두 부분 모두 recursion태울 필요 x
- 정렬된 상태이기 때문에 찾는 값이 없는 부분이 어딘지 알고 있음
-> 이 부분은 재귀호출로 계속 찾을 필요 없음
- 두 자리 정수 x, y의 곱셈을 분할 정복으로 풀어보기
- x와 y의 자리수를 반으로 나누기
- x = a*10^n/2 + b
- y = c*10^n/2 + d
- x*y 식을 전개하면 a*c, a*d, b*c, b*d값(recursion으로 계산)을 알면 x*y값을 구할 수 있다는 것을 알 수 있다.
- 시간복잡도?
- recursion part : n/2사이즈의 instance들을 4번 호출
- not recursion part : 10의 거듭제곱을 얼마나 곱하는지, 더하기 연산을 얼마나 하는지 등을 계산해보면 됨
- T(n) = 4T(n/2) + O(n) 즉 O(n^2)
- 곱셈 O(n^2)보다 더 빠르게 안 되는 걸까?
- bc+ad=ac+bd-(a-b)(c-d) 공식을 활용하면 recursive call을 4번에서 3번으로 줄일 수 있음
- T(n) = 3T(n/2) + O(n) 즉 O(n^log2^3) = O(n^1.584..)
- x와 y의 자리수를 반으로 나누기
Backtracking
- n-queen 문제
- goal : sequence of queen position을 찾는 것
- contraints : queen은 각 row 당 하나씩만 놓을 수 있음, 서로 공격할 수 없게 놓아야 함
- decision : 각 row에서 queen을 어디 놓을지 결정하기

- 한 줄에 퀸을 하나만 놓는다
- 다음 줄로 간다
- 아무 데도 놓을 수 없으면 이전 단계로 백트래킹한다
- 놓을 수 있으면 다음 줄에 놓으러 간다
- 모든 줄에 놓았으면 정답을 찾은 것
- -> 모든 경로를 깊이 우선 탐색하고있다는 것을 알 수 있음
placequeens(Q[1..n], r):
if r=n+1
print Q[1..n]
else
for j <- 1 to n
legal <- True
for i <- 1 to r-1
if(Q[i]=j) or (Q[i]=j+r-i) or (Q[i]=j-r+i)
legal <- False
if legal
Q[r]<-j
placequeens(Q[1..n], r+1) //recursion
- 최선의 결정(decisioin)을 반복하여 점진적으로 답을 만들어 나간다.
- Tries to construct a solution incrementally by a sequence of correct or best "decisions"
- 그 길이 옳은지 아닌지 알 수 있는 방법? 가보지 않고서는 아직 알 수 없다.
- 따라서 best decision은 모든 선택지를 모두 고려하여 임시적으로 가본 후에 backtracking하는 것
- 최장 증가 부분 수열 문제 (LIS) 백트래킹으로 풀기
- increasing subsequence 중 길이가 가장 긴 것이 무엇인지 찾는 문제
- (1) 어떤 결정(decide)의 연속(sequence)을 통해 LIS를 만들어내기 -> 어떤 결정인가?
- 임의의 subsequence는 각 원소를 포함시킬지 말지 결정함으로써 만들어짐
- 그런 선택을 함으로써 정답에 가까워지냐 아니냐를 판단해서 백트래킹하든지 계속 탐색하든지 선택하면 됨
- 예) 3141592653 5897532384626
- 1456까지는 선택했다. 그 다음부터 선택할지 말지 계속 정해보자
- 5는 6보다 크므로 선택하지 않는다.
- 8은 6보다 크므로 선택 가능. 그리고 subsequence도 1 증가한다. 그런데 8을 넣는 것이 '최선'일까?
- 8을 무시하는 것이 최선일 수도 있다.
- 따라서 8을 포함시키는 경우, 포함시키지 않는 경우를 분기시켜 둘 다 시행해보고 더 불리한 것을 무시(백트래킹)하는 방법을 사용하자.
- 따라서 백트래킹 알고리즘은 8을 포함시킨 후 / 포함시키지 않은 후 나머지 결정을 recursion태우기 -> 두 가지로 분기됨
- 둘 중에 더 긴 increasing subsequence가 나오는 것으로 결정
- (2) j번째 원소 A[j]를 포함시킬까 말까를 결정하기 위해 임시로 포함시키거나 포함시키지 않은 결과를 recursion태우기
- (3) 이런 방법이 동작하려면 지난 결정들에 대한 어떤 정보를 기억해야 하는가?
- 마지막 원소만 기억하면 됨. 마지막 원소보다 더 큰 숫자이기만 하면 되므로
- 따라서 recursive call을 할 때 마지막 원소를 넘겨주기
lisbigger(prev, a[1..n]): #prev값보다 커야 함
if n=0
return 0
else if a[1]<=prev
return lisbigger(prev, a[2..n])
else
skip <- lisbigger(prev, a[2..n])
take <- lisbigger(a[1], a[2..n])+1
return max{skip, take}
- 최장 증가 부분 수열을 백트래킹으로 풀면 모든 가능한 증가하는 부분 수열을 탐색해야 한다.
- 즉 각 원소가 선택되는 경우, 선택되지 않는 경우를 고려하며 모든 경우를 탐색해야 한다.
- 이 경우 시간 복잡도는 O(2^n)이다.
- LIS를 효율적으로 해결하려면 dp로 중복 계산을 줄여야 한다.
# top-down(memoization)
def lisbigger(prev_index, curr_index, arr, memo):
if curr_index == len(arr):
return 0
if (prev_index, curr_index) in memo:
return memo[(prev_index, curr_index)]
# 현재 원소를 선택하지 않는 것이 최선일 수도 있음 -> 확인하기 위해 재귀 보내기
skip = lisbigger(prev_index, curr_index + 1, arr, memo)
# 현재 원소를 선택하는 것이 최선일 수도 있음 -> 확인하기 위해 재귀 보내기
take = 0
if prev_index == -1 or arr[curr_index] > arr[prev_index]:
take = 1 + lisbigger(curr_index, curr_index + 1, arr, memo)
# 같은 문제를 여러 번 중복해서 구하고 있을 때 그 값을 dp에 저장해두기
memo[(prev_index, curr_index)] = max(skip, take)
return memo[(prev_index, curr_index)]
arr = [10, 22, 9, 33, 21, 50, 41, 60]
memo = {}
print(lisbigger(-1, 0, arr, memo)) # 출력: 5 (10, 22, 33, 50, 60)
# bottom-up
def lis_dp(arr):
n = len(arr)
dp = [1] * n # 최소 LIS 길이는 1
for i in range(n):
for j in range(i):
if arr[j] < arr[i]: # 증가하는 경우
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
arr = [10, 22, 9, 33, 21, 50, 41, 60]
print(lis_dp(arr)) # 출력: 5
Dynamic Programming
- 피보나치 수열을 dp로 풀기
- 피보나치 수열을 재귀로 구현하면 매우 느리다.
-> 시간 복잡도가 지수함수임. recursion tree를 그려보면 호출 횟수가 기하급수적으로 늘어나는 것을 확인할 수 있음 - dp는 테이블 채우기가 아니고.. recursion 알고리즘에서 중복 연산이 나올 때 그 부분을 재사용해서 효율적으로 만들어주는 방법임
- k번째 답을 구하기 위해서는 k-1, k-2번째 등의 답이 필요하기 때문에 k번째 답을 k-1, k-2번째 답으로 표현할 수 있음
-> 점화식 - 그리고 k-1, k-2번째 답을 구하기 위해서는 재귀, 백트래킹을 이용하는 것
- DP is recursion without repitition
- DP를 사용해 문제를 해결하는 일반적인 과정
- 문제를 해결하는 recursive한 패턴 혹은 점화식을 찾아내야 함
- 계산할 값이 무엇인지 결정하기(어떻게 계산해야 하는지 생각 x) 그것에 대한 점화식 구하기
- basecase부터 차곡차곡 table에 쌓아가는 방식으로 해결
- 피보나치 수열을 재귀로 구현하면 매우 느리다.
- LCS (Longest Common Subsequence, 최장 공통 부분 수열)
- a와 b는 배열 형식으로 주어졌다고 가정
- l(i, j)는 a(1..i), b(1..j)사이의 lcs 길이로 정의하기
- 두 배열을 탐색하다가 다른 문자가 나오면
- a[k]의 값을 무시하고 a[k+1]부터 b[k]의 값과 매치되는지 계속 탐색하거나
- b[k]의 값을 무시하고 b[k+1]부터 a[k]의 값과 매치되는지 계속 탐색해야 함
- -> 두 분기 중 매칭 수가 더 많은 부분이 이김
- base case : i 또는 j가 0일 때 (배열 a 또는 b가 공집합인 경우)
- l(i, j)의 재귀적인 구조를 찾기
# 점화식
if a[i] == b[j]
l(i, j) = l(i-1, j-1) + 1
if a[i] != b[j]
l(i, j) = max(l(i-1, j), l(i, j-1))
- recursion: 알고리즘에서 가장 중요한 개념
- divide and conquer : 크게 자르는 recusion
- backtracking : 하나씩 떼어내는 recursion
- dp : 반복 계산을 없앤 recursion
재귀가 모든 알고리즘의 기초가 되는 개념이라는 사실을 알게 되었다.
그리고 재귀 알고리즘을 짤 때 점화식만 잘 찾으면 구체적으로 모든 케이스에서 코드가 어떻게 작동되는지 완벽히 이해하지 않아도 된다는 말을 듣고 재귀의 원리에 대해 조금 더 이해할 수 있게 되었다.
두 번째 특강 시간에는 분할 정복과 dp를 적용하기 위해서 문제를 어떤 방식으로 접근해야 하는지 자세하게 배울 수 있었다.
스스로 자료를 찾아서 공부하기만 했다면 모든 알고리즘을 관통하는 개념이 재귀인지 알 수 없었을 것 같다. 또한 유형을 파악하기 어려운 문제를 마주했을 때 먼저 문제를 작게 분할해본 후 재귀적으로 해결할 수 있는지부터 고려하고 가능하면 반복되는 문제가 있을 경우 dp, 아닐 경우 백트래킹으로 접근하는 방식을 배웠는데 이러한 접근은 앞으로 어려운 문제가 나올 때마다 적용할 수 있을 것 같다.
'정글 > 회고' 카테고리의 다른 글
| 정글 알럼나이 패널토크 (0) | 2025.04.12 |
|---|---|
| [정글/회고] 3주차 회고 (0) | 2025.04.04 |
| [정글/시험] 3주차 퀴즈 (0) | 2025.04.01 |
| [정글/회고] 2주차 회고 (0) | 2025.03.27 |
| [정글/시험] 2주차 퀴즈 (0) | 2025.03.25 |