오늘은 코테준비 스터디 팀원들과 함께 "분할정복" 알고리즘을 주제로 풀어본 2번째 문제인 "백준 10830 행렬제곱" 문제에 대해 고찰해보았다.
분할정복은 학교 알고리즘 수업시간에 배운 퀵정렬, 병합정렬 등등 아주 기본적인 알고리즘에 대한 틀만 알고있어서 헷갈린 내용이 조금 있어 그 내용을 정리해보려고 한다.
우선 내가 "행렬제곱" 문제에 대해 처음 접근한 방식이다.
접근 방식 자체는 퀵정렬과 병합정렬과 동일하게 문제를 1/2 한 subproblem을 계속만들어 이진트리를 쭉쭉 뻗어나가는 식으로 구성했다.
public class DayProb28 {
private static int[][] matrix;
private static int N;
public static void main (String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st;
st = new StringTokenizer(br.readLine());
N = Integer.parseInt(st.nextToken());
long B = Long.parseLong(st.nextToken());
matrix = new int[N][N];
for (int i = 0; i < N; i++){
st = new StringTokenizer(br.readLine());
for (int j = 0; j < N; j++){
matrix[i][j] = Integer.parseInt(st.nextToken());
}
}
int[][] result = divideConquer(B);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < N; i++){
for (int j = 0; j < N; j++){
sb.append(result[i][j] % 1000).append(" ");
}
sb.append("\n");
}
System.out.print(sb.toString());
}
private static int[][] divideConquer(long n){
if (n == 1) {
return matrix;
} else {
return square(divideConquer(n/2), divideConquer(n-n/2));
}
}
private static int[][] square(int[][] a, int[][] b){
int[][] midResult = new int[N][N];
for (int i = 0; i < N; i++){
for (int j = 0; j < N; j++){
for (int k = 0; k < N; k++){
midResult[i][j] += a[i][k] * b[k][j];
}
}
}
return midResult;
}
}
%1000
을 해줘야되는 문제인가 했는데 이렇게 해도 해결되지 않았다.🤔 선형으로 풀면
O(n)
번 계산하는건데, 이렇게 분할해서 이진트리를 만들면O(logn)
번 계산하는거 아닌가?
우선 처음 들었던 이 생각은 얼마 안가서 완전히 잘못됐다는것을 금방 깨달았다. 사실 이진트리를 만들면 그 깊이만큼 계산하는 것이 아니라, 노드의 개수만큼 계산하는 것이기 때문에 분할을 했다고 해서 연산회수가 n -> logn (깊이) 만큼 줄어드는 것은 아니다. 오히려 노드의 개수가 2n-1개가 되어서 계산 회수자체는 늘어난다.
그렇다면 분할정복은 왜 메리트가 있는것일까? 라는 질문에 대한 정답이 내 오해를 풀어줄 것이라고 생각했다.
이 밑에 내가 고찰한 내용을 정리해보았다.
퀵정렬, 병합정렬이랑 똑같은 방식으로 큰 문제를 반으로 쪼개서 두개로 나누고 그렇게 계속해서 리프노드가 나올 때까지 이진트리 가지를 만들어나가는 방식인데 왜 퀵, 병합정렬은 시간복잡도가 개선이 되고 이 문제의 경우에는 개선 되지 않는가?
⇒ 그냥 “분할” 후 “정복” 한다고 해서 알고리즘의 효율, 시간복잡도가 개선되는 것이 아니다!
이진트리로 계속해서 분할하게 되면 결국 이진트리의 노드의 수는 2n -1 개임. 2n -1 번 계산하게 되는것임. 이 문제의 경우, 선형으로 계산했을 때 n 번 계산하면 될거를 분할정복하게 되면 오히려 2n-1번 계산해야 되는것임.
🤔 그렇다면 퀵정렬, 병합정렬은 트리의 노드의 개수가 2n -1임에도 불구하고 왜 성능이 개선되었고, 왜 이문제만 그렇게 하면 안되지?
이유는 크게 2가지가 있었다.
이 문제의 경우, 분할정복으로 나누었을 때 subproblem에 중복이 존재한다.
큰 문제를 subproblem으로 나누었다고 해서 subproblem을 푸는데 걸리는 시간이 줄어들지 않는다.
[ 마스터 방정식 ]
결국 분할정복에 대한 함수를 아래와 같이 바꿨을 때 문제가 풀렸다.
private static int[][] divideConquer(long n){
if (n == 1) {
return matrix;
} else {
int[][] half = divideConquer(n/2);
int[][] squared = square(half, half);
if (n%2==0) {
return squared;
} else {
return square(squared, matrix);
}
}
}
⇒ 메모이제이션 하는 방법도 있긴 함. 모든 지수에 대해 다 제곱을 구하는게 아니고, 재귀 경로에 등장하는 지수에 대해서만 저장하면 되니깐 개의 원소만 메모이제이션 하면 되긴 함.