이 글은 세그먼트 트리를 한번 쯤은 써본 사람들을 기준으로 작성하였습니다.
그래도 기본 개념부터 핵심 코드 설명까지 꽤나 자세하게 작성하였습니다. 또 어떤 점에서 이득이 있는지, 응용을 어떻게 하는지, 어떤 문제에 적용할 수 있는지 등 전체적으로 정리하는 느낌으로 써봤습니다.
혼자 여러 블로그 보면서 독학한 내용을 정리한 것이라 좀 설명이 난잡할 수 있지만 최대한 깔끔하게 정리하려고 노력하며 작성했습니다.
세그먼트 트리는 구간 쿼리(합, 곱 같은 계산)를 효율적으로 다룰 수 있는 자료구조이다.
누적합을 이용하면 구간 계산은 O(1)에 가능하지만 갱신의 경우가 O(N)으로 느리다는 한계점이 있다.
누적합으로는 갱신이 조금만 많아져도 못 풀기 때문에 이런 문제를 해결하기 위해서 세그먼트 트리를 사용한다. “구간 합 구하기”와 같은 세그트리 기본 문제를 DP나 누적합을 이용해서 풀 수 없나 열심히 생각해보면 왜 세그트리를 써야하는지 조금 더 이해가 가능하다.
N = 10 인 경우의 세그먼트 트리는 다음과 같다.


세그먼트 트리 구축 (init 함수)
ll init(int node, int s, int e){ // ll = long long
    if (s == e) return tree[node] = arr[s]; //리프 노드
    int m = (s + e) / 2;
    return tree[node] = init(node * 2, s, m) + init(node * 2 + 1, m + 1, e);
}
query 함수 (여기서는 구간의 합)
ll sum(int node, int s, int e, int l, int r) {
    if (l > e || r < s) return 0; // 범위가 겹치지 않는 경우
    if (l <= s && e <= r) { // [l,r]가 [s,e]를 완전히 포함하는 경우
        return tree[node];
    }
    int m = (s + e) / 2;
    return sum(node * 2, s, m, l, r)
        + sum(node * 2 + 1, m + 1, e, l, r);
}
[s,e] 은 처음에는 [1,n]으로 들어오며 계속 바뀌는 범위이다.
[l,r] 은 우리가 원하는 범위로 바뀌지 않는 찾는 범위이다.
이 둘의 범위에 따라 크게 3가지 경우의 수로 나눠진다.
[left,right]와 [start,end]가 겹치지 않는 경우[left,right]가 [start,end]를 완전히 포함하는 경우아래는 N=10일 때 예시이다.
https://book.acmicpc.net/ds/segment-tree 이 사이트에서 직접 값을 바꿔가며 시도해볼 수 있다.

update 함수 (값 변경)
void update(int node, int s, int e, int idx, ll val) {
    if (idx < s || idx > e) return; // 범위 밖
    if (s == e) { // idx 찾음
        tree[node] = val;
        return;
    }
    int m = (s + e) / 2;
    update(node * 2, s, m, idx, val);
    update(node * 2 + 1, m + 1, e, idx, val);
    tree[node] = tree[node * 2] + tree[node * 2 + 1];
}
여기서는 크게 범위에 따라 세 가지로 나뉜다.
[start,end]에 index가 포함되지 않는 경우start == end 인 경우 = idx에 도착했을 때이 방법은 리프 노드를 갱신하고 재귀를 타고 올라오면서 트리를 갱신하는 방식이고
반대로 내려가면서 즉시 갱신하는 방식으로 해도 된다. 위에 링크를 올린 사이트에서는 내려가면서 갱신하는 방식으로 짰다.

세그먼트 트리의 가장 기본 문제로 구간 합을 구하는 문제다.
위에서 설명한 함수만 구현해주면 풀리는 간단한 문제다.
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef vector<int> vi;
typedef vector<ll> vll;
typedef pair<int, int> pi;
typedef pair<ll, ll> pll;
const int N = 1000100;
vll tree, arr;
ll init(int node, int s, int e){
    if (s == e) return tree[node] = arr[s]; //리프 노드
    int m = (s + e) / 2;
    return tree[node] = init(node * 2, s, m) + init(node * 2 + 1, m + 1, e);
}
ll sum(int node, int s, int e, int l, int r) {
    if (l > e || r < s) return 0; // 범위 밖
    if (l <= s && e <= r) { // 범위 안
        return tree[node];
    }
    int m = (s + e) / 2;
    return sum(node * 2, s, m, l, r)
        + sum(node * 2 + 1, m + 1, e, l, r);
}
void update(int node, int s, int e, int idx, ll val) {
    if (idx < s || idx > e) return; // 범위 밖
    if (s == e) { // idx 찾음
        tree[node] = val;
        return;
    }
    int m = (s + e) / 2;
    update(node * 2, s, m, idx, val);
    update(node * 2 + 1, m + 1, e, idx, val);
    tree[node] = tree[node * 2] + tree[node * 2 + 1];
}
int main() {
    ios::sync_with_stdio(false);cin.tie(NULL);cout.tie(NULL);
    int n, m, k;
    cin >> n >> m >> k;
    tree = vll(4 * N);
    arr = vll(N);
    for (int i = 1; i <= n; i++) {
        cin >> arr[i];
        //update(1, 1, n, i, arr[i]);
    }
    init(1, 1, n);
    for (int i = 0; i < m + k; i++) {
        int op; cin >> op;
        if (op == 1) {// update
            ll a, b; cin >> a >> b;
            update(1, 1, n, a, b);
        }
        else if (op == 2) {// sum
            ll a, b; cin >> a >> b;
            cout << sum(1, 1, n, a, b) << '\n';
        }
    }
    return 0;
}다음과 같이 함수 실행
이 코드에서 범위를 [1~n]으로 했는데 [0~n-1]으로 해도 된다. [0~n], [1,N] 등 범위가 포함만 되어있으면 어찌 됐든 돌아가긴 한다. 한 마디로 크게는 상관 없지만, 아무래도 최적화를 위해 맞춰주는게 좋다고 생각한다.
이 문제에서 범위 입력값이 1~n으로 주어지기 때문에 그렇게 짠 거 뿐이다. 만약 범위를 [0~n-1]로 하게 되면 update(1,0,n-1,a-1,b-1) 이런식으로 범위를 조정해줘야 한다.
하지만 node는 꼭 1부터 시작해야 한다!!! 0*2는 계속해서 0이기 때문에 큰일난다.
init 함수는 초기에 세그먼트 트리를 구축하는 함수인데
이 문제와 같이 초기 배열값을 넣어주는 경우에는 굳이 init 함수를 짜지 않고 입력받을 때마다 update를 해주기도 한다. (주석 처리 해둔 부분)
init 함수를 쓰면 따로 입력값도 배열에 담아줘야 하고 함수도 짜야되고 귀찮다.
물론 처음에 모든값에 1을 넣어준다거나 배열 값을 계속 바꿔주고 넣어줘야 되는 경우가 있는데 그런 경우에는 init함수를 만들어주는 편이다.
그런데 사실 시간복잡도 차이를 보면 init 함수를 쓰는게 이득이다.
init함수는 O(N)이고, 매번 update로 하게되면 O(NlogN)이므로 init 함수를 만드는게 더 빠르다.

걸린 시간을 보면 위가 init 함수를 안 쓴것(380ms)이고 아래가 init 함수를 쓴 것(208ms)이다.
시간차이가 꽤 있는 편이라 좀 놀랬다..
물론 이 문제는 N이 M+K(쿼리수)보다 현저히 커서 초기 트리 구축 시간 비중이 좀 컸던 것 같지만,
생각보다 차이가 크므로 init 함수 쓰는걸 습관 들이는 것도 나쁘지 않겠다.
O(logN), 즉 M개 쿼리의 총 시간복잡도 = (MlogN)tree[node] = tree[node * 2] + tree[node * 2 + 1]tree[node] = tree[node * 2] * tree[node * 2 + 1]추가로 응용되는 알고리즘들이 많은데 우선 간략하게만 정리해봤습니다.
하나하나 정리하면 글이 너무 길어져서 하나씩 따로 글을 작성할 예정입니다.
그래서 우선 제가 보고 공부했던 블로그 링크를 올려둡니다.
Wow! 너무 잘 정리되어 있네요 읽으면서 감탄했습니다👍👍 게다가 세그먼트 시뮬레이션 사이트가 있었다니.... 지금껏 아이패드로 그려왔던 저에게 좋은 정보네요