5-1. 이진탐색 개념 & 실전 문제

Speedwell🍀·2022년 4월 5일
0
post-thumbnail

이번 장에서는 리스트 내에서 데이터를 매우 빠르게 탐색하는 이진 탐색 알고리즘에 대해 다룬다.

이진 탐색을 알아보기 전에 가장 기본 탐색 방법인 순차 탐색을 공부해보자!

리스트 안에 있는 특정한 데이터를 찾기 위해 앞에서부터 데이터를 하나씩 차례대로 확인하는 방법

  • 보통 정렬되지 않은 리스트에서 데이터를 찾아야 할 때 사용한다.

  • 장점👍: 시간만 충분하면 리스트 내에 데이터가 아무리 많아도 항상 원하는 원소(데이터)를 찾을 수 있다.

  • 엄청 자주 사용된다.

    • 리스트에 특정 값의 원소가 있는지 체크할 때
    • 리스트 자료형에서 특정한 값을 가지는 원소의 개수를 세는 count() 메서드
  • 데이터 정렬 여부와 상관없이 가장 앞에 있는 원소부터 하나씩 확인해야 한다.
    ➡️ 데이터 개수가 N개일 때, 최대 N번의 비교 연산이 필요하므로 순차 탐색의 최악의 경우 시간 복잡도는 O(N)


# 순차 탐색 소스코드 구현
def sequential_search(n, target, array):
	# 각 원소를 하나씩 확인하며
    for i in range(n):
    	# 현재의 원소가 찾고자 하는 원소와 동일한 경우
        if array[i] == target:
        	return i + 1 # 현재의 위치 반환 (인덱스는 0부터 시작하므로 1 더하기)

print("생성할 원소 개수를 입력한 다음 한 칸 띄고 찾을 문자열을 입력하세요.")
input_data = input().split()
n = int(input_data[0]) # 원소의 개수
target = input_data[1] # 찾고자 하는 문자열

print("앞서 적은 원소 개수만큼 문자열을 입력하세요. 구분은 띄어쓰기 한 칸으로 합니다.")
array = input().split()

# 순차 탐색 수행 결과 출력
print(sequential_search(n, target, array))

배열 내부의 데이터가 정렬되어 있어야만 사용할 수 있는 알고리즘

특징

  • 데이터가 이미 정렬되어 있다면 매우 빠르게 데이터를 찾을 수 있다.

    • 데이터가 무작위일 때는 사용 불가!
  • 탐색 범위를 절반씩 좁혀가며 데이터를 탐색한다.

  • 위치를 나타내는 변수 3개를 사용한다. - 시작점, 끝점, 중간점
    ➡️ 찾으려는 데이터와 중간점 위치에 있는 데이터를 반복적으로 비교해서 원하는 데이터를 찾는다.

중간점이 실수일 때는 소수점 이하를 버린다.


이진 탐색의 시간 복잡도

한 단계를 거칠 때마다 확인하는 원소가 평균적으로 절반으로 줄어든다.
➡️ 단계마다 2로 나누는 것과 동일하므로 log₂N에 비례
➡️ O(logN)


이진 탐색 소스코드 구현

재귀함수

#이진 탐색 소스코드 구현 (재귀 함수)
def binary_search(array, target, start, end):
	if start > end:
    	return None
    mid = (start + end) // 2
    # 찾은 경우 중간점 인덱스 반환
    if array[mid] == target:
    	return mid
    # 중간점의 값보다 찾고자 하는 값이 작은 경우 왼쪽 확인
    elif array[mid] > target:
    	retrun 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, 0, n - 1)
if result == None:
	print("원소가 존재하지 않습니다.")
else:
	print(result + 1)

몫 구하는 방법
1) 몫 연산자 사용: mid = (start + end) // 2
2) int() 함수 사용: mid = int(start + end)


반복문

# 이진 탐색 소스코드 구현 (반복문)
def binary_search(array, target, start, end):
	while start <= end:
        mid = (start + end) // 2
        # 찾은 경우 중간점 인덱스 반환
        if array[mid] == target:
            return mid
        # 중간점의 값보다 찾고자 하는 값이 작은 경우 왼쪽 확인
        elif array[mid] > target:
            end = mid - 1
        # 중간점의 값보다 찾고자 하는 값이 큰 경우 오른쪽 확인
        else:
            start = mid + 1
	return None

# n(원소의 개수)과 target(찾고자 하는 문자열)을 입력받기
n, target = list(map(int, input().split()))
# 전체 원소 입력받기
array = list(map(int, input().split()))

# 이진 탐색 수행 결과 출력
result = binary_search(array, target, 0, n - 1)
if result == None:
	print("원소가 존재하지 않습니다.")
else:
	print(result + 1)

코딩 테스트에서의 이진 탐색

이진 탐색 코드는 꼭 외워두자!!!

이진 탐색의 원리는 다른 알고리즘에서도 폭넓게 적용되는 원리와 유사하기 때문에 매우 중요하다.

높은 난이도의 문제에서는 이진 탐색 알고리즘이 다른 알고리즘과 함께 사용되기도 한다.
➡️ 이진 탐색 코드를 암기해뒀다면 훨씬 풀기 편하다!!


📌 이진 탐색 문제는 탐색 범위가 큰 상황에서의 탐색을 가정하는 문제가 많다.
➡️ 탐색 범위가 2,000만을 넘어가면 이진 탐색으로 접근해보기!!

처리해야 할 데이터의 개수나 값이 1,000만 단위 이상으로 넘어가면 이진 탐색과 같이 O(logN)의 속도를 내야 하는 알고리즘을 떠올리기!!


📌 이진 탐색의 전제 조건 : 데이터 정렬

데이터베이스는 내부적으로 대용량 데이터 처리에 적합한 트리 자료구조를 이용하여 항상 데이터가 정렬되어 있다.
➡️ 데이터베이스에서의 탐색은 이진 탐색과 유사한 방법을 이용해 탐색을 항상 빠르게 수행하도록 설계되어 있어서 데이터가 많아도 탐색하는 속도가 빠르다.

트리 자료구조

노드와 노드의 연결로 표현하며, 여기서 노드는 정보의 단위로서 어떠한 정보를 가지고 있는 개체로 이해할 수 있다.

3장 DFS/BFS에서 그래프를 다룰 때 언급했던 노드와 동일하다.
차이점: 최단 경로에서는 노드가 '도시'와 같은 정점의 의미를 가지지만, 트리 자료구조는 그래프 자료구조의 일종으로 데이터베이스 시스템이나 파일 시스템과 같은 곳에서 많은 양의 데이터를 관리하기 위한 목적으로 사용한다.

특징

  • 트리는 부모 노드자식 노드의 관계로 표현된다.
  • 트리의 최상단 노드를 루트 노드라고 한다.
  • 트리의 최하단 노드를 단말 노드라고 한다.
  • 트리에서 일부를 떼어내도 트리 구조이며 이를 서브 트리라고 한다.
  • 트리는 파일 시스템과 같이 계층적이고 정렬된 데이터를 다루기에 적합하다.

📌 정리하면 큰 데이터를 처리하는 소프트웨어는 대부분 데이터를 트리 자료구조로 저장해서 이진 탐색과 같은 탐색 기법을 이용해 빠르게 탐색이 가능하다.


이진 탐색 트리

트리 자료구조 중에서 가장 간단한 형태
이진 탐색이 동작할 수 있도록 고안된, 효율적인 탐색이 가능한 자료구조

특징

  • 왼쪽 자식 노드 < 부모 노드 < 오른쪽 자식 노드

이진 탐색 트리에 데이터 삽입/제거는 알고리즘보다는 자료구조!
이진 탐색 트리 자료구조 구현 문제는 출제 빈도가 낮다!
➡️ 이진 탐색 트리가 미리 구현되어 있다고 가정하고 데이터 조회 과정만 살펴보자!!


예제를 보자!

  1. 이진 탐색은 루트 노드부터 방문한다. '14'를 찾아보자!

  2. 공식에 따라 부모 노드의 왼쪽 자식 노드는 '10' 이하이므로 왼쪽에 있는 모든 노드는 확인할 필요가 없다. 오른쪽 노드를 방문하자!

  3. 이제 오른쪽 자식 노드인 '17'이 부모 노드가 된다. '17'은 찾는 원소값인 '14'보다 크기 때문에 부모 노드(17)의 오른쪽 자식 노드는 확인할 필요가 없다. 왼쪽 노드를 방문하자!

  4. 현재 방문한 노드의 값인 '14'와 찾는 원소값이 동일하다. 탐색 끝!

자식 노드가 없을 때까지 원소를 찾지 못했다면, 이진 탐색 트리에 원소가 없는 것


📌 빠르게 입력받기

이진 탐색 문제는 입력 데이터가 많거나, 탐색 범위가 매우 넓은 편

  • 데이터의 개수가 1,000만 개가 넘어가거나, 탐색 범위의 크기가 1,000억 이상이면 이진 탐색 알고리즘을 생각해보자!!

➡️ 이처럼 입력 데이터가 많은 문제는 sys 라이브러리의 readline() 함수를 이용하면 시간 초과를 피할 수 있다.


import sys
# 하나의 문자열 데이터 입력받기
imput_data = sys.stdin.readline().rstrip()

# 입력받은 문자열 그대로 출력
print(input_data)

📌 sys 라이브러리를 사용할 때는 rstrip() 함수를 꼭 호출해야 한다!!

readline()으로 입력하면 입력 후 엔터가 줄 바꿈 기호로 입력되는데, 이 공백 문자를 제거하기 위해 rstrip() 함수를 사용해야 한다.


실전 문제

1. 부품 찾기

난이도: 🌕🌗
풀이 시간: 30분
시간 제한: 1초
메모리 제한: 128MB


전자 매장에 부품이 N개 있다. 각 부품은 정수 형태의 고유한 번호가 있다.
어느 날 손님이 M개 종류의 부품을 구매하겠다며 견적서를 요청했다. 손님이 문의한 부품 M개 종류를 모두 확인해서 견적서를 작성해야 한다.

이때 가게 안에 부품이 모두 있는지 확인하는 프로그램을 작성하시오.

입력 조건

  • 첫째 줄에 정수 N이 주어진다. (1 ≤ N ≤ 1,000,000)
  • 둘째 줄에는 공백으로 구분하여 N개의 정수가 주어진다. 이때 정수는 1보다 크고 1,000,000 이하이다.
  • 셋째 줄에는 정수 M이 주어진다. (1 ≤ M ≤ 100,000)
  • 넷째 줄에는 공백으로 구분하여 M개의 정수가 주어진다. 이때 정수는 1보다 크고 1,000,000 이하이다.

출력 조건

  • 첫째 줄에 공백으로 구분하여 각 부품이 존재하면 yes를, 없으면 no를 출력한다.
# 입력 예시
5
8 3 7 9 2
3
5 7 9

# 출력 예시
no yes yes

<해설>

이 문제는 세 방법으로 풀 수 있다.

  1. 이진 탐색
  2. 계수 정렬
  3. 집합 자료형 이용

1) 이진 탐색

  • 매장 내 N개의 부품을 번호를 기준으로 정렬한다.
  • 그 후 M개의 찾고자 하는 부품이 각각 매장에 존재하는지 검사한다.
    ➡️ 정렬이 되어 있기 때문에 이진 탐색을 수행하여 찾을 수 있다.

  1. 부품을 찾는 과정에서 최악의 경우 시간 복잡도 O(M x logN)의 연산이 필요하다.

    • 이론상 최대 약 200만 번의 연산이 이루어진다고 분석할 수 있다.
  2. N개의 부품을 정렬하기 위해 시간 복잡도 O(N x logN)이 요구된다.

    • 이론상 최대 약 2,000만 번의 연산이 필요하다.

➡️ 결과적으로 이진 탐색을 사용하면 O((M + N) x logN)

# 이진 탐색 소스코드 구현 (반복문)
def binary_search(array, target, start, end):
    while start <= end:
        mid = (start + end) // 2
        # 찾은 경우 중간점 인덱스 반환
        if array[mid] == target:
            return mid
        # 중간점의 값보다 찾고자 하는 값이 작은 경우 왼쪽 확인
        elif array[mid] > target:
            end = mid - 1
        # 중간점의 값보다 찾고자 하는 값이 작은 경우 오른쪽 확인
        else:
            start = mid + 1
    return None

# N(가게의 부품 개수) 입력
n = int(input())
# 가게에 있는 전체 부품 번호를 공백을 기준으로 구분하여 입력
array = list(map(int, input().split()))
array.sort() # 이진 탐색을 수행하기 위해 사전에 정렬 수행
# M(손님이 확인 요청한 부품 개수) 입력
m = int(input())
# 손님이 확인 요청한 전체 부품 번호를 공백을 기준으로 구분하여 입력
x = list(map(int, input().split()))

# 손님이 확인 요청한 부품 번호를 하나씩 확인
for i in x:
    # 해당 부품이 존재하는지 확인
    result = binary_search(array, i, 0, n - 1)
    if result != None:
        print('yes', end=' ')
    else:
        print('no', end=' ')

2) 계수 정렬

모든 원소의 번호를 포함할 수 있는 크기의 리스트를 만든 뒤에, 리스트의 인덱스에 직접 접근하여 특정한 번호의 부품이 매장에 존재하는지 확인한다.

# N(가게의 부품 개수) 입력
n = int(input())
array = [0] * 1000001

# 가게에 있는 전체 부품 번호를 입력 받아서 기록
for i in input().split():
    array[int(i)] = 1

# M(손님이 확인 요청한 부품 개수) 입력
m = int(input())
# 손님이 확인 요청한 전체 부품 번호를 공백을 기준으로 구분하여 입력
x = list(map(int, input().split()))

# 손님이 확인 요청한 부품 번호를 하나씩 확인
for i in x:
    # 해당 부품이 존재하는지 확인
    if array[i] == 1:
        print('yes', end=' ')
    else:
        print('no', end=' ')

3) 집합 자료형 이용

📌 단순히 특정한 데이터가 존재하는지 검사할 때 집합 자료형을 사용하면 매우 효과적이다.

set() 함수는 집합 자료형을 초기화할 때 사용한다.

# N(가게의 부품 개수) 입력
n = int(input())
# 가게에 있는 전체 부품 번호를 입력 받아서 집합(Set) 자료형에 기록
array = set(map(int, input().split()))

# M(손님이 확인 요청한 부품 개수) 입력
m = int(input())
# 손님이 확인 요청한 전체 부품 번호를 공백을 기준으로 구분하여 입력
x = list(map(int, input().split()))

# 손님이 확인 요청한 부품 번호를 하나씩 확인
for i in x:
    # 해당 부품이 존재하는지 확인
    if i in array:
        print('yes', end=' ')
    else:
        print('no', end=' ')

2. 떡볶이 떡 만들기

난이도: 🌕🌕
풀이 시간: 40분
시간 제한: 2초
메모리 제한: 128MB


떡볶이 떡의 길이가 일정하지 않은 대신에 한 봉지 안에 들어가는 떡의 총 길이는 절단기로 잘라서 맞춘다.
절단기에 높이(H)를 지정하면 줄지어진 떡을 한 번에 절단한다. 높이가 H보다 긴 떡은 H 위의 부분이 잘릴 것이고, 낮은 떡은 잘리지 않는다.

손님이 왔을 때 요청한 총 길이가 M일 때 적어도 M만큼의 떡을 얻기 위해 절단기에 설정할 수 있는 높이의 최댓값을 구하는 프로그램을 작성하시오.

입력 조건

  • 첫째 줄에 떡의 개수 N과 요청한 떡의 길이 M이 주어진다. (1 ≤ N ≤ 1,000,000, 1 ≤ M ≤ 2,000,000,000)
  • 둘째 줄에는 떡의 개별 높이가 주어진다. 떡 높이의 총합은 항상 M 이상이므로, 손님은 필요한 양만큼 떡을 사갈 수 있다. 높이는 10억보다 작거나 같은 양의 정수 또는 0이다.

출력 조건

  • 적어도 M만큼의 떡을 집에 가져가기 위해 절단기에 설정할 수 있는 높이의 최댓값을 출력한다.
# 입력 예시
4 6
19 15 10 17

# 출력 예시
15

예를 들어 높이가 19, 14, 10, 17cm인 떡이 나란히 있고 절단기 높이를 15cm로 지정하면 자른 뒤 떡의 높이는 15, 14, 10, 15cm가 될 것이다. 잘린 떡의 길이는 차례대로 4, 0, 0, 2cm이다. 손님은 6cm만큼의 길이를 가져간다.


<해설>

전형적인 이진 탐색 문제이자, Parametric Search 유형의 문제이다.

Parametric Search: 최적화 문제를 결정 문제(예 혹은 아니오 답하는 문제)로 바꾸어 해결하는 기법
📌 "원하는 조건을 만족하는 가장 알맞은 값을 찾는 문제"에 주로 사용한다.

  • 예를 들어 범위 내에서 조건을 만족하는 가장 큰 값을 찾으라는 최적화 문제라면 이진 탐색으로 결정 문제를 해결하면서 범위를 좁혀갈 수 있다.
  • 코딩테스트/프로그래밍대회에서는 보통 Parametric Search 유형은 이진 탐색을 이용해 해결한다!

💡 풀이 아이디어: 적절한 높이를 찾을 때까지 절단기의 높이 H를 반복해서 조정하기

➡️ "현재 이 높이로 자르면 조건을 만족할 수 있는가?"를 확인한 뒤에 조건의 만족 여부(예/아니오)에 따라서 탐색 범위를 좁혀 나간다. (범위를 좁힐 때는 이진 탐색의 원리를 이용해 절반씩 좁혀 나간다.)


절단기의 높이(탐색 범위)는 1부터 10억까지의 정수 중 하나
➡️ 이처럼 큰 수를 보면 당연하다는 듯이 가장 먼저 이진 탐색을 떠올려야 한다!!
여기서 순차탐색을 썼다면 분명 시간 초과가 난다. (절단기의 높이 범위가 한정적이었다면 순차탐색 가능)


# 떡의 개수(N)와 요청한 떡의 길이(M)을 입력
n, m = list(map(int, input().split(' ')))
# 각 떡의 개별 높이 정보를 입력
array = list(map(int, input().split()))

# 이진 탐색을 위한 시작점과 끝점 설정
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
    # 떡볶이 양이 부족한 경우 더 많이 자르기 (오른쪽 부분 탐색)
    if total < m:
        end = mid - 1
    # 떡볶이 양이 충분한 경우 덜 자르기 (왼쪽 부분 탐색)
    else:
        result = mid # 최대한 덜 잘랐을 때가 정답이므로, 여기에서 result에 기록
        start = mid + 1

# 정답 출력
print(result)

중간점의 값은 시간이 지날수록 '최적화된 값'을 찾기 때문에, 과정을 반복하면서 얻을 수 있는 떡의 길이 합이 필요한 떡의 길이보다 크거나 같을 때마다 결과값을 중간점(mid) 값으로 갱신해주면 된다.

현재 얻을 수 있는 떡볶이의 양에 따라서 자를 위치를 결정해야 하기 때문에 재귀적으로 구현하는 것은 귀찮은 작업 ➡️ 반복문을 이용해 구현하자!!

0개의 댓글