기초 알고리즘부터 심화까지 이론 정리 및 문제 풀이를 업로드하는 저장소
숫자로 시작하는 문제는 Leet Code문제
문자로 시작하는 문제는 백준 문제이다. (G4_5052_전화번호목록은 Gold4 레벨의 5052번 문제라는 뜻)
참고자료
- 이것이 취업을 위한 코딩 테스트다 with 파이썬 - 나동빈
- 파이썬 알고리즘 인터뷰 - 박상길
- 알고리즘 문제 해결 전략 - 구종만
[TOC]
입출력
- 입력되는 데이터가 많은 경우
sys.stdin.readline()
이용input()
과 거의 동일하게 이용할 수 있음split()
을 이용하는 경우가 아니면 오른쪽에 줄 바꿈 기호가 남으니sys.stdin.readline().rstrip()
필요
파이썬 문자열 처리 관련 함수
- 대문자, 소문자
string.upper()
: 대문자로 변환string.lower()
: 소문자로 변환
- 문자 판독
char.isalnum()
: char가 영문자 혹은 숫자일 경우 True, 아니면 False 반환char.isdigit()
: 숫자면 True
- 문자열 바꾸기
string.replace(string1, string2)
: 문자열 내의 문자열1을 문자열2로 변환- 일부 글자를 바꾸거나 삭제할 수 있고, 공백을 없애는 데 편리함
- 변환하면 string이 바뀌는 것이 아니라 새로운 문자열을 리턴함
- 문자열 찾기
string.find(word)
: 찾는 문자열이 처음 나오는 위치 리턴, 없으면 -1 리턴string.count(word)
: 해당 문자열의 갯수를 리턴
- 문자열 슬라이싱
string[start:end]
: start에서 end-1번 인덱스에 해당하는 문자열을 추출string[::2]
(2개씩 건너뛰며 추출),string[::-1]
(뒤에서부터 출력)
- 문자열 분리, 결합
string.split(word)
: word를 기준으로 분리"word".join(string)
: string의 각 원소 사이에 word를 삽입
- 문자열 공백 제거
string.strip()
: 양쪽 공백 제거string.lstrip()
: 왼쪽 공백 제거string.rstrip()
: 오른쪽 공백 제거string.strip(letter)
: 양쪽에서 letter가 나오지 않을 때까지 제거string.lstrip(letter)
: 왼쪽에서 letter가 나오지 않을 때까지 제거string.rstrip(letter)
: 오른쪽에서 letter가 나오지 않을 때까지 제거string.strip(ab)
: 양쪽에서 a 또는 b가 나오지 않을 때까지 제거
- 아스키 코드 관련
chr(num)
: 아스키 코드를 입력받아 문자를 출력ord(letter)
: 문자를 입력받아 아스키 코드를 출력
리스트 관련 함수
list.reverse()
: 리스트를 뒤집어줌- 리턴 값 없이 리스트 내부에서 실행되고, 원소가 없을 경우 에러 발생
list[::-1]
과 같은 기능을 하는데, 이 쪽이 빠르다고 함
list.insert(index, x)
: list 리스트의 index에 x를 삽입함del list[index]
: list 리스트의 index번 째 원소를 삭제함- 리스트도 enumerate를 지원함
for i, n in enumerate(list)
하면 앞은 인덱스, 뒤는 값이 됨
딕셔너리 관련
-
defaultdict
- 존재하지 않는 키를 조회할 경우 해당 키에 대한 딕셔너리 아이템을 "기본값을 기준으로 " 생성함
-
일반 딕셔너리는 에러메세지를 반환
-
기본값은 int 기준 0임
-
counts = collections.defaultdict(int) for word in words: counts[word] += 1
-
- 존재하지 않는 키를 조회할 경우 해당 키에 대한 딕셔너리 아이템을 "기본값을 기준으로 " 생성함
-
Counter
-
아이템의 갯수를 계산해 딕셔너리로 리턴함
-
a = [1, 2, 3, 4, 5, 5, 5, 6, 6] b = collections.Counter(a) # b는 {5: 3, 6: 2, 1: 1, 2: 1, 3: 1, 4: 1}
-
b.most_common(2)
를 하면 b에서 가장 빈도가 높은 2개의 요소 (5, 3), (6, 2)를 담은 리스트를 반환함
-
-
get()
- key를 이용하여 value를 얻음
- key가 존재하지 않는 경우 에러메시지를 반환하는게 아니라 None을 반환한다는 특징이 있음
-
values()
- 딕셔너리의 값들을 묶어서 반환
- list() 안에 넣어주면 리스트 형태로 받을 수 있음
파이썬 기본 함수
-
max()
- key에 들어가는 함수를 활용하여 해당 함수를 처리한 결과 중 최댓값을 구할 수 있음
- 예)
max([1, 0, -2, 3, -5], key=abs)
: 절댓값 기준으로 찾기 때문에 -5가 나옴 - 예2)
max(counts, key=counts.get)
: 딕셔너리 counts 중 value가 가장 큰 것의 키를 구할 수 있음
-
zip()
-
2개 이상의 시퀀스를 짧은 길이를 기준으로 1대1 대응하는 튜플 시퀀스를 만들어줌
-
예시
-
a = [1, 2, 3, 4, 5] b = [2, 3, 4, 5] c = [3, 4, 5] # [(1, 2), (2, 3), (3, 4), (4, 5)] list(zip(a, b)) # [(1, 2, 3), (2, 3, 4), (3, 4, 5)] list(zip(a, b, c))
-
-
-
sort()
- key 인자에 함수를 넘겨주면 해당 함수의 반환값을 비교하여 순서대로 정렬함
- 예시1) 두개의 원소를 갖는 튜플들을 2번째 원소 기준으로 정렬하기
list.sort(key = lambda x : x[1])
- 예시2) 2번째 이후의 원소들만 정렬하되 순서가 같으면 1번째 원소 기준으로 정렬하기
letters.sort(key = lambda x : (x.split()[1:], x.split()[0]))
- 위와 같이 정렬의 우선순위가 필요하면 튜플로 지정할 수 있음
-
Asterisk
- Unpack을 해 주는 연산자
- 예시
print(*fruits)
를 하여 리스트 fruits에 있는 원소들을 구분하여 한 번에 출력list(zip(*collections.Counter(nums).most_common(k)))
: 튜플이 담긴 리스트를 반환하는 most_common 함수의 결과값을 zip으로 묶으면 튜플 내부의 값끼리 묶는 것이 아니라 튜플이 통으로 나오기 때문에 원하는 결과를 얻을 수 없어 unpack을 해준 후에 묶음
그리디 알고리즘이란?
- 각 단계에서 로컬 최적의 선택을 하여 글로벌 최적을 찾는 것을 목표로 하는 해결법
- 최적해를 찾지 못하더라도 어느 정도 괜찮은 해를 찾을 수 있음
그리디 알고리즘으로 풀 수 있는 문제
- 탐욕 선택 속성을 가짐
- 앞의 선택이 이후 선택에 영향을 주지 않는 것
- 최적 부분 구조
- 문제의 최적 해결 방법이 부분 문제에 대한 최적 해결 방법으로 구성되는 경우
- DP로 해결할 수 있는 문제도 최적 부분 구조를 가짐
DP와의 비교
- DP는 하위 문제에 대한 최적해를 찾아 이를 결합해 Globally Optimum Solution을 찾음
- 그리디는 각 단계마다 로컬 최적해를 찾아 문제를 작게 줄여나감
배낭에 담을 수 있는 무게의 최댓값이 정해져 있고, 짐의 가치와 무게를 알고 있을 때 배낭에 담긴 짐의 가치를 최대로 하려면?
배낭 문제는 짐을 쪼갤 수 있는 경우 그리디로, 쪼갤 수 없는 경우 DP로 해결한다
- 쪼갤 수 있는 경우
- 단위 무게 당 가치가 가장 높은 짐부터 배낭에 넣음
동전을 최소로 사용해서 특정 액수를 만드는 방법
보초법
정렬된 배열에서 두 개의 포인터를 이동시키며 원하는 값을 찾음
고정 사이즈 윈도우를 이동시키며 탐색
정렬되지 않은 배열에서도 사용 가능
깊이 우선 탐색
DFS란?
- 스택을 이용하여 그래프의 모든 노드를 탐색
- 자신과 연결된 노드 중 하나를 방문함
- 방문할 노드가 없으면(이미 방문한 노드들이면), 자신을 호출한 노드로 돌아감
- 처음 출발한 위치에서 탐색 종료
- 방문은 재귀 함수를 이용하거나 스택에 노드를 삽입-삭제하는 것을 반복함으로써 구현
백트래킹을 통해 뛰어난 효용을 보임
재귀로 구현한 DFS
def recursive_dfs(v, visited):
visited.append(v)
for w in graph[v]:
if w not in visited:
recursive_dfs(w, visited)
return visited
반복으로 구현한 DFS
def iterative_dfs(v):
visited = []
stack = [v]
while stack:
t = stack.pop()
if t not in visited:
visited.append(t)
for w in graph[t]:
stack.append(w)
return visited
해결책에 대한 후보를 쌓아가다 가능성이 없다고 판단되는 즉시 후보를 포기(backtrack)
트리를 탐색하다가 불필요한 부분을 버리는 것을 가지치기(pruning)라 함
너비 우선 탐색
BFS란?
- 큐를 이용하여 출발점으로부터 가까운 순서대로 탐색
- 자신과 연결된 노드들을 큐에 삽입함
- 큐에서 추출한 노드를 아직 방문하지 않았다면 방문함
- 더 이상 방문할 노드가 없으면(큐가 비면) 탐색 종료
최단 경로를 구하는 문제에 주로 사용됨
큐를 이용하여 구현
def iterative_bfs(v):
visited = [v]
queue = [v]
while queue:
# 파이썬으로 큐는 잘 사용하지 않으니 덱을 사용한다. 여기서는 큐 이용
t = queue.pop(0)
for w in graph[t]:
if w not in visited:
visited.append(w)
queue.append(w)
return visited
정렬된 데이터에서 탐색 범위를 절반씩 좁혀가며 탐색
O(logN)
재귀를 이용하여 구현
def binary_search(arr, target):
def search(left, right):
if left > right:
return -1
mid = (left+right)//2
if target < arr[mid]:
return search(left. mid-1)
elif target > arr[mid]:
return search(mid+1, right)
else:
return mid
return search(0, len(arr)-1)
반복을 이용하여 구현
def binary_search(arr, target):
n = len(arr)
l, r = 0, n-1
while l <= r:
mid = (l + r) // 2
if target < arr[mid]:
r = mid-1
elif target > arr[mid]:
l = mid+1
else:
return mid
return -1
파이썬에서는 그냥 모듈을 쓰자
보간 탐색이란?
-
탐색 대상의 시작과 끝 값을 이용하여 비례식을 구성해 탐색 위치를 결정하는 방식
- 이진 탐색은 무조건 중앙에 위치한 데이터를 탐색 : 끝 쪽에 치우친 데이터 탐색에 비효율 발생
-
보간 탐색할 인덱스의 계산
-
target값-시작 값 차이 vs 시작 값-끝 값 차이의 비율이 전체 길이에서 target 인덱스 길이가 차지하는 비율
-
$$ s = {target - arr[low] \over arr[high] - arr[low]}(high - low) + low $$
-
보간 탐색의 구현
- 이진 탐색과 똑같이 구현하되 탐색할 인덱스
mid
를 위 수식을 이용하여 설정하면 됨 - 탈출 조건의 설정
- 이진 탐색처럼
if(left > right)
를 이용해서는 안 됨- mid값이 left~right범위를 벗어남에 따라 무한 루프에 빠질 수 있음
if(arr[left] > target || arr[right] < target)
로 설정해야 함- 조금 더 넓은 조건을 설정
- "탐색 대상이 존재하지 않을 경우 탐색 대상의 값은 탐색 범위를 벗어난다"
- 이진 탐색처럼
실무에서 쓸 일은 없지만...
문제 해결 기본 아이디어랄 것도 없다. 하나씩 비교해서 크면 뒤로 넘긴다.
해결 방법
for i in range(n): # 반복 횟수
for j in range(n-i-1): # i번째 반복일 때 뒤에서 i번 인덱스까지는 정렬되어 있음
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
시간 복잡도
- 최선의 경우에도(이미 정렬된 경우) n*(n-1)/2번 연산해야 함
- 최악의 경우(역순으로 정렬) 맨 아래 swap연산을 모두 해야 하지만 최선의 경우의 상수배이기 때문에 O(n^2)
문제 해결 기본 아이디어
- 배열 내 가장 작은 원소를 찾고
- 해당 원소를 제일 앞에 있는 원소와 swap한 후 다시 반복
제일 큰 원소를 맨 뒤로 이동시키는 bubble sort와 정확히 반대라고 할 수 있음
해결 방법
for i in range(len(arr)-1):
# 배열 내에서 가장 작은 원소를 찾아서 인덱스 i에 저장
minId = i
for j in range(i+1, len(arr)):
if arr[j] < arr[minId]:
minId = j
# 찾았으면 (for문이 끝났으면) 정렬되지 않은 수 중에 제일 앞의 수와 swap
arr[minId], arr[i] = arr[i], arr[minId]
이미 정렬된 배열에 대해서는 O(N)에 끝나는 정렬
문제 해결 기본 아이디어
정렬되어 있는 부분과 그렇지 않은 부분으로 나누어 정렬 안 된 부분의 데이터를 정렬된 부분에 삽입하는 것
- 배열의 두 번째 원소부터 다음을 반복
- 자신의 앞에 있는 원소들과 하나씩 비교하여, 자신이 작으면 앞으로 이동하고 그렇지 않으면 멈춤
- 제일 작은 수는 맨 앞으로 이동하는 식으로 오름차순 정렬됨
해결 방법
# 첫 번째 원소는 놔두고 두 번째 원소부터 시작
for i in range(1, len(arr)):
# 현재 보고 있는 원소 arr[i]를 앞에 있는 수들과 비교
for j in range(i, 0, -1):
# 앞의 수가 더 작다면 swap하여 앞으로 이동, 아니면 stop
if arr[j] < arr[j-1]:
arr[j], arr[j-1] = arr[j-1], arr[j]
else:
break
시간 복잡도
- 최선의 경우(이미 정렬됨) 안쪽의 for문에서 항상 break가 호출되기 때문에 O(n)으로 끝남
- 최악의 경우 다른 정렬들과 마찬가지로 O(n^2)를 소모함
가장 많이 사용되는 정렬 알고리즘
평균적으로 O(NlogN)이지만 최악의 경우 O(N^2)
문제 해결 기본 아이디어
- 기준 데이터를 설정하고 기준보다 큰 데이터와 작은 데이터의 위치를 바꿈
- 기준을 정하는 방식에 따라 Hoare Partition, Lumoto Partition이 존재
- Hoare는 맨 앞을, Lumoto는 맨 뒤를 pivot으로 설정
- 피벗이 제일 작은 값 or 제일 큰 값으로 설정되면 성능이 떨어지기 때문에 중간으로 설정해줘야 함
- 난수를 이용해서 설정하거나(불안정) 왼쪽, 오른쪽, 가운데 중 중앙값을 이용해서 설정 가능(안정)
- Hoare는 다음과 같이 정렬함
i=1, j=len(n)-1
로 설정하여 각각 오른쪽과 왼쪽으로 이동시키면서 pivot과 비교- pivot보다 작은 데이터를 왼쪽에, 큰 데이터를 오른쪽에 위치시킴
- i와 j가 교차되면 pivot과 j를 swap
- 다음 pivot에 대해서도 반복
해결 방법
def quick_sort(arr, start, end):
if start >= end:
return
pivot = start
left = start + 1
right = end
while left <= right:
# 피벗보다 큰 데이터를 찾을때까지
while left <= end and arr[left] <= arr[pivot]: left += 1
# 피벗보다 작은 데이터를 찾을때까지
while right > start and arr[right] > arr[pivot]: right -= 1
# 엇갈렸다면 작은 데이터와 피벗을 교체
# left 왼쪽으로는 전부 피벗보다 작음이 보장되지만 left는 피벗보다 큰 값을 가리키고 있으므로
if left > right:
arr[pivot], arr[right] = arr[right], arr[pivot]
# 엇갈리지 않았다면 작은 데이터와 큰 데이터를 교체
else:
arr[left], arr[right] = arr[right], arr[left]
quick_sort(arr, start, right-1)
quick_sort(arr, right+1, end)
Stable sort로 최선과 최악 모두 O(nlogn)의 성능을 보임
분할 정복의 진수를 보여주는 알고리즘
문제 해결 기본 아이디어
- 배열을 가장 작은 단위(1개씩)까지 쪼갠다
- 작은 배열은 정렬한 후 2개씩 병합하면서 다시 정렬된 배열을 구성한다
- 원래 배열의 크기가 될 때까지 반복하여 원래 배열을 정렬된 상태로 만든다
해결 방법
def merge_sort(list):
if len(list) <= 1:
return list
mid = len(list) // 2
leftList = list[:mid]
rightList = list[mid:]
# 왼쪽과 오른쪽을 각각 정렬한 후 병합
leftList = merge_sort(leftList)
rightList = merge_sort(rightList)
return merge(leftList, rightList)
def merge(left, right):
result = []
while len(left) > 0 or len(right) > 0:
if len(left) > 0 and len(right) > 0:
if left[0] <= right[0]:
result.append(left[0])
left = left[1:]
else:
result.append(right[0])
right = right[1:]
elif len(left) > 0:
result.append(left[0])
left = left[1:]
elif len(right) > 0:
result.append(right[0])
right = right[1:]
return result
상황에 따라 O(NlogN)보다 빨라질 수 있으므로 그 상황을 기억해뒀다 활용하자
O(N+K)이므로 데이터 중 최대값이 너무 크지만 않으면 대개의 경우 아주 빠름
문제 해결 기본 아이디어
데이터의 모든 범위를 담을 수 있는 배열을 만들고 거기에 데이터를 한 번 씩만 채움
배열을 선언 → 데이터를 순회하면서 해당 숫자와 같은 인덱스의 배열의 값을 1씩 추가한다 → 끝
해결 방법
# 데이터의 최대 범위만큼의 길이를 갖는 배열 선언
count = [0]*n
for i in range(len(arr)):
count[arr[i]] += 1
for i in range(len(count)):
for j in range(count[i]):
print(i, end=' ')
비교연산 없이 정렬하는 알고리즘
기수 정렬이란?
- 자릿수가 있는 데이터에 대해
- 자릿수가 적을 때 효율이 좋음
- 시간 복잡도 : O(kn), k는 데이터의 자릿수
- 문자열 정렬 등에 사용할 경우 연산이 많아지기 때문에 비효율적
- 종류
- Least Significant Digit : 낮은 자릿수부터 정렬
- Most Significant Digit : 높은 자릿수부터 정렬
문제 해결 기본 아이디어(LSD 기준)
- Radix(기수)의 갯수에 해당하는 길이의 배열을 만든다
- 10진법이면 길이 10
- 각 데이터를 순서대로 현재 자릿수의 숫자를 인덱스로 하여 배열에 저장
- 배열의 원소는 Queue여야 함 : 14, 34면 인덱스 4에 두 숫자가 순서대로 저장되어야 하기 때문
- 다음 자릿수로 이동하여 배열에서 데이터를 꺼낸 후 2를 반복
해결 방법
buckets = [[] for _ in range(m)] # m진법
div = 1
for i in range(k): # 가장 큰 수의 길이가 k일 때
for j in range(n): # 정렬할 수의 갯수
radix = (arr[j]//div) % m # j번째 자리의 숫자를 추출
buckets[radix].append(arr[j]) # 추출한 숫자를 근거로 버킷에 데이터 저장
j = 0
for k in range(m):
while buckets[k]: # 버킷에 담긴 데이터를 다시 arr로 옮김
arr[j] = buckets[k].pop()
j += 1
div *= m # 다음 자리로 이동하기 위해 나누는 수 증가
문제를 각각의 작은 문제로 나누어 해결한 결과를 저장해뒀다가 나중에 큰 문제의 결과와 합하여 풀이
최적 부분 구조란
- 전체 문제의 최적해가 부분 문제의 최적해의 합으로 구성되는 경우
- 다이나믹 프로그래밍으로는 최적 부분 구조를 가지며 중복된 하위 문제들로 풀 수 있는 문제를 푼다
- 분할 정복으로 푸는 문제는 같은 문제가 반복되지는 않음
- 같은 문제가 반복되는 대표적인 예로는 피보나치 수열이 있음
다이나믹 프로그래밍 방법론
-
하향식: 메모이제이션
-
하위 문제에 대한 정답을 계산했는지 확인해가며 문제를 해결
-
def fib(n): if n <= 1: return n if dp[n]: # 하위 문제 정답을 계산했는지 확인 return dp[n] dp[n] = fib(n-1) + fib(n-2) return dp[n]
-
-
상향식: 타뷸레이션
-
하위 문제를 먼저 해결하고 큰 문제의 정답을 구함
-
데이터를 테이블 형태로 만든다는 뜻 Tabulate
-
def fib(n): dp[0] = 0 dp[1] = 1 for i in range(2, n+1): # 하위 문제를 차례대로 정답을 풀어 나감 dp[i] = dp[i-1] + dp[i-2] return dp[n]
-
수열 nums 내 가장 긴 증가하는 부분 수열의 길이는 얼마인가?
문제 해결 기본 아이디어
- 수열을 순차적으로 돌면서
- 현재 보고 있는 nums[i]보다 작은 동시에 LIS의 마지막 원소인 수를 찾음
- 순차적으로 찾으면 O(N^2), 이진 탐색하면 O(NlogN)
- 찾았다: 현재 보고 있는 수가 LIS의 마지막 원소가 됨, LIS의 길이 += 1
- 못 찾았다
- O(N^2): 업데이트 필요하지 않으므로 무시
- O(NlogN): 업데이트 필요(이진 탐색 위해서 오름차순 정렬 해줘야)
해결 방법
-
O(N^2)인 방법
-
# nums[i]를 마지막 원소로 하는 '증가하는 순열'의 길이를 담는 배열 LIS = [0]*n for i in range(n): # nums[i]보다 작은 수 중에, 증가하는 수열의 길이를 최대가 되게 하는 인덱스j를 찾음 temp = 0 for j in range(i): if nums[j] < nums[i] and LIS[j] > temp: temp = LIS[j] LIS[i] = temp + 1 # LIS배열 내에서 최댓값을 출력하면 종료 print(max(LIS))
-
-
O(NlogN)인 방법
-
nums 내에 존재하는 증가하는 부분수열들을 마지막 원소의 크기에 따라 오름차순으로 정렬함
- 먼저 부분수열의 길이 별로, 해당 길이의 수열 중 가장 작은 마지막 원소를 저장하는 배열을 작성
- 부분수열들의 마지막 원소 중 nums[i] 바로 다음으로 큰 수를 찾음
- 해당 원소를 nums[i]로 갱신해 줌: 해당 부분수열이 더 작은 수를 마지막 원소로 가져야 더 긴 증가하는 부분수열을 가질 수 있음
- 부분수열들의 마지막 원소의 크기가 오름차순으로 정렬됨
-
# 이진탐색을 하기 위한 배열 LIS # 첫 번째 인덱스는 부분순열의 길이, 두 번째 인덱스는 해당 부분순열의 마지막 원소 LIS = [[1, nums[0]]] for i in range(1, n): # nums[i]가 LIS에서 몇 번 째 위치로 갈 수 있는지 탐색 index = binary(LIS, 0, len(LIS)-1, nums[i]) if index > len(LIS)-1: LIS.append([LIS[-1][0]+1, nums[i]]) else: LIS[index] = [LIS[index][0], nums[i]] # LIS배열 내에서 최댓값을 출력하면 종료 print(LIS[-1][0])
-
두 수열의 부분수열들을 비교했을 때 공통의 부분수열이 되는 것 중에 가장 긴 것은?
문제 해결 기본 아이디어
해결 방법
앞뒤가 똑같은 전화번호
문제 해결 기본 아이디어
- 팰린드롬을 이루려면, 해당 수열의 선두와 후미는 같은 수여야 한다
- 선두와 후미를 제외한 내부의 수열이 팰린드롬을 이룬다면 해당 수열은 팰린드롬이다
해결 방법
-
s번째 수에서 e번째 수까지의 수열이 팰린드롬인지 알 수 있게 하는 N x N 배열을 작성
-
먼저 길이 1인 수열은 무조건 팰린드롬이므로, 반복문을 이용해
arr[i][i] = 1
입력 -
길이 2부터 n까지의 수열이 팰린드롬인지 판별하기 위해, 기본 아이디어를 이용
# 먼저 길이1 팰린드롬과 길이2 팰린드롬(연속한 두 수가 같다면)에 대해 dp에 True를 기록 for i in range(1, n+1): dp[i][i] = 1 if nums[i-1] == nums[i]: dp[i-1][i] = 1 # 길이 3부터 n까지의 팰린드롬을 기록 for i in range(3, n+1): for j in range(1, n-i+2): # 시작과 끝이 같고 그 내부가 팰린드롬을 이룬다면 팰린드롬 if nums[j] == nums[j+i-1] and dp[j+1][j+i-2]: dp[j][j+i-1] = 1
외판원 순회 문제
문제 해결 기본 아이디어
- 먼저 "순회"이므로 어느 지점에서 출발해도 결과는 같음: 0번 도시에서 출발한다 가정
- 지금까지 방문한 도시의 목록으로 배열이 아니라 비트마스킹을 이용
- 지금까지 방문한 목록으로부터, 앞으로 모든 도시를 순회하는 데 필요한 비용 중 최솟값을 취해 나가면서 계산을 진행(동적 계획법)
해결 방법
-
n번 도시를 방문하고 있을 때 지금까지 방문한 모든 경우에 대해 앞으로 필요한 최소한의 비용을 담을 배열 DP를
dp = [[ING]*(1<<n) for _ in range(n)]
과 같이 만든다 -
나머지는 재귀적으로 구현하되 dp의 값이 None이 아니라면(이미 계산을 수행했다면) 리턴하여 중복 제거
# 현재 위치, 방문한 기록 def travel(start, visit): # 모든 도시를 다 순회했다면 if visit == check: # 마지막 도시에서 출발 도시로 돌아가는 길이 있다면 값을, 아니면 INF 리턴 return cost[start][0] or INF # 이미 해당 출발점에 대해 계산이 이루어졌다면 바로 리턴: 중복 제거 if dp[start][visit] is not None: return dp[start][visit] temp = INF for i in range(n): # i로 가는 길이 존재하고, 아직 방문하지 않았다면 if cost[start][i] and visit & (1<<i) == 0: temp = min(temp, travel(i, visit|(1<<i))+cost[start][i]) # 현재 위치와 현재까지 방문한 목록에 대응되는 최솟값을 갱신 dp[start][visit] = temp return temp
어떤 배열의 최대 부분합을 어떻게 구할 것인가?
문제 해결 기본 아이디어
- 배열 arr에 대해 i번 인덱스까지의 최대 부분합을 dp[i]라고 하자
dp[i] = max(dp[i-1], dp[i-1]+arr[i])
해결 방법은 생략^^
음의 가중치가 없는 그래프의 한 정점에서 모든 정점까지의 최단거리를 구하는 알고리즘
문제 해결 기본 아이디어
- 출발점으로부터 각 노드 까지의 최단 거리를 담을 배열 D[V]를 선언
- 값은 INF로 설정
- 모든 노드를 방문할 때까지 다음을 반복함
- 현재 노드로부터 갈 수 있는 임의의 노드에 대해
D[A] + dist[A][B]
와D[B]
를 비교 - 둘 중 더 작은 값으로 D[B]를 업데이트
- 현재 노드의 모든 주변 노드에 대해 같은 작업을 실시
- 현재 노드의 상태를 방문 완료로 바꾸고, 미방문 노드중 D[V]값이 가장 작은 곳을 방문해서 반복함
- 현재 노드로부터 갈 수 있는 임의의 노드에 대해
해결 방법
-
O(V^2)
-
다익스트라가 원래 고안한 방법
-
visit[start] = 1 distance[start] = 0 for i in graph[start]: distance[i[0]] = i[1] # 모든 노드를 방문하며 거리를 업데이트 for i in range(v-1): # 미방문 노드 중 출발점과의 거리가 최단인 곳을 탐색: O(V^2) now = get_smallest_node() visit[now] = 1 for edge in graph[now]: cost = distance[now] + edge[1] # 다익스트라의 핵심: 출발점-now + now-주변노드 vs 출발점-주변노드 거리 비교 if cost < distance[edge[0]]:
-
-
O((V+E)logV)
-
우선순위 큐를 이용한 방법
-
E는 한 노드와 연결된 주변 노드의 수
-
각 노드마다 출발점으로부터 현재까지 최단거리 계산에 VlogV, 이웃노드의 최단거리 갱신에 ElogV 필요
-
# 각 노드의 최단거리를 "업데이트"하는 데 사용하는 우선순위큐 q = [] heapq.heappush(q, (0, start)) # 출발점으로부터의 각 노드의 최단거리를 저장하는 배열: 최종 결과 distance[start] = 0 while q: dist, now = heapq.heappop(q) # 우선순위 큐는 거리가 짧은 것부터 먼저 처리함 # 같은 노드에 대해 거리가 더 짧은 경로를 처리했다면 다음부터는 처리할 필요 없음 # dist가 갖고 있는 정보(큐에 저장된 거리)와 distance(처리된 거리)를 비교 if distance[now] < dist: continue # now의 주변 노드들을 탐색 for edge in graph[now]: cost = dist + edge[1] if cost < distance[edge[0]]: distance[edge[0]] = cost heapq.heappush(q, (cost, i[0]))
-
수학적 증명
귀류법으로 증명 가능 네이버 블로그
그래프 내 최단 경로가 모두 다익스트라 알고리즘에 의거하지는 않는다 는 가정을 세우고 이를 반박해보자.
-
임의의 꼭짓점 u에서부터 v까지 다익스트라 알고리즘에 의한 최단경로를 상정하고, 그 위에 노드 w가 있음
-
이 때 w와 u의 최단경로가 다익스트라 알고리즘에 따른 최단거리를 갖지 않는다고 가정하면
-
u-w와 w-v의 거리의 합이 u-v의 거리의 합보다 짧아져야 함
- u-v는 다익스트라 알고리즘에 의한 최단거리를 갖기 때문에, 이것은 u-w거리 + w-v거리와 같아야 함
-
그럼 u-v사이에 최단경로보다 더 짧은 경로가 존재해야 하므로 모순
- 따라서 u-w 사이에도 다익스트라 알고리즘에 따른 최단경로보다 더 짧은 경로는 존재하지 않음
- 이를 그래프 내 모든 노드로 확장할 수 있음
- 모든 노드는 다익스트라 알고리즘에 의해 최단경로를 구할 수 있음
그래프의 모든 노드 간의 최단 거리를 구하는 알고리즘
음의 가중치를 가지는 그래프에서도 사용 가능하다
문제 해결 기본 아이디어
- 기본적으로 다익스트라와 같은 원리: 출발점 - 노드 뿐 아니라 모든 노드에 대해서 확장
- i-j 거리와 i에서 임의의 노드 k + k-j 거리를 비교해 더 짧은 쪽으로 i-j 거리를 업데이트
- 이걸 모든 노드 k에 대해서 반복하여 i-j의 최단거리를 얻는다
- 같은 것을 모든 노드에 대해 실행한다
해결 방법
-
반복문 3개를 중첩하되, 가운데 노드 k가 제일 위에 와야 함
- 이건 위키백과에 잘 설명되어 있는데, 요약하자면 다음과 같음
- i에서 j로 가는데, 1~k번 사이의 노드만 경유할 수 있다고 할 때 최단거리를
path(i, j, k)
라고 하자 - 위 상황에서 i에서 j로 가는 모든 경로는 k를 경유하는가 or 경유하지 않는가 로 분리됨(DP)
- 따라서
path(i, j, k)
는min(k를 경유하는 경로, k를 안 경유하는 경로)
가 됨- k를 안 경유하는 경로 중 최단거리는
path(i, j, k-1)
임 - k를 경유하는 경로 중 최단거리는
path(i, k, k-1) + path(k, j, k-1)
임
- k를 안 경유하는 경로 중 최단거리는
- 그러므로 k=1에서 모든 i, j에 대해 최단거리를 구하고, k=N이 될 때까지 반복하면 모든 쌍의 최단경로를 찾을 수 있음
-
graph = [[INF]*(V+1) for _ in range(V+1)] # 자기 자신으로 가는 거리는 0으로 초기화 for a in range(1, V+1): graph[a][a] = 0 # 노드 a에서 b로 가는 거리 c를 입력받아 그래프 작성 for _ in range(E): a, b, c = map(int, input().split()) graph[a][b] = c # 점화식에 따라 각 노드 k에 대해 a에서 b로 가는 최단거리 갱신 # a-b로 직접가는 것보다 a-k-b로 가는 거리가 짧으면 갱신 for k in range(1, V+1): for a in range(1, V+1): for b in range(1, V+1): graph[a][b] = min(graph[a][b], graph[a][k]+graph[k][b])
공통 원소가 없는 두 집합
두 트리가 서로소 집합인지 확인하려면 UNION연산을 통해 root가 다른지 확인해본다
parent = [0] * V
# 먼저 모든 노드에 대해 자기 자신을 부모로 갖도록 설정함
for i in range(V):
parent[x] = x
# 재귀적으로 자신의 root를 찾는 함수
def find_parent(x):
if parent[x] != x:
# 부모 노드를 root로 갱신하여 root에 빠르게 접근할 수 있도록 함(경로 압축)
parent[x] = find_parent(parent[x])
return parent[x]
# 노드 x와 노드 y의 root를 같도록 함: UNION
# 여기선 번호가 더 작은 노드를 부모로 가게 합침
def union(x, y):
x = find_parent(x)
y = find_parent(y)
if x < y:
parent[y] = x
else:
parent[x] = y
root별로 구분하여 서로소 집합을 나눌 수 있음
사이클 판별하기
- 각 간선을 확인하며 간선에 연결된 두 노드의 root를 확인
- root가 다르다면 union을 수행
- root가 같다면 사이클이 존재하는 것
- 모든 간선에 대해 1을 반복
신장 트리란?
- 하나의 그래프가 있을 때, 모든 노드를 포함하면서 사이클이 존재하지 않는 부분 그래프
- 스패닝(신장) 트리는 DFS/BFS를 이용해 선형 시간에서 찾을 수 있음
- 사이클이란?
- 단순 경로(simple path)이면서 처음과 끝이 같은 경로
- 동일한 간선을 중복하여 포함하지 않는 경로
- Minimum Spanning Tree(최소 신장 트리)란?
- 신장 트리의 모든 간선의 가중치 합이 최소인 그래프
- 간선의 수 + 1 = 정점의 수 를 만족함
- MST를 만들 수 있는 알고리즘
- Boruvka's algorithm O(m log n)
- Prim's algorithm O(m log n) or O(m + n log n)
- Kruskal's algorithm O(m log n)
문제 해결 기본 아이디어
- 하나의 정점을 시작으로 MST가 될 때까지 트리를 확장해 나감
문제 해결 기본 아이디어
- 모든 간선에 대해 비용의 오름차순으로 정렬
- 간선을 비용순서대로 추가하는데, 사이클을 발생시킨다면 추가하지 않음
- 비용이 적은 순서대로 사이클 없이 추가하기 때문에 말 그대로 최소 비용 신장 트리가 됨
- 간선을 내림차순으로 정렬하면 간선을 추가하는 방식이 아니라 빼는 방식이 됨
해결 방법
-
사이클을 발생시키는지 UNION을 이용하여 판별하면서 구현
- 집합 자료구조를 이용해서 판별하면 훨씬 빠르게 동작함
-
cnt = 0 for i in range(E): px = find_root(edge[i][0]) py = find_root(edge[i][1]) # cycle check if px != py: cnt += 1 union(px, py) # MST의 간선의 수는 노드의 개수 -1 이다 if cnt == V-1: break
방향 그래프의 모든 노드를 방향성에 거스르지 않도록 순서대로 나열하는 것
선수과목을 고려한 학습 순서 설정을 예로 들 수 있음
문제 해결 기본 아이디어
- 진입차수가 0인 노드를 큐에 넣는다
- 진입차수: 특정한 노드로 들어오는 간선의 개수
- 진입차수가 0인 과목은 선수과목이 없는 1학년 과목이라 할 수 있음
- 큐가 빌 때까지 다음을 반복한다
- 큐에서 원소를 꺼내 해당 노드에서 출발하는 간선을 제거한다
- 새롭게 진입차수가 0이 된 노드를 큐에 넣는다
- 주의) 사이클이 존재한다면 모든 노드를 방문하기 전에 큐가 빔
- 사이클 내 노드들은 진입차수가 0이 되지 않기 때문
해결 방법
모든 노드와 간선들을 한 번씩 확인하므로 시간 복잡도는 O(V+E)
# 진입차수: 해당 노드로 들어오는 간선의 갯수
indegree = [0]*(v+1)
graph = [[] for _ in range(v+1)]
for _ in range(e):
a, b = map(int, input().split())
graph[a].append(b)
indegree[b] += 1
q = deque()
for i in range(1, v+1):
# 진입차수가 0인 노드를 큐에 넣는다
if indegree[i] == 0:
q.append(i)
while q:
# 현재 방문중인 노드 now
now = q.popleft()
for node in graph[now]:
# now에서 출발하는 간선을 제거: 도착 노드들의 진입차수 -=1
indegree[node] -= 1
if indegree[node] == 0:
q.append(now)
파이썬의 문자열은 기본적으로 불변인 자료구조임에 유념하자
기본적으로 슬라이싱이 가장 빠르기 때문에 다른 연산보다 이를 우선으로 할 것
어떤 문자열 안에 다른 문자열이 들어있는지를 O(n+m)으로 찾아내는 알고리즘
반복되는 부분을 건너뛰면서 찾는다
문제 해결 기본 아이디어
- 순차적으로 두 문자열 T, P를 비교하다 불일치가 발생하면 "일치했던 부분을 건너뛰고" 다시 비교
- 단, 일치했던 부분을 전부 건너뛰지는 않음
- 일치했던 부분 내에 반복되는 부분이 존재하면 거기서부터 시작함
- 반복되는 부분을 찾기 위해 비교할 문자열인 P의 각 위치마다 실패 함수 값을 설정
- 실패 함수란?
- 불일치가 발생했을 때 얼마만큼 건너뛸지 나타내는 값
- P의 부분 문자열에 대해 해당 문자열 내에서 prefix와 suffix가 일치하는 최대 길이
- 이걸 LPS(Longest proper Prefix which is Suffix)라고 함
해결 방법
-
실패함수 fail[] 이 있다고 했을 때, KMP는 다음과 같이 구현 가능
-
# T와 P를 비교 j = 0 # T와 P가 일치하는 지점의 인덱스를 저장 result = [] for i in range(n): # 불일치가 발생하면 fail값을 "따라감" while j > 0 and T[i] != P[j]: j = fail[j-1] if T[i] == P[j]: # 문자열 매칭 성공 if j == m-1: result.append(i-m+2) # 계속 탐색: fail값을 따라감 j = fail[j] else: j += 1
- 실패 함수 값을 따라간다는 것은 불일치가 발생했을 때 P를 얼마나 "밀어주는가"를 뜻함
- 실패함수 값이 2라면 맨 앞 2글자==맨 뒤 2글자이므로 P의 3번째 글자부터 비교를 시작
j=2
가 됨: 여기서 2는 불일치가 발생한 지점 직전의 실패함수 값이므로j=fail[j-1]
이 된다
- if가 아니라 while문인 이유는 실패함수의 값이 클 때 "밀어준" 직후에 바로 불일치가 발생할 수 있기 때문
- 실패 함수 값을 따라간다는 것은 불일치가 발생했을 때 P를 얼마나 "밀어주는가"를 뜻함
-
실패함수는 P와 P를 비교하는 KMP로 구현 가능
-
fail = [0]*(m) j = 0 # i = 0일 때는 실패함수가 0임이 자명하므로 pass for i in range(1, m): while j > 0 and P[i] != P[j]: j = fail[j-1] # i번째 인덱스까지의 문자열에서 현재 j가 위치한 자리의 길이(j+1)가 실패함수의 값이 됨 if P[i] == P[j]: # j를 오른쪽으로 한 칸 이동 j += 1 fail[i] = j
해결 가능할 때까지 문제를 쪼개고, 해결된 문제들을 조합하여 원래 문제의 해답을 도출함
분할정복 문제는 재귀를 활용하여 해결함
def F(x):
# 정복
if F(x) is soluable:
return solution
# 분할
else:
x를 x1, x2로 분할
call F(x1)
call F(x2)
return F(x1), F(x2)로 F(x)를 구한 값