새소식

SW 개발

알고리즘은 재밌어 - 알고리즘

 

🍄 알고리즘

 

시간복잡도 및 요약

 

카테고리 이름 시간복잡도 설명
자료구조 유니온 파인드 O(α(N)) 서로소 집합 자료구조, 경로 압축 + union by rank
자료구조 우선순위 큐 (힙) 삽입/삭제: O(log N), 조회: O(1) 최대/최소값을 빠르게 관리하는 큐
자료구조 링크드 리스트 삽입/삭제: O(1), 탐색: O(n) 노드 포인터 기반 구조, 삽입·삭제 효율적
자료구조 Trie 삽입/탐색: O(L) 문자열 저장 트리, 접두사 탐색에 최적
완전탐색 DFS O(V + E) 깊이 우선 탐색, 스택/재귀 기반
완전탐색 BFS O(V + E) 너비 우선 탐색, 큐 기반
백트래킹 백트래킹 O(조건에 따라 다양) 상태 공간 트리 탐색, 가지치기 통해 효율화
탐색 이진 탐색 O(log N) 정렬된 배열에서 중간 기준 이분 탐색
최단경로 다익스트라 O((V + E) log V) 음수 간선 불가, 우선순위 큐 사용
최단경로 플로이드-워셜 O(V³) 모든 정점 쌍 간의 최단경로 계산
DP 동적 계획법 문제마다 다름 부분 문제를 저장하며 중복 계산 방지
MST 크루스칼 O(E log E) 간선 중심, 정렬 후 유니온파인드 사용
MST 프림 O(E log V) 정점 중심, 우선순위 큐 사용
위상정렬 위상정렬 O(V + E) DAG에서 순서 결정, 진입 차수 사용
그래프 LCA O(log N) 트리에서 두 노드의 최소 공통 조상 찾기
그래프 펜윅 트리 (BIT) 업데이트/쿼리: O(log N) 누적합, 구간합 처리에 효율적
그래프 오일러 경로 O(V + E) 모든 간선을 정확히 한 번 방문
기타 투 포인터 O(N) ~ O(N²) 두 포인터를 움직이며 조건 만족 탐색
기타 구간합 (Prefix Sum) 쿼리: O(1), 사전 계산: O(N) 구간합 빠르게 계산, 누적합 배열 사용
기타 세그먼트 트리 쿼리/업데이트: O(log N) 동적 구간합 처리
기타 비트마스킹 연산: O(1), 조합: O(2ⁿ) 상태/집합 표현 및 조합에 활용

 

 

 


 

자료구조

서로소 집합(Disjoint Sets)

  • 공통 원소가 없는 두 집합
  • {1,2}와 {3,4}는 서로소지만 {2,3}은 아님

 

1. UnionFind

 

  • Disjoint Set(서로소) 서로 중복되지 않는 부분 집합, 즉 서로소 집합 자료 구조를 표현할 때 사용된다.
    • 서로소 집합 자료구조는 합치기 찾기(Union Find) 자료구조라고 불리기도 한다.
  • 크루스칼에서 cycle check시도 사용
  • 합집합(Union): 두 개의 원소가 포함된 집합을 하나의 집합으로 합치는 연산
  • 찾기(Find): 특정한 원소가 속한 집합이 어떤 집합인지 알려주는 연산
  • 동작과정
    1. 합집합(Union) 연산을 확인하여 서로 연결된 두 노드 A, B를 확인한다.
      1. A와 B의 루트노드 A'과 B'을 찾는다
      2. 모든 합집합(Union) 연산을 처리할 때까지 1을 반복한다.
  • 서로소 집합 자료구조에서는 연결성을 통해 손쉽게 집합의 형태를 확인할 수 있다.
    • 루트 노드의 갯수가 집합의 갯수

 

# Find 루트 노드를 찾을 때까지 재귀 호출
def find_parent(parent, x):
    if parent[x] != x:
        return find_parent(parent, parent[x])
    return x

# Union 두 원소가 속한 집합 합치기
def union_parent(parent, a, b):
    a = find_parent(parent, a)
    b = find_parent(parent, b)
    # 별다른 조건이 없으면 더 큰 값을 부모로함
    if a < b:
        parent[b] = a
    else:
        parent[a] = b

# node 갯수, edge 갯수
v, e = map(int, input().split())
# 부모 테이블 노드 갯수만큼 자기 자신으로 초기화
parent = [i in range(v + 1)]

# Union
for i in range(e):
    a, b = map(int, input().split())
    union_parent(parent, a, b)

print('각 원소가 속한 집합: ', end='')
for i in range(1, v+1):
    print(find_parent(parent, i), end=' ')
print()

print('부모 테이블: ', end='')
for i in range(1, v+1):
    print(parent[i], end=' ')

 

 

문제점

  • 합집합 연산이 편향되게 이루어지는 경우 Find가 비효율적으로 동작한다. 부모 노드를 찾을 때 거슬러 올라가야 하기 때문
  • 최악의 경우 Find가 모든 노드를 확인해야 해서 O(V)

 

  • 경로 압축(Path Compression) 이용해 Find 최적화
    • Find를 재귀적으로 호출한 후 부모 테이블 값을 바로 갱신

 

def find_parent(parent, x):
    if parent[x] != x:
        parent[x] = find_parent(parent, parent[x])
    return parent[x]

 

 

서로소 집합을 이용한 사이클 판별

 

  • 무방향 그래프의 경우 사이클 판별할 때 사용할 수 있다.
    • 참고로 방향 그래프는 DFS를 통해 판별할 수 있음
  1. 각 간선을 하나씩 확인하며 두 노드의 루트 노드를 확인
    1. 루트 노드가 서로 다르다면 Union
    2. 루트 노드가 서로 같으면 Cycle 발생한 것
  2. 모든 간선에 대하여 1 반복

 

# find_parent(), union_parent()는 같고,

# Union
cycle = False
for i in range(e):
    a, b = map(int, input().split())
    if find_parent(parent, a) == find_parent(parent, b):
        cycle = True
        break
    else:
        union_parent(parent, a, b)

 

 

 

2. 우선순위 큐

 

  • 최단 경로 → 다익스트라 O(V^2) - 노드 5000개 이하일 때, 이상일 때 우선순위 큐 사용
  • 우선순위 큐는, 우선순위가 가장 높은 데이터를 먼저 삭제하는 자료구조,
    • 스택은 가장 나중에 삽입된걸 삭제하고 큐는 가장 먼제 삽입된 데이터를 삭제하는 자료구조
  • 리스트로 구현하면 삽입 O(1), 삭제 O(N)
  • 힙으로 구현하면 삽입 O(logN), 삭제 O(logN)
  • 단순히 N개의 데이터를 힙에 넣었다가 모두 꺼내는 작업은 정렬과 동일함 (heap정렬 O(logN))
  • 힙은 완전 이진 트리 자료구조의 일종

 

  • python은 기본적으로 minheap이고 maxheap구현하고 싶으면 - 붙이면됨

 

import heapq

def heapsort(iterable):
		h, result = [], []
		for value in iterable:
				heapq.heappush(h, value)
		for i in range(len(h)):
				result.append(heapq.heappop(h))
		return result

n = int(input())
arr = []

for i in range(n):
		arr.append(int(input()))

res = heapsort(arr)
for i in range(n):
		print(res[i])

 

 

 

3. 링크드리스트

 

  • 삽입 삭제가 잦을 때.
    • List: find O(N), insert O(N), Delete O(N)
    • Linkedlist : find O(N), insert O(1), Delete O(1)
  • 위로만, 아래로만 뺴는 경우 싱글, 위 아래 둘다 넣고 뺴야하는 경우 더블링크드리스트
  • 더블 링크드리스트 예제 : 코드트리 > 산타의선물공장

 

4. Trie

 

  • 집합(set)에서 특정 키를 찾는데 사용되는 m-ary 자료구조 중 하나이다.
    • m-ary는 최대로 가질 수 있는 자식 수를 말하고 m=2이면 이진트리이다.
  • 문자열 조회를 빠르게 하기 위해 사용되므로 자동완성 등에 많이 사용된다.
  • 꼭 문자열에만 사용되는 것은 아니고 비트와 같이 순서가 있는 열거 가능한 자료형에도 사용할 수 있다.
  • n개의 단어 사전에서 길이 m의 단어를 일일히 비교해가며 찾으면 이진 검색 트리를 사용해도 O(mlogn)이 필요하다.
  • 겹치는 문자열이 없는 경우 O(포인터크기 * 포인터갯수 * 총노드수)의 메모리가 필요하다
  • 비트를 통해 최적화 하는 방법이 있다

 

문제 백준 전화번호 목록

# 트라이 자료구조
import sys

input = sys.stdin.readline

class Node(object):
    def __init__(self, has_end=False):
        self.has_end = has_end
        self.children = dict()

class Trie(object):
    def __init__(self):
        self.head = Node(None)
    
    # 트라이에 전화번호 추가 
    def insert(self, num):
        curr_node = self.head
        
        for d in num:
            # 현재 노드에 해당하는 숫자의 자식이 없으면 생성
            if curr_node.children.get(d) is None:
                curr_node.children[d] = Node()
            # 해당하는 숫자 자식으로 이동
            curr_node = curr_node.children[d]
        # 자료구조 말단에서 끝 표시
        curr_node.has_end = True
    
    # 트라이에서 전화번호 조회
    def search(self, num):
        curr_node = self.head
        
        for d in num:
            # 해당하는 숫자의 자식이 없으면 전화 가능하므로 일관성 있음
            if curr_node.children.get(d) is None:
                return True
            curr_node = curr_node.children[d]
            # 현재 위치에서 끝나는 전화번호가 있다면 해당 번호로 전화되므로 일관성 없음
            if curr_node.has_end:
                return False
        return True
        
        
if __name__=="__main__":
    t = int(input())
    
    for _ in range(t):
        n = int(input())
        
        phone_numbers = [input().rstrip() for _ in range(n)]

        # 길이가 짧은 것 부터 삽입하기 위해 정렬
        phone_numbers = list(map(lambda x : (len(x), x), phone_numbers))
        phone_numbers.sort(key = lambda x : x[0])
        
        data = Trie()
        
        # 번호에 대해 일관성 있는지 확인후 Trie 자료구조 데이터에 삽입
        for _, number in phone_numbers:
            # 일관성 없으면 즉시 NO 출력후 다음 테스트케이스 탐색
            if data.search(number) == False:
                print("NO")
                break
            # 일관성 있으면 데이터 삽입
            data.insert(number)
        # 모든 전화번호가 일관성 있다면 YES 출력
        else:
            print("YES")

 


 

탐색

 

  • 경우의 수 나눠서 그래프 그려보기

 

1. 완전 탐색

 

  • 가능한 모든 방법을 찾아봐서 해를 구하는 것
  • 그래프 탐색 : DFS, BFS
  • BFS : 큐에서 노드 꺼내고 해당 노드의 인접 노드 중 방문하지 않은 노드를 모두 큐에 삽입하고 방문처리
  • DFS : 보통 재귀로. 함수 시작 부분에 종료 조건 명시. 익숙하지 않을 때 방문 경로 저장같은 거 global로 하면 안헷갈림

 

방향그래프의 사이클 판별 (DFS)

 

  • 무방향 그래프의 경우 UnionFind를 통해 사이클을 판별할 수 있고
  • 방향 그래프의 경우 DFS를 통해 판별할 수 있다.
  • DFS로 순회하면서 다음 방문할 곳이 이미 방문했고 재귀를 시작한 곳이라면 cycle을 이룬 것.

 

백준 10451번 순열 사이클

from sys import stdin
input = stdin.readline

# 재귀로 현 숫자가 가리키는 다음 숫자 확인
def dfs(startNum, originNum):
    global cycle
    nextNum = A[startNum]   # 현 숫자가 가리키는 다음 숫자
    if check[nextNum]:      # 이미 방문했다면 사이클 이루는지 확인
        if nextNum == originNum:    
            cycle += 1      # 사이클 이룬 것이므로 +1
            return
    else:   				# 첫 방문이라면 방문 표시
        check[nextNum] = 1
        dfs(nextNum, originNum)     # 재귀로 다음 숫자 확인

# main
T = int(input())
for _ in range(T):
    N = int(input())
    A = [0] + list(map(int, input().split()))
    cycle = 0
    check = [0] * (N+1)             # 방문 표시
    for start in range(1, N+1):
        if check[start]:            # 이미 방문한 곳이면 탐색 X
            continue
        check[start] = 1            # 새로 탐색 시작
        dfs(start, start)           # 이동할 숫자와 사이클 확인용 시작한 숫자

    print(cycle)

 

 

DFS로 연결 요소 찾기 (Connected Component)

n, m = 4, 5
graph = [[0,0,1,1,0], [0,0,0,1,1], [1,1,1,1,1], [0,0,0,0,0]]
result = 0

def dfs(x, y):
	if x > 0 and x < n and y > 0 and y < m:
		if graph[x][y] == 0:
			graph[x][y] == 1
			dfs(x+1, y)
			dfs(x-1, y)
			dfs(x, y+1)
			dfs(x, y-1)
			return True
	return False

for i in range(n):
	for j in range(m):
		if dfs(n, m) == True:
			result += 1

 

BFS로 최단경로 찾기

 

  • BFS : queue 이용, 큐에서 노드 꺼내고 해당 노드의 인접 노드 중 방문하지 않은 노드를 모두 큐에 삽입하고 방문처리
  • BFS는 간선의 비용을 이용해 최단거리를 찾는 문제에도 이용

 

n, m = 5, 6
_map = [[1,0,1,0,1,0],
        [1,1,1,1,1,1],
        [0,0,0,0,0,1],
        [1,1,1,1,1,1],
        [1,1,1,1,1,1]]

dx = [-1,1,0,0]
dy = [0,0,-1,1]
print(bfs(x,y))

def bfs(x,y):
    queue = deque()
    queue.append(x,y)
    while queue:
        x, y = queue.popleft()
        for i in range(4):
            nx = x + dx[i]
            ny = y + dy[i]
            if nx < 0 or nx >=n or ny < 0 or ny >= m:
                    continue
            if graph[nx][ny] == 0:
                    continue
            if graph[nx][ny] == 1:
                    graph[nx][ny] == graph[x][y] + 1
                    queue.append((nx,ny))
	return graph[n-1][m-1]

 

2. 백트래킹

 

 

  • DFS와의 차이 
    • 예시 : 그래프나 트리의 모든 노드를 탐색, 경로 찾기(미로 찾기), 그래프 연결성 확인, 경로 존재 여부 탐색
    • 탐색 목표 : 모든 노드 방문

 

  • 한 경로를 끝까지 탐색한 후 다른 경로로 넘어감.
    • 예시 : 특정조건(제약조건)을 만족하는 해를 찾는 문제, 퍼즐, N-queen(퀸이 서로 공격하지 않는 배치를 찾는 문제), 순열과 조합
    • 탐색 목표 : 최적의 해를 찾을 때, 조건을 만족하는 해를 찾을 때

 

  • 모든 가능한 해를 시도하면서도 조건을 만족하지 않는 경로는 미리 Pruning하여 효율성을 높이므로 DFS보다 시간을 줄일 수 있다.
  • 비트마스킹, DP 등을 활용해 Pruning하여 시간 최적화.
  • 상태공간트리 를 그려보고 새로운 탐색이 무의미하다고 생각되면 자르기.
  • 상태공간트리에서 depth를 내려갈 때마다 조건 체크.

 

백트래킹 문제들

 

백준 N-Queen Gold 4 백트래킹 Well-known 문제
백준 소문난 칠공주 Gold 3 BFS와 같지만 Y가 4개 이상인 루트는 탐색하지 않음(백트래킹), 십자모양탐색(DFS로 조합찾고 BFS로 인접체크)
백준 Don’t Get Rooked Gold 5  
SWEA 4317 칩생산 Gold 5 1x1사이즈 방문이 아니고 2x2사이즈 모두 방문가능해야 방문 > DFS인데 현재칸 방문 + 다음칸 방문 동시에, 한 열 or 행 쭉 따라서 방문하고 도달하면 행 or 열 바꾸기 / 비트마스킹, DP로 탐색 최적화
백준 낚시왕 Gold 1 배열 늘려서 탐색하는 문제

 

 

3. 이진 탐색

 

  • 탐색 범위를 반씩 좁혀가며 데이터를 탐색
  • 리스트가 정렬되어 있을 때
  • 탐색 범위가 큰 경우
  • 특정 원소를 찾기 위해 앞에서부터 그냥 확인하는 것 → 순차 탐색
  • 탐색 범위를 절반씩 좁혀가며 데이터를 탐색하는 방법으로 리스트가 정렬되어 있을 때 씀 O(logN) → 탐색 범위가 큰 경우 떠올려야 함

 

def binary_search(arr, target, start, end):
    if start > end:
        return None # 찾을 수 없는 경우
    mid = (start + end) // 2
    if arr[mid] == target:
        return mid
    elif arr[mid] < target:
        return binary_searcy(arr, target, mid + 1, end)
    else:
        return binary_search(arr, target, start, mid - 1)
    # mid + 1, mid - 1을 해주어야 while s <= e 에서 무한루프 걸리지 않음

 

정렬된 배열에서 특정 수의 갯수 구하기 (bisect) 이용

 

  • N개의 원소를 포함하고 있는 수열이 오름차순으로 정렬되 있습니다. 이때 수열에서 x가 등장하는 횟수를 계산하시오.
  • 예를 들어수열 {1, 1, 2, 2, 2, 2, 3}이 있고 x=2이면, 현재 수열에서 값이 2인 원소가 4개 이므로 4를 출력합니다.
  • 이 문제는 시간복잡도 O(logN)으로 설계하지 않으면 시간 초과 판정을 받는 문제

 

from bisect import bisect_left, bisect_right

def count_by_range(arr, left_val, right_val):
    right_index = bisect_right(arr, right_val)
    left_index = bisect_left(arr, left_val)
    return right_index - left_index

 

  • 특정 값이 등장하는 첫번째 위치 bisect_left와 마지막 위치 bisect_right를 찾아 위치 차이를 계산해서 문제를 해결

 

 


 

최단경로

  • 모든 노드를 연결하는 경로 중 비용이 최소가 되는 경로

 

1. 다익스트라 (Dijkstra) - Greedy

 

  • 매 상황에서 가장 비용이 적은 노드를 선택해 임의의 과정을 반복하므로 Greedy로 분류하지만 DP의 원리가 적용된 것임.
  • 특정 노드에서 출발해 다른 모든 노드로 가는 최단 경로를 계산
  • 한 지점에서 다른 특정 지점까지 최단경로 → 1차원 리스트에 저장
  • algorithm
    1. 출발 노드 설정
    2. 최단 거리 테이블 초기화
    3. 방문하지 않은 노드 중 최단 거리가 가장 짧은 노드를 선택
      • 이렇게 선택된 최단 거리가 가장 짧은 노드는 바뀌지 않기 때문에 그리디
    4. 해당 노드를 거쳐 다른 노드로 가는 비용을 계산해 최단 거리 테이블 갱신
    5. 3, 4 반복
    • 이렇게 하면 최단 거리를 알 수 있는데, 경로를 출력하려면 추가적인 작업 필요

 

다익스트라 O(V^2)

 

import math
n, m = 6, 7
# graph[i] = [(j, c), ..]
# node i에서 node j로 가는 비용이 c
graph = [[], [(2, 2), (4, 1)], [(1, 2), (4, 2), (3, 3)], 
        [(2, 3), (6, 5)], [(1, 1), (2, 2), (5, 1)],
        [(4, 1), (6, 2)], [(5, 2), (3, 5)]]

distance = [math.inf] * (n + 1)
visited = [False] * (n + 1)

def get_min_node():
    index = -1
    min_val = math.inf
    for i in range(1, n + 1):
        if not visited[i] and min_val > distance[i]:
            min_val = distance[i]
            index = i
    return index

def dijkstra(start):
    distance[start] = 0
    visited[start] = True
    for j, c in graph[start]:
        distance[j] = c

    for _ in range(n - 1): # while all(visited)와 같음, 모든 노드 방문 위해
        cur = get_min_node()
        visited[cur] = True
        # cur을 거쳐 이동하는 경우가 더 짧은 경우
        for j, c in graph[cur]:
            cost = distance[cur] + c
            if distance[j] > cost:
                distance[j] = cost

dijkstra(1)
for i in range(1, n + 1):
    if distance[i] == math.inf:
        print('INF')
    else:
        print(distance[i])

 

 

다익스트라 우선순위 큐(삽입, 삭제 O(NlogN) 사용)

 

import heapq
import math
n, m = 6, 7
# graph[i] = [(j, c), ..]
# node i에서 node j로 가는 비용이 c
graph = [[], [(2, 2), (4, 1)], [(1, 2), (4, 2), (3, 3)], 
        [(2, 3), (6, 5)], [(1, 1), (2, 2), (5, 1)],
        [(4, 1), (6, 2)], [(5, 2), (3, 5)]]

distance = [math.inf] * (n + 1)

def dijkstra(start):
    q = []
    heapq.heappush(q, (0, start))
    distance[start] = 0
    while q:
        dist, cur = heapq.heappop(q)
        if distance[cur] > dist: # visitied check와 같은 역할, 이미 처리된 노드 무시
            continue
        for j, c in graph[cur]:
            cost = dist + c
            if cost < distance[j]:
                distance[j] = cost
                heapq.heappush(q, (cost, j))

dijkstra(1)
for i in range(1, n + 1):
    if distance[i] == math.inf:
        print('INF')
    else:
        print(distance[i])

 

  • 노드 5000개 이하일 때 사용 이상일 때 우선순위 큐(삽입, 삭제 O(NlogN)) 사용
  • 기본 원리는 동일하고, 각 단계마다 방문하지 않는 노드 중 최단 거리가 가장 짧은 노드를 선택(get_min_node())할 때 힙(Heap) 자료구조를 이용하는 것만 다름.
  • 힙구조 사용 다익스트라 → O(ElogV)
    • while은 최대 노드 갯수 V임.
    • 인접 노드 확인하는 총횟수는 최대 E
    • E개의 원소를 우선순위 큐에 넣었다가 모두 빼는 연산과 유사하며 O(ElogE)이고 중복 간선을 포함하지 않으면 O(ElogV)가 되는 것
  • 음의 가중치를 허용하지 않음

 

 

2. 플로이드워셜 (Floyd-Warshall) - DP

 

  • DP, O(N^3) 이므로 노드의 개수가 적은 경우에 사용, 그렇지 않으면 다익스트라
  • 즉, 노드가 500개 이하, 모든 노드 - 모든 노드 간 최단거리 구할 때 사용
  • 모든 지점에서 다른 모든 지점까지 최단경로 → 2차원 리스트에 저장
  • 다익스트라와 마찬가지로 단계별로 거쳐 가는 노드를 기준으로 알고리즘을 수행
  • 다익스트라와 다르게 매 단계마다 방문하지 않은 노드 중 최단 거리를 갖는 노드를 찾는 과정이 필요하지 않다.
  • 점화식 $D_{ab} = \min (D_{ab}, D_{ak} + D_{kb}$
    • 각 단계마다 특정 노드 k를 거쳐 가는 경우를 확인하는데,
    • a에서 b로 가는 최단 보다 a에서 k를 거쳐 b로 가는 거리가 더 짧은지 검사

 

import math
n, m = 6, 7
# graph[i] = [(j, c), ..]
# node i에서 node j로 가는 비용이 c
tmp = [[], [(2, 2), (4, 1)], [(1, 2), (4, 2), (3, 3)], 
        [(2, 3), (6, 5)], [(1, 1), (2, 2), (5, 1)],
        [(4, 1), (6, 2)], [(5, 2), (3, 5)]]

graph = [[math.inf] * (n+1) for _ in range(n+1)]

for i in range(1, n+1):
    graph[i][i] = 0
    for j, c in tmp[i]:
        graph[i][j] = c

# for 문 순서주의 : K (경유지) -> 시작지 I -> 도착지 J
for k in range(1, n+1):
    for i in range(1, n+1):
        for j in range(1, n+1):
            graph[i][j] = min(graph[i][j], graph[i][k] + graph[k][j])

# result
for a in range(1, n+1):
    for b in range(1, n+1):
        if graph[a][b] == math.inf:
            print('INF', end=' ')
        else:
            print(graph[a][b], end=' ')
    print()

 

⭐️ For 문 순서 왜 K (경유지) -> 시작지 I -> 도착지 J 냐면,

경유점 k를 순차적으로 먼저 접근해야 DP의 전제인 
i-j-k의 최단 거리는 i-k 최단거리 + k-j 최단거리 이다가 성립

노드가 1,2,3 있는 경우
자기 자신으로 가는 경로를 제외하고 1>2, 1>3, 2>1, 2>3, 3>1, 3>2를 찾아야 한다
K 고정시 순서가
k=1 1>1>2, 1>1>3, 2>1>1, 2>1>3, 3>1>1, 3>1>2
k=2 1>2>2, 1>2>3, 2>2>1, 2>2>3, 3>2>1, 3>2>2
k=3 1>3>2, 1>3>3, 2>3>1, 2>3>3, 3>3>1, 3>3>2

k=3 1>3>2 일때 1 > 2로 가능한 모든 경로 갱신 후이고, 마지막 경로가 되서 최단 경로를 찾을 수 있다.

i - k - j 인 경우

i=1 1>1>1 1>1>2 1>1>3 1>2>1 1>2>2 1>2>3 1>3>1 1>3>2 1>3>3
i=2 2>1>1 2>1>2 2>1>3 2>2>1 2>2>2 2>2>3 2>3>1 2>3>2 2>3>3
i=3 3>1>1 3>1>2 3>1>3 3>2>1 3>2>2 3>2>3 3>3>1 3>3>2 3>3>3

i=1에서 1>1>3 1>2>3 1>3>3 에서 1>3 경로 거리를 업데이트 해버림
하지만 이때 2>3과 3>3은 아직 갱신이 안된 상태라 최단 경로가 아님
경유지 노드를 고려하기 전에 특정 노드 쌍에 대한 최단 경로를 먼저 고려해버림

 

 


 

동적 계획법(DP, Dynamic Programming)

 

  • 그리디, 구현, 완전탐색 고려 후 풀이 방법이 떠오르지 않거나 시간이 오래걸릴 것 같으면 DP를 고려.
  • 점화식을 떠올릴 수 있어야 한다.
  • DP가 코테에 나오면 어렵다.
  • Dynamic은 자료구조의 동적할당 같은 의미랑 상관없음. 별 뜻 없음
  • DP의 사용 조건
    • 최적 부분 구조(Optimal Substructure) : 큰 문제를 작은 문제로 나눌 수 있다.
    • 중복되는 부분 문제(Overlapping Subproblem) : 동일한 작은 문제를 반복적으로 해결.
  • DP solution
    • Top -> Down (하향식, 메모이제이션, 재귀 사용)
    • Down -> Top (DP의 전형적인 형태)
  • DP와 분할 정복은 최적 부분 구조를 가질때 사용한다는 점은 같지만
    • DP는 각 부분 문제들이 서로 영향을 미치며 부분 문제가 중복 되지만,
    • 분할 정복은 동일한 부분 문제가 반복적으로 계산되지 않는다.
      • ex) 퀵정렬. 한 번 Pivot이 자리를 변경해서 자리 잡으면 그 pivot의 위치는 바뀌지 않고, 분할 이후 해당 Pivot을 다시 처리하는 부분 문제는 호출하지 않는다.
  • 그리디, 구현, 완전탐색 아이디어로 해결할 수 있는지 검토하고 풀이 방법이 떠오르지 않거나 완전탐색 같은 것이 시간이 오래 걸릴 것 같으면 DP를 고려해 볼것.
  • 일단 재귀 함수로 비효율적인 완전 탐색 프로그램을 작성하고 (탑다운) 작은 문제에서 구한 답이 큰 문제에서 그대로 사용될 수 있으면, 메모이제이션으로 코드를 개선하는 방법으로 DP를 사용할 수 있다.
    • EX) 소프티어 염기서열 : 완전탐색으로 풀렸지만 시간초과났음.
    • EX) 프로그래머스 N으로 표현 : DFS로 풀면 속도가 100배 차이남
  • DP가 코테에 나올 경우 기본 유형 문제가 나올 확률이 높은데, DP의 점화식을 떠올리는데 시간이 많이 소요되기 때문.

 

DP Well-known 문제 - 외판원 순회(TSP, Travelling Salesman Problem)

 

# 비트마스킹 없이
N = int(input())
W = [list(map(int, input().split())) for _ in range(N)]
# W[i][j] > i에서 j로 가기 위한 비용, asymmetric, W[i][j] =/= W[j][i]

INF = float('inf')
def dfs(cur, route):
    if tuple(route) in dp[cur]:
        return dp[cur][tuple(route)]
    
    if len(route) == N:
        return W[cur][0] if W[cur][0] > 0 else INF
    
    min_d = INF
    for nxt in range(N):
        if nxt not in route and W[cur][nxt] > 0:
            d = W[cur][nxt] + dfs(nxt, sorted(route + [nxt]))
            min_d = min(min_d, d)

    dp[cur][tuple(route)] = min_d
    return min_d

dp = {i: {} for i in range(N)}
print(dfs(0, [0]))

 

# 비트마스킹

N = int(input())
W = [list(map(int, input().split())) for _ in range(N)]
# W[i][j] > i에서 j로 가기 위한 비용, asymmetric, W[i][j] =/= W[j][i]

INF = float('inf')
def dfs(cur, route):
    if dp[cur][route]:
        return dp[cur][route]
    
    if route == (1 << (N-1)) - 1:
        return W[cur][0] if W[cur][0] > 0 else INF
    
    min_d = INF
    for nxt in range(1, N):
        if W[cur][nxt] and not route & (1 << (nxt - 1)):
            d = W[cur][nxt] + dfs(nxt, route | (1 << (nxt - 1)))
            min_d = min(min_d, d)
    
    dp[cur][route] = min_d
    return min_d


dp = [[0] * (1 << (N-1)) for _ in range(N)]
print(dfs(0, 0))

 

 

DP 문제 - 1로 만들기 (https://www.acmicpc.net/problem/1463)

 

  • 1이될때까지 라는 문제는 n이 무엇이든 1로 빼는 것보다 나누는 것이 값을 빠르게 줄일 수 있어 Greedy 였지만, 이 문제는 당장 큰 수로 나누는 것 보다 다른 연산과 섞었을 때 더 빠르게 값을 줄일 수 있는 경우가 존재할 수 있기 때문에 그리디X
  • 최적 부분 구조와 중복되는 부분 문제를 만족.
  • $a_i = i$를 1로 만들기 위한 최소 연산 횟수
    • 점화식 $a_i = \min (a_{i-1}, a_{i/2}, a_{a/3} + 1$
    • 1을 빼는 연산을 제외하고는 해당 수로 나누어떨어질 때에 한해 점화식을 적용

 

# 1 Bottom up
# dp의 index가 n이고 value가 연산 횟수이다.
# index 2는 index 1에서 -1 연산 한번 해서 만들어진 것 이므로 dp[2] = 1
dp = [0] * n
for i in range(2, n+1):
    dp[i] = dp[i-1] + 1
    if i % 2 == 0:
        # 이전 값에서 -1 한 것 누적횟수
        # 2로 나눈 값에서 -1 한 것 누적횟수
        # 중 더 작은거
        dp[i] = min(dp[i], dp[i // 2] + 1)
    if i % 3 == 0:
        dp[i] = min(dp[i], dp[i // 3] + 1)

 

# 2 Top Down 재귀
# 애가 더 빨랐음
dp={1:0}
def rec(n):
    if n in dp.keys():
        return dp[n]
    if (n%3==0) and (n%2==0):
        dp[n]=min(rec(n//3)+1, rec(n//2)+1)
    elif n%3==0:
        dp[n]=min(rec(n//3)+1, rec(n-1)+1)
    elif n%2==0:
        dp[n]=min(rec(n//2)+1, rec(n-1)+1)
    else:
        dp[n]=rec(n-1)+1
    return dp[n]

 

# 3. BFS
from collections import deque
x=int(input())
Q=deque([x])
visited=[0]*(x+1)
while Q:
    c=Q.popleft()
    if c==1:
        break
    if c%3==0 and visited[c//3]==0:
        Q.append(c//3)
        visited[c//3]=visited[c]+1
    if c%2==0 and visited[c//2]==0:
        Q.append(c//2)
        visited[c//2]=visited[c]+1
    if visited[c-1]==0:
        Q.append(c-1)
        visited[c-1]=visited[c]+1
print(visited[1])

 

 

DP 문제 - 배낭문제

 

# 1차원 DP
N, M = map(int, input().split(' '))

dp = [0 for _ in range(M + 1)]
objects = [tuple(map(int, input().split())) for _ in range(N)]

for W, C in objects:
  for i in range(M, W - 1, -1):
    dp[i] = max(dp[i], dp[i - W] + C)

print(dp[M])

 

 


최소 신장 트리(MST, Minimum Spanning Tree)

 

  • 모든 노드를 포함하되 사이클이 없는 최소비용 tree(MST)를 만들기 위한 알고리즘
  • MST의 전체 edge 갯수는 node 갯수 - 1이다. E = V - 1
  • spanning tree는 cycle이 존재하지 않고, 모든 노드를 포함하는 트리
    • node가 n개면 edge는 n-1개
  • MST는 모든 노드를 최소 비용으로 연결하는 트리
  • 크루스칼과 프림 알고리즘이 있다

 

1. 크루스칼 (Kruskal) 알고리즘

  • 그리디 알고리즘
  • 정렬부분 때문에 O(ElogE)
  • UnionFind 사용
  • 동작과정
    1. 간선을 비용에 따라 오름차순으로 정렬 (비용이 적은 간선부터 확인하므로 그리디)
    2. 간선을 하나씩 확인하며 사이클(같은 집합에 속했는지, Find) 체크(union find이용)
      1. 사이클 X → MST에 포함시킴(Union)
      2. 사이클 O → MST에 포함X
    3. 모든 간선에 대해 1-2 반복

 

v, e = 7, 9
# (cost, start_node, end_node)
graph = [(29, 1, 2), (75, 1, 5), (35, 2,3), 
         (34, 2, 6), (7, 3, 4), (23, 4, 6), 
         (13, 4, 7), (53, 5, 6), (25, 6, 7)]
graph.sort()

# 루트노드
def find_parent(parent, v):
    if parent[v] != v:
        # 원래는 바로 직전 부모를 parent에 저장하는데
        # 그냥 가장 최상단 root 노드를 parent로 저장하므로
        # 재귀는 2번밖에 안됨
        parent[v] = find_parent(parent, parent[v])
    return parent[v]

# 부모를 같게 만드는 연산 Union 연산
def union_parent(parent, a, b):
    a = find_parent(parent, a) 
    b = find_parent(parent, b)
    if a < b:
        parent[b] = a
    else:
        parent[a] = b

parent = [i for i in range(v + 1)]
total_cost = 0
for cost, s, e in graph:
    # 부모 다름, Cycle X
    if find_parent(parent, s) != find_parent(parent, e):
        union_parent(parent, s, e)
        total_cost += cost
print(total_cost)

 

 

2. 프림 (Prim) algorithm

 

  • 크루스칼의 경우 O(eloge)이고, 프림은 O(n^2)이다.
  • 프림도 그리디 Priority Queue(heapq) 사용
  • 그래프 내에서 적은 숫자 간선을 가지는 희소 그래프 (Sparse graph)인 경우는 크루스칼
  • 밀집 그래프 (Dense graph)인 경우는 프림이 적합.
  • 동작 과정
    1. 시작 정점과 연결된 모든 간선을 Priority Queue에 삽입
    2. 최소 가중치의 간선 선택
    3. 선택한 간선의 root가 트리에 포함되어 있지 않으면, Union + 인접 간선 모두 PQ에 삽입
    4. 모든 정점이 트리에 포함될 때까지 3,4 반복

 

문제 

  • 백준 1717, 백준 4195

 

import heapq

graph = {
    'A': {'B': 1, 'C': 4},
    'B': {'A': 1, 'C': 2, 'D': 5},
    'C': {'A': 4, 'B': 2, 'D': 1},
    'D': {'B': 5, 'C': 1}
}

def prim(graph, start):
    mst = []
    visited = set()
    edges = [] # priority queue
    count, cost = 0, 0
    heapq.heappush(edges, (0, start))

    # 이동가능한 노드가 없거나 모든 노드 방문(e = v - 1)할 경우 stop
    while edges and count < len(graph) - 1:
        w, node = heapq.heappop(edges)

        if node not in visited:
            cost += 1
            count += 1
            visited.add(node)
            mst.append((node, w))
            for node, weight in graph[node].items():
                heapq.heappush(edges, (weight, node))
    
    if count != len(graph) - 1:
        return '고립 노드가 있어 모두 연결 할 수 X' 
    else:
        return f'total weight: {cost}'

print(prim(graph, 'A'))

 

 

3. 위상 정렬 (Topology Sort)

 

  • cycle이 없는, 방향 그래프 DAG(Direct Acycle Graph) 여야 한다.
  • 사이클이 있다면 각 노드는 모두 InDegree는 1 이상이므로 큐에 넣을게 없어 위상 정렬 할 수 없음
  • ex. 선수과목이 있을 때 적절한 학습순서
  • 한번 방문한 노드는 다시 Q에 들어가지 않고, 엣지도 다시 보지 않으므로 O(V+E)

 

방법

  1. DFS
  2. 큐 이용
    1. indegree가 0인 모든 노드를 큐에 넣고
    2. 큐가 빌때 까지 아래 과정 반복
        1. 큐에서 원소를 꺼내 해당 노드에서 나가는 outdegree 간선을 제거
        1. 새롭게 진입차수가 0이된 노드를 큐에 삽입
    • 결과적으로 각 노드가 큐에 들어온 순서가 위상 정렬을 수행한 결과와 같아진다.

 

from collections import deque
v, e = 7, 8
edges = [(1,2), (1,5), (2,3), (2,6), (5,6), (3,4), (6,4), (4,7)]
graph = [[] for i in range(v+1)]
indegree = [0] + (v + 1)
for s, e in edges:
    graph[s].append(e)
    indegree[b] += 1

def topology_sort():
    result = []
    q = deque()
    for i in range(1, v+1):
        if indegree[i] == 0:
            q.append(i)
    while q:
        cur = q.popleft()
        result.append(cur)
        for i in graph[cur]:
            indegree[i] -= 1
            if indgree[i] == 0:
                q.append(i)
    for i in result:
        print(i, end=' ')
topology_sort()
# 1 2 5 3 6 4 7
# 1 5 2 3 6 4 7
# ...
# 여러 답 존재
# 모든 노드를 방문하기 전에 Q가 빈다면 사이클 존재

 

 

 

 


 

그래프

 

1. LCA(Lowest Common Ancestor) 최소 공통 조상

 

  1. 모든 노드에 대한 깊이(depth)를 계산
  2. 최소 공통 조상을 찾을 두 노드를 확인
    1. 먼저 두 노드의 깊이가 동일하도록 거슬러 올라감
    2. 이후에 부모가 같아질 때까지 반복적으로 두 노드의 부모 방향으로 거슬러 올라감
  3. 모든 LCA(a,b) 연산에 대해 2번의 과정을 반복
  • 매 쿼리 lca(a,b)마다 부모 방향으로 거슬러 올라가기 위해 최악의 경우 O(N)
    • 쿼리 갯수 M이면 O(NM)

 

문제 백준11437

 

import sys
sys.setrecursionlimit(int(1e5))
n = int(input())

parent = [0] * (n+1)
d = [0] * (n+1) # 깊이 저장
c = [0] * (n+1) # 방문 여부 (깊이 저장 여부)
graph = [[] for _ in range(n+1)]

for _ in range(n-1):
    a, b = map(int, input().split())
    graph[a].append(b)
    graph[b].append(a)

# dfs로 depth 저장하기
def dfs(x, depth):
    c[x] = True
    d[x] = depth
    for y in graph[x]:
        if c[y]:
        # 이미 depth를 구한경우
            continue
        parent[y] = x
        dfs(y, depth + 1)

def lca(a, b):
    # 먼저 깊이를 맞춰주기
    while d[a] != d[b]:
        if d[a] > d[b]:
            a = parent[a]
        else:
            b = parent[b]
    # 부모가 같을 때까지 거슬러 올라가기
    while a != b:
        a = parent[a]
        b = parent[b]
    return a

dfs(1, 0)
m = int(input())

for i in range(m):
    a, b = map(int, input().split())
    print(lca(a,b))

 

 

 

2. 바이너리 인덱스 트리(BIT, Binary Indexed Tree), 펜윅 트리(Fenwick Tree)

 

  • 2진법 인덱스 구조를 사용해 구간 합 문제를 효과적으로 해결할 수 있는 자료구조
  • 세그먼트 트리의 변형으로 수열의 구간 합을 빠르게 계산
  • 세그먼트 트리와 마찬가지로 O(logN) 시간에 구간 합을 계산할 수 있고 세그먼트 트리에 비해 적은 공간이 필요하고 구현하기 쉽다는 장점이 있음
  • -7은 7의 2진수 표기에서 flip을 한 후 +1을 해준 것
  • 7의 경우 0이 아닌 가장 마지막 비트는 00..00111에서 가장 마지막인 1이다
  • 이를 알기 위해서 7 & -7 를 하면 된다
  • 정수 3은 0이 아닌 마지막 비트 1, 정수 4는 4
  • 0이 아닌 마지막 비트를 BIT배열에서 저장하고 있는 값들의 갯수로 사용한다.

 

 

  • 사이즈 n짜리 배열에 세그먼트 트리를 만들려먼 2n-1만큼의 배열이 필요하지만, 펜윅 트리는 n만큼의 배열이 필요하다.
  • 배열 A에 값이 담겨 있고, BIT에는 저장하고 있는 값들의 갯수이다. 예를 들어 BIT의 인덱스가 i이고 0아닌 마지막 비트가 4라면, A[i-3], A[i-2], A[i-1], A[i]의 합을 가지고 있다.
    • BIT에서 index=1은 0아닌 마지막 비트가 1이기 때문에 A배열의 1~1까지 값을 가지고 있고
    • BIT에서 index=2은 0아닌 마지막 비트가 2이기 때문에 A배열의 1~2까지 값을 가지고 있고
    • BIT에서 index=3은 0아닌 마지막 비트가 1이기 때문에 A배열의 3~3까지 값을 가지고 있고
    • BIT에서 index=12은 0아닌 마지막 비트가 4이기 때문에 A배열의 9~12까지 값을 가지고 있고
    • BIT에서 index=16은 0아닌 마지막 비트가 16이기 때문에 A배열의 1~16까지 값을 가지고 있고

 

예시 문제

  • 백준 '구간 합 구하기'
  • 어떤 N개의 수가 주어져 있고 중간 수에 변경이 빈번히 일어나고 그 중간에 어떤 부분 합을 구하려 한다. 만약 1,2,3,4,5가 있을 때 3번째 수를 6으로 바꾸고 2~5의 합을 구하라고 하면 답은 17이다.
  • 이와 같은 문제는 변경이 빈번하므로 값 변경 O(N), 구간 합 계산 O(K)로 O(NK)이므로 비효율적이다.
  • 펜윅을 사용하면, 값 변경이 O(logN)이다. 예를 들어 A[3]의 값을 바꿨다면, A[3] 값이 포함된 BIT[3], BIT[4], BIT[8], BIT[16]의 값만 변경해 update하면 된다.
  • 구간 합(Prefix Sum)을 구할 때 0이 아닌 마지막 비트만큼 빼면서 구간 합을 계산 O(logN)
    • 11에서 시작해서, 11의 0아닌비트는 1이므로 -1칸 이동해 BIT[10]을 더하고
    • 10에서 0아닌비트는 2이므로 -2칸 이동해 BIT[8]을 더한다.
  • 최종 O(logN) * O(logN) = O(logN)

 

n, m, k = map(int, input().split())

arr = [0] * (n+1)
tree = [0] * (n+1)

def prefix_sum():
    result = 0
    while i > 0:
        result += tree[i]
        i -= (i & -i)

def update(i, dif):
    while i <= n:
        tree[i] += dif
        i += (i & -i)

def interval_sum(start, end):
    return prefix_sum(end) - prefix_sum(start - 1)

for i in range(1, n+1):
    x = int(input())
    arr[i] = x
    update(i, x)

for i in range(m + k):
    a, b, c = map(int, input().split())
    if a == 1: # update
        update(b, c - arr[b]) # dif = 바뀐 크기
        arr[b] = c
    else: # 구간 합 연산
        print(interval_sum(b, c))

 

 

3. 오일러 경로(Eulerian trail)

 

  • 오일러 경로(Eulerian trail)은 그래프에 존재하는 모든 Edge를 정확히 1번씩만 방문하는 연속된 경로이고, 이때 시작점과 도착점이 같다면 오일러 회로(Circuit)이 된다.
  • 오일러 회로는 시작과 끝점을 제외하고는 들어오는 간선이 있다면, 반드시 나가는 간선이 하나 더 있어야 한다. 따라서 차수가 항상 짝수
  • 오일러 경로는 무향 그래프일 경우 차수가 홀수인 정점이 2개일 때 존재
  • 오일러 회로는 무향 그래프일 경우 차수가 홀수인 정점이 0개일 때 존재
  • 오일러 회로 구하는 방법은 Hierholzer's 알고리즘과 DFS가 있다.

 

오일러 경로 - DFS

 

  1. 정점을 방문하면서 정점의 간선을 지우고, 차수를 1씩 줄여나감
  2. 해당 정점에 더이상 간선이 없는 순간 정점 번호를 출력하고 이어 붙인 것이 답
  • 위 그림에서 A B C 방문하면 다음 방문지는 D, E, F가 될 수 있음
  • D를 방문하는 경우 D-> A로 가서 A가 더이상 간선이 없어 방문이 끝나 C로 돌아오고 "A D"를 출력
  • C로 돌아와서 D, E, F 에서 E를 방문하고 C E F C로 가면 C도 더이상 간선이 없으므로 "C E F C"를 출력하고 C에서도 시작점인 A로 돌아가 "B A"를 출력해
  • 이어붙이면 " A D C E F C B A "
  • D말고 E를 먼저 방문해도 결과는 같음

 

오일러 경로 - Hierholzer's

 

  • 아무 정점 v에서 출발하여 v로 돌아오는 경로를 하나 뽑는다.
  • 경로에 속한 정점 중 인접한 간선들 중 경로에 쓰이지 않은 간선이 있는 정점 u가 존재하면 u로 시작해 아직 방문하지 않은 간선만 사용해 u로 돌아오는 경로를 하나 더 찾아 원래 경로에 끼워 넣는다

 

 

오일러 경로 문제 - 프로그래머스 여행 경로

# 문제를 오일러 회로 구하는 방식으로 접근하여 Hierholzer's 알고리즘 사용
# DFS 스택? 으로도 볼 수 있나?

def solution(tickets):
    routes = {}
    for t in tickets:
        routes[t[0]] = routes.get(t[0], []) + [t[1]]
    for r in routes:
        routes[r].sort(reverse=True)
    stack = ["ICN"]
    path = []
    while len(stack) > 0:
        top = stack[-1]
        if top not in routes or len(routes[top]) == 0:
            path.append(stack.pop())
        else:
            stack.append(routes[top][-1])
            routes[top] = routes[top][:-1]
    return path[::-1]


######################################################
from collections import defaultdict

def solution(tickets):
    r = defaultdict(list)
    for i,j in tickets:
        r[i].append(j)
    for i in r.keys():
        r[i].sort()

    s = ["ICN"]
    p = []
    while s:
        q = s[-1]
        if r[q] != []: # 위랑 차이점은 defaultdict를 써서 dest가 없는 노드라도 에러가 안난다는거
            s.append(r[q].pop(0))
        else:
            # 다음 목적지가 없는데 티켓이 소진이 된 것이 아니라면 맨 마지막 dest일 것임
            # 경로가 남은 곳 까지 pop해서 p에 넣으면 r[q]가 있는 곳으로 가게되고 남은 티켓을 소진하게 됨
            p.append(s.pop())
    return p[::-1]

 

 

오일러 경로 문제 - 프로그래머스 방의 개수

 

from collections import defaultdict

def solution(arrows):
    dx = [-1, -1, 0, 1, 1, 1, 0, -1]
    dy = [0, 1, 1, 1, 0, -1, -1, -1]
    
    graph = defaultdict(list)
    
    x, y = (0, 0)
    answer = 0
    for d in arrows:
        for _ in range(2):
            nx, ny = x + dx[d], y + dy[d]
            
            if (nx, ny) in graph and not (x, y) in graph[(nx, ny)]:
                # 노드에 또 다시 방문했고,
                # 이미 지나왔던 간선이 아니면
                answer += 1
            
            graph[(nx, ny)].append((x, y))
            graph[(x, y)].append((nx, ny))
            
            x, y = nx, ny
            
    return answer

 

 

 

 


 

심화/기타 알고리즘

 

1. 투포인터(Sliding Window)

 

  • 슬라이딩 윈도우라고도 함
  • 리스트에 순차적으로 접근해야 할 때 두개의 점의 위치를 기록하면서 처리하는 알고리즘
  • 우리가 2,3,4,5,6번 학생을 지목해야 할 때 2번부터 7번까지 학생이라고 함
    • 이처럼 리스트에 담긴 데이터에 순차적으로 접근해야 할 때는 시작점과 끝점 2개의 점으로 접근할 데이터의 범위를 표현할 수 있다.
  • 전체 값을 갱신하지 않고 이동한 왼쪽, 오른쪽 포인터의 값만 갱신하는 것이 포인트고 이 때문에 O(N)
  • 크기가 고정일 때 슬라이딩 윈도우 투포인터 떠올리기

 

예제 : 특정한 합을 가지는 부분 연속 수열 찾기

  • N개의 자연수로 구성된 수열이 있다.
  • 이때 합이 M인 부분 연속 수열의 개수를 구해보시오. 제한 수행시간은 O(N)
  • 완전탐색으로 푼다면 O(N * N)
  • 문제 해결 아이디어
    1. 시작점과 끝점이 첫 번째 원소 인덱스 0을 가리키도록 한다
    2. 현재 부분 합이 M과 같다면, count += 1
    3. 현재 부분 합이 M보다 작다면, end += 1
    4. 현재 부분 합이 M보다 크다면, start += 1
    5. 모든 경우를 확인할 떄까지 2~4과정 반복

 

n, m = 5, 5
data = [1, 2, 3, 2, 5]

count = 0
interval_sum = 0
end = 0

for start in range(n):
    while interval_sum < m and end < n:
        interval_sum += data[end]
        end += 1
    if interval_sum == m:
        print(f'{start}-{end - 1}')
        count += 1
    interval_sum -= data[start]

print(count)

 

  • 문제 
    • 카카오엔프 기출 문자열의 다양성
    • 백준 12891 : DNA 비밀번호
    • 백준 2003 : 수들의 합
    • 백준 1644 : 소수의 연속합
    • 백준 1806 : 부분합
    • 백준 2230 : 수 고르기
    • 백준 1484 : 다이어트
    • 백준 2038 : 골룽 수열
    • 백준 2531 : 회전 초밥
    • 백준 2096 : 내려가기
    • 백준 2293 : 동전1

 

 

2. 구간 합(Interval Sum) 빠르게 계산

 

  • 연속적으로 나열된 N개의 수가 있을 때 특정 구간의 모든 수를 합한 값을 계산하는 문제
    • ex. 10, 20, 30, 40, 50 에서 2번재부터 4번째 수까지의 합은 20 + 30 + 40 = 90
  • 구간 합 계산이 한번이라면 선형탐색하면 되는데, 여러번 이라면?
  • 문제
    • N개의 정수로 구성된 수열과 M개의 쿼리가 주어진다.
      • 각 쿼리는 Left와 Right로 구성되어 있고 [Left, Right] 구간 합 출력
      • 수행시간 O(N+M)
  • 해결 아이디어 : Prefix Sum(접두사 합)
    • 배열의 맨 앞부터 특정 위치까지의 합을 미리 구해 놓은 것
    • 알고리즘
      1. N개의 수 위치 각각에 대한 접두사 합을 계산해 P에 저장한다.
      2. 매 M개의 쿼리 정보를 확인할 때 구간 합은 P[Right] - P[Left - 1]이다.
    • [10, 20, 30, 40, 50]
    • P > [0, 10, 30, 60, 100, 150]
    • Left=1, Right=3 > P[3] - P[0] = 60

 

n = 5
data = [10, 20, 30, 40, 50]

sum_value = 0
prefix_sum = 0
for i in data:
    sum_value += i
    prefix_sum.append(sum_value)

left, right = 3, 4
print(prefix_sum[right] - prefix_sum[left - 1])

 

 

 

3. 소수 판별 알고리즘

 

  • 약수의 대칭성을 이용하기 O(sqrt(x))
    • 16의 약수는 1,2,4,8,16이고, 2*8=16, 8*2=16으로 대칭이다. (여기서 가운데 약수는 4이고 루트16이다.)
    • 특정 자연수의 모든 약수를 찾을 때 가운데 약수(제곱근)까지만 확인하면 된다. 예를 들어, 16이 2로 나누어 떨어진다는 것은 8로도 나누어 떨어진다는 것을 의미.

 

import math
def is_prime(x):
    for i in range(2, int(math.sqrt(x) + 1)):
        if x % i == 0:
            return False
    return True

 

  • 위 알고리즘은 1개의 수의 소수를 판별
  • 특정 범위 안의 수에 존재하는 소수를 찾을 땐? > 에라토스테네스의 체

 

4. 에라토스테네스의 체

 

  • 특정한 수의 범위 안에 존재하는 모든 소수를 찾을 때, 즉 다수의 자연수에 대해 소수 여부를 판별
  • N보다 작거나 같은 모든 소수를 찾을 수 있다.
  • 동작과정
    1. 2부터 N까지 모든 자연수를 나열
    2. 남은 수 중에서 아직 처리하지 않은 가장 작은 수 i를 찾는다.
    3. 남은 수 중에서 i의 배수를 모두 제거한다 (i는 제거하지 않는다)
    4. 더 이상 반복할 수 없을 때까지 2와 3을 반복한다.
  • 시간 복잡도는 선형에 가까울 정도로 빠름 O(NloglogN)
  • 하지만 메모리가 많이 필요하다
    • 메모리를 줄여야 한다면

 

import math

n = 1000
array = [True for i in range(n + 1)]

for i in range(2, int(math.sqrt(n)) + 1):
    if array[i] == True: # i가 남은 경우 = 소수
        j = 2
        while i * j <= n:
            array[i * j] = False
            j += 1

for i in range(2, n + 1):
    if array[i]:
        print(i, end=' ')

 

 

 

5. 비트마스킹

 

  • 정수의 이진수 표현을 자료 구조로 쓰는 알고리즘.
  • 효율적인 집합 연산을 수행하거나 관리할 때 사용.
    • ex. 부분 집합 생성 및 탐색, 조합 문제, 상태 압축
  • bit 연산이기 때문에 시간 복잡도가 O(1)
  • 비트연산자
    • 1010 & 1111 = 1010 # AND 모두 1이면 1
    • 1010 | 1111 = 1111 # OR 둘 중 하나라도 1이면 1
    • 1010 ^ 1111 = 0101 # XOR 대응하는 비트가 서로 다르면 1
    • ~1010 = 0101 # NOT 비트반전
    • 1001 << 2 = 100100 # Left shift 왼쪽으로 2칸 밀고 0으로 채우기
      • 1001은 10진수로 9, int("1001",2)=9 | A * 2^B
      • 100100은 10진수로 36, int("100100",2)=36
    • 1010 >> 2 = 0010 # Arithmetic Right shift
      • 00000100 >> 1 = 00000010 (4 >> 1 = 2) | A * 2^B
      • 11111111 >> 2 = 10011111 (-1 >> 2 = -31)

 

  • 파이썬으로 비트표현
# 1. 0b 접두사 붙이기
num = 0b1010 # 앞의 0은 생략 00000000 00000000 00000000 00001010
# 2. bin() 사용
num = bin(10) # '0b1010'
# 2진수 > 10진수
num = int('00001010', 2)
# str을 이진수로
s = '0100'
bin(int(s, 2))

 

 

  • 비트연산
S = 122 # 0b1111010
idx = 2 # 0b1111'0'10

# 1. ADD : 2진수 숫자 S의 idx에 1을 추가
# 1 << idx : 1을 idx만큼 left shift 00000001 << 2 = 00000100
# 즉, idx자리만 1인 비트 만듬
bin(S | (1 << idx)) # 01111'0'10 | 00000'1'00 = 01111110

# 2. REMOVE : 2진수 숫자 S의 idx에 1을 제거
# 1 << idx idx자리만 1인 비트 만들고 not을 하면 idx자리만 0인 비트
# and하면 idx 값만 무조건 0이되고 나머지는 S자신 그대로
bin(S & ~(1 << idx)) # 01111'0'10 & 11111'0'11 = 01111010

# 3. CHECK : idx 값 확인
# 1 << idx idx자리만 1인 비트 만들고
# and하면 idx자리만 자기 자신이고 나머지는 모두 0
(S & (1 << idx)) # 01111'0'10 & 00000'1'00 = 00000000 # False = 0
idx2=1
(S & (1 << idx2)) # 011110'1'0 & 000000'1'0 = 00000010 # True = 1

# 4. Toggle : idx 값을 toggle(1이면 0, 0이면 1로 변환)
# 0과 XOR 연산하면 자기자신, 1과 XOR 연산하면 자신이 1이면 0, 0이면 1인 것 이용
bin(S ^ 1 << idx) # 01111'0'10 ^ 00000'1'00 = 01111110

# NOT 연산을 하면 부호비트까지 바뀌는거 처리해줘야해서 XOR 사용하는 것이 좋음
binary_string = "010100"

# 문자열을 정수로 변환
num = int(binary_string, 2)
# 모든 비트를 1로 만드는 마스크 생성 (ex. '010100'의 길이만큼)
mask = (1 << len(binary_string)) - 1

# XOR 연산으로 비트를 반전
inverted_num = num ^ mask

# 다시 2진수 문자열로 변환
inverted_string = bin(inverted_num)[2:].zfill(len(binary_string))

print(inverted_string)  # 출력: 101011

# S를 -1로 초기화하면 모든 비트가 1, S = -0b1
# S를 0으로 초기화하면 모든 비트가 0, S = 0b0
bin(-0b1 & 0b0) # '0b0'
bin(-0b1 | 0b0) # '-0b1'

 

 

 


 

 

최애 유형은 비트마스킹이랑 DP

 

 

추천 문제

 

문제 유형
124나라의숫자 수학 (삼진법
최고의 집합 수학
디스크 컨트롤러 우선순위 큐
여행경로 그래프 - 오일러, DFS
순위 그래프 - 플로이드 워셜
퍼즐 조각 채우기 구현 - BFS
아이템 줍기 구현 - BFS
징검다리 건너기 이진탐색, 우선순위 큐
가장 긴 증가하는 부분 수열 5 이진탐색
방의 개수 그래프 - 오일러
사칙연산 DP
평범한 배낭2 DP
파일 합치기 DP - Knuth's optimize
조이스틱 그리디
큰수 만들기 그리디
섬 연결하기 그리디 - 크루스칼, 프림
단속카메라 그리디
징검다리 이분탐색
젼력망을 둘로 나누기 BFS
백준4195 UnionFind
신촌통폐합계획 LinkedList
통신망분석 UnionFind, BFS
코드트리투어  
어항정리 빡구현
도넛과막대그래프 그래프
소문난 칠공주 BFS, DFS, 백트래킹
N-Queen 백트래킹

 

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.