위상 정렬이란,  선후 관계가 존재하는 대상의 순서를 정렬하는 알고리즘이다.

게임에서 스킬 트리를 생각해보자

설명을 보면, 다크 사이트를 10레벨 이상 배워야만 어드밴스드 다크 사이트를 배울 수 있다고 적혀있는 것을 볼 수 있다.

즉, 스킬을 배우는 순서대로 정렬한다면 무조건 다크사이트가 앞에 올 수 밖에 없는 것이다.

 

이처럼, 어떤 작업을 완료하기 위해 선행해야 하는 작업이 있는 상황에서 작업의 순서를 정렬하는 알고리즘을 위상 정렬이라고 한다.

위의 그래프에서 화살표를 선행관계라고 해보자.

1번을 완료해야만, 2,3번을 작업할 수 있다는 뜻이다.

 

즉, 위의 표에서 작업의 실행 순서대로 정렬한다면 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 로 정렬할 수 있을 것이다.

하지만, 1 -> 3 -> 2 -> 4 -> 5 -> 7 -> 8 -> 6 으로 정렬할 수도 있다.

 

선행 작업이 모두 완료된 작업들 사이에서는 선행, 후행이 존재하지 않으므로 정렬 결과가 다양하게 나올 수 있다는 것을 염두에 두자. 

 

그렇다면, 위상 정렬은 어떤 과정으로 이루어질까?

먼저, 진입 차수라는 개념이 나온다.

 

진입 차수란, 해당 작업을 실행하기 위해 선행되어야 하는 작업의 개수를 의미한다.

 

위의 그래프에서 보자.

2, 3번 작업을 실행하기 위해선 1번 작업을 먼저 실행해야 한다.

그러므로, 2,3의 진입차수는 1이 된다.

 

반면, 4번 작업을 실행하기 위해선 (2, 3) 두 개의 작업을 실행해야 한다.

이 때, 4번 작업의 진입 차수는 2라고 할 수 있다. (1까지 포함해서 3개가 아니냐? 라고 할 수 있지만, 바로 앞에서 선행되어야 하는 작업에 대해서만 계산한다.)

 

진입차수 배열을 만들어서, 정리해보자.

각 작업에 대한 진입차수는 위와 같이 정리될 것이다.

배열 값을 보면, 1번만 0인 것을 볼 수 있다.

 

모든 작업이 실행되려면, 가장 먼저 실행되어야 하는 작업이 존재할 것이다.

그리고 해당 작업에 대해서는 선행 작업이 없어야 한다. (당연히 모든 작업에 대해 선행작업이 존재한다면, 무한 순환이 발생하면서 작업의 순서를 매길 수가 없게 된다. )

 

그러므로, 진입차수가 0인 작업이 무조건 가장 먼저 실행되는 작업이 된다는 것이다.

작업 순서 배열도 하나 만들어서, 0번을 가장 앞에 삽입하자.

 

 

0번 작업을 시작으로, 작업을 시작해보자.

 

1번 작업이 끝났다면, 이제 1번 작업을 선행으로 하는 작업들의 진입 차수를 1씩 내려보자.

하나의 작업이 끝났다면, 해당 작업을 선행으로 하는 작업들의 진입차수를 1씩 깎으며, 다음 작업을 탐색할 것이다.

 

위와 같이 진입차수 배열이 갱신되었다.

보면, 2와 3번 작업에 대해 진입차수가 0이 된 것을 볼 수 있다.

즉, 새롭게 진입 차수가 0이 된 작업들은 모든 선행작업이 완료되었다는 뜻이다.

 

2번과 3번 작업에 대해서도, 후행 작업의 진입차수를 깎으며 배열을 갱신할 것이다.

그 이전에, 2번과 3번 작업을 작업 순서 배열에 삽입해주자.

 

다음은 진입차수 배열을 갱신해보자.

2번 작업에 대해 갱신을 하면 위와 같아진다.

 

3번 작업에 대해서도 갱신하면, 위와 같이 4번 작업의 진입차수가 0이 된다.

이제, 작업 순서 배열에 4번을 삽입해주자.

 

이 과정을 계속 반복하면, 아래와 같이 작업 순서 배열을 완성할 수 된다.

 

 

이처럼, 진입차수가 0이되는 작업을 현재 실행해야 하는 작업이라 가정한 뒤, 진입차수를 계속 갱신하며 0이 되는 작업을 순서대로 배열에 삽입해주면 위상 정렬이 완료된다.

 

이해를 더하기 위해, 코드를 보며 이해하는 것도 좋다.

아래는 위상정렬을 활용한 예제 문제이다.

 

백준 14567 - 선수 과목 (C++) (tistory.com)

 

백준 14567 - 선수 과목 (C++)

특정 과목을 수강하기 위한 선행과목이 있다고 했을 때, 각 과목은 최소 몇학기에 수강할 수 있는가를 구하는 문제이다.A->B 의 선행관계가 성립된다면, A와 B를 같은 학기에 들을 수 없기 때문에

yuu5666.tistory.com

단조 스택 (Monotonic Stack)

단조 스택이란, Stack을 활용한 알고리즘중 하나이다.

수학에서 단조란, 우상향 혹은 우하향을 계속 유지하는 형태를 의미한다.

 

단조 스택도 이와 유사하다.

스택 내부의 원소가 오름차순 혹은 내림차순으로 정렬된 형태를 유지하는 것을 의미한다.

 

그렇다면, 단조 스택을 어떻게 활용할까?

단조 스택은 보통 NGE 혹은 PGE를 구하기 위해 사용한다.

 

NGE, PGE

NGE는 Next Greater Element의 약자로, 다음의 수중 가장 가까운 더 큰 수를 의미한다.

PGE는 Previous Greater Element의 약자로, 이전의 수중 가장 가까운 더 큰 수를 의미한다.

 

말로만 보면 이해가 잘 안된다.

아래 예시를 보자.

 

만약, 배열에 {1, 7, 5, 3, 6, 10} 이 있다고 해보자.

 

5 기준으로 보았을 때, 뒤의 원소는 3, 6, 10이 있다.

이 중 5보다 더 큰 원소는 6과 10이 있다.

6이 5와 가장 가깝기 때문에, 6이 5의 NGE가 된다.

 

3을 기준으로 보았을 때, 앞의 원소는 1, 7, 5가 있다.

이 중 3보다 더 큰 원소는 7과 5가 있다.

5가 3과 가장 가깝기 때문에, 5가 3의 PGE가 된다.

 

구현 방법

먼저, 기대값을 보고 가자.

 

1,7,5,3,6,10 이라는 배열이 처음에 주어진다면, NGE와 PGE는 그림과 같을 것이다.

X표시된 부분은 해당 원소에 대해 NGE혹은 PGE가 존재하지 않는다는 뜻이다.

 

본인보다 더 큰 수가 범위내에 없다면, X를 표시한 것이다.

 

이제, 차례대로 배열 원소들의 NGE를 구해본 뒤, 기대값과 동일한지 확인해보자.

 

먼저, 사용할 스택을 하나 선언해주었고, NGE를 저장할 Answer배열도 선언해주었다.

먼저, 배열의 마지막 원소를 Stack에 넣어보자.

Stack에는 10이라는 값이 삽입되었고, 해당 원소는 배열의 마지막 원소라 NGE가 존재하지 않으므로 Answer엔 -1을 저장해주었다.

 

다음은, 6에 대한 NGE를 검사해야 한다.

 

먼저, Stack의 Top을 보자. Top의 값은 현재 10이다.

이 Top의 값이 현재 원소의 NGE가 된다.

즉, 6의 NGE는 10이 된다.

 

이렇게, NGE를 갱신해준 뒤 6또한 다시 Stack에 삽입해주었다. 

 

다음 3에 대해서도 똑같은 과정을 거치면 된다.

현재 stack 의 top은 6이다. 6은 3보다 큰 값이기 때문에, 6은 3의 NGE라고 할 수 있다.

그러므로 아래와 같이 Answer 배열을 갱신해주면 된다.

Answer배열을 갱신해주고, Stack에 3을 삽입하였다.

5에 대해서 동일한 과정을 거치려고 하니까, 한가지 문제가 발견되었다.

Stack의 Top이 5보다 작기 때문에, Stack의 Top을 NGE라고 할 수가 없어져버린 것이다.

이럴 땐, Stack의 Top이 5보다 커질때까지, Stack에서 pop을 하면 된다.

이렇게 스택에서 3을 pop했더니, top이 5보다 큰 값이 되었다.

이 값이 5의 NGE가 되는 것이다.

그러므로, 배열을 아래와 같이 갱신해주면 된다.

스택에 본인을 삽입해주는 것도 잊지 말자.

 

7에 대해서도 동일한 과정을 거치면 된다.

Stack에서 5를 pop하고, 6을 pop하면 top은 10이 된다.

그러므로, 10을 7의 NGE로 저장한 뒤, 7을 다시 Stack에 push해주면 된다.

 

마지막 1에 대해서도 동일한 과정을 거치면 최종적으로 아래와 같아진다.

처음에 보았던 기댓값과 Answer배열의 값이 완전히 동일한 것을 볼 수 있다.

 

PGE를 구하는 방법도 동일하다. 다만, 배열의 뒤에서부터 진행하는 것이 아니라 앞에서 부터 진행하면 된다.

이렇게 NGE를 구하는 과정에서 Stack의 원소를 보면, 항상 오름차순 형태를 유지하고 있다. 이러한 형태때문에 단조 스택(Monotonic Stack)이라는 이름이 붙은 것 같다.

 

코드 구현

std::vector<int> Array = {1, 7, 5, 3, 6, 10};
std::vector<int> Answer(Array.size(), 0);

std::stack<int> MonotonicStack;

 

먼저 이렇게 입력을 받고 사용할 스택도 선언해주었다.

 

다음은 배열을 순회하며 NGE를 구해줄 것이다.

for (int i = Array.size() - 1; i >= 0; i--)
{
    int CurNum = Array[i];

    while (MonotonicStack.size() > 0)
    {
        if (MonotonicStack.top() > CurNum)
        {
            break;
        }

        MonotonicStack.pop();
    }

    if (MonotonicStack.size() == 0)
    {
        Answer[i] = -1;
    }
    else
    {
        Answer[i] = MonotonicStack.top();
    }

    MonotonicStack.push(CurNum);
}

 

for문의 조건을 보면, 배열의 마지막원소부터 순회하는 것을 볼 수 있다.

스택의 top이 현재 값보다 큰 값이 될 때까지, 계속 pop을 해주고 있다. 만약 stack의 모든 원소를 pop했음에도 큰 값을 찾지 못했다면 NGE가 없는 것으로 간주하고 Answer을 -1로 갱신해주었다.

반대로, NGE를 찾았다면 그 값으로 Answer을 갱신하였다.

 

마지막으로, MonotonicStack에 자기 자신을 push해주면 된다.

 

반복문이 모두 끝나면, 아래와 같이 Answer 배열의 값은 갱신되어 있을 것이다.

위에서 보았던 기댓값과 완전히 일치하는 것을 알 수 있다.

 

만약 PGE를 구하고 싶다면, 동일한 코드에서 for문의 조건만 아래와 같이 수정하면 된다.

이렇게, 앞에서부터 탐색하게 되면 Answer의 값은 아래와 같이 갱신된다.

위에서 보았던 PGE의 기댓값과 동일하다.

 

코드 전문

더보기
#include <stack>
#include <vector>
#include <iostream>

int main()
{
    std::vector<int> Array = { 1, 7, 5, 3, 6, 10 };
    std::vector<int> Answer(Array.size(), 0);

    std::stack<int> MonotonicStack;

    for (int i = Array.size() - 1; i >= 0; i--)
        
        int CurNum = Array[i];

        while (MonotonicStack.size() > 0)
        {
            if (MonotonicStack.top() > CurNum)
            {
                break;
            }

            MonotonicStack.pop();
        }

        if (MonotonicStack.size() == 0)
        {
            Answer[i] = -1;
        }
        else
        {
            Answer[i] = MonotonicStack.top();
        }

        MonotonicStack.push(CurNum);
    }

    return 0;
}

 

라인 스위핑 알고리즘이란, 1차원 좌표(수직선)에 그려진 여러 선분을 합치는 알고리즘이다.

 

위의 그림과 같이, 수직선에 5개의 선분을 긋고자 한다고 해보자.

 

위의 그림에선, 여러 선분들이 서로 겹치지 않게 그려있지만 수직선은 1차원 좌표이기 때문에 실제로 수직선에 선분을 긋게 되면 선분들은 서로 겹치게 된다.

 

이런 식으로, 실제로 좌표에 선분을 긋게되면 범위가 겹치는 선분은 모두 하나로 합쳐지게 된다.

이런 식으로 여러 구간이 주어졌을 때, 겹치는 구간을 모두 합치고자 할 때 사용하는 알고리즘이 라인 스위핑 알고리즘이다.

 

위와 같은 선분에 대해서만 사용할 수 있는 것이 아니라, 시간처럼 시작과 끝이 주어질 수 있는 대상에 대해서는 모두 사용이 가능하다.

 

라인 스위핑

 

위처럼, 여러 구간을 하나로 합치려면 일반적으로는 이중 반복문으로 구현하게 된다.

현재 선분과 다른 모든 선분을 비교하여, 구간이 겹치는 선분이 있다면 하나로 합치는 것이다.

이런 방식은, O(N^2)의 시간복잡도를 지니게 된다.

 

너무 시간복잡도가 크기때문에, 이를 줄이기 위해 라인 스위핑을 사용하는 것이다.

라인 스위핑은 먼저 선분을 시작지점을 기준으로 정렬해야 한다. 

 

입력이 아래와 같이 주어졌다고 해보자.

 

 

자료구조에 선분의 정보를 저장한 뒤, 시작점을 기준으로 정렬하게 되면

노란색 -> 초록색 -> 보라색 -> 하늘색 -> 갈색 순서로 선분을 처리하게 된다. (노란색과 초록색은 바뀔 수도 있다.)

 

먼저, 합쳐진 선분에 대한 정보를 MergeLine 이라는 곳에 저장한다고 해보자.

가장 처음 비교할 선분은 노란색 선분이다. 노란색 선분에 대해서는 비교할 정보가 없기 때문에 MergeLine을 최초에는 노란색 선분으로 저장해주어야 한다.

 

 

 

이후, MergeLine과 초록색 선분을 비교해보자.

 

MergeLine의 시작점 : 0

MergeLine의 끝점 : 2

 

초록색 선분의 시작점 : 0

초록색 선분의 끝점 : 7

 

이 떄, 시작점과 끝점을 모두 비교할 필요는 없다.

 

초록색 선분의 시작지점이 MergeLine의 끝점보다 작거나 같다면, 두 선분은 합칠 수 있는 것이라고 생각할 수 있다.

만약, 초록색 선분의 시작지점이 MergeLine의 끝점보다 크다면 두 선분은 합칠 수 없다고 생각할 수 있다.

 

현재, 두 선분의 관계를 보면, MergeLine의 끝점(2)보다 초록색 선분의 시작점(0)이 더 작기때문에, 두 선분은 합칠 수 있다.

 

위와 같이, MergeLine의 정보가 갱신되었다.

 

다음은 MergeLine과 보라색선분을 비교해보자.

위와 동일하게 계산해보면 보라색선분도 MergeLine과 합칠 수 있다.

두 선분을 합쳐도 MergeLine에 변화는 없다.

 

다음으로, MergeLine과 하늘색 선분을 비교해보자.

이번엔, 합칠 수 없다.

하늘색선분의 시작지점(8)이 MergeLine의 끝지점(7)보다 크기 때문이다.

그러므로 새로운 MergeLine을 하나 더 만들어야 한다.

 

 

이렇게, 하늘색 선분의 길이와 동일한 MergeLine를 하나 더 생성하였다.

이제는 새로 만들어진 이 MergeLine을 기준으로 비교를 진행할 것이다.

 

다음으로 비교할 선분은 갈색 선분이다.

갈색 선분은 MergeLine과 합칠 수 있으므로, 두 선분을 합쳐주면 모든 선분을 합치는 작업이 끝나게 된다.

 

코드 구현

 

먼저, 위 그림과 같이 5개의 선분을 입력받는다고 가정해보자.

//입력

int NumOfLines = 5;

std::vector<std::pair<int, int>> Lines;
Lines.resize(NumOfLines);

Lines[0] = { 0, 2 };
Lines[1] = { 8, 15 };
Lines[2] = { 3, 6 };
Lines[3] = { 0, 7 };
Lines[4] = { 13, 15 };

 

시작점과 끝점을 효율적으로 관리하기 위해, std::pair를 사용하였다.

선분은 정렬되지 않은 상태로 입력받았다.

 

먼저, 위에서 말했듯이 정렬을 해주어야 한다.

std::sort(Lines.begin(), Lines.end());

 

std::pair의 경우 정렬기준은 first가 우선이고, first가 같다면 second를 기준으로 정렬하게 된다.

그러므로 시작점(first)를 기준으로 정렬하는 현재 상황에선 정렬 함수를 직접 정의할 필요는 없다.

 

std::pair<int, int> MergeLine = Lines[0];
std::pair<int, int> CurLine = { 0, 0 };

비교를 진행하기 위해, 이렇게 2개의 변수를 선언하였다.

 

MergeLine은 합쳐진 선분을 의미한다. 이 선분의 초기값은 가장 앞에있는 선분으로 잡아주었다.

CurLine은 현재 비교하고자 하는 선분이다. 초기값은 크게 의미는 없다. 아무값으로나 초기화해주어도 상관없다.

 

std::vector<std::pair<int, int>> MergeLines;
MergeLines.reserve(5);

MergeLines는 합쳐진 선분들을 저장할 vector이다.

MergeLines는 선분의 개수를 초과할 수 없기 때문에, 전체 선분의 개수와 동일하게 5로 reserve해주었다.

(입력받는 선분의 개수가 너무 많을 땐, 메모리적으로 낭비가 심할 수 있기 때문에 std::list를 사용하는 것도 좋다.)

 

for (int i = 1; i < NumOfLines; i++)
{
    CurLine = Lines[i];

    if (CurLine.first <= MergeLine.second)
    {
        MergeLine.second = std::max(CurLine.second, MergeLine.second);
    }
    else
    {
        MergeLines.push_back(MergeLine);
        MergeLine = CurLine;
    }
}

MergeLines.push_back(MergeLine);

위는 선분을 비교하는 함수이다.

현재 선분을 CurLine에 저장해준 뒤, MergeLine과 비교하도록 했다.

위에서 말했듯, CurLine의 시작지점과 MergeLine의 끝지점만 확인하였다.

 

두 선분을 비교할 때, 시작지점은 무조건 MergeLine이 더 앞에있거나 같을 수 밖에 없다.

(정렬된 상태로 merge를 진행했기 때문에)

 

그러므로, 두 선분을 합칠 때는 끝지점에 대해서만 갱신해주면 된다.

만약, MergeLine 이 (1, 2)이고, CurLine이 (1, 5)인 상황이 있을 수도 있다.

이 경우 CurLine과 MergeLine의 끝지점중 더 큰쪽으로 MergeLine을 갱신해야 하기 때문에

std::max를 사용하여 더 큰값을 저장해주었다.

 

만약, 두 선분이 겹치지 않는다면, 기존의 MergeLine은 벡터에 저장해주고 현재 선분으로 MergeLine을 갱신해주었다.

 

반복문이 다 끝나고 나서, 현재 MergeLine을 꼭 벡터에 다시 넣어주어야 한다.

마지막 MergeLine은 벡터에 저장되지 않았으니 말이다.

 

내부의 값을 보면, 위 사진과 같이 선분이 잘 합쳐져있는 것을 볼 수 있다.

 

 

코드 전문

더보기
#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
    int NumOfLines = 5;

    std::vector<std::pair<int, int>> Lines;
    Lines.resize(NumOfLines);

    Lines[0] = { 0, 2 };
    Lines[1] = { 8, 15 };
    Lines[2] = { 3, 6 };
    Lines[3] = { 0, 7 };
    Lines[4] = { 13, 15 };
    
    std::sort(Lines.begin(), Lines.end());

    std::pair<int, int> MergeLine = Lines[0];
    std::pair<int, int> CurLine = { 0, 0 };

    std::vector<std::pair<int, int>> MergeLines;
    MergeLines.reserve(5);

    for (int i = 1; i < NumOfLines; i++)
    {
        CurLine = Lines[i];

        if (CurLine.first <= MergeLine.second)
        {
            MergeLine.first = std::min(CurLine.first, MergeLine.first);
            MergeLine.second = std::max(CurLine.second, MergeLine.second);
        }
        else
        {
            MergeLines.push_back(MergeLine);
            MergeLine = CurLine;
        }
    }

    MergeLines.push_back(MergeLine);

    return 0;
}

 지난 게시글에서 세그먼트 트리에 대해 알아보았다.

펜윅트리는 세그먼트 트리에서 조금 더 진보된 형태라고 보면 된다.

 

비트연산을 이용하기 때문에, 처음에 이해하는 것이 다소 까다롭지만 완벽히 이해하고 나면

구현하는 것도 훨씬 간단하고 메모리 사용량도 상대적으로 적기때문에 세그먼트 트리보다 효율적인 알고리즘이다.

 

 

위는 세그먼트 트리의 구조이다.

N개의 숫자에 대해 저장하는 구간합의 최대 개수는 약 4N개이다.

 

반면, 아래 그림은 펜윅 트리이다.

 

위 그림은 펜윅트리에서 각 인덱스에 저장된 구간합의 정보이다.

어떤 기준으로 합이 저장되고, 어떻게 사용하는지는 일단 뒤로 미루고 전체적인 크기를 보자.

 

세그먼트 트리는 구간합에 대한 정보를 트리구조로 저장하기 때문에 최대 4N개의 메모리가 필요했다.

반면, 펜윅트리는 숫자N개에 대한 N개의 배열 하나에 구간합의 정보를 모두 저장하고 있다.

메모리를 1/4로 절약할 수 있는 것이다.

 

메모리를 아끼는 만큼 구간합을 구할 때, 시간복잡도가 더 크지 않을까? 라는 생각을 할 수도 있지만

시간복잡도는 O(logN)으로 동일하다.

또한 펜윅트리는 비트연산을 이용하기 때문에, 같은 시간복잡도여도 재귀함수를 사용하는 세그먼트 트리보다 더 빠르다.

 

즉, 가능하다면 펜윅트리를 사용하는 것이 훨씬 효율적이라는 것이다.

 

다만, 펜윅트리를 모든 상황에서 사용할 수 있는 것은 아니다.

세그먼트 트리 게시글에서 구간 합에 대해서만 언급하였지만, 실제로는 구간의 최소값, 최대값 등을 구하기 위해서도 세그먼트 트리를 활용할 수 있다.

 

반면 펜윅트리는 구간의 합과 같은 몇가지 정보에 대해서만 사용이 가능하다. 기능이 다소 제한적이라는 뜻이다.

이유는 뒤에서 설명하겠지만, 펜윅트리는 합을 구하는 연산을 할 때, 1~K로만 구할 수 있다.

 

세그먼트 트리는 a~b구간에 대해, 그 사이에 있는 여러 구간을 활용하여 a~b의 구간합을 구하지만,

펜윅트리는 1~b 의 구간합에서 1~(a-1)의 구간합을 빼는 방식으로 이루어진다. 

 

즉, 펜윅 트리에서 직접적으로 구할 수 있는 것은 구간합이 아니라 누적합인 것이다.

이러한 이유로 펜윅 트리는 다소 사용할 수 있는 상황이 제한적이기 때문에, 상황에 맞게 잘 사용해야 한다.

 

이제 펜윅트리가 어떻게 작동하는지 확인해보자.

 

 

 

펜윅 트리


 

펜윅 트리에도 세그먼트 트리와 동일하게 Update와 GetSum함수 두가지가 있다.

세그먼트 트리는 초기화 함수도 따로 있었지만, 세그먼트 트리는 Update를 활용해서 초기화를 진행할 수 있기 때문에 초기화 함수가 따로 필요하지는 않다.

 

 

먼저 이 그림을 보자.

 

1의 경우 1로부터 앞쪽으로 1칸의 구간합을 저장하고 있다.

2의 경우 2로부터 앞쪽으로 2칸의 구간합을 저장하고 있다.

3의 경우 3으로부터 앞쪽으로 1칸의 구간합을 저장하고 있다.

4의 경우 4로부터 앞쪽으로 4칸의 구간합을 저장하고 있다.

 

쭉쭉 해보면 8의 경우엔 8로부터 앞쪽으로 8칸의 구간합을 저장하고 있다.

정확한 규칙성은 모른다 하더라도, 일단 2의 지수에 해당하는 칸의 구간합을 저장하고 있다는 것은 쉽게 알 수 있다.

 

규칙성을 찾기 위해 이진수의 형태로 인덱스를 확인해보자.

 

1-> 1

2-> 10

3-> 11

4-> 100

5->101

6->110

7->111

8->1000

 

여기서 최하위 비트에 집중을 해보자.

최하위 비트란, 이진수에서 가장 오른쪽에 있는 1을 의미한다.

위의 표는 각 인덱스를 이진수로 표현했을 때, 최하위 비트가 오른쪽으로 부터 몇 번째에 있는가를 기록한 표이다.

해당 배열의 원소값을 n이라고 했을 때, 2^(n - 1)을 구해보자.

이렇게 값이 변하게 된다.

 

보면 어디서 본 것 같은 숫자가 나온다.

위에서 구해보았던 구간합의 개수 정보이다.

4번 인덱스의 경우 원소값이 4가 되는데, 이 경우 4로부터 앞으로 4칸에 대한 구간합을 저장하겠다는 것이다.

 

8의 경우, 8로부터 앞으로 8칸까지의 구간합을 저장하겠다는 것이다.

 

일단, 이 정보만을 기억해두고 Update과정에 대해 알아보자.

만약, 3번째 숫자를 변경하고 싶다면, 위의 그림에서 주황색으로 표현된 곳처럼 3번 숫자를 포함하고 있는 구간합의 정보를 갱신해야 한다.

 

즉, 3번 숫자가 바뀌게 되면 펜윅트리에선 3, 4, 8 번의 구간합을 수정해야 하는 것이다.

 

3, 4, 8을 이진수로 표현해보자.

3-> 11

4-> 100

8->1000

 

3의 최하위 비트는 1이다.

3에다가 이진수 1을 더해보자.

100이 된다. 11 + 1 -> (3 + 1) -> 4 -> 100

 

이번엔, 4를 보자.

4의 최하위 비트는 3번째 1이고, 이진수로는 100이다.

4에다가 이진수 100을 더해보자.

1000이 된다. (100 + 100) -> 4 + 4 -> 8 -> 1000

 

즉, 변경된 숫자의 인덱스에서 최하위 비트를 더해가며 나오는 값이 변경된 숫자가 포함된 구간의 인덱스가 되는 것이다.

반복문을 돌며, 최하위 비트를 더해가며 인덱스를 구하면서 구간합을 갱신하면 끝이다.

 

다음은, 구간합을 구하는 법에 대해 알아보자.

위에서도 말했듯이 정확히는 누적합이다.

펜윅트리에서 직접적으로 구할 수 있는 구간은 무조건 1부터 시작한다.

K까지의 구간합을 구하게 되면 1~K의 합을 반환하기 때문에 누적합이라고 표현하는 것이 조금 더 적합하다.

N~K 의 구간합을 구하고 싶다면, [1 ~ K] - [1 ~ (N - 1)] 의 방식으로 구간합을 구하게 된다. 

 

구간합을 구하는 방식은 업데이트와 반대이다.

이번엔 최하위 비트를 빼면서 계산하면 된다.

 

예를 들어, 7까지의 누적합을 구한다고 해보자.

이진수로 표현하면 7은 111이다.

최하위 비트는 첫번째 1이고, 이진수로는 1이다.

 

111에서 1을 빼면, 110이 된다.

110은 십진수로 6이다.

 

이번엔 6에서 다시 최하위 비트를 빼보자.

110의 최하위 비트는 2번째 1이고, 이진수로는 10이다.

110 에서 10을 빼면, 100 -> 4가 나온다.

 

다음은 4에서 다시 최하위 비트를 빼보자.

100의 최하위 비트는 세번째 1이고, 이진수로는 100이기 때문에

4에서 최하위 비트를 빼면 0이 나온다. 

 

즉, 이제 더이상 더할 것이 없다는 것이다.

 

이 결과대로라면 펜윅트리의 7번 인덱스 + 6번 인덱스 + 4번 인덱스의 값이 1~7의 누적합이 되는 것이다.

 

그림에서 보면, 7번 인덱스 + 6번 인덱스 + 4번 인덱스의 값이 [1] ~ [7]까지의 합인 것을 확인할 수 있다.

 

이처럼, 펜윅트리는 최하위 비트를 활용하여 트리를 갱신하고 누적합을 구하게 된다.

개념 자체는 다소 복잡하지만 코드로 구현해보면 매우 간단하다는 것을 알 수 있다.

 

이제, Update와 GetSum을 알아보았으니 초기화 과정도 알아보자.

위에서 말했듯이 초기화는 Update를 이용해서 구현한다고 하였다.

최초에 배열은 이렇게 비어있다.

모든 값이 현재 0으로 저장되어 있는 것이다.

 

숫자가 위와 같이 주어진다면, 이 것을 변경되는 값이라고 가정하고 Update를 진행하는 것이다.

 

1번 인덱스의 숫자가 현재 1이다. 이를 숫자가 0->1로 변경되었다고 가정해보자.

그렇다면, 1번 숫자가 포함된 구간합을 저장하고 있는 펜윅트리의 인덱스를 모두 갱신해야 한다.

위에서 설명했던 최하위 비트를 활용하여 갱신하게 되면 아래와 같아진다. 

 

 

2번 숫자에 대해서도 갱신해보자.

3번 숫자에 대해서도 갱신하면?

 

이런 식으로 펜윅 트리가 계속 갱신된다.

즉, 맨 처음의 숫자가 모두 0이었다가 주어진 숫자로 변경되었다고 가정하고 모든 숫자에 대해 Update를 진행하게 되면

규칙대로 구간합을 저장하게 되는 것이다.

 

최종적으로는 이렇게 펜윅 트리가 완성된다.

이제 코드로 구현해보자.

 

 

 

코드 구현


std::vector<int> Nums;
std::vector<int> FenWickTree;

 

먼저 숫자를 저장할 벡터와 구간합을 저장할 펜윅트리 두 가지를 선언해주었다.

Nums.resize(NumSize + 1);
FenWickTree.resize(NumSize + 1);

 

이후, resize를 해주었다. 세그먼트 트리와 동일하게 펜윅트리도 인덱스 0번을 사용하지 않고 1번부터 사용한다.

왜냐하면 0의 이진수는 0이기 때문에 최하위 비트를 활용한 연산이 불가능하기 때문이다.

 

void Update(int _Index, int _Value)
{
    while (_Index < Nums.size())
    {
        FenWickTree[_Index] += _Value;
        _Index += (_Index & -_Index);
    }
}

 

그리고, 초기화를 하기 위해 Update함수를 먼저 정의하였다.

_Index는 변경된 숫자의 인덱스이고, _Value는 변화량이다.

1->3으로 바뀌었다면 _Value는 3이 아니라 2이다.

5->2로 바뀌었다면 _Value는 2가 아니라 3이다.

 

반복문 내부를 보면 &연산자를 이용해 비트연산을 진행하고 있다.

비트 연산에 익숙하지 않은 사람은 해당 연산이 뭘하는건지 잘 모를 수 있다.

 

먼저, 숫자 15를 비트로 표현해보자.

int자료형이라면 실제로는 32자리가 되겠지만, 편의를 위해 8자리만 사용해 설명하도록 하겠다.

 

15를 비트로 표현하면, 0000 1111이 된다.

-15는 어떻게 표현할까?

 

결론부터 말하자면, -15는 1111 0001이다.

 

어떤 비트의 부호를 바꾸고 싶다면, 1와 0을 모두 뒤집은 뒤 1을 더해주면 된다.

 

0000 1111 의 0과 1을 모두 뒤집게 되면 1111 0000 이 된다.

여기서 1을 더해주면? 1111 0001 이 된다.

더보기

여기서, -15와 15는 2의 보수라고 표현하기도 한다.

 

왜냐하면 1111 0001과  0000 1111을 수학적으로 더하면, 1 0000 0000 이 된다. 절대값은 같지만 부호가 다른 두 수를 더하면 최대 비트를 초과하면서 가장 작은 2의 제곱수가 나오게 되는데, 수학에서 이 것을 2의 보수라고 한다.

 

하지만, 컴퓨터 공학에선 1111 0001 과 0000 1111을 더한다고 1 0000 0000 이 나오지는 않는다.

 

1111 0001은 -15이고, 0000 1111은 15이다. 두 수를 더하면 0이 나온다.

0의 비트는 0000 0000 이다. 

 

즉, 1111 0001 과 0000 1111을 더하면 0000 0000이 나온다는 뜻인데, 이는 컴퓨터 공학에서 비트 연산이 수학적인 이진수 덧셈과는 다르다는 것을 알려주는 부분이다. 

 

그럼에도 2의 보수라는 단어를 사용하는 이유는 그냥 수학에서 2의 보수라고 하니까 관습적으로 사용하는 것 같다.

 

15의 비트 -> 0000 1111

-15의 비트 -> 1111 1001 

 

두 비트의 &연산을 하게 되면 두 비트 모두 1인 비트는 1로, 다른 비트는 0으로 표현하여 반환하게 된다.

즉 15 & -15 를 하게 되면 0000 0001 이라는 비트가 반환된다.

최하위 비트를 구할 수 있는 것이다.

 

한 번 더 확인하기 위해 이번엔 19와 -19를 &연산 해보자.

 

20의 비트 -> 0001 0100

-20의 비트 -> 1110 1100

 

20 & -20 -> 0000 0100

이 번에도 최하위 비트를 반환하였다.

 

이 결과를 보면 Num과 -Num을 &연산한 결과는 Num의 최하위 비트라는 것을 알 수 있다.

이를 이용해, 인덱스에 최하위 비트를 더해주면서 최대 인덱스를 초과하기 전까지 펜윅 트리를 갱신하는 것이 Update 함수이다.

 

다음은 누적합을 구하는 GetSumFromZero 함수이다.

본인은 GetSumFromZero와 GetSegSum함수를 분리해놓았는데

GetSumFromZero는 누적합을 구하는 함수이고, GetSegSum함수는 원하는 구간의 구간합을 구하는 함수로 만들었다.

 

int GetSegSum(int _Start, int _End)
{
    return GetSumFromZero(_End) - GetSumFromZero(_Start - 1);
}

 

구간합은 위에서 설명했듯이, 두 누적합의 차를 반환하면 된다.

 

int GetSumFromZero(int _Index)
{
	int Sum = 0;

	while (_Index > 0)
	{
		Sum += FenWickTree[_Index];
		_Index &= (_Index - 1);
	}

	return Sum;
}

 

누적합을 구하는 함수는 위와 같다.

누적합을 구할 때에는 최하위 비트를 빼면서 값을 더한다고 하였다.

그런데, while문 내부를 보면 위에서 보았던 양수와 음수의 &연산을 통해 최하위 비트를 구하고 있지 않다.

 

물론 위에서 설명한 방식으로 해도 된다. 다만, 한 가지 방식이 더 있기 때문에 보여주기 위해 사용하였다.

먼저, 최하위 비트를 뺀다는 것은 다른 비트는 그대로 냅두고 최하위 비트만 0으로 만들겠다는 것이다.

 

1110 0101 이라면 1110 0100으로 만들고, 0000 1000이라면 0000 0000으로 만드는 것이다.

 

Index를 7이라고 가정해보자. 7은 0000 0111이 된다.

여기서 1을 빼면, 0000 0110이 된다.

 

두 수를 &연산하면, 0000 0110이 나온다. 최하위 비트를 뺀 값이 나왔다.

 

이번엔, Index를 20이라고 가정해보자.

20은 0001 0100이 된다.

여기서 1을 빼면, 0001 0011이 된다.

 

두 수를 &연산하면 0001 0000이 된다. 

 

결과를 보면 _Index 와 _Index - 1 을 &연산하면, _Index에서 최하위 비트를 뺀 값이 나온다는 것을 알 수 있다.

더보기

비트의 마지막 수가 1이라면, 1을 뺐을 때 마지막 수가 0이된다.

측, 최하위 비트가 1에서 0으로 바뀌는 것이다.

 

그러므로 기존의 값과 &연산을 하게 되면 마지막 값만 0으로 바뀐 채로 나머지 비트는 유지된다.

 

반면, 비트의 마지막 수가 0이라면, 1을 뺐을 때 최하위 비트의 값이 1->0으로 바뀐다.

 

0100에서 1을 빼보자. 0011이 된다.

0010에서 1을 빼면, 0001이 된다.

1000에서 1을 빼면, 0111이 된다.

1011 0100에서 1을 빼면, 1011 0011이 된다.

 

즉 1을 빼면, 가장 오른쪽 비트부터 최하위 비트까지 0과 1이 반전된다.

그러므로, Num 과 Num - 1을 &연산하면 최하위 비트는 서로 다르기에 0이 되고, 나머지 값은 모두 그대로 보전이 된다.

 

(최하위 비트 뒤의 값은 서로 달라 &연산을 하면 0이 되지만, 최하위 비트의 특성을 생각해보면 원래 0이었기 때문에 값에 변함이 없다. 최하위 비트 앞의 값들은 변하지 않았기 때문에 &연산을 하여도 값이 변하지 않는다.)

 

코드를 모두 정리하면 아래와 같다.

std::vector<int> Nums;
std::vector<int> FenWickTree;

int GetSumFromZero(int _Index)
{
    int Sum = 0;

    while (_Index > 0)
    {
        Sum += FenWickTree[_Index];
        _Index &= (_Index - 1);
    }

    return Sum;
}

int GetSegSum(int _Start, int _End)
{
     return GetSumFromZero(_End) - GetSumFromZero(_Start - 1);
}

void Update(int _Index, int _Value)
{
    while (_Index < Nums.size())
    {
        FenWickTree[_Index] += _Value;
        _Index += (_Index & -_Index);
    }
}

int main()
{
    //숫자는 모두 입력받았다고 가정하자.
    Nums.resize(NumSize + 1);
    FenWickTree.resize(NumSize + 1);
    
    //초기화
    for (int i = 1; i <= NumSize; i++)
    {
        Update(i, Nums[i]);
    }
}

 

 

위와 같은 값에 대해 테스트 해보자.

 

먼저 초기화 함수를 테스트해보자.

 

초기화가 잘 된 것을 확인할 수 있다.

 

다음은 GetSegSum함수를 테스트해보자.

 

매우 잘 구해지는 것을 확인할 수 있다.

만약, 1~100까지의 숫자가 있을 때 그 사이에 있는 구간의 합을 구하고 싶다고 해보자.

예를 들면, 66~80의 합을 구하고 싶다거나 31~99의 합을 구하고 싶을 수도 있다.

이렇게 숫자가 간단하면 등차수열 공식을 이용하여 빠르게 구하면 된다.

 

그런데 숫자가 , 1, 6, 11, 12 ,18, 23,91 이런 식으로 규칙성 없이 주어진다면?

이 땐, 직접 더해볼 수 밖에 없다.

 

하지만, 더하는 것이 단 1번이라면 몰라도 여러 구간의 합을 계속 구해야 한다면?

불규칙적인 숫자가 100만개가 주어졌다고 해보자.

 

여기서, 랜덤한 구간의 합을 구하는 명령을 1만번정도 실행해야 한다고 하면, 구간의 길이만큼의 반복문을 1만번 실행해야 한다.

 

만약, 구간이 1~1000000 인 입력만 계속 들어온다면?

100억번의 반복문을 돌아야 한다 .

 

정말 끔찍한 연산량이다.. 이러한 시간복잡도를 줄이고 효율적으로 구간의 합을 구하기 위한 알고리즘이 세그먼트 트리이다.

 

 

세그먼트 트리


 

세그먼트 트리는 구간의 합을 미리 저장해놓고, 저장해놓은 값을 기반으로 빠르게 구간의 합을 탐색하는 알고리즘이다.

이름이 세그먼트 트리인 만큼 트리구조를 이용하여 구간의 합을 저장한다.

 

먼저, 아래의 배열과 같은 숫자가 주어졌다고 해보자.

 

여기서, 세그먼트 트리는 아래와 같이 구간을 나눠 합을 미리 저장하게 된다.

 

루트 노드엔 모든 숫자의 합을 저장하게 되고, 그 중 절반을 나눠 왼쪽 노드와 오른쪽 노드에 저장하게 된다.

말단까지 가면, 숫자 1개만큼의 합을 가진 노드가 위치하게 된다.

 

이렇게 저장한 뒤, 찾고자 하는 구간을 입력받으면 해당 구간에 대해 탐색하게 된다.

탐색 과정은 아래와 같다.

이렇게, 구간을 쪼개서 세그먼트 트리 내부에 저장된 구간의 합을 탐색하게 된다.

 

원리 자체는 이게 끝이다. 간단하다. 숫자 범위가 좁아서 연산 횟수가 별 차이가 안나는 것 처럼 보이지만,

 

실제로는 O(LogN)의 시간복잡도를 보유하기 때문에 선형으로 구간의 합을 구할 때보다 압도적으로 빠른 속력을 보인다.

다만, 구간의 합을 나눠서 미리 저장하는 만큼 메모리 사용량이 다소 많아지게 된다.

 

일반적으로 세그먼트 트리는 배열을 이용하기 때문에, 몇개의 구간합을 보유하는지에 대한 예측이 필요하다.

(reserve, resize를 사용하기 위해)

 

일반적으로 N개의 숫자가 주어지면, 4 *N으로 reserve, resize를 진행하게 된다.

그 이유는 아래와 같다. (수학적 증명이 필요없다면, 스킵해도 된다.)

 

더보기

N개의 숫자에 대해 아래의 등식이 성립한다고 가정해보자.

$$ 2^n <= N < 2^ {n + 1} $$

이 경우, 절반씩 나누어지는 세그먼트 트리의 특성을 생각하며 등비수열의 합 공식을 사용하면 구간합의 개수 M에 대해 아래와 같은 등식이 성립하게 된다.

$$ 2^ {n +1} + 1 <= M < 2^ {n + 2} $$

 

두 등식을 합쳐보면, 아래와 같은 등식으로 다시 표현할 수 있다.

$$2^n <= N < 2^{n + 1}< 2^{n + 1} + 1 <= M < 2^{n + 2}$$

 

마지막 항인 2^ (n + 2)는 4 * 2^n으로 풀어쓸 수 있기 때문에, 다시 아래와 같이 표현해보자.

$$2^n <= N < 2^ {n + 1} < 2^ {n + 1} + 1 <= M <  4 * 2^n $$

 

위에서 정의한 N의 범위를 생각해보면, 아래와 같은 등식까지 유도할 수 있다.

$$2^n <= N < 2^{n+1} < 2^{n+1} + 1 <= M < 4 * 2^n <= 4N$$

 

결과적으로 구간합의 개수 M에 대해 아래와 같은 관계가 성립하게 된다.

$$ M < 4N $$

 

세그먼트 트리는 일반적으로 3가지의 함수를 구현하게 된다.

 

1. 세그먼트 트리 초기화

2. 구간합 탐색

3. 숫자 변경에 대한 트리 업데이트

 

1번은 세그먼트 트리를 사용하기 위래 트리에 구간합을 저장하는 것이며,

2번은 원하는 구간을 입력하여 구간합을 구하는 함수이다.

 

3번은 무슨 말인지 잘 이해가 안될 수 있으니 설명해보겠다.

 

최초에 1,2,3,4,5 가 주어진다면 세그먼트 트리는 이 숫자를 기반으로 구간합을 저장할 것이다.

 

그런데, 중간에 세번째 숫자를 6으로 바꾸게 된다면?

1,2,6,4,5로 숫자가 변경되면 세그먼트 트리에 저장된 구간합을 수정할 필요가 있다.

 

3번은 이렇게 숫자를 중간에 변경할 경우 세그먼트 트리에 저장된 구간합을 갱신하는 함수를 의미한다.

 

 

이제 코드를 구현해 보겠다.

 

 

 

코드 구현


 

위와 같은 숫자 배열에 대해 구간합을 구한다고 가정하도록 하겠다.

std::vector<int> Nums;
std::vector<int> SegmentTree;

int main()
{
	int NumSize = 8;

	Nums.resize(NumSize + 1);
	SegmentTree.resize(4 * NumSize);

	Nums[1] = 1;
	Nums[2] = 3;
	Nums[3] = 4;
	Nums[4] = 6;
	Nums[5] = 8;
	Nums[6] = 11;
	Nums[7] = 13;
	Nums[8] = 16;

	return 0;
}

 

숫자의 개수를 저장하고, 이를 기반으로 숫자를 담을 배열과 세그먼트 트리의 사이즈를 resize하였다.

주의할 점은 0번 인덱스가 아닌 1번 인덱스부터 사용한다는 것이다.

 

배열을 이용해 트리를 구현하게 되면, 인덱스의 곱을 통해 부모자식관계를 맺게 되는데 0번 인덱스부터 시작하면 모든 인덱스가 0이 되어 버린다.

 

이제, SegmentTree를 초기화 하는 함수를 세워보자.

먼저 아래 그림을 보자.

 

현재 우리가 아는 정보는 말단 노드에 단일 숫자들이 저장될 것이라는 것이다.

그렇다면, 밑에서부터 올라오며 합을 기록해야 할텐데 문제는 말단 노드의 인덱스를 알지 못한다는 것이다.

 

처음부터 아래에서 위로 올라가며 구간합을 더하는 것은 불가능하기 때문에, 재귀함수를 이용해서 말단 노드까지 내려간 다음 말단 노드에서 다시 합을 해주며 위로 올라올 것이다.

 

int InitTree(int _Start, int _End, int _CurIndex)
{
    if (_Start == _End)
    {
        SegmentTree[_CurIndex] = Nums[_Start];
        return SegmentTree[_CurIndex];
    }

    int Mid = (_Start + _End) / 2;

    int Left = InitTree(_Start, Mid, _CurIndex * 2);
    int Right = InitTree(Mid + 1, _End, _CurIndex * 2 + 1);
    SegmentTree[_CurIndex] = Left + Right;

    return SegmentTree[_CurIndex];
}

 

보자. 파라미터의 _Start는 합을 구하고자 하는 구간의 시작지점이고 _End는 합을 구하고자 하는 구간의 끝지점이다.

_CurIndex는 SegmentTree의 인덱스이다.

 

아래쯤을 보면 Mid를 기준으로 Left와 Right를 나눈 뒤, 두 값을 더해 SegmentTree에 저장해주고 있다.

Left와 Right를 구하는 과정에서 구간을 계속 절반으로 쪼개고 있는데, 계속 쪼개다 보면 구간의 시작과 끝이 같아지는 지점이 온다.

 

이 때가 바로 말단노드인 것이다. _Start == End가 되는 지점에 Nums의 [_Start]를 SegmentTree에 저장해준 뒤 해당 값을 반환해주고 있다.

 

이 때부터, 위로 올라오며 구간합을 갱신하게 된다.

 

처음에는 InitTree(1, 8, 1)을 호출할 것이다.

_Start와 _End는 모든 숫자를 포함하는 구간으로 설정하고 _CurIndex는 1번 인덱스로 설정할 것이다.

이렇게 되면 루트노드부터 내려오며 모든 구간에 대한 구간합을 갱신하게 된다.

 모두 끝나면 위 그림과 같은 값이 배열에 들어가 있을 것이다.

내부의 값을 보면 동일하게 들어있는 것을 확인할 수 있다.

SegmentTree의 초기화는 끝났다.

 

이제 구간합을 탐색하는 함수를 만들어보자.

int GetSum(int _NumStart, int _NumEnd, int _SegStart, int _SegEnd, int _CurIndex)
{
    if (_SegStart > _NumEnd || _SegEnd < _NumStart)
    {
        return 0;
    }
    else if(_SegStart <= _NumStart && _SegEnd >= _NumEnd)
    {
        return SegmentTree[_CurIndex];
    }

    int _NumMid = (_NumStart + _NumEnd) / 2;

    return GetSum(_NumStart, _NumMid, _SegStart, _SegEnd, _CurIndex * 2) + GetSum(_NumMid + 1, _NumEnd, _SegStart, _SegEnd, _CurIndex * 2 + 1);
}

 

_NumStart와 _NumEnd는 노드에 기록된 구간이고, _SegStart와 _SegEnd는 합을 구하고자 하는 구간이다.

 

먼저, 두 가지의 예외처리를 주었다.

 

이렇게, _NumStart ~ _NumEnd 와 _SegStart~_SegEnd가 전혀 겹치지 않을 땐 0을 반환하도록 하였다.

 

위와 같이, 인자로 받은 _SegStart와 _SegEnd가 _NumStart와 _NumEnd를 품고있는 상황이 된다면

그대로 SegmentTree[_CurIndex]를 반환하도록 하였다.

 

이렇게, 두 가지 예외처리를 설정한 뒤 재귀호출을 하였다.

재귀호출은 InitTree와 유사하게 Mid를 둔 뒤, 양 방향으로 나누어 탐색하였다.

 

_SegStart, _SegEnd는 그대로 두고 _NumStart, _NumEnd만 쪼개가며 탐색을 하였다.

과정을 하나하나 보며 어떻게 진행되나 보자.

 

이건, 루트노드의 왼쪽 자식 노드를 탐색하는 과정이다.

위에서 _SegStart, _SegEnd와 _NumStart, _NumEnd 범위가 아예 겹치지 않는 경우와 그 안에 포함되는 경우 두 가지에 대해 재귀호출 종료 조건을 달았다.

 

하얀색 배경으로 칠해진 노드는 범위가 겹치지 않아 0을 반환하고 있는 노드이며

검은색 배경으로 칠해진 노드는 _NumStart와 _NumEnd를 포함하고 있어 SegmentTree[_CurIndex]를 반환하는 경우이다.

주황색 노드는 _NumStart와 _NumEnd가 일부분만 겹쳐있는 노드이다.

 

2~6에 대한 구간합을 구하기 위해 재귀호출을 했더니, 왼쪽 자식 노드에서는 2~4의 구간합을 반환하고 있다.

 

오른쪽 자식 노드에 대해서도 보자.

동일하게 진행되지만, 오른쪽 노드는 말단노드에 도착하기 전에 예외처리가 되어, 말단 노드까지 가기 전에 답을 찾아냈다.

오른쪽 노드에선 5~6의 구간합을 반환하고 있다.

 

마지막으로 루트 노드에서 왼쪽 자식노드에서 구한 구간합 (2~4)와 오른쪽 자식 노드에서 구한 구간합(5~6)을 더한 (2 ~6)의 구간 합을 반환하게 되면, 찾고자 했던 2~6의 구간합을 구할 수 있게 된다.

 

이번엔, 중간에 숫자가 변경되는 경우 구간합을 갱신하는 Update함수를 만들어보겠다.

void Update(int _NumStart, int _NumEnd, int _NumIndex, int _AddValue, int _CurIndex)
{
	if (_NumIndex < _NumStart || _NumIndex > _NumEnd)
	{
		return;
	}

	SegmentTree[_CurIndex] += _AddValue;

	if (_NumStart == _NumEnd)
	{
		return;
	}

	int Mid = (_NumStart + _NumEnd) / 2;

	Update(_NumStart, Mid, _NumIndex, _AddValue, _CurIndex * 2);
	Update(Mid + 1, _NumEnd, _NumIndex, _AddValue, _CurIndex * 2 + 1);

}

 

이번에도 거의 유사하다.

_NumStart, _NumEnd는 노드가 저장하고 있는 구간 합의 구간이며, _NumIndex는 바뀐 숫자의 인덱스이다.

여기서 주의할 점은 _Addvalue이다.

 

만약, Nums[5] =3 에서 Nums[5] = 10 으로 바뀌었다면, _AddValue는 7이 된다.

Nums[5] = 10에서 Nums[5] = 3이 되었다면, _Addvalue는 -7이 된다.

 

이 함수는 숫자의 변화량만큼 구간합에 그대로 더해주는 방식이기 때문에 _Addvalue에는 바뀐 숫자를 넣는 것이 아니라 기존의 숫자와의 차이를 넣어주어야 한다.

 

_CurIndex는 SegmentTree의 인덱스이다.

 

먼저, _NumIndex를 _NumStart, _NumEnd가 포함하지 않는 경우에는 바로 Return 해주었다.

포함되었다면, SegmentTree에 _AddValue를 더해주어 구간합을 갱신해준다.

 

_NumStart와 _NumEnd가 같다면 (말단 노드라면) 이대로 끝내면 되고,

말단 노드가 아니라면 자식 노드에 대해서도 검사해야 한다.

 

자식노드에 대해 왼쪽 오른쪽 Update함수를 호출해주면 된다.

여기서, Update함수를 왼쪽과 오른쪽에 대해 둘 다 호출하고 있는데, _NumIndex는 둘 중 하나에밖에 속하지 못하기 때문에 둘 다 호출할 필요는 없다.

 

조건문을 달아서 한 쪽만 호출하게 할 수도 있지만, 어차피 양쪽의 함수를 호출해도 한쪽은 가장 위의 조건문 때문에 바로 return된다.

 

조건문을 다는 것보다 간단하기 때문에 위처럼 양쪽 노드의 update를 모두 호출하고 있다.

본인이 조건문을 달아서 한 쪽만 호출하게 하고 싶다면 그렇게 하면 된다.

 

이제 기능은 모두 구현하였다.

테스트 한 번 해보자.

 

InitTree는 위에서 확인하였으니 GetSum과 Update에 대해 확인해보자.

 

 

2~5의 구간합은 21이다.

 

3~8의 구간합은 58이다.

 

1~3의 구간합은 8이다.

 

잘 작동하는 것을 확인할 수 있다.

 

다음은 Update이다.

 

이렇게 4번 인덱스를 10으로 바꿔보겠다.

 

이렇게 갱신을 한 뒤 SegmentTree의 값을 확인해보자.

제대로 갱신된 것을 확인할 수 있다.

 

 

에라토스테네스의 체란, 일정 범위안에 있는 숫자들 중 소수만와 소수가 아닌 수를 구별하기 위한 알고리즘이다.

 

이렇게, 1부터 25까지의 숫자가 있다고 해보자.

 

이중에 소수만을 판별하면, 이렇게 된다.

이 그림처럼 소수만 걸러내는 알고리즘이 에라토스 테네스의 체이다.

 

그렇다면, 어떻게 알고리즘이 작동할까?

어렵지 않다.

먼저 소수란, 1과 본인을 제외한 어떠한 약수도 가지지 않는 수이다.

다르게 말하면, 1이 아닌 다른 어떠한 수의 배수도 되지 않는다는 뜻이다.

그렇다면, 2부터 시작해서 25까지 배수를 모두 제거한다면 남는 수가 소수라고 할 수 있지 않을까?

 

그림으로 보자.

 

이게 최초의 상태이다.

여기서 2의 배수를 먼저 제거해보자.

파란색으로 칠해진 부분이 제거된 숫자이다.

2의 배수를 모두 제거해보았으니, 3의 배수도 제거해보도록 하겠다.

 

이번엔 5의 배수도 지워보자.

 

이렇게 되었다. 5까지밖에 반복문을 돌지 않았는데도 소수만 남은 상태가 되었다.

 

그런데 보면 한 가지 문제점이 보인다.

2, 3, 5 또한 소수인데 불구하고 함께 제거해버린 것이다.

이런 일을 방지하기 위해, 배수를 제거할 때엔 본인은 제외하고 다음 배수부터 제거하는 것이 옳다.

 

2, 3,5 를 다시 추가해주면 아래와 같이 소수만 남는 것을 볼 수 있다.

마지막으로, 1의 경우는 소수가 아니다.

2의 배수부터 제거했기 때문에 마지막에 1이 남아있는 것을 볼 수 있다.

그러므로 1도 제거해주자.

 

이렇게 배수를 차례대로 지워나가면서 소수만 걸러내는 것이 에라토스 테네스의 체이다.

매우 간단하다.

 

하지만, 상식적으로 생각했을 떈 1~25까지 반복문을 다 돌아야할 것 같은데

실제로는 2~5까지만 돌았음에도 모든 소수가 제거되었다.

왜 그럴까? 

 

이는 우연이 아니다. 실제로 1~N까지의 소수를 판별하고 싶다면, sqrt(N) 까지의 배수만 검사해도 모든 소수를 판별할 수 있다.

 

다음은 이에 대한 수학적 증명이다. 어렵진 않으니 참고하도록 하자.

 

먼저, N이라는 자연수는 어떠한 실수의 제곱으로 표현할 수 있다.

N = m * m; 이라고 표현해보자.

 

이번엔 N을 어떠한 두 약수의 곱으로 표현해보자.

N = a * b 가 될 것이다.

 

여기서 a,b 와 m의 관계를 생각해보자.

 

먼저 a * b =  m * m 이다.

이 식을 보면 3가지의 경우를 나누어 볼 수 있다.

 

1. a == m , b == m 인 경우

당연히 a와 b가 m과 동일한 경우가 있을 것이다.

 

2. a > m, b > m 인 경우

다음 경우를 생각해보면, a와 b가 모두 m보다 클 수는 없다.

m보다 큰 두 수의 곱이라면 당연히 N보다 큰 값이 나오기 때문이다.

그러므로, 이러한 경우는 존재하지 않는다.

 

3. a < m , b > m 혹은 a >m , b < m 인 경우

a과 b가 m과 같지 않은 수라고 한다면, 두 숫자중 하나는 반드시 m보다 커야하고 남은 하나는 m보다 작아야 한다.

둘 다 m보다 크다면 a와 b의 곱은 N보다 큰 값이 나올 것이며, 둘 다 m보다 작다면 a와 b의 곱은 N보다 작은 값이 나올 것이다.

 

즉, N이라는 숫자는 반드시 sqrt(N)보다 작거나 같은 수의 배수가 될 것이다.

그러므로, 1~sqrt(N)까지의 수의 배수만 검사하여도 1~N 까지의 모든 배수를 제거할 수 있는 것이다.

 

 

코드 구현


이를 코드로 구현해보자.

int N = 100;
std::vector<int> Nums(N + 1, -1);

먼저, N을 100이라고 가정해보자.

Nums 배열의 값은 최초에 -1로 초기화 하였다.

만약, 해당 인덱스가 소수가 아니라고 판별된다면 값을 0으로 바꿀 것이다.

당연히 int가 아닌 bool값을 활용하여 구현하여도 된다.

 

Nums[1] = 0;

 

1은 소수가 아니므로, 먼저 제거해주자.

for (int i = 2; i * i <= N; i++)
{
    if (Nums[i] != -1)
    {
        continue;
    }

    for (int j = i + i; j <= N; j += i)
    {
        Nums[j] = 0;
    }
}

 

그 다음은 위와 같이 반복문을 돌리면 된다.

 

반복문의 조건을 보면, i * i <= N 까지로 조건을 설정해두었다.

위에서 말했듯이 N의 제곱근까지만 반복문을 돌면 된다.

i * i <= N 이나 i <= sqrt(N) 이나 동일하기 때문에 편한대로 사용하면 된다.

 

i*i는 매 프레임마다 곱하기 연산을 해주어야 하지만, sqrt(N)의 경우 처음 한 번만 하고 변수에 저장한 뒤 사용하면 되기 때문에 최적화를 조금이라도 노리려면 sqrt(N)을 하는게 더 좋을 수도 있다.

(다만 sqrt연산 자체가 곱하기에 비해 매우 느린 편이라 N이 충분히 크지 않다면 별 효과 없을 수도 있다.)

 

이후 내부 코드를 보자.

해당 인덱스의 값이 이미 0이라면, 이미 제거된 수이다.

당연히 이 숫자의 배수도 제거되었을테니 검사할 필요 없이 continue하면 된다.

 

제거되지 않은 수라면 그 숫자의 배수를 모두 지워나가면 된다.

위에서 말했듯이, 시작 인덱스는 i부터가 아니라, i + i 부터 시작하고 있다.

첫 번째 수는 반드시 소수이기 때문에 두 번째 배수부터 제거해나가면 된다.

 

이러면 에라토스 테네스의 체 끝이다.

 

이제, 배열의 값을 기반으로 남은 소수를 출력해보면 아래와 같이 소수만 걸러져 나오는 것을 확인할 수 있다.

 

 

에라토스 테네스의 체는 어렵지 않은 알고리즘이다.

하지만, 소수에 관한 문제가 나온다면 거의 100%확률로 사용되는 알고리즘이기도 하다.

알고 있는 것과 모르는 것은 문제를 푸는 것에 있어 천지차이이기 때문에 반드시 숙지하도록 하자.

 

유니온 파인드란, 노드간의 집합을 구분하는 알고리즘이다.

 

 

위 그림과 같이 10개의 노드가 있다고 해보자.

이 때, 같은 색을 가진 노드끼리 구분하여 집합을 연결하고 싶다.

 

이런 식으로 말이다.

 

그렇다면, 어떻게 이렇게 구분을 지을 수 있을까?

가장 간단한 방법은 배열을 만드는 것이다.

 

배열을 만들어서 파란색이면 1번, 초록색이면 2번, 주황색이면 3번으로 입력한 뒤,

배열을 1~N까지 순회하며 값을 갱신해주면 된다.

 

하지만, 다음의 경우엔 어떨까?

위 문제처럼 직접적으로 어떤 노드가 파란색인지 초록색인지 정보가 없고, 두 노드가 색이 같다는 정보만 주어지는 것이다.

예를 들어 입력이 아래처럼 들어왔다고 해보자.

1 2
2 3
3 4

5 6
5 7

8 10
9 10

 

12가 같은색이고, 23이 같은 색이고 34가 같은 색이다.

56이 같은 색이고 57이 같은 색이다.

8 10이 같은 색이고, 9 10이 같은 색이다.

 

이렇게, 어떤 노드가 같은 색을 가지고 있는 가에 대한 정보만 주어지고, 정확히 무슨 색인지 주어지지 않는다면

위에서처럼 배열을 순회하며 값을 특정 색상으로 갱신하는 방법은 불가능해진다.

 

이런 경우에 사용하는 알고리즘이 union_Find알고리즘이다.

union - find 알고리즘은 이름 그대로, Union과 Find의 2개의 기능으로 구성된다.

 

1. Union

    : 같은 속성을 지닌 노드들을 하나의 집합으로 합치는 것.

 

2. Find

    : 노드가 어느 집합에 속하는지 찾아보는 것.

 

그렇다면 그걸 어떻게 구현하게 될까?

아래 그림을 보자.

 

 

정확한 색은 알 수 없지만, 위와 같은 연결 관계가 주어져있다는 것은 입력을 통해 확인하였다.

그렇다면, 연결된 노드끼리 합쳐서 하나의 집합으로 만들어 볼 수 있을 것이다.

이 과정을 union이라고 한다.

 

 

위에서 그림을 보면, 12, 34, 23의 연결관계를 보면 오른쪽처럼 1-2-3-4로 하나로 묶을 수 있다.

나머지 노드들도 위의 그림처럼 하나의 집합으로 묶을 수 있을 것이다.

 

그런데, 단순히 이렇게 집합만 묶어버리면 Find연산을 할 수가 없어진다.

 

Find연산이란, 특정 노드가 어느 집합에 속해있는가를 찾는 연산인데 이렇게 하나로 묶어버리기만 하면, 집합간의 관계를 구별할 수가 없다. 무슨 말이냐면, 1과 5가 같은 집합에 속해있는가? 3과 7이 같은 집합에 속해있는가? 를 묻는다면 그걸 구별할 방법이 없다는 것이다.

 

그렇기 때문에, 트리 구조를 사용할 것이다.

 

이렇게, 선형적인 연결에서 트리구조로 바꾸게 되면, 루트 노드의 인덱스를 통해 집합을 서로 구분할 수 있게 된다.

예를 들어보면, 2가 속한 집합의 루트노드는 1이고 3이 속한 집합의 루트노드 역시 1이다.

그러므로 두 노드는 같은 집합에 속한다고 표현할 수 있게 되는 것이다.

 

즉, Union이란 같은 속성을 지닌 노드를 트리구조로 구축하는 것이며

Find란 노드의 루트 노드를 검사하는 일이 되는 것이다.

 

 

그렇다면, 구체적으로 어떻게 Union과 Find가 작동하는지 알아보도록 하자.

 

 

 

유니온 파인드


먼저, 각 노드별로 부모 노드가 어디인가를 저장하는 배열을 하나 선언해야 한다.

std::vector<int> Parents;

 

노드의 개수가 10개라고 했을 때, 배열을 표로 그려보자.

각 배열의 원소는 인덱스에 해당하는 노드의 부모 노드가 누구인가를 가리키는 것이다.

예를 들어, 2번 원소의 부모노드가 6번이라고 한다면 2번 원소의 값은 6이 되는 것이다.

 

최초에는 이 배열의 값을 인덱스로 초기화 할 것이다.

 

이 값은 Union과정을 거치면서 계속 바뀌게 될 것이다. 하지만, 변하지 않고 계속 자기 자신을 가리키게 될 노드들도 있다. 그건 바로 루트노드이다.

 

루트 노드의 경우엔, 부모노드가 존재하지 않는다. 그렇기 때문에 이 배열에서 인덱스와 원소의 값이 동일하다면 그 노드는 루트 노드라고 가정할 것이다.

 

먼저, 입력은 아래와 같이 주어진다고 가정하도록 하겠다.

1 2
2 3
3 4

5 6
5 7

8 10
9 10

 

 

먼저 주어진 1, 2에 대해서 확인해보자.

 

현재 배열 내부에서는 모든 노드가 본인 자신을 부모 노드로 가리키고 있다.

 

1번과 2번도 이렇게 연결되지 않고 분리되어 있는 상태인 것이다.

하지만, 입력을 보면 1과 2는 연결되어 있다는 것을 확인할 수 있다.

그러므로 1과 2를 이어주어야 한다.

 

이려렇게 트리구조로 연결해주도록 하겠다.

 

(노드를 연결할 때, 인덱스가 더 작은 노드를 부모로 둘 것인가 더 큰 노드를 부모로 둘 것인가를 생각해야 하는데, 어느 방식으로 하든 결과는 동일하다. 하지만 하나의 기준을 선택했다면 모든 노드에 대해 동일한 기준을 적용해야 한다. 본인은 인덱스가 더 작은 노드를 부모 노드로 연결하도록 하였다.)

 

이렇게 연결하고 나니, 2번 노드의 부모 노드는 1번이 되었다.

그렇다면, 배열의 값을 갱신해야 한다.

 

이렇게 값을 갱신하였다.

 

이번엔, 두 번째 입력을 보자.

2와 3이 연결되어 있다고 한다.

그렇다면, 이건 아래 그림과 같이 연결해줄 수 있다.

 

이렇게 연결하면, 배열에서 3번째 원소의 값은 2가 될 것이다.

 

하지만, 이렇게 연결하게 되면 문제가 있다.

이렇게 연결하게 되면 3의 루트 노드를 찾기 위해 3->2->1 이렇게 선형으로 탐색해야 한다.

노드가 적을 땐 문제가 없을지 몰라도, 노드가 1억개정도 된다고 치면 1억번을 타고 올라가서 루트 노드를 구해야 하는 것이다. 이 과정이 굉장히 비효율적이다.

그렇기 때문에 위 그림과 같이 연결해줄 것이다.

 

이 알고리즘에서 중요한 것은 2 3 이라는 입력이 주어졌을 때, 2와 3이 직접적으로 연결된다는 것이 아니라 두 숫자가 같은 속성을 공유한다는 것이다.

즉, 연결 관계는 중요하지 않고 같은 집합에만 존재하면 된다는 것이다.

 

그러므로, 위와 같은 트리로 구현할 것이다.

배열에는 3이 속한 트리의 루트 노드를 저장해주도록 하겠다.

 

똑같은 과정으로 다른 노드에도 적용을 하게 되면 아래와 같이 트리를 구성할 수 있다.

 

 

또한, 배열의 값은 아래와 같을 것이다.

 

이제 집합이 완성되었으니, Union과정은 끝난 것이다.

 

하지만, 문제가 한가지 있다.

항상 위의 트리처럼 깊이가 2인 깔끔한 트리구조를 구현할 수 있다면 좋겠지만 그렇게 되지는 않는다.

2 3, 3 1의 입력을 받았다고 가정해보자.

먼저 2와 3을 연결해주면 아래 그림과 같을 것이다.

여기서 1을 연결해주면?

 

이런 형태의 트리가 되고 만다.

그런데 이는 위에서 말했던 것처럼 비효율적인 트리 형태이다.

즉 완벽하게 깊이가 2인 트리를 항상 유지하는 것은 아니라는 것이다.

이걸 최적화하는 기법도 있는 걸로 아는데, 그 부분은 본인도 조금 더 찾아봐야 할 듯 하다.

 

이제 이 알고리즘을 코드로 구현해보자.

 

 

코드 구현


std::vector<int> Parents;

먼저, 부모 노드를 저장할 배열을 선언하였다.

 

입력은 아래와 같이 들어온다고 가정하도록 하겠다.

10
8
1 2
2 3
3 4
5 6
5 7
8 10
9 10

 

첫 번째 입력은 노드의 개수를 의미한다.

두 번째 입력은 이후에 주어질 집합 정보의 수를 의미한다.

다음은 집합 정보가 주어진다.

1 2 가 주어진다면, 1과 2는 같은 집합에 속한다는 의미이다.

 

입력을 저장해보자.

int NumOfNode = 0;
int NumOfLink = 0;

std::cin >> NumOfNode >> NumOfLink;

std::vector<std::pair<int, int>> Links(NumOfLink, {0, 0});
for(int i = 0; i < NumOfLink; i++)
{
    std::cin >> NumOfLink[i].first;
    std::cin >> NumOfLink[i].second;
}

 

입력은 이렇게 하면 끝이다.

 

이후, parents 배열의 값을 인덱스로 초기화해주었다.

Parents.resize(NumOfNode + 1);
for (int i = 0; i <= NumOfNode; i++)
{
	Parents[i] = i;
}

 

이제, Root노드를 구하는 함수를 먼저 만들어보자.

int GetRootParents(int _Num)
{
    if (Parents[_Num] == _Num)
    {
        return _Num;
    }
    else
    {
        return GetRootParents(Parents[_Num]);
    }
}

 

먼저, 현재 배열의 값과 인덱스가 동일하다면 그 노드는 루트노드라고 정의하였다.

그러므로, 루트 노드를 반환할 때까지 배열을 타고 올라가서 루트 노드를 반환하도록 하였다.

void SetParents(const int& _Left, const int& _Right)
{
    int LeftParents = GetRootParents(_Left);
    int RightParents = GetRootParents(_Right); 
    
    if (LeftParents < RightParents)
    {
    	Parents[RightParents] = LeftParents;
    }
    else
    {
    	Parents[LeftParents] = RightParents;
    }
}

 

이 함수가 union역할을 하는 함수이다.

루트노드를 확인하며 부모 노드를 수정해주면 된다.

 

다음은 아래와 같이 반복문을 돌며 부모 노드를 설정해주면 끝이다.

for (int i = 0; i < NumOfLinkInfo; i++)
{
    SetParents(Links[i].first, Links[i].second);
}

 

입력값에 대한 부모 노드 배열의 값을 확인해보자.

 

이와 같이 정확하게 저장되어 있음을 알 수 있다.

 

하지만, 똑같은 연결 관계더라도 입력을 아래와 같이 바꾼다면 어떨까.

10
7
4 3
2 1
1 4
7 6
5 6
9 10
9 8

 

결과는 아래와 같다.

 

 

이를 시각화 해보자.

이런 모양이 된다.

 

즉, 입력에 따라 아주 비효율적인 트리구조를 구성할 수도 있게 되는 것이다.

이를 최적화하기 위해 사용하는 몇가지 기법이 더 있는 것으로 알고 있는데, 그 부분에 대해선 본인도 찾아봐야 할 듯 하다.

 

관련 게시글을 추후에 올려보도록 하겠다. 

 

유클리드 호제법이란, 두 숫자가 주어졌을 때 최대 공약수를 간단하게 구하는 알고리즘이다.

 

일반적으로 두 수의 최대공약수를 구하기 위해선, 두 수를 1부터 차례대로 나누어 가며 두 숫자 모두 나머지가 0이 나오는 숫자를 구해야 한다.

 

숫자의 값이 N, M 이고, N < M 이라고 했을 때, 시간복잡도는 O(N)이 된다.

반면, 유클리드 호제법을 사용하게 되면 O(logN)의 시간 복잡도로 최대 공약수를 구할 수 있다.

 

 

 

유클리드 호제법


 

그렇다면, 유클리드 호제법은 어떻게 사용하는 것일까?

 

먼저 코드를 보자.

int GetGCD(int _A, int _B)
{
    int Remain = 0;
    
    while(_B > 0)
    {
    	Remain = A % B;
        A = B;
        B = Remain;
    }
    
    return A;
}

 

위와 같이 매우 짧은 코드이다.

A를 B로 나눈 나머지가 0이 될 때까지 반복문을 돌리면 된다.

 

R이 A % B이고, GCD(A, B)가 A와 B의 최대 공약수라고 했을 때,

GCD(A, B)  = GCD(B, R) 이 성립한다.

 

R1 = B % R이라고 한다면,

GCD(B, R) = GCD(R, R1)또한 성립하는 것이다.

 

반복문을 계속 실행하여, GCD(N, 0)이 될 때 까지 반복한다.

GCD(N,0)의 값은 N이므로, GCD(A,B) = N 이라는 결론이 난다.

 

즉, 위의 코드에서 보면 Remain이 0이 될 때의 A가 GCD가 되는 것이다.

 

아마, 왜 이렇게 되는지는 수학적 증명과정을 거치지 않으면 이해하기 힘들 것이다.

아래는 이해를 돕기 위한 수학적 증명 과정이다.

 

다만, 증명 과정을 꼭 알아야하는 것은 아니다. 오히려 이해를 더 어렵게 만들 수도 있다.

그러니, 관심이 없다면 증명과정은 그냥 스킵하고 위의 공식만 가져다 사용해도 사실 문제는 없다.

 

수학적 증명

더보기

먼저, R = A & B 일 때, GCD(A, B) == GCD(B, R)인 이유

 

최대 공약수를 G라고 할 때, 

A = aG;

B = bG; 로 표현할 수 있다. (a와 b는 서로소이다.)

 

q = A / B라고 했을 때,

A = qB + R; 로 표현할 수도 있다.

 

A = qB + R; 

aG = q * bG + R;

aG - q * bG = R;

(a - qb)G  = R;

 

이렇게, R = (a - qb)G 라는 식을 유도할 수 있다.

 처음에 정의했듯, B = bG 이다.

 

R = (a - qb)G 

B = bG 

 

두 수 모두 G라는 공약수를 보유하고 있다.

여기서 a-qb와 b가 서로소라면, G는 최대공약수라고 할 수 있다.

 

이번엔, a-qb와 b가 서로소임을 증명해보겠다.

귀류법을 이용해 증명하도록 하겠다.

 

귀류법이란, 해당 명제가 틀렸음을 가정한 상태에서 모순을 찾는 방식이다.

명제가 틀렸다고 가정했는데 모순이 발생한다면, 반대로 해당 명제는 틀리지 않았음을 증명할 수 있는 것이다.

 

a-qb와 b가 c라는 공약수를 보유하였다고 가정해보자. (단, c는 2이상의 정수이다.)

 

a-qb = Xc;

b = Yc;

 

a - q * Yc = Xc;

a = (X + qY)c;

 

여기서, a = (X + qY)c; 라는 식을 도출하였다.

 

a = (X + qY)c; 가 성립하는 동시에 b = Yc; 도 성립해야 한다.

두 식을 보면 a와 b는 c라는 공약수를 보유하고 있다.

 

그런데, 맨 처음의 가정을 보면 a와 b는 서로소라고 가정을 하였다.

즉, a-qb와 b가 c라는 공약수를 보유하였다는 명제는 모순이 되는 것이다.

그러므로, a-qb와 b는 서로소이다.

 

a-qb과 b가 서로소이므로, 

R = (a - qb)G 

B = bG 

이 두 식에서, R과 B는 G라는 최대공약수를 보유하고 있음을 알 수 있다.

 

G는 A와 B의 최대 공약수이자, B와 R의 최대공약수이다.

-> GCD(A, B) == GCD(B, R)

 

이번엔, GCD(N, 0) 이 N인 이유를 증명해보겠다.


GCD(N, 0) 이 N인 이유

 

1. N을 나눌 수 있는 최대 약수는 N이다.

 

2. 나누어 떨어진다는 것은 나머지가 0임을 의미한다.

-> 0은 모든 정수에 대해 나머지가 0이다.

-> 0은 모든 정수로 나누어 떨어진다.

-> 모든 정수는 0의 약수이다.

 

3. N을 나눌 수 있는 최대 약수 = N이며, N은 0의 약수이다..

-> GCD(N, 0)은 N이다.

 

이 유클리드 호제법을 사용하여 최소 공배수(LCM)또한 쉽게 구할 수 있다.

이건 증명과정이 어렵지 않으므로 간단히 참고하도록 해보자.

 

A와 B의 최소 공배수를 L이라고 해보자.

L은 A로도 나누어지고, B로도 나누어지는 수 중에서 최소인 수이다.

즉 A = aG, B = bG라고 했을 때 L = a * b * G; 라고 할 수 있다. (a, b는 서로소이며 G는 A와 B의 최대 공약수)

 

A * B = a * b * G * G

A * B / G = a * b * G = L

 

즉, L 은 A와 B의 곱을 A와 B의 최대 공약수로 나눈 수이다.

int GetLCM(int _A, int _B)
{
    int GCD = GetGCD(_A, _B);
    return (_A * _B) / GCD;
}

 

이렇게 사용하면 된다. 

매우 간단하다.

 

최소 공배수와 최대 공약수를 이용하여 푸는 문제가 생각보다 자주 나온다.

유클리드 호제법을 아는 것과 모르는 것은 문제 풀이에 생각보다 큰 영향을 미친다.

어려운 알고리즘이 아닌 만큼 반드시 숙지하도록 하자.

CCW 란 Counter Clock Wise 의 준말이다.

Counter Clock Wise 란 반시계 방향을 의미한다.

 

CCW 알고리즘은 세 점의 좌표가 주어졌을 때, 반시계 방향으로 위치하는가를 판별하는 알고리즘이다.

위의 두 그림에서 A->B->C의 방향을 보자.

첫 번째 그림은 반시계 방향으로 세 점이 위치하고 있고, 두 번째 그림은 시계 방향으로 세 점이 위치하고 있다.

 

이렇게, 세 점이 순서대로 주어졌을 때, 반시계 방향으로 위치하는지 시계 방향으로 위치하는지를 판별하는 알고리즘이 CCW이다.

 

 

CCW 알고리즘


CCW알고리즘은 기본적으로 벡터의 외적을 이용한 알고리즘이다.

이 알고리즘을 알기 위해선 외적에 대한 이해가 있어야 하며, 왼손 좌표계와 오른손 좌표계에 대한 이해가 있어야 한다.

모른다면, 찾아보고 나서 다시 알고리즘을 보는 것을 추천한다.

 

이 알고리즘은 상술했듯이 벡터의 외적을 기반으로 작동한다.

허나, 벡터의 외적은 해당 좌표계가 오른손 좌표계인지 왼손 좌표계인지에 따라 다른 결과를 나타낸다.

(수치는 같지만, 적용되는 방향이 다르다.)

 

만약 DirectX나 unreal Engine같은 환경에서 이 알고리즘을 사용하겠다면 왼손 좌표계를 기준으로 해야할 것이다.

수학에서 일반적인 좌표계는 오른손 좌표계이기 때문에 해당 게시글에선 오른손 좌표계를 기준으로 설명할 것이다.

 

일반적인 오른손 좌표계는 위와 같이 축이 설정되어 있다.

이 공간 안에, 그림에 보이는 것과 같이 벡터 1과 벡터 2가 존재한다고 해보자.

두 선분의 z값은 0이라고 가정하겠다.

 

이 떄, 두 선분을 외적하면 XY평면과 수직이며, z축과 평행한 벡터가 결과로 나오게 될 것이다.

 

이 때, 벡터 1과 벡터 2의 위치관계를 보자.

그림과 같이 위치한다면, 오른손 법칙에 의해, (벡터 1) X (벡터 2) 의 결과는 z축의 양의 방향으로 뻗어나가는 벡터일 것이다.

 

반면, 이 그림처럼 벡터 1과 벡터 2가 위치한다면, (벡터 1) X (벡터 2)의 결과는 Z축의 음의 방향으로 뻗어나가는 벡터가 될 것이다.

 

즉, 두 벡터의 위치관계에 따라 외적한 벡터의 Z값이 양수인지 음수인지 달라지는 것이다.

 

그럼 다시 이 그림을 보자.

 

세 점이 반시계 방향으로 위치할 때, 점을 제거하고 벡터만을 보게 되면 위와 같이 정리할 수 있다.

 

2차원 평면에 해당 그림과 같이 위치했을 때, 파란색 벡터와 빨간색 벡터를 외적하면?

오른손 법칙에 의해, Z축의 양의 방향으로 뻗어나가는 벡터가 나온다.

 

즉, (파란색 벡터 X 빨간색 벡터) 를 통해 나온 벡터의 Z값이 양수라면 세 점이 반시계 방향으로 위치한다고 생각할 수 있다.

 

이번엔 시계 방향으로 있는 상황을 보자.

그림과 같이 벡터의 관계를 정리할 수 있다.

이 때, 파란색 벡터와 빨간색 벡터를 외적하게 되면, Z축의 음의 방향으로 뻗어나가는 벡터가 결과로 나오게 된다.

 

즉, (파란색 벡터 X 빨간색 벡터) 를 통해 나온 벡터의 Z값이 음수라면 세 점이 시계 방향으로 위치한다고 생각할 수 있다.

 

이와 같이, 세 점의 좌표를 기준으로 벡터를 구한 뒤 벡터를 외적하게 되면 세 점이 시계방향으로 있는지 반시계 방향으로 있는지를 판별할 수 있다.

 

 

백준 17386- 선분 교차 1 (C++) (tistory.com)

 

백준 17386- 선분 교차 1 (C++)

위 그림과 같이 두개의 선분이 주어졌을 때, 왼쪽처럼 두 선분이 만나고 있다면 1을 출력하고 오른쪽처럼 만나지 않는다면 0을 출력하면 되는 문제이다. 문제 풀이 이 문제는 CCW알고리즘을 활용

yuu5666.tistory.com

위의 링크는 이 알고리즘을 적용하여 풀어볼 수 있는 문제이다.

문제를 먼저 풀어보고 나서 게시글을 참고하면 도움이 될 것 같다.

LCA(Lowest Common Ancestor) 알고리즘에 대해 알아보자.

 

먼저 LCA란 뭘까? 

LCA는 한글로 최소 공통 조상을 의미한다.

 

 

위와 같은 트리가 있다고 치자.

 

노란색으로 칠해진 9번 노드와 8번 노드의 최소 공통 조상 노드를 찾고자 한다.

조상 노드란, 부모노드, 부모의 부모노드 등등 상위에 있는 모든 노드를 의미한다.

 

9번 노드의 경우 7번노드, 3번노드, 1번 노드가 조상노드가 될 것이다.

8번 노드의 경우 3번노드, 1번노드가 조상 노드가 될 것이다.

 

보면, 9번 노드와 8번 노드의 조상노드중 겹치는 노드가 있다.

3번 노드와 1번 노드이다. 이 두 노드중 더 가깝게 위치한 노드는 3번 노드이다.

 

이처럼 공통된 조상 중에서 가장 가까이 있는 노드 (3번 노드)최소 공통 조상 노드(LCA)라고 한다.

 

그런데, LCA에선 하나 유의해야 할 점이 있다.

바로 조상노드가 본인을 포함한다는 점이다.

 

위에서는

 

9번 노드의 조상노드 : 7, 3, 1

8번 노드의 조상노드 : 3, 1

 

이라고 설명했지만 실제로는 

 

9번 노드의 조상노드 : 9, 7, 3, 1

8번 노드의 조상노드 : 8, 3, 1

인 것이다.

 

왜 조상노드인데 본인을 포함하냐라고 생각하겠지만 아래 그림을 보자.

여기서, 1번 노드와 3번 노드의 공통 조상 노드를 찾겠다고 해보자.

 

1번 노드의 경우엔 조상노드가 없다.

반면, 3번노드의 경우 1번노드와 2번노드가 조상노드이다.

 

1번 노드는 누군가의 조상노드이면서 본인은 조상노드를 가지고 있지 않다.

 

이러한 경우 "1과 3의 최소 공통 조상 노드는 없다" 라고 할 것인가?

아니다. 그냥 1을 최소 공통 조상 노드로 설정하면 된다.

 

이 알고리즘은 진짜 조상노드를 찾기 위한 알고리즘이 아니라, 어느 노드부터 공통되어 있는가를 찾기 위한 알고리즘이다.

 

이름이 최소 공통 조상 노드이다 보니, 자기 자신을 포함하는 이유에 대해 헷갈리는 부분이 많았다.

허나 이름의 편견에서 벗어나서 알고리즘의 목적을 생각하면 본인노드를 포함하는 것이 더 적합하다고 본다.

 

그런 이유로 3와 2의 최소 공통 조상 노드 역시 1이 아닌 2가 된다.

 

최소 공통 조상 노드에 대해서 알아보았으니 이제 구하는 법을 알아보자.

 

 

여기서 6과 9의 최소 공통 조상 노드를 찾아보자.

 

먼저, 두 노드의 공통 조상 노드를 찾기 위해선 깊이를 맞춰주어야 한다.

깊이란, 루트노드를 기준으로 얼만큼 내려가 있느냐를 의미한다.

 

 

위 그림처럼 루트노드와의 거리를 깊이라고 한다.

 

그렇다면, 이 깊이를 왜 맞춰주어야 할까?

동일한 깊이에서부터 동일하게 한 칸씩 위로 올라가보며 탐색할 것이기 때문이다.

 

아래에 설명을 쭉 보면 이해 될 것이다.

먼저 깊이를 맞춰 주겠다.

 

 

이처럼 9번노드가 아닌 7번 노드를 선택함으로써 두 노드의 깊이가 같아졌다.

이제 한 칸씩 올라가며 부모노드를 비교해보자.

 

 

이렇게 한 칸 올라가 보았다.

4번 노드와 5번 노드는 서로 다르다.

두 노드가 같아질 때까지 계속 올라가보자.

 

 

한칸 올라갔더니, 두 노드가 같은 곳에서 만나게 되었다. 

즉, 현재 노드가 두 노드의 최소 공통 조상 노드가 되는 것이다.

 

이런식으로 선형으로 최소공통조상 노드를 탐색하는 방법이 가장 간단한 방법이다.

하지만 문제가 하나 있다.

 

이런 식으로 5만개의 노드가 한줄로 연결되어있다고 치자.

그렇다면 연산을 최대 5만번까지 해야한다.

1억개라면? 1억번을 해야한다.

O(n)의 시간복잡도를 가지는 만큼 n이 늘어날수록 연산량이 많아지고 부담스러워진다.

 

시간복잡도를 줄이기 위해 2의 지수를 이용하여 탐색하는 방법을 주로 사용하게 된다.

전체적인 과정은 위의 선형탐색과 동일하다.

하지만, 1씩 위로 움직이며 깊이를 맞추고 조상을 탐색하는 것이 아니라 2의 승수만큼 움직이며 깊이를 맞추고 조상을 탐색한다.

 

 

 

아래 그림을 보자.

 

50000과 2의 최소 공통 조상을 찾기 위해서 위에서 설명한 것처럼 선형으로 탐색하게 되면 49999번의 연산이 필요하다.

2의 지수를 사용하면 어떻게 될까?

 

먼저 노드가 최대 5만개이기 때문에 최대 지수를 구해보겠다.

2^15 < 50000 < 2^16 이다. 16 이상의 지수는 의미가 없기 때문에 최대 지수를 15로 설정해보겠다.

편의를 위해 최대 지수를 MaxE 라고 하겠다.

 

노드의 번호가 순서대로 정렬되어있다고 가정을 먼저 하겠다.

50000에서 위로 2^(MaxE) 만큼 이동해보자.

50000 - 32768 = 17232가 된다.

 

현재 깊이가 17232이므로, 노드 2보다 여전히 깊이가 깊다.

 

이번엔 2^(MaxE - 1)만큼 더 이동해보겠다.

17232 - 16384 = 848이 된다.

 

현재 깊이가 848이므로, 노드 2보다 여전히 깊이가 깊다.

 

이번엔 2^(MaxE - 2)만큼 더 이동해보겠다.

848 - 8192 = -7344 이다. 깊이는 최소 0이기 때문에 음수 깊이가 나왔다는 뜻은 이동할 수 없다는 뜻이다.

이럴 땐 이동하지 않도록 하겠다.

 

다음은 2^(MaxE - 3)을 이동해보고, 2^(MaxE - 4)를 이동해보고.......

 

2^(MaxE - i) 만큼 계속 이동하다 보면 깊이가 노드 2와 같아지는 시점이 온다.

(모든 정수는 2의 지수의 합으로 표현이 가능하기 때문에 이론적으로 반드시 같아지는 시점이 나온다.)

 

반복문은 최대 MaxE + 1 번만큼 돌기 때문에, 아무리 많아도 16번까지밖에 돌지 않는다.

선형으로 탐색할 땐 49999번을 돌아야 했던 반복문이 16번으로 줄어들었다.

 

O(N)의 시간복잡도가O(LogN)이 된 것이다.

 

깊이를 맞추고 조상을 탐색할 때에도 이처럼 2의 지수를 이용해서 이동하며 탐색하기 때문에

시간복잡도가 엄청나게 줄어들게 된다.

 

 

그렇다면 이걸 어떻게 구현해야 할까?


 

먼저 위와 같이 최대 깊이가 4인 트리라고 가정해보자.

배열을 두 만들어 보겠다.

 

 

이렇게 만들어보자. 가로는 노드의 번호이다. 총 노드가 9개 이므로 가로는 9의 길이로 만들어 주었다.

세로는 2^i번째 조상 노드의 번호이다.

 

위에서 말했지만 조상으로 이동하며 탐색할 때, 2^i만큼 이동하며 탐색할 것이다. 그렇기 때문에 2^i번째 조상이 누구인가를 알고 있어야 한다.

 

다음은 깊이를 저장하는 노드이다.

깊이를 맞춘 뒤에 조상노드를 탐색하기 때문에 깊이에 대한 정보도 알아야 한다.

 

 

1번 노드부터 먼저 배열을 채워보자.

반복문을 돌며 해야 할 것은 현재 노드의 깊이를 기록하는 것과 조상노드를 기록하는 것이다.

 

현재 노드의 깊이는 이렇게 표현할 수 있다.

현재 노드의 깊이 =  부모 노드의 깊이 + 1

 

부모노드와 무조건 깊이가 1만큼 차이나기 때문에 이와 같이 표현할 수 있다.

 

다음은 조상 노드를 기록할 것이다.먼저, DP를 이용하는 이유는 조상 노드에 기록된 조상 노드들을 그대로 가져오기 위해서 이다.

 

이 트리에서 보면, 6의 조상노드는  6, 4, 2, 1이다.

4의 조상노드는 4, 2, 1이다.

2의 조상노드는 2, 1이다.

 

즉, 조상의 조상 노드는 동시에 나의 조상노드인 것이다.

 

그렇다면 조상노드를 모두 기록할 것인가?

아니다. 우리는 2의 지수를 이용하기로 했기 때문에 2^i번째 조상 노드만 기록할 것이다.

 

예제를 하나 보자.

 

8번 노드에서 1번 노드로 가기 위해선 4칸을 올라가야 한다.

즉 2^2승만큼 이동해야 하는 것이다.

 

그런데, 이 것은 이렇게 표현할 수도 있다.

8에서 2^1승만큼 이동하여 5를 간 뒤, 5에서 2^1승만큼 이동하여 1로 가는 것이다.

 

즉, 8에서 2^i만큼 위로 올라간 노드는 8의 2^(i - 1) 번째 조상 노드의 2^(i - 1)번째 조상 노드인 것이다.

 

그렇기 때문에,

현재 노드의 2^i번째 조상 노드 =  2^(i - 1)번째 조상 노드의 2^(i - 1)번째 조상 노드

라는 점화식을 세울 수 있는 것이다.

 

이 점화식을 이용해, 2^1번째부터 2^MaxE 번째 조상노드까지 기록할 수 있는 것이다.

(만약, 2^i번째 조상노드가 없다면 그냥 0으로 저장하면 된다.)

 

 

먼저, 1번 노드의 경우 루트노드이므로 깊이가 0이다.

조상 노드또한 없으므로 모두 0으로 기록한다.

 

이제, DFS를 이용하여 1번과 연결된 노드를 탐색하자.

 

 

1번에는 2번과 3번 노드가 연결되어 있다.

2번 노드를 먼저 탐색해보자.

 

깊이는 부모노드의 깊이 + 1이므로 1이 된다.

 

2^0번째 조상 노드가 1번 노드이므로 1을 기록하고 나머지엔 0을 기록한다.

DFS 방식으로 탐색하고 있기 때문에, 다음엔 4번 노드로 이동해 보겠다.

 

깊이는 부모 노드의 깊이 + 1이므로 2가 된다.

 

 

2^0번째 조상은 2번 노드를 기록하고, 2^1번째 조상은 1번 노드를 기록해주자.

 

이와 같은 방식을 쭉 반복하여 모든 배열을 채우고 나면

 

이렇게 된다.

이제 그냥 조상을 타고다니며 탐색하면 끝이다.

위에서 설명했던 것처럼, 2^(MaxE - i)씩 이동하며 탐색하면 된다.

 

사실, 아이디어를 알아도 코드로 구현하는 건 상당히 쉽지 않은 일이다

아래의 링크에 해당 알고리즘을 사용하여 해결할 수 있는 기초 문제에 대한 풀이가 있다.

이 알고리즘을 어떻게 코드로 작성하는 지는 해당 문제 풀이를 보며 이해하는 것이 도움이 될 듯 하다.

 

백준 11437 - LCA, LCA2 (C++) (tistory.com)

+ Recent posts