[백준] 수 정렬하기 2

최동혁·2022년 12월 6일
0

백준

목록 보기
11/68

수 정렬하기 2

solved_ac[Class2][수 정렬하기 2](https://www.acmicpc.net/problem/2751)

문제

N개의 수가 주어졌을 때, 이를 오름차순으로 정렬하는 프로그램을 작성하시오.

입력

첫째 줄에 수의 개수 N(1 ≤ N ≤ 1,000,000)이 주어진다. 둘째 줄부터 N개의 줄에는 수가 주어진다. 이 수는 절댓값이 1,000,000보다 작거나 같은 정수이다. 수는 중복되지 않는다.

출력

첫째 줄부터 N개의 줄에 오름차순으로 정렬한 결과를 한 줄에 하나씩 출력한다.

예제 입력 1

5
5
4
3
2
1

예제 출력 1

1
2
3
4
5

문제 해석

파이썬에서 제공하는 sort 함수를 이용해서 풀면 된다.

하지만 정렬들의 시간 복잡도에 대해서 알아보자.

정렬 알고리즘

정렬

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

선택 정렬

  • 가장 작은 데이터를 선택해 첫 번째 위치의 데이터와 바꾸고, 그 다음으로 작은 데이터를 선택해서 두 번째 위치의 데이터와 바꾸는 식으로 (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)

시간복잡도

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

삽입 정렬

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

삽입 정렬 과정

  • 첫 번째 데이터는 그 자체로 정렬되어 있다고 판단하고 두 번째 데이터가 어느 위치에 들어갈 지 판단
  • 그 다음 순서의 데이터가 앞까지의 정렬된 데이터들 중 어느 위치에 들어갈 지 판단하고 삽입하는 과정 반복 (총 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)

시간복잡도

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

퀵 정렬

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

퀵 정렬 과정

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

image

퀵 정렬 소스 코드

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

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)

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

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

계수 정렬

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

계수 정렬 과정

  • 가장 큰 데이터와 가장 작은 데이터의 범위가 모두 담길 수 있도로 리스트 생성(0으로 초기화)
    • ex) 각각 9, 0일 경우 크기가 10인 리스트 선언
  • 데이터를 하나씩 확인하며 데이터의 값과 동일한 인덱스의 데이터를 1씩 증가시킴
  • 결과적으로 리스트에는 각 데이터가 몇 번 등장했는지 그 횟수에 따라 기록된다. 정렬 결과를 눈으로 확인하고 싶다면, 리스트의 첫 번째 데이터부터 하나씩 그 값만큼 인덱스를 출력하면 된다.
# 모든 원소의 값이 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)
  • 데이터의 범위만 한정되어 있다면 효과적으로 사용 가능하며 항상 빠르게 동작

파이썬 정렬 라이브러리

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

sorted() 소스 코드

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

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

sort() 소스 코드

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

array.sort()
print(array)

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)을 보장한다. (우리가 직접 구현하는 퀵 정렬보다 효율적)
  • 문제에서 별도의 요구가 없다면 단순히 정렬해야 하는 상황에서는 기본 정렬
  • 라이브러리를 사용하고, 데이터의 범위가 한정되어 있고 더 빠르게 동작해야 할 필요가 있을 때는 계수 정렬을 사용하자.

풀이

  • 리스트의 길이 입력 받음

  • 리스트 초기화

  • 리스트의 길이 만큼 루프를 돌면서 입력 받기

  • sort를 써서 오름차순으로 정렬

  • 루프를 돌면서 출력

import sys

n = int(sys.stdin.readline())
list = []
for i in range(n):
    list.append(int(sys.stdin.readline()))
    
list.sort(reverse=False)

for i in range(n):
    print(list[i])       

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

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

0개의 댓글