1. Big O 표기법이란?
- 빅오는 입력값이 무한대로 향할때 함수의 상한을 설명하는 수학적 표기법입니다.
- 빅오는 점근적 실행 시간을 표기할 때 가장 널리 쓰이는 표기법입니다.
- 점근적 실행 시간: 입력값 n이 무한대로 향할 때 함수의 실행 시간 추이
- 아무리 복잡한 알고리즘도 입력의 크기가 작으면 금방 끝나기 때문에 입력의 크기가 커질수록 시간이 어떻게 증가하는지를 표현하는 방법입니다.
1.1 상한과 최악
- 빅오는 상한을 의미한다.
- 하한을 나타내는 빅 오메가(Ω)와 평균을 나타내는 빅 세타(Θ)도 있습니다.
- 상한과 최악을 혼동하는 경우가 있는데, 빅오는 최악의 경우를 설명하는 것이 아닙니다.
- 빅오 표기법은 함수를 적당히 정확하게 표현하는 방법일 뿐 최악의 경우/ 평균적인 경우의 시간 복잡도와는 관련이 없습니다.
- 빅오 표기법은 주어진(최선/최악/평균) 경우의 수행 시간의 상한을 나타냅니다.
- 즉 최악(최선, 평균)의 경우의 수행 시간이 이 표기법을 사용하여 나타낸 수행 시간보다 더 나쁠 수는 없다는 것을 의미합니다.
2. 빅오의 기본 규칙
2.1 상수항 제외
- 빅오는 입력 크기에 따른 증가율만 고려합니다
- 실제 연산 횟수나 상수 계수는 제외됩니다
2.1.1 상수항 제외 첫 번째 예시
def find_in_half(arr):
# O(n/2) -> O(n)
for i in range(len(arr) // 2):
if arr[i] == target:
return i
return -1
- O(n/2)는 O(n)으로 표기합니다
2.1.2 상수항 제외 두 번째 예시
def process_half_matrix(matrix):
n = len(matrix)
# O(n²/2) -> O(n²)
for i in range(n):
for j in range(i): # 대각선 아래 부분만 처리
process(matrix[i][j])
- O(n²/2)는 O(n²)으로 표기합니다
2.1.3 상수항 제외 세 번째 예시
def process_with_constants(arr):
# O(4n) -> O(n)
for i in range(len(arr)):
calculate_sum(arr[i]) # 1회 연산
find_average(arr[i]) # 1회 연산
update_max(arr[i]) # 1회 연산
check_threshold(arr[i]) # 1회 연산
- 반복문 내부에 4개의 연산이 있어도 O(4n)은 O(n)으로 표기합니다
- 상수 4는 증가율에 영향을 주지 않으므로 제외됩니다
2.2 비우세항 제외
- 가장 빠르게 증가하는 항만 남기고 나머지는 제외합니다
2.2.1 비우세항 제외 예시
def example_function(n):
# O(n² + n + 1) -> O(n²)
for i in range(n): # O(n)
for j in range(n): # O(n)
print(i, j)
for k in range(n): # O(n)
print(k)
print("done") # O(1)
- O(n² + n + 1)은 O(n²)으로 표기합니다
2.3 입력이 다르면 변수도 다르다
2.3.1 서로 다른 입력 크기의 예시
def compare_arrays(arr_a, arr_b):
# O(a + b): a와 b는 서로 다른 입력
for i in arr_a: # O(a)
print(i)
for j in arr_b: # O(b)
print(j)
def process_same_array(arr):
# O(2n) -> O(n): 같은 입력을 두 번 순회
for i in arr: # O(n)
print(i)
for j in arr: # O(n)
print(j)
- 서로 다른 입력 크기의 변수는 다른 변수로 표기합니다
- 같은 입력을 두 번 순회하는 경우에는 O(2n) 대신 O(n)으로 표기합니다
- 상수항은 제외합니다
2.4 단계의 합산과 곱
2.4.1 같은 레벨의 반복문 예시
def sequential_loops(n):
# O(n + n) = O(2n) -> O(n)
for i in range(n):
print(i)
for j in range(n):
print(j)
- 같은 레벨의 반복문은 합산하여 표기합니다
2.4.2 중첩된 반복문 예시
def nested_loops(n):
# O(n * n) = O(n²)
for i in range(n):
for j in range(n):
print(i, j)
- 중첩된 반복문은 곱하여 표기합니다
2.4.3 상수 반복문이 포함된 중첩 반복문 예시
def nested_with_constant(n):
# O(n * n * 1000000) -> O(n²)
for i in range(n):
for j in range(n):
for k in range(1000000): # 상수 반복
print(i, j, k)
- O(n * n * 1000000)은 O(n²)으로 표기합니다
- 상수 반복문은 영향을 주지 않으므로 제외합니다
3. 대표적인 시간 복잡도
3.1 O(1) - 상수 시간
- 입력 크기와 관계없이 항상 일정한 시간이 소요됩니다
- 예시
- 배열의 인덱스 접근
- 해시 테이블의 삽입, 삭제, 조회
3.1.1 O(1) 예제
def get_first_element(arr):
return arr[0]
3.2 O(log n) - 로그 시간
- 입력 크기가 증가할 때 실행 시간이 로그함수처럼 증가합니다
- 로그는 매우 큰 입력값에도 크게 영향을 받지 않아 효율적입니다
- 이진 탐색이 대표적인 예시입니다
3.2.1 O(log n) 예제
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
3.3 O(n) - 선형 시간
- 입력 크기에 비례하여 실행 시간이 증가합니다.
- 얘시
- 정렬되지 않은 배열의 최댓값 찾기
3.3.1 O(n) 예제
def find_max(arr):
max_val = arr[0]
for num in arr:
if num > max_val:
max_val = num
return max_val
3.4 O(n log n) - 선형 로그 시간
- 퀵 정렬, 병합 정렬과 같은 효율적인 정렬 알고리즘의 시간 복잡도입니다
3.4.1 O(n log n) 예제
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
3.5 O(n²) - 이차 시간
- 중첩된 반복문에서 주로 나타납니다
- 버블 정렬, 선택 정렬이 대표적인 예시입니다
3.5.1 O(n²) 예제
def bubble_sort(arr):
n = len(arr)
for i in range(n):
for j in range(0, n-i-1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
return arr
3.6 O(n!) - 팩토리얼 시간
- 모든 가능한 순열을 생성하는 경우에 나타납니다
- n이 증가할수록 실행 시간이 급격하게 증가합니다
- 외판원 순회 문제(TSP)가 대표적인 예시입니다
3.6.1 O(n!) 예제
def generate_permutations(arr):
if len(arr) <= 1:
return [arr]
perms = []
for i in range(len(arr)):
curr = arr[i]
remaining = arr[:i] + arr[i+1:]
for p in generate_permutations(remaining):
perms.append([curr] + p)
return perms