728x90
반응형

최단 경로 Shortest Path 알고리즘은 말 그대로 가장 짧은 경로를 찾는 알고리즘이다. 

최단 경로 알고리즘 유형에는 여러가지가 있다.

'한 지점에서 다른 특정 지점까지의 최단 경로를 구해야 하는 경우', '모든 지점에서 다른 모든 지점까지의 최단 경로를 모두 구해야 하는 경우' 등등.

최단 경로 알고리즘은 보통 그래프로 표현한다. 각 지점은 그래프에서 노드로 표현되고, 지점 간 연결된 도로는 그래프에서 간선으로 표현된다. 

 

여기서는 다익스트라(Dijkstra's algorithm)에 대해 배우겠다.

그리디 알고리즘과 다이나믹 알고리즘이 최단 경로 알고리즘에 그대로 적용된다.

 

다익스트라 알고리즘은, 그래프 내의 특정 정점에서 갈 수 있는 모든 정점들까지의 최단 경로를 구하는 알고리즘이다.

특정한 노드에서 출발, 다른 노드로 가는 각각의 최단 경로를 구해주는 알고리즘인 것이다. 

 

그래프가 큰 경우에도 사용할 수 있어 효율적이지만,

'음의 간선'이 있으면 정상적으로 작동하지 않는다. '음의 간선'이란 0보다 작은 값을 가지는 간선을 의미한다.

 

다익스트라 알고리즘은 노드 주변을 탐색할 때 BFS(너비 우선 탐색)를 이용하는 대표적인 알고리즘이기도 하다.

//DFS와 BFS 관련

jobdong7757.tistory.com/64

 

DFS / BFS

탐색Search이란 많은 양의 데이터 중에서 원하는 데이터를 찾는 과정을 의미한다. 대표적인 탐색 알고리즘이 DFS와 BFS이다. 프로그래밍에서는 자료구조 안에서 탐색을 하는데 DFS와 BFS를 알려면 스

jobdong7757.tistory.com

DFS는 한 사람이 미로를 찾아 헤매는 과정과 비슷한 반면, BFS는 여러 명의 사람이 각기 서로 다른 갈림길로 흩어져서 길을 찾는 것과 비슷하다. 이때 각자 실뭉치를 가지고 풀어 놓았다가 되감으면서 새로운 길을 탐색한다. 길을 따라가다 갈림길이 나오면 사람들을 나눠 다시 각자 새로운 길을 탐색하고, 다시 갈림길을 만나 새로 탐색할 길이 하나로 모이면, 나뉘어 탐색하던 사람들이 다시 모여서 함께 탐색한다. 

다익스트라 알고리즘은 이때 먼저 도착한 사람의 실뭉치를 사용한다. 

 

다익스트라 알고리즘은 임의의 정점을 출발 집합에 더할 때, 그 정점까지의 최단거리는 계산이 끝났다는 확신을 가지고 더한다. 만일 이후에 더 짧은 경로가 존재한다면 다익스트라 알고리즘의 논리적 기반이 무너진다. 이때는 모두 값을 더해서 양수로 변환하는 방법이 있다. 같은 이유로 최장거리를 구하는 데에는 다익스트라 알고리즘을 사용할 수 없다.

 

다익스트라 알고리즘은 매번 가장 비용이 적은 노드를 선택해서 임의의 과정으로 반복하기 때문에 그리디 알고리즘으로 분류된다 알고리즘의 과정은 다음과 같다.

 

1. 출발 노드를 설정한다.

2. 최단 거리 테이블을 초기화 한다. (초기에는 전부 무한대 값으로 초기화 한다)

3. 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택한다.

4. 해당 노드를 거쳐 다른 노드로 가는 비용을 계산하여 최단 거리 테이블을 갱신한다.

5. 위 과정의 3,4를 반복한다.

 

다익스트라 알고리즘은 최단 경로를 구하는 과정에서 '각 노드에 대한 현재까지의 최단 거리' 정보를 항상 1차원 리스트에 저장하며 리스트를 계속 갱신한다. 3번에서 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택해서 4번을 수행한다는 점에서 그리디 알고리즘으로 볼 수 있다.

다익스트라 최단 경로 알고리즘에서는 '방문하지 않은 노드 중에서 가장 최단 거리가 짧은 노드를 선택'하는 과정을 반복한다. 그래서 최적의 해답을 구할 수 있다.

 

다익스트라 알고리즘 구현

1. 첫번째 방법

import sys
input = sys.stdin.readline
INF = int(1e9)#무한

#노드의 개수, 간선의 개수 입력 받기
n,m = map(int,input().split())
#시작 노드
start = int(input())
#각 노드에 연결되어 있는 노드에 대한 정보 담는 리스트 만들기
graph=[[] for i in range(n+1)]
#방문 리스트
visited = [False]*(n+1)
#최단 거리 테이블 무한으로 초기화
distance = [INF]*(n+1)
#모든 간선 정보 입력받기
for _ in range(m):
    a,b,c = map(int,input().split())
    #a번 노드에서 b번 노드로 가는 비용이 c
    graph[a].append((b,c))

#방문하지 않은 노드 중에서, 가장 최단 거리가 짧은 노드의 번호를 반환
def get_smallest_node():
    min_value = INF
    index =0 #가장 최단 거리가 짧은 노드
    for i in range(1,n+1):
        if distance[i]<min_value and not visited[i]:
            min_value = distance[i]
            index = i
    return index

def dijkstra(start):
    #시작 노드 초기화
    distance[start]=0
    visited[start]=True
    for j in graph[start]:
        distance[j[0]]=j[1]
    #시작 노드를 제외한 전체 n-1개의 노드에 대해 반복
    for i in range(n-1):
        #현재 최단 거리가 가장 짧은 노드를 꺼내서, 방문 처리
        now = get_smallest_node()
        visited[now]=True
        #현재 노드와 연결된 다른 노드를 확인
        for j in graph[now]:
            cost = distance[now]+j[1]
            #현재 노드를 거쳐서 다른 노드로 이동하는 거리가 더 짧은 경우
            if cost<distance[j[0]]:
                distance[j[0]]=cost
#다익스트라 알고리즘 실행
dijkstra(start)

#모든 노드로 가기 위한 최단 거리를 출력
for i in range(1,n+1):
    #도달할 수 없는 경우, 무한(INFINITY)출력
    if distance[i]==INF:
        print("INFINITY")
    #도달할 수 있는 경우 거리를 출력
    else:
        print(distance[i])

-첫번째 다익스트라 알고리즘의 시간 복잡도 : O(N^2)

다익스트라 알고리즘의 코드를 보면 전체 노드에서 for문을 돌릴때 get_smallest_node()함수를 계속 사용한다. 

get_smallest_node()함수에서는 전체 노드에서 제일 거리가 짧은 노드를 찾기 때문에 O(V)번 걸린다. 결국 시간복잡도는 O(V^2)이 된다. 따라서 노드의 개수가 10000개를 넘어가면 문제를 해결할 때 이 방법을 쓰면 시간 초과가 날 수 있다.

 

2. 두번째 방법

-두번째 다익스트라 알고리즘의 시간 복잡도 : O(MlogN)

이 방법은 최악의 경우에도 O(MlogN)을 보장하여 해결할 수 있다. 여기서 N은 노드의 개수, M은 간선의 개수에 해당한다. 첫번째 알고리즘에서 최단 거리 노드를 하나하나씩 탐색했다면 이 과정에서 시간을 단축하여 알고리즘을 개선하였다. 여기서는 힙 자료구조를 사용한다. 힙 자료구조를 이용하게 되면 특정 노드까지의 최단 거리에 대한 정보를 힙에 담아서 처리하므로 출발 노드로부터 가장 거리가 짧은 노드를 더욱 빠르게 찾을 수 있다. 이 과정에서 로그 시간이 걸린다.

 

//힙 관련

jobdong7757.tistory.com/69

 

힙 자료구조

힙과 우선순위 큐 자료구조 힙(heap): 힙 자료구조는 완전 이진 트리의 일종으로 우선순위 큐(Prioirty Queue)를 위하여 만들어진 자료구조이다. 힙을 사용한 모듈은 heapq 모듈이다. 파이썬은 최소 힙

jobdong7757.tistory.com

데이터의 개수가 N개일 때, 힙 자료구조에 N개의 데이터를 모두 넣은 뒤에 다시 모든 데이터를 꺼낸다고 해보자. 이때의 시간 복잡도는 어떻게 될가? 삽입할 때는 O(logN)의 연산을 N번 반복하므로 O(NlongN)이고 삭제할 때에서 O(logN)의 연산을 N번 반복하므로 O(NlogN)이다. 따라서 전체 연산 횟수는 대략 2NlogN으로 시간복잡도는 O(NlogN)이 될 것이다. 

 

최소 힙을 이용하는 경우 힙에서 원소를 꺼내면 '가장 값이 작은 원소'가 추출되는 특징이 있으며, 파이썬의 우선순위 큐라이브러리는 최소 힙에 기반한다. 우선순위 큐를 이용해서 시작 노드로부터 '거리'가 짧은 노드 순서대로 큐에서 나올 수 잇도록 다익스트라 알고리즘을 작성하면 된다. 

 

1번 방법에서 현재 가장 가까운 노드를 저장하기 위한 목적으로만 우선순위 큐를 추가로 이용한다고 보면 된다.  파이썬의 heapq 라이브러리는 원소로 튜플을 입력받으면 튜플의 첫 번째 원소를 기준으로 우선순위 큐를 구성한다. 따라서 (거리, 노드 번호) 순서대로 튜플 데이터를 구성해 우선순위 큐에 넣으면 거리순으로 정렬된다.

 

import heapq
import sys
input = sys.stdin.readline
INF = int(1e9)
#노드의 개수, 간선의 개수 입력받기
n,m = map(int,input().split())
#시작 노드 번호를 입력받기
start = int(input())
#각 노드에 연결되어 있는 노드에 대한 정보를 담는 리스트 만들기
graph=[[] for i in range(n+1)]
#최단 거리 테이블을 모두 무한으로 초기화
distance=[INF]*(n+1)

#모든 간선 정보를 입력받기
for _ in range(m):
    a,b,c = map(int,input().split())
    #a번 노드에서 b번 노드로 가는 비용이 c
    graph[a].append((b,c))

def dijkstra(start):
    q=[]
    #시작 노드로 가기 위한 최단 경로는 0으로 설정하여, 큐에 삽입
    heapq.heappush(q,(0,start))
    distance[start]=0
    while q:#큐가 비어있지 않다면
        #가장 최단 거리가 짧은 노드에 대한 정보 꺼내기
        dist,now = heapq.heappop(q)
        #현재 노드가 이미 처리된 적이 있는 노드라면 무시
        if distance[now]<dist:
            continue
        #현재 노드와 연결된 다른 인접한 노드들을 확인
        for i in graph[now]:
            cost = dist + i[1]
            #현재 노드를 거쳐서, 다른 노드로 이동하는 거리가 더 짧은 경우
            if cost < distance[i[0]]:
                distance[i[0]] = cost
                heapq.heappush(q,(cost,i[0]))
#다익스트라 알고리즘 수행
dijkstra(start)

#모든 노드로 가기 위한 최단 거리 출력
for i in range(1,n+1):
    if distance[i]==INF:
        print("INFINITY")
    else:
        print(distance[i])
728x90
반응형

'알고리즘 > 알고리즘 이론' 카테고리의 다른 글

힙 자료구조  (0) 2021.05.04
DFS / BFS  (0) 2021.04.12
정렬(Sort)  (0) 2021.04.12
재귀 함수  (0) 2021.04.02
브루트 포스 (Brute Force)  (0) 2021.03.18
728x90
반응형

힙과 우선순위 큐

자료구조 힙(heap):

힙 자료구조는 완전 이진 트리의 일종으로 우선순위 큐(Prioirty Queue)를 위하여 만들어진 자료구조이다. 힙을 사용한 모듈은 heapq 모듈이다. 파이썬은 최소 힙만 구현되어 있다. 최소 힙은 부모가 항상 자식보다 작기 때문에 루트가 결국 가장 작은 값을 갖게 되며, 우선순위 큐에서 가장 작은 값을 추출하는 것은 매번 힙의 루트를 가져오는 형태로 구현된다. 

우선순위 큐(Prioirty Queue):

우선순위 큐는 우선순위가 가장 높은 데이터를 가장 먼저 삭제한다는 점이 특징이다. 우선순위 큐는 데이터를 우선순위에 따라 처리하고 싶을 때 사용한다. 여러 개의 값들 중에서 최대값이나 최솟값을 빠르게 찾아내도록 만들어진 자료구조이다. 대표적으로 최댓값 추출을 보면 예를 들어 큐에 [1, 4, 5, 3, 2] 가 들어 있고 최댓값을 추출하는 우선순위 큐가 있다고 가정하자. 이 경우 항상 남아 잇는 요소들의 최댓값이 우선순위에 따라 추출되어 5, 4, 3, 2, 1 순으로 추출된다. 

 

힙의 특징

힙에서 착각하기 쉬운 특징은, 힙은 정렬된 구조가 아니라는 점이다. 최소 힙의 경우 부모 노드가 항상 작다는 조건만 만족할 뿐, 서로 정렬되어 있지 않다. 예를 들어 오른쪽의 자식 노드가 더 커야될 것 같지만 왼쪽 노드보다 작은 경우도 얼마든지 있을 수 있다. 부모, 자식 간의 관계만 정의할 뿐, 좌우에 대한 관계는 정의하지 않기 때문이다. 

 

자식이 둘인 힙은 특별히 이진 힙(Binary Heap)이라 하며, 대부분은 이진 힙이 널리 사용된다. 힙을 저장하는 표준적인 자료구조는 배열이다. 구현을 쉽게 하기 위해 첫 번째 인덱스인 0은 사용되지 않는다. 힙은 여러 알고리즘에서 사용되는데 우선 순위 큐 뿐만 아니라 다익스트라 알고리즘, 최소 신장 트리를 구현하는 프림 알고리즘 등에도 활용된다. 

 

728x90
반응형

'알고리즘 > 알고리즘 이론' 카테고리의 다른 글

최단경로 (다익스트라)  (0) 2021.05.04
DFS / BFS  (0) 2021.04.12
정렬(Sort)  (0) 2021.04.12
재귀 함수  (0) 2021.04.02
브루트 포스 (Brute Force)  (0) 2021.03.18
728x90
반응형

탐색Search이란 많은 양의 데이터 중에서 원하는 데이터를 찾는 과정을 의미한다. 대표적인 탐색 알고리즘이 DFS와 BFS이다. 프로그래밍에서는 자료구조 안에서 탐색을 하는데 DFS와 BFS를 알려면 스택과 큐에 관한 자료구조를 알아야된다.

 

스택

스택Stack은 레고 쌓기에 비유할 수 있다. 레고는 아래에서부터 위로 차곡차곡 쌓는다. 맨 아래에 레고를 빼기 위해서는 위에 레고를 빼야 된다. 즉, 먼저 쌓은 것이 제일 나중에 뺄 수 있다. 선입후출(First In Last Out) 또는 후입선출(Last In First Out) 구조 라고 한다. 

 

stack=[]
#삽입(1) - 삭제() - 삽입(3) - 삽입(5) - 삽입(6) - 삭제() - 삽입(2) - 삭제()
stack.append(1)
stack.pop()
stack.append(3)
stack.append(5)
stack.append(6)
stack.pop()
stack.append(2)
stack.pop()
print(stack)#[3,5]
print(stack[::-1])#[5,3]

 

append() 메서드는 리스트의 가장 뒤쪽에 데이터를 삽입하고, pop() 메서드는 리스트의 가장 뒤쪽에서 데이터를 꺼낸다.


 

큐Queue는 대기 줄에 비유할 수 있다. 우리가 놀이공원에 입장하기 위해 줄을 설 때, 먼저 온 사람이 먼저 들어가게 된다. 나중에 온 사람이 나중에 들어가기 때문에 흔히 '공정한' 자료구조라고 비유된다. 이러한 구조를 선입선출(First In First Out)라고 한다.

 

from collections import deque
queue=deque()
#삽입(4) - 삽입(2) - 삽입(1) - 삽입(8) - 삭제() - 삭제() - 삽입(4)
queue.append(4)
queue.append(2)
queue.append(1)
queue.append(8)
queue.popleft()
queue.popleft()
queue.append(4)

print(queue)
queue.revers()
print(queue)

 

파이썬으로 큐를 구현할 때는 collections 모듈에서 제공하는 deque 자료구조를 활용한다. deque는 스택과 큐의 장점을 모두 채택한 것인데 데이터를 넣고 빼는 속도가 리스트 자료형에 비해 효율적이며 queue 라이브러리를 이용하는 것보다 더 간단하다.

deque객체를 자료형으로 변경하고자 한다면 list(queue)를 사용하면 리스트 자료형이 반환된다.


DFS

DFS는 Depth-First Search, 깊이 우선 탐색이라고도 부르며, 그래프에서 깊은 부분을 우선적으로 탐색하는 알고리즘이다. DFS를 알려면 그래프의 표현 방식을 알아야 된다. 

 

그래프는 2가지 방식으로 표현할 수 있는데 인접 행렬 방식과 인접 리스트 방식이 있다.

인접 행렬 방식은 2차원 배열로 그래프의 연결 관계를 표현하는 방식이고 인접 리스트 방식은 리스트로 그래프의 연결 관계를 표현하는 방식이다. 

 

인접 행렬 방식은 2차원 배열에 각 노드가 연결된 형태를 기록하는 방식이다. 연결이 되어 있지 않은 노드끼리는 무한Infinity의 비용이라고 작성한다. 

 

인접 행렬 방식 예제

INF = 9999999999

graph = [
    [0,7,5],
    [7,0,INF],
    [5,INF,0]
]

print(graph)

[[0,7,5],[7,0,99999999],[5,999999999,0]]

 

 인접 리스트 방식에서는 모든 노드에 연결된 노드에 대한 정보를 차례대로 연결하여 저장한다. C++이나 Java와 같은 프로그래밍 언어에서는 별도로 연결 리스트 기능을 위한 표준 라이브러리를 제공한다.

 반면에 파이썬은 기본 자료형인 리스트 자료형이 append()와 메소드를 제공하므로, 전통적인 프로그래밍 언어에서의 배열과 연결 리스트의 기능을 모두 기본으로 제공한다. 파이썬으로 인접 리스트를 이용해 그래프를 표현하고자 할 때에도 단순히 2차원 리스트를 이용하면 된다. 

 

# 행(ROW)이 3개인 2차원 리스트로 인접 리스트 표현
graph = [[] for _ in range(3)]

#노드 0에 연결된 노드 정보 저장(노드, 거리)
graph[0].append((1,7))
graph[0].append((2,5))

#노드 1에 연결된 노드 정보 저장(노드, 거리)
graph[1].append((0,7))

#노드 2에 연결된 노드 정보 저장(노드, 거리)
graph[2].append((0,5))

print(graph)

 

DFS는 어떻게 작동할까? DFS는 깊이 우선 탐색 알고리즘이다. 이 알고리즘은 특정한 경로로 탐색하다가 특정한 상황에서 최대한 깊숙이 들어가서 노드를 방문한 후, 다시 돌아가 다른 경로로 탐색하는 알고리즘이다. 

 

1. 탐색 시작 노드를 스택에 삽입하고 방문 처리를 한다.

2. 스택의 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 인접 노드를 스택에 넣고 방문 처리를 한다. 방문하지 않은 인접 노드가 없으면 스택에서 최상단 노드를 꺼낸다. 

3. 2번의 과정을 더 이상 수행할 수 없을 때까지 반복한다.

 

방문 처리는 스택에 한 번 삽입되어 처리된 노드가 다시 삽입되지 않게 체크하는 것을 의미한다. 방문 처리를 함으로써 각 노드를 한 번씩만 처리할 수 있다.

 

깊이 우선 탐색 알고리즘인 DFS는 스택 자료구조에 기초한다는 점에서 구현이 간단하다. 데이터의 개수가 N개인 경우 O(N)의 시간이 소요된다는 특징이 있다. 

 

또한 DFS는 스택을 이용하는 알고리즘이기 때문에 실제 구현은 재귀 함수를 이용했을 때 매우 간결하게 구현할 수 있다. 

 

def dfs(graph, v, visited):
    #현재 노드를 방문 처리
    visited[v]=True
    print(v,end=" ")
    #현재 노드와 연결된 다른 노드를 재귀적으로 방문
    for i in graph[v]:
        if not visited[i]:
            dfs(graph,i,visited)


#각 노드가 연결된 정보를 리스트 자료형으로 표현(2차원 리스트)
graph= [
    [],
    [2,3,8],
    [1,7],
    [1,4,5],
    [3,5],
    [3,4],
    [7],
    [2,6,8],
    [1,7]
]

#각 노드가 방문된 정보를 리스트 자료형으로 표현
visited = [False]*9

#정의된 DFS 함수 호출
dfs(graph,1,visited)

#1 2 7 6 8 3 4 5

BFS

BFS(Breadth First Search) 알고리즘은 '너비 우선 탐색'이라는 의미를 가진다. BFS는 가까운 노드부터 탐색하는 알고리즘이다. DFS는 최대한 멀리 있는 노드를 우선으로 탐색하는 방식으로 동작한다고 했는데, BFS는 그 반대로 가까이 있는것부터 차근차근 탐색해나간다. 

 

BFS 구현에서는 선입선출 방식인 큐 자료구조를 이용한다. 인접한 노드를 반복적으로 큐에 넣어서 가까운 노드부터 탐색해서 탐색할 수 있을때까지 찾아나가는 것이다.

 

1. 탐색 시작 노드를 큐에 삽입하고 방문 처리를 한다.

2. 큐에서 노드를 꺼내 해당 노드의 인접 노드 중에서 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리를 한다.

3. 2번의 과정을 더 이상 수행할 수 없을 때까지 반복한다.

 

BFS는 큐 자료구조에 기초한다. 구현함에 있어 deque라이브러리를 사용하는 것이 좋으며 탐색을 수행함에 있어 O(N)의 시간이 소요된다. 일반적인 경우 실제 수행 시간은 DFS보다 좋은 편이다.

 

from collections import deque

graph=[
    [],
    [2,3,8],
    [1,7],
    [1,4,5],
    [3,5]
]
visited=[False]*9

def bfs(graph,start,visited):
    queue=deque([start])
    visited[start]=True
    while queue:
        #큐에서 하나의 원소를 뽑아 출력
        v=queue.popleft()
        print(v,end=' ')
        #해당 원소와 연결된, 아직 방문하지 않은 원소들을 큐에 삽입
        for i in graph[v]:
            if not visited[i]:
                queue.append(i)
                visited[i]=True

 

2차원 배열에서의 탐색 문제를 만나면 그래프 형태로 바꿔서 생각하면 풀이 방법을 조금 더 쉽게 떠올릴 수 있다.

728x90
반응형

'알고리즘 > 알고리즘 이론' 카테고리의 다른 글

최단경로 (다익스트라)  (0) 2021.05.04
힙 자료구조  (0) 2021.05.04
정렬(Sort)  (0) 2021.04.12
재귀 함수  (0) 2021.04.02
브루트 포스 (Brute Force)  (0) 2021.03.18
728x90
반응형

정렬Sorting

이란 데이터를 특정한 기준에 따라서 순서대로 나열하는 것을 말한다. 정렬을 이용해서 이진탐색도 가능해지는 것이다.

 

선택 정렬(Selection Sort) 

: 가장 먼저 생각이 날 수 있는 자연스러운 정렬 알고리즘

  각 위치에 어떤 값이 들어갈지 찾는다.

 

가장 작은 값을 찾아서 0번 인덱스에 놓고, 두 번째로 작은 값을 찾아서 1번 인덱스에 놓고, 세 번째로 작은 값을 찾아서 2번 인덱스에 놓고.....

즉, 데이터가 무작위로 여러 개 있을 때, 이중에서 가장 작은 데이터를 맨 앞에 있는 데이터와 바꾸고, 그 다음 작은 데이터를 앞에서 2번째 데이터와 바꾸는 과정을 반복하자.

array = [2,4,5,1,3,9,7,8]

for i in range(len(array)):
    min_index=i
    for j in range(i+1,len(array)):
        if array[min_index]>array[j]:
            min_index=j
    array[i],array[min_index]=array[min_index],array[i]

print(array)

선택 정렬의 시간 복잡도는 N-1번 만큼 가장 작은 수를 찾아서 맨 앞으로 보내야 한다. 또한 매번 가장 작은 수를 찾기 위해서 비교 연산이 필요하다. 연산 횟수는 N+(N-1)+(N-2) ... 이렇기 때문에 O(N^2)시간복잡도가 나온다.


삽입 정렬(Insertion Sort)

: 각 값이 어떤 위치에 들어갈지 찾는다

 

정렬 문제의 경우 "절대적인 좋은 답"이란 없다. 상황에 따른 각 알고리즘의 장단점을 파악해야 올바른 알고리즘을 선택할 수 있다. 

삽입 정렬은 선택 정렬처럼 동작 원리를 직관적으로 이해하기 쉽다. 삽입 정렬은 필요할 때만 위치를 바꾸므로 '데이터가 거의 정렬되어 있을 때' 훨씬 효율적이다. 선택 정렬은 현재 데이터의 상태와 상관없이 무조건 모든 원소를 비교하고 위치를 바꾸는 반면 삽입 정렬은 그렇지 않다. 

 

삽입 정렬은 특정한 데이터가 적절한 위치에 들어가기 이전에, 그 앞까지의 데이터는 이미 정렬되어 있다고 가정한다. 정렬되어 있는 데이터 리스트에서 적절한 위치를 찾은 뒤에, 그 위치에 삽입된다.

array = [7,5,9,0,3,1,2,8,6]

for i in range(1,len(array)):
    for j in range(i,0,-1):#인덱스 i부터 1까지 감소하며 반복하는 문법
        if array[j]<array[j-1]:
            array[j],array[j-1]=array[j-1],array[j]
        else:
            break
print(array)

 

삽입 정렬의 시간복잡도는 O(N^2)이다. 그러나 최선의 경우 O(N)의 시간 복잡도를 가진다. 모두 정렬되어있으면 O(N)의 시간복잡도를 가진다.


합병 정렬(Merge Sort)

 Divide and Conquer에서 대표적으로 설명하는 정렬 방식이다.

 Divide에서는 리스트를 왼쪽 반, 오른쪽 반으로 나눈다. 왼쪽 반을 정렬하는 부분 문제랑 오른쪽 반을 정렬하는 부분 문제로 나누어 주는 것이다.

 Conquer 단계에서는 왼쪽 리스트와 오른쪽 리스트를 각각 정렬해 준다.

 Combine 단계에서는 정렬된 두 리스트를 하나의 정렬된 리스트로 합병한다.

 

두 리스트를 하나로 Combine 할 때가 중요하다. 이미 정렬되었다고 가정하였을때 왼쪽이 제일 작게 정렬했다고 생각하자. 그럼 왼쪽과 오른쪽의 첫번째 값을 비교해 주어서 더 작은 것을 Combined된 리스트에 넣어준다.

 계속 하나씩 작은것부터 차례대로 넣어주면 한쪽 리스트만 남을거이다. 이것은 이미 정렬되어 있는 것이기 때문에 남은 것을 왼쪽부터 넣으면 된다. 이렇게 하면 하나의 정렬된 리스트가 된다.

 

def merge(list1, list2):
    i = 0
    j = 0

    # 정렬된 항목들을 담을 리스트
    merged_list = []

    # list1과 list2를 돌면서 merged_list에 항목 정렬
    while i < len(list1) and j < len(list2):
        if list1[i] > list2[j]:
            merged_list.append(list2[j])
            j += 1
        else:
            merged_list.append(list1[i])
            i += 1

    # list2에 남은 항목이 있으면 정렬 리스트에 추가
    if i == len(list1):
        merged_list += list2[j:]

    # list1에 남은 항목이 있으면 정렬 리스트에 추가
    elif j == len(list2):
        merged_list += list1[i:]

    return merged_list

# 테스트
print(merge([1],[]))
print(merge([],[1]))
print(merge([2],[1]))
print(merge([1, 2, 3, 4],[5, 6, 7, 8]))
print(merge([5, 6, 7, 8],[1, 2, 3, 4]))
print(merge([4, 7, 8, 9],[1, 3, 6, 10]))
[1]
[1]
[1, 2]
[1, 2, 3, 4, 5, 6, 7, 8]
[1, 2, 3, 4, 5, 6, 7, 8]
[1, 3, 4, 6, 7, 8, 9, 10]

 

결론적으로 합병 정렬을 재귀적으로 구하면 다음과 같다.

def merge(list1, list2):
    merged_list=[]
    index1=0
    index2=0
    while index1<len(list1) and index2<len(list2):
        if list1[index1]<list2[index2]:
            merged_list.append(list1[index1])
            index1+=1
        else:
            merged_list.append(list2[index2])
            index2+=1
    merged_list=merged_list+list1[index1:]+list2[index2:]
    return merged_list
# 합병 정렬
def merge_sort(my_list):
    return merge(merge_sort(my_list[:len(my_list)//2]),merge_sort(my_list[len(my_list)//2:]))
# 테스트
print(merge_sort([1, 3, 5, 7, 9, 11, 13, 11]))
print(merge_sort([28, 13, 9, 30, 1, 48, 5, 7, 15]))
print(merge_sort([2, 5, 6, 7, 1, 2, 4, 7, 10, 11, 4, 15, 13, 1, 6, 4]))

퀵 정렬 (Quick Sort)

Divide and Conquer를 사용하는 또다른 정렬방법은 퀵 정렬(Quicksort)이다. 합병 정렬은 사실 Divide랑 Conquer단계는 생각하기 쉬우나 Combine이 어렵다. 퀵 정렬은 완전히 반대로 Divide과정이 되게 어렵다. 


 

1. Divide 

 

Partition에는 2단계가 있다. 먼저 pivot을 정해주는 것이다. 두 번째 단계는 이 pivot을 기준으로 값들을 새롭게 배치하는 것이다. pivot을 기준으로 더 작은 값들은 왼쪽으로, 더 큰 값들은 오른쪽으로 오게 하는 것이다. 

 

2. Conquer 

 

pivot 왼쪽과 pivot 오른쪽을 정렬해 준다. 그리고 왼쪽과 오른쪽을 재귀적으로 sort해주면 된다.

 

3. Combine

 

할 일이 없다


 

Divide 부분을 다시 한 번 살펴보자. 일단 partition함수는 parameter를 3개 받는다.

def partition(my_list,start,end):

 

 

 

변수를 3개 두어야 된다. start, big_index, i, pivot. start는 처음 인덱스, b는 pivot값보다 큰 인덱스, i는 계속 진행해가는 인덱스이다. 

 

아래는 Divide과정인 Partition 하는 과정을 담고 있다.

 

# 두 요소의 위치를 바꿔주는 helper function
def swap_elements(my_list, index1, index2):
    # 코드를 작성하세요.
    my_list[index1],my_list[index2]=my_list[index2],my_list[index1]

# 퀵 정렬에서 사용되는 partition 함수
def partition(my_list, start, end):
    # 코드를 작성하세요.
    pivot = end
    big_index=0
    for i in range(len(my_list)-1):
        if my_list[i]>my_list[pivot]:
            continue
        else:
            swap_elements(my_list,i,big_index)
            big_index+=1
    swap_elements(my_list,pivot,big_index)
    pivot=big_index
    return pivot

# 테스트 1
list1 = [4, 3, 6, 2, 7, 1, 5]
pivot_index1 = partition(list1, 0, len(list1) - 1)
print(list1)
print(pivot_index1)

# 테스트 2
list2 = [6, 1, 2, 6, 3, 5, 4]
pivot_index2 = partition(list2, 0, len(list2) - 1)
print(list2)
print(pivot_index2)

 

partition을 했으면 merge_sort처럼 나머지를 재귀함수를 통해서 구하면 된다.

# 두 요소의 위치를 바꿔주는 helper function
def swap_elements(my_list, index1, index2):
    my_list[index1], my_list[index2] = my_list[index2], my_list[index1]
# 퀵 정렬에서 사용되는 partition 함수
def partition(my_list, start, end):
    i = start
    pivot = end
    big_index = start
    while i < pivot:
        if my_list[i] <= my_list[pivot]:
            swap_elements(my_list, i, big_index)
            big_index += 1
        i += 1
    swap_elements(my_list, big_index, pivot)
    pivot = big_index
    return pivot
# 퀵 정렬 (start, end 파라미터 없이도 호출이 가능하도록 수정해보세요!)
def quicksort(my_list,start=0,end=None):
    if end==None:
        end=len(my_list)-1
    if end-start<1:
        return
    pivot = partition(my_list, start, end)
    quicksort(my_list,start,pivot-1)
    quicksort(my_list,pivot+1,end)
# 테스트 1
list1 = [1, 3, 5, 7, 9, 11, 13, 11]
quicksort(list1) # start, end 파라미터 없이 호출
print(list1)

# 테스트 2
list2 = [28, 13, 9, 30, 1, 48, 5, 7, 15]
quicksort(list2) # start, end 파라미터 없이 호출
print(list2)

# 테스트 3
list3 = [2, 5, 6, 7, 1, 2, 4, 7, 10, 11, 4, 15, 13, 1, 6, 4]
quicksort(list3) # start, end 파라미터 없이 호출
print(list3)


728x90
반응형

'알고리즘 > 알고리즘 이론' 카테고리의 다른 글

힙 자료구조  (0) 2021.05.04
DFS / BFS  (0) 2021.04.12
재귀 함수  (0) 2021.04.02
브루트 포스 (Brute Force)  (0) 2021.03.18
다이나믹 프로그래밍  (0) 2021.03.08
728x90
반응형

재귀 함수란 자기자신을 다시 호출하는 함수를 의미한다.

 

def recursive_function():
	print("재귀 함수를 호출합니다")
    recursive_function()
    
recursive_function()

 

이 코드를 실행하면 '재귀 함수를 호출합니다' 라는 문자열을 무한히 출력한다. 여기서 정의한 recursive_function()이 자기 자신을 계속해서 추가로 불러오기 때문이다. 물론 어느 정도 출력하다가 오류 메시지를 출력하고 멈출 것이다. 

 

 재귀 함수를 쓸 때는 종료 조건이 중요하다. 다음 코드는 i가 100이 될 때 종료한다.

 

def recursive_function(i):
	if i==100:
    return
    print(i)
    recursive_function(i+1)
   
recursive_function(1)

 

컴퓨터 내부에서 재귀 함수의 수행은 스택 자료구조를 사용한다. 함수를 계속 호출했을 때 가장 마지막에 호출한 함수가 먼저 수행을 끝내야 그 앞의 함수 호출이 종료되기 때문이다. 

 

따라서 스택 자료구조를 활용해야 하는 상당수 알고리즘은 재귀 함수를 이용해서 간편하게 구현될 수 있다. DFS가 대표적인 예이다. 

 

재귀 함수에 접근하기 위해서는 2가지 case를 고려해야 한다. 재귀 함수에는 recursive case 와 base case가 있다.

Recursive case : 현 문제가 너무 커서, 같은 형태의 더 작은 부분 문제를 재귀적으로 푸는 경우

Base case : 이미 문제가 충분히 작아서, 더 작은 부분 문제로 나누지 않고도 바로 답을 알 수 있는 경우

 

예를 들어, some_list라는 배열을 파라미터로 받고, 뒤집힌 리스트를 리턴해 주는 재귀 함수를 만들어보자.

 

먼저 Base case를 생각해보자. Base case는 some_list의 길이가 1이거나 0인 경우이다. Base Case가 종료 조건이라고 생각하면 된다.

 

이때는 아무 조치없이 some_list 자체를 그냥 반환하면 된다. 

 

def flip(some_list):
	#base case
    if len(some_list)==0 or len(some_list)==1:
    	return some_list

 

그리고 현재 문제가 너무 커서 바로 풀지 못하는 경우가 recursive case이다. some_list가 1보다 큰 경우에는 바로 답을 알지 못하기 때문에 부분 문제를 풀어야 된다.

 

def flip(some_list):
    if len(some_list)==0 or len(some_list)==1:
        return some_list
    return some_list[-1:]+flip(some_list[:-1])

 

시간복잡도는 some_list의 길이를 n이라고 하면 some_list[:-1]은 시간 복잡도가 O(n-1)이다.

flip함수는 재귀적으로 n번 실행되고 각 호출은 O(n)이기 때문에 시간 복잡도는 O(n^2)이다.

 

728x90
반응형

'알고리즘 > 알고리즘 이론' 카테고리의 다른 글

DFS / BFS  (0) 2021.04.12
정렬(Sort)  (0) 2021.04.12
브루트 포스 (Brute Force)  (0) 2021.03.18
다이나믹 프로그래밍  (0) 2021.03.08
알고리즘 평가  (0) 2021.03.05
728x90
반응형

 Brute-Force Attack은 우리말로 무차별 대입 공격이란 뜻이다. 이 말은 정말 무차별적으로 모든 방법을 시도하는 것을 뜻한다. Brute-Force 알고리즘도 비슷하다.

 예를 들어 자물쇠 0~99까지 가능하다면 0부터 99까지 모든 경우의 수를 시도하는 것이다. 힘들긴 하겠지만 시간만 있다면 가능한 것이다. 브루트 포스는 말 그대로 가장 순진한 알고리즘 접근법이다. 가능한 모든 조합을 찾아서 계산하면 되는 것이다.

 

 브루트 포스 알고리즘은 모든 경우의 수를 봐서 확실한 답을 구한다는 장점이 있지만 알고리즘 자체는 비효율적이다. 특히, input이 클수록 더 비효율적이라는 생각이 들것이다. 

 

 브루트 포스는 직관적이고 명확하고 답을 확실하게 찾을 수 있다는 장점이 있다. 그래서 효율적인 알고리즘의 첫 시작은 브루트 포스이기때문에 알고리즘을 배울 필요가 있는 것이다.

 

아래에 있는 브루트 포스 알고리즘의 예시를 보자.

 

[문제 설명]

카드 두 뭉치가 있습니다.

왼쪽 뭉치에서 카드를 하나 뽑고 오른쪽 뭉치에서 카드를 하나 뽑아서, 두 수의 곱이 가장 크게 만들고 싶은데요. 어떻게 하면 가장 큰 곱을 구할 수 있을까요?

 

함수 max_product는 리스트 left_cards와 리스트 right_cards를 파라미터로 받습니다.

left_cards는 왼쪽 카드 뭉치의 숫자들, right_cards는 오른쪽 카드 뭉치 숫자들이 담겨 있고, max_product는 left_cards에서 카드 하나와 right_cards에서 카드 하나를 뽑아서 곱했을 때 그 값이 최대가 되는 값을 리턴합니다.

 

def max_product(left_cards, right_cards):
	print(max_product([1, 6, 5], [4, 2, 3]))
	print(max_product([1, -9, 3, 4], [2, 8, 3, 1]))
	print(max_product([-1, -7, 3], [-4, 3, 6])) 

[풀이과정]

 브루트 포스 방법으로 for문을 2개 사용하여 풀면 된다. 처음에 max를 사용하여 left_cards 와 right_cards에서 하나씩 최대값을 뽑아서 곱했는데 그러면 음수와 음수의 곱이 최대가 될 경우를 생각못할 수 있다. 

그래서 일일이 다 구해서 최대값을 구하면 된다.

[해답]

def max_product(left_cards, right_cards):
    bigg=0
    for left_card in left_cards:
        for right_card in right_cards:
            wow = left_card*right_card
            if wow>bigg:
                bigg=wow
    return bigg
# 테스트
print(max_product([1, 6, 5], [4, 2, 3]))
print(max_product([1, -9, 3, 4], [2, 8, 3, 1]))
print(max_product([-1, -7, 3], [-4, 3, 6]))

 

#Tip#

3중 반복문에서 3번째 반복문을 실행 후 2번째 반복문을 다시 실행하는 과정을 재귀로 구현한 코드에서 현재 실행 중인 함수를 호출한 곳으로 돌아가는 과정으로 비유하면 백트래킹 기법을 적용했다고 한다. 백트래킹을 브루트 포스를 수행하는 방법 중 하나로 본다.

728x90
반응형

'알고리즘 > 알고리즘 이론' 카테고리의 다른 글

정렬(Sort)  (0) 2021.04.12
재귀 함수  (0) 2021.04.02
다이나믹 프로그래밍  (0) 2021.03.08
알고리즘 평가  (0) 2021.03.05
[알고리즘] 그리디(Greedy)  (0) 2020.10.02
728x90
반응형

 컴퓨터를 이용하여 최적의 해를 구하는데 메모리 공간에는 한계가 있다. 그래서 우리는 메모리 공간을 최대한으로 활용할 수 있는 효율적인 알고리즘을 작성해야 한다.

 

 반대로, 다이나믹 프로그래밍은 메모리 공간을 약간 더 사용하여 연산 속도를 증가시키는 방법이다. 다이나믹 프로그래밍은 탑다운과 보텀업 두가지 방식으로 나뉘다. 그리고 메모이제이션 기법까지 알아야 된다.

 

 피보나치 수열은 다이나믹 프로그래밍의 대표적인 예이다. 점화식으로 표현하여 푸는 것을 다이나믹 프로그래밍의 문제라고 생각하면 된다. 

 

def fibo(x):
	if x==1 or x==2 :
    	return 1
    return fibo(x-1)+fibo(x-2)

다음과 같이 코드를 짜면 fibo(x)에서 x에 값이 중복되면 계속 새로 계산해야된다는 단점이 있다. 그렇다면 시간복잡도가 기하급수적으로 늘어난다. 따라서 다이나믹 프로그래밍을 통해 문제를 해결한다. 다이나믹 프로그래밍을 항상 사용할 수 수는 없고 다음 조건을 만족할 때 사용할 수 있다.

 

1. 큰 문제를 작은 문제로 나눌 수 있다.

2. 작은 문제에서 구한 정답은 그것을 포함하는 큰 문제에서도 동일하다.

 

피보나치 수열은 다음과 같은 조건을 만족한다. 이 문제를 메모이제이션(Memoization)기법을 사용해서 해결할 수 있다.

 

아래는 탑다운 방식이다.

#한 번 계산된 결과를 메모이제이션하기 위한 리스트 초기화

d = [0]*100

def fibo(x):
    if x==1 or x==2:
        retun 1
    if d[x]!=0:
        return d[x]
    d[x]=fibo(x-1)+fibo(x-2)
    return d[x]

이렇게 되면 시간복잡도가 O(N)이 된다.

 

 이처럼 재귀함수를 이용하여 다이나믹 프로그래밍 소스코드를 작성하는 방법을, 큰 문제를 해결하기 위하여 작은 문제를 호출한다고 하여 탑다운 방식이라고 말한다. 

 

 반면에 단순히 반복문을 이용하여 소스코드를 작성하는 경우 작은 문제부터 차근차근 답을 도출한다고 하여 보텀업 방식이라고 한다. 피보나치 수열 문제를 보텀업으로도 풀어보자.

 

d = [0]*100
d[1]=1
d[2]=1
n=99

for i in range(3,n+1):
    d[i]=d[i-1]+d[i-2]  

시스템상 재귀 함수의 스택 크기가 한정되어 있을 수 있으므로 가능하다면 재귀 함수를 이용하는 탑다운 방식보다는 보텀업 방식으로 구현하는 것을 권장한다.

728x90
반응형

'알고리즘 > 알고리즘 이론' 카테고리의 다른 글

정렬(Sort)  (0) 2021.04.12
재귀 함수  (0) 2021.04.02
브루트 포스 (Brute Force)  (0) 2021.03.18
알고리즘 평가  (0) 2021.03.05
[알고리즘] 그리디(Greedy)  (0) 2020.10.02
728x90
반응형

알고리즘은 시간과 공간에 따라 평가 된다. 공간이라고 하면 뭔가 내 방 같은 눈에 보이는 공간 느낌이라 이상할 수 있는데 공간은 컴퓨터의 메모리라고 생각하면 된다. 

 

둘 중 어느 것이 중요하냐고 물어보면 시간을 더 중요하게 여긴다. (알고리즘 문제를 풀더라도.. 보통 시간초과 문제에 머리를 싸멘다..)

메모리는 돈 주고 늘릴 수 있지만, 시간은 그렇지 않기 때문이다. 

 

 컴퓨터 사양, 프로그래밍 언어에 따라 속도에 차이가 있을 수 있으므로 단순히 프로그램이 돌아가는 시간으로 알고리즘을 평가하기는 어렵다. 그래서 시간복잡도를 이용해서 평가한다.

 

시간복잡도(Time Complexity)

시간복잡도의 사전적 정의는 어떤 알고리즘을 수행하는 데 걸리는 시간을 설명하는 계산 복잡도를 의미하며, 계산 복잡도를 표기하는 대표적인 방법이 바로 빅오다.

 

빅오로 시간복잡도를 표현할 때는 최고차항만을 표기하며, 상수항은 무시한다.

 

데이터가 많아질수록 걸리는 시간이 얼마나 증가하는가에 따라 결정한다. 시간복잡도를 평가하기위해서는 수학적 개념이 들어간다. 거듭제곱과 로그이다.

내가 보통 Python으로 알고리즘을 풀면, 시간제한은 1초, 2초, (함정으로 0.5초도 가끔 보인다.) 이다. 해당 문제를 해결하려면 시간복잡도를 항상 따져야 한다. 
1초라면 1억번의 연산을 할 수 있다고 생각하고 문제를 풀면 된다. 예를 들어, n이 10000보다 작다면 n제곱까지 고려를 할 수 있다. 시간제한과 n의 값의 범위를 보고 어떤 알고리즘을 사용할지 결정하는데 도움이 된다.

 

점근 표기법(Big -O Notation)

 

빅오(O, big-O)란 입력값이 무한대로 향할때 함수의 상한을 설명하는 수학적 표기 방법이다.

 

 알고리즘 소요시간은 input 크기에 따라 표현할 수 있다. 점근 표기법이란 소요시간이 20n +40이면 40은 없애주고 앞에 붙은 20도 없애줘서 O(n)으로 그냥 표기하는 것이다. 또 2n^2+8n+157이면 O(n^2)이 되는것이다. 

 

 점근 표기법의 핵심은 n이 엄청 크다고 가정하는 것이다. n이 별로 크지 않으면, 안 좋은 알고리즘을 써도 상관없다. n이 엄청 큰 경우 알고리즘의 중요함을 느낀다.

 

O(1) 

 

input사이즈에 영향을 받지 않는다. input크기와 상관 없이 실행되는 코드만 O(1)이다. 최고의 알고리즘이라 할 수 있다. O(1)에 실행되는 알고리즘으로 해시 테이블의 조회 및 삽입이 이에 해당한다.

 

 

O(logn)

 

로그는 매우 큰 입력값에도 크게 영향을 받지 않는편. 대표적으로 이진 검색이 이에 해당한다.

def print_powers(n) :
	i=1
    while i<n:
    	print(i)
        i=i*2

 

O(n)

 

입력값만큼 실행 시간에 영향을 받으며, 알고리즘을 수행하는 데 걸리는 시간은 입력값에 비례한다. 이러한 알고리즘을 선형 시간(Linear-Time) 알고리즘이라고 한다. 정렬되지 않은 리스트에서 최댓값 또는 최솟값 경우가 이에 해당한다. 이 값을 찾기 위해서는 모든 입력값을 적어도 한 번 이상은 살펴봐야 한다.

 

 

O(nlogn) : O(n)과 O(logn)이 겹쳐진 것

 

병합 정렬을 비롯한 대부분의 효율 좋은 정렬 알고리즘이 이에 해당한다. 비교 기반 정렬 알고리즘은 아무리 좋은 알고리즘도 O(nlogn) 보다 빠를 수 없다. 

def print_powers_of_two_repeatedly(n):
    for i in range(n): # 반복횟수: n에 비례
        j = 1
        while j < n: # 반복횟수: lg n에 비례
            print(i, j)
            j = j * 2

 

O(n^2) 

 

버블 정렬 같은 비효율적인 정렬 알고리즘이 이에 해당한다.

 

 

O(2^n)

 

피보나치 수를 재귀로 계산하는 알고리즘이 이에 해당한다. n^2보다 2^n이 훨씬 크다.

 

 

O(n!)

 

각 도시를 방문하고 돌아오는 가장 짧은 경로를 찾는 외판원 문제(TSP)를 브루트 포스로 풀이할 때. 가장 느린 알고리즘이다.

 

 

공간 복잡도

O(1) 

def product(a, b, c):
    result = a * b * c
    return result

result가 차지하는 메모리 공간은 인풋과 무관하기 때문

 

O(n)

def get_every_other(my_list):
    every_other = my_list[::2]
    return every_other

my_list의 길이가 n이라고 하면 변수 every_other이 공간을 차지한다. every_other에는 my_list의 짝수 인덱스의 값들이 복사돼서 들어간다. 약 n/2개의 값이 들어간다. O(n)이다. 

 

 

728x90
반응형

'알고리즘 > 알고리즘 이론' 카테고리의 다른 글

정렬(Sort)  (0) 2021.04.12
재귀 함수  (0) 2021.04.02
브루트 포스 (Brute Force)  (0) 2021.03.18
다이나믹 프로그래밍  (0) 2021.03.08
[알고리즘] 그리디(Greedy)  (0) 2020.10.02

+ Recent posts