sorting


자료구조 정리 : 정렬

정렬이란?

정렬 : 데이터 집합을 일정한 순서로 바꾸어 늘어놓는 작업

안정적인 정렬 알고리즘

-> 값이 같은 원소의 순서가 정렬한 후에도 유지되는 정렬 알고리즘

안정적이지 않은 정렬 알고리즘은 정렬한 후에 원래 순서가 유지된다는 보장 없음

내부정렬 : 정렬할 모든 데이터를 하나의 배열에 저장할 수 있는 경우에 사용하는 정렬

외부정렬 : 정렬할 데이터가 많아서 하나의 배열에 저장할 수 없는 경우에 사용하는 정렬

정렬 알고리즘의 핵심

  1. 교환
  2. 선택
  3. 삽입

버블 정렬

  • 이웃한 두 원소의 대소관계를 비교하여 필요에 따라 교환을 반복하는 알고리즘
  • 단순 교환 정렬
  • 패스 : 일련의 비교를 수행하는 과정
  • 패스를 한 번 수행할 때마다 정렬할 대상이 한개씩 감소
  • 모든 정렬을 마치기 위해선 패스를 (n-1)번 수행해야함
  • 패스를 k번 수행하면 맨 앞부터 k개 원소가 정렬됨
from typing import MutableSequence
def bubble_sort(a:MutableSequence) -> None:
    n = len(a)
    for i in range(n-1):
        for j in range(n-1, i, -1):
            if a[j - 1] > a[j]:
                a[j], a[j - 1] = a[j - 1], a[j]

셰이커 정렬

  • 칵테일 정렬, 칵테일 셰이커 정렬
  • 양방향 버블 정렬
  • 홀수 패스에서 가장 작은 원소를 맨 앞으로 이동
  • 짝수 패스에서 가장 큰 원소를 맨 뒤로 이동
  • 스캔 범위 첫 원소 이름 : left
  • 스캔 범위 마지막 원소 이름 : right
from typing import MutableSequence
def shaker_sort(a : MutableSequence) -> None:
    left = 0
    right = len(a) - 1
    last = right
    while left < right:
        for j in range(right, left, -1):
            if a[j - 1] > a[j]:
                a[j - 1], a[j] = a[j], a[j - 1]
                last = j
        left = last
        for j in range[left, right]:
            if a[j] > a[j + 1]:
                a[j], a[j + 1] = a[j + 1], a[j]
                last = j
        right = last

단순 선택 정렬

  • 가장 작은 원소부터 선택해 알맞은 위치로 옮기는 작업을 반복하여 정렬
  • 셔틀 정렬
  • 안정적이지 않은 정렬 알고리즘
    from typing import MutableSequence
    def selection_sort(a : MutableSequence) -> None:
      n = len(a)
      for i in range(n - 1):
          min = i
          for j in range(i + 1, n):
              if a[j] < a[min]:
                  min = j
          a[i], a[min] = a[min], a[i]
    

    단순 삽입 정렬

  • 주목한 원소보다 더 앞쪽에서 알맞은 위치로 삽입하며 정렬하는 알고리즘
  • 두번째인 원소부터 주목하여 진행
  • 아직 정렬되지 않은 부분의 맨 앞 원소를 정렬된 부분의 알맞은 위치에 삽입하는 과정을 (n-1)번 반복
  • 알맞은 위치에 삽입
    from typing import MutableSequence
    def insertion_sort(a : MutableSequence) -> None:
     n = len(a)
     for i in range(1, n):
         j = i
         tmp = a[i]
         while j > 0 and a[j - 1] > tmp:
             a[j] = a[j - 1]
             j -= 1
         a[j] = tmp
    
  • 반복 제어 변수 j에 i를, tmp에 a[i]를 대입하고 종료조건 만족시까지 j를 1씩 감소하며 대입 작업을 진행
  • 종료조건
    • 정렬된 배열의 왼쪽 끝에 도달한 경우
    • tmp보다 작거나 키값이 같은 원소 a[j-1]을 발견한 경우
  • 계속조건
    • j가 0보다 큰 경우
    • tmp보다 a[j-1]의 값이 큰 경우

      이진 삽입 정렬

  • 단순 삽입 정렬은 배열의 원소 수가 많아지면 원소 삽입에 필요한 비교, 교환 비용이 커짐
  • 이진 검색을 이용하여 원소 삽입 위치 계산에 필요한 비용을 절약
     from typing import MutableSequence
     def binary_insertion_sort(a : MutableSequence) -> None:
     n = len(a)
     for i in range(1, n):
         key = a[i]
         pl = 0
         pr = i - 1
    
         while True:
             pc = (pl + pr) // 2
             if a[pc] == key:
                 break
             elif a[pc] < key:
                 pl = pc + 1
             else:
                 pr = pc - 1
             if pl > pr:
                 break
         pd = pc + 1 if pl <= pr else pr + 1
         for j in range(i, pd, -1):
             a[j] = a[j - 1]
         a[pd] = key
    

bisect모듈의 insort()함수

  • 파이썬 표준 라이브러리에서 단순 삽입 정렬 알고리즘 함수를 제공
  • bisect.insort(a, x, lo, hi)
    • a가 이미 정렬된 상태를 유지하면서 a[io] ~ a[hi]사이에 x를 삽입

셸 정렬

단순 삽입 정렬의 문제

  • 삽입할 위치가 멀리 떨어져 있으면 이동 횟수 증가

    셸 정렬

  • 단순 삽입 정렬의 장점을 살리면서 단점을 보완
  • 먼저 정렬한 배열의 원소를 그룹으로 나눠 각 그룹별로 정렬을 수행한 뒤 정렬된 그룹을 합치는 작업을 반복 -> 이동 횟수를 줄임

    h-정렬

  • 서로 h칸 떨어진 원소를 정렬하는 것으로 셸 정렬 과정에서 수행하는 각각의 단순 삽입 정렬

    셸 정렬의 수행 과정

  • 모든 정렬은 단순 삽입 정렬을 수행
  • 셸 정렬은 단순 삽입 정렬의 장점을 살리고 정렬 횟수는 늘어나지만 이동 횟수는 줄어듬
 from typing import MutableSequence
 def shell_sort(a : MutableSequence) -> None:
    n = len(a)
    h = n // 2
    while h > 0:
        for j in range(h, n):
            j = i - h
            tmp = a[i]
            while j>= 0 and a[j] > tmp:
                a[j + h] = a[j]
                j -= h
            a[j + h] = tmp
        h //= 2

### h값의 선택

  • 원소 수가 8이라면 h는 4, 2, 1 순으로 변화
  • 학생 8명의 점수를 나열하는 문제
    • 학생 2명씩 4개 그룹으로 나누어 정렬한 뒤, 학생 4명씩 2개 그룹으로 나누어 정렬
    • 4개의 그룹에서 두 그룹을 합쳐 2개의 그룹으로 만들 때 두 그룹의 원소가 섞이지 않음
  • h값은 서로 배수가 되지 않아야 원소가 섞일 수 있음
  • h의 초기값은 n//9를 넘지 않은 큰 수에서 시작
     from typing import MutableSequence
     def shell_sort(a : MutableSequence) -> None:
    n = len(a)
    h = 1
    while h < n // 9:
        h = h * 3 + 1
    while h > 0:
        for j in range(h, n):
            j = i - h
            tmp = a[i]
            while j>= 0 and a[j] > tmp:
                a[j + h] = a[j]
                j -= h
            a[j + h] = tmp
        h //= 2
    

    # 퀵 정렬

  • 일반적으로 사용되는 가장 빠르다고 알려진 정렬 알고리즘
  • 피벗(중심 축)
    • 그룹을 나누는 기준
    • 임의로 선택할 수 있고, 선택된 피벗은 2개로 나눈 그룹 어디에 넣어도 무관
  • 피벗을 선택하여 그룹 나누기를 반복하며 정렬

    배열을 두 그룹으로 나누기

  • 피벗을 선택하여 두 그룹으로 분할
    • pl : 분할할 배열의 왼쪽 끝 원소의 인덱스
    • pr : 분할할 배열의 오른쪽 끝 원소의 인덱스
    • 피벗 이하인 원소를 배열의 왼쪽으로, 피벗 이상인 원소를 배열의 오른쪽으로 이동
    • a[pl] >= x가 성립하는 원소를 찾을 때까지 pl을 오른쪽 방향으로 스캔, a[pr] <= x가 성립하는 원소를 찾을 때까지 pr을 왼쪽 방향으로 스캔
    • pl과 pr이 서로 교차할 때까지 반복
  • 그룹을 나누는 작업이 끝난 후 pl > pr +1 인 경우에 한하여 피벗과 일치하는 그룹이 생성
    • a[pr + 1],…, a[pl-1]
  • pl과 pr 모두 피벗과 같은 위치에 있음
    • 이 경우에도 같은 원소를 교환하는 작업이 필요함
    • 교환하는 작업을 하지 않으면 pl과 pr이 같은 위치에 있게 됨

      퀵 정렬 만들기

  • 원소 수가 2개 이상인 그룹을 반복해서 분할
    • pr이 0보다 크면 (left < pr)왼쪽 그룹으로 분할
    • pl이 n-1보다 작으면(pl < right)오른쪽 그룹으로 분할
  • 원소수가 1개인 그룹과 가운데 그룹은 더 이상 분할할 필요가 없음
  • 퀵 정렬은 분할 정복 알고리즘으로 재귀 호출을 사용하여 생성 가능 ```python from typing import MutableSequence def qsort(a : MutableSequence, left : int, right : int) -> None: n = len(a) pl = left pr = right x = a[(left + right) // 2] while pl<= pr: while a[pl] < x : pl += 1 while a[pr] > x : pr -= 1 if pl<=pr: a[pl], a[pr] = a[pr], a[pl] pl += 1 pr -= 1 if left < pr : qsort(a, left, pr) if pl < right: qsort(a, pl, right)

def quick_sort(a : MutableSequence) -> None: qsort(a, 0, len(a) - 1)

## 비재귀적인 퀵 정렬의 구현
 - 배열을 분할할 범위(맨 앞의 원소의 인덱스와 맨 끝의 원소의 인덱스를 조합)를 튜플 형태로 스택에 저장
   - (left, right)
```python
%%writefile stack.py
from typing import Any
from collections import deque

class Stack:
    def __init__(self, maxlen : int = 256) -> None:
        self.capacity = maxlen
        self.__stk = deque([], maxlen)

    def __len__(self) -> int:
        return len(self.__stk)
    def is_empty(self)-> bool:
        return not self.__stk
    def is_full(self) -> bool:
        return len(self.__stk) == self.__stk.maxlen
    def push(self, value : Any) -> None:
        self.__stk.append(value)
    def pop(self) -> Any:
        return self.__stk.pop()
    def peek(self) -> Any:
        return self.__stk[-1]
    def clear(self) -> None:
        self.__stk.clear()
    def find(self, value : Any) -> Any:
        try:
            return self.__stk.index(value)
        except ValueError:
            return -1
    def count(self, value : Any) -> int:
        return self.__stk.count(value)
    def __contains__(self, value : Any) -> bool:
        return self.count(value)

    def dump(self) -> None:
        print(list(self.__stk))
from stack import Stack
def qsort(a, left, right):
    range = Stack(right - left + 1)
    range.push((left, right))
    while not range.is_empty():
        pl, pr = left, right = range.pop()
        x = a[(left + right) // 2]
        while pl <= pr:
            while a[pl] < x : pl += 1
            while a[pr] < x : pr -= 1
            if pl <= pr:
                a[pl], a[pr] = a[pr], a[pl]
                pl += 1
                pr -= 1
        if left < pr : range.push((left, pr))
        if pl < right : range.push((pl, right))

def quick_sort(a : MutableSequence) -> None:
    qsort(a, 0, len(a) - 1)

스택의 크기

  • 퀵 정렬로 배열 나누기(피벗 : 2)
  • 스택에 배열을 푸시하는 규칙
    • 규칙 1 : 원소 수가 많은 쪽의 그룹을 먼저 푸시
    • 규칙 2 : 원소 수가 적은 쪽의 그룹을 먼저 푸시
  • 원소 수가 적은 배열일수록 나누는 과정을 빠르게 마칠 수 있으며, 규칙 1과 같이 원소 수가 많은 그룹의 나누기를 나중에 하면 스택에 동시에 쌓이는 데이터 수가 적어짐
  • 규칙 1에 따르면 스택에 쌓이는 데이터의 최대 개수는 logn보다 작아짐

## 피벗 선택하기

  • 피벗 선택 방법은 퀵 정렬 효율에 큰 영향을 미침
  • 맨 앞의 원소를 피벗으로 선택한 경우
    • 피벗만 있는 그룹과 나머지 원소 그룹으로 분할
    • 한쪽으로 완전히 치우친 분할을 반복하게함
  • 배열을 완전히 정렬한 뒤 피벗으로 중앙값을 선택하는 것이 가장 빠른 경우
    • 정렬된 배열의 중앙값을 구하기 위한 처리에 대한 비용이 소모되어 피벗을 선택하는 의미가 없어짐
  • 방법 1 : 분할할 배열의 원소수가 3개 이상이면 임의의 원소 3개를 꺼내 중앙값을 피벗으로 지정
  • 방법 2 : 분할할 배열의 맨 앞 원소, 가운데 원소, 맨 끝 원소를 정렬한 뒤, 가운데 원소와 맨 끝에서 두번째 원소를 교환하여 맨 끝에서 두번째 원소 a[right -1]을 피벗으로 선택
  • 그룹이 한쪽으로 치우치는 것을 방지하여 스캔할원소를 3개 줄일 수 있음

효율적인 퀵 정렬의 구현

  • 퀵 정렬은 원소 수가 적은 경우 비효율적이라 알려져있음
    • 원소 수가 9개 미만인 경우 단순 삽입 정렬 사용

sorted() 함수

  • 파이썬에서 정렬을 수행하는 내장 함수
  • 전달받은 이터러블 객체의 원소를 정렬하여 리스트 형으로 반환
    • 정렬을 수행한 뒤 늘어선 원소를 새로운 리스트로 생성하여 반환
  • 오름차순을 기본

# 병합 정렬

  • 배열을 앞부분과 뒷부분의 두 그룹으로 나누어 각각 정렬한 뒤 병합하는 작업을 반복하는 정렬 알고리즘
  • 각 배열에서 주목하는 원소의 값과 비교하여 작은 쪽의 원소를 꺼내 새로운 배열에 저장
  • 각 배열을 스캔할 때 주목하는 원소의 인덱스를 pa, pb, pc라고 하고 0으로 초기화
    from typing import Sequence, MutableSequence
    def merge_sorted_list(a : Sequence, b : Sequence, c : MutableSequence) -> None:
    pa, pb, pc = 0, 0, 0
    na, nb, nc = len(a), len(b), len(c)
    while pa < na and pb < nb:
        if a[pa] <= b[pb]:
            c[pc] = a[pa]
            pa += 1
        else:
            c[pc] = b[pb]
            pb += 1
        pc += 1
    while pa < na:
        c[pc] = a[pa]
        pa += 1
        pc += 1
    

    병합 정렬 알고리즘

  • 병합 정렬을 하는 방법
    • 배열을 나누고 나눈 두 배열을 각각 정렬한 뒤 병합하여 배열 정렬을 완료(분할 정복법)
  • 병합 정렬 알고리즘
    • 배열의 원소 수가 2개 이상인 경우
      1. 배열의 앞부분을 병합 정렬로 정렬
      2. 배열의 뒷부분을 병합 정렬로 정렬
      3. 배열의 앞부분과 뒷부분을 병합
  • 서로 떨어져 있는 원소를 교환하는 것이 아니므로 안정적임