나는 알고리즘엔 엄청 약하다. 솔직히 아직도 잘 모르겠다.
그래서 이번에 알고리즘 관련 복습을 할 겸, 내 알고리즘이 어느 정도의 효율을 가지고 있는지,
어떻게 측정하는지를 써보려고 한다.
(주의! 이건 필자가 공부한 걸 바탕으로 적어 내려 가는 것이기 때문에 틀릴 수 있음을 미리 경고합니다.)
(주의! 이 글은 C# 언어 기준입니다.)
시간 복잡도 표기법?
보통 알고리즘을 얘기하면 아래의 표기법으로 많이 얘기한다.
각각의 표기법은
빅오: 상한선을 표현
빅오메가: 하한선을 표현
빅세타: 상한선과 하한선 사이를 표현
이렇게 된다.
이걸 현실에 사용한다면 어떻게 표현할 수 있을까?
예를 들어, 누군가와 게임 내에 있는 어떤 기능의 알고리즘의 효율에 대해 이야기한다고 가정해보자.
A: "이 알고리즘은 효율이 어느 정도인가?"
(빅오 기준일 경우)B: "최악의 경우 (n2) 정도의 시간 복잡도를 가집니다."
(빅오메가 기준일 경우)B: "값이 잘 주어질 때 (1) 정도의 시간 복잡도를 가집니다."
(빅세타 기준일 경우)B: "평균적으로 (logn) 시간 복잡도를 가집니다."
개인적인 생각으론 빅오메가는 쓸 일이 없어 보인다.
물론 쓰이긴 할 것이다! 자신의 알고리즘에서 최악의 경우가 너무 나빠서 상사한테 마치 괜찮은 알고리즘인 것 마냥 숨길 때 쓰일 것이다.
내 알고리즘의 시간 복잡도 구하기
필자는 수학을 매우 못한다...
그래서 가장 계산하기 쉬운 기본적인 예시만 들어서 설명하겠다.
일단, 시간 복잡도를 계산할 때 중요한 요소와 규칙이 있다.
중요한 요소
- 조건문 (if)
- 반복문 (for, while, foreach)
- 재귀 호출
규칙
- 시간 복잡도에서 상수값은 무시된다.
- 실제 개발자가 짜 놓은 코드를 수행하는 것은 상수 시간으로 간주한다.
예시
1.
void Function(int n)
{
for (int i = 0; i < n; ++i)
{
// 필요한 코드 수행
}
}
이 코드의 시간 복잡도는 O(n)이다.
간단하게, 코드를 n번 반복했기 때문이다.
2.
void Function(int n)
{
for (int i = 0; i < n; ++i)
{
for (int j = 0; j < n; ++j)
{
// 필요한 코드 수행
}
}
}
이 코드는 n * n번 수행하기 때문에, 시간 복잡도는 O(n2) 이다.
3.
void Function(int n)
{
// 짝수값마다 코드를 수행
for (int i = 0; i < n; i += 2)
{
// 코드 수행
}
}
이 코드는 i를 2씩 더해서 n2번 코드를 반복하기 때문에, O(n2)일 것 같지만...!
n2은 12n이기 때문에
시간 복잡도에서는 상수를 무시하므로 그냥 O(n)이다.
4.
void Function(int n)
{
for (int i = 0; i < n; ++i)
{
// 홀수 일 때만 코드를 수행하도록 함
if (n % 0)
{
continue;
}
// 코드 수행
}
}
이 코드는 예시 3번과 유사한데, i += 2를 하는 대신 if을 넣어서 짝수 케이스를 무시하도록 하고 있다.
이것은 어떻게 계산할까?
if문은 if조건문 코드와 내부 코드 else if의 조건문, else 내의 코드 등등을 다 더하는 계산을 한다.
그리고 코드 한 줄 한 줄은 앞서 얘기한 대로 상수 실행 값으로 처리한다.
if조건문의 코드 실행 시간을 c0, continue 코드의 실행 시간을 c1, 그 아래 나머지 코드 실행 시간을 c2이라고 하면,
if문의 실행시간을 이것을 다 더한 (c0+c1+c2)이 되고, 이것을 for 반복문으로 n번 반복하니,
이 코드의 복잡도는 3f(n) = (c0+c1+c2)∗n 이 된다.
그리고 시간 복잡도를 따질 때, 상수는 무시되므로 이 예시의 시간 복잡도는 O(n)이 된다.
5.
void Function(int n)
{
if (n == 0)
{
return;
}
Function(n / 2);
}
여기서부턴 조금 계산이 어려워진다.
재귀 함수가 나올 때 공식의 모습은 함수 공식 안에 함수 공식을 또 호출하는 모양이 나온다.
그래서 위 예시의 함수는, f(n)=f(12n)+c0이다. (상수 값 c0는 if문 처리 시간을 뜻한다.)
여기서 f(12n)는 다시 f(12n)=f(14n)+c0 으로 풀어쓸 수 있는데,
이걸 위 첫 공식에 대입 하면 f(n)=f(14n)+c0+c0이 된다.
그리고 또 여기서 한번더 풀어쓰면 f(n)=f(18n)+c0+c0+c0이 된다.
그럼 만약에 여기서 또 이 함수를 풀어쓰고, 거기서 또 풀어쓰고를 무한히 반복하면?
f(n)=f(n2k)+kc0 와 같은 모습이 나올 것이다.
그런데, 코드 로직의 한계상 2k는 n보다 2배 혹은 2배보다 작은 수가 된다.
예를 들어, n = 3일 경우,
- Function(3 / 2) => 1
- Function(1 / 2) => 0
- 0 == 0 이므로 리턴
이렇게 Function(n / 2)가 총 2번 반복하므로, 우리는 n < 2k ≤ 2n임을 알 수 있다.
여기서 머리가 나쁜 나 같은 사람은 범위 계산엔 쥐약이므로 2k = 2n인 경우만 따져보자.
2k를 2n으로 치환하면, f(n2n)+kc0가 되는데, n2n을 계산하면, f(2)+kc0이다.
그럼 남은 것은 f(2)와 kc0인데, f(2)와 c0는 상수이기 때문에 버려진다.
그럼 남은 것은 k뿐인데, k는 어떻게 구하냐? 우리는 여기서 지수의 로그 변환 공식을 기억해야 한다.
'2a=b는 a=log2b 이다.'
이 공식과 아까 2k=2n을 섞으면, 우리는 k의 값을 알아낼 수 있다.
2k=2n은 k=log22n
그럼 이를 다시 kc0에 넣으면, f(n)=log22n이 된다, 여기서 로그의 성질에 의해 진수는 더하기로 분리가 가능하므로,
f(n)=log22+log2n => f(n)=1+log2n이 된다. 헉헉
거의 다 왔다! 여기서 상수인 1을 버리면?
f(n)=log2n
마지막으로! 여기서 밑인 2를 상용로그로 생략하면?
f(n)=logn
드디어 구했다!
이로써 우리는 예제의 시간 복잡도가 logn임을 구하게 되었다.
그런데!
우리는 아까 2k = 2n일 때만 구하였다. 2k < 2n 일 때는 어떻게 할까?
이 경우에도 결과적으로 똑같다. f(n2k)를 f(n2n)로 치환해서 상수 값으로 버릴 때,
어쨋꺼나 2k는 n보다 크고 2n보다 작은 수므로, 결국엔 n으로 나눠져 상수 값으로 버려질 값이기 때문이다.
하지만, 실제 실행 횟수는 정확히 logn값이 아닌 정수로 딱딱 떨어지는 횟수이므로 빅오로는 표기하기 애매하다.
그래서 이 경우에는 평균 시간 복잡도인 빅세타로 표기해야 한다.
결국, 이 예제의 정확한 시간 복잡도는
Θ(log n)
마무리하며...
나는 솔직히 알고리즘의 시간 복잡도를 써본 적 없다. 아마 앞으로도 평생 쓸 일이 없을 것이라 생각하지만, 한번쯤 복습하는 것도 나쁘지 않다고 생각한다. 왜인지 회사에서는 이런걸 쓰지도 않음에도 꼭 면접 볼 때 이것 관련 지식을 요구하므로...
p.s 오늘 하루 종일 예제 5에 대해 어떻게 계산하는지 구글링을 엄청 했는데,
다들 마스터 정리의 기본 예제인 'T(n)=2T(12n)+O(n)은 Θ(n log n)이다.' 만 주구장창 대충 설명하고 있고
왜 그렇게 되는지 하나하나 자세하게 쉽게 설명하는 곳은 없었다.
그래서 이해하느라 애먹었다. :(