💡 이번에 배운 내용
- Section4. 사람과 기계가 모두 쉽고 빠르게 접근 가능한 Web App을 만들 수 있다.
- Unit11. [자료구조/알고리즘] 코딩테스트 준비: 코딩테스트를 대비해 알고리즘과 알고리즘을 이용하여 해결할 수 있는 문제를 다루며 문제 해결 능력을 기른다.
알고리즘의 각 개념을 공부하고 예제를 풀어보면서 머리가 살짝 빠개질 뻔한 경험을 해보았다. 재귀함수를 이미 배우긴 했는데, 막상 활용하려면 쉽지 않다. 특히 제일 기억에 남는 것은 반복문을 여러번 중첩해서 사용해야 할 때, 이를 재귀함수로 만드는 것이었다. 처음부터 하려니 좀 막막했지만 몇 번 해보니까 조금은 익숙해지긴 했다. 부디 취업전까지 응애 코린이 좀 조금이나마 탈출하길 바라며...
Big-O 빅오 표기법, 시간 복잡도 Time complexity, 공간 복잡도 Space Complexity, Brute Force, Greedy Algorithm 탐욕 알고리즘 탐욕법, DP Dynamic Programming 동적 계획법,피보나치 수열 fibonacci, Recursion, Memoizatio, Iteration, Tabulation, 순열, 조합, nPr, nCr, GCD, LCM, 유클리드 호제법, 부분집합, 멱집합
시간복잡도란 입력값의 변화에 따라 연산을 실행할 때, 연산 횟수에 비해 시간이 얼마만큼 걸리는가를 나타낸 것이다. 주로 빅-오 표기법을 사용해 나타낸다.
O(예상 연산 횟수)
로 표기한다.O(1)는 constant complexity라고 하며, 입력값이 증가하더라도 시간이 늘어나지 않는다. 입력값의 크기와 관계없이 바로 출력값을 얻어낼 수 있다.
예시는 아래와 같다.
function findIndex(arr, index) {
return arr[index];
}
let arr = [1, 2, 3, 4];
let index = 3;
let result = findIndex(arr, index);
console.log(result); // 4
O(n)은 linear complexity라고 하며, 입력값이 증가함에 따라 시간 또한 같은 비율로 증가한다.
예시는 아래와 같다.
//예1
function findSum(n) {
let sum=0;
for (let i = 1; i < n; i++) {
sum+=i;
}
return sum;
}
//예2
function findDoubleSum(n) {
let sum=0;
for (let i = 0; i < 2n; i++) {
sum+=i;
}
return sum;
}
보통 1에서 n까지 반복문을 한 번 사용할 경우 O(n)이 된다.
예시1은 O(n), 예시2는 O(2n)이다. 그러나 이 n이 무한대로 커질경우 앞의 상수는 의미가 없게 된다.
따라서 n앞에 상수가 있어도 그냥 n과 같다고 보면 된다.
ex) O(2n)=O(4n)=O(n)
O(log n)은 logarithmic complexity라고 하며, 입력값이 증가함에 따라 시간이 log n의 비율로 증가하는 것을 의미한다.
Big-O표기법중 O(1) 다음으로 빠른 시간 복잡도를 가진다.
쉽게 말해, 보통 단계를 진행할 때마다 탐색량이 전체의 절반씩 줄어드는 경우 O(log n)의 복잡도를 가졌다고 한다. 대표적인 알고리즘으로 이진탐색(BST, Binary Search Tree)이 있다.
O(n2)은 quadratic complexity라고 하며, 입력값이 증가함에 따라 시간이 n의 제곱수의 비율로 증가하는 것을 의미한다.
예시는 아래와 같다.
function O_expo2_algorithm(n) {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
//식
}
}
}
function O_expo3_algorithm(n) {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
for (let k = 0; k < n; k++) {
// do something for 1 second
}
}
}
}
보통 1에서 n까지 반복하는 반복문을 중첩해 사용했을 때, 중첩한 만큼 n을 제곱하여 표기한다.
O(2^n)은 exponential complexity라고 하며, 입력값이 증가함에 따라 시간의 2배씩 증가하는 것을 의미한다. Big-O 표기법 중 가장 느린 시간 복잡도를 가진다.
만약 알고리즘의 시간 복잡도가 O(2^n)이라면 다른 접근 방식을 고려해 보는 것이 좋다.
예시는 아래와 같으며, 피보나치 수열을 O(2^n) 시간복잡도로 풀이한 알고리즘이다.
function fibonacci(n) {
if (n <= 1) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
이렇게 구현할 경우 n이 40이상만 되어도 알고리즘 출력값을 반환하는데 수 초 이상이 걸리므로 다른 방식으로 구현하는 것이 좋다.
입력으로 주어지는 데이터의 범위를 확인하면 어느 정도의 시간 복잡도를 가져도 괜찮을지 유추할 수 있다.
입력값이 어느정도 제한되어 있다면 시간을 줄이려고 애쓰는 것보다 일단 시간복잡도가 커도(느려도) 문제를 풀어보는 것이 나을 수 있다.
일단 문제를 풀고 최적화는 나중에 생각하도록 한다.
데이터 크기에 따른 수용 가능한 시간 복잡도는 다음과 같다.
데이터 크기 제한 | 예상되는 시간 복잡도 |
---|---|
n ≤ 1,000,000 | O(n) or O (logn) |
n ≤ 10,000 | O(n2) |
n ≤ 500 | O(n3) |
공간 복잡도는 알고리즘이 수행되는 데에 필요한 메모리의 총량을 의미한다.
프로그램이 요구하는 공간은 고정적인 공간과 함께 가변적인 공간을 함께 요구하는데, 여기서 가변적인 공간이 중요하다. 가변적인 공간은 처리할 데이터의 양에 따라 다르게 요구되는 공간으로서 프로그램의 성능에 큰 영향을 준다.
이런 공간 복잡도 계산도 마찬가지로 빅 오 (Big-O) 표기법을 사용한다.
아래는 공간복잡도의 예시다.
function factorial(n) {
if(n === 1) {
return n;
}
return n*factorial(n-1);
}
함수 factorial은 재귀함수로 구현되었다. 그리고 변수 n이 n개가 만들어지고 재귀함수를 호출할 경우 n부터 1까지 스택에 쌓이게 된다. 따라서 이 함수의 공간 복잡도는 O(n)이다.
보통 때의 공간 복잡도는 시간 복잡도보다 중요성이 떨어진다. 시간이 적으면서 메모리까지 지수적으로 증가하는 경우는 거의 없기 때문이다.
그리고 시간 복잡도에 맞다면 공간 복잡도도 대부분 통과한다. 만약 공간 복잡도에 실패했다면, 보통 변수를 설정할 때 쓸데없는 공간을 많이 차지하도록 설정했을 경우가 많으니 확인해야 한다.
그러나 동적 계획법(Dynamic Programming)과 같은 알고리즘이나 하드웨어 환경이 매우 한정되어 있는 경우 공간복잡도도 고려해야 한다. 동적 계획법은 알고리즘 자체가 구현 시 메모리를 많이 요구하기 때문이다.
구현 알고리즘은 문제의 설명과 조건을 그대로 코드로 구현하는 알고리즘을 의미한다.
이 경우 문제의 출제의도는 다음과 같은 사항에 초점이 맞춰져 있다.
구현 Algorithm 해결 방법
구현 Algorithm 적용 예시: Brute Force 문자열 매칭
길이가 n인 전체 문자열이 길이가 m인 문자열 패턴을 포함하는지 검색하고 그 시작위치를 반환한다.
시작은 0부터 이다.
ex) 'abcdgkgkgk' 에서 'cd'를 포함하며, 시작위치는 2이다.
//arr은 전체문자열, patternArr은 찾을 문자열 패턴
function BruteForceStringMatch(arr, patternArr) {
let n = arr.length;
let m = patternArr.length;
// 전체 요소개수에서 패턴개수를 뺀 만큼만 반복한다.
//시작 위치만 찾으면 되기 때문이다.
for (let i = 0; i <= n - m; i++) {
let j = 0;
//전체와 패턴의 각 요소를 비교하는 반복문이다.
//패턴의 개수만큼 반복해야 하므로 j가 패턴의 개수 m보다 커지면 안된다.
while (j < m && patternArr[j] === arr[i + j]) {
//patternArr[j] === arr[i + j] :
// 패턴의 글자와 현재 전체 인덱스의 글자가 같은지 판단한다.
// 같다면 다음 글자도 비교해야 하므로 인덱스 j에 +1 한다.
j = j + 1;
}
//만약 j와 패턴의 개수 m이 같다면 문자열 패턴을 찾은 것이 된다.
if (j === m) {
// 이 때 i는 전체 인덱스 중에서 문자열 패턴이 시작했던 지점이므로
//i를 바로 반환한다.
return i;
}
}
//위의 과정에서 반환되지않고 여기까지 왔다면 찾을 문자열이 없는 것이다.
return -1;
}
Greedy Algorithm(탐욕 알고리즘)은 말 그대로 당장 눈앞에 보이는 최적의 상황만을 쫓아 최종적인 해답에 도달하는 방법이다. 문제를 해결하는 방법은 다음과 같다.
Greedy Algorithm 문제 해결 단계
탐욕 알고리즘은 항상 최적의 해를 보장하지 못한다. (당장 좋은 방법을 택하는 것이 최선이 아닐수도 있기 때문이다.)
만약 적용하려면 해결하려는 문제가 다음의 2가지 조건을 성립해야 한다.
Greedy Algorithm의 조건
Greedy Algorithm 적용 예시: 거스름돈의 동전 최소 개수 구하기
function findChange(change){
//동전 개수
let count=0;
//동전 종류
let coins=[500, 100, 50, 10];
//동전들을 검사하기 위해 동전 종류만큼 반복한다.
for(let i=0; i<coins.length; i++){
//거스름돈이 0이 되면 반복문을 멈춘다.
if(change===0) break;
//선택된 동전이 거슬러준 금액을 초과하는지 검사하고, 동전의 개수를 센다.
count+=Math.floor(Number(change/coins[i]));
//현재 거스름돈을 남은 거스름돈으로 대체한다.
change=change%coins[i];
}
//동전 개수를 반환한다.
return count;
}
탐욕 알고리즘으로 위 예시를 해결하려면 동전 간의 관계가 배수로 연결되어야 한다.
만약 coins=[6,7,8]
이런식으로 되어있다면 탐욕 알고리즘은 최적의 방법이 아니게 된다.
DP, 즉 동적 계획법은 모든 경우의 수를 조합해 최적의 해법을 찾는다.
탐욕 알고리즘과 같이 작은 문제에서 출발하지만, 매 순간 최적의 선택을 찾는 탐욕 알고리즘과는 달리 모든 경우의 수를 조합한 후 최적의 해법을 찾는다.
DP 문제 해결 단계
DP는 다음 두 가지 조건이 만족되어야 사용할 수 있다.
DP의 조건
DP 적용 예시: 피보나치 수열 구하기
피보나치 수열은 첫째와 둘째 항이 1이고, 그 다음의 항은 바로 앞 두 항의 합으로 구성된다.
원래 O(2^n)의 시간복잡도를 가진 피보나치 수열은 다음과 같다.
function fibo(n) {
if(n <= 2) {
return 1;
};
return fib(n - 1) + fib(n - 2);
}
그러나 이 경우 시간복잡도가 너무 크기 때문에 다음과 같은 방법을 사용해 좀 더 효율적으로 해결할 수 있다.
function fiboMemo(n, memo = []) {
//결과가 저장된 하위 문제인지 확인한다.
if(memo[n] !== undefined) {
return memo[n];
}
//피보나치의 첫 항, 두번째 항은 1이다.
if(n <= 2) {
return 1;
}
//결과가 저장되지 않았다면 결과를 구한다.
let result = fibMemo(n-1, memo) + fibMemo(n-2, memo);
//동일한 하위 문제가 나왔을 경우를 대비해 결과를 저장한다.
memo[n] = result;
//결과를 반환한다.
return res;
}
function fiboTab(n) {
//피보나치의 첫 항, 두번째 항은 1이다.
if(n <= 2) {
return 1;
}
//피보나치의 첫 항, 두번째 항을 미리 배열에 저장한다.
let fibNum = [0, 1, 1];
//다음 항부터 배열에 저장해가며 해결한다.
for(let i = 3; i <= n; i++) {
//앞서 배열에 저장해 놓은 값들을 이용해 다음항을 구하고, 저장한다.
fibNum[i] = fibNum[i-1] + fibNum[i-2];
}
//배열에 저장된 값을 반환한다.
return fibNum[n];
}
몇몇 알고리즘 문제를 풀기 위해서는 수학개념을 몇가지 알아야 한다.
다음은 자주 나오는 기본적인 수학개념들에 대한 설명과 그 예제들이다.
순열(Permutation)은 서로 다른 n개의 원소로 이뤄진 집합에서 중복 없이, 순서를 지켜 r개의 원소를 선택.나열하는 것을 의미한다. 여기서 r은 n보다 작아야 하며, 기호로 나타내면 nPr로 나타낼 수 있다.
순열 구하는 공식
순열은 팩토리얼을 사용해 다음과 같은 공식으로 나타낼 수 있다.
nPr=n!/(n-r)!
순열 적용 예시: r만큼 반복문 사용하기
nPr, 즉 n개의 원소중 n개를 순서를 지켜 뽑아야 할 때 모든 경우의 수를 구하는 함수를 아래와 같이 만들 수 있다. r의 개수만큼 반복문을 중첩시켜 찾는다.
function permutation() {
let target = ['A', 'B', 'C', 'D', 'E']; //선택할 대상. nPr에서 n개
let result = []; //각 부분집합이 배열로 들어있는 2차원 배열
//nPr의 r개수만큼 중첩하여 반복문을 돌린다.
//각 반복문은 target의 처음부터 끝까지 반복한다.
for (let i = 0; i < target.length; i++) {
for (let j = 0; j < target.length; j++) {
for (let k = 0; k < target.length; k++) {
if(i === j || j === k || k === i) continue; //중복 체크
//순열의 각 부분집합을 나타내는 배열
result.push([target[i], target[j], target[k]])
}
}
}
return result;
}
만약 nPr에서 r이 매번 다르다면 반복문 부분을 재귀함수로 만들어 사용한다.
function permutation(r) {
let target = ['A', 'B', 'C', 'D', 'E']; //선택할 대상. nPr에서 n개
let result = []; //각 부분집합이 배열로 들어있는 2차원 배열
//nPr의 r개수만큼 중첩하여 반복문을 돌린다.
//각 반복문은 target의 처음부터 끝까지 반복한다.
const recursive=(r, arr)=>{
//base case
if(r===0){
result.push(arr);
return;
}
//recursive case
for (let i = 0; i < target.length; i++) {
let currentValue=target[i];
if(!arr.includes(currentValue)){
recursive(r-1, [...arr,currentValue]);
}
}
}
recursive(r, []);
return result;
}
조합(combination)은 서로 다른 n개의 원소로 이뤄진 집합에서 중복 없이, 순서없이 r개의 원소를 선택.나열하는 것을 의미한다. 여기서 r은 n보다 작아야 하며, 기호로 나타내면 nCr로 나타낼 수 있다.
조합 구하는 공식
조합은 팩토리얼을 사용해 다음과 같은 공식으로 나타낼 수 있다.
nCr=n!/r!(n-r)!
조합은 순열을 이용해서도 나타낼 수 있다.
nCr=nPr/r!
조합 적용 예시: r만큼 반복문 사용하기
nCr, 즉 n개의 원소중 n개를 순서없이 뽑아야 할 때 모든 경우의 수를 구하는 함수를 아래와 같이 만들 수 있다. 순열을 구할 때처럼 r의 개수만큼 반복문을 중첩시켜 찾되, 각 반복문은 이전 상위 반복문의 다음 인덱스부터 시작해야 한다.
function combination() {
let target = ['A', 'B', 'C', 'D', 'E']; //선택할 대상. nCr에서 n개
let result = []; //각 부분집합이 배열로 들어있는 2차원 배열
//nCr의 r개수만큼 중첩하여 반복문을 돌린다.
//처음 반복문은 target의 처음부터 끝까지 반복한다.
//그 다음 반복문은 상위 반복문의 다음부터 반복해야 한다.
for (let i = 0; i < target.length; i++) {
for (let j = i+1; j < target.length; j++) {
for (let k = j+1; k < target.length; k++) {
//순열의 각 부분집합을 나타내는 배열
result.push([target[i], target[j], target[k]])
}
}
}
return result;
}
만약 nCr에서 r이 매번 다르다면 반복문 부분을 재귀함수로 만들어 사용한다.
function combination(r) {
let target = ['A', 'B', 'C', 'D', 'E']; //선택할 대상. nCr에서 n개
let result = []; //각 부분집합이 배열로 들어있는 2차원 배열
//nCr의 r개수만큼 중첩하여 반복문을 돌린다.
//처음 반복문은 target의 처음부터 끝까지 반복한다.
//그 다음 반복문은 상위 반복문의 다음부터 반복해야 한다.
const recursive=(r, arr, start)=>{
//base case
if (r === 0) {
result.push(arr);
return;
}
//recursive case
for (let i = start; i < cards.length; i++) {
const item = cards[i];
recursive(r - 1, [...arr, item], i + 1);
}
}
recursive(r, [], 0);
return result;
}
GCD, LCM은 각각 최대공약수, 최소공배수로 이를 활용한 알고리즘 문제를 풀기 위해서는 아래의 개념들을 필수로 알아야 한다.
- 최대공약수(Greatest Common Divisor, GCD): 두 수 이상의 여러 공약수 중 최대인 수
- 공약수(Common Divisor): 두 수의 여러 약수(1과 자기자신만으로 나눠진다) 중 공통된 약수
- 최소공배수(Lowest Common Multiple, LCM): 두 수 이상의 여러 공배수 중 최소인 수
- 공배수(Common Multiple): 두 수의 배수 중 공통된 배수
GCD를 구하는 방법 중 유클리드 호제법이 있으며, GCD를 알아내면 LCM을 알아낼 수 있다.
공식: GCD를 구하는 유클리드 호제법
기본적인 방식은 시간이 많이 걸릴수도 있으므로, 비교적 간단하게 구할 수 있는 방법을 소개한다.
유클리드 호제법 예시
위에서 설명한 유클리드 호제법은 다음의 예시 코드를 보면 좀 더 이해가 쉽다.
아래 코드는 유클리드 호제법으로 GCD를 구하고, 그 GCD로 LCM을 구하는 방법이다.
//GCD 구하기
const gcd=(a, b)=>{
while(b !== 0){ //b가 0이 되면 최종적으로 Infinity가 리턴되므로 주의한다.
let r = a % b;
a = b;
b = r;
//이 과정을 통해 다음 반복시 b%r을 새로운 나머지로 할당하게 된다.
}
return a;
}
//LCM 구하기
const lcm=(a, b)=>{
return a * (b / gcd(a, b));
}
부분집합은 어떤 집합의 원소로 만들 수 있는 집합으로, 개념은 따로 자세히 설명하지 않는다.
다만 알고리즘 문제에 부분집합의 개념을 적용하여 풀이하려면 다음의 사항을 알아둬야 한다.
부분집합 적용 예시
위에서 언급한대로 부분집합을 구하는 것은 트리구조와도 같으므로 재귀함수를 사용할 수 있다.
function powerSet () {
const targetSet = ['A', 'B', 'C']; //대상이 될 집합
const result = []; //모든 부분집합(배열)들을 담을 2차원 배열
const recursion =(subset, start)=>{
result.push(subset);
for(let i = start; i < targetSet.length; i++){
recursion([...subset, targetSet[i]], i+1);
//이렇게도 구현할 수 있습니다.
//recursion(subset.concat(arr[i]), i+1);
}
}
//공집합부터 시작하여 부분집합을 구한다.
recursion([], 0);
return result;
}