<퀵 정렬>
- 불안정 정렬
- 다른 원소와의 바교만으로 정렬을 수행하는 비교정렬
- 분할 정복 알고리즘 --> 매우 빠른 수행속도
- 합병 정렬과 달리 리스트를 비균등하게 분할
- 분할정복방법
- 문제를 작은 2개의 문제로 분리하고 각각을 해결한 다음, 결과를 모아서 원래의 문제를 해결하는 전략
- 대개 순환 호출을 이용하여 구현
- 과정 설명
- 1. 리스트 안에 있는 한 요소를 선택. --> 피벗 이라고 한다.
- 2. 피벗을 중심으로 왼쪽 : 피벗보다 작은 요소들, 오른쪽 : 피벗보다 큰 요소들로 옮겨진다.
- 3. 피벗을 제외한 왼쪽 리스트와 오른쪽 리스트를 다시 정렬
- 분할된 부분 리스트에 대하여 순환호출을 이용하여 정렬을 반복
- 부분 리스트에서도 다시 피벗을 정하고 피벗을 기준으로 2개의 부분 리스트로 나누는 과정을 반복
- 4. 부분 리스트들이 더이상 분할이 불가능 할 때 까지 반복
- 하나의 리스트를 피벗을 기준으로 두 개의 비균등한 크기로 분할하고 분할된 부분 리스트를 정렬할 다음, 두 개의 정렬된 부분 리스트를 합하여 전체가 정렬된 리스트가 되게 하는 방법
- 퀵정렬의 단계
- 분할 : 입력 배열을 피벗을 기준으로 비균등하게 2개의 부분 배열로 분할
- 정복 : 부분 배열을 정렬. 부분 배열의 크기가 충분히 작지 않으면 순환호출을 이용하여 다시 분할 정복방법 적용
- 결합 : 정렬된 부분 배열들을 하나의 배열에 합병한다.
- 순환 호출이 한번 진행될 때마다 최소한 하나의 피벗을 최종적으로 위치가 정해지므로, 이 알고리즘으 납ㄴ드시 끝난다는 것을 보장할 수 있다.
- 장점
- 속도가 빠르다.
- 시간 복잡도가 O(nlog2n)를 가지는 다른 정렬 알고리즘과 비교했을 때 가장 빠르다.
- 추가 메모리 공간을 필요로 하지 않는다.
- 퀵 정렬은 O(logn)만큼의 메모리를 필요로 한다.
- 속도가 빠르다.
- 단점
- 정렬된 리스트에 대해서는 퀵 정렬의 분할에 의해 오히려 수행시간이 더 많이 걸린다.
- 퀵 정렬의 불균형 분할을 방지하기 위해 피벗을 선택할 떄 더욱 리스트를 균등하게 분할할 수 있는 데이터를 선택한다.
- EX) 리스트 내의 몇 개의 데이터 중에서 크기순으로 중간 값을 피벗으로 선택한다.
- 정렬된 리스트에 대해서는 퀵 정렬의 분할에 의해 오히려 수행시간이 더 많이 걸린다.
- 퀵 정렬 C언어 코드
# include <stdio.h>
# define MAX_SIZE 9
# define SWAP(x, y, temp) ( (temp)=(x), (x)=(y), (y)=(temp) )
// 1. 피벗을 기준으로 2개의 부분 리스트로 나눈다.
// 2. 피벗보다 작은 값은 모두 왼쪽 부분 리스트로, 큰 값은 오른쪽 부분 리스트로 옮긴다.
/* 2개의 비균등 배열 list[left...pivot-1]와 list[pivot+1...right]의 합병 과정 */
/* (실제로 숫자들이 정렬되는 과정) */
int partition(int list[], int left, int right){
int pivot, temp;
int low, high;
low = left;
high = right + 1;
pivot = list[left]; // 정렬할 리스트의 가장 왼쪽 데이터를 피벗으로 선택(임의의 값을 피벗으로 선택)
/* low와 high가 교차할 때까지 반복(low<high) */
do{
/* list[low]가 피벗보다 작으면 계속 low를 증가 */
do {
low++; // low는 left+1 에서 시작
} while (low<=right && list[low]<pivot);
/* list[high]가 피벗보다 크면 계속 high를 감소 */
do {
high--; //high는 right 에서 시작
} while (high>=left && list[high]>pivot);
// 만약 low와 high가 교차하지 않았으면 list[low]를 list[high] 교환
if(low<high){
SWAP(list[low], list[high], temp);
}
} while (low<high);
// low와 high가 교차했으면 반복문을 빠져나와 list[left]와 list[high]를 교환
SWAP(list[left], list[high], temp);
// 피벗의 위치인 high를 반환
return high;
}
// 퀵 정렬
void quick_sort(int list[], int left, int right){
/* 정렬할 범위가 2개 이상의 데이터이면(리스트의 크기가 0이나 1이 아니면) */
if(left<right){
// partition 함수를 호출하여 피벗을 기준으로 리스트를 비균등 분할 -분할(Divide)
int q = partition(list, left, right); // q: 피벗의 위치
// 피벗은 제외한 2개의 부분 리스트를 대상으로 순환 호출
quick_sort(list, left, q-1); // (left ~ 피벗 바로 앞) 앞쪽 부분 리스트 정렬 -정복(Conquer)
quick_sort(list, q+1, right); // (피벗 바로 뒤 ~ right) 뒤쪽 부분 리스트 정렬 -정복(Conquer)
}
}
void main(){
int i;
int n = MAX_SIZE;
int list[n] = {5, 3, 8, 4, 9, 1, 6, 2, 7};
// 퀵 정렬 수행(left: 배열의 시작 = 0, right: 배열의 끝 = 8)
quick_sort(list, 0, n-1);
// 정렬 결과 출력
for(i=0; i<n; i++){
printf("%d\n", list[i]);
}
}
- 퀵 정렬 python 코드
def qsort(data):
if len(data) <= 1:
return data
pivot = data[0]
left = [ item for item in data[1:] if pivot > item ]
right = [ item for item in data[1:] if pivot <= item ]
return qsort(left) + [pivot] + qsort(right)
<병합 정렬>
- 안정정렬
- 분할정복 알고리즘
- 문제를 작은 2개의 문제로 분리하고 각각을 해결한 다음, 결과를 모아서 원래의 문제를 해결하는 전략
- 순환 호출을 이용
- 과정 설명
- 1. 리스트의 길이가 0또는 1이면 이미 정렬된 것으로 본다.
- 2. 그렇지 않은 경우에는 정렬되지 않은 리스트를 절반으로 잘라 비슷한 크기의 두 부분 리스트로 나눈다.
- 3. 각 부분 리스트를 재귀적으로 합병 정렬을 이용해 정렬
- 4. 두 부분 리스트를 다시 하나의 정렬된 리스트로 합병
- 하나의 리스트를 두 개의 균등한 크기로 분할하고 분할된 부분 리스트를 정렬한 다음, 두 개의 정렬된 부분 리스트를 합하여 전체가 정렬된 리스트가 된게 하는 방법
- 합병정렬 단계
- 분할 : 입력 배열을 같은 크기의 2개의 부분 배열로 분할
- 정복 : 부분 배열을 정렬. 부분 배열의 크기가 충분히 작지 않으면 순환호출을 이용해 다시 분할정복방법 적용
- 결합 : 정렬된 부분 배열들을 하나의 배열에 합병
- 합병정렬 과정
- 추가적인 리스트 필요
- 각 부분 배열을 정렬할 때도 합병 정렬을 순환적으로 호출하여 적용
- 합병 정렬에서 실제로 정렬이 이루어지는 시점은 2개의 리스트를 합병하는 단계
- 단점
- 만약 레코드를 배열로 구성하면, 임시 배열이 필요하다.
- 제자리 정렬이 아니다.
- 레코드들의 크기가 큰 경우에는 이동 횟수가 많으므로 매우 큰 시간적 낭비를 초래한다.
- 만약 레코드를 배열로 구성하면, 임시 배열이 필요하다.
- 장점
- 안정적인 정렬 방법
- 데이터의 분포에 영향을 덜 받는다. => 입력데이터가 무엇이든 간에 정렬되는 시간을 동일
- 만약 레코드를 연결리스트로 구성하면, 링크 인덱스만 변경되므로 데이터의 이동은 무시할 수 있을 정도로 작아진다.
- 제자리 정렬로 구현할 수 있다.
- 따라서 크기가 큰 레코드를 정렬할 경우에 연결 리스트를 사용한다면, 합병정렬은 퀵 정렬을 포함한 다른 어떤 정렬 방법보다 효율적이다.
- 안정적인 정렬 방법
- 합병정렬 C언어 코드
# include <stdio.h>
# define MAX_SIZE 8
int sorted[MAX_SIZE] // 추가적인 공간이 필요
// i: 정렬된 왼쪽 리스트에 대한 인덱스
// j: 정렬된 오른쪽 리스트에 대한 인덱스
// k: 정렬될 리스트에 대한 인덱스
/* 2개의 인접한 배열 list[left...mid]와 list[mid+1...right]의 합병 과정 */
/* (실제로 숫자들이 정렬되는 과정) */
void merge(int list[], int left, int mid, int right){
int i, j, k, l;
i = left;
j = mid+1;
k = left;
/* 분할 정렬된 list의 합병 */
while(i<=mid && j<=right){
if(list[i]<=list[j])
sorted[k++] = list[i++];
else
sorted[k++] = list[j++];
}
// 남아 있는 값들을 일괄 복사
if(i>mid){
for(l=j; l<=right; l++)
sorted[k++] = list[l];
}
// 남아 있는 값들을 일괄 복사
else{
for(l=i; l<=mid; l++)
sorted[k++] = list[l];
}
// 배열 sorted[](임시 배열)의 리스트를 배열 list[]로 재복사
for(l=left; l<=right; l++){
list[l] = sorted[l];
}
}
// 합병 정렬
void merge_sort(int list[], int left, int right){
int mid;
if(left<right){
mid = (left+right)/2 // 중간 위치를 계산하여 리스트를 균등 분할 -분할(Divide)
merge_sort(list, left, mid); // 앞쪽 부분 리스트 정렬 -정복(Conquer)
merge_sort(list, mid+1, right); // 뒤쪽 부분 리스트 정렬 -정복(Conquer)
merge(list, left, mid, right); // 정렬된 2개의 부분 배열을 합병하는 과정 -결합(Combine)
}
}
void main(){
int i;
int n = MAX_SIZE;
int list[n] = {21, 10, 12, 20, 25, 13, 15, 22};
// 합병 정렬 수행(left: 배열의 시작 = 0, right: 배열의 끝 = 7)
merge_sort(list, 0, n-1);
// 정렬 결과 출력
for(i=0; i<n; i++){
printf("%d\n", list[i]);
}
}
- 합병정렬 python코드
# 데이터 리스트 앞뒤로 자르는 코드
def mergesplit(data):
if len(data) <= 1:
return data
medium = int(len(data) / 2)
left = mergesplit(data[:medium])
right = mergesplit(data[medium:])
return merge(left, right)
# 머지하는 코드
# left 와 right 의 리스트 데이터를 정렬해서 sorted_list 라는 이름으로 return 하기
def merge(left, right):
merged = list()
left_point, right_point = 0, 0
# case1 - left/right 둘다 있을때
while len(left) > left_point and len(right) > right_point:
if left[left_point] > right[right_point]:
merged.append(right[right_point])
right_point += 1
else:
merged.append(left[left_point])
left_point += 1
# case2 - left 데이터가 없을 때
while len(left) > left_point:
merged.append(left[left_point])
left_point += 1
# case3 - right 데이터가 없을 때
while len(right) > right_point:
merged.append(right[right_point])
right_point += 1
return merged
BFS와 DFS
-
너비 우선 탐색 (Breadth First Search): 정점들과 같은 레벨에 있는 노드들 (형제 노드들)을 먼저 탐색하는 방식
한 단계씩 내려가면서, 해당 노드와 같은 레벨에 있는 노드들 (형제 노드들)을 먼저 순회함
-
깊이 우선 탐색 (Depth First Search): 정점의 자식들을 먼저 탐색하는 방식
한 노드의 자식을 타고 끝까지 순회한 후, 다시 돌아와서 다른 형제들의 자식을 타고 내려가며 순화함
너비 우선 탐색 BFS
graph = dict()
graph['A'] = ['B', 'C']
graph['B'] = ['A', 'D']
graph['C'] = ['A', 'G', 'H', 'I']
graph['D'] = ['B', 'E', 'F']
graph['E'] = ['D']
graph['F'] = ['D']
graph['G'] = ['C']
graph['H'] = ['C']
graph['I'] = ['C', 'J']
graph['J'] = ['I']
알고리즘 구현
- 자료구조 큐를 활용한다. need_visit 큐와 visited 큐, 두 개의 큐를 생성!
def bfs(graph, start_node):
visited, need_visit = list(), list()
need_visit.append(start_node)
while need_visit:
node = need_visit.pop(0)
if node not in visited:
visited.append(node)
need_visit.extend(graph[node])
return visited
일반적인 BFS 시간 복잡도는 노드의 수 V, 간선의 수 E 일때 while need_visit이 V+E 번 수행한다.
시간복잡도는 O(V+E)
깊이 우선 탐색 DFS
def bfs(graph, start_node):
visited, need_visit = list(), list()
need_visit.append(start_node)
while need_visit:
node = need_visit.pop(0)
if node not in visited:
visited.append(node)
need_visit.extend(graph[node])
return visited
알고리즘 구현
자료구조 스택과 큐를 활용한다. need_visit 스택과 visited 큐, 두 개의 자료 구조를 생성!
BFS는 두개의 큐를 활용하지만 DFS는 스택과 큐를 사용한다. 그 이유는 뒤에서부터 pop하기 때문에 최근순으로 순회함.
def dfs(graph, start_node):
visited, need_visit = list(), list()
need_visit.append(start_node)
while need_visit:
node = need_visit.pop()
if node not in visited:
visited.append(node)
need_visit.extend(graph[node])
return visited
일반적인 DFS 시간 복잡도는 노드의 수 V, 간선의 수 E 일때 while need_visit은 V+E 번 수행
시간 복잡도 : O(V+E)
문제!
DFS(깊이우선탐색)로 1번 노드부터 탐색한다고 할 때 방문하는 노드의 순서는?
(단, 여러 개의 노드들이 연결되어 있는 경우, 작은 노드번호를 기준으로 먼저 순회한다.)
-
답
1, 2, 4, 7, 5, 6, 3
동적 계획법과 분할 정복
동적계획법 (DP 라고 많이 부름)
입력 크기가 작은 부분 문제들을 해결한 후, 해당 부분 문제의 해를 활용해서, 보다 큰 크기의 부분 문제를 해결, 최종적으로 전체 문제를 해결하는 알고리즘
-
상향식 접근법으로, 가장 최하위 해답을 구한 후, 이를 저장하고, 해당 결과값을 이용해서 상위 문제를 풀어가는 방식
-
Memoization 기법을 사용함
Memoization (메모이제이션) 이란: 프로그램 실행 시 이전에 계산한 값을 저장하여, 다시 계산하지 않도록 하여 전체 실행 속도를 빠르게 하는 기술
-
문제를 잘게 쪼갤 때, 부분 문제는 중복되어, 재활용됨
예: 피보나치 수열
분할 정복
- 문제를 나눌 수 없을 때까지 나누어서 각각을 풀면서 다시 합병하여 문제의 답을 얻는 알고리즘
- 하양식 접근법으로, 상위의 해답을 구하기 위해, 아래로 내려가면서 하위의 해답을 구하는 방식
- 일반적으로 재귀함수로 구현
- 문제를 잘게 쪼갤 때, 부분 문제는 서로 중복되지 않음
- 예: 병합 정렬, 퀵 정렬 등
공통점
문제를 쪼개서, 가장 작은 단위로 분할하여 해결
차이점
-
동적 계획법
부분 문제는 중복되어, 상위 문제 해결 시 재활용 된다.
메모이제이션 기법을 사용한다. (부분 문제의 해답을 재활용하여 최적화)
-
분할 정복
부분 문제는 서로 중복되지 않는다.
메모이제이션 기법을 사용하지 않는다.
'알고리즘 > 알고리즘 개념' 카테고리의 다른 글
비트마스크 & minimum spanning tree & 플로이드 워셜 (0) | 2020.11.28 |
---|---|
[알고리즘] 다익스트라 (0) | 2020.11.09 |
정렬 알고리즘 (0) | 2020.10.21 |
자료구조와 알고리즘 (0) | 2020.10.14 |
탐욕알고리즘(Greedy) (0) | 2020.08.22 |