C++ 트리 알고리즘 완벽 가이드 | 세그먼트 트리·펜윅 트리·트라이 [실전]

C++ 트리 알고리즘 완벽 가이드 | 세그먼트 트리·펜윅 트리·트라이 [실전]

이 글의 핵심

C++ 구간 쿼리·누적합·자동완성을 위한 고급 트리. 세그먼트 트리 구간 쿼리, 펜윅 트리 누적합, 트라이 문자열 검색. 문제 시나리오, 완전한 예제, 흔한 실수, 성능 팁, 프로덕션 패턴.

들어가며: “구간 합 쿼리가 O(n)이라 느려요”

실제 겪는 문제 시나리오

배열에서 구간 [L, R]의 합을 10만 번 쿼리할 때, 매번 O(n)으로 순회하면 시간 초과가 납니다. 비유하면 “매번 책 전체를 읽는 것”과 “목차에서 필요한 페이지만 찾는 것”의 차이입니다.

flowchart TD
  subgraph wrong["❌ 단순 반복 O(n) per query"]
    W1[쿼리 10만 번] --> W2[매번 [L,R] 순회]
    W2 --> W3[n=10만 × 10만 = 100억]
    W3 --> W4[시간 초과]
  end
  subgraph right["✅ 세그먼트/펜윅 트리 O(log n) per query"]
    R1[쿼리 10만 번] --> R2[트리에서 구간 합 O(log n)]
    R2 --> R3[10만 × log(10만) ≈ 170만]
    R3 --> R4[밀리초 단위]
  end

이 글에서는 실제로 겪는 문제 시나리오부터 시작해, 세그먼트 트리·펜윅 트리·트라이의 완전한 C++ 구현, 자주 하는 실수, 성능 최적화 팁, 프로덕션 패턴까지 단계별로 다룹니다.

이 글을 읽으면:

  • 구간 쿼리·누적합·접두사 검색 문제에 맞는 자료구조를 선택할 수 있습니다.
  • 세그먼트 트리, 펜윅 트리, 트라이를 직접 구현할 수 있습니다.
  • 흔한 실수를 피하고 성능을 최적화할 수 있습니다.

요구 환경: C++17 이상


목차

  1. 문제 시나리오
  2. 세그먼트 트리 완전 구현
  3. 펜윅 트리 완전 구현
  4. 트라이 완전 구현
  5. 자주 발생하는 에러와 해결법
  6. 베스트 프랙티스
  7. 성능 최적화 팁
  8. 프로덕션 패턴
  9. 구현 체크리스트

1. 문제 시나리오

시나리오 1: 구간 합/최솟값 쿼리 + 원소 업데이트

상황: 실시간 주가 데이터에서 “3일~7일 구간의 최저가”를 1초에 수천 번 조회하고, 가격이 바뀔 때마다 업데이트해야 합니다.

잘못된 접근:

// ❌ 매 쿼리마다 O(n) 순회
std::vector<int> prices = {100, 95, 102, 98, 105, 103, 107};
int query_min(int L, int R) {
    int mn = INT_MAX;
    for (int i = L; i <= R; ++i) mn = std::min(mn, prices[i]);
    return mn;
}
// 쿼리 10만 번 × n=10만 → 100억 연산 → 시간 초과

해결: 세그먼트 트리 — 구간 쿼리 O(log n), 단일 원소 업데이트 O(log n).


시나리오 2: 누적합 + 빈번한 업데이트

상황: 주문 시스템에서 “1번~k번 주문의 총 금액”을 자주 조회하고, 새 주문이 들어올 때마다 배열이 갱신됩니다.

잘못된 접근:

// ❌ prefix sum만 쓰면 업데이트 시 O(n) 재계산
std::vector<long long> prefix;
void update(int i, int delta) {
    arr[i] += delta;
    for (int j = i; j < n; ++j) prefix[j] += delta;  // O(n)
}

해결: 펜윅 트리(BIT) — 누적합 쿼리 O(log n), 단일 업데이트 O(log n). 구현이 세그먼트 트리보다 간단합니다.


시나리오 3: 접두사 검색 (자동완성)

상황: 검색창에 “app”을 입력하면 “apple”, “application”, “apply” 등을 O(접두사 길이)에 제안해야 합니다.

잘못된 접근:

// ❌ std::map - 접두사 검색 O(n)
std::map<std::string, int> words;
for (auto it = words.lower_bound("app"); it != words.end(); ++it) {
    if (it->first.substr(0, 3) != "app") break;
    suggest(it->first);  // 전체 키 순회 가능
}

해결: 트라이(Trie) — 삽입/검색 O(m), 접두사 검색 O(m). m은 문자열 길이.


시나리오 4: 구간에 대한 복합 연산

상황: 구간 [L,R]에서 “최대값 - 최소값” 또는 “XOR 합”을 구해야 합니다.

해결: 세그먼트 트리의 병합 함수를 해당 연산으로 바꾸면 됩니다. (합 → 최대/최소/XOR 등)


시나리오 5: k번째 원소 찾기 (순위 쿼리)

상황: 게임 순위표에서 “상위 10%에 속하는 사용자 수” 또는 “k번째로 큰 점수”를 실시간으로 찾아야 합니다. 점수가 자주 업데이트됩니다.

잘못된 접근:

// ❌ 매번 정렬하면 O(n log n) × 쿼리 수
std::vector<int> scores;
int kth_largest(int k) {
    auto sorted = scores;
    std::sort(sorted.begin(), sorted.end(), std::greater<int>());
    return sorted[k - 1];
}

해결: 세그먼트 트리 또는 펜윅 트리로 값의 범위를 인덱스로 삼아 구간 합을 유지. 이진 탐색으로 “합이 k 이상인 최소 인덱스”를 찾으면 k번째 원소를 O(log² n)에 구할 수 있습니다.


시나리오 6: 동적 구간 쿼리 (LeetCode 307 스타일)

상황: 배열 nums가 주어지고, 다음 두 연산을 반복합니다.

  • update(index, val): nums[index]val로 변경
  • sumRange(left, right): nums[left] + ... + nums[right] 반환

잘못된 접근:

// ❌ prefix sum만 쓰면 update 시 O(n) 재계산
// ❌ 매 쿼리마다 for 루프 → O(n) per query

해결: 세그먼트 트리 또는 펜윅 트리로 둘 다 O(log n)에 처리할 수 있습니다.


알고리즘 선택 가이드

문제 유형추천 자료구조쿼리업데이트비고
구간 합펜윅 트리O(log n)O(log n)구현 간단
구간 최소/최대세그먼트 트리O(log n)O(log n)펜윅은 비적합
구간 XOR, GCD세그먼트 트리O(log n)O(log n)역원 필요
접두사 검색트라이O(m)O(m)m=문자열 길이
순위 쿼리 (k번째)세그먼트 트리O(log n)O(log n)이진 탐색 결합

2. 세그먼트 트리 완전 구현

2.1 핵심 아이디어

세그먼트 트리는 이진 트리로, 각 노드가 배열의 한 구간을 담당합니다. 루트는 [0, n-1], 왼쪽 자식은 [0, mid], 오른쪽 자식은 [mid+1, n-1]을 담당합니다.

flowchart TD
  subgraph tree[세그먼트 트리 구조]
    R["[0,6] 합"]
    R --> L[[0,2]]
    R --> RR[[3,6]]
    L --> LL[[0,1]]
    L --> LR[[2,2]]
    RR --> RL[[3,4]]
    RR --> RRR[[5,6]]
  end

시간 복잡도: 구간 쿼리 O(log n), 단일 업데이트 O(log n). 공간: O(n) (배열 4n 크기로 충분).


2.2 구간 합 세그먼트 트리

#include <vector>
#include <algorithm>
#include <cassert>

// 구간 합 세그먼트 트리
// - build: O(n)
// - query: O(log n)
// - update: O(log n)
class SegmentTree {
    std::vector<long long> tree_;
    int n_;

    // [l, r]은 현재 노드가 담당하는 구간, [ql, qr]은 쿼리 구간
    long long queryImpl(int node, int l, int r, int ql, int qr) {
        if (qr < l || r < ql) return 0;           // 구간 겹침 없음
        if (ql <= l && r <= qr) return tree_[node]; // 완전 포함
        int mid = (l + r) / 2;
        return queryImpl(2 * node, l, mid, ql, qr)
             + queryImpl(2 * node + 1, mid + 1, r, ql, qr);
    }

    void updateImpl(int node, int l, int r, int idx, long long delta) {
        if (idx < l || r < idx) return;
        if (l == r) {
            tree_[node] += delta;
            return;
        }
        int mid = (l + r) / 2;
        updateImpl(2 * node, l, mid, idx, delta);
        updateImpl(2 * node + 1, mid + 1, r, idx, delta);
        tree_[node] = tree_[2 * node] + tree_[2 * node + 1];
    }

public:
    explicit SegmentTree(const std::vector<long long>& arr) : n_(static_cast<int>(arr.size())) {
        tree_.resize(4 * n_);
        build(1, 0, n_ - 1, arr);
    }

    void build(int node, int l, int r, const std::vector<long long>& arr) {
        if (l == r) {
            tree_[node] = arr[l];
            return;
        }
        int mid = (l + r) / 2;
        build(2 * node, l, mid, arr);
        build(2 * node + 1, mid + 1, r, arr);
        tree_[node] = tree_[2 * node] + tree_[2 * node + 1];
    }

    long long query(int ql, int qr) {
        assert(0 <= ql && ql <= qr && qr < n_);
        return queryImpl(1, 0, n_ - 1, ql, qr);
    }

    void update(int idx, long long delta) {
        assert(0 <= idx && idx < n_);
        updateImpl(1, 0, n_ - 1, idx, delta);
    }
};

2.3 구간 최소값 세그먼트 트리 (RMQ)

#include <vector>
#include <algorithm>
#include <climits>

// Range Minimum Query 세그먼트 트리
class SegmentTreeMin {
    std::vector<int> tree_;
    int n_;
    static constexpr int INF = INT_MAX;

    int queryImpl(int node, int l, int r, int ql, int qr) {
        if (qr < l || r < ql) return INF;
        if (ql <= l && r <= qr) return tree_[node];
        int mid = (l + r) / 2;
        return std::min(
            queryImpl(2 * node, l, mid, ql, qr),
            queryImpl(2 * node + 1, mid + 1, r, ql, qr)
        );
    }

    void updateImpl(int node, int l, int r, int idx, int val) {
        if (idx < l || r < idx) return;
        if (l == r) {
            tree_[node] = val;
            return;
        }
        int mid = (l + r) / 2;
        updateImpl(2 * node, l, mid, idx, val);
        updateImpl(2 * node + 1, mid + 1, r, idx, val);
        tree_[node] = std::min(tree_[2 * node], tree_[2 * node + 1]);
    }

public:
    explicit SegmentTreeMin(const std::vector<int>& arr) : n_(static_cast<int>(arr.size())) {
        tree_.resize(4 * n_, INF);
        build(1, 0, n_ - 1, arr);
    }

    void build(int node, int l, int r, const std::vector<int>& arr) {
        if (l == r) {
            tree_[node] = arr[l];
            return;
        }
        int mid = (l + r) / 2;
        build(2 * node, l, mid, arr);
        build(2 * node + 1, mid + 1, r, arr);
        tree_[node] = std::min(tree_[2 * node], tree_[2 * node + 1]);
    }

    int query(int ql, int qr) { return queryImpl(1, 0, n_ - 1, ql, qr); }
    void update(int idx, int val) { updateImpl(1, 0, n_ - 1, idx, val); }
};

2.4 사용 예시

#include <iostream>

int main() {
    std::vector<long long> arr = {1, 3, 5, 7, 9, 11};
    SegmentTree st(arr);

    std::cout << st.query(1, 3) << "\n";  // 3+5+7 = 15
    st.update(2, 10);  // arr[2] += 10 → 5→15
    std::cout << st.query(1, 3) << "\n";  // 3+15+7 = 25

    std::vector<int> mins = {2, 5, 1, 8, 3, 9};
    SegmentTreeMin stMin(mins);
    std::cout << stMin.query(0, 2) << "\n";  // min(2,5,1) = 1
    return 0;
}

2.5 통합 예제: 구간 쿼리 + 점 업데이트 (LeetCode 307 스타일)

문제: NumArray 클래스가 update(index, val)sumRange(left, right)를 O(log n)에 지원해야 합니다.

// LeetCode 307: Range Sum Query - Mutable
class NumArray {
    std::vector<long long> tree_;
    int n_;
    std::vector<int> nums_;

    void build(int node, int l, int r) {
        if (l == r) { tree_[node] = nums_[l]; return; }
        int mid = (l + r) / 2;
        build(2*node, l, mid);
        build(2*node+1, mid+1, r);
        tree_[node] = tree_[2*node] + tree_[2*node+1];
    }

    void updateImpl(int node, int l, int r, int idx, long long delta) {
        if (idx < l || r < idx) return;
        if (l == r) { tree_[node] += delta; return; }
        int mid = (l + r) / 2;
        updateImpl(2*node, l, mid, idx, delta);
        updateImpl(2*node+1, mid+1, r, idx, delta);
        tree_[node] = tree_[2*node] + tree_[2*node+1];
    }

    long long queryImpl(int node, int l, int r, int ql, int qr) {
        if (qr < l || r < ql) return 0;
        if (ql <= l && r <= qr) return tree_[node];
        int mid = (l + r) / 2;
        return queryImpl(2*node, l, mid, ql, qr)
             + queryImpl(2*node+1, mid+1, r, ql, qr);
    }

public:
    explicit NumArray(const std::vector<int>& nums)
        : n_(static_cast<int>(nums.size())), nums_(nums) {
        tree_.resize(4 * n_);
        build(1, 0, n_ - 1);
    }

    void update(int index, int val) {
        long long delta = val - nums_[index];
        nums_[index] = val;
        updateImpl(1, 0, n_ - 1, index, delta);
    }

    long long sumRange(int left, int right) {
        return queryImpl(1, 0, n_ - 1, left, right);
    }
};
// 사용: NumArray na({1,3,5}); na.sumRange(0,2)==9; na.update(1,2); na.sumRange(0,2)==8;

펜윅 트리로 동일 문제 해결:

class NumArrayFenwick {
    FenwickTree ft_;
    std::vector<int> nums_;
public:
    explicit NumArrayFenwick(const std::vector<int>& nums)
        : ft_(static_cast<int>(nums.size())), nums_(nums) {
        for (int i = 0; i < static_cast<int>(nums.size()); ++i) ft_.add(i + 1, nums[i]);
    }
    void update(int index, int val) {
        ft_.add(index + 1, val - nums_[index]);
        nums_[index] = val;
    }
    long long sumRange(int left, int right) { return ft_.rangeSum(left + 1, right + 1); }
};

3. 펜윅 트리 완전 구현

3.1 핵심 아이디어

펜윅 트리(Binary Indexed Tree, BIT)는 비트 연산을 활용해 누적합을 O(log n)에 구합니다. 세그먼트 트리보다 코드가 짧고, 캐시 친화적입니다.

핵심: i의 이진 표현에서 마지막 1의 위치를 i & -i로 구합니다. i번 노드는 [i - (i&-i) + 1, i] 구간의 합을 담습니다.

flowchart LR
  subgraph bit[펜윅 트리 인덱스]
    B1["1: [1,1]"]
    B2["2: [1,2]"]
    B3["3: [3,3]"]
    B4["4: [1,4]"]
    B5["5: [5,5]"]
    B6["6: [5,6]"]
  end

3.2 펜윅 트리 구현

#include <vector>
#include <cassert>

// 펜윅 트리 (1-indexed)
// - prefix sum [1..i]를 O(log n)에 구함
// - update(i, delta): i번 원소에 delta 더함
class FenwickTree {
    std::vector<long long> tree_;
    int n_;

public:
    explicit FenwickTree(int n) : n_(n), tree_(n + 1, 0) {}

    // i번째(1-indexed) 원소에 delta를 더함
    void add(int i, long long delta) {
        assert(1 <= i && i <= n_);
        for (; i <= n_; i += (i & -i)) {
            tree_[i] += delta;
        }
    }

    // [1, i] 구간 합 (prefix sum)
    long long prefixSum(int i) {
        assert(0 <= i && i <= n_);
        long long sum = 0;
        for (; i > 0; i -= (i & -i)) {
            sum += tree_[i];
        }
        return sum;
    }

    // [L, R] 구간 합 (1-indexed)
    long long rangeSum(int L, int R) {
        assert(1 <= L && L <= R && R <= n_);
        return prefixSum(R) - prefixSum(L - 1);
    }

    // 초기 배열로 구성 (선택적)
    void init(const std::vector<long long>& arr) {
        for (int i = 0; i < static_cast<int>(arr.size()) && i + 1 <= n_; ++i) {
            add(i + 1, arr[i]);
        }
    }
};

3.3 펜윅 트리 사용 예시

int main() {
    FenwickTree ft(6);
    ft.add(1, 1);
    ft.add(2, 3);
    ft.add(3, 5);
    ft.add(4, 7);
    ft.add(5, 9);
    ft.add(6, 11);

    std::cout << ft.rangeSum(2, 4) << "\n";  // 3+5+7 = 15
    ft.add(3, 10);  // 3번 원소에 10 추가
    std::cout << ft.rangeSum(2, 4) << "\n";  // 3+15+7 = 25
    return 0;
}

3.4 펜윅 vs 세그먼트 트리

항목펜윅 트리세그먼트 트리
구현 난이도간단상대적으로 복잡
공간O(n)O(4n)
구간 합O(log n)O(log n)
구간 최소/최대❌ 부적합✅ 지원
역원합: 뺄셈필요 시 역원 정의
캐시더 친화적상대적으로 불리

펜윅 사용 조건: 연산이 역원을 가져야 합니다 (합→뺄셈, XOR→XOR). 최소/최대는 역원이 없어 펜윅으로 구간 쿼리가 어렵습니다.


4. 트라이 완전 구현

4.1 핵심 아이디어

트라이는 문자별로 자식을 따라가는 트리입니다. “apple”을 저장하면 a→p→p→l→e 경로가 생기고, 각 노드에 “단어 끝” 표시를 둡니다.

flowchart TD
  R[(루트)] --> A[a]
  A --> P1[p]
  P1 --> P2[p]
  P2 --> L[l]
  L --> E[e*]
  A --> N[n]
  N --> T[t*]

4.2 트라이 구현 (영소문자)

#include <string>
#include <array>
#include <vector>
#include <memory>

class Trie {
    struct Node {
        std::array<std::unique_ptr<Node>, 26> children{};
        bool is_end = false;
    };
    std::unique_ptr<Node> root_ = std::make_unique<Node>();

    static int index(char c) {
        if (c < 'a' || c > 'z') return -1;
        return c - 'a';
    }

public:
    void insert(const std::string& word) {
        Node* cur = root_.get();
        for (char c : word) {
            int i = index(c);
            if (i < 0) return;
            if (!cur->children[i]) {
                cur->children[i] = std::make_unique<Node>();
            }
            cur = cur->children[i].get();
        }
        cur->is_end = true;
    }

    bool search(const std::string& word) {
        Node* cur = root_.get();
        for (char c : word) {
            int i = index(c);
            if (i < 0 || !cur->children[i]) return false;
            cur = cur->children[i].get();
        }
        return cur->is_end;
    }

    bool startsWith(const std::string& prefix) {
        Node* cur = root_.get();
        for (char c : prefix) {
            int i = index(c);
            if (i < 0 || !cur->children[i]) return false;
            cur = cur->children[i].get();
        }
        return true;
    }

    // 접두사 prefix로 시작하는 모든 단어 수집
    std::vector<std::string> wordsWithPrefix(const std::string& prefix) {
        std::vector<std::string> result;
        Node* cur = root_.get();
        for (char c : prefix) {
            int i = index(c);
            if (i < 0 || !cur->children[i]) return result;
            cur = cur->children[i].get();
        }
        collect(cur, prefix, result);
        return result;
    }

private:
    void collect(Node* node, const std::string& prefix, std::vector<std::string>& out) {
        if (!node) return;
        if (node->is_end) out.push_back(prefix);
        for (int i = 0; i < 26; ++i) {
            if (node->children[i]) {
                collect(node->children[i].get(), prefix + static_cast<char>('a' + i), out);
            }
        }
    }
};

4.3 트라이 사용 예시 (자동완성)

#include <iostream>

int main() {
    Trie trie;
    trie.insert("apple");
    trie.insert("application");
    trie.insert("apply");
    trie.insert("banana");

    std::cout << trie.search("apple") << "\n";    // 1 (true)
    std::cout << trie.search("app") << "\n";     // 0 (false)
    std::cout << trie.startsWith("app") << "\n"; // 1 (true)

    auto suggestions = trie.wordsWithPrefix("app");
    for (const auto& w : suggestions) {
        std::cout << w << " ";  // apple application apply
    }
    return 0;
}

4.4 압축 트라이 (Patricia Trie) 개요

자식이 하나만 있는 노드를 합쳐 공간을 절약하는 변형입니다. 실무에서는 대용량 문자열 집합에 유용합니다.

// 압축 트라이: 연속된 단일 자식 경로를 하나의 "간선 레이블"로 압축
// 예: "application" → "app" + "lication" (app 다음에 여러 분기)
// 구현 복잡도가 높아, 기본 트라이로 충분한 경우가 많음

5. 자주 발생하는 에러와 해결법

문제 1: 세그먼트 트리 인덱스 0 vs 1

증상: 쿼리 결과가 잘못되거나 세그먼트 폴트가 발생합니다.

원인: 세그먼트 트리는 보통 0-indexed로 구현하는데, 문제에서 1-indexed를 요구할 수 있습니다.

// ❌ 1-indexed 문제를 0-indexed로 그대로 쿼리
int ans = st.query(L, R);  // L,R이 1..n이면 범위 초과

// ✅ 1-indexed 입력을 0-indexed로 변환
int ans = st.query(L - 1, R - 1);

문제 2: 펜윅 트리 0-indexed vs 1-indexed 혼동

증상: add(0, x) 호출 시 무한 루프 또는 잘못된 결과.

원인: 펜윅 트리는 1-indexed가 표준입니다. i += (i & -i)에서 i=0이면 진행이 안 됩니다.

// ❌ 0-indexed 배열을 그대로 add(0, x)
ft.add(0, arr[0]);  // 0은 사용하지 않음!

// ✅ 1-indexed로 변환
for (int i = 0; i < n; ++i) ft.add(i + 1, arr[i]);

문제 3: 세그먼트 트리 배열 크기 부족

증상: 런타임에 인덱스 초과 또는 쓰레기 값.

원인: tree 크기가 4*n 미만이면 재귀 깊이에 따라 접근이 넘칠 수 있습니다.

// ❌ n만 할당
tree_.resize(n_);

// ✅ 4*n 이상 할당 (안전)
tree_.resize(4 * n_);

문제 4: 트라이에서 메모리 누수

증상: 대량 문자열 삽입 후 메모리 사용량이 계속 증가합니다.

원인: delete를 호출하지 않거나, unique_ptr 없이 raw 포인터만 사용할 때.

// ❌ raw 포인터, 수동 delete 누락
Node* child = new Node();
cur->children[i] = child;  // 나중에 delete 안 함

// ✅ unique_ptr 사용
cur->children[i] = std::make_unique<Node>();

문제 5: 구간 [L,R] 경계 처리

증상: query(L, R)에서 L > R일 때 잘못된 동작.

원인: L > R이면 빈 구간인데, 구현에 따라 0을 반환해야 합니다.

// ✅ 쿼리 전 검증
long long query(int ql, int qr) {
    if (ql > qr) return 0;  // 빈 구간
    return queryImpl(1, 0, n_ - 1, ql, qr);
}

문제 6: 펜윅 트리에서 구간 최소/최대 시도

증상: 구간 최소값을 펜윅으로 구현하려다 실패합니다.

원인: 최소/최대 연산은 역원이 없습니다. min(a,b)에서 a를 알 수 없으면 b만으로 복원 불가.

// ❌ 펜윅으로 RMQ 시도 - 불가능
// prefixMin(i) = min(arr[1], ..., arr[i])는 가능하지만
// rangeMin(L, R)은 prefixMin만으로 불가능

// ✅ 구간 최소/최대는 세그먼트 트리 사용
SegmentTreeMin stMin(arr);
int mn = stMin.query(L, R);

문제 7: 합 쿼리에서 int 오버플로우

증상: 구간 합이 예상과 다르거나 음수가 나옵니다.

원인: int 범위(약 ±21억)를 넘는 누적합. n=10만, 원소 최대 10만이면 합이 100억을 넘을 수 있습니다.

// ❌ int 사용 - 오버플로우
std::vector<int> tree_;
long long query(...) { return tree_[node]; }  // 반환형만 long long

// ✅ tree_와 반환형 모두 long long
std::vector<long long> tree_;
long long query(...) { return tree_[node]; }

문제 8: lazy propagation에서 전파 순서 오류

증상: 구간 업데이트 후 쿼리 결과가 잘못됩니다.

원인: 자식에게 lazy를 전파하기 전에 현재 노드 값을 갱신해야 합니다. 순서가 바뀌면 자식의 lazy가 덮어쓰여질 수 있습니다.

// ✅ 올바른 순서: 1) 현재 노드에 lazy 반영 2) 자식에게 전파 3) lazy 초기화
if (lazy[node] != 0) {
    tree_[node] += lazy[node] * (r - l + 1);
    if (l != r) {
        lazy[2*node] += lazy[node];
        lazy[2*node+1] += lazy[node];
    }
    lazy[node] = 0;
}

6. 베스트 프랙티스

항목권장 사항
인덱스 규칙코드 상단에 // SegmentTree: 0-indexed, // FenwickTree: 1-indexed 명시
검증assert(0 <= ql && ql <= qr && qr < n_)로 디버그 빌드에서 검증
항등원합: 0, 최소: INF, 최대: INT_MIN, XOR: 0, 곱: 1
트라이 문자index(c)에서 영소문자 외 -1 반환, early return
대규모 nn≥100만이면 재귀 대신 반복문(Bottom-Up) 세그먼트 트리 고려

7. 성능 최적화 팁

1. 세그먼트 트리 반복문 구현 (스택 오버플로우 방지)

재귀 대신 반복문으로 구현하면 스택 사용을 줄이고 대규모 n에서 안정적입니다.

// 반복문 기반 구간 합 쿼리 (Bottom-Up)
long long queryIter(int ql, int qr) {
    long long sum = 0;
    for (ql += n_, qr += n_; ql <= qr; ql /= 2, qr /= 2) {
        if (ql % 2 == 1) sum += tree_[ql++];
        if (qr % 2 == 0) sum += tree_[qr--];
    }
    return sum;
}
// 주의: 이 방식은 트리 레이아웃이 다름 (2n 크기, 인덱스 n..2n-1에 리프)

2. 펜윅 트리 캐시 최적화

i += (i & -i)i -= (i & -i)비트 연산이라 매우 빠릅니다. tree_를 연속 메모리에 두면 캐시 히트율이 좋습니다.

// tree_를 vector로 연속 할당 - 캐시 친화적
std::vector<long long> tree_;

3. 트라이 메모리 절약 (맵 기반)

알파벳 26개가 아닌 희소한 문자 집합이면 array<26> 대신 unordered_map을 쓰면 메모리를 줄일 수 있습니다.

// 26개 자식 대신 맵 사용 (문자 집합이 작을 때)
std::unordered_map<char, std::unique_ptr<Node>> children;

4. 세그먼트 트리 lazy propagation (구간 업데이트)

구간 [L,R] 전체에 delta를 더하는 업데이트가 필요하면, lazy propagation으로 O(log n)에 처리할 수 있습니다.

// Lazy 세그먼트 트리: 구간 [L,R]에 delta 더하기
// lazy[node]: 이 노드가 담당하는 구간 전체에 더해질 값
void updateRange(int node, int l, int r, int ql, int qr, long long delta) {
    if (lazy[node] != 0) {
        tree_[node] += lazy[node] * (r - l + 1);
        if (l != r) {
            lazy[2*node] += lazy[node];
            lazy[2*node+1] += lazy[node];
        }
        lazy[node] = 0;
    }
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) {
        tree_[node] += delta * (r - l + 1);
        if (l != r) {
            lazy[2*node] += delta;
            lazy[2*node+1] += delta;
        }
        return;
    }
    int mid = (l + r) / 2;
    updateRange(2*node, l, mid, ql, qr, delta);
    updateRange(2*node+1, mid+1, r, ql, qr, delta);
    tree_[node] = tree_[2*node] + tree_[2*node+1];
}

5. 벤치마크 참고 (n=100,000, 쿼리 100,000회)

자료구조쿼리 유형예상 시간
단순 배열 순회구간 합수 초 (TLE)
prefix sum (업데이트 없음)구간 합~10ms
펜윅 트리구간 합 + 업데이트~20ms
세그먼트 트리구간 합 + 업데이트~30ms
세그먼트 트리구간 최소 + 업데이트~35ms
트라이접두사 검색 (m=10)~5ms (1만 단어 기준)

8. 프로덕션 패턴

8.1 범용 세그먼트 트리 (템플릿)

template<typename T, typename Merge>
class SegmentTreeGeneric {
    std::vector<T> tree_;
    int n_;
    Merge merge_;
    T identity_;

public:
    SegmentTreeGeneric(const std::vector<T>& arr, Merge merge, T identity)
        : n_(static_cast<int>(arr.size())), merge_(merge), identity_(identity) {
        tree_.resize(4 * n_);
        build(1, 0, n_ - 1, arr);
    }

    void build(int node, int l, int r, const std::vector<T>& arr) {
        if (l == r) { tree_[node] = arr[l]; return; }
        int mid = (l + r) / 2;
        build(2*node, l, mid, arr);
        build(2*node+1, mid+1, r, arr);
        tree_[node] = merge_(tree_[2*node], tree_[2*node+1]);
    }

    T query(int ql, int qr) {
        return queryImpl(1, 0, n_-1, ql, qr);
    }

    T queryImpl(int node, int l, int r, int ql, int qr) {
        if (qr < l || r < ql) return identity_;
        if (ql <= l && r <= qr) return tree_[node];
        int mid = (l + r) / 2;
        return merge_(
            queryImpl(2*node, l, mid, ql, qr),
            queryImpl(2*node+1, mid+1, r, ql, qr)
        );
    }

    void update(int idx, const T& val) {
        updateImpl(1, 0, n_-1, idx, val);
    }

    void updateImpl(int node, int l, int r, int idx, const T& val) {
        if (idx < l || r < idx) return;
        if (l == r) { tree_[node] = val; return; }
        int mid = (l + r) / 2;
        updateImpl(2*node, l, mid, idx, val);
        updateImpl(2*node+1, mid+1, r, idx, val);
        tree_[node] = merge_(tree_[2*node], tree_[2*node+1]);
    }
};

// 사용: 구간 최대값
auto stMax = SegmentTreeGeneric<int>(
    arr,
     { return std::max(a, b); },
    INT_MIN
);

8.2 트라이 기반 자동완성 서비스

#include <string>
#include <vector>
#include <algorithm>

class AutocompleteService {
    Trie trie_;
    std::unordered_map<std::string, int> popularity_;

public:
    void addWord(const std::string& word, int score = 1) {
        trie_.insert(word);
        popularity_[word] += score;
    }

    std::vector<std::string> suggest(const std::string& prefix, int limit = 10) {
        auto candidates = trie_.wordsWithPrefix(prefix);
        std::sort(candidates.begin(), candidates.end(),
            [this](const std::string& a, const std::string& b) {
                return popularity_[a] > popularity_[b];
            });
        if (static_cast<int>(candidates.size()) > limit) {
            candidates.resize(limit);
        }
        return candidates;
    }
};

8.3 입력 검증 및 안전한 래퍼

#include <optional>

std::optional<long long> safeQuery(SegmentTree& st, int ql, int qr, int n) {
    if (ql < 0 || qr >= n || ql > qr) return std::nullopt;
    return st.query(ql, qr);
}

8.4 실무 활용 사례

도메인활용
실시간 분석구간 합/최소 (주가, 로그)
검색 엔진트라이 기반 자동완성, 제안
게임순위표 (펜윅/세그먼트로 k번째 찾기)
데이터베이스인덱스 구조 (B-tree는 트리 변형)
코딩 테스트LeetCode 307, 308, 208, 211 등

8.5 실전 통합 예제: 주가 구간 분석기

구간 최소값(세그먼트 트리)과 구간 합(펜윅 트리)을 함께 사용하는 예시입니다.

// 주가: 구간 최저가(SegmentTreeMin) + 구간 거래량 합(FenwickTree)
class StockAnalyzer {
    SegmentTreeMin minTree_;
    FenwickTree volumeTree_;

public:
    StockAnalyzer(const std::vector<int>& prices, const std::vector<long long>& volumes)
        : minTree_(prices), volumeTree_(static_cast<int>(volumes.size())) {
        volumeTree_.init(volumes);
    }
    int rangeMinPrice(int left, int right) { return minTree_.query(left, right); }
    long long rangeTotalVolume(int left, int right) {
        return volumeTree_.rangeSum(left + 1, right + 1);
    }
    void updatePrice(int index, int newPrice) { minTree_.update(index, newPrice); }
    void addVolume(int index, long long delta) { volumeTree_.add(index + 1, delta); }
};

9. 구현 체크리스트

세그먼트 트리

  • 인덱스 체계 확인 (0-indexed vs 1-indexed)
  • tree 크기 4*n 이상 할당
  • 병합 함수 역원/항등원 확인 (합: 0, 최소: INF, XOR: 0)
  • 경계 ql > qr 처리
  • 오버플로우 long long 사용 여부 검토

펜윅 트리

  • 1-indexed 사용 (add(0, x) 금지)
  • 역원 존재하는 연산만 (합, XOR 등)
  • 구간 최소/최대는 세그먼트 트리 사용

트라이

  • 메모리 unique_ptr 또는 명시적 해제
  • 문자 범위 (영소문자 26, 확장 시 맵)
  • 접두사 검색 wordsWithPrefix 구현

프로덕션

  • 입력 범위 검증
  • 대규모 n에서 스택 오버플로우 방지 (반복문 구현 고려)
  • 필요 시 lazy propagation (구간 업데이트)

정리

항목설명
세그먼트 트리구간 쿼리+업데이트 O(log n), 합/최소/최대/XOR 등
펜윅 트리누적합 O(log n), 구현 간단, 역원 필요
트라이접두사 검색 O(m), 자동완성, 문자열 집합

핵심 원칙:

  1. 구간 합만 필요하면 펜윅, 최소/최대 등이면 세그먼트 트리.
  2. 인덱스(0 vs 1)와 경계(ql>qr)를 꼼꼼히 확인.
  3. 트라이는 unique_ptr로 메모리 관리.
  4. 프로덕션에서는 입력 검증과 lazy propagation을 고려.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 구간 쿼리, 빠른 업데이트, 순위 계산, 자동완성, 검색 제안 등에 활용합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 LeetCode 307(Range Sum), 208(Implement Trie), 211(Design Add and Search) 등을 풀어보세요.

한 줄 요약: 세그먼트 트리·펜윅 트리·트라이를 상황에 맞게 선택하고, 흔한 실수를 피해 실무에 적용할 수 있습니다.


참고 자료


관련 글

  • C++ 그래프 알고리즘 완벽 가이드 | BFS·DFS·다익스트라·최소신장트리 [실전]
  • C++ 커스텀 자료구조 완벽 가이드 | 해시테이블·트라이·LRU 캐시·Skip List [#54-2]
  • C++ 알고리즘 |
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3