알고리즘의 성능을 판단하는 데에는 시간 복잡도(Time Complexity), 공간 복잡도(Space Complexity) 등이 쓰입니다.
시간 복잡도는 알고리즘이 얼마나 빠르게 실행되는지,
공간 복잡도는 알고리즘이 얼마나 메모리를 잡아먹는지를 판단하는데 쓰입니다.
이번 편에서는 그 중에서도 시간 복잡도(Time Complexity)를 알아보겠습니다.
시간 복잡도 (Time Complexity)
시간 복잡도는 입력되는 데이터의 양에 따라
알고리즘의 수행 시간이나 연산 횟수가 어떻게 달라지는지를 분석하는데 쓰입니다.
알고리즘을 짤 때에는 문제를 해결하는 것도 좋지만,
알고리즘을 얼마나 효율적으로 짜는 것 역시 중요합니다.
같은 입력을 넣었을 때, 같은 출력을 갖는다 하더라도, 알고리즘에 따라 성능이 달라질 수 있습니다.
시간 복잡도는 이러한 성능의 부분 중 시간, 즉 연산횟수를 고려할 때 쓰는 개념입니다.
표기법
복잡도는 최악의 경우, 평균의 경우, 최악의 경우. 총 세 가지로 나타낼 수 있습니다.
최악의 경우 : Big-O(빅 오),
평균의 경우 : Big-θ(빅 세타),
최선의 경우 : Big-Ω(빅 오메가)로 나타냅니다.
보통 알고리즘의 성능을 이야기 할 때는 최악의 경우를 생각하여 Big-O(빅 오) 표기법으로 나타냅니다.
최선의 경우, 평균의 경우를 고려해 알고리즘을 짰다가,
최악의 경우에 대비를 하는 것 보다는, 처음부터 최악의 경우를 고려해 알고리즘을 설계하는 것이
더 바람직한 방법이기 때문입니다.
알고리즘 성능
시간복잡도의 여러 유형 중 몇 가지를 알아보겠습니다.
O(1)
O(1)은 자료량의 크기가 얼마나 되던간에,
고정 횟수의 연산을 진행합니다.
public class Main {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
System.out.println(arr[0]);
}
}
배열의 특정 인덱스에 접근해 print 하는 프로그램은,
배열의 크기가 얼마가 되었건 인덱스로 접근하므로 늘 고정된 연산 횟수를 가집니다.
O(n)
O(n)은 자료량에 따라 시간이 정비례로 증가합니다.
int[] arr = {1, 2, 3, 4, 5};
int sum = 0;
for (int i = 0; i < arr.length; i++) {
sum += arr[i];
}
위 코드와 같이, 배열 내 원소의 값을 하나씩 더해 배열 내 원소의 합을 구하는 프로그램을 작성하였습니다.
이 경우, arr 배열의 원소 하나가 더 생길 때 마다 for문의 연산횟수는 1 증가합니다.
int[] arr = {1, 2, 3, 4, 5};
int sum = 0;
for (int i = 0; i < arr.length; i++) {
sum += arr[i];
}
for (int i = 0; i < arr.length; i++) {
sum += arr[i];
}
그렇다면, 위 코드와 같이 2개의 for문을 가졌을 경우,
원소 내 값이 1 증가할 때 마다 연산횟수는 2만큼 증가합니다.
이 경우 시간복잡도는 O(2n)일까요? 틀린 표현은 아닙니다.
다만, 연산횟수 자체는 arr의 크기에 비례하며, 빅 오 표기법에서 상수는 크게 중요하지 않습니다.
중요한 것은 입력데이터량에 따른 시간과 연산횟수의 변화입니다.
입력 데이터가 작은 경우, 이러한 상수 요소는 꽤 큰 차이를 나타낼 수 있지만,
데이터의 양이 커질수록 상수 요소의 영향은 상대적으로 작아집니다.
따라서 O(2n)이라더라도 보통 O(n)으로 나타내곤 합니다.
2, 3번의 고정 연산횟수를 가진 경우도 O(2), O(3) 이 아닌 O(1)으로 나타내는 이유입니다.
O(n^2) / O(n^k) - 거듭제곱
O(n^2)는 데이터의 양이 증가함에 따라, 그 제곱의 비율만큼 증가하는 경우입니다.
int[] arr = {1, 2, 3, 4, 5};
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr.length; j++) {
System.out.println(arr[i] + " " + arr[j]);
}
}
배열 내 원소를 중복을 허용해 모든 순서쌍을 출력하는 코드입니다
보시다시피, 2개의 for문이 중첩되어 사용됩니다
배열 내 원소가 5개이며, print문은 총 25번 호출됩니다
배열 내 원소가 6개면, 36번,
배열 내 원소가 7개면, 49번과 같이 n^2번 연산됩니다
이와 같이 for문이 2중으로 중첩되어 있으면 O(n^2)와 같이 나타냅니다.
물론 꼭 n^2번 연산하지 않더라도, 빅 오(Big-O) 표기법은 최악의 경우를 고려하므로
for문이 이중으로 중첩되어 있으면 O(n^2)와 같이 나타냅니다
int[] arr = {1, 2, 3, 4, 5};
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr.length; j++) {
for (int k = 0; k < arr.length; k++) {
System.out.println(arr[i] + " " + arr[j] + " " + arr[k]);
}
}
}
그럼 위와 같은 3중 for문의 코드의 시간복잡도는 어떻게 표현할 수 있을까요?
배열 내 원소의 개수가 5일 땐 125번,
6개일땐 216번,
7개일땐 512번.
원소의 개수가 n개라면 n^3번 연산합니다.
for문이 중첩될수록 제곱의 횟수가 증가합니다.
즉, for문이 k번 중첩되어 있다면 O(n^k)와 같이 나타낼 수 있습니다.
다만, O(2n), O(3n)을 데이터가 커질수록 상수의 영향력이 줄어들어 O(n)으로 나타내었던 것과 달리
O(n^3), O(n^4)과 같이 거듭제곱 형태의 경우 O(n^k)의 경우엔 데이터가 커지더라도
상수의 영향력이 여전이 크므로 O(n^3), O(n^4)와 같이 나타냅니다.
O(log n)
이번엔 log n의 시간복잡도를 가지는 경우입니다.
컴퓨터의 경우 이진법을 사용하기에 주로 밑이 2인 로그를 사용합니다.
고등학교를 졸업한지 꽤 되서 가물가물 하지만 log를 떠올려봅시다.
로그란 어떤 수를 구하기 위해 밑을 몇 번 곱해야 하는지 나타낼 때 썼었죠?
때문에 로그 함수는 지수 함수의 역함수이며, 이를 그래프로 그렸을 때
y = x^2와 y= log x(밑=2)는 y=x를 기준으로 대칭이다. 점점 생각이 납니다.
O(n^2)가 자료량이 커짐에 따라, 그 연산횟수(=시간)이 제곱으로 증가해 매우 빨랐다면,
O(log n)은 자료량의 증가에 따른 연산횟수의 증가가 자료량의 증가량보다 적다고 볼 수 있습니다.
이를 가장 잘 나타낼 수 있는 것은 이진탐색입니다.
병뚜껑 숫자 맞히기 게임은 특정 숫자를 외치면 Up, Down을 말하여 병뚜껑의 숫자를 찾는 게임입니다.
이진탐색을 쉽게 말하면 정중앙을 탐색하는 탐색 방법입니다.
병뚜껑 숫자가 1~50범위라면 25를 외치고 down 이라면, 이제 1~24 범위 중 중앙인 12를 말하는 방법이죠.
병뚜껑 숫자가 10, 최소 1, 최대 50이라네요.
한 명씩 번갈아가며 숫자를 이야기해봅시다. 다만 이진탐색적 기법으로.
1턴 : 25 -> down, 남은 범위 : 1 ~ 24
2턴 : 12 -> down, 남은 범위 : 1 ~ 11
3턴 : 6 -> up, 남은 범위 : 7 ~ 11
4턴 : 9 -> up, 남은 범위 : 10 ~ 11
5턴 : 10 -> 정답!
어떤가요? 매 탐색을 진행할 때 마다 탐색해야 할 범위가 절반으로 줄어듭니다.
때문에 1~50 범위의 수가 1~100 범위로 2배 증가해도, 탐색 횟수는 평균 1회밖에 증가하지 않습니다.
1~200으로 4배 증가하더라도, 탐색 횟수는 평균 2회밖에 증가하지 않고요.
이를 코드로 나타내면 다음과 같습니다.
int start = 1;
int end = 50;
int target = 10;
while (start <= end) {
int mid = (start + end) / 2;
if (mid == target) {
System.out.println("정답 " + target + "을(를) 찾았습니다.");
break;
} else if (mid < target) {
start = mid + 1; // 오른쪽 반으로 범위 조정
} else {
end = mid - 1; // 왼쪽 반으로 범위 조정
}
}
O(log n)의 시간복잡도를가지는 이진 탐색의 경우,
데이터가 2배 늘어나면 탐색횟수가 1회,
데이터가 4배 늘어나면 2회,
8(2^3)배 늘어나면 3회,
16(2^4)배 늘어나면 4회,
n배 커지면, log_2 n번 증가합니다.
때문에 O(log n)은 O(n) 보다 더 효율적이라고 볼 수 있지요.
마치며
사실 공간복잡도와 관련된 글을 쓰고 싶어 시간복잡도와 공간복잡도를 비교하는 글을 쓰다가,
생각보다 길어져 별도의 글로 포스팅했습니다.
공간복잡도 편에서 다시 만나요.
'CS 지식 > 알고리즘' 카테고리의 다른 글
[알고리즘] 에라토스테네스의 체 (Sieve of Eratosthenes) (0) | 2024.01.30 |
---|---|
[알고리즘] 이분 매칭 (Bipartite Matching) (0) | 2024.01.18 |
알고리즘 성능 평가 [2] : 공간 복잡도(Space Complexity) (2) | 2023.08.21 |
[알고리즘] 그래프 탐색 - 깊이 우선 탐색(DFS)와 너비 우선 탐색(BFS) (0) | 2023.07.18 |
[알고리즘] 최소 스패닝 트리 (MST, Minimum Spanning Tree) (0) | 2023.04.24 |