바로가기
시간 복잡도란?
컴퓨터 환경이나 성능에 따라, 또는 프로그래밍 언어에 따라 실행 시간은 제각각입니다. 그렇기에 데이터의 개수(n)가 늘어날 때, 연산의 횟수가 어떤 패턴으로 늘어나는지를 측정하고 어떤 환경에서도 예상을 할 수 있도록 지표를 만들었습니다.
시간 복잡도의 중요성(In Python)
보통의 환경에서는 체감할 수 없을 정도로 컴퓨터는 빠릅니다. 하지만 실무에서는 100만 개, 1,000만 개... 매우 많은 데이터를 다루기 때문에 n만큼 실행 시간이 늘어나는 게 아닌 n제곱만큼 늘어난다면 현재의 컴퓨터로는 해결할 수 없게 됩니다.
그렇기에 Big-O(빅 오) 표기법을 사용해 데이터의 양에 따라 연산량이 얼마나 늘었는지, 최악의 상황에 얼마나 걸리게 되는지 예측을 하고 그 예측을 통해 대응을 하거나 문제 해결을 할 수 있게 됩니다.

Big-O 종류와 시간 차이
1. 최적의 영역
아무리 데이터가 많아져도 문제없는 알고리즘입니다.
(1) O(1) - (Constant Time, 상수 시간)
데이터가 1개든 100만 개든 연산 시간이 같습니다.
ex. 리스트의 인덱스로 값 찾기, 해시맵 조회
(2) O(log n) - (Logarithmic Time, 로그 시간)
데이터가 2배로 늘어나도 연산은 딱 1번 늘어납니다.
ex. 이진 탐색, 균형 잡힌 이진트리 조회
2. 합리적 영역
이정도면 합리적으로 넘어갈 수 있는 알고리즘입니다.
(1) O(n) - (Linear Time, 선형 시간)
데이터 양에 비례해서 시간이 늘어납니다.
ex. for 문, 리스트 전체 합
(2) O(n log n) - (Linerarithmic Time, 선형 로그 시간)
효율적인 정렬의 표본으로 O(n)보다는 조금 느리지만 대규모 정렬에서 이보다 잘하기 어렵습니다.
ex. 파이썬의 sort(), 병합 정렬
3. 위험 영역
데이터가 많아지면 서비스가 터진다,라고 확신할 수 있는 알고리즘입니다.
(1) O(n^2) - (Quadratic Time, 이차 시간)
중첩 루프로 데이터 10배가 늘어날 동안 시간은 100배 늘어납니다.
ex. 2중 for문, 버블 정렬
(2) O(2^n) - (Exponetial Time, 지수 시간)
데이터가 조금만 커져도 절대 끝나지 않을 수 있습니다.
ex. 피보나치수열의 단순 재귀 구현
한눈에 알아볼 수 있도록 표로 정리해 보았습니다.
| 데이터의 수(n) | O(1) | O(lon n) | O(n) |
| 10 | 1 | 3.3 | 10 |
| 1,000 | 1 | 10 | 1,000 |
| 1,000,000 | 1 | 20 | 1,000,000 |
파이썬 코드로 비교하는 시간 차이
실제 코드를 이용해 체감할 수 있도록 해보겠습니다. 단 시간은 환경이나 실행에 따라 차이가 있으니 유의해주세요.
import time
def sum_linear(n):
# (n): 숫자를 하나씩 더함
result = 0
for i in range(n):
result += i
return result
def sum_quadratic(n):
# O(n^2): 불필요한 중복 루프
result = 0
for i in range(n):
for j in range(n):
pass # 아무것도 안 해도 루프 횟수 자체가 폭발함
return result
n = 10000
# O(n) 측정
start = time.time()
sum_linear(n)
print(f"O(n) 걸린 시간: {time.time() - start:.5f}초")
# O(n^2) 측정
start = time.time()
sum_quadratic(n)
print(f"O(n^2) 걸린 시간: {time.time() - start:.5f}초")

코드를 실행함으로써 둘의 차이는 겨우 1만 개 데이터로도 엄청난 차이가 발생했습니다. 그렇기에 시간 복잡도를 고려해서 코드를 짜는 것이 매우 중요하다는 점을 다시금 알 수 있었습니다.
헷갈리기 쉬운 파이썬의 O(n)
헷갈리기 쉬운 파이썬의 O(n)에 대해서 정리해 보겠습니다.
1. List와 Set
리스트에서 값을 찾을 때 O(n)이지만 셋(Set)에서는 O(1)입니다.
import time
# 1,000만 개의 데이터 준비
large_list = list(range(10000000))
large_set = set(large_list)
target = 9999999
# 리스트 검색 (O(n) - 선형 탐색)
start = time.time()
print(f"List 검색 결과: {target in large_list}")
print(f"List 검색 시간: {time.time() - start:.5f}초")
# 셋 검색 (O(1) - 상수 시간 / 해시 테이블)
start = time.time()
print(f"Set 검색 결과: {target in large_set}")
print(f"Set 검색 시간: {time.time() - start:.5f}초")

2. 리스트의 0번 인덱스 삭제
리스트에서 맨 앞 요소를 삭제하면 뒤의 모든 데이터를 한 칸씩 앞으로 당기는 데, 이때 O(n)만큼 걸립니다. 하지만 deque로 구현하면 데이터 이동 없이 즉시 삭제됩니다.
import time
from collections import deque
# 테스트 설정: 10만 개의 데이터를 가진 객체에서 맨 앞 요소를 1,000번 추출
n = 100000
repeat = 1000
# 1. 리스트 pop(0) 성능 측정
my_list = list(range(n))
start_time = time.time()
for _ in range(repeat):
my_list.pop(0) # O(n) 연산이 repeat만큼 반복됨
list_duration = time.time() - start_time
print(f"--- 리스트(List) 결과 ---")
print(f"pop(0) {repeat}회 수행 시간: {list_duration:.5f} 초")
# 2. 데큐(deque) popleft() 성능 측정
my_deque = deque(range(n))
start_time = time.time()
for _ in range(repeat):
my_deque.popleft() # O(1) 연산이 repeat만큼 반복됨
deque_duration = time.time() - start_time
print(f"\n--- 데큐(deque) 결과 ---")
print(f"popleft() {repeat}회 수행 시간: {deque_duration:.5f} 초")

이런 결과가 나오는 이유?
list.pop(0)을 할 때 메모리에서 일어나는 일
1. 제거 : 0번 인덱스의 주소값을 삭제합니다.
2. 쉬프트(Shift) : 1번 인덱스 주소값을 0번으로, 2번 인덱스 주소값을 1번으로 ... n번 인덱스 주소값을 n-1번으로 모든 주소값을 한 칸씩 앞으로 복사해서 옮깁니다.
=> 시작 주소는 고정이지만 그 안의 주소값이 물리적으로 이동이 일어납니다.
deque.popleft()를 할 때 메모리에서 일어나는 일
1. 포인터 변경 : 첫 번째 노드가 가리키는 다음 노드를 새로운 시작 노드로 지정합니다.
2. 연결 끊기 : 기존 첫 번째 노드의 연결을 끊어 버립니다.
=> 헤드 포인터가 가리키는 메모리 주소만 이동합니다.
마무리
빅데이터 시대에서 시간 복잡도를 고려하는 것은 필수라고 생각합니다. 코드를 완성했더라도 시간 복잡도를 줄일 수는 없는지, 쓸 때 없이 자원을 사용하지 않는지 등 늘 고려하는 개발자가 되어야겠습니다.
핵심 요약
1. 시간 복잡도 = 효율성
2. 파이썬의 함정을 조심할 것 (적절한 자료구조, 함수의 선택)

'파이썬 > 파이썬 관련 도움글' 카테고리의 다른 글
| 과대적합, 과소적합 알기 (0) | 2023.08.02 |
|---|---|
| loc, iloc에 대해 알기 (0) | 2023.08.02 |
| 활성화 함수 알기 (0) | 2023.07.31 |
| 상관계수 알기 (0) | 2023.07.21 |
| 알고리즘 순서도를 그리는 방법 (0) | 2023.07.05 |