21-03-16 자바스크립트로 하는 자료구조와 알고리즘(11)
19장 동적 프로그래밍
- 동적 프로그래밍(이하 DP)은 문제를 더 작은 부분들로 쪼개는 과정이 포함된다
- 해시 테이블을 사용한다면 이미 계산된 피보나치 숫자를 저장할 수 있고 계산 없이 O(1)의 시간으로 그 값을 불러올 수 있기 때문에 더 빠르게 구현할 수 있다
1
2
3
4
5
6let cache = {};
const fiboBest = n => {
if (n <= 1) return n;
if(cache[n]) return cache[n];
return (cache[n] = fiboBest(n-1) + fiboBest(n-2));
} - DP는 재계산을 피하기 위해 이미 계산된 값들을 저장하고 해당 값들을 사용하는 방법이다
- 중복 부분 문제 : DP에서 보통 문제의 해결책으로 해시 테이블과 배열, 행렬에 저장하는 방식을 사용하는데, 이 방식을 메모이제이션이라고 부른다
- 최적 부분 구조 : 문제의 부분 최적 해결책을 통해 큰 문제를 해결하는 방법
- 배낭 문제 알고리즘은 대표적인 DP의 예로 무게와 가치를 지니는 n개의 항목이 주어졌을 때 최대 w의 무게를 담을 수 있는 배낭에 가치의 최대값을 담는 문제
- 배열(배낭)에 담긴 n개의 항목이 최대값이 되려면
- n번째 항목이 제외되는 경우: n-1개의 항목에서 얻은 최대값
- n번째 항목을 포함하는 경우: n-1개의 항목에서 얻은 최대값 + n번째 항목의 값
- 동적 프로그래밍의 구현은 현재 배열 인덱스와 목표치를 객체에 대한 키로 사용해 결과를 저장함 (메모이제이션 활용)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18const knapsackDP = (idx, weights, values, target, matrix) => {
let result = 0;
if(matrix[idx + '-' + target]) {
return matrix[idx + '-' + target]
}
if(idx <= -1 || target <= 0) {
result = 0
} else if (weights[idx] > target) {
result = knapsackDP(idx - 1, weights, values, target, matrix);
} else {
let current = knapsackDP(idx - 1, weights, values, target, matrix),
currentPlusOther = values[idx] + knapsackDP(idx - 1, weights, values, target - weights[idx], matrix);
result= Math.max(current, currentPlusOther);
}
matrix[idx + '-' + target] = result
return result;
} - 최장 공통 부분 수열 알고리즘
- 두 개의 수열이 있을 때 두 수열의 가장 긴 공통 부분 수열의 길이를 찾는 알고리즘
- 부분 수열 내 항목들이 연속일 필요는 없고 순서만 맞으면 됨
- 길이 m인 문자열 str1과 길이 n인 문자열 str2가 함수 LCS를 통해 최장 공통 부분을 구한다면
1
2
3
4
5
6if (두 수열의 마지막 글자가 일치한다면) {
result = 1 + LCS(X[0:m-2], Y[0:n-2])
}
if (두 수열의 마지막 글자가 일치하지 않는다면) {
result = Math.max(LCS(X[0:m-1], Y[0:n-1]), LCS(X[0:m-2], Y[0:n-2]))
} - 재귀를 기반으로 의사 코드를 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18const LCSNaive = (str1, str2, str1Length, str2Length) => {
if (str1Length === 0 || str2Length === 0) {
return 0;
}
if (str1[str1Length-1] === str2[str2Length-1]) {
return 1 + LCSNavie(str1, str2, str1Length - 1, str2Length - 1);
} else {
return Math.max(
LCSNavie(str1, str2, str1Length, str2Length - 1),
LCSNavie(str1, str2, str1Length - 1, str2Length)
)
}
}
const LCSNaiveWrapper = (str1, str2) => {
return LCSNaive(str1, str2, str1.length, str2.length);
} - DP식 접근법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18const longestCommonSequenceLength = (str1, str2) => {
let matrix = Array(str.length + 1).fill(Array(str2.length + 1).fill(0)),
rowLength = str1.length + 1,
colLength = str2.length + 1,
max = 0;
for (let row = 1; row < rowLength; row++) {
for (let col = 1; col < colLength; col++) {
let str1Char = str1.charAt(row - 1),
str2Char = str2.charAt(col - 1);
if (str1Char === str2Char) {
matrix[row][col] = matrix[row - 1][col - 1] + 1;
max = Math.max(matrix[row][col], max);
}
}
}
return max;
} - 동전 교환 알고리즘 : 동전의 금액 종류 M개가 들어있는 객체 S가 주어질때 금액 n을 동전으로 교환하기 위한 조합을 구하는 문제
- 이때 이 문제의 최적 부분 구조는 (1) M번째 동전이 포함되지 않는 해결책과 (2) M번째 동전이 최소 한 개 포함되는 해결책 으로 나눌 수 있다
coinChange(S, M, n) = coinChange(S, M-1, N) + coinChanges(S, M, N-Sm)
1
2
3
4
5
6
7
8
9
10
11
12
13
14// coinArr의 인덱스는 [0, ..., coinIndex]
const countCoinWays = (coinArr, coinIndex, total) => {
if (total === 0) return 1;
// total 값이 0에 도달한 경우에는 아무 동전도 포함하지 않는 것
if (total < 0 || (coinIndex <= 0 && total >= 1)) return 0;
// total값이 0 보다 작다는 것은 M번째 동전이 포함되거나 포함되지 않을 때의 해결책이 없다는 뜻
// 남은 동전이 없을 때 (index값은 coinArr.length에서 점점 줄어드므로) total값을 채우지 못해도 해결책이 없다는 뜻
return countCoinWays(coinArr, coinIndex - 1, total) +
countCoinWays(coinArr, coinIndex - 1, total - coinArr[coinIndex - 1])
}
const countCoinWaysWrapper = (coinArr, total) => {
return countCoinWays(coinArr, coinArr.length, total);
} - 하지만 이 방법을 사용한다면 중복 부분 문제가 많다
- 동전 교환 알고리즘을 DP로 구현하기
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47const countCoinWaysDP = (coinArr, coinIndex, total) => {
let dpMatrix = [];
// 메모이제이션용 행렬, 추후 2차원 배열이 된다
for (let i = 0; i <= total; i++) {
dpMatrix[i] = [];
// 원하는 값까지 전부 배열이 들어가게 된다
for (let j = 0; j < coinIndex; j++) {
dpMatrix[i][j] = undefined;
// undefined로 초기화
}
}
for (let i = 0; i < coinIndex; i++) {
// 맨 윗줄을 1로 채움 (undefined에서 기본값으로)
dpMatrix[0][i] = 1;
}
for (let i = 1; i < total + 1; i++) {
// 행렬의 세로이동
for (let j = 0; j < coinIndex; j++) {
// 행렬의 가로이동
let temp1 = 0, temp2 = 0;
// 두 가지의 부분 방법으로 나뉘므로 temp값도 두개가 필요함
if (i - coinArr[j] >= 0) {
temp1 = dpMatrix[i - coinArr[j]][j];
// temp1값에 dpMatrix[0][0] 번값부터 가로줄 값을 저장
}
if (j >= 1) {
temp2 = dpMatrix[i][j-1];
// temp2값에 dpMatrix[1][0] 값부터 세로줄 값을 저장
}
// dpMatrix[1][0] 값부터 temp1(M번째 동전이 포함되지 않는 해결책)값과
// temp2(M번째 동전이 최소한 한 개 포함되는 해결책)값을 더해 저장
dpMatrix[i][j] = temp1 + temp2;
}
}
// 결과값이 저장되어있는 것 중에서 total값을 만족하는 행의 max값을 리턴함
// if()에서도 i < coinIndex 와 같이 리턴하였으므로 여기서도 -1 해주어야 함
return dpMatrix[total][coinIndex - 1]
}
const countCoinWaysDPWrapper = (coinArr, coinValue) => {
return countCoinWaysDP(coinArr, coinArr.length, coinValue);
} - 편집 거리 알고리즘 (레벤슈타인 거리 알고리즘)
- 길이 m인 문자열 str1과 길이 n인 문자열 str2가 주어졌을때 str1을 str2로 변환하기 위환 최소 편집 횟수는? (이 때, 사용가능한 연산은 삽입, 제거, 교환만 가능하다)
1
2
3
4
5
6
7
8if (문자가 동일하다)
아무것도하지 않는다
if (문자가 다르다) {
1) m과 n-1의 삽입
2) m-1과 n의 제거
3) m-1과 n-1의 교환
세가지를 재귀적으로 고려
} - Brute-force 방법으로 구현하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20const editDistanceRecursive = (str1, str2, length1, length2) => {
// 빈문자열이라면 나머지 한 쪽의 문자열을 그대로 복사하면 되므로 나머지 한 쪽 길이값을 리턴함 (length1 or length2번 시행해야 하므로)
if (length1 === 0) return length2;
if (length2 === 0) return length1;
// 마지막 글자가 같다면 마지막 글자를 제외한 나머지 문자만큼 돌면 된다
// 이 과정은 재귀적으로 반복되므로 맨 마지막 글자부터 첫번째 글자까지 비교를 할 것
if (str[length1-1] === str2[length2-1])
return editDistanceRecursive(str1, str2, length1-1, length2-1)
return 1 + Math.min(
editDistanceRecursive(str1, str2, length1, length2-1), // 삽입
editDistanceRecursive(str1, str2, length1-1, length2), // 제거
editDistanceRecursive(str1, str2, length1-1, length2-1) // 교환
)
}
const editDistanceRecursiveWrapper = (str1, str2) => {
return editDistanceRecursive(str1, str2, str1.length, str2.length)
} - DP식 접근법
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
30const editDistanceDP = (str1, str2, length1 = str1.length, length2 = str2.length2) => {
let dpMatrix = [];
for (let i = 0; i < length1+1; i++) {
dpMatrix[i] = [];
for (let j = 0; j < length2+1; j++) {
dpMatrix[i][j] = undefined;
}
}
// let dpMatrix = Array.from({length: length1+1},
// () => Array.from({length:length2+1}, () => undefined))
for (let i = 0; i < length+1; i++) {
for (let j = 0; j < length2+1; j++) {
if (i === 0) {
dpMatrix[i][j] = j;
} else if (j === 0) {
dpMatrix[i][j] = i;
} else if (str1[i-1] === str2[j-1]) {
dpMatrix[i][j] = dpMatrix[i-1][j-1];
} else {
let insertCost = dpMatrix[i][j-1],
removeCost = dpMatrix[i-1][j],
replaceCost = dpMatrix[i-1][j-1];
dpMatrix[i][j] = 1 + Math.min(insertCost, removeCost, replaceCost)
}
}
}
return dpMatrix[length1][length2];
}20장 비트 조작
- 고성능 서버 사이드 코드를 구현하길 원한다면 비트 조작도 알아야 함
- 자바스크립트의 비트 연산자
- &는 AND, |는 OR, ~는 NOT, ^는 XOR에 해당하고 왼쪽 이동은 <<로, 오른쪽 이동은 >>로, >>>는 오른쪽 이동 후에 0으로 채운다는 뜻이다
- AND 연산자(&)는 두 개의 비트가 모두 1일때 참이다
a = 1100, b = 1010일 때, a & b = 1000이다
- OR 연산자(|)는 두 개의 비트 중 하나만 1이어도 참이다
a = 1100, b = 1010일 때, a & b = 1110이다
- XOR 연산자(^)는 비트 중 하나가 1일 때만 참이 된다
a = 1100, b = 1010일 때, a & b = 0110이다
- NOT 연산자는 모든 비트를 뒤집는다
a = 1100일 때 NOT a = 0011이다
- 왼쪽 이동 연산자(<<)는 모든 비트가 왼쪽으로 이동되고 왼쪽 끝 범위를 벗어나는 비트는 버려진다
9(1001) << 1 = 10010(2) = 18
,9 << 2 = 100100(2) = 36
- 위 예와 같이 왼쪽 이동 연산자는 해당 항목을 두 배로 증가시킨다 (이진 연산 체계이기 때문) 하지만 왼쪽 이동으로 비트가 넘친다면 버려지므로 값이 작아질 수 있다
- 오른쪽 이동 연산자(>>)는 모든 비트가 오른쪽으로 이동되고 오른쪽 끝 범위를 벗어나면 버려진다
9(1001) >> 1 = 100(2) = 4
- 오른쪽 이동 연산자는 항목을 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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75// 이진수를 더하기는 두 수를 더한 다음 10을 초과하면 1을 다음 수로 올린다 (carry)
const BitwiseAdd = (a, b) => {
while (b !== 0) {
let carry = (a & b);
a = a ^ b;
b = carray << 1;
}
return a;
}
// 이진수에서 음의 이진수로부터 양의 이진수를 빼려면 모든 비트를 뒤집은 다음 1을 더하면 된다
const BitwiseNegate = a => {
return BitwiseAdd(~a, 1);
}
// 빼기는 음수를 더하는 것이기 때문에 음의 이진수를 구하고 더한다
const BitwiseSubtract = (a, b) => {
return BitwiseAdd(a, BitwiseNegate(b))
}
// 곱셈은 두 수를 곱하고 10이 넘어가는 수는 다음 자릿수로 올리고 다음 자릿수로 이동해 해당 자릿수의 수를 곱한다
const BitwiseMultiply = (a, b) => {
let m = 1, c = 0;
if (a < 0) {
a = BitwiseNegate(a);
b = BitwiseNegate(b);
}
while (a >= m && b) {
if (a & m)
c = BitwiseAdd(b, c);
b = b << 1;
m = m << 1;
}
return c;
}
// a나누기 b는 b를 여러번 빼는 것이라고 생각하면 쉽다
const BitwiseDividePositive = (a, b) => {
let c = 0;
if (b !== 0) {
while (a >= b) {
a = BitwiseSubtract(a, b);
c++
}
}
return c;
}
// 하지만 음의 정수를 나누기한다면 while값이 무한히 반복될 것이기 때문에 부호를 저장할 값이 필요하다
const BitwiseDivide = (a, b) => {
let c = 0, isNegative = 0;
if (a < 0) {
a = BitwiseNegate(a);
isNegative = !isNegative;
}
if (b < 0) {
b = BitwiseNegate(b);
isNegative = !isNegative;
}
if (b !== 0) {
while (a >= b) {
a = bitwiseSubstract(a, b);
c++;
}
}
if (isNegative) {
c = BitwiseNegate(c);
}
return c;
} - 비트연산자는 Math의 기본 함수보다 빠르다
- 최근의 자바스크립트는 Node.js를 활용해 서버 측 프로그래밍에 진입하고 있기 때문에 비트 연산자는 효율적인 코드를 만드는데 도움이 될 것이다