인덱스 바이너리

마지막 업데이트: 2022년 2월 25일 | 0개 댓글
  • 네이버 블로그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 트위터 공유하기
  • 카카오스토리 공유하기
입출력 예시

이진 탐색(Binary Search) 알고리즘 개념과 예제

이진 탐색은 오름차순으로 정렬되어있는 데이터에서 원하는 값(타겟 넘버)의 위치를 찾아내는 알고리즘이다.

여기서 이진(Binary)는 우리가 알고있는 그 이진 코드 (0101001. ) 가 아니라

데이터를 반(2개)으로 나누어서 비교하고 찾는 방식이여서 이진 탐색이라고 한다.

이진 탐색의 알고리즘 진행방식은 아래와 같다.

  1. 정렬되어있는 데이터의 중간 값을 임의의 값 X로 정함
  2. 타겟 넘버의 값과 X를 비교
  3. 타겟 넘버의 값이 X보다 크다면, 타겟 넘버는 데이터에서 X보다 우측에 위치해 있으니 반으로 나눈 데이터의 우측에서 1번 과정부터 다시 시작
  4. 타겟 넘버의 값이 X보다 작다면, 타겟 넘버는 데이터에서 X보다 좌측에 위치해 있으니 반으로 나눈 데이터의 좌측에서 1번 과정부터 다시 시작

이진 탐색 예시

아래와 같은 데이터가 있다고 가정하자.

1 5 6 8 10 16 25 33

여기서 찾고싶은 값(타겟 넘버)은 25일 때

임의의 값 X를 정하기 위해서 데이터의 중간 값인 8을 X로 정한다.

1 5 6 8 10 16 25 33

이제 타겟 넘버와 X를 비교하였을 때,

타겟 넘버인 25가 X인 8보다 더 크기 때문에 정렬된 데이터에서 258보다 우측 에 있다는 것이 정해졌다.

인덱스 바이너리
1 5 6 8 10 16 25 33

그럼 데이터에서도 비교해야 될 대상이 반으로 줄어들 것이다.

10 16 25 33

현재 비교해야할 데이터의 중간 값인 16을 X의 값으로 정한다.

10 16 25 33

타겟 넘버와 X를 비교하였을 때,

타겟 넘버인 25가 X인 16보다 더 크기 때문에 정렬된 데이터에서 2516보다 우측 에 있다는 것이 정해진 것이다.

10 16 25 33

그럼 또 데이터가 반으로 줄어들 것이다.

25 33

현재 비교해야 할 데이터의 중간 값인 25를 X의 값으로 정한다.

25 33

타겟 넘버와 X를 비교하였을 때,

X = 25 == 타겟 넘버 = 25

이렇게 하여 타겟 넘버인 25를 찾게 되었다.

한번 더 똑같은 데이터로 다른 타겟 넘버를 찾아보겠다.

이번에 사용할 타겟 넘버는 5이다.

임의의 값 X는 처음과 같이 데이터의 중간 값인 8로 시작한다.

1 5 6 8 10 16 25 33

이제, 타겟 넘버와 X를 비교하였을 떄,

X = 8 > 타겟 넘버 = 5

타겟 넘버인 5가 X인 8보다 더 작기 때문에 정렬된 데이터에서 58보다 좌측 에 있다는 것이 정해진 것이다.

1 5 6 8 10 16 25 33

그럼 비교해야 할 데이터가 반으로 줄어들게 될 것이다.

1 5 6

이제 X를 수정 할 차례이다.

현재 비교해야 할 데이터의 중간 값인 5를 X로 정한다.

1 5 6

타겟 넘버와 X를 비교했을 때,

X = 5 == 타겟 넘버 = 5

이렇게 하여 타겟 넘버인 5를 찾게 되었다.

이진 탐색 C언어 예제 코드

위의 이진 탐색 예시를 코드로 표현한 것이다.

low의 값과 high의 값은 중간 값을 찾기 위해서 필요한 값이다.

배열의 첫번째 인덱스는 0으로 시작하기 때문에 low의 값은 0으로 주고,

배열의 마지막 인덱스는 C의 경우는 배열의 요소 개수에서 하나를 뺀 값으로 주어졌다.

그 이유는 마찬가지로 배열은 0부터 시작하기 때문에 마지막 인덱스는 전체 요소의 개수 - 1 이 되기 때문이다.

low의 값이 high의 값 보다 커지게 되면, 데이터에서 타겟 넘버가 없기 때문이다.

mid는 중간 값 X를 찾기 위해 사용되는 변수이다. 중간 값을 찾는 방법으로는 '최소 인덱스와 최대 인덱스를 더한 값을 나누었을 때의 몫'을 사용했다.

조건문에서는 타겟 넘버와 X 값이 일치할 때는 값을 출력,

타겟 넘버가 X보다 클 때는 low의 값을 mid + 1 값으로 바꾸게 된다.

그 이유는, 비교할 필요가 없는 인덱스는 제외 하고 비교해야 할 인덱스들 중에서 중간 값을 찾아야 하기 때문이다.

자바 [JAVA] - 이진 삽입 정렬 (Binary Insertion Sort)

이번 포스팅의 경우 삽입 정렬을 토대로 하기에 반드시 삽입 정렬을 먼저 보고 오시기를 바란다.인덱스 바이너리

만약 필자의 정렬 알고리즘을 시리즈로 보았다면 왜 퀵 정렬 다음에 이진 삽입 정렬을 다루지? 싶을 수도 있다. 일단 왜 그런지부터 말하자면 원래 포스팅 순서대로라면 팀 정렬(Tim Sort)를 다루어야 한다.

그러나 팀 정렬의 경우 Merge Sort(합병 정렬), Insertion Sort(삽입 정렬)이 혼용 된 하이브리드 정렬 알고리즘이다.

여기서 좀 더 구체적 설명해보자면 Insertion Sort의 메커니즘은 같으나, 원소가 들어 갈 위치를 선형 탐색이 아닌 이분 탐색(이진 탐색)을 이용한 방법으로 구현한다.

삽입 정렬의 메커니즘이 무엇이었는지 생각해보자.

정렬 해야 할 target 원소에 대해 target 원소의 인덱스를 기점으로 타겟이 이전 원소보다 크기 전 까지 반복하면서 반복을 멈춘 위치가 target 원소가 들어갈 위치가 되는 것이다.

문제는 선형 탐색이다보니 한 원소에 대해 비교 작업이 최대 N번이 일어난다.

그렇기 때문에 탐색 과정을 이분탐색을 통해 logN 의 탐색 시간 복잡도를 갖도록 하는 방식이 바로 이 번 정렬의 핵심이다.

(전체적인 시간 복잡도 자체는 O(N 2 )으로 같다. 이 부분은 뒤에서 설명하도록 하겠다.)

먼저 구현을 하기전에 차이점에 대해 잠깐 더 구체적으로 들여다 보자.

일단 기존의 삽입 인덱스 바이너리 정렬 과정은 아래 그림을 참고하면 되고, 코드를 중심으로 보자.

위에서 보면 비교 탐색과 동시에 원소를 뒤로 밀어내는 작업을 같이 while문을 통해 이루어지고 있다.

여기서 while 조건문을 보면 j >= 0 과, target < a[j] 이렇게 두 개의 조건이 매 번 반복되고, j >= 0 은 비교 가능한 최대 하한 인덱스인 0까지 탐색이 이루어질 수 있다는 의미이며, target < a[j] 는 배치해야 할 target 원소가 탐색하면서 이전 원소가 target보다 작을 때 까지 반복한다는 것이다.

그러면 이분 탐색을 활용한 정렬은 어떻게 될까? 일단, 이분 탐색을 구현했다고 가정하에 코드를 작성하자면 다음과 같다.

[이진 삽입 정렬 코드]

다만, 0 ~ i (현재 target의 위치) 사이 이분탐색을 통해 target 원소가 위치 할 곳을 찾아낸 뒤, target 원소가 해당 위치에 삽입되기 위해 원소들을 한 칸씩 뒤로 밀어주는 것으로 변경되었을 뿐이다.

그럼 일단 구현 메커니즘을 보자.

이진 삽입 정렬의 전체적인 과정은 이렇다. (오름차순을 기준으로 설명)

1. 현재 타겟이 되는 숫자에 대해 이전 위치에 있는 원소들에 들어 갈 위치를 이분 탐색을 통해 인덱스 바이너리 인덱스 바이너리 얻어낸다. (첫 번째 타겟은 두 번째 원소부터 시작한다.)

2. 들어가야 할 위치를 비우기 위해 후방 원소들을 한칸 씩 밀고 비운 공간에 타겟을 삽입한다.

3. 그 다음 타겟을 찾아 위와 같은 방법으로 반복한다. 인덱스 바이너리

즉, 그림으로 보면 다음과 같은 과정을 거친다.

삽입 정렬과 마찬가지로 첫 번째 원소는 타겟이 되어도 비교 할 원소가 없기 때문에 처음 원소부터 타겟이 될 필요가 없고 두 번째 원소부터 타겟이 되면 된다.

그러면 삽입정렬 과정은 알았으니 이를 적용 할 이분 탐색을 구현하는 부분이 관건이겠다.

일단 이분 탐색 과정을 이해해보자면, '정렬 된 상태의 구간' 내에서 중간에 위치한 원소와 비교하여 중간 원소보다 작다면 왼쪽 구간으로, 크다면 오른쪽 구간으로 다시 나누어 탐색하는 과정을 말한다.

쉽게 그림으로 이해하자면 다음과 같다.

위와 같은 메커니즘으로 구현을 하면 된다. 이분 탐색만 따로 떼어서 코드로 구현하자면 다음과 같다.

생각해보자. 우리가 이진탐색하는 범위는 이미 정렬되어있는 상태다. 그리고 우리는 범위에서 key값이 삽입 될 위치를 찾는 것이 관건이다.

이 때 이진 탐색의 범위가 정렬 되어있다는 것은 이미 왼쪽 원소부터 순차적으로 삽입 정렬이 이루어져있다는 의미다.

쉽게 말해 이진 탐색 범위 내에 어떤 원소 a가 key값이랑 동일한 값이더라도 a원소가 key보다 선행원소였기 때문에 안정정렬을 위해서는 key가 a원소 뒤에 위치해야 한다. 즉, key에 대한 중복원소가 존재 할 때, key가 '가장 오른쪽 위치'로 가도록 하기 위함이다.

이에 대한 참고 글은 아래에서도 잘 보여주고 있으니 한 번 확인해보는 것도 좋을 것 같다.

Binary search algorithm - Wikipedia

From Wikipedia, the free encyclopedia Jump to navigation Jump to search This article is about searching a finite sorted array. For searching continuous function values, see bisection method. Search algorithm finding the position of a target value within a

※그리고 중요한 점이 있다.

여기서는 이해를 돕기 위해 중간 값을 구할 때 mid = (lo + hi) / 2 로 표기하고 있지만, 사실 값의 범위가 클 경우에는 int overflow가 발생할 수 있다.

쉽게 가정해서 lo = 1, hi = 2147483647 (= Integer.MAX_VALUE) 이렇다면,

(lo + hi) 과정에서 overflow가 발생하여 -2147483648 가 되고, 여기에 2를 나누게 되어 -1073741824 라는 잘못된 값을 반환할 수 있다.

이러한 경우 어떻게 인덱스 바이너리 해결하냐면, 결국 lo와 hi의 중간 값이라는 것은 lo 로부터 hi까지의 거리를 2로 나눈 값을 더한 값이라는 것이다.

lo = 3, hi = 7 이라 할 때, 중간 값은 (3 + 7) / 2 = 5 일 것이다.

이렇게 위와 같이 표현 할 수 있지만, 사실 생각해보면, 3 ~ 7까지 거리라는 건 결국 3을 기준으로 7이 얼만큼 떨어져 있는가이다.

즉, 3으로부터 4만큼 떨어져있는 지점은 7이라는 것이고, 만약 두 지점의 중간 값이라면, 떨어진 거리의 절반이라는 것이다.

그러면 다음과 같이 표현 할 수 있다.

중간 지점 = 시작점 + (거리 차) / 2

이를 수식으로 표현하면 다음과 같다.

mid = lo + ((hi - lo) / 2)

즉, overflow를 고려했을 때 위와 같이 두 거리의 차에 대한 값을 토대로 중간 값을 구하는게 좋다.

설명은 이해를 돕기 위해 (lo + hi) / 2로 일단 진행을 하고, 코드 작성 부분에서만 lo + ((hi - lo) / 2) 로 작성하도록 하겠다.

이분 탐색을 구현하긴 했는데.. 선형 탐색과는 뭐가 다른거지?

바로 시간복잡도가 O(logN) 이라는 것이다.

N개의 요소를 갖고있는 리스트가 주어졌다고 해보자. 그리고 우리가 이분 탐색에서 N/2 씩 쪼개가면서 탐색을 한다.

즉, K번 반복한다고 할 때, (1/2)K * N 이 된다는 의미다.

이 때, 위 이미지에서 보면 결국 최악의 경우인 마지막 탐색 범위가 1인 경우가 있을 것이다. K번 반복 했을 때 쪼개진 크기가 1이라고 가정한다면, 결국 K만큼 반복한다고 했으니 K에 대해 풀어보면 다음과 같다.

즉, N개의 리스트에서 어떤 수를 찾는 과정에 대한 시간 복잡도는 O(logN)이라는 것이다.

분명 기존의 삽입정렬은 while()문으로 한 번 순회했고, 이진 삽입 정렬은 이분탐색 과정과 원소들을 밀기 위한 순회 과정을 거쳐야 하는데, 오히려 이진 삽입 정렬이 느린 것이 아닌가?

기본적으로 Insertion Sort나, Binary Insertion Sort나 N개의 각 원소들은 삽입시 N개의 원소를 밀어내는 시프트 작업이 발생하기 때문에 O(N 2 ) 의 시간 복잡도를 갖는 것은 같다.

그리고 위에서 말했던 것처럼 while문으로 한 번 순회하는 일반적인 Insertion Sort가 별도의 이진탐색 과정을 거친 뒤 얻은 위치에 원소를 삽입하기 위한 시프트 작업이 발생하는 Binary Insertion Sort보다 빠르게 보이기도 한다.

그러면 왜 이진 삽입 정렬이 필요한 것인가? 이를 이해하기 위해서는 이분 탐색부터 이해해야 한다.

이분 탐색 자체는 앞서 설명했듯, 탐색 비용이 O(logN)의 시간 복잡도를 갖는다. 그리고 우리는 이진 삽입 정렬에서 원소가 들어갈 위치를 찾는 비교작업으로 사용을 하고 있다.

즉, 일반적인 삽입 정렬과의 차이라면 삽입 정렬은 탐색과 시프팅 작업을 동시에 하지만, 이진 삽입 정렬은 탐색을 이분 탐색으로 따로 구한 뒤 시프팅 작업을 거친다.

그럼 이진 삽입 정렬이 빠른 경우는 어떤 경우일까?

바로 '비교 작업 비용'이 '시프팅(스왑) 비용'보다 클 때 이진 삽입 정렬이 좀 더 빠르다는 것이다.

무슨말인가 싶을 수도 있으니 좀 더 구체적으로 설명해보겠다.

위처럼 일반적인 삽입 정렬의 경우 각 반복문 단계마다 j >= 0 과 target < a[j] 조건을 검사를 하면서 순회를 한다.

즉, target 이 들어갈 위치를 찾아가면서(target < a[j]) 동시에 시프팅 작업을 한다.

반면에 위처럼 이진 삽입 정렬의 경우 비교 작업을 이분탐색을 통해 사용되며, 그 다음 while문에는 j ~ location 까지 시프팅 작업이 이루어진다.

여기서 가장 큰 차이는 바로 '비교작업'의 차이라는 것이다.

앞선 예시에서는 우리가 두 원소를 비교할 때, 모두 int 형으로 '단일 비교'를 전제로 했지만, 사실 해당 타입만 존재하는 건 아니다. 특히 사용자가 만든 클래스 객체에서 비교작업을 하는 경우 대개 단일 비교로 이루어지지 않고 다양한 변수들의 비교를 통해 Comparable 혹은 Comparator을 구현하여 정렬을 할 것이다.

위 처럼 일반적인 primitive 타입의 배열의 경우 단일 값에 대한 비교를 하기 떄문에 비교 비용이 크지 않지만, 만약 다음과 같은 상황이라면 어떻겠는가?

비교 작업마다 Custom 클래스의 a, b, s 를 모두 비교를 하여 어떤 원소가 더 우선순위가 높은지를 판단해야 인덱스 바이너리 한다. 즉, 일반적인 단일 변수에 의한 비교보다 훨씬 비교작업 비용이 크다.

이렇게 여러 변수에 의해 우선순위가 결정되는 객체의 경우에는 결국 삽입정렬처럼 선형 탐색을 하게 된다면 시프팅작업과 탐색 작업이 한 반복문 내에 이루어진다 하더라도 N번의 비교 작업 자체가 비용(Cost)이 비싸진다.

그렇기 때문에 위와 같은 경우에는 별도의 이분탐색을 거치더라도 logN 시간 복잡도를 갖는, 즉 비교 비용을 줄일 수 있는 이분 탐색을 활용하는 것이 더욱 빠르다.

실제로 그럴까? 필자가 테스트 한 것을 보면 이렇다. (테스트는 Java를 통해 실험했고, 얻어온 결과를 파이썬의 plot으로 표현한 것 뿐이다.)

참고로 빨간색 이 일반적인 삽입 정렬(Insertion Sort) 이고, 파란색이진 인덱스 바이너리 삽입 정렬(Binary Insertion Sort)이다.

먼저 무작위로 생성 된 원소들을 갖는 int배열을 각각의 정렬 방식을 통해 얻어진 시간(밀리초) 그래프다.

보면 엄청 큰 차이는 아니더라도 분명하게 빨간색 선인 삽입정렬이 좀 더 빠른 걸 볼 수 있다.

그러면 객체를 정렬하는 경우는 어떨까?

위와 동일하게 Custom 클래스를 사용하여 Custom 배열 내의 Custom 원소들의 변수들도 랜덤으로 생성하여 각각 정렬을 했을 때의 어떻게 나타나지는지 보자.

보면 위 그래프와는 달리 이진 삽입 정렬이 더 빠른 것을 볼 수 있다.

위 결과처럼 비교 비용이 비싸질 수록 이진 삽입 정렬은 삽입 정렬에 비해 빠를 것이다.

정리하자면 이진 삽입 정렬이 항상 빠른 것은 아니지만, 비교비용이 스왑비용보다 비싸질 수록 상대적으로 효율이 좋아질 수 있으며 각 원소에서의 이분 탐색은 매우 작은 비용이더라도 작은 logN 비용은 분명 유의미한 차이를 낼 수 있다는 것이다.

그러면 굳이 왜 삽입 정렬을 안쓰고 이진 삽입 정렬을 쓰는가?

자바에서 기본적으로 정렬하는 클래스는 Arrays 클래스의 sort에 의해 이루어진다. (Collections.sort의 경우도 List를 배열로 변환하여 Arrays.sort로 내보낸다.)

그리고 크게 정렬 방식은 두 가지로 나누어지는데 하나는 dual-pivot Quick Sort이고, 다른 하나는 Tim Sort다.

그럼 어느 때 어떤 정렬을 쓰는가를 따져보아야 하는데, 기본적으로 primitive type 일차원 배열의 경우 dual-pivot Quick Sort에 의해 정렬이 된다. (참고로 2차원 배열(ex. int[][])같은 다차원 배열은 Object 타입으로 간다. 쉽게 생각해서 2차원 배열 int[][]의 경우 int[] 타입의 객체가 배열로 된 형태라고 이해하시면 된다.)

Tim Sort는 객체(Object)를 정렬하고자 할 때 쓰인다. 대개 여러 변수에 의해 비교 될 가능성이 높은 타입이 바로 객체이기 때문이지 않을까 싶다. 그리고 실제로 우리가 객체를 정렬하는 경우가 단순히 Wrapper클래스만 있는 것이 아닌 실제 현실 데이터를 반영한 class를 구현하여 이를 리스트로 만들어 다루는 경우가 많다.

그렇기 때문에 오히려 비교 횟수가 많을수록 오버헤드가 커지게 되므로 이럴 때엔 이진 삽입 정렬처럼 비교 횟수를 최소화하는 것이 더욱 효율적일 것이다.

인덱스 바이너리

이미 정렬되어 있는 배열에서 특정 값을 탐색할 때, 탐색 범위를 절반씩 나눠가며 해당 값을 찾는 방법이다.

알고리즘

1. 배열을 정렬 후 left(인덱스 시작), right(인덱스 끝), mid((left+right)/2)를 설정한다.

2. mid 인덱스의 원소 값과 대상 값을 비교한다.

3. 대상 인덱스 바이너리 값이 중간 값보다 클 경우 최소값의 인덱스를 중간값의 인덱스+1로 변경하고, 대상 값이 중간 값보다 작을 경우 최대값의 인덱스를 중간값의 인덱스-1로 변경한다.

4. 두 값이 일치할 때까지 2, 3번 과정을 반복한다. 만일 left 값이 right 값보다 커질 경우 대상이 없는 것으로 판단하고 종료한다.

예시

다음과 같이 [1, 7, 17, 27, 37, 50, 70, 77, 90]의 배열이 있다. 이곳에서 77의 위치를 찾고싶다.

배열은 0부터 8번까지 있고 정렬되어 있는 상황이다.

start = 0, end = 8(배열 크기 -1, 마지막 원소), mid = (start+end)/2가 된다.

중간 값인 37은 내가 찾는 77이 아니다. 또한, arr[mid] 값 즉, 37이 77보다 작기때문에 [start, mid]에는 77이 없다는 의미다. 따라서 start의 값을 mid+1로 갱신한다.

(만일 arr[mid]의 값이 찾는 값보다 크다면 end 값을 mid-1로 갱신한다.)

다시 같은 방법으로 탐색을 진행한다.

현재 start의 값은 5, end의 값은 8이다. 따라서 mid의 값은 6이 된다. arr[mid]의 값은 70이며 70은 77보다 작기때문에 start의 값을 mid+1로 변경한다.

인덱스 바이너리

LeetCode, Binary Search(이진 탐색/검색)

  • 이진 탐색(Binary Search)

문제 요약

1. 오름차순으로 정렬된 유일한 원소를 갖고 있는 정수 배열 nums와 정수 target이 주어진다.
2. 주어진 nums와 target에 대해서 target이 nums에 존재하는지 찾는 함수를 작성하라.
3. 만약 target이 존재한다면 해당 인덱스를 반환하고, 존재하지 않는 경우에는 -1을 반환한다.
4. 시간 복잡도는 반드시 O(logn)이 되어야 한다.

- 오름차순으로 정렬된 배열과 찾아야 하는 정수, O(logn)의 시간복잡도를 통해 Binary search를 통해 문제를 해결해야 함을 알 수 있다.

(정렬된 배열, O(logn)의 시간복잡도는 Binary search의 대표 특징들이다.)

입출력 형태

입출력 예시

Binary search를 구현하는 방법은 여러 가지가 존재한다.

첫 번째 방법은 재귀적으로 해결하는 방법이다. 중간 지점을 찾기 위한 left와 right를 매개 변수로 인덱스 바이너리 갖는 재귀 함수를 작성한다.

- 중간 지점의 인덱스(mid)일 때의 값이 target과 일치한다면 해당 값을 반환한다.

- mid일 때의 값이 target보다 작을 경우 target은 mid 이후의 인덱스에 존재할 것이기 때문에 left를 mid + 1로 하여 재귀 함수를 실행한다.

- mid일 때의 값이 target보다 클 경우 target은 mid 이전의 인덱스에 존재할 것이기 때문에 right를 mid - 1로 하여 재귀 함수를 실행한다.

- 찾는 값이 존재하지 않는 경우(left > right)에는 -1을 반환한다.

두 번째 방법은 반복문으로 해결하는 방법이다. 구현한 로직은 재귀일 때와 동일하다.

세 번째 방법은 `bisect` 모듈을 사용하는 방법이다. python에는 Binary search를 구현해 둔 bisect 모듈이 존재한다. 이를 사용해서 문제를 해결할 수 있다.

bisect 모듈의 `bisect_left` 메소드는 주어진 nums에서 정렬된 순서를 유지하며 target이 들어갈 수 있는 index를 반환하는 함수이다.

즉, target을 삽입하기 좋을 때의 index가 target의 값이라면, 이미 리스트에는 target이 존재한 것이므로 해당 index를 반환하면 된다. 이 때, index의 값이 리스트의 길이를 넘어서면 안된다. 해당하는 경우는 배열에 target이 없는 경우이다.

인덱스 바이너리

위와 같은 1차원 배열이 있다. 해당 배열에서 ary[3]~ary[7] 구간합을 구하고자 한다.
가장 기본적인 코드는 아래와 같이 하나씩 모두 더하는 것이다.

ary[3]~ary[7]의 합 구하기

해당 코드의 시간복잡도는 O(N) 이다.(연산의 수가 M일 경우 O(MN))
펜윅트리를 사용하게 되면 이를 O(logN) 으로 감소시킬 수 있다.
그렇다면 펜윅트리가 무엇인지 알아보자.

1. 펜윅트리란 무엇인가?
펜윅트리는 Binary Indexed Tree, BIT 라고도 하며, 쉽게 말해 구간합을 빠르게 구하기 위한 자료구조이다.

2. 트리만들기(초기화)
입력받은 배열 ary[9] = 를 이용해 펜윅트리를 만들어 보자.
펜윅트리는 아래과 같이 배열을 구성하게 된다.(펜윅트리는 0번 index를 사용하지 않는다.)

Fenwick Tree

펜윅트리를 확인해 보면, 각각의 index(2진수)에서 가장 뒤에 위치한 1의 값 만큼 자신의 index를 포함하여 그 앞부분 index의 value를 더한다. 1번, 6번, 8번 index를 예로 들어보자.
1번 index의 경우 2진수 값이 12 이므로, 가장 뒤에 위치한 1의 값은 1 이다.
따라서, fenwick tree[1] = ary[1] 이다.
6번 index의 경우 2진수 값이 1102 이므로, 가장 뒤에 위치한 1의 값은 2 이다.
따라서, fenwick tree [6] = ary[6] + ary[5] 이다.
8번 index의 경우 2진수 값이 10002 이므로, 가장 뒤에 위치한 1의 값은 8 이다.
따라서, fenwick tree [8] = ary[8] + ary[7] + . + ary[1] 이다.

2-1. 펜윅트리에서 사용되는 수식
펜윅트리에서는 위와 같이 어떤 값 k를 2진수로 나타냈을 때, 가장 뒤에 위치한 1의 값이 얼마인지 구할 수 있어야 한다.
이를 수식으로 나타내면 다음과 같다.

LB[i] = i & -i ( &는 비트연산, -i = ~i + 1)

LB[i]는 i를 이진수로 나타내었을 때 가장 뒤에 나오는 1이 위치한 값을 구하는 식이다. 예를들어보면 아래와 같다.
LB[3] = 3 & -3 = 112 & 012 = 1
LB[4] = 4 & -4 = 1002 & 1002 = 4
LB[23] = 23 & -23 = 101112 & 010012 = 000012 = 1
LB[56] = 56 & -56 = 1110002 & 0010002 = 0010002 = 8

왜 그런지 간단하게 설명해 보면,
k = 1011000 일 경우, ~k = 0100111 이 된다.
~k+1(-k) 은 ~k의 가장 뒤에 위치한 0이 1로 바뀌며 이 부분이 바로 LB 값이 된다. 그 뒤의 1은 모두 0으로 바뀐다.
(-k = 0101000)
결국, k & -k 를 하게 되면, LB값이 되는 비트만 1이 되고 나머지는 모두 0이 된다.

3. 구간합 구하기
펜윅트리를 이용해 ary[1]~ary[7]의 합을 구하자.

위 그림의 fenwick tree에서 4번, 6번, 7번 index는 아래 수식과 같이, 음영이 칠해진 부분의 값을 의미한다.
f.tree[4] = ary[1]+ary[2]+ary[3]+ary[4]
f.tree[6] = ary[5]+ary[6]
f.tree[7] = ary[7]
이를 통해 구하고자 하는 ary[1]~ary[7]의 합을 구할 수 있다.

이 때, 7=1112, 6=1102, 4=1002 이며, 가장 마지막에 위치한 1 비트가 0으로 바뀌고 있음을 알 수 있다.
즉, ary[1]~ary[7] = f.tree[1112]+f.tree[1102]+f.tree[1002] 이 된다.
이는, 펜윅트리를 초기화 할 때 가장 마지막 비트의 숫자 만큼을 더하는 과정을 거쳤기 때문에 가능하다.(1. 트리만들기 부분 참고)

이를 통해 일반화한 코드를 작성하면 다음과 같다.( tree는 fenwick tree를, i는 index를 의미)
해당 코드에서는 가장 마지막에 위치한 1 비트를 확인하는 수식(LB[i] = i & -i)을 사용하고 있다.

펜윅트리 합 구하기

이를 통해 ary[3]~ary[7]의 합 = sum(tree, 7) - sum(tree, 2) 가 됨을 알 수 있다.

이를 이용해 ary[i]~ary[j]의 합을 구하는 식은 다음과 같이 나타낼 수 있다.

sum(tree, j) - sum(tree, i-1)

4. 트리 업데이트
입력받은 배열에서 특정 값이 변경될 경우, 펜윅트리에서는 이 값을 포함하는 모든 인덱스를 변경해 줘야 한다.

위 그림에서 ary[3]을 6에서 4로 변경해 주었다. 값이 2만큼 감소했으므로 펜위트리에서는 3, 4, 8번 index를 2만큼 감소시킴으로써 업데이트를 완료하였다.
변경한 index 들을 살펴보면, 3=112, 4=1002, 8=10002 임을 알 수 있다. 이는, 마지막에 위치한 1의 값을 더함으로써 구해짐을 확인할 수 있다.
이를 통해 일반화한 코드를 작성하면 다음과 같다.(tree는 fenwick tree를, i는 index를, diff는 변경 후 값과 변경 전 값의 차를 의미)

업데이트 수행하기


0 개 댓글

답장을 남겨주세요