정렬 알고리즘

박성재·2020년 12월 3일
0

알고리즘 공부

목록 보기
4/9
post-thumbnail

출처: [이것이 취업을 위한 코딩테스트다 - 나동빈 저](https://ridibooks.com/books/443000825
배너: godori님이 만드신 배너 메이커 활용


정렬 알고리즘

정렬

  • 데이터를 특정한 기준에 따라서 순서대로 나열하는 것을 말함
  • 프로그램에서 데이터를 가공할 때 정렬해서 사용하는 경우가 많아서 프로그램 작성 시 가장 많이 사용되는 알고리즘 중 하나
  • 정렬 알고리즘은 이진 탐색의 전처리 과정이기도 함

선택 정렬

  • 가장 작은 데이터를 선택해 첫 번째 위치의 데이터와 바꾸고, 그 다음으로 작은 데이터를 선택해서 두 번째 위치의 데이터와 바꾸는 식으로 (N-1)번 반복
  • 매번 '가장 작은 것을 선택'

선택 정렬 소스 코드

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

for i in range(len(array) - 1):
    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] # 원소 swap

print(array)

swap

  • 여기서 swap은 특정한 리스트에서 두 변수의 위치를 변경하는 작업을 의미한다.
  • 위의 예시에서는 파이썬이기에 가능한 편리한 방식으로 swap 가능
  • 다른 대부분의 언어에서는 명시적으로 임시 저장용 변수를 만들어서 두 원소의 값을 변경해야 함

출력

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

선택 정렬 시간 복잡도

  • 구체적인 구현 방식에 따라 아주 조금 다를 수 있지만, 반복문이 중첩된 형태로 O(N2^2)의 시간복잡도를 갖는다.
  • 데이터가 100개일 때 0.0123초, 1,000개일 때 0.354초, 10,000개일 때 대략 15.475초 소요
  • 선택 정렬은 파이썬 기본 정렬 라이브러리를 포함해, 다른 알고리즘에 비해 매우 비효율적
  • 현재 데이터의 상태와 상관없이 무조건 모든 원소를 비교하교 위치를 바꿈

삽입 정렬

  • '데이터를 하나씩 확인하며, 각 데이터를 적절한 위치에 삽입'
  • 선택 정렬에 비해 실행 시간 측면에서 더 효율적
  • 필요할 때만 위치를 바꾸므로 '데이터가 거의 정렬되어 있을 때' 효율적
  • 특정한 데이터가 적절한 위치에 들어가기 이전에, 그 앞까지의 데이터는 이미 정렬되어 있다고 가정

삽입 정렬 과정

  1. 첫 번째 데이터는 그 자체로 정렬되어 있다고 판단하고 두 번째 데이터가 어느 위치에 들어갈 지 판단
  2. 그 다음 순서의 데이터가 앞까지의 정렬된 데이터들 중 어느 위치에 들어갈 지 판단하고 삽입하는 과정 반복 (총 N-1번 수행)
  • 특징: 정렬이 수행된 원소는 항상 오름차순을 유지하고 있기 때문에, 특정 데이터가 삽입될 위치를 찾는 과정에서 삽입될 데이터보다 작은 데이터를 만나면 그 위치에서 멈추면 된다. (더 이상 앞쪽의 데이터를 더 살펴볼 필요가 없다는 의미)

삽입 정렬 소스 코드

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

for i in range(1, len(array)):
    for j in range(i, 0, -1): # 인덱스 i부터 1까지 1씩 감소하며 반복
        if array[j] < array[j - 1]:
            array[j], array[j - 1] = array[j - 1], array[j]
        else: # 자기보다 작은 데이터를 만나면 그 위치에서 멈춤
            break

print(array)

출력

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

삽입 정렬 시간 복잡도

  • O(N2^2) - 선택 정렬과 마찬가지로 반복문이 2번 중첩
  • 특징: 삽입 정렬은 현재 리스트의 데이터가 거의 정렬되어 있는 상태라면 매우 빠르게 동작함 (최선의 경우 O(N)의 시간 복잡도를 가짐)
  • 보통은 정렬이 거의 되어 있는 상황에서는 퀵 정렬 알고리즘보다 더 강력하다.

퀵 정렬

  • 그 이름처럼, 빠른 속도를 가진 알고리즘
  • '기준 데이터를 설정하고, 그 기준보다 큰 데이터와 작은 데이터의 위치를 바꾼다'
  • 기준 데이터를 설정한 다음 큰 수와 작은 수를 교환하고, 리스트를 반으로 나누는 방식으로 동작

퀵 정렬 과정

  1. 기준 데이터(피벗; pivot)을 정한다. (퀵 정렬을 수행하기 전에는 피벗을 어떻게 설정할 것인지 미리 명시해야 함).
    • 피벗을 설정하고 리스트를 분할하는 방시에 따라 퀵 정렬을 구분함
    • 여기서는 호어 분할(Hoare Partition) 방식을 기준으로 함
    • 이 방식에서는 '리스트에서 첫 번째 데이터를 피벗으로 정한다'
  2. 리스트의 왼쪽에서부터 피벗보다 큰 데이터를 찾아서 선택하고, 오른쪽에서부터 피벗보다 작은 데이터를 찾아서 선택해 두 데이터의 위치를 바꾸는 과정을 반복한다.
  3. 왼쪽에서부터 찾는 값과 오른쪽에서부터 찾는 값이 엇갈리면, 피벗 데이터와 피벗보다 작은 데이터의 위치를 변경한다.
  4. 결과적으로, 피벗의 왼쪽에는 피벗보다 작은 값들만이, 오른쪽에는 피벗보다 큰 값들만이 존재하게 된다.
    • 이렇게 피벗의 왼쪽에는 피벗보다 더 작은 데이터가, 오른쪽에는 피벗보다 더 큰 데이터가 위치하도록 하는 작업을 분할(Divide) 또는 파티션(Partition)이라고 한다.
  5. 이후 피벗 왼쪽 리스트와 오른쪽 리스트 각각에 대해서 새로운 피벗을 설정하고 이에 따른 파티션 작업을 반복하고 나면 전체 리스트에 대한 정렬이 이루어지게 된다.
  • 퀵 정렬은 재귀 함수 형태로 작성했을 때 구현이 매우 간결해진다.
  • 퀵 정렬에서 재귀의 종료 조건은 리스트의 원소가 1개일 경우이다.

퀵 정렬 소스 코드

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

def quick_sort(array, start, end):
    # base case
    if start >= end: # 원소가 1개인 경우 종료
        return
	
    # recursive case
    pivot = start # 피벗은 첫 번째 원소로 설정
    left = start + 1
    right = end
    while left <= right:
        # 피벗보다 큰 데이터를 찾을 때까지 반복
        while left <= end and array[left] <= array[pivot]:
            left += 1
        # 피벗보다 작은 데이터를 찾을 때까지 반복
        while right > start and array[right] >= array[pivot]:
            right -= 1
        if left > right: # 엇갈렸다면 작은 데이터와 피벗을 교체
            array[right], array[pivot] = array[pivot], array[right]
        else: # 엇갈리지 않았다면 작은 데이터와 큰 데이터 교체
            array[left], array[right] = array[right], array[left]

    # 분할 이후 왼쪽 부분과 오른쪽 부분에서 각각 정렬 수행
    quick_sort(array, start, right - 1)
    quick_sort(array, right + 1, end)

quick_sort(array, 0, len(array) - 1)
print(array)

출력

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

파이썬의 장점을 잘 살린 퀵 정렬 소스코드

  • 리스트 컴프리헨션 사용
  • 피벗과 데이터를 비교하는 연산 횟수가 증가하므로(피벗을 제외한 부분을 2번 full로 돌기 때문) 시간 면에서는 조금 비효율적
  • 더 직관적이고 기억하기 쉽다는 장점이 있음
array = [5, 7, 9, 0, 3, 1, 6, 2, 4, 8]

def quick_sort(array):
    # base case:  리스트가 하나 이하의 원소만을 담고 있다면 종료
    if len(array) <= 1:
        return array

    # recursive case
    pivot = array[0] # 피벗은 첫 번째 원소
    tail = array[1:] # 피벗을 제외한 모든 원소

    left_side = [x for x in tail if x <= pivot] # 분할된 왼쪽 부분
    right_side = [x for x in tail if x > pivot] # 분할된 오른쪽 부분

    # 분할 이후 왼쪽 부분과 오른쪽 부분에서 각각 정렬을 수행하고, 전체 리스트를 반환
    return quick_sort(left_side) + [pivot] + quick_sort(right_side)


print(quick_sort(array))

퀵 정렬 시간 복잡도

  • 평균적으로 O(NlogN)
    - 데이터의 개수가 많을수록 선택 정렬 및 삽입 정렬에 비해 압도적으로 빠르게 동작
  • 최악의 경우 O(N2^2)
  • 데이터가 무작위로 입력되는 경우 빠르게 동작할 확률이 높음
  • 여기서의 퀵 정렬처럼 리스트의 가장 왼쪽 데이터를 피벗으로 삼는 경우, '이미 데이터가 정렬되어 있는 경우'에는 매우 느리게 동작함
  • C++와 같이 퀵 정렬을 기반으로 작성된 정렬 라이브러리를 제공하는 언어들은 최악의 경우에도 시간 복잡도가 O(NlogN)이 되는 것을 보장할 수 있게 피벗값을 설정할 때 추가적인 로직을 더해준다고 한다.

계수 정렬

  • 특정한 조건이 부합할 때만 사용할 수 있지만, 매우 빠름
  • 모든 데이터가 양의 정수인 상황에서 데이터의 개수가 N개, 최대값의 크기를 K라고 할 때 시간 복잡도는 O(N + K)임
  • '데이터의 크기 범위가 제한되어 정수 형태로 포현할 수 있을 때'만 사용 가능 (실수형 데이터에는 사용하기 어려움)
  • 일반적으로 가장 큰 데이터와 가장 작은 데이터의 차이가 1,000,000을 넘지 않을 때 효과적으로 사용 가능
    - 계수 정렬을 이용할 때는 '모든 범위를 담을 수 있는 크기의 리스트(배열)을 선언'해야 하기 때문
  • 앞선 3가지 정렬 알고리즘처럼 비교 기반의 정렬 알고리즘이 아님.
    - 비교 기반의 정렬 알고리즘: 데이터를 비교하며 위치를 변경하는 정렬 방법
  • 별도의 리스트를 선언하고 그 안에 정렬에 대한 정보를 담는다.

계수 정렬 과정

  1. 가장 큰 데이터와 가장 작은 데이터의 범위가 모두 담길 수 있도로 리스트 생성(0으로 초기화)
    • ex) 각각 9, 0일 경우 크기가 10인 리스트 선언
  2. 데이터를 하나씩 확인하며 데이터의 갑과 동일한 인덱스의 데이터를 1씩 증가시킴
  3. 결과적으로 리스트에는 각 데이터가 몇 번 등장했는지 그 횟수에 따라 기록된다. 정렬 결과를 눈으로 확인하고 싶다면, 리스트의 첫 번째 데이터부터 하나씩 그 값만큼 인덱스를 출력하면 된다.

계수 정렬 소스 코드

# 모든 원소의 값이 0보다 크거나 같다고 가정
array = [7, 5, 9, 0, 3, 1, 6, 2, 9, 1, 4, 8, 0 ,5, 2]
# 모든 범위를 포함하는 리스트 선언(모든 값은 0으로 초기화)
count = [0] * (max(array) + 1)

for i in range(len(array)):
    count[array[i]] += 1 # 각 데이터에 해당하는 인덱스의 값 증가

for i in range(len(count)): # 리스트에 기록된 정렬 정보 확인
    for j in range(count[i]):
        print(i, end=' ') # 띄어쓰기를 구분으로 등장한 횟수만큼 인덱스 출력

계수 정렬 시간 복잡도

  • 모든 데이터가 양의 정수인 상황에서 데이터의 개수가 N, 데이터 중 최대값의 크기가 K일 때 O(N + K)
    - 앞에서부터 데이터를 하나씩 확인하면서 리스트에서 적절한 인덱스의 값을 1씩 증가시킬 뿐만 아니라 : O(N)
    - 추후에 리스트의 각 인덱스에 해당하는 값들을 확인할 때 데이터 중 최댓값의 크기만큼 반복을 수행해야 하기 때문 : O(K)
  • 데이터의 범위만 한정되어 있다면 효과적으로 사용 가능하며 항상 빠르게 동작

계수 정렬 공간 복잡도

  • 때에 따라서 심각한 비효율성 초래 가능
    - ex) 0과 999,999 단 2개만 존재하더라도 리스트의 크기가 100만 개가 되도록 해야 함
  • 데이터의 범위가 한정되어 있고, 동일한 값의 데이터가 여러 개 등장할 때 적합 ex) 성적(100점 이하)
  • 데이터의 특성을 파악하기 어렵다면 오히려 퀵 정렬을 이용하는 것이 유리
  • 공간 복잡도: O(N + K)

파이썬 정렬 라이브러리

  • 파이썬이 기본 정렬 라이브러리인 sorted() 함수를 제공한다
  • sorted()는 병합 정렬(merge sort; 합병 정렬)을 기반으로 만들어 졌다. (정확히는 병합 정렬과 삽입 정렬의 아이디어를 더한 하이브리드 방식)
  • 병합 정렬은 일반으로 퀵 정렬보다 느리지만, 최악의 경우에도 시간 복잡도 O(NlogN)을 보장한다.
  • 이러한 sorted() 함수는 리스트, 집합, 딕셔너리 자료형 등을 입력받아서 정렬된 결과를 리스트 자료형으로 반환한다.

sorted() 소스 코드

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

result = sorted(array)
print(result)

출력

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

리스트 객체의 내장 함수인 sort() 를 사용하면, 별도의 정렬된 리스트를 반환하지 않고도 기존 리스트의 내부 원소를 바로 정렬할 수도 있다.

sort() 소스 코드

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

array.sort()
print(array)

출력

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

sorted()sort() 를 이용할 때 key 매개변수를 입력받을 수 있다. key 값으로는 하나의 함수가 들어가야 하고, 이는 정렬 기준으로 작용한다.

key 매개변수 활용 소스 코드

array = [('홍길동', 30), ('김민아', 27), ('이동수', 17)]

def setting(data):
	return data[1]

result = sorted(array, key=setting)
print(result)

result.sort(key=lambda x: x[1], reverse=True)
print(result)

출력

[('이동수', 17), ('김민아', 27), ('홍길동', 30)]

[('홍길동', 30), ('김민아', 27), ('이동수', 17)]

정렬 라이브러리의 시간 복잡도

  • 정렬 라이브러리는 항상 최악의 경우에도 시간 복잡도 O(NlogN)을 보장한다. (우리가 직접 구현하는 퀵 정렬보다 효율적)
  • 문제에서 별도의 요구가 없다면 단순히 정렬해야 하는 상황에서는 기본 정렬 라이브러리를 사용하고, 데이터의 범위가 한정되어 있고 더 빠르게 동작해야 할 필요가 있을 때는 계수 정렬을 사용하자.

코딩테스트 정렬 알고리즘 문제 유형

  1. 정렬 라이브러리로 풀 수 있는 문제
    • 단순히 정렬 기법을 알고 있는지 확인.
    • 기본 정렬 라이브러리 방법을 숙지하고 있으면 어렵지 않음
  2. 정렬 알고리즘의 원리에 대해서 물어보는 문제
    • 선택 정렬, 삽입 정렬, 퀵 정렬 등의 원리를 알고 있어야 문제를 풀 수 있음
  3. 더 빠른 정렬이 필요한 문제
    • 퀵 정렬 기반의 정렬 기법으로는 풀 수 없고,
    • 계수 정렬 등의 다른 정렬 알고리즘을 이용하거나
    • 문제에서 기존에 알려진 알고리즘의 구조적인 개선을 거쳐야 풀 수 있음

0개의 댓글