데이터를 특정한 기준에 따라 순서대로 나열하는 것
데이터가 적을 때, 데이터가 많은데 범위가 특정하게 한정되어 있을 때 등 다양한 상황이 많은데 상황에 맞게 적절한 정렬 알고리즘이 공식처럼 사용된다.
처리되지 않은 데이터 중 가장 작은 데이터를 선택해 맨 앞에 있는 데이터와 자리를 바꾸는 것 반복한다.
동작 예시



코드로 확인
array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
# 0부터 데이터의 전체 수 -1까지 반복
# i: 가장 작은 데이터와 바뀔 가장 앞쪽 인덱스 위치
for i in range(len(array)):
# 가장 작은 원소의 인덱스
min_index = i
# 선형 탐색 -> 가장 작은 값 찾기
for j in range(i+1, len(array)):
# 가장 작은 원소보다 더 작은 원소가 있다면 그 값이 min_index가 되도록함
if array[min_index] > array[j]:
min_index = j
# 자리 변경
array[i], array[min_index] = array[min_index], array[i]
print(array)
처리되지 않은 데이터를 하나씩 확인하면서 골라 매번 계산해서 적절한 위치에 삽입
선택 정렬보다 복잡하지만 더 빠르고 효율적
동작 예시



코드로 확인
array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
# 가장 앞은 정렬되어 있다고 생각하기 때문에 1부터 데이터의 전체 수 -1까지 반복
# 왼쪽으로 이동하면서 위치를 바꿔가는 거
for i in range(1, len(array)):
# i부터 1까지 -1씩 감소하며 반복
# j: 삽입하고자하는 원소의 현재 위치
for j in range(i, 0, -1):
# 자리 변경
if array[j] < array[j-1]:
array[j], array[j-1]=array[j-1], array[j]
else:
break
print(array)
데이터의 특성과 관련없이 표준적으로 사용할 수 있는 정렬 알고리즘 중 하나다.
기준 데이터를 고른 뒤에 그 기준보다 큰 데이터와 작은 데이터의 위치를 바꾸는 방법이다.
일반적인 상황에서 가장 많이 사용되는 정렬 알고리즘
병합 정렬과 더불어 대부분의 언어의 정렬 라이브러리의 근간이 되는 알고리즘이다.
정렬을 수행하는 과정에서 기준 데이터, pivot이라는 것을 설정하게 되는데, 가장 기본적인 퀵 정렬에서는 첫 번째 데이터를 pivot으로 설정하게 된다.
동작 예시
가장 첫번째 5를 pivot으로 설정하고
왼쪽에서부터는 pivot보다 큰 데이터를 선택하여 7
오른쪽에서부터는 pivot보다 작은 데이터를 선택하여 4
그래서 이 7과 4 두 값의 위치를 바꿔준다.

다음으로 다시 왼쪽부터는 pivot보다 큰 9, 오른쪽부터는 pivot보다 작은 2를 선택하고 바꾼다.

똑같이 진행하는데 만약 이와 같이 위치가 넘어가서 엇갈리게 되는 경우에는
pivot과 자신보다 작은 데이터(1)의 위치를 서로 바꿔주게 된다.

그렇게 되면 기존의 pivot인 5를 기준으로 왼쪽은 모두 5보다 작고, 오른쪽은 5보다 크게 된다.
이렇게 pivot을 기준으로 데이터 묶음을 나누는 작업을 분할(divide)이라고 한다.

그렇다면 이제 왼쪽의 5보다 작은 묶음과, 오른쪽의 5보다 큰 묶음 각각을 하나의 배열로 판단을 해서 각 배열에서 다시 퀵 정렬을 반복해서 정렬될때 까지 재귀적으로 수행하게 된다.
왼쪽은 가장 앞인 1이 pivot이 되고

오른쪽은 가장 앞인 6이 pivot이 된다.

그렇다면 이 퀵은 왜 빠르다고 할까?
이상적인 경우, 분할이 절반씩 일어난다면 전체 연산 횟수는 이 된다.
아래 그림을 보자.
실제로는 pivot을 기준으로 해서 왼쪽 오른쪽으로 분할이 이루어진다고 봐야하겠지만 일단은 절반씩 나뉜다고 가정하고 보자.
그렇다면 이렇게 분할이 이루어질 때마다 데이터의 범위가 절반씩 줄어들게 되기 때문에 전체 높이를 확인했을 때 이라고 볼 수 있다.

그 중에서도 데이터의 범위가 절반씩 줄어들기 때문에 이라고 표현할 수 있겠다.
그래서 이러한 연산 과정을 직관적으로 이해하고자 한다면,
전체 연산 횟수, 너비(데이터의 수, ) X 높이() = 이라고 할 수 있다.
그래서 이 퀵 정렬의 시간 복잡도는 이라고 할 수 있는데, 하지만 최악의 경우 이 될 수도 있다.
만약 첫 번째 원소를 pivot으로 삼았는데, 이미 정렬된 배열에 대해 퀵 정렬을 수행하게 되면 어떻게 될까?

앞서 퀵정렬을 수행했던 것을 생각해보면 0, 1, 2, ... 이런식으로 하나씩 계속 왼쪽은 분리되어 나가고 오른쪽 배열들이 똑같은 과정을 반복하게 될 것이다.
그렇다면 분할이 일어나는 횟수가 과 비례하게 되고 분할을 하기 위해서 매번 선형탐색을 해야하기 때문에 전체 시간 복잡도가 가 되는 것이다.
이러한 이유 때문에 다양한 언어에서 제공하는 표준 정렬 라이브러리에서는 항상 을 보장할 수 있는 형태로 구현해놓았다.
코드로 확인
array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
def quick_sort(array, start, end):
# 원소가 1개인 경우 종료
if start >= end:
return
# 첫번째 원소를 pivot으로 설정
pivot = start
# pivot을 제외한 데이터 중 가장 왼쪽을 left
left = start + 1
# 가장 오른쪽을 right로 설정
right = end
# 엇갈릴때까지 반복
# left가 가리키는 인덱스의 값 보다 right가 가리키는 인덱스의 값이 작다면 엇갈린 것
while(left <= right):
# 왼쪽 -> 오른쪽 (while pivot 보다 큰 데이터를 찾을 때 까지)
while(left <= end and array[left] <= array[pivot]):
left += 1
# 오른쪽 -> 왼쪽 (while pivot 보다 작은 데이터를 찾을 때 까지)
while(right > start and array[right] >= array[pivot]):
right -= 1
# 만약 위치가 엇갈렸다면 "작은 데이터"와 "pivot" 바꿔줌
if(left > right):
array[right], array[pivot] = array[pivot], array[right]
# 엇갈리지 않았다면 "작은 데이터"와 "큰 데이터" 바꿔줌
else:
array[left], array[right] = array[right], array[left]
# 분할 이후 왼쪽 부분과 오른쪽 부분에서 각각 재귀적으로 quick_sort 호출
quick_sort(array, start, right-1)
quick_sort(array, right+1, end)
quick_sort(array, 0, len(array)-1)
print(array)
array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
def quick_sort(array):
# 원소가 1개인 경우 종료
if len(array) <= 1:
return array
# 리스트 슬라이싱 이용
# 첫번째 원소를 pivot으로, 나머지를 tail로
pivot = array[0]
tail = array[1:]
# 컴프리핸션 이용하여 간결하게 바꾸기
# left: tail 안의 원소중 pivot보다 작으면 left에 담도록 하고
left=[x for x in tail if x <= pivot]
# right: tail 안의 원소중 pivot보다 작으면 right에 담도록 하고
right=[x for x in tail if x > pivot]
"""
분할 이후
왼쪽 부분에 대해서 퀵정렬을 수행한 결과 리스트에
현재 pivot 값을 포함하고 있는 리스트를 이어서 붙여주고
다시 오른쪽 부분에 대해서 퀵정렬을 수행한 결과 리스트를 붙여주게 되면
전체 리스트는 모든 원소에 대해서 정렬을 수행한 결과와 같게 된다.
"""
return quick_sort(left) + [pivot] + quick_sort(right)
print(quick_sort(array))
특정한 조건이 부합할 때 사용할 수 있는 매우 빠르게 동작하는 정렬 알고리즘
데이터의 크기 범위가 제한되어 정수 형태로 표현할 수 있을 때 사용 가능하다.
데이터의 개수 , 데이터(양수) 중 최댓값이 일 때, 최악의 경우에도 수행 시간 를 보장한다.
동작 예시




이처럼 데이터의 크기 범위만큼 배열을 만들어줘야 하기 때문에 공간 복잡도가 높지만 조건만 만족한다면 퀵정렬보다 더 빠르게 동작하게 된다.
코드로 확인
array=[7, 5, 9, 0, 3, 1, 6, 2, 9, 1, 4, 8, 0, 5, 2]
# 모든 범위를 포함하는 리스트를 만들기 위해 max(array) -> 9 + 1(0포함)
# 모든 값을 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=" ") # 0 0 1 1 2 2 3 4 5 5 6 7 8 9 9
for j in range(count[i]):
print(i, end=" ")
그렇기 때문에 이중 중첩 반복문의 시간 복잡도는 다.
따라서 전체 코드의 시간 복잡도는 라고 할 수 있다.

선택 정렬은 매우 간단하며 구현 또한 쉽다.
삽입 정렬의 경우 일반적으로 선택 정렬에 비해서 조금 더 빠르게 동작한다.
퀵 정렬은 구현 방식에 따라서 최악의 경우 시간 복잡도가 이 나올 수도 있다.
계수 정렬은 조건만 만족한다면 매우 빠르게 동작한다.
대부분의 언어에서 지원하는 표준 정렬 라이브러리는 최악의 경우에도 O(NlogN)을 보장하도록 설게되어 있기 때문에 별도로 문제에서 정렬 함수를 구현하라고 만들지 않는다면 일반적으로 정렬 기능을 위해서 호출해서 수행하는 것이 낫다.
이 선택 정렬과 기본 정렬 라이브러리의 수행 시간을 비교하는 코드를 살펴보면 다음과 같다.
from random import randint
import time
# 10,000개 정수 삽입
array=[]
for _ in range(10000):
# 1부터 100 사이 랜덤한 정수
array.append(randint(1, 100))
# 선택 정렬 성능 측정 시작
start_time = time.time()
# 선택 정렬
for i in range(len(array)):
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]
# 선택 정렬 성능 측정 종료
end_time=time.time()
print(f"선택 정렬: {end_time - start_time}")
# ===============================================
# 배열 다시 무작위 초기화
array = []
for _ in range(10000):
array.append(randint(1, 100))
start_time = time.time()
# 기본 정렬 라이브러리
array.sort()
end_time=time.time()
print(f"기본 정렬 라이브러리: {(end_time - start_time):.10f}")

파이썬의 경우 병합 정렬을 기반으로 하는 하이브리드 방식의 알고리즘을 사용하기 때문에 최악의 경우에도 의 시간복잡도를 보장한다.
7 4
4 3 3 2 5 1 7
3 3 5 2 1 6 7
35
N, K = map(int, input().split())
A = list(map(int, input().split()))
B = list(map(int, input().split()))
# 배열 A 오름차순
A.sort()
# 배열 B 내림차순
B.sort(reverse=True)
print(f"\n교체전\nA: {A}\nB: {B}")
# 첫 번째 인덱스부터 확인하며 최대 K번 비교
for i in range(K):
# 그래서 매번 A의 원소가 B보다 작은 경우에는 두 원소를 교체해서
# 배열 A의 합이 더 커지게 만들고
if A[i] < B[i]:
A[i], B[i] = B[i], A[i]
# A의 원소가 B보다 크거나 같으면 반복문 탈출함
else:
break
print(f"\n교체후\nA: {A}\nB: {B}")
# A의 모든 원소 합 출력
print(sum(A))

순차 탐색: 가장 기본적인 데이터 탐색 알고리즘으로 리스트 안에 존재하는 특정한 데이터를 찾기위해 앞에서부터 단순히 데이터를 하나씩 확인하는 방법
1.1. 선택 정렬 에서 다루었던 선택 정렬에서 매 단계마다 가장 작은 크기의 데이터를 찾는 과정도 이러한 순차 탐색을 이용했다고 볼 수 있다. 이진 탐색: 정렬되어 있는 리스트에서 특정한 데이터를 빠르게 탐색할 수 있도록 해주는 탐색 알고리즘
동작 예시



이는 단계마다 탐색 범위를 2로 나누는 것과 동일하기 때문에 연산 횟수는 에 비례한다.
예를들어 초기 데이터 개수가 32개일 때, 이상적으로 1단계를 거치면 16개, 2단계는 8개, 3단계는 4개 정도만 남게 된다. 다시 말해 이진 탐색은 탐색 범위를 절반씩 줄이며, 시간 복잡도는 을 보장한다.
코드로 확인 (재귀함수 ver)
# 재귀함수
def binary_search(array, target, start, end):
# 탐색하고자 하는 범위에 데이터가 존재하지 않는 것과 동일하니까 None반환시키고
if start > end: return None
# 2를 나눈 몫(소수점떨구기)
mid = (start + end) // 2
# 찾은 경우 중간점 인덱스 반환
if array[mid] == target:
return mid
# 중간점 값보다 찾고자 하는 값이 작은 경우 왼쪽 확인
elif array[mid] > target:
return binary_search(array, target, start, mid-1)
# 중간점 값보다 찾고자 하는 값이 큰 경우 오른쪽 확인
else:
return binary_search(array, target, mid+1, end)
# N: 원소개수 / target: 찾고자하는 값
N, target = list(map(int, input().split()))
# 전체 원소
array = list(map(int,input().split()))
# 이진 탐색 수행 결과 출력
result = binary_search(array, target, start=0, end=N-1)
if result == None:
print("원소가 존재하지 않습니다!")
else:
print(f"찾고자 하는 값 \"{array[result]}\"은(는) \"{result+1}\"번째 위치에 존재합니다!")

# 반복문
def binary_search(array, target, start, end):
# 탐색하고자 하는 범위에 데이터가 존재할동안 while
while start <= end:
# 2를 나눈 몫(소수점떨구기)
mid = (start + end) // 2
# 찾은 경우 중간점 인덱스 반환
if array[mid] == target:
return mid
# 중간점 값보다 찾고자 하는 값이 작은 경우 왼쪽 확인
elif array[mid] > target:
end = mid -1
# 중간점 값보다 찾고자 하는 값이 큰 경우 오른쪽 확인
else:
start = mid + 1
# 범위에 없으면 None 반환
return None
# N: 원소개수 / target: 찾고자하는 값
N, target = list(map(int, input().split()))
# 전체 원소
array = list(map(int,input().split()))
# 이진 탐색 수행 결과 출력
result = binary_search(array, target, start=0, end=N-1)
if result == None:
print("원소가 존재하지 않습니다!")
else:
print(f"찾고자 하는 값 \"{array[result]}\"은(는) \"{result+1}\"번째 위치에 존재합니다!")

코딩테스트에서 문제 풀이를 위해 알아두면 좋은 라이브러리가 있는데
bisect_left(a, x)bisect_right(a, x)
from bisect import bisect_left, bisect_right
a = [1, 2, 4, 4, 8]
x = 4
print(bisect_left(a, x)) # 2
print(bisect_right(a, x)) # 4
따라서 이를 이용하면 값이 특정 범위에 속하는 데이터의 개수를 구할 수도 있다.
from bisect import bisect_left, bisect_right
# a = [1, 2, 4, 4, 8]
# x = 4
# print(bisect_left(a, x)) # 2
# print(bisect_right(a, x)) # 4
def count_by_range(a, left_value, right_value):
left_index = bisect_left(a, left_value)
right_index = bisect_right(a, right_value)
return right_index-left_index
a = [1, 2, 3, 3, 3, 3, 4, 4, 8, 9]
# 값이 4인 데이터 개수 출력
print(count_by_range(a, 4, 4)) # 6 8 -> 2개
# 값이 [4, 8] 범위에 있는 데이터 개수 출력
print(count_by_range(a, 4, 8)) # 6 9 -> 3개
# 값이 [-1, 3] 범위에 있는 데이터 개수 출력
print(count_by_range(a, -1, 3)) # 0 6 -> 6개
4 6
19 15 10 17
15
적절한 높이를 찾을 때까지 이진 탐색하여 높이 H를 반복 조정한다.
절단기의 높이와 잘린 떡의 길이는 반비례하기 때문에 이진 탐색을 수행할 수 있다.
현재 이 높이로 자르게 되면 조건을 만족하는지를 확인한 뒤에 조건의 만족 여부(Y/N)에 따라 탐색 범위를 좁혀서 해결할 수 있다.
즉 최소한 M만큼의 떡을 얻을 수 있는가를 확인하고 조건 만족 여부를 확인하면서 탐색 범위를 좁혀 나가는 것이다. 만약 M만큼의 떡을 얻지 못한다면 절단기 높이를 더 낮출 것이다.
절단기의 높이가 0부터 10억까지의 정수라고 했으니 단순히 선형 탐색과 같은 알고리즘을 이용하면 시간 초과 판정을 받을 수 있기 때문에 이렇게 큰 탐색 범위를 보면 가장 먼저 이진 탐색을 떠올려야 한다.
동작 예시




이와 같이 이진 탐색을 수행하여 더 이상 탐색 범위를 줄어 들지 못할 때까지 시작점과 끝점의 위치를 바꾸어가며 높이 값을 바꾸어 가며 잘랐을 때 조건을 만족하는지를 체크하는 방식으로 문제에서 요구하는 최적의 해를 구할 수 있는 것이다.
동작했던 과정을 본다면 중간점의 값이 시간이 지날수록 최적화된 값이 되기 때문에, 과정을 반복하며 중간점의 값을 기록하면 된다.
# N: 떡의 개수 / M: 요청한 떡의 길이(M)
N, M = list(map(int, input().split(" "))) # 4 6
# 각 떡의 개별 높이 정보
array = list(map(int, input().split())) # 19 15 10 17
# 초기 시작점과 끝점
start = 0
end = max(array)
result = 0
# 시작점이 끝점보다 작거나 같을 때까지 반복
while(start <= end):
total = 0
# 중간점(절단기 높이)
mid = (start+end) // 2
for x in array:
# 잘랐을 때의 떡의 길이
if x > mid:
total += x - mid
# 자른 떡의 길이가 M보다 부족하면 -> 왼쪽 부분 탐색
if total < M:
print(f"자른 떡의 길이의 총합은 {total}(으)로, 요청한 떡의 길이인 {M}보다 작습니다.\n재조정...\n")
end = mid-1
# 자른 떡의 길이가 M과 비교해서 충분하면 -> 오른쪽 부분 탐색
else:
# 최대한 덜 잘른 최적의 때가 정답이므로, 여기서 result에 기록
print(f"자른 떡의 길이의 총합은 {total}(으)로, 요청한 떡의 길이인 {M}을(를) 만족합니다.\n최적 높이 갱신: {mid}\n")
result = mid
start = mid+1
print(f"최적의 절단기 높이: {result}")

7 2
1 1 2 2 2 2 3
4

from bisect import bisect_left, bisect_right
def count_by_range(array, left_value, right_value):
left_index = bisect_left(array, left_value)
right_index = bisect_right(array, right_value)
return right_index - left_index
# N: 데이터 개수 / x: 찾고자하는 값
N, x = map(int, input().split())
array = list(map(int, input().split()))
# [x, x] 범위에 있는 데이터의 개수 계산
count = count_by_range(array, x, x)
# x인 원소가 존재하지 않는다면
if count == 0:
print(-1)
# 값이 x인 원소가 존재한다면
else:
print(f"찾는 \"{x}\"는 현재 수열 {array}에 \"{count}\"개 존재합니다.")
