Featured image of post [Algorithm] C++/Python 백준 16975번 : 수열과 쿼리 21

[Algorithm] C++/Python 백준 16975번 : 수열과 쿼리 21

백준 16975번 '수열과 쿼리 21' 문제는 구간에 수를 더하고 특정 위치의 값을 빠르게 조회하는 효율적 자료구조 설계가 핵심입니다. 누적합과 차분, Fenwick Tree(BIT) 활용으로 O(log N) 복잡도 내에서 대용량 쿼리를 모두 빠르게 처리하는 방법을 다룹니다.

이번 글에서는 백준 16975번 문제인 “수열과 쿼리 21"을 다룰 것이다. 이 문제는 주어진 수열에 대해 여러 번의 구간 갱신과 특정 위치의 원소 값을 빠르게 조회해야 하는 상황을 제시한다. 이러한 연산을 효율적으로 처리하기 위해 Binary Indexed Tree(BIT) 또는 Segment Tree를 적용할 수 있다.

문제 : https://www.acmicpc.net/problem/16975

문제 설명

길이가 N인 수열 A가 주어진다고 할 때, 두 가지 유형의 쿼리를 처리하는 문제이다. 첫 번째 유형의 쿼리는 “1 i j k"의 형태로, 이는 수열 A의 구간 [i, j]에 있는 모든 원소에 k를 더한다는 의미이다. 예컨대, A가 [1, 2, 3, 4, 5]이고 쿼리가 “1 2 4 3"이라면, A[2], A[3], A[4]에 각각 3을 더하여 최종 수열은 [1, 5, 6, 7, 5]가 되는 식이다. 두 번째 유형의 쿼리는 “2 x"의 형태로, A[x] 값을 출력하라는 의미이다. 예를 들어 두 번째 유형의 쿼리가 “2 3"이고, 현재 수열이 [1, 5, 6, 7, 5]라면 결과로 6을 출력해야 한다.

수열의 크기 N은 최대 100,000까지 가능하며, 쿼리의 개수 M 역시 최대 100,000번 주어진다. 각 쿼리마다 구간 갱신을 수행해야 하므로, 단순한 방식으로 매 쿼리마다 구간 합을 순회하며 갱신하면 O(N) 시간이 걸리게 되고, M개의 쿼리를 전부 처리하는 데에 최악의 경우 O(N*M) = 10^10 정도의 연산이 필요할 수 있어 시간 안에 해결하기 어려울 것이다. 따라서 이를 효율적으로 해결하기 위해선 빠른 갱신과 빠른 질의를 처리할 수 있는 자료 구조가 필요하다.

이에 흔히 사용하는 자료 구조가 Fenwick Tree(이진 인덱스 트리)와 Segment Tree(세그먼트 트리)이다. Fenwick Tree를 사용하면 구간 업데이트와 한 점에서의 질의를 O(log N)에 처리할 수 있고, Segment Tree에 Lazy Propagation 기법을 적용하면 구간 업데이트와 한 점 질의를 동일하게 O(log N)에 처리할 수 있다. 이 문제에서는 Fenwick Tree를 사용하여 간단하고 빠르게 구현할 수 있다.

또한 문제에서 k는 -1,000,000 이상 1,000,000 이하이므로, 수열 원소에 음수도 더해질 수 있으며, 결과적으로 수열 원소가 커질 수도 있지만 64비트 정수(long long) 범위 내에서 충분히 처리 가능하다. 쿼리의 결과를 모두 출력한 뒤 프로그램을 종료하면 문제 해결이 완료된다.

정리하자면, 이 문제는 최대 100,000 길이의 수열에 대해 100,000번의 구간 갱신과 한 점 조회를 처리해야 하는 문제이다. 따라서 효율적인 자료 구조를 이용하여 O(M log N)의 시간 복잡도로 해결하는 것이 핵심이다. 대략적인 알고리즘 아이디어는 다음과 같다: Fenwick Tree를 이용해 특정 구간 [i, j]에 k를 더할 때, Fenwick Tree에 i 위치에 k를 더하고 j+1 위치에 -k를 더함으로써 누적합의 원리를 이용하여 구간 전체가 업데이트되도록 만든다. 그 후, 특정 위치 x의 값을 조회하려 할 때는 x 위치까지의 누적합을 구하여 원본 수열 값 + 누적합을 통해 실제 값을 찾아낸다.

以上의 설명에서 볼 수 있듯이, 주어진 문제는 구현 난이도가 크게 높지 않으나, 올바른 자료 구조 선택과 구현이 관건이다. 구간 업데이트와 한 점 쿼리를 빠르게 처리하는 Fenwick Tree의 사용 원리를 잘 이해한다면, 문제를 보다 간단히 해결할 수 있다.

접근 방식

  1. Fenwick Tree(BIT) 활용

    • Fenwick Tree를 두 개 구성할 필요 없이, 하나의 Fenwick Tree에 누적합을 이용한 ‘차분(Difference)’ 개념을 적용한다.
    • 구간 [i, j]에 k를 더한다는 것은, Fenwick Tree에 i 위치에 k를 더해주고, (j+1) 위치에 -k를 더해주는 방식으로 처리한다(단, j < N일 때에만 j+1 위치 업데이트를 수행).
    • 이는 누적합을 구할 때, i부터 j까지는 +k, j+1부터는 -k가 반영되어 결과적으로 [i, j] 구간 전체가 k만큼 증가하는 효과를 낸다.
  2. 한 점 쿼리

    • x 위치의 실제 값은 A[x] + Fenwick Tree에서 x까지의 누적합을 구한 결과와 같다.
    • 초기에 주어진 수열 A의 원소들은 Fenwick Tree에 반영되어 있지 않으므로, Fenwick Tree를 통해 얻어지는 것은 ‘추가로 더해진 값의 합’에 불과하다. 따라서 원래 배열의 값 A[x]와 Fenwick Tree에서 얻은 누적합을 더해 최종 결과값을 얻는다.
  3. 시간 복잡도

    • Fenwick Tree의 업데이트 연산과 누적합 계산 연산은 모두 O(log N)에 처리할 수 있다.
    • 쿼리가 최대 100,000번 주어지므로, 전체 복잡도는 O(M log N)이 되어 제한 시간을 무난히 만족한다.
  4. 자료형 주의

    • k가 -1,000,000 이상 1,000,000 이하이며, 최대 100,000번 갱신이 가능하므로 중간 과정에서 값이 매우 커질 수 있다.
    • 이를 안전하게 처리하기 위해 long long(64비트 정수) 자료형을 사용한다.

C++ 코드와 설명

아래 코드는 Fenwick Tree를 이용하여 구간 업데이트와 한 점 쿼리를 처리하는 방법을 예시로 보인 것이다. 각 줄에 주석을 달아, 핵심 동작을 상세히 설명한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <iostream>
#include <vector>
using namespace std;

int main(){
    ios::sync_with_stdio(false);    // C++ 입출력 동기화를 끊어 속도를 높이는 명령어이다.
    cin.tie(nullptr);               // cin과 cout의 묶임을 풀어서 입출력 성능을 향상시킨다.

    int N;
    cin >> N;                       // 수열의 크기 N을 입력받는다.
    vector<long long> arr(N+1, 0LL);

    // 초기 수열의 정보를 입력받는다.
    for(int i = 1; i <= N; i++){
        cin >> arr[i];
    }

    // Fenwick Tree(BIT)를 구현하기 위한 벡터를 선언한다.
    vector<long long> BIT(N+1, 0LL);

    // Fenwick Tree에 값을 갱신하는 함수이다.
    auto update = [&](int idx, long long val){
        while(idx <= N){
            BIT[idx] += val;
            idx += (idx & -idx); // idx의 마지막 비트를 더하여 올라간다.
        }
    };

    // Fenwick Tree에서 idx까지의 누적합을 구하는 함수이다.
    auto fenwickSum = [&](int idx){
        long long result = 0LL;
        while(idx > 0){
            result += BIT[idx];
            idx -= (idx & -idx); // idx의 마지막 비트를 빼가며 내려간다.
        }
        return result;
    };

    int M;
    cin >> M;                       // 쿼리의 개수를 입력받는다.

    while(M--){
        int type;
        cin >> type;                // 쿼리 유형(1 또는 2)을 입력받는다.

        if(type == 1){
            int i, j;
            long long k;
            cin >> i >> j >> k;
            // 구간 [i, j]에 k를 더한다.
            update(i, k);          // i 위치에 k를 더한다.
            if(j+1 <= N) {         // j+1 위치에 -k를 더해, i~j 구간만 k가 적용되도록 한다.
                update(j+1, -k);
            }
        }
        else{
            int x;
            cin >> x;
            // x 위치의 실제 값을 구한다.
            long long answer = arr[x] + fenwickSum(x);
            cout << answer << "\n";
        }
    }
    return 0;
}

코드 동작 상세

  1. arr[i]에는 초기에 주어진 수열 A의 값이 저장되어 있다.
  2. Fenwick Tree(BIT)는 모든 원소가 0으로 시작한다.
  3. update(i, k)를 통해 i 이상 구간에 k만큼을 더하는 의미를 BIT에 반영한다.
  4. 만약 구간이 [i, j]라면, j+1 위치에는 -k를 더함으로써, 누적합 과정에서 i부터 j까지만 k가 더해진다.
  5. 쿼리 종류가 2인 경우, arr[x](원래 수열의 값)에 Fenwick Tree에서 얻은 누적합(fenwickSum(x))을 더해 실제 A[x] 값을 계산한다.
  6. 이러한 과정을 통해, 모든 쿼리를 O(log N)에 처리가 가능해진다.

Python 코드와 설명

Python 코드에서도 Fenwick Tree 방식을 동일하게 사용할 수 있다. 구현 방법은 비슷하며, 주의할 점은 인덱스 처리가 C++과 다를 수 있으니 적절히 조정해 주어야 한다는 것이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import sys
input = sys.stdin.readline

def update(BIT, idx, val, N):
    # idx부터 Fenwick Tree에 val를 더함
    while idx <= N:
        BIT[idx] += val
        idx += (idx & -idx)

def fenwick_sum(BIT, idx):
    # idx까지의 누적합을 구함
    result = 0
    while idx > 0:
        result += BIT[idx]
        idx -= (idx & -idx)
    return result

def main():
    N = int(input().strip())
    arr = [0] + list(map(int, input().split()))
    
    BIT = [0] * (N+1)
    
    M = int(input().strip())
    for _ in range(M):
        query = list(map(int, input().split()))
        
        if query[0] == 1:
            # 1 i j k
            _, i, j, k = query
            update(BIT, i, k, N)       # 구간 [i, j]에 k를 더하기 위해
            if j+1 <= N:
                update(BIT, j+1, -k, N)# j+1 위치에 -k를 더한다.
        
        else:
            # 2 x
            _, x = query
            # x 위치의 값 = 초기값 arr[x] + Fenwick Tree 누적합
            print(arr[x] + fenwick_sum(BIT, x))

if __name__ == "__main__":
    main()

코드 동작 상세

  1. arr 리스트는 1번 인덱스부터 시작하도록 하여 인덱스 처리를 통일하였다.
  2. Fenwick Tree 배열 BIT 역시 크기를 N+1로 두어 1번 인덱스부터 사용한다.
  3. update() 함수는 인덱스에 val를 더한 후, (idx & -idx)를 통해 Fenwick Tree의 구조에 맞게 상위 구간에 누적 적용을 해주는 원리이다.
  4. fenwick_sum() 함수는 idx부터 비트 마스크를 이용해 하위 구간들의 합을 점진적으로 더해 최종 누적합을 구한다.
  5. 구간 업데이트 시, [i, j]에 k를 더하기 위해 i 위치에 k를 더하고 j+1 위치에 -k를 더한다.
  6. 최종적으로 한 점 쿼리를 수행할 때, arr[x] + fenwick_sum(BIT, x)를 출력함으로써 구간 업데이트까지 반영된 실제 값을 얻을 수 있다.

결론

“수열과 쿼리 21” 문제는 구간에 특정 값을 더하고 특정 위치의 값을 빠르게 구하는 전형적인 Range Update & Point Query 문제이다. Fenwick Tree(또는 Segment Tree의 Lazy Propagation)를 통해 효율적으로 해결 가능하다. 본 문제를 통해 Fenwick Tree의 차분(Difference) 개념을 활용하는 방법을 익히면, 구간 업데이트가 등장하는 다양한 문제에 응용할 수 있다. 추가적으로, Segment Tree를 사용하면 더 일반적인 구간 쿼리(예를 들어 구간 합, 구간 최솟값 등) 문제에서도 비슷한 원리로 확장할 수 있다는 점이 유용하다. 문제를 해결하면서 자료 구조와 알고리즘의 결합이 얼마나 큰 효과를 내는지 다시금 체감할 수 있었다.