자기개발/프로그래밍 알고리즘

이진 탐색 (Binary Search)

KGW2027 2022. 12. 9. 00:32
728x90
반응형

이진 탐색은 PS에서 정말 자주 쓰이는 알고리즘이다.

근데 알고는 있는데, 뭔가 자꾸 왼쪽.. 오른쪽.. 최대.. 최소.. 하면서 헷갈리게 된다.

글을 쓰고 나면 좀 이 헷갈림이 사라질까..


이진 탐색은 정렬된 데이터에서 특정한 값을 O(log N)시간에 찾기 위한 알고리즘이다.

데이터가 정렬되어져있기 때문에, 배열의 중간값과 찾으려는 값의 크기를 비교하면서 탐색하게 된다.

데이터가 오름차순으로 정렬됬다고 생각해보자. ( 1, 2, .... N )

그렇다면 다음의 두 명제는 자명할 것이다.

 - 만약, 찾으려는 값이 중간값보다 크다면, 찾으려는 값은 중간값보다 오른쪽에 있을 것이다.

 - 만약, 찾으려는 값이 중간값보다 작다면, 찾으려는 값은 중간값보다 왼쪽에 있을 것이다.

이 명제를 활용해 값을 찾는 방식이 이진 탐색이다.

 

이 방식을 알고리즘으로 구현한다고 생각하고 모든 경우의 수의 작동 순서를 생각해보자.

배열은 [ 1, 2, ... , 9 ] 이다.

 


1. 먼저, Key 0 ~ 8의 중간값인 Key=4와 비교한다.

Arr[4]의 값은 5다.

만약 찾는 값이 5라면? return key;

만약 찾는 값이 <5라면? 그 값이 Key=4보다 왼쪽에 있음이 자명하므로, 다음 탐색 구간은 Key 0 ~ 3이다.

만약 찾는 값이 <5라면? 그 값이 Key=4보다 오른쪽에 있음이 자명하므로, 다음 탐색 구간은 Key 5 ~ 8이다.

 

 1-L) Key 0 ~ 3의 중간값은 1.5이므로, Key=1과 비교한다.

 Arr[1]의 값은 2다.

 만약 찾는 값이 2라면? return key;

 만약 찾는 값이 <2라면? 그 값이 Key=1보다 왼쪽에 있음이 자명하므로, 다음 탐색 구간은 Key 0 ~ 0 이다.

 만약 찾는 값이 >2라면? 그 값이 Key=1보다 오른쪽에 있음이 자명하므로, 다음 탐색 구간은 Key 2 ~ 3 이다.

 

 1-L-L) Key 0 ~ 0 의 중간 값은 0이므로, Key=0과 비교한다.

  Arr[0]의 값은 1이다.

 만약 찾는 값이 1이라면? return key;

 이외의 경우는 존재하지 않는다.

 

 1-L-R) Key 2 ~ 3 의 중간 값은 2.5이므로, Key=2와 비교한다.

 Arr[2]의 값은 3이다.

 만약 찾는 값이 3이라면? return key;

 만약 찾는 값이 >3이라면? 그 값이 Key=2보다 오른쪽에 있음이 자명하므로, 다음 탐색 구간은 Key 3 ~ 3 이다.

 왼쪽에 있는 경우는 없다.

 

 1-L-R-R) Key 3 ~ 3 의 중간 값은 3이므로, Key=3과 비교한다.

 Arr[3]의 값은 4이다.

 만약 찾는 값이 4라면? return key;

 이외의 경우는 존재하지 않는다.


간단하게 중간값을 포함한 왼쪽 수 1~5를 탐색할 때의 경우의 수를 모두 적어봤다.

이 과정에서 계속 반복된 Arr[key]의 값을 찾는 값(Find)와 비교하는 과정을 코드로 작성해보면 이렇다.

int left, right;
int key = (left + right) / 2;

if(arr[key] == find) { // 찾는 수가 key에 있다면 반환
    return key;
}

if(find > arr[key]) { // 찾는 수가 배열의 수보다 크면, 오른쪽에 있음. -> 왼쪽 범위를 줄임
    left = key+1;
}

if(find < arr[key]) { // 찾는 수가 배열의 수보다 작으면, 왼쪽에 있음. -> 오른쪽 범위를 줄임
    right = key-1;
}

이제 left=0, right=arr.length-1 일 때 이 과정을 반복하기만 하면 된다.

반복문을 사용하려면 반복을 종료하는 조건이 필요한데, 이것은 배열에 찾으려는 값이 없을 때 필요하므로 생각해보자.

 

위에서 쭉 적어봤던 이진 탐색의 흐름을 생각해보면, Find값을 찾아 left와 right의 범위가 점점 줄어든다.

계속 찾지 못하면 결국 left와 right가 같아지는 시점이 오는데, ( 위의 1-L-L이나 1-L-R-R 같은 경우 )

이 때도 발견하지 못하면 'left = right = key'인 상황에서 'left = key+1', 'right = key-1' 중에 하나가 실행된다.

어느 쪽이 실행되던 그 결과는 left = right + 1이고, 이 경우 탐색 범위는 -1이므로 탐색할 수 없다.

그러므로 반복문의 조건은 left가 right보다 작거나 같을 때로 한다면, 모든 경우의 수를 탐색할 수 있을 것이다.

그리고, 값을 찾지 못한 경우에는 반복문 바깥에서 -1을 반환한다.

 

이를 코드로 정리해보면 다음과 같다.

int[] array = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9}
int left = 0;
int right = array.length-1;

for(int key = (left+right)/2 ; left <= right ; key = (left+right)/2) {
    if(find == array[key]) { // 같은 경우
        return key;
    } else if (find > array[key]) { // 찾으려는 값이 큰 경우
        left = key + 1;
    } else if (find < array[key]) { // 찾으려는 값이 작은 경우
        right = key - 1;
    }
}

return -1;

 


자, 그러면 기본적인 이진 탐색에 대해서는 알아봤으니까....

 

 좀 더 머리를 아프게 만들 이진 탐색에 대해서 추가로 알아보자.

배열에 중복된 값이 있고, 그 중복된 값 중에서 가장 왼쪽 혹은 가장 오른쪽의 값을 반환하는 방법이다.

자 심플한 '중복이 있는 배열'을 가져와봤다.

우리는 여기서 5를 찾아야 하는데, 가장 왼쪽에 있는 5의 Key가장 오른쪽의 5의 Key를 찾아보자.

 

 먼저 왼쪽 5를 찾는 방법이다.

기본적인 이진 탐색 순서에 따라서 arr[4] 와 5를 비교해보면, 같다.

그러나 우리는 arr[4]의 5가 가장 왼쪽에 있는 5인지 확신할 방법없다.

그렇다면 어떻게 해야 할까?

 1. arr[4]부터 왼쪽으로 key를 1씩 줄여나가면서 5가 아닌 지점의 key+1이 가장 왼쪽이다.

가능한 방법이지만, 그러면 최악의 경우 O(N/2) 이므로, O(logN)시간을 지킬 수 없다.

 

 그렇기 때문에 이진 탐색의 룰을 그대로 따르는 방법에 대해 생각해보자.

우리는 Find값을 찾았지만, 이 값이 '현재의 중앙보다 왼쪽'에 추가로 더 있는지 확인할 필요가 있다.

'왼쪽'이다.

기본 이진 탐색에서 우리가 왼쪽 값을 탐색하려면? right = key-1을 통해 탐색 범위를 중앙의 왼쪽으로 좁혀줬었다.

여기서도 똑같은 방법으로 해보자.

 

그러면 조건문은 다음과 같다.

Find > arr[key] : left = key+1 // 찾는 값이 중앙값보다 크므로 오른쪽으로 범위를 좁힌다.
Find <= arr[key] : right = key-1 // 찾는 값이 왼쪽에 있거나 탐색해야하므로 왼쪽으로 범위를 좁힌다.

이 방식을 따라서 한 번 계산을 손으로 진행해보자.

 


 1. 먼저, arr[4]에서 왼쪽으로 범위를 좁혔으니 다음 범위는 0~3이고, 중앙 값은 1이다.

 Find = 5 > arr[1] = 3 이므로, left = key + 1 = 2이다.

 

 2. 범위는 2 ~ 3 이고, 중앙 값은 2이다.

 Find = 5 > arr[2] = 3 이므로, left = key + 1 = 3 이다.

 

 3. 범위는 3 ~ 3 이고, 중앙 값은 3이다.

 Find = 5 == arr[3] = 5 이므로, right = key - 1 = 2 이다.

 

 4. 범위는 3 ~ 2 이므로 반복이 종료된다.

 

 


가장 마지막에 탐색한 값인 left가 가장 왼쪽 값이 됬다.

그러므로, 반복이 종료된 후 return left;를 하면 가장 왼쪽의 5를 반환할 수 있을 것이다.

 

예외 검사를 위해 하나 더 가정을 해보자.

배열에서 참고했던 첫 중앙 값이 가장 왼쪽 값인 경우다.

이 배열에 대해 위에서 새로 구한 가장 왼쪽 값의 key를 구하는 이진 탐색을 진행해보자.

 


1. 범위는 0 ~ 6, 중앙 값은 3

 Find = 5 == key[3] = 5 이므로, right = key - 1 = 2 이다.

 

 2. 범위는 0 ~ 2, 중앙 값은 1

 Find = 5 > key[1] = 3 이므로, left = key + 1 = 2 이다.

 

 3. 범위는 2 ~ 2, 중앙 값은 2

 Find = 5 > key[2] = 3 이므로, left = key + 1 = 3 이다.

 

 4. 범위는 3 ~ 2, 반복문이 종료된다.

 


이 경우도 left 값이 key = 3으로 올바른 값을 반환한 것을 볼 수 있다.

 

그렇다면 이를 바탕으로 가장 왼쪽 값의 Key를 구하는 이진 탐색 코드를 작성해보면

int[] arr = // 숫자 배열
int left = 0;
int right = arr.length - 1;

for(int key = (left+right) / 2 ; left <= right ; key = (left+right) / 2) {
    if(find > arr[key]) left = key+1;
    else right = key-1;
}

return left;

이 된다.

 

이 방식이 증명됬으므로, 사실 오른쪽 값의 Key를 구하는 방법도 쉽게 구할 수 있다.

우리가 처음에 왼쪽 Key를 구하기 위한 방법에 대해 접근할 때 했듯이, 오른쪽 Key에 대해서도 똑같이 하면된다.

find == arr[key] 일 때 left = key + 1을 하면, right값이 오른쪽 key가 될 것이다.

위의 코드를 참조하면 다음과 같다.

if(find >= arr[key]) left = key+1;
else right = key-1;

좀 더 역할이 반전됬음을 직관적으로 표현하면

if(find < arr[key]) right = key-1;
else left = key+1;

이다. 

 

이 방식이 실제로 가장 오른쪽 key값을 구할 수 있는지 방금 테스트 했던 배열로 검사해보자.

 


1. 범위는 0 ~ 6, 중앙 값은 3

 Find = 5 == arr[3] = 5 이므로, left = key + 1 = 4

 

2. 범위는 4 ~ 6, 중앙 값은 5

 Find = 5 < arr[5] = 7 이므로, right = key - 1 = 4

 

3. 범위는 4 ~ 4, 중앙 값은 4

 Find = 5 == arr[4] = 5 이므로, left = key + 1 = 5

 

4. 범위는 5 ~ 4 반복문이 종료된다.

 

종료시 변수 상태 : left = 5, right = 4


 

 이를 통해, 왼쪽 값을 구할 때의 내용을 완전히 반전시키면 오른쪽 값을 구하는 내용으로 쓸 수 있다는 사실을 알았다.

확실히 과정을 따라가면서 글로 정리를 하니, 기존보다 좀 더 이해가 잘 되는 느낌이다.

그래도 이 값이 오른쪽에 있으니 왼쪽 범위를 오른쪽으로 좁혀서..

막 이렇게 왔다갔다 해버리니까 좀 어지럽다. 이과할걸...!

 

백준에서 이진 탐색에 대한 도전 카테고리가 있다. https://www.acmicpc.net/step/29 

위에 적어둔 링크에 들어가면 풀 수 있는데, 5단계 까지는 순수 이진 탐색으로 푸는 문제이므로 개념 공부를 위해 풀어보면 좋을 것 같다. 6단계부터는 이진 탐색은 이진 탐색인데.... 이걸 아니라고 하기도 뭐하고... 뭐 이런 느낌이라 개념 공부중이라면 비추

728x90
반응형