정렬 알고리즘(sorting algorithm)은 원소들을 일정한 순서대로 열거하는 알고리즘입니다. 정렬 방식에 따라 정렬에 필요한 시간이나 공간 같은 비용이 각기 다르게 소요되는데요. 정렬 알고리즘을 분류하는 기준은 여러가지가 있으나, 이번 글에서는 비교정렬과 비-비교정렬 크게 두 가지로 나누어 소개해보도록 하겠습니다.

1. Sorting Algorithm (비교정렬)

1.1 Comparison Sort

비교정렬에는 시간복잡도 O(n^2)과 O(nlogn)에 따라 다음과 같은 정렬 방법이 존재합니다.

  • O(n^2) 소트: bubble sort, selection sort, insertion sort
  • O(nlogn) 소트: quick sort, merge sort, heap sort
  • O(n^2) 소트와 O(nlogn) 소트를 섞은것: intro sort, tim sort

1.1.1 Bubble Sort

버블 정렬은 배열의 첫 원소부터 순차적으로 진행하며, 현재 원소가 그 다음 원소의 값보다 크면 두 원소를 바꾸는 작업을 반복합니다. 이런 식으로 배열을 계속 살펴보면서 완전히 정렬된 상태가 될 때까지 반복하는 것이 특징입니다.

  • 평균 및 최악 실행시간 : O(n^2)
  • 메모리 : O(1)
  • 가장 느리다. / 잘 안 씀. / 큰 수를 가장 오른쪽으로 옮기는 컨셉.
numbers = [int(param) for param in input().split()]

for i in range(len(numbers)):
  for j in range(len(numbers) - (1+i)):
    if numbers[j] > numbers[j+1]:
      numbers[j], numbers[j+1] = numbers[j+1], numbers[j]
print(' '.join(str(num) for num in numbers))

1.1.2 Selection Sort

배열을 선형 탐색(linear scan)하며 가장 작은 원소를 배열 맨 앞으로 보냅니다. (맨 앞에 있던 원소와 자리를 바꾸게 됩니다.) 그 다음에는 두 번째로 작은 원소를 찾은 뒤 앞으로 보내줍니다. 이 작업을 모든 원소가 정렬될 때까지 반복됩니다.

  • 평균 및 최악 실행시간 : O(n^2)
  • 메모리 : O(1)
numbers = [int(param) for param in input().split()]

# selection sort - (1)
for i in range(len(numbers)):
  for j in range(i, len(numbers)):
    if numbers[i] > numbers[j]:
      numbers[i], numbers[j] = numbers[j], numbers[i]
print(' '.join(str(num) for num in numbers))

# selection sort - (2)
for i in range(len(numbers)):
  min_index = i
  for j in range(i + 1, len(numbers)):
    value = numbers[j]
    if value > numbers[min_index]:
      min_index = j
  numbers[i], numbers[min_index] = numbers[min_index], numbers[i]

1.1.3 Insertion Sort

정렬되지 않은 배열을 정렬된 배열에 삽입합니다.

  • 평균 및 최악 실행시간 : O(n^2)
  • 메모리 : O(1)
numbers = [int(param) for param in input().split(' ')]

# insertion sort - 두 배열을 활용하는 방법 # 첫 번째
list = []
for i in range(len(numbers)):
  is_inserted = False
  for j in range(len(list)):
    if numbers[i] < list[j]:
      is_inserted = True
      list.insert(j, numbers[i])
      break
  if not is_inserted:
    list.append(numbers[i])
print(' '.join(str(num) for num in list))

# insertion sort - 두 배열을 활용하는 방법 # 두 번째
sorted = []
for number in numbers:
  not_inserted = True
  for i in range(len(sorted)):
    if number < sorted[i]:
      not_inserted = False
      sorted.insert(i, number)
      break
  if not_inserted:
      sorted.append(number)

# Insertion Sort - 두 배열을 활용하는 방법  # 세 번째
def insertion_sort(numbers: list) -> list:
  sorted_list = []
  for number in numbers:
    i = 0
    while i < len(sorted_list) and number >= sorted_list[i]:
      i += 1
    sorted_list.insert(i, number)
  return sorted_list

print(sorted)

# insertion sort - 배열 하나로 정렬하는 방법

def delete(list, index):
  new_list = list.copy()

  for i in range(index, len(list) - 1):
    print(new_list)
    new_list[i] = new_list[i+1]
  new_list.pop()
  return new_list

def insert(list, index, new_element):
  new_list = list.copy()
  new_list.append(None)
  for i in range(index +1, len(list) - 1):
    print(new_list)
    new_list[i] = new_list[i+1]
    # new_list[index] = new_element
    # new_list += new_list[index]
  return new_list


result = insert(['a', 'b', 'c', 'd', 'e'], 1, 'f')
print(result)

1.1.4 Merge Sort

Quick Sort를 사용할 수 없을 경우에 매우 유용하게 활용할 수 있습니다.

  • 평균 및 최악 실행시간 : O(nlogn)
    • 분할 => logn
    • 머지 => n
def merge(list1, list2):
  merged_list = []
  i = 0
  j = 0

  while i < len(list1) and j < len(list2):
    if list1[i] < list2[j]:
      merged_list.append(list1[i])
      i += 1
    else:
      merged_list.append(list2[j])
      j += 1

  # 남은 원소가 있는지 확인할 수도 있고
  # if i < len(list1):
  #     while i < len(list1):
  #         merged_list.append(list1[i])
  #         i += 1

  # 확인하지 않을 수도 있다.
  # if j < len(list2):
  #     while j < len(list2):
  #         merged_list.append(list2[j])
  #         j += 1  
  merged_list += list1[i:]
  merged_list += list2[j:]
  return merged_list

# Test
# assert merge([1, 3, 5], [2, 4, 6]) == [1, 2, 3, 4, 5, 6]

def merge_sort(my_list):
  if len(my_list) < 2:
    return my_list

  left = my_list[:len(my_list) // 2]
  right = my_list[len(my_list) // 2:]
  return merge(merge_sort(left), merge_sort(right))
some_list = [11, 3, 6, 4, 12, 1, 2]
print(merge_sort(some_list))

1.1.6 Quick Sort

  • 평균 : O(nlogn)
  • 최악 : O(n^2)
  • 메모리 : O(logn)
# quick sort 첫번째 방법
def quick_sort(a):
  n = len(a)

  if n <= 1:
    return a

  pivot = a[-1]
  g1 = []
  g2 = []
  for i in range(0, n-1):
    if a[i] < pivot:
      g1.append(a[i])
    else:
      g2.append(a[i])
  return quick_sort(g1) + [pivot] + quick_sort(g2)

d = [6, 8, 3, 9, 10, 1, 2, 4, 7, 5]
print(quick_sort(d))
# quick sort 두번째 방법: in-place
def quick_sort_in_list(unsorted_list, start, end):
  if end - start <= 0:
    return
  pivot = unsorted_list[end]
  i = start
  for j in range(start, end):
    if unsorted_list[j] <= pivot:
      unsorted_list[i], unsorted_list[j] = unsorted_list[j], unsorted_list[i]
      i += 1
  unsorted_list[i], unsorted_list[end] = unsorted_list[end], unsorted_list[i]

  quick_sort_in_list(unsorted_list, start, i - 1)
  quick_sort_in_list(unsorted_list, i + 1, end)

def quick_sort(a):
  quick_sort_in_list(a, 0, len(a) - 1)

d = [6, 8, 3, 9, 10, 1, 2, 4, 7, 5]
quick_sort(d)
print(d)

1.2 Non-Comparison Sort (비-비교정렬)

하나 하나 비교하는 것이 아니라, 고차원적인 성질을 비교하는 방법입니다. 주로 정렬해야 하는 대상의 특징이 다를 때 사용합니다. 예를 들면 type이 다를 때 사용해볼 수 있겠네요.

  • 구하는 방법에 관한 예시 : nC2
  • 시간 복잡도 : kn (단, 여기서 k는 자리수)
  • radix sort, counting sort

1.2.1 Radix Sort

낮은 자리수부터 비교하여 정렬하는 알고리즘입니다.

  • 시간복잡도 : O(kn)
    • 데이터 전체 크기에 대한 기수 전체의 테이블이 비례하기 때문입니다.
  • stable
  • Non-Comparison Sort
  • 자리수가 없을 때 못 쓴다는 특징이 있습니다. ex. 벡터값 (1, 3), (2, 4), (3, 6)
# lsd
def radix_lsd(input_list):
  position = 1

  while True:
    empty_lists = [list() for _ in range(10)]
    is_sorted = True

    for number in input_list:
      radix = number // position % 10
      empty_lists[radix].append(number)

      if number // position > 10:
        is_sorted = False

    position *= 10
    input_list.clear()

    for numbers in empty_lists:
      for num in numbers:
        input_list.append(num)

    if is_sorted:
      return input_list
# msd
def find_digit(number, digit, base):
	return (number // base ** digit) % base

def counting_sort_with_digit(input_list, digit, base):
  B = [-1] * len(input_list)
  C = [0] * base # [0] * ((base -1) + 1)
  for item in input_list:
    C[find_digit(item, digit, base)] += 1

  for i in range(base - 1):
    C[i + 1] += C[i]

  for j in reversed(range(len(input_list))):
    B[C[find_digit(input_list[j], digit, base)] - 1] = input_list[j]
    C[find_digit(input_list[j], digit, base)] -= 1
  return B


from math import log

def radix_sort_msd(data, base=10):
  # 입력된 리스트 가운데 최대값의 자릿수 확인
  digit = int(log(max(data), base) + 1)
  # 자릿수 별로 counting sort
  for d in range(digit):
    data = counting_sort_with_digit(data, d, base)
  return data

1.2.2 Counting Sort

요소 값을 명시적으로 비교하지 않아도 정렬할 수 있다.

  • 시간복잡도 : O(n)
  • input_list의 max_value에 따라서 for 문을 도는 횟수가 달라짐에 따라 비효율적일 수 있습니다.
  • max_value가 counting sort의 복잡성을 지배합니다.
  • stable sort
def counting_sort(input_list, max_value):
  result_list = [-1] * len(input_list)
  counting_list = [0] * (max_value + 1)

  for elem in input_list:
    counting_list[elem] += 1

  for i in range(max_value):
    counting_list[i + 1] += counting_list[i]

  for j in reversed(range(len(input_list))):
    result_list[counting_list[input_list[j]] - 1] = input_list[j]
    counting_list[input_list[j]] -= 1

  return result_list

unordered_list = [95, 1, 656, 72, 35, 83, 760, 944, 876, 684, 767, 709]
print(counting_sort(unordered_list, max(unordered_list)))