💡 자료구조와 알고리즘에 대해 고민해보고 깨달은 것들을 내 언어로 정리해두는 공간
✍️ 혼자 공부하면서 고민한 결과를 그때 그때 기록해두기 위해 씁니다. 정확하지 않은 정보일 수 있습니다.
점근적 관점에서 수행 시간 분석할 때는 나 이나 이나 같기 때문. 샌드위치 정리.
두 알고리즘의 차이는 배열 내부 원소 위치를 바꾸는 작업의 횟수에 있다.
대소 비교 횟수를 기준으로 보면 두 알고리즘은 같다. 다만,
의 차이.
그래서 선택 정렬은 아묻따 모든 케이스 같은 시간이 들고
버블 정렬은 중간중간 원소 교환이 먹히는 케이스가 들어오면 시간이 덜 들 수도 있는 것.
(👉 정렬이 얼추 되어있으면 + 알고리즘을 개량해 중간에 정렬을 완료할 수 있으면)
하지만 최악의 경우엔 당연히 중간중간 다 교환하면서 가는 게 비효율적이 되고.
(👉 정반대 순서로 정렬이 되어있으면)
어쨌든 원소 교환을 다르게 한다고 해도,
그건 어차피 해야 하는 대소 비교 하면서 추가 작업이 있거나 없거나의 관점이기 때문에,
수행 시간의 관점에선 최대 두 배일 뿐. 그래서 결국 시간 복잡도는 둘 다
하지만 표현식 자체는 다를 것이기 때문에 점근적 관점이 아닌 미시적인 레벨의 현실에서는 상술한 것처럼 차이가 생긴다.
비교 정렬이 아닌 특수한 케이스를 제외하면,
일반적인 비교 정렬의 경우에는 두 값의 사이의 대소 비교 연산이 정렬 알고리즘에 있어 모든 작업의 토대가 되는 원소 작업이다.
비교 정렬 알고리즘이란 러프하게 말해서,
전체 배열 안에서 서로 다른 두 값끼리의 대소 비교를 여러 번 하되 알잘딱깔센 하는 것이니까.
문제에서 요구하는 정렬 방식을 모든 케이스에 대해 깔끔히 구현하는 게 쉽지 않다면,
정렬 알고리즘은 언어에 내장된 정렬 함수에 맡기고,
(👉 의 수행 시간은 이 쪽에서 알아서 책임져 줄 것.)
배열 내부 객체들끼리의 대소 비교를 구현하는 방향으로 접근했을 때 오히려 간단하게 문제가 해결될 때가 있다.
(👉 현 상태로는 문제에서 요구하는 기준으로 양자 비교가 바로 안 되니까)
프로그래머스에 있는 정렬 파트 가장 큰 수 문제가 그 예시
def solution(numbers):
return "".join(
map(
str,
sorted(
map(
lambda n: Digit(n),
numbers
),
reverse=True
)
)
).lstrip("0") or "0"
class Digit:
def __init__(self, number: int):
self.digit = str(number)
def __lt__(self, another: "Digit") -> bool:
return self.digit + another.digit < another.digit + self.digit
def __gt__(self, another: "Digit") -> bool:
return self.digit + another.digit > another.digit + self.digit
def __str__(self) -> str:
return self.digit
두 전략의 가장 큰 차이는 전체 작업의 빌딩 블록이 되는 원소 작업의 시간 복잡도가 다르다는 데 있다.
그리고 임의의 데이터셋을 힙으로 만드는 전체 작업은,
여기서 결정적으로 힙은 complete binary tree이기 때문에 leaf가 많다.
(👉 or : 대략 전체의 1/2 )
그러니 당연히 Sift Down이 유리한 것.
(👉 먼저 트리 구성 이후 Sift Down 할 때 leaf 노드에는 적용하지 않는다.)
(👉 원소 작업이 일어나는 전체 횟수가 훨씬 적다(절반 이하).)
*이미 한 번 힙이 된 다음에 순차적으로 값이 추가(삽입)되는 경우에는 두 방법 다 같다.
(👉 leaf로 넣든 root로 넣든 어차피 끝에서 끝까지의 거리는 같으니까)
개발하는 모든 프로그램은 어찌 됐든 프로그래밍 언어를 통해 작성되고 실행된다.
그래서 프로그램의 이론적인 시간 복잡도 외에도
언어 레벨의 구현에 따른 다양한 기본 작업들의 실제 수행 시간이 언제나 변수가 되어
전체 프로그램의 실제 수행 시간을 결정한다.
from itertools import combinations
from math import sqrt
def is_prime(n):
for i in range(2, int(sqrt(n)) + 1):
if n % i == 0:
return False
return True
def solution(nums):
return len(list(filter(lambda comb: is_prime(sum(comb)), combinations(nums, 3))))
예시로 위와 같은 프로그래머스의 소수 만들기 문제 풀이의 경우,
filter
제너레이터의 길이를 구하는 문제로 귀결하는데
파이썬 빌트인 len()
이 CPython 구현이기 때문에
제너레이터를 순회하면서 변수에 +1
씩 해서 길이를 구하는 것보다,
후에 len()
을 사용하기 위해 한 번 순회하면서 리스트 안에 먼저 담고 전체 길이를 한 번에 구하는 방식이 속도 면에선 장점이 있다.
즉,
+1
len()
의 두 가지 선택지가 이론적인 시간 복잡도 측면에선 같지만,
두 번째 옵션이 수행할 기본 작업의 선택 측면에서 현실의 수행 시간에는 이점이 있다는 것.
하지만 제너레이터를 리스트로 변환하는 방식은 대신 공간 복잡도에 부하를 주는 방식이고 일반적으로 추천되는 방식은 아니다.
그리고len()
이 가져다 주는 속도적 이점도 크리티컬한 수준은 아님.
list comprehension
이 for
문에 비해 속도적 이점을 갖는 것도 비슷한 맥락이겠고
어찌 됐든 이런 점을 숙지하고 사용하는 언어의 내부 구현을 많이 파악해 놓는다면
프로그래밍하는 데 있어 제어할 수 없는 요소를 최대한 배제할 수 있겠다.
Ref: https://frhyme.github.io/python/python_length_of_generator/
프림 알고리즘과 다익스트라 알고리즘은 둘 다 본질적으론 BFS 알고리즘이라고 볼 수 있다.
다만,
이처럼 다른 상황에 다른 태스크를 수행하긴 하지만 결국 두 알고리즘은 다음과 같은 뼈대를 BFS와 공유한다.
한 노드씩 거치면서 인접 노드들을 순회한다
정리하자면 위와 같이
에서 서로 다르다.
그리고 한 노드씩 거칠 때 다음 노드를 선택하는 순서가 다른데,
두 알고리즘 모두 최적의 시간 복잡도를 달성하려면 최소 힙을 사용해 비용을 관리해야 한다는 조건이 있다.
하지만 의사 코드 수준에서 두 알고리즘의 비용을 관리하는 방식은 보통 다음과 같이 표현된다.
0
으로 변경 후 모든 노드 순회 시작그리고 위와 같이 모든 노드들에 대해 비용을 수정하면서 동시에 힙도 사용해야 한다고 생각하면 문제가 발생한다.
결국 루트가 아닌 중간 노드에 계속 접근해서 수정 작업을 해줘야 하는 상황으로 인해 힙의 장점은 사라지게 되고,
오히려 딕셔너리로 관리하면서 매번 최솟값을 구하는 방식보다 시간 복잡도에서 손해가 된다.
이제 여기서 문제의 발단을 살펴보면,
이미 알고 있는 노드의 비용을 힙 내부에서 수정해야 하는 상황인데,
비용 업데이트가 일어날 때,
하는 식으로 나눠서 접근하면 힙의 장점을 활용하는 비용 관리가 가능하다.
import heapq
from math import inf
def dijkstra(
start: int, graph: list[list[int, float]]
) -> tuple[dict[int, float], dict[int, int]]:
# init
visited = []
costs_book = {node: inf for node in range(len(graph))}
costs_book[start] = 0
costs_heap = [(0, start)]
back_tracks = {}
while costs_heap: # until all nodes are visited
# pop next node
curr_cost, curr_node = heapq.heappop(costs_heap) # greedy
if curr_node in visited: # only nodes not visited
continue
# update neighbors
visited.append(curr_node)
for nghbr_node, weight in filter(None, enumerate(graph[curr_node])):
if (
nghbr_node not in visited
and curr_cost + weight < costs_book[nghbr_node]
):
# cost update
updated_cost = curr_cost + weight
costs_book[start] = updated_cost
heapq.heappush(costs_heap, updated_cost)
# back track update
back_tracks[nghbr_node] = curr_node
return costs_book, back_tracks
프림과 다익스트라 알고리즘의 다음과 같은 세 가지 성질 때문에 위와 같은 구현이 가능하다.
costs_heap
) 안에 모든 노드가 최소 한 번 들어간다.최근 코딩 테스트를 보던 중 DFS를 사용한 풀이가 로직에는 전혀 문제가 없는데 테스트 상황에서 런타임 에러가 나는 상황을 겪었다.
문제의 조건에 맞게 최대 크기의 테스트 케이스를 만들어 돌려보니 그제야 RecursionError
가 발생하는 것을 확인할 수 있었다.
파이썬은 과도한 재귀 호출로 인한 스택 오버플로우를 방지하기 위해 최대 재귀 깊이를 정해두고 이를 초과할 시 RecursionError
를 발생시킨다.
DFS는 재귀 호출을 이용하는 대표적인 알고리즘이고 그래서 큰 케이스를 만났을 때 최대 재귀 깊이를 넘는 호출이 일어났던 것이다.
이런 경우에는,
sys
모듈을 활용해 최대 재귀 깊이를 늘리거나
>>> import sys
>>> sys.setrecursionlimit(1e6)
재귀 호출을 이터레이션으로 대체한다.
하지만 성능과 안정성에 따른 이유로 과도한 재귀 호출은 최대한 피하는 게 좋고 따라서 두 번째 방법으로 문제를 해결할 수 있으면 베스트.
그리고 DFS의 경우,
특수한 용법이 아니라 단순히 모든 노드 방문이 목적이라면
간단히 같은 역할을 하지만 재귀 호출이 없는 BFS로 대체하면 쉽게 해결이 가능하다.