본문 바로가기

6.5. Data Sturcture

7/3(월) IT 자료구조(13일차) / 12. 고급 정렬 알고리즘

 

 

12. 고급 정렬 알고리즘

고급 정렬 알고리즘은 효율적으로 데이터를 정렬하기 위해 개발된 알고리즘.

이러한 알고리즘들은 일반적으로 일반적인 정렬 알고리즘인 버블 정렬이나

삽입 정렬보다 더 효율적인 성능을 제공함.

 

분할 정복(Divide and Conquer):
큰 문제를 작은 하위 문제로 분할하고, 각 하위 문제를 재귀적으로 해결한 다음,
그 결과를 결합하여 원래 문제의 해답을 얻는 알고리즘 설계 기법.

주요 아이디어:
1) 분할(Divide):
원래 문제를 작은 부분 문제로 분할. 문제를 반으로 쪼개는 것이 일반적이지만, 경우에 따라 다른 방식으로 분할할 수도 있음.
2) 정복(Conquer):
각 하위 문제를 재귀적으로 해결. 하위 문제가 충분히 작아서 직접 해결할 수 있는 경우에는 재귀 호출을 중단하고 해결함.
3) 결합(Combine):
각 하위 문제의 해답을 결합하여 원래 문제의 해답을 얻음.

문제를 더 작고 해결하기 쉬운 하위 문제로 분할함으로써 전체적인 문제 해결 과정을 간소화하는데 목적을 둠.
대부분의 분할 정복 알고리즘은 재귀적인 구조를 가지며, 이를 통해 문제의 크기를 점진적으로 줄여나감.

하향식 접근법(Top-down Approach)은 분할 정복과 관련있음. 
이 접근법은 큰 문제를 해결하기 위해 재귀적으로 작은 하위 문제를 해결하는 방식으로, 문제를 작은 부분으로 나누고
각 부분을 해결하면서 최종적인 해답을 구성함.
이러한 접근법에서는 주로 재귀 함수를 사용하여 하위 문제를 해결하며, 이를 통해 분할 정복 알고리즘을 구현할 수 있음.

하향식 접근법에서는 문제를 작은 단위로 분할하고, 각 단계에서 필요한 계산을 수행하면서 
전체적인 문제 해결 과정을 이끌어감. 이는 재귀 호출과 함께 사용되는 경우가 많음.

 

시간 복잡도:

알고리즘을 수행하는 데 평균적으로, 또는 최악의 경우 얼마만큼의 시간이 걸리는 지를 확인.

복잡도는 주로 빅오 표기법을 사용해서 나타냄. 최악의 경우 걸리는 시간을 표기하는 방법인데,

아무리 최악이더라도 이 정도는 넘지 않겠구나라고 생각하면 됨. 

아래 그래프에서 기울기가 가파를수록 성능이 좋지 않음.

출처: https://www.zerocho.com/category/Algorithm/post/57ea2987fdea850015313534

 

12-1. 병합 정렬(Merge Sort)

병합 정렬은 분할 정복(Divide and Conquer) 알고리즘을 기반으로 하는 정렬 알고리즘.

큰 문제를 작은 문제로 분할한 다음, 작은 문제들을 해결하고 결과를 병합하여 정렬된 결과를 얻는 방식으로 작동.

출처: https://reakwon.tistory.com/38


주요 아이디어:
1) 분할(Divide):

정렬할 리스트를 절반으로 분할. 분할은 리스트의 크기가 1 이하가 될 때까지 재귀적으로 수행됨.
2) 정복(Conquer):

분할된 부분 리스트에 대해 재귀적으로 정렬을 수행합니다. 리스트의 크기가 충분히 작아지면 정렬을 중단하고, 그 부분 리스트를 반환합니다.
3) 병합(Combine):

정렬된 부분 리스트들을 병합하여 전체 리스트를 생성함.

이때, 각 부분 리스트에서 가장 작은 요소부터 차례대로 선택하여 병합함.

병합 정렬은 리스트를 반으로 분할하는 과정을 반복하면서 분할된 리스트를 정렬하고 병합하며,

이를 통해 최종적으로 정렬된 리스트를 얻을 수 있음. 시간 복잡도는 평균적으로 O(n log n)임.

병합 정렬은 안정적인 정렬 알고리즘으로 알려져 있으며, 대규모 데이터에 대해서도 효과적으로 동작함.

그러나 정렬하려는 리스트의 크기에 따라 추가적인 메모리 공간이 필요할 수 있으므로,

공간 복잡도에 대한 고려가 필요할 수 있음.

 


merge_sort(list[], p, r):
  if p < r:
    q <- |(p+r)/2| # 분할하기 위한 p, r의 중간지점 계산
    merge_sort(list[], p, q) # 전반부 정렬 재귀
    merge_sort(list[], q+1, r) # 후반부 정렬 재귀
    merge(list[], p, q, r) # 병합

merge(list[], p, q, r):
  정렬된 두 리스트 list[p...q], list[q+1...r]을 합쳐
  정렬된 하나의 리스트 list[p...r]을 만듦

merge(list[], p, q, r):
  # prev, curr, next 역할 : 포인터
  i <- p
  j <- q+1
  t <- 0 # 옮겨담을 새로운 리스트 tmp에 대한 인덱스
  while i <= q and j <= r
    if list[i] <= list[j]
      tmp[t] <- list[i]
      t++; i++
    else
      tmp[t] <- list[j]
      t++; j++
  while i <= q # 왼쪽 부분 리스트가 남은 경우
    tmp[t] <- list[i]
    t++; i++
  while j <= r # 오른쪽 부분 리스트가 남은 경우
    tmp[t] <= list[j]
    t++; j++
  i <- p; t <- 0 # 다 옮겼으면 원래꺼에 복사할 준비
  while i <= r
    list[i] <- tmp[t]
    i++; t++

 

12-1-1. 구현

def merge_sort(alist, p: int, r: int):
  if p < r:
    q = (p+r) // 2
    merge_sort(alist, p, q)
    merge_sort(alist, q+1, r)
    merge(alist, p, q, r)

def merge(alist, p: int, q: int, r: int):
  i = p; j = q+1; t = 0
  tmp = [0 for _ in range(len(alist))]
  while i <= q and j <= r:
    if alist[i] <= alist[j]:
      tmp[t] = alist[i]
      t += 1; i += 1
    else:
      tmp[t] = alist[j]
      t += 1; j += 1
  while i <= q:
    tmp[t] = alist[i]
    t += 1; i += 1
  while j <= r:
    tmp[t] = alist[j]
    t += 1; j += 1
  i = p; t = 0
  while i <= r:
    alist[i] = tmp[t]
    t += 1; i += 1

 

12-1-2. 테스트

import random as r

ms_list = [r.randint(1,20) for _ in range(12)]
print(ms_list)
merge_sort(ms_list, 0, len(ms_list)-1)
print(ms_list)

 

12-2. 퀵 정렬(Quick Sort)

퀵 정렬은 분할 정복(Divide and Conquer) 알고리즘을 기반으로 하는 정렬 알고리즘.

특히 평균적으로 빠른 속도와 효율성으로 알려져 있음.

출처:&nbsp;https://itstory.tk/entry/%ED%80%B5%EC%A0%95%EB%A0%ACQuick-Sort


주요 아이디어:
1) 분할(Divide):

정렬할 리스트에서 하나의 요소를 선택하고,

이를 기준으로 작은 요소는 왼쪽으로, 큰 요소는 오른쪽으로 분할함.

이렇게 하면 기준 요소가 자신의 최종 위치를 찾게 되며 이 과정을 파티셔닝(Partitioning)이라고 함.
2) 정복(Conquer):

분할된 부분 리스트에 대해 재귀적으로 정렬을 수행함.

파티셔닝된 왼쪽 부분 리스트와 오른쪽 부분 리스트에 대해 동일한 분할 과정을 적용함.
3) 결합(Combine): 

정렬된 부분 리스트를 결합하여 최종적으로 정렬된 리스트를 얻음.

여기서는 이미 정렬된 부분 리스트들을 병합할 필요가 없음.

분할과정에서 기준 요소를 기준으로 작은 요소는 왼쪽에, 큰 요소는 오른쪽에 위치시키기 때문임.

시간 복잡도는 평균적으로 O(n log n)이지만, 최악의 경우에는 O(n^2)의 시간 복잡도를 가질 수 있음.

최악의 경우는 주로 이미 정렬된 리스트와 같이 기준 요소가 항상 최소값이나 최대값에 가까울 때 발생.
퀵 정렬은 불필요한 데이터 이동을 최소화하며, 재귀 호출을 사용하여 구현할 수 있음.

 


quick_sort(list[], p, r):
  if p < r
    q <- partition(list[], p, r) # 분할 기준점 찾기
    quick_sort(list[], p, q-1) # 왼쪽 퀵정렬 재귀
    quick_sort(list[], q+1, r)

partition(list[], p, r):
  리스트 list[p...r]의 원소들을 기준 원소인 list[k]와의 상대적 크기에 따라
  양쪽으로 재배치하고, 기준 원소가 자리한 위치를 리턴
  # pivot : 기준 원소
  x <- list[r]
  # i가 작은 지역의 끝지점 (첫지점 p) : 1지역
  i <- p - 1
  # j가 아직 모르는 부분의 시작 지점 : 3지역
  for j <- p to r-1
    # 탐색 중 1구역 원소를 발견한다면
    if list[j] < x
      # 1구역을 한 칸 늘리고, 탐색한 원소 list[j]랑 교환한다
      list[++i] <-> list[j]
  # 기준점 제자리 찾기 : 3구역 원소와 교환
  list[i+1] <-> list[r]
  # 정렬에서 제외할 기준점 리턴
  return i+1

 

12-2-1. 구현

def quick_sort(alist, p: int, r: int) -> None:
  if p < r:
    q = partition(alist, p, r)
    quick_sort(alist, p, q-1)
    quick_sort(alist, q+1, r)

def partition(alist, p:int, r:int) -> int:
  # pivot : 기준 원소
  pivot = alist[r]
  # i가 기준보다 작은 지역의 끝지점 : 1지역
  i = p - 1
  # j는 아직 모르는 부분(탐색 지점)의 시작 인덱스 : 3지역
  for j in range(p,r):
    # 탐색 중 1구역 원소(기준보다 작은 원소) 발견 시
    if alist[j] < pivot:
      # 1구역(기준보다 작은 지점)을 한 칸 먼저 늘린다
      i += 1
      # 탐색한 원소 list[j]랑 교환
      alist[i], alist[j] = alist[j], alist[i]
  # 배열리스트에서 기준점 제자리 찾기
  alist[i+1], alist[r] = alist[r], alist[i+1]
  # 다음 퀵정렬에서 제외할 기준점 리턴
  return i+1

 

12-2-2. 테스트

import random as r

qs_list = [r.randint(1,20) for _ in range(8)]
print(qs_list)
quick_sort(qs_list, 0, len(qs_list)-1)
print(qs_list)

 

12-3. 힙 정렬(Heap Sort)

힙 정렬은 힙(Heap) 자료구조를 기반으로 한 정렬 알고리즘.

(힙은 완전 이진 트리의 일종으로, 특정한 조건을 만족하는 트리)

힙 정렬은 이러한 힙 자료구조를 이용하여 정렬을 수행.

출처:&nbsp;https://velog.io/@emplam27/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EA%B7%B8%EB%A6%BC%EC%9C%BC%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%ED%9E%99%EC%A0%95%EB%A0%ACHeap-Sort

주요 아이디어:
1) 힙 구성(Build Heap):

정렬할 리스트를 순회하며 조건을 만족하도록 힙 구조를 생성 및 구성함.

일반적으로는 Max 힙(부모 노드의 값이 자식 노드의 값보다 큰 경우)을 사용함.
2) 최대값 추출(Extract Max):

힙에서 최대값(루트 노드)을 추출함. 추출한 값은 정렬된 리스트의 맨 뒤로 이동함.
3) 힙 재구성(Heapify):

힙에서 최대값을 추출한 후, 남은 요소들을 다시 힙 구조로 재구성함.

이 단계에서 힙 조건을 유지하기 위해 필요한 조정 작업을 수행함.
4) 추출 및 재구성 과정 반복:

2번과 3번 단계를 반복하여 최대값을 추출하고 힙을 재구성함. 이 과정을 모든 요소가 추출될 때까지 반복함.

힙 정렬은 안정적인 정렬 알고리즘으로 알려져 있으며, 평균 및 최악의 경우에도 시간 복잡도가 O(n log n)임.

힙 정렬은 추가적인 메모리 공간을 필요로 하지 않는 인-장(in-place) 정렬 알고리즘으로 분류됨.
대규모 데이터를 정렬하는 데 효과적이며, 힙 자료구조를 구성하는 과정이 추가적인 시간을 요구하지만

추출 및 재구성 과정에서는 빠른 속도를 제공함.

따라서 힙 정렬은 우선순위 큐와 같이 최대값/최소값을 빠르게 찾아야 하는 상황에 유용하게 사용될 수 있음.


heap_sort():
  build_heap()
  for i <- n-1 down to 1
    list[i] <-> delete_max() # list[0] 바꿔주면서 관심범위 조정

 

12-3-1. 구현

from my_heap import Heap

def heap_sort(alist):
  blist = [element for element in alist]
  h = Heap(blist)
  for i in range(len(blist)-1, 0, -1):
    alist[i] = h.delete_max()

# 관심 범위를 조정한 heap_sort
def heap_sort_ipv(alist):
  build_heap(alist)
  for last in range(len(alist)-1, 0, -1):
    alist[last], alist[0] = alist[0], alist[last]
    percolate_down(alist, 0, last-1)

def build_heap(alist):
  for i in range((len(alist)-2)//2, -1, -1):
    percolate_down(alist, i, len(alist)-1)

# end : 관심 범위 축소 지정
def percolate_down(alist, parent: int, end: int):
  child = 2*parent + 1
  right = 2*parent + 2
  if child <= end:
    if right <= end and alist[child] < alist[right]:
      child = right
    if alist[parent] < alist[child]:
      alist[parent], alist[child] = alist[child], alist[parent]
      percolate_down(alist, child, end)

# import random as r

# list_ex = [r.randint(1,20) for _ in range(20)]
# print(list_ex)
# heap_sort(list_ex)
# print(list_ex)

 

12-3-2. 테스트

import random as r

list_ex = [r.randint(1,30) for _ in range(20)]
print(list_ex)
heap_sort_ipv(list_ex)
print(list_ex)

 

12-3-3. 내장 라이브러리 사용

import heapq
import random as r

def heap_sort_lib(alist):
  h = [ ]
  for element in alist:
    heapq.heappush(h, element)
  return [heapq.heappop(h) for i in range(len(h))]

liex = [r.randint(1,30) for _ in range(20)]
print(liex)
result = heap_sort_lib(liex)
print(result)