최근에 알고리즘 스터디에서 제가 문제를 출제하게 됐습니다.
요새 저는 알고리즘 문제를 선별할 때 다음과 같은 생각으로 문제를 내고 있어요.
내가 해결할 수 있는 난이도보다 좀 더 어려운 문제를 내고 다같이 고민해보자!
이 기준은 항상 가변적이기는 하지만, 대개 정답률과 문제 내용을 보고 내는데요!
이 문제는 그런 의미에서 좀 더 탐났습니다.
오! Lv4에, 정답률이 5%라니! 문제를 정말 풀고 싶었어요.
그래서 문제를 보고, 약 2시간이 걸려 혼자서 문제를 해결하는 데 성공했습니다 😭
그럼, 어떻게 풀었는지 살펴볼까요?
n = 100
입니다. 따라서 문제에 대한 시간복잡도를 좀 널널하게 가져갈 수 있지 않을까 싶었어요.
하지만 이 문제는 경우의 수를 따져야 하는 문제입니다. 또한, 조합의 문제가 아닌, 순열의 문제이지요.
정확히 말하자면 1~n에서의 순열은 아닙니다. 왜냐하면, 수 사이에 다른 수가 끼어있는 경우가 있기 때문입니다.
다만 같은 수들이, 다른 곳에 배치되면 서로 독립적인 것으로 인정된다는 관점에서 그냥 순열이라고 가정해봅시다!
따라서 이 문제를 Brute Force로 풀게 되면 얼마나 걸릴지 생각해봅시다.
count
가 일치하는지 찾은 다음즉 어림 잡아봐도 약 O(N^2 * N!)
이 걸린다는 무지막지한 결과가 나오겠군요!
공간복잡도 역시 저만큼은 나올 것 같아서, 수가 커질 수록 매우 부담이 크겠군요.
즉, 어떻게 봐도 최적의 답안이 아니며, 시간 초과 및 런타임 에러가 예상되기 때문에 보자마자 이 문제는 DP로 풀어야겠다고 생각했습니다.
저는 이 문제를 보았을 때, 직관적으로 다음과 같이 메모이제이션을 할 배열을 생성했어요.
어떤 배열을
arr
이라 할 때,
arr[A][B]
=arr[n개][현재 count]
=현재 n개 중에서 count개의 쌍둥이 빌딩이 나올 수 있는 경우의 수
또한, 점화식을 위한 함수는 다음과 같이 정의하겠습니다.
f(A, B)
=현재 n개 중에서 count개의 쌍둥이 빌딩이 나올 수 있는 경우의 수
즉, arr[A][B] = f(A, B)
라고 정의하겠습니다.
예를 들면, 문제에서 주어진 예시인 f(3, 1) = 8
입니다.
결과적으로 쌍둥이 빌딩이라는 건, 다음과 같은 조건을 갖고 있어요.
n = 3일 때, 나올 수 있는 빌딩의 높이를
1,2,3
이라고 하겠습니다.
언뜻 봐서는 사실 점화식이 떠오르질 않아요.
그래서 더는 좀 더 그려보기로 했습니다. 바로 다음과 같이 말이죠!
이를 하나하나 그리고, 답을 도출하기가 굉장히 까다로웠어요.
왜냐하면, 위에서 정의한, 높은 빌딩 사이에는 더 높은 빌딩이 위치할 수 없다.
는 조건 때문이었어요.
글로 설명하면 이해가 되지 않을 것 같아 예시를 들어 볼게요.
n = 3, count = 2
일 때 나올 수 있는 모든 경우의 수는 어느 값에 영향을 미칠까요?
막상 생각만 해도, 다음 3가지 케이스에 영향을 미치게 됩니다.
O(4,1)
- 맨 앞에 높이가 4인 빌딩 쌍이 위치할 때O(4,2)
- 맨 앞에 높이가 1 || 2 || 3
인 빌딩을 놓고, 뒤에 바로 높이가 4인 빌딩 쌍이 위치할 때O(4,3)
- 높이가 4인 빌딩이 맨 뒤에 위치할 때즉, 1개의 케이스가 이전/현재/다음까지 영향을 미치게 되는 점화식을 고른다?
이건 제게 너무 불합리하다고 생각했어요. f(n,c)
라는 함수로부터 3개의 다른 함수 f(n+1, c-1)
, f(n+1, c)
f(n+1, c+1)
을 따지는 게 생각만 해도 너무 복잡하달까요? 😖
그래서 약 40분의 고민 끝에 다음과 같은 가설을 세웠어요. 바로 다음과 같이 말이죠.!
이를 위해 제가 접근한 문제의 접근 원리는 바로 이것입니다.
높이가 가장 작은 크기를
1
이라 할 때,
11
이라는 쌍둥이 빌딩을 하나하나의 빌딩 사이로 넣어서 도출해보는 건 어떨까?
이유는 다음과 같습니다.
11
이 놓이는 경우는 굉장히 단순합니다! 이전의 빌딩 숲 중 아무데나 놓을 수 있기 때문이죠. 이는 높은 빌딩 사이에는 더 높은 빌딩이 위치할 수 없다.
는 조건을 언제든 만족할 수 있습니다.4
인 빌딩의 쌍이 놓이는 경우의 수는 44
외에도 41122334
등 위치해야 할 곳을 찾기 까다로운데 반해, 11
을 찾는 로직으로 바꿔버리면 경우의 수를 찾기 쉽습니다.따라서 만약 이전의 결과값이 332211
이었다면, 저는 이를 443322
로 해석하고, 11
을 넣는 방법을 찾게 됐어요.
ex:
11443322
,41143322
,44113322
,44311322
, ...
즉, 어디에다가 더 큰 빌딩을 놓을지가 아닌, 어디에다가 더 작은 빌딩을 놓을지로 치환하여 문제를 풀어버린 셈이죠.
결과적으로 표를 볼까요? 다음과 같은 결과가 나옵니다.
어때요. 문제에 달려 있던 제약 조건을 다른 방법으로 단순화해버리니, 이제 표로 깔끔하게 정리할 수 있죠? 다만 아직 직관적으로 점화식이 와닿지 않아요. 이를 엑셀의 색상으로 경우의 수를 정리해볼게요.
어?
뭔가 규칙이 보이지 않나요?
즉,
f(n, c)
가 영향을 받는 곳은 다음과 같다고 할 수 있어요!
f(n-1, c-1)
인 곳에서11
을 맨 앞에 놓는 경우f(n-1, c)
인 곳에서11
을 맨 앞에 놓지 않는 경우
오! 엄청 문제에 대한 접근이 단순화되었어요.
이제 점화식을 생성할 수 있을 것 같아요. 바로 다음과 같이 말이죠!
// f(n, 0)인 경우는 0이라고 해석합니다. 왜냐하면, 그럴 경우는 존재하지 않기 때문이죠.
// 2(n-1): 이전 빌딩의 총 갯수를 의미해요 😉
// f(n, c) = f(n-1, c-1) + f(n-1, c) * 2(n-1)
arr[n][c] = arr[n-1][c-1] + 2 * (n - 1)* arr[n-1][c]
이제 끝났네요. 최종적으로 문제를 풀어봅시다.
const MODULAR_ARITHMETIC_DIVIDE_NUMBER = 1000000007;
function solution(n, count) {
const arr = Array.from({ length: n + 1 }, () => new Array(n + 1).fill(0));
arr[1][1] = 1;
for (let row = 2; row < n + 1; row += 1) {
const prevRow = row - 1;
for (let col = 1; col <= row; col += 1) {
arr[row][col] =
(arr[prevRow][col - 1] + 2 * prevRow * arr[prevRow][col]) %
MODULAR_ARITHMETIC_DIVIDE_NUMBER;
}
}
return arr[n][count];
}
야호! 문제를 풀었어요 😆
하지만 저는 찝찝했어요. 결국 DP의 최대 단점은 쓸 데 없는 값까지 모두 계산해버린다는 점입니다.
문제를 굉장히 직관적으로 풀어냈지만, 더 효율적으로 풀 수 있을 것 같은 자신감이 들었어요.
어떻게 하면 문제를 최적화할 수 없을까?
결과적으로, 다음과 같은 엄청난 꼼수(?)를 발견하게 됩니다.
count
가 주어졌을 때, 더 큰 값을 계산할 필요가 있을까?아까 우리가 도출했던 점화식의 조건이 무엇이었죠?
즉,
f(n, c)
가 영향을 받는 곳은 다음과 같다고 할 수 있어요!
f(n-1, c-1)
인 곳에서11
을 맨 앞에 놓는 경우f(n-1, c)
인 곳에서11
을 맨 앞에 놓지 않는 경우
결과적으로 이것이 상징하는 것은, f(n-1, c+1)
인 경우를 애초부터 고려할 필요가 없었다는 이야기이군요?
즉, 사소한 것일지라도, 어쨌든 계산을 하지 않는다는 것은 희소식이겠군요. 따라서 이를 다시 바꾸었습니다.
const MODULAR_ARITHMETIC_DIVIDE_NUMBER = 1000000007;
function solution(n, count) {
const arr = Array.from({ length: n + 1 }, () => new Array(count + 1).fill(0));
arr[1][1] = 1;
for (let row = 2; row < n + 1; row += 1) {
const prevRow = row - 1;
const nextColLength = Math.min(count, row);
for (let col = 1; col <= nextColLength; col += 1) {
arr[row][col] = (arr[prevRow][col - 1] + 2 * prevRow * arr[prevRow][col]) % MODULAR_ARITHMETIC_DIVIDE_NUMBER;
}
}
return arr[n][count];
}
대충 n = 10000
이고, 절반인 count = 5000
의 경우를 비교해볼게요.
const MODULAR_ARITHMETIC_DIVIDE_NUMBER = 1000000007;
function before(n, count) {
const arr = Array.from({ length: n + 1 }, () => new Array(n + 1).fill(0));
arr[1][1] = 1;
for (let row = 2; row < n + 1; row += 1) {
const prevRow = row - 1;
for (let col = 1; col <= row; col += 1) {
arr[row][col] =
(arr[prevRow][col - 1] + 2 * prevRow * arr[prevRow][col]) %
MODULAR_ARITHMETIC_DIVIDE_NUMBER;
}
}
return arr[n][count];
}
const beforeStart = +new Date();
before(10000, 5000);
const beforeEnd = +new Date();
console.log(beforeEnd - beforeStart);
function after(n, count) {
const arr = Array.from({ length: n + 1 }, () => new Array(count + 1).fill(0));
arr[1][1] = 1;
for (let row = 2; row < n + 1; row += 1) {
const prevRow = row - 1;
const nextColLength = Math.min(count, row);
for (let col = 1; col <= nextColLength; col += 1) {
arr[row][col] = (arr[prevRow][col - 1] + 2 * prevRow * arr[prevRow][col]) % MODULAR_ARITHMETIC_DIVIDE_NUMBER;
}
}
return arr[n][count];
}
const afterStart = +new Date();
after(10000, 5000);
const afterEnd = +new Date();
console.log(afterEnd - afterStart);
console.log((beforeEnd - beforeStart) / (afterEnd - afterStart));
결과는 다음과 같이 차이가 나는군요!
n = 100, count = 100
인 경우에는 추가적인 연산으로 인해 효율성이 떨어질 수 있겠으나, 경우의 수가 늘어나고, 주어진 count
가 작을 수록 분명 효율성이 더욱 증가될 것입니다.
그 중, count
가 절반일 경우에는 약 1.33배의 성능 개선을 이루었습니다!
정리
이전의 방법: 최악-최선일 때의 시간 복잡도
O(NlogN)
(2번째 반복문이row
만큼 반복됩니다.)
이후의 방법: 최악 -O(NlogN)
, 최선 -O(N)
(count = 1일 때)
만약 직관적인 방법을 좋아하신다면, 최적화 하기 이전의 방법을 추천드려요.
모든 경우의 수가 계산되기 때문에, 다른 사람들에게 설명하기 적합할 것입니다 🙆🏻
하지만 나는 좀 더 효율적으로 문제에 접근하고 싶어!하시는 분들은 이후의 방법을 추천드려요.
이는 모든 경우의 수를 계산하지는 않기에 더 빠른 해답입니다 🙆🏻♀️
역시 생각보다 글을 쓴다는 것은 오랜 호흡이 걸리네요.
하지만 정말 글을 쓰고 나니, 제가 이 문제를 제대로 풀었다는 쾌감이 들어서 기분이 좋았어요.
요새 알고리즘 중 DP
와 재귀함수에 많은 관심이 생기고 있어요.
chore
한 계산 식을 컴퓨팅 사고 기반으로 해결한다는 점이 매력 있는 알고리즘이라는 생각이 들게끔 합니다. 너무 재밌어요 😆
비록 2시간이 걸렸지만... 그래도 오늘 또 한 걸음 성장했다는 뿌듯함에 기분이 좋군요!
이 글이 누군가에겐 도움이 되었으면 좋겠군요!
그럼 다들 즐거운 코딩하시길 바라며. 이상 :)