직접 구현하는 것보다 내장함수를 제대로 쓸 수 있으면 속도가 빠르다

알고리즘을 공부하면서 내장함수를 쓰는게 좋을까? 직접 구현하는게 좋을까?는 누구나 고민하는 부분이다.

 

크기 n인 배열에서 최댓값을 구하고자 할때 어떻게 하는가?

 

max(A)로 바로 구하거나, for문을 돌아서 최댓값을 갱신해서 찾는다.

 

어떻게 해도 둘의 시간복잡도는 O(N)이다.

 

그렇지만 실행시간에는 확실한 차이가 있다.

 

gemini를 이용해 실제 둘의 실행시간의 테스트 코드를 짜서 비교해본다.

 

import time
import random

# 1. 테스트 환경 셋팅
N = 1000000 # 100만 개의 데이터
print(f"🚀 테스트 데이터 생성 중... (크기: {N}개)")
A = [random.randint(1, 1000000) for _ in range(N)]
print("✅ 데이터 준비 완료!\n")

print("-" * 50)

# 2. 내장 함수 max()를 사용하는 경우
start_time = time.perf_counter()
max_builtin = max(A)
end_time = time.perf_counter()

time_builtin = end_time - start_time
print(f"⚡ 내장 함수 max(A) 실행 시간 : {time_builtin:.5f}초")

# 3. 직접 for문을 돌려가며 갱신하는 경우
start_time = time.perf_counter()
max_manual = A[0]
for num in A:
    if num > max_manual:
        max_manual = num
end_time = time.perf_counter()

time_manual = end_time - start_time
print(f"🐢 직접 구현 for문 실행 시간  : {time_manual:.5f}초")

print("-" * 50)

# 4. 결과 비교
speed_diff = time_manual / time_builtin
print(f"💡 결론: 동일한 O(N)임에도 내장 함수가 약 {speed_diff:.1f}배 더 빠릅니다!")

 

 

 

몇번 돌려보면 둘이 평균적으로 유의미하게 차이가 난다

 

 

 

 

 

 

왜 차이가 나는가?

 

기본적으로 파이썬의 내장함수들은 순수한 파이썬 코드가 아니라, C언어로 작성되어 있어서, 이미 기계어로 컴파일된

 

고도로 최적화된 함수들이다.

 

내장함수 max()는 중간에 인터프리터가 해석하지 않고 바로 메모리에 접근해서 숫자만 비교해서 max값을 가져온다.

 

for문으로 직접 구현하면, 파이썬 인터프리터가 실시간으로 코드를 한줄한줄 해석하면서 실행하기 때문에 속도 차이가 있다.

 

for문이라는건 단순히 반복문이 아니다. 내부에서 생각보다 많은 일을 한다.

 

1) 다음 객체를 가져와라 

 

2) 배열의 끝에 도달했는지 검사한다

 

3) 꺼낸 숫자가 정수인지, 비교 가능한 객체인지 타입을 본다.

 

하나의 for문이 이 과정을 100만번이나 해줘야한다.

 

또한 단순히 if num > max_manual: 이라는 코드도, 이를 실행하기 위해 내부적으로 거대한 사전을 뒤져본다.

 

수많은 전역변수 중에서 num, max_manual이라는 이름표를 가진 데이터를 가져와야하며, 이 과정이 100만번 누적된다.

 

 

 

내장함수가 잘 쓰면 빠를 수 있지만, 남발하면 안되는데도 이유가 있다.

 

max()함수를 호출하는데도 비용이 존재하는데... 단순히 max()에 들어가는 인자가 매우 적다면.. 이 호출하는 비용이 더 클수도 있다.

 

예를 들어 max(a,b)와 if a > b로 순수하게 2개의 수만 비교한다면?

 

import time
import random

N = 10000000  # 1000만 번 비교
print(f"🚀 테스트 준비 중... (두 숫자의 대소 비교 {N}번 반복)")

# 공정한 측정을 위해 1000만 개의 (a, b) 숫자 쌍을 미리 생성해둡니다.
pairs = [(random.randint(1, 100), random.randint(1, 100)) for _ in range(N)]
print("✅ 데이터 준비 완료!\n")

print("-" * 50)

# 1. 내장 함수 max(a, b)를 사용하는 경우 (비행기)
start = time.perf_counter()
for a, b in pairs:
    val = max(a, b)  # 함수 호출 발생
end = time.perf_counter()
time_max = end - start
print(f"✈️ 내장 함수 max(a, b) 소요 시간 : {time_max:.5f}초")

# 2. 순수 조건문 (삼항 연산자)을 사용하는 경우 (자전거)
start = time.perf_counter()
for a, b in pairs:
    val = a if a > b else b  # 함수 호출 없이 바이트코드 레벨에서 즉시 비교
end = time.perf_counter()
time_if = end - start
print(f"🚲 순수 조건문(if) 소요 시간      : {time_if:.5f}초")

print("-" * 50)

# 결과 비교
speed_diff = time_max / time_if
print(f"💡 결론: 변수 2개 비교 시, 순수 조건문이 약 {speed_diff:.1f}배 더 빠릅니다!")

 

 

몇번 해보면 평균적으로 조건문으로 단순히 비교하는게 유의미하게 빠르다.

 

 

 

 

그리고 초보자들이 가장 많이 하는 실수지만, 많은 내장 함수들이 O(N)의 시간복잡도가 있다보니..

 

for문 안에 아무 생각 없이 쓰다가 O(N)이라고 생각했지만.. O(N^2)이 되는 경우가 있다.

 

예를 들어 배열 A의 원소들의 개수들을 구할때

 

A.count(num)함수는 A 내에 num이 몇개 있는지 구해준다.

 

그렇지만 다음과 같이 for문 안에 count()를 생각 없이 써버리면 O(N^2)이 되어서 시간초과를 받는다.

 

# A는 10만 개의 숫자가 들어있는 리스트라고 가정합니다.

freq = {}
for num in A:             # 리스트 A를 처음부터 끝까지 순회 (O(N))
    freq[num] = A.count(num)  # 🚨 치명적 실수: count()가 배열을 매번 끝까지 다시 뒤짐 (O(N))

 

 

이분 탐색을 직접 구현하는 편인데... 내장함수를 사용할 수 있으면 좋을 수 있다.

 

import time
import random
import bisect

print("데이터 준비 중... (100만 개 정렬된 배열, 10만 번 탐색)")
nums = sorted([random.randint(1, 10000000) for _ in range(1000000)])
targets = [random.randint(1, 10000000) for _ in range(100000)]
print("준비 완료!\n")

# 직접 구현한 이분 탐색 (Lower Bound)
def my_bisect_left(arr, target):
    left, right = 0, len(arr) - 1
    while left <= right:
        mid = (left + right) // 2
        if arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return left

# ==========================================
# 테스트 1: 직접 구현한 while문 이분 탐색
# ==========================================
print("-" * 50)
start = time.perf_counter()
for t in targets:
    my_bisect_left(nums, t)
end = time.perf_counter()
time_manual = end - start
print(f"🐢 직접 구현 (while문) 소요 시간: {time_manual:.5f}초")

# ==========================================
# 테스트 2: 파이썬 내장 라이브러리 bisect 사용
# ==========================================
start = time.perf_counter()
for t in targets:
    bisect.bisect_left(nums, t)
end = time.perf_counter()
time_builtin = end - start
print(f"✅ bisect 라이브러리 소요 시간:   {time_builtin:.5f}초")

# ==========================================
# 결과 비교
# ==========================================
print("-" * 50)
print(f"💡 최종 결과: bisect 라이브러리가 약 {time_manual / time_builtin:.1f}배 더 빠름!")
print("-" * 50)

 

 

몇번 돌려보면 꽤 유의미하게 차이가 난다.

 

 

 

 

bisect_left는 lower_bound를 구해주고, bisect_right는 upper_bound를 구해준다.

 

import bisect

arr = [1, 2, 2, 2, 5]

# 숫자 2가 들어갈 수 있는 가장 왼쪽 위치 (첫 번째 2의 위치)
print(bisect.bisect_left(arr, 2))  
# 출력: 1 (arr[1] 자리에 들어감)

# 숫자 2가 들어갈 수 있는 가장 오른쪽 위치 (마지막 2의 다음 위치)
print(bisect.bisect_right(arr, 2)) 
# 출력: 4 (arr[4] 자리에 들어감)

# 배열에 없는 숫자를 찾을 때 (둘 다 같은 값을 반환)
print(bisect.bisect_left(arr, 3))  # 출력: 4
print(bisect.bisect_right(arr, 3)) # 출력: 4

 

 

그러나 내장함수는 내가 내부를 변형시킬 수 없다는게 문제다.

 

그러다보니 이분탐색에서 응용된 parametric search 같은 응용 문제에서는 직접 구현해서 짜야되기 때문에...

 

직접 구현할줄 알아야하는 경우가 분명히 있다.

 

 

728x90