Featured image of post [Algorithm] C++/Python 백준 10999번 : 구간 합 구하기 2

[Algorithm] C++/Python 백준 10999번 : 구간 합 구하기 2

세그먼트 트리와 Lazy Propagation(지연 갱신)을 활용해, 크기가 최대 1,000,000에 달하는 배열에 대하여 대규모 구간 덧셈 및 구간 합 쿼리를 O(log N) 시간 내에 빠르고 효율적으로 처리하는 방법을 구현하고 설명합니다.

최근에 세그먼트 트리(Segment Tree)와 Lazy Propagation을 이용한 문제를 접하게 되었고, 그 대표적인 예시 중 하나가 바로 백준 10999번 구간 합 구하기 2이다. 많은 범위를 빠르게 갱신하고, 특정 구간의 합을 빠르게 구해야 할 때 사용하기 좋은 알고리즘이다. 오늘은 이 문제를 어떻게 해결할 수 있는지 살펴보도록 한다.

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


문제 설명

이 문제는 \(N\)개의 수로 이루어진 배열이 주어지고, 총 \(M + K\)번의 연산을 수행하는 과정을 다룬다. 두 종류의 연산이 존재한다. 첫 번째는 특정 구간 \([b, c]\)에 동일한 값을 더하는 연산이고, 두 번째는 특정 구간 \([b, c]\)의 합을 구하는 연산이다. 이 과정에서 수의 크기는 매우 클 수도 있으며(문제에서 -2^63 이상, 2^63-1 이하의 정수라고 명시), \(N\) 또한 최대 1,000,000에 이를 수 있다. 따라서 단순하게 모든 연산을 반복문으로 처리하면 시간 초과가 발생하기 쉽다.

이를 좀 더 구체적으로 살펴보면, 예를 들어 배열이 \([1, 2, 3, 4, 5]\)로 시작한다고 할 때, “3번째부터 4번째 수에 6을 더한다”는 연산을 수행하면 배열은 \([1, 2, 9, 10, 5]\)가 된다. 그리고 “2번째부터 5번째 수의 합을 구한다”는 쿼리에 대해선 \(2 + 9 + 10 + 5 = 26\)이라는 결과를 얻게 된다. 그다음 “1번째부터 3번째 수에 -2를 더한다”면 배열은 \([-1, 0, 7, 10, 5]\)가 되고, 다시 “2번째부터 5번째 수의 합을 구한다”는 쿼리를 처리하면 \(0 + 7 + 10 + 5 = 22\)가 된다.

이렇게 구간별로 대규모 업데이트구간 합 쿼리가 빈번하게 일어나는 상황에서, 단순한 방법(매번 \([b, c]\) 범위를 순회하여 갱신하거나 합을 구하는 방식)으로는 시간 복잡도가 \(O(N)\)에 달하여, 대규모 입력에 대해서는 절대 시간 내에 해결하기 어렵다. 특히 \(N\)의 최댓값이 1,000,000, 그리고 쿼리 횟수 역시 최대 20,000에 달하므로, 이 둘을 곱하면 최악의 경우 \(2 \times 10^4 \times 10^6 = 2 \times 10^{10}\)번 연산이라는, 매우 큰 수를 처리해야 하는 문제가 발생한다.

따라서 구간 합(혹은 구간에 대한 연산)을 빠르게 처리하기 위한 자료 구조인 Segment Tree(세그먼트 트리)와, 구간 업데이트를 효율적으로 처리하는 Lazy Propagation(지연 갱신) 기법을 함께 사용하여 \(O(\log N)\) 시간 복잡도로 구간 연산을 처리할 수 있어야 한다. 이를 통해 최대 \(M + K\)번의 연산에도 충분히 빠른 시간 안에 답을 구할 수 있게 된다.

위와 같이, 이 문제는 세그먼트 트리 + 지연 갱신을 구현하면 해결할 수 있다. 세그먼트 트리는 구간의 합을 저장하고, Lazy 배열을 사용해 “아직 반영되지 않은 구간 업데이트”를 효율적으로 처리한다. 이 방식의 핵심 아이디어는, 실제로 모든 노드의 값을 매번 업데이트하지 않고, ‘필요한 시점’이 왔을 때에만 자식 노드로 갱신을 전파(propagate)하는 데에 있다. 이러한 접근법을 통해 업데이트와 쿼리 모두 \(O(\log N)\) 내에 처리할 수 있게 된다.

접근 방식

  1. Segment Tree 구축

    • 길이 \(N\)인 배열을 빠르게 관리하기 위해, 트리로 구간을 분할하고, 각 구간의 합을 노드에 저장한다.
    • 일반적으로 트리는 크기가 \(4N\) 정도이면 충분하다.
  2. Lazy Propagation(지연 갱신)

    • 업데이트가 빈번할 경우, 모든 노드를 직접 갱신하면 비효율적이다.
    • Lazy 배열을 두어, “아직 아래로 전파하지 않은 업데이트 정보”를 임시로 저장하고, 필요할 때만 자식 노드로 정보를 넘겨준다.
  3. 구간 업데이트

    • 한 구간 전체에 동일한 값을 더해주어야 한다면, 그 구간을 담당하는 노드에 Lazy 값을 설정한다.
    • 해당 노드가 실제로 참조될 때(혹은 그 자식 노드를 조회해야 할 때) Lazy 값을 확인하여 본인에게 먼저 반영하고, 자식들에게도 propagate한다.
  4. 구간 합 쿼리

    • 현재 노드가 담당하는 구간이 쿼리 구간과 전혀 겹치지 않으면 0을 반환한다.
    • 노드의 Lazy 값을 propagate로 먼저 처리하여, 트리에 최신화된 정보가 들어 있도록 한다.
    • 쿼리 구간에 완전히 포함되는 노드라면, 그 노드가 가진 트리 값을 반환한다.
    • 그렇지 않다면, 자식 노드로 쿼리를 나눠서 구간 합을 구해 합산한다.

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
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

// 최대 N 크기(문제에서 최대 1,000,000)
static const int MAXN = 1000000;

// 세그먼트 트리 배열과 Lazy 배열
ll segtree[4 * MAXN];
ll lazy[4 * MAXN];

// 입력받을 원본 배열
ll arr[MAXN + 1];

// 세그먼트 트리를 구성하는 함수
// node: 현재 노드 번호
// start, end: arr에서 현재 노드가 담당하는 구간
void build_tree(int node, int start, int end) {
    if (start == end) {
        segtree[node] = arr[start];
        return;
    }
    int mid = (start + end) / 2;
    build_tree(node * 2, start, mid);
    build_tree(node * 2 + 1, mid + 1, end);
    segtree[node] = segtree[node * 2] + segtree[node * 2 + 1];
}

// Lazy 값을 자식에게 전파(propagate)하고, 현재 노드 정보를 갱신한다.
void propagate(int node, int start, int end) {
    if (lazy[node] != 0) {
        segtree[node] += (end - start + 1) * lazy[node];
        // 리프 노드가 아니면 자식 노드 lazy값에 누적
        if (start != end) {
            lazy[node * 2] += lazy[node];
            lazy[node * 2 + 1] += lazy[node];
        }
        lazy[node] = 0;
    }
}

// 구간 [l, r]에 val을 더해주는 함수
void update_range(int node, int start, int end, int l, int r, ll val) {
    // 이전에 남아있는 lazy가 있으면 우선 반영
    propagate(node, start, end);

    // 범위를 벗어난 경우
    if (r < start || end < l) {
        return;
    }
    // 현재 구간이 [l, r]에 완전히 포함되는 경우
    if (l <= start && end <= r) {
        lazy[node] += val;
        propagate(node, start, end);
        return;
    }
    // 부분적으로 겹치는 경우 자식 노드에 나누어서 업데이트
    int mid = (start + end) / 2;
    update_range(node * 2, start, mid, l, r, val);
    update_range(node * 2 + 1, mid + 1, end, l, r, val);
    // 자식 노드들을 업데이트한 뒤, 현재 노드 값 갱신
    segtree[node] = segtree[node * 2] + segtree[node * 2 + 1];
}

// 구간 [l, r]의 합을 구하는 함수
ll query_range(int node, int start, int end, int l, int r) {
    // lazy 값이 있으면 반영
    propagate(node, start, end);

    // 범위를 벗어난 경우
    if (r < start || end < l) {
        return 0LL;
    }
    // 현재 구간이 [l, r]에 완전히 포함
    if (l <= start && end <= r) {
        return segtree[node];
    }
    // 자식 노드에 쿼리를 나누어서 처리
    int mid = (start + end) / 2;
    ll left_sum = query_range(node * 2, start, mid, l, r);
    ll right_sum = query_range(node * 2 + 1, mid + 1, end, l, r);
    return left_sum + right_sum;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int N, M, K;
    cin >> N >> M >> K;

    for(int i = 1; i <= N; i++) {
        cin >> arr[i];
    }

    // 세그먼트 트리 빌드
    build_tree(1, 1, N);

    // 총 M+K 번의 연산 처리
    for(int i = 0; i < M + K; i++){
        ll a, b, c;
        cin >> a >> b >> c;

        if(a == 1) {
            // 구간 [b, c]에 값을 더함
            ll d; 
            cin >> d;
            if(b > c) swap(b, c);
            update_range(1, 1, N, b, c, d);
        }
        else {
            // 구간 [b, c] 합을 출력
            if(b > c) swap(b, c);
            cout << query_range(1, 1, N, b, c) << "\n";
        }
    }

    return 0;
}

C++ 코드 동작 단계별 설명

  1. 입력 받기: \(N\), \(M\), \(K\)를 받고 배열 arr에 데이터를 채운다.
  2. build_tree 함수 호출: 세그먼트 트리 segtree 배열과 지연 배열 lazy를 초기화한다.
  3. 연산 처리: 총 \(M + K\)번의 연산을 순서대로 처리한다.
    • a == 1이면, 구간 업데이트(더하기) 명령이므로 update_range를 실행한다.
    • a == 2이면, 구간 합 쿼리 명령이므로 query_range를 실행한다.
  4. Lazy Propagation: 모든 업데이트와 쿼리는 propagate를 통해, 노드에 잠재된 Lazy 값을 미리 반영하고, 필요 시 자식 노드로 이전한다.

Python 코드와 설명

주의: Python에서는 재귀 깊이에 주의해야 한다. 필요하다면 sys.setrecursionlimit()를 사용한다.

 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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import sys
input = sys.stdin.readline
sys.setrecursionlimit(10**6)

# 최대 N
MAXN = 1000000

# 전역 세그먼트 트리와 Lazy 배열
segtree = [0] * (4 * MAXN)
lazy = [0] * (4 * MAXN)
arr = []

def build_tree(node, start, end):
    if start == end:
        segtree[node] = arr[start]
        return
    mid = (start + end) // 2
    build_tree(node*2, start, mid)
    build_tree(node*2 + 1, mid+1, end)
    segtree[node] = segtree[node*2] + segtree[node*2 + 1]

def propagate(node, start, end):
    if lazy[node] != 0:
        segtree[node] += (end - start + 1) * lazy[node]
        if start != end:
            lazy[node*2] += lazy[node]
            lazy[node*2 + 1] += lazy[node]
        lazy[node] = 0

def update_range(node, start, end, left, right, val):
    propagate(node, start, end)

    if right < start or end < left:
        return
    
    if left <= start and end <= right:
        lazy[node] += val
        propagate(node, start, end)
        return
    
    mid = (start + end) // 2
    update_range(node*2, start, mid, left, right, val)
    update_range(node*2 + 1, mid+1, end, left, right, val)
    segtree[node] = segtree[node*2] + segtree[node*2 + 1]

def query_range(node, start, end, left, right):
    propagate(node, start, end)

    if right < start or end < left:
        return 0
    
    if left <= start and end <= right:
        return segtree[node]
    
    mid = (start + end) // 2
    left_sum = query_range(node*2, start, mid, left, right)
    right_sum = query_range(node*2 + 1, mid+1, end, left, right)
    return left_sum + right_sum

def main():
    N, M, K = map(int, input().split())
    global arr
    arr = [0] + [int(input()) for _ in range(N)]  # 1-based index

    build_tree(1, 1, N)

    for _ in range(M + K):
        data = list(map(int, input().split()))
        if data[0] == 1:
            # update
            _, b, c, d = data
            if b > c:
                b, c = c, b
            update_range(1, 1, N, b, c, d)
        else:
            # query
            _, b, c = data
            if b > c:
                b, c = c, b
            print(query_range(1, 1, N, b, c))

if __name__ == "__main__":
    main()

Python 코드 동작 단계별 설명

  1. 전역 리스트 선언: segtreelazy 배열을 \(4 \times N\) 크기로 준비한다.
  2. build_tree: 입력받은 arr를 토대로 세그먼트 트리를 구성한다.
  3. propagate: 현재 노드에 Lazy 값이 있으면 구간의 길이만큼 더해주고, 자식 노드가 존재한다면 그 자식 노드들의 Lazy 값에도 동일한 값을 더한다. 작업이 끝나면 현재 노드의 Lazy 값은 0으로 초기화한다.
  4. update_range: 구간 \([left, right]\)에 주어진 val을 더해주어야 할 때 사용한다.
  5. query_range: 구간 \([left, right]\)의 합을 구하여 반환한다.
  6. main: N, M, K를 받은 뒤, 배열을 입력받아 트리를 구성하고, 이후 M+K번의 연산(update / query)을 차례대로 처리한다.

결론

본 문제는 대규모 입력에 대해 구간 합과 구간 업데이트 연산을 빠르게 처리해야 하므로, 세그먼트 트리(Segment Tree)Lazy Propagation 기법을 적절히 활용하여 시간 복잡도를 \(O(\log N)\)으로 유지하는 것이 핵심이다. 단순 반복문으로 구현할 경우 시간 초과에 직면할 가능성이 높으며, 세그먼트 트리를 구축하고 Lazy 배열을 운용함으로써 문제를 해결할 수 있다.

추가적인 최적화로는, 재귀 함수를 반복문으로 바꾸는 방법(Iterative Segment Tree), 혹은 C++에서는 ios::sync_with_stdio(false), cin.tie(nullptr) 등의 입출력 최적화를 사용하는 방법이 있다. 그러나 대체로 위 코드와 같은 전형적인 세그먼트 트리 + 지연 갱신 구현만으로도 시간 제한 내에 해결이 가능하다.

이상으로 백준 10999번 구간 합 구하기 2 문제 풀이를 살펴보았다. 이 과정을 통해 Lazy Propagation이 주는 이점을 다시금 확인할 수 있었으며, 세그먼트 트리를 조금 더 유연하게 사용할 수 있는 기회가 되었다. 앞으로도 유사한 구간 연산 문제를 만났을 때, 이 기법을 적절하게 적용해보길 바란다.

코너 케이스 및 실수 포인트

케이스설명처리 방법
최소 입력N=1 또는 빈 입력반복문 범위·예외 처리 확인
오버플로우답이 $2^{31}$ 초과 가능long long (C++) 등 사용