Skip to content
Code Card

Fenwick Tree 기반 온라인 구간 합

Fenwick Tree는 배열의 한 값을 바꾸면서 prefix sum과 range sum을 반복 조회할 때 쓰는 자료구조다. 원 논문에서 Peter M. Fenwick는 이를 binary indexed tree라고 불렀다. 목적은 동적 산술 부호화에 필요한 누적 빈도표를 작고 빠르게 유지하는 것이었다

Jeong Dongwoo · 연습 메모 · 33 min read · Medium

Fenwick Tree는 값 하나를 수정하면서 prefix sum과 range sum을 반복 조회해야 할 때 사용하는 조밀한 배열 기반 자료구조다. 단순 배열은 한 지점 수정이 O(1)이지만 구간 합이 O(N)이고, 누적합 배열은 구간 합이 O(1)이지만 중간 값 수정 후 뒤쪽 누적값을 모두 고쳐야 하므로 O(N)이다. Fenwick Tree는 두 연산을 모두 O(log N)으로 절충한다.

Peter Fenwick이 1994년 제안한 원래 구조는 동적으로 변하는 누적 빈도표를 효율적으로 유지하기 위한 것이었다. 저자는 같은 논문에서 이 구조의 이름으로 binary indexed tree를 제안했다. 배열 하나에 암묵적인 구간 계층을 저장한다. ([Fenwick 1994]1)

핵심은 내부 index i가 하나의 값이 아니라 i에서 끝나는 길이 LSB(i)의 구간 합을 저장한다는 점이다. 여기서 LSB(i)는 이진 표현에서 가장 낮은 1-bit가 나타내는 값이며, 양수에 대해 i & -i로 계산할 수 있다.

이 구조는 segment tree보다 기능이 좁다. 일반적인 구간 최솟값, lazy propagation, 임의 구간 갱신까지 한 자료구조에 넣으려 하면 Fenwick Tree의 단순성이 사라진다. 반대로 요구사항이 point update와 prefix/range sum에 정확히 맞으면 구현 크기, 메모리, cache footprint 면에서 강력한 선택이다. 후속 연구도 Fenwick 계열 구조의 실용성과 함께 index 산술 및 CPU cache 사이의 상호작용을 분석하고 있다. ([Marchini and Vigna 2020]2, [arXiv preprint]3)

Text
외부 배열:
values[0..N)

내부 배열:
tree[1..N]

LSB(i)
= i & -i

tree[i]가 담당하는 범위:
(i - LSB(i), i]
1-based 기준

Point Add:
index의 조상 구간을 따라 delta 반영

Prefix Sum:
endExclusive에서 부모 구간을 따라 누적

Range Sum:
sum([start, end))
= prefix(end) - prefix(start)

복잡도:
Point Add   = O(log N)
Prefix Sum  = O(log N)
Range Sum   = O(log N)
Storage     = O(N)

1. 문제 상황

트래픽 분석 서비스가 하루를 1분 단위로 나누어 요청 수를 집계한다고 하자.

Text
Bucket 수:
1,440

입력:
- 현재 발생한 요청
- 지연 도착한 과거 요청
- 중복 제거 후의 음수 보정

조회:
- 09:00~10:00 요청 수
- 자정부터 현재까지 누적 요청 수
- 최근 15분 요청 수

단순 누적합 배열을 사용하면 조회는 빠르다.

Text
prefix[minute]
= 자정부터 minute 전까지의 누적값

09:00~10:00
= prefix[600] - prefix[540]

그러나 08:17에 발생했던 요청이 늦게 도착하면 문제가 생긴다.

Text
values[497] += 1

그 뒤의 모든 prefix:
prefix[498..1440] += 1

업데이트 하나가 O(N)이다.

반대로 원본 count 배열만 유지하면 point update는 O(1)이지만 매 구간 조회가 해당 범위를 순회해야 한다.

Fenwick Tree를 사용하면 다음 두 작업을 모두 O(log N)으로 처리한다.

Text
add(497, +1)

rangeSum(540, 600)

이번 공통 API의 범위 규칙은 다음과 같다.

Text
외부 index:
0-based

구간:
[startInclusive, endExclusive)

prefixSum(endExclusive):
[0, endExclusive)의 합

Half-open range를 사용하면 빈 구간 [x, x)의 합이 자연스럽게 0이 되고, 배열 slicing 및 반복문 경계와 맞추기 쉽다.


2. 핵심 표현

C++20

C++ 구현은 std::int64_t 누적값을 사용한다.
Update 도중 overflow가 발생하면 일부 내부 node만 수정된 상태가 되지 않도록, 영향을 받는 index를 먼저 수집하고 모든 덧셈을 사전 검증한 뒤 적용한다.

C++
#include <array>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <span>
#include <stdexcept>
#include <string>
#include <vector>

class FenwickTree final
{
public:
    explicit FenwickTree(std::size_t size)
        : size_(validatedSize(size)),
          tree_(size_ + 1, 0)
    {}

    FenwickTree(const FenwickTree&) = default;
    FenwickTree& operator=(const FenwickTree&) = default;

    FenwickTree(FenwickTree&&) noexcept = default;
    FenwickTree& operator=(FenwickTree&&) noexcept = default;

    void add(
        std::size_t index,
        std::int64_t delta)
    {
        validateIndex(index);

        std::array<
            std::size_t,
            std::numeric_limits<
                std::size_t>::digits> touched{};

        const std::size_t touchedCount =
            collectTouchedIndices(
                index,
                touched);

        validateUpdate(
            touched,
            touchedCount,
            delta);

        applyUpdate(
            touched,
            touchedCount,
            delta);
    }

    [[nodiscard]]
    std::int64_t prefixSum(
        std::size_t endExclusive) const
    {
        validateEnd(endExclusive, "endExclusive");

        std::int64_t result = 0;

        for (std::size_t index = endExclusive;
             index > 0;
             index -= leastSignificantBit(index))
        {
            result = checkedAdd(
                result,
                tree_[index]);
        }

        return result;
    }

    [[nodiscard]]
    std::int64_t rangeSum(
        std::size_t startInclusive,
        std::size_t endExclusive) const
    {
        validateRange(
            startInclusive,
            endExclusive);

        return checkedSubtract(
            prefixSum(endExclusive),
            prefixSum(startInclusive));
    }

    [[nodiscard]]
    std::int64_t getPointValue(
        std::size_t index) const
    {
        validateIndex(index);

        return rangeSum(
            index,
            index + 1);
    }

    [[nodiscard]]
    std::size_t getSize() const noexcept
    {
        return size_;
    }

private:
    static constexpr std::size_t maximumSize =
        10'000'000;

    std::size_t size_;
    std::vector<std::int64_t> tree_;

    [[nodiscard]]
    std::size_t collectTouchedIndices(
        std::size_t externalIndex,
        std::span<std::size_t> output) const
    {
        std::size_t count = 0;

        for (std::size_t index = externalIndex + 1;
             index <= size_;
             index += leastSignificantBit(index))
        {
            output[count] = index;
            count += 1;
        }

        return count;
    }

    void validateUpdate(
        std::span<const std::size_t> indices,
        std::size_t count,
        std::int64_t delta) const
    {
        for (std::size_t index = 0;
             index < count;
             index += 1)
        {
            ensureCanAdd(
                tree_[indices[index]],
                delta);
        }
    }

    void applyUpdate(
        std::span<const std::size_t> indices,
        std::size_t count,
        std::int64_t delta)
    {
        for (std::size_t index = 0;
             index < count;
             index += 1)
        {
            tree_[indices[index]] += delta;
        }
    }

    static std::size_t leastSignificantBit(
        std::size_t value) noexcept
    {
        return value & (~value + 1);
    }

    static void ensureCanAdd(
        std::int64_t left,
        std::int64_t right)
    {
        constexpr auto minimum =
            std::numeric_limits<std::int64_t>::min();

        constexpr auto maximum =
            std::numeric_limits<std::int64_t>::max();

        if (right > 0 && left > maximum - right)
        {
            throw std::overflow_error(
                "Fenwick Tree 합이 int64 범위를 초과합니다.");
        }

        if (right < 0 && left < minimum - right)
        {
            throw std::overflow_error(
                "Fenwick Tree 합이 int64 범위를 초과합니다.");
        }
    }

    static std::int64_t checkedAdd(
        std::int64_t left,
        std::int64_t right)
    {
        ensureCanAdd(left, right);
        return left + right;
    }

    static std::int64_t checkedSubtract(
        std::int64_t left,
        std::int64_t right)
    {
        constexpr auto minimum =
            std::numeric_limits<std::int64_t>::min();

        constexpr auto maximum =
            std::numeric_limits<std::int64_t>::max();

        if (right > 0 && left < minimum + right)
        {
            throw std::overflow_error(
                "구간 합이 int64 범위를 초과합니다.");
        }

        if (right < 0 && left > maximum + right)
        {
            throw std::overflow_error(
                "구간 합이 int64 범위를 초과합니다.");
        }

        return left - right;
    }

    void validateIndex(
        std::size_t index) const
    {
        if (index >= size_)
        {
            throw std::out_of_range(
                "index가 Fenwick Tree 범위를 벗어났습니다.");
        }
    }

    void validateEnd(
        std::size_t value,
        const char* name) const
    {
        if (value > size_)
        {
            throw std::out_of_range(
                std::string{name}
                + "가 범위를 벗어났습니다.");
        }
    }

    void validateRange(
        std::size_t startInclusive,
        std::size_t endExclusive) const
    {
        validateEnd(startInclusive, "startInclusive");
        validateEnd(endExclusive, "endExclusive");

        if (startInclusive > endExclusive)
        {
            throw std::out_of_range(
                "구간이 유효하지 않습니다.");
        }
    }

    [[nodiscard]]
    static std::size_t validatedSize(
        std::size_t size)
    {
        if (size == 0 || size > maximumSize)
        {
            throw std::invalid_argument(
                "size는 1 이상 10,000,000 이하이어야 합니다.");
        }

        return size;
    }
};

사용 방법:

C++
FenwickTree counts{1'440};

counts.add(497, 1);
counts.add(540, 3);
counts.add(599, 2);

const std::int64_t morning =
    counts.rangeSum(540, 600);

Update 사전 검증은 단순 예제에서는 과해 보일 수 있으나, overflow를 검사하면서 부분 mutation을 허용하는 구현은 실패 후 자료구조가 조용히 손상되므로 더 나쁘다.(실제로 다른 구현자들 보면 항상 사전검증이 들어가니까 나도 반사적으로 넣는 편이다)


Python

Python의 int는 고정 64-bit overflow가 없으므로 C++의 transactional overflow preflight가 필요하지 않다.

Python
from __future__ import annotations


class FenwickTree:
    _MAXIMUM_SIZE = 10_000_000

    def __init__(self, size: int) -> None:
        _validate_size(
            size,
            self._MAXIMUM_SIZE,
        )

        self._size = size
        self._tree = [0] * (size + 1)

    def add(
        self,
        index: int,
        delta: int,
    ) -> None:
        self._validate_index(index)
        _validate_integer(delta, "delta")

        internal_index = index + 1

        while internal_index <= self._size:
            self._tree[internal_index] += delta

            internal_index += (
                _least_significant_bit(
                    internal_index
                )
            )

    def prefix_sum(
        self,
        end_exclusive: int,
    ) -> int:
        self._validate_end(
            end_exclusive,
            "end_exclusive",
        )

        result = 0
        internal_index = end_exclusive

        while internal_index > 0:
            result += self._tree[internal_index]

            internal_index -= (
                _least_significant_bit(
                    internal_index
                )
            )

        return result

    def range_sum(
        self,
        start_inclusive: int,
        end_exclusive: int,
    ) -> int:
        self._validate_range(
            start_inclusive,
            end_exclusive,
        )

        return (
            self.prefix_sum(end_exclusive)
            - self.prefix_sum(start_inclusive)
        )

    def get_point_value(self, index: int) -> int:
        self._validate_index(index)

        return self.range_sum(
            index,
            index + 1,
        )

    def get_size(self) -> int:
        return self._size

    def _validate_index(self, index: int) -> None:
        _validate_integer(index, "index")

        if index < 0 or index >= self._size:
            raise IndexError(
                "index가 Fenwick Tree 범위를 벗어났습니다."
            )

    def _validate_end(
        self,
        value: int,
        field_name: str,
    ) -> None:
        _validate_integer(value, field_name)

        if value < 0 or value > self._size:
            raise IndexError(
                f"{field_name}가 범위를 벗어났습니다."
            )

    def _validate_range(
        self,
        start_inclusive: int,
        end_exclusive: int,
    ) -> None:
        self._validate_end(
            start_inclusive,
            "start_inclusive",
        )
        self._validate_end(
            end_exclusive,
            "end_exclusive",
        )

        if start_inclusive > end_exclusive:
            raise ValueError(
                "start_inclusive는 "
                "end_exclusive보다 클 수 없습니다."
            )


def _least_significant_bit(
    value: int,
) -> int:
    return value & -value


def _validate_size(
    size: int,
    maximum_size: int,
) -> None:
    _validate_integer(size, "size")

    if size < 1 or size > maximum_size:
        raise ValueError(
            "size는 1 이상 "
            f"{maximum_size} 이하이어야 합니다."
        )


def _validate_integer(
    value: int,
    field_name: str,
) -> None:
    if (
        not isinstance(value, int)
        or isinstance(value, bool)
    ):
        raise TypeError(
            f"{field_name}는 정수여야 합니다."
        )

사용:

Python
counts = FenwickTree(1_440)

counts.add(497, 1)
counts.add(540, 3)
counts.add(599, 2)

morning = counts.range_sum(
    540,
    600,
)

Python에서는 일반 list가 객체 reference를 저장하므로 C++·C#의 primitive array보다 메모리 밀도가 낮다. 데이터 크기와 query 빈도가 충분히 크다면 native extension이나 array-backed 구현을 검토할 수 있지만, 먼저 전체 처리 시간에서 이 부분의 비중을 측정해야 한다.


C#

C#은 long[]을 사용한다. 아래 코드는 관계 패턴을 사용하므로 C# 9 이상 컴파일러가 필요하다. Update 대상 node를 stackalloc으로 수집해 overflow 발생 전 모든 내부 합을 검증한다.

C#
using System;

public sealed class FenwickTree
{
    private const int MaximumSize = 10_000_000;
    private const int MaximumTreeDepth = 64;

    private readonly long[] tree;
    private readonly int size;

    public FenwickTree(int size)
    {
        ValidateSize(size);

        this.size = size;
        this.tree = new long[size + 1];
    }

    public void Add(
        int index,
        long delta)
    {
        this.ValidateIndex(index);

        Span<int> touched =
            stackalloc int[MaximumTreeDepth];

        int touchedCount =
            this.CollectTouchedIndices(
                index,
                touched);

        this.ValidateUpdate(
            touched,
            touchedCount,
            delta);

        this.ApplyUpdate(
            touched,
            touchedCount,
            delta);
    }

    public long PrefixSum(
        int endExclusive)
    {
        this.ValidateEnd(
            endExclusive,
            nameof(endExclusive));

        long result = 0;

        for (
            int index = endExclusive;
            index > 0;
            index -= LeastSignificantBit(index))
        {
            result = checked(
                result + this.tree[index]);
        }

        return result;
    }

    public long RangeSum(
        int startInclusive,
        int endExclusive)
    {
        this.ValidateRange(
            startInclusive,
            endExclusive);

        return checked(
            this.PrefixSum(endExclusive)
            - this.PrefixSum(startInclusive));
    }

    public long GetPointValue(int index)
    {
        this.ValidateIndex(index);

        return this.RangeSum(
            index,
            index + 1);
    }

    public int GetSize()
    {
        return this.size;
    }

    private int CollectTouchedIndices(
        int externalIndex,
        Span<int> output)
    {
        int count = 0;

        for (
            int index = externalIndex + 1;
            index <= this.size;
            index += LeastSignificantBit(index))
        {
            output[count] = index;
            count += 1;
        }

        return count;
    }

    private void ValidateUpdate(
        ReadOnlySpan<int> indices,
        int count,
        long delta)
    {
        for (int index = 0; index < count; index += 1)
        {
            _ = checked(
                this.tree[indices[index]]
                + delta);
        }
    }

    private void ApplyUpdate(
        ReadOnlySpan<int> indices,
        int count,
        long delta)
    {
        for (int index = 0; index < count; index += 1)
        {
            this.tree[indices[index]] += delta;
        }
    }

    private static int LeastSignificantBit(
        int value)
    {
        return value & -value;
    }

    private void ValidateIndex(int index)
    {
        if (index < 0 || index >= this.size)
        {
            throw new ArgumentOutOfRangeException(
                nameof(index));
        }
    }

    private void ValidateEnd(
        int value,
        string parameterName)
    {
        if (value < 0 || value > this.size)
        {
            throw new ArgumentOutOfRangeException(
                parameterName);
        }
    }

    private void ValidateRange(
        int startInclusive,
        int endExclusive)
    {
        this.ValidateEnd(
            startInclusive,
            nameof(startInclusive));
        this.ValidateEnd(
            endExclusive,
            nameof(endExclusive));

        if (startInclusive > endExclusive)
        {
            throw new ArgumentOutOfRangeException(
                nameof(startInclusive));
        }
    }

    private static void ValidateSize(int size)
    {
        if (size is < 1 or > MaximumSize)
        {
            throw new ArgumentOutOfRangeException(
                nameof(size));
        }
    }
}

사용:

C#
FenwickTree counts = new(1_440);

counts.Add(497, 1);
counts.Add(540, 3);
counts.Add(599, 2);

long morning =
    counts.RangeSum(540, 600);

checked update를 적용 루프 안에서 직접 수행하면 세 번째 node에서 overflow가 발생할 때 앞의 두 node는 이미 변경된다. 사전 검증과 실제 적용을 분리한 이유다.


TypeScript

TypeScript는 정확한 정수 누적을 위해 bigint를 사용한다. JavaScript number는 안전한 정수 범위가 제한되므로 장기 누적 계수에 무조건 적합하다고 볼 수 없다.

TypeScript
export class FenwickTree
{
  static readonly #maximumSize =
    10_000_000;

  readonly #size: number;
  readonly #tree: bigint[];

  public static fromValues(
    values: readonly bigint[],
    signal?: AbortSignal,
  ): FenwickTree
  {
    if (values.length === 0) {
      throw new RangeError(
        "빈 배열에서는 Fenwick Tree를 만들 수 없습니다.",
      );
    }

    const result =
      new FenwickTree(values.length);

    for (
      let internalIndex = 1;
      internalIndex <= values.length;
      internalIndex += 1
    ) {
      signal?.throwIfAborted();

      const value =
        values[internalIndex - 1];

      validateBigInt(value, "value");

      result.#tree[internalIndex] += value;

      const parentIndex =
        internalIndex
        + leastSignificantBit(
            internalIndex);

      if (parentIndex <= values.length) {
        result.#tree[parentIndex] +=
          result.#tree[internalIndex];
      }
    }

    return result;
  }

  public constructor(size: number)
  {
    validateSize(
      size,
      FenwickTree.#maximumSize,
    );

    this.#size = size;
    this.#tree =
      new Array<bigint>(size + 1)
        .fill(0n);
  }

  public add(
    index: number,
    delta: bigint,
  ): void
  {
    this.#validateIndex(index);
    validateBigInt(delta, "delta");

    let internalIndex = index + 1;

    while (internalIndex <= this.#size) {
      this.#tree[internalIndex] += delta;

      internalIndex +=
        leastSignificantBit(internalIndex);
    }
  }

  public prefixSum(
    endExclusive: number,
  ): bigint
  {
    this.#validateEnd(
      endExclusive,
      "endExclusive",
    );

    let result = 0n;
    let internalIndex = endExclusive;

    while (internalIndex > 0) {
      result += this.#tree[internalIndex];

      internalIndex -=
        leastSignificantBit(internalIndex);
    }

    return result;
  }

  public rangeSum(
    startInclusive: number,
    endExclusive: number,
  ): bigint
  {
    this.#validateRange(
      startInclusive,
      endExclusive,
    );

    return this.prefixSum(endExclusive)
      - this.prefixSum(startInclusive);
  }

  public getPointValue(
    index: number,
  ): bigint
  {
    this.#validateIndex(index);

    return this.rangeSum(
      index,
      index + 1,
    );
  }

  public getSize(): number
  {
    return this.#size;
  }

  #validateIndex(index: number): void
  {
    validateInteger(index, "index");

    if (index < 0 || index >= this.#size) {
      throw new RangeError(
        "index가 Fenwick Tree 범위를 벗어났습니다.",
      );
    }
  }

  #validateEnd(
    value: number,
    fieldName: string,
  ): void
  {
    validateInteger(
      value,
      fieldName,
    );

    if (value < 0 || value > this.#size) {
      throw new RangeError(
        `${fieldName}가 범위를 벗어났습니다.`,
      );
    }
  }

  #validateRange(
    startInclusive: number,
    endExclusive: number,
  ): void
  {
    this.#validateEnd(
      startInclusive,
      "startInclusive",
    );
    this.#validateEnd(
      endExclusive,
      "endExclusive",
    );

    if (startInclusive > endExclusive) {
      throw new RangeError(
        "startInclusive는 "
        + "endExclusive보다 클 수 없습니다.",
      );
    }
  }
}

function leastSignificantBit(
  value: number,
): number
{
  return value & -value;
}

function validateSize(
  size: number,
  maximumSize: number,
): void
{
  validateInteger(size, "size");

  if (size < 1 || size > maximumSize) {
    throw new RangeError(
      "size는 1 이상 "
      + `${maximumSize} 이하이어야 합니다.`,
    );
  }
}

function validateInteger(
  value: number,
  fieldName: string,
): void
{
  if (!Number.isSafeInteger(value)) {
    throw new TypeError(
      `${fieldName}는 안전한 정수여야 합니다.`,
    );
  }
}

function validateBigInt(
  value: bigint,
  fieldName: string,
): void
{
  if (typeof value !== "bigint") {
    throw new TypeError(
      `${fieldName}는 bigint여야 합니다.`,
    );
  }
}

사용:

TypeScript
const counts =
  new FenwickTree(1_440);

counts.add(497, 1n);
counts.add(540, 3n);
counts.add(599, 2n);

const morning =
  counts.rangeSum(540, 600);

JavaScript bitwise 연산은 ToInt32를 거쳐 signed 32-bit 정수로 계산한다. 이 방식에서 쓸 수 있는 양수 내부 index의 이론적 상한은 2^31 - 1이다. 이 구현이 size를 1천만으로 제한한 이유는 bitwise 제약이 아니라 배열 할당량을 제한하기 위한 별도 정책이다. 더 큰 index 공간이 필요하면 bitwise 연산을 그대로 확장하지 말고 index 표현과 메모리 정책을 함께 다시 정해야 한다. 이 코드 블록은 noUncheckedIndexedAccess: false를 전제로 한다.


3. 호출부

Fenwick Tree는 시간, 날짜, DB, 네트워크를 알지 못한다. 상위 집계 계층이 timestamp를 minute index로 변환하고, 저장 계층이 durability를 담당한다.

TypeScript
export type MinuteCountRepository = Readonly<{
  readDayAsync: (
    day: string,
    signal?: AbortSignal,
  ) => Promise<readonly bigint[]>;

  appendDeltaAsync: (
    day: string,
    minuteIndex: number,
    delta: bigint,
    signal?: AbortSignal,
  ) => Promise<void>;
}>;

const minuteCountPerDay = 1_440;

export class DailyTrafficIndex
{
  readonly #tree: FenwickTree;

  public constructor(
    tree = new FenwickTree(
      minuteCountPerDay,
    ),
  )
  {
    if (tree.getSize() !== minuteCountPerDay) {
      throw new RangeError(
        "일별 Fenwick Tree 크기가 유효하지 않습니다.",
      );
    }

    this.#tree = tree;
  }

  public recordDelta(
    minuteIndex: number,
    delta: bigint,
  ): void
  {
    this.#tree.add(
      minuteIndex,
      delta,
    );
  }

  public countRange(
    startMinute: number,
    endMinute: number,
  ): bigint
  {
    return this.#tree.rangeSum(
      startMinute,
      endMinute,
    );
  }

  public countUntil(
    endMinute: number,
  ): bigint
  {
    return this.#tree.prefixSum(
      endMinute,
    );
  }
}

export async function rebuildDailyIndexAsync(
  repository: MinuteCountRepository,
  day: string,
  signal?: AbortSignal,
): Promise<DailyTrafficIndex>
{
  validateDay(day);

  const counts =
    await repository.readDayAsync(
      day,
      signal,
    );

  if (counts.length !== minuteCountPerDay) {
    throw new RangeError(
      "일별 count snapshot 크기가 유효하지 않습니다.",
    );
  }

  signal?.throwIfAborted();

  return new DailyTrafficIndex(
    FenwickTree.fromValues(
      counts,
      signal,
    ),
  );
}

export async function recordDeltaDurablyAsync(
  repository: MinuteCountRepository,
  index: DailyTrafficIndex,
  day: string,
  minuteIndex: number,
  delta: bigint,
  signal?: AbortSignal,
): Promise<void>
{
  validateDay(day);

  await repository.appendDeltaAsync(
    day,
    minuteIndex,
    delta,
    signal,
  );

  index.recordDelta(
    minuteIndex,
    delta,
  );
}

function validateDay(day: string): void
{
  if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
    throw new RangeError(
      "day는 YYYY-MM-DD 형식이어야 합니다.",
    );
  }
}

책임 분리:

Text
Timestamp Boundary
= UTC 또는 업무 timezone 결정
= timestamp → minute index 변환

MinuteCountRepository
= 원본 event·delta의 영속성
= 재시작 복구용 snapshot 제공

DailyTrafficIndex
= point delta와 구간 합
= 외부 I/O 없음

Query Service
= 사용자 시간 범위를 minute range로 변환

Central Policy Handler
= repository 장애
= cancellation
= snapshot 불일치 처리

Fenwick Tree는 process-local index일 뿐 source of truth가 아니다. 프로세스가 종료되면 메모리 상태는 사라지므로 event log나 durable delta store에서 재구축해야 한다.


4. 읽는 순서

Text
외부 index가 0-based인가
→ 내부 배열이 1-based인가
→ index 0을 내부 update loop에 직접 넣지 않는가
→ tree[i]가 어떤 구간을 담당하는가
→ update가 i += LSB(i)로 이동하는가
→ query가 i -= LSB(i)로 이동하는가
→ prefix range가 [0, end)인가
→ range가 [start, end)인가
→ 빈 range가 0을 반환하는가
→ overflow 실패 시 부분 mutation이 남지 않는가
→ 동시 update/query 정책이 명시되어 있는가

중요 핵심 불변식:

Text
tree[i]
=
values[
  i - LSB(i)
  ...
  i - 1
]

외부 0-based,
내부 1-based 기준

5. 경계와 오해

1-based 내부 index는 장식이 아니다

Fenwick Tree의 이동량은 LSB(i)다.

Text
i = 0

LSB(0)
= 0

내부 update를 0부터 시작하면 다음 index도 0이므로 무한 루프가 된다.

Text
0 += LSB(0)
0 += 0

외부 API는 0-based로 유지하더라도 내부에서는 index + 1로 변환해야 한다.

Fenwick Tree는 고정된 index universe를 전제로 한다

다음 예시에는 적합하다라고 할 수 있다.

Text
- 하루 1,440개 minute bucket
- 상품 category 0..N-1
- 압축된 좌표
- 고정된 histogram bucket

다음에는 직접 적용하기 어렵다.

Text
- 임의 UUID
- 중간 index 삽입
- 동적으로 계속 늘어나는 정렬 key

값의 순서만 중요하고 key가 크거나 희소하다면 coordinate compression으로 조밀한 index를 만든 뒤 사용한다. 새로운 key가 실행 중 계속 삽입된다면 ordered tree나 다른 구조가 더 적합할 수 있다.

합 연산에 특화된 구조다

Fenwick Tree의 핵심은 prefix aggregate와 inverse-like range 계산이다.

Text
rangeSum(start, end)
=
prefix(end) - prefix(start)

임의 구간 질의를 prefix 값 두 개로 만들려면 결합법칙만으로는 부족하고 앞부분을 되돌릴 역연산이 필요하다. 합은 뺄셈으로 이를 만족하지만 min에는 그런 역연산이 없다. 단조로운 갱신에 한정한 prefix minimum 변형은 존재한다. 그러나 임의 점 갱신과 임의 구간 최솟값을 이 구현과 같은 방식으로 처리할 수는 없다. 그런 요구나 복잡한 lazy range update가 있으면 segment tree 같은 구조를 검토한다.

예상 가능한 입력 실패는 공개 경계에서 Result로 처리한다

앞 절의 자료구조 구현은 범위 이탈과 잘못된 크기를 언어별 예외로 표현한다. 그러나 호출자가 제공할 수 있는 입력에서 이런 실패가 충분히 예상 가능하다면, 제품 API는 예외 대신 명시적인 Result<Success, Failure>를 반환하는 편이 낫다. 호출부는 성공과 실패를 구조화된 값으로 분기하고, 실패 코드와 입력을 기록할 수 있다.

다만 prefixSum()rangeSum()이 수만 번 반복되는 루프에서 매 호출마다 Result 객체를 만들고 언래핑하면 다른 비용이 생기는데, 객체 할당, 참조 추적, 성공 플래그 분기, 값 언래핑의 비용은 언어와 런타임에 따라 다르다. 이 비용이 O(log N) 탐색보다 항상 크거나 작다고 일반화할 수는 없으며, 배열 크기, 런타임 최적화, 값 표현, 호출 분포를 고정한 benchmark가 필요하다.

해결은 예외로 되돌아가는 것이 아니라, 검증 경계와 계산 경계를 나누는 것이다라고 할 수 있다.

Text
외부 입력
  ↓
validateRange
  ↓ Result<ValidatedRange, FenwickFailure>
호출부가 실패를 처리
  ↓
검증된 range 묶음
  ↓
rangeSumUnchecked
  ↓ bigint, long, int64_t
반복 계산

unchecked은 검증을 생략한다는 뜻이 아니라, 이 함수의 입력이 이미 검증되었다는 내부 계약이다. 이 메서드는 자료구조 내부에 두고, 검증을 끝낸 facade만 호출하게 한다. public API로 노출하거나 검증되지 않은 값을 넘기면 안 된다.

2절 구현에는 이 경로가 없다. 도입한다면 기존 rangeSum()에서 검증만 걷어낸 형태로 클래스 안에 둔다.

TypeScript
  // FenwickTree 내부. ValidatedRange를 통과한 값만 받는다.
  #rangeSumUnchecked(
    startInclusive: number,
    endExclusive: number,
  ): bigint
  {
    return this.prefixSum(endExclusive)
      - this.prefixSum(startInclusive);
  }

  public sumValidatedBatch(
    ranges: readonly ValidatedRange[],
  ): bigint[]
  {
    return ranges.map((range) =>
      this.#rangeSumUnchecked(
        range.startInclusive,
        range.endExclusive,
      ));
  }

prefixSum()도 내부에서 #validateEnd()를 다시 호출하므로, 검증을 완전히 걷어내려면 prefix 탐색까지 unchecked 경로로 내려야 한다. 어디까지 걷어낼지는 앞서 말한 benchmark로 정한다.

TypeScript
type Result<T, E> =
  | Readonly<{ ok: true; value: T }>
  | Readonly<{ ok: false; error: E }>;

type FenwickFailure =
  | Readonly<{ code: "invalid-size"; size: number }>
  | Readonly<{
      code: "invalid-range";
      startInclusive: number;
      endExclusive: number;
      size: number;
    }>;

type ValidatedRange = Readonly<{
  startInclusive: number;
  endExclusive: number;
}>;

const maximumSize = 10_000_000;

function validateRange(
  size: number,
  startInclusive: number,
  endExclusive: number,
): Result<ValidatedRange, FenwickFailure>
{
  if (
    !Number.isSafeInteger(size)
    || size < 1
    || size > maximumSize
  ) {
    return {
      ok: false,
      error: { code: "invalid-size", size },
    };
  }

  if (
    !Number.isSafeInteger(startInclusive)
    || !Number.isSafeInteger(endExclusive)
    || startInclusive < 0
    || startInclusive > endExclusive
    || endExclusive > size
  ) {
    return {
      ok: false,
      error: {
        code: "invalid-range",
        startInclusive,
        endExclusive,
        size,
      },
    };
  }

  return {
    ok: true,
    value: { startInclusive, endExclusive },
  };
}

invalid-size의 판정 기준은 2절 생성자의 validateSize()와 같아야 한다. 한쪽만 상한을 검사하면 경계 검증을 통과한 size가 생성자에서 예외로 거절되어, Result로 표현하려던 실패가 다시 예외로 새어 나온다.

한 건씩 외부에서 조회하면 Result 하나는 피할 수 없다. 조회 요청을 미리 검증해 ValidatedRange[]로 만든 뒤 한 batch 안에서 원시 합을 계산하면, 실패 표현은 입력 경계에만 남고 루프는 값과 배열 접근만 수행한다. C++23에서는 std::expected를, C++20에서는 프로젝트가 소유한 동등한 결과 타입을 사용할 수 있다.
C#에서는 프로젝트가 소유한 Result<T, E>, TypeScript에서는 discriminated union을 같은 원칙으로 적용할 수 있다.

Result 도입 전후는 같은 질의열로 비교한다.

구현

검증과 반환 방식

측정 목적

raw 내부 계산

검증된 range, 원시 합 반환

자료구조 자체의 기준선

호출마다 Result

매 query가 Result 생성과 언래핑 수행

가장 단순한 안전 API의 비용

경계 Result와 batch 계산

batch 준비 시 한 번 검증, 내부는 원시 합

안전성과 hot path 비용을 분리한 비용

구현

raw 내부 계산

검증과 반환 방식

검증된 range, 원시 합 반환

측정 목적

자료구조 자체의 기준선

구현

호출마다 Result

검증과 반환 방식

매 query가 Result 생성과 언래핑 수행

측정 목적

가장 단순한 안전 API의 비용

구현

경계 Result와 batch 계산

검증과 반환 방식

batch 준비 시 한 번 검증, 내부는 원시 합

측정 목적

안전성과 hot path 비용을 분리한 비용

동일한 N, update:query 비율, range 길이 분포, 난수 시드, 정수 폭을 사용한다. 총 시간뿐 아니라 allocation 수, GC 또는 allocator 활동, branch miss, cache miss, query당 지연을 함께 본다. 결과가 나오기 전에는 "Result는 느리다"나 "Result 비용은 무시할 수 있다"고 단정하지 않는 것이 좋다.(조기 최적화라는 게 다른게 아니고 측정없는 최적화가 조기최적화다)

이 원칙에서 예외는 예상 가능한 검증 실패의 표현이 아니다. 범위 이탈, 잘못된 크기, 형식 오류는 Failure 값으로 반환한다. 메모리 부족, 손상된 내부 상태, 계약을 깨는 internal caller처럼 복구 경로가 별도로 설계되지 않은 실패는 언어와 시스템의 fatal 경계 정책에 맡긴다.

Negative delta는 허용되지만 의미를 확인해야 한다

이번 구현은 음수 보정을 지원한다.

Text
중복 event 취소:
add(minute, -1)

구간 합 자체에는 문제가 없음을 확인 가능하다.

그러나 cumulative count를 이용해 “누적합이 X 이상이 되는 최초 index”를 찾는 order-statistic 확장을 사용하려면 prefix sum이 단조 증가해야 한다. 음수 값이 존재하면 그 탐색 조건이 깨질 수 있다.

Prefix array가 더 나은 경우도 많다

Text
데이터:
초기 한 번 생성

Update:
없음

Query:
수백만 회

이 경우 누적합 배열은 다음 성능을 제공한다.

Text
Build:
O(N)

Range Query:
O(1)

Fenwick Tree의 O(log N) query는 불필요한 비용이며, 반대로 query가 거의 없고 update만 많다면 원본 배열만 유지하는 편이 단순할 수 있다.

Fenwick Tree는 update와 query가 모두 반복되는 경우에 정당화된다고 할 수 있다.

선형 배열이라고 모든 접근이 연속적인 것은 아니다

Storage는 조밀한 배열이지만 update와 query가 방문하는 index는 powers-of-two 간격으로 이동한다.

Text
update 5:
6 → 8 → 16 → 32 ...

query 27:
27 → 26 → 24 → 16 ...

큰 자료구조에서는 이 index 패턴과 cache hierarchy가 성능에 영향을 준다. Fenwick 구조의 compactness가 장점이지만, 실제 CPU에서는 배열 크기와 query 분포를 포함한 benchmark가 필요하다. 관련 연구도 Fenwick index 산술과 cache 구조의 상호작용이 실용 성능에 중요하다고 분석한다. ([Marchini and Vigna 2020]4)

동시 접근은 자동으로 안전하지 않다

Update는 여러 tree node를 순서대로 변경한다.

Text
tree[6] 수정
tree[8] 수정
tree[16] 수정

동시에 query가 실행되면 update 전·후가 섞인 합을 관찰할 수 있다.

가능한 정책:

Text
- 단일 owner actor
- read/write lock
- update batch 후 immutable snapshot 교체
- shard별 독립 tree
- 원자적 node update와 허용 가능한 약한 일관성

각 node를 atomic으로 바꾸는 것만으로 전체 query snapshot이 원자적이 되는 것은 아니다.

Overflow 정책은 자료구조의 일부다

C++과 C#에서 내부 node는 여러 원소의 합을 저장하므로 개별 원소가 작아도 누적합이 64-bit를 넘을 수 있다.

나쁜 구현:

Text
node 1 수정 성공
node 2 수정 성공
node 3에서 overflow 예외

결과:
일부 node만 갱신
→ 내부 불변식 손상

따라서 다음 중 하나가 필요하다가 할 수 있다.

Text
- 업데이트 전 전체 node overflow 사전 검증
- 더 넓은 정수 형식
- saturation 정책
- modulo arithmetic을 명시한 domain

Python은 arbitrary-precision integer라 overflow는 없지만 큰 정수 연산 비용은 값 크기와 함께 증가한다.

데이터 영속성과 자료구조는 별도다

Fenwick Tree는 query index다.

Text
Source of truth:
event log, DB, durable snapshot

Derived index:
Fenwick Tree

Update를 메모리 tree에 먼저 적용하고 DB 저장에 실패하면 상태가 분기된다. 영속 delta를 먼저 commit한 뒤 memory index를 갱신하거나, 실패 시 rebuild 가능한 명확한 순서를 정의해야 한다. 3절의 recordDeltaDurablyAsync()가 이 순서를 코드로 고정한 형태다. appendDeltaAsync()가 실패하면 예외가 그대로 전파되고 메모리 index는 손대지 않은 상태로 남는다.

프로덕션에서 실패하는 상황들:

Text
- 내부 index를 0부터 시작해 무한 루프 발생
- Inclusive와 exclusive range를 혼용함
- size보다 큰 end를 조용히 clamp함
- 음수 index가 언어별로 다른 동작을 함
- overflow 후 일부 tree node만 수정됨
- Min query를 prefix subtraction으로 구현함
- 여러 thread가 synchronization 없이 update함
- process-local tree를 source of truth로 사용함
- 희소한 64-bit key에 거대한 배열을 할당함
- update가 없는 데이터에도 Fenwick Tree를 사용함

6. 잘못된 예제

다음 구현은 외부 0-based index를 내부에서도 그대로 사용한다.

TypeScript
class FenwickTreeBad
{
  readonly #tree: number[];

  public constructor(size: number)
  {
    this.#tree =
      new Array<number>(size).fill(0);
  }

  public add(
    index: number,
    delta: number,
  ): void
  {
    while (index < this.#tree.length) {
      this.#tree[index] += delta;
      index += index & -index;
    }
  }
}

add(0, 1)의 동작:

Text
index = 0

index & -index
= 0

다음 index
= 0 + 0
= 0

무한 루프가 된다.

그 외 문제:

Text
- 입력 validation이 없다.
- NaN과 Infinity를 허용한다.
- 누적 합이 Number.MAX_SAFE_INTEGER를 넘을 수 있다.
- 내부 배열 길이와 valid index 규칙이 불명확하다.
- range의 inclusive/exclusive 의미가 없다.

올바른 구조:

Text
externalIndex = 0

internalIndex
= externalIndex + 1
= 1

LSB(1)
= 1

다음:
1 → 2 → 4 → 8

7. 프로덕션 확장

O(N) 초기 구축

초기 snapshot이 이미 존재한다면 각 원소에 add()를 호출하는 O(N log N) 구축보다 O(N) build가 가능하다. 2절의 FenwickTree.fromValues()는 클래스 내부에 정의된 정적 생성자이며, 3절의 rebuildDailyIndexAsync()가 이 경로를 사용한다. 선택적 AbortSignal을 받아 큰 snapshot의 구축 중에도 취소를 확인한다.

각 node의 현재 부분합을 직접 부모 node에 전달한다.

Text
tree[i]에 values[i-1] 반영
→ parent = i + LSB(i)
→ tree[parent] += tree[i]

초기 1,440개 minute snapshot에서는 차이가 작다.
수백만 bucket을 rebuild하거나 index를 자주 재생성할 때 의미가 커진다.

Reference array 기반 상태 테스트

TypeScript
import assert from "node:assert/strict";
import test from "node:test";

import { FenwickTree } from "./fenwick-tree.js";

test("Fenwick Tree는 기준 배열의 구간 합과 같다", () =>
{
  const size = 512;
  const tree = new FenwickTree(size);
  const reference =
    new Array<bigint>(size).fill(0n);

  const random =
    createDeterministicRandom(0x5eed);

  for (
    let step = 0;
    step < 50_000;
    step += 1
  ) {
    const operation =
      random() % 2;

    if (operation === 0) {
      const index =
        random() % size;

      const delta =
        BigInt(
          (random() % 201) - 100);

      tree.add(index, delta);
      reference[index] += delta;

      continue;
    }

    const first =
      random() % (size + 1);

    const second =
      random() % (size + 1);

    const start = Math.min(
      first,
      second,
    );

    const end = Math.max(
      first,
      second,
    );

    assert.equal(
      tree.rangeSum(start, end),
      sumReference(
        reference,
        start,
        end),
    );
  }
});

test("빈 구간의 합은 0이다", () =>
{
  const tree = new FenwickTree(10);

  tree.add(5, 100n);

  assert.equal(
    tree.rangeSum(5, 5),
    0n,
  );
});

test("첫 index와 마지막 index를 처리한다", () =>
{
  const tree = new FenwickTree(10);

  tree.add(0, 3n);
  tree.add(9, 7n);

  assert.equal(
    tree.rangeSum(0, 1),
    3n,
  );

  assert.equal(
    tree.rangeSum(9, 10),
    7n,
  );

  assert.equal(
    tree.rangeSum(0, 10),
    10n,
  );
});

function sumReference(
  values: readonly bigint[],
  start: number,
  end: number,
): bigint
{
  let result = 0n;

  for (
    let index = start;
    index < end;
    index += 1
  ) {
    result += values[index];
  }

  return result;
}

function createDeterministicRandom(
  seed: number,
): () => number
{
  let state = seed >>> 0;

  return () =>
  {
    state = (
      Math.imul(state, 1_664_525)
      + 1_013_904_223
    ) >>> 0;

    return state >>> 16;
  };
}

LCG의 하위 bit는 짧은 주기를 갖는다.
따라서 이 테스트는 반환값을 state >>> 16으로 제한하고, random() % 2random() % 512가 하위 bit에 직접 의존하지 않게 한다. 이 하네스의 size는 512이므로 16-bit 반환값으로 충분하다.

size가 65,536 이상인 하네스에서는 raw state를 유지한 뒤 Math.floor((state / 0x1_0000_0000) * upperBound)처럼 상위 구간에서 범위를 정해야 한다.

실행 검증 범위

각 언어 구현은 같은 결정적 질의열로 기준 배열과 대조한다. seed 0x5eed, size 512, point add와 range sum 50,000회를 섞어 실행하고, 매 조회 결과를 기준 배열의 직접 합과 비교한다. 이 검증에는 다음 실패 경로도 포함한다.

Text
- size 0, 최대 size 초과
- 음수 index, size와 같은 point index
- startInclusive > endExclusive, endExclusive > size
- 빈 구간, 첫 index, 마지막 index, 전체 구간
- C++와 C#의 overflow 사전 검증 후 내부 배열이 변하지 않는지
- TypeScript fromValues의 빈 배열 거부와 O(N) build 결과
- rebuild의 정상 snapshot과 길이가 잘못된 snapshot

TypeScript는 strict 모드에서 noUncheckedIndexedAccess: false 조건으로, C#은
C# 9 이상 Release build로,
C++은 -std=c++20 -Wall -Wextra -Werror -pedantic으로 컴파일한다.
Python은 python3 -m py_compile <파일 경로>로 먼저 문법을 확인한 뒤 같은 기준 배열 검사를 수행한다.

핵심 테스트 불변식:

Text
prefixSum(0) = 0

rangeSum(i, i) = 0

rangeSum(i, i+1)
= getPointValue(i)

rangeSum(0, N)
= 모든 point의 합

add(i, d) 후:
i를 포함하는 range만 d만큼 변함

Benchmark 비교

다음 세 구조를 함께 비교해야 한다.

Text
Raw Array:
Update O(1)
Query O(range length)

Prefix Array:
Update O(N)
Query O(1)

Fenwick Tree:
Update O(log N)
Query O(log N)

Benchmark 변수:

Text
- N
- Update:Query 비율
- Query range 길이 분포
- Random/clustered index 분포
- Thread 수
- Integer width
- 초기 build 빈도

Fenwick Tree가 알고리즘적으로 균형 잡힌 선택이라는 사실과 현재 workload에서 가장 빠르다는 주장은 다르다.

운영 metric

Text
fenwick.update.count
fenwick.query.count
fenwick.update.duration_ns
fenwick.query.duration_ns
fenwick.rebuild.count
fenwick.rebuild.duration_ms
fenwick.size
fenwick.overflow.count

Bucket index나 사용자 ID를 metric label로 사용하지 않는다.


8. C++ / Python / C# / TypeScript 비교 메모

언어

내부 값

저장 표현

주요 위험

C++

int64_t

vector<int64_t>

overflow 후 부분 mutation

Python

arbitrary int

list[int]

객체 메모리와 interpreter 비용

C#

long

long[]

checked update의 원자성 착각

TypeScript

bigint

bigint[]

성능·JSON 직렬화 제약

언어

C++

내부 값

int64_t

저장 표현

vector<int64_t>

주요 위험

overflow 후 부분 mutation

언어

Python

내부 값

arbitrary int

저장 표현

list[int]

주요 위험

객체 메모리와 interpreter 비용

언어

C#

내부 값

long

저장 표현

long[]

주요 위험

checked update의 원자성 착각

언어

TypeScript

내부 값

bigint

저장 표현

bigint[]

주요 위험

성능·JSON 직렬화 제약

C++

std::vector<int64_t>는 contiguous하고 소유권이 명확하다. std::array를 사용한 touched-index preflight는 update당 heap allocation 없이 transactional overflow 검사를 수행한다.

필요한 값 범위가 명확히 작다면 overflow 검사를 제거할 수 있지만, 이는 benchmark가 아니라 domain invariant로 증명해야 한다.

Python

Python integer는 overflow에서 자유롭지만 각 tree element가 별도 Python object reference로 표현될 수 있어 memory density가 낮다.

N이 작거나 Python orchestration 안에서 query 수가 제한적이면 충분하다. 수백만 node를 높은 빈도로 처리한다면 native implementation을 검토한다.

C#

long[]은 compact하며 stackalloc으로 update 경로의 임시 index를 관리할 수 있다.

checked는 arithmetic overflow를 검출할 뿐 여러 배열 변경을 transaction으로 만들어 주지는 않는다. 사전 검증이 필요한 이유다.

TypeScript

bigint는 정확하지만 다음 trade-off가 있다.

Text
- number보다 산술 비용이 클 수 있음
- JSON.stringify가 bigint를 직접 처리하지 못함
- number와 혼합 연산 불가

값의 최대 누적합이 Number.MAX_SAFE_INTEGER 안이라는 invariant가 있다면 number 구현이 더 빠를 수 있다. 이 경우 모든 update와 query 결과에 safe-integer 검사를 적용하거나 상한을 입력 정책으로 보장해야 한다.

JavaScript bitwise 연산의 ToInt32 변환은 양수 내부 index에 2^31 - 1이라는 이론적 상한을 둔다. 이 구현의 실효 상한은 그보다 작은 10,000,000이며, 배열 할당량을 제한하는 메모리 정책이다.

공통 계약

네 언어의 정수 타입은 달라도 index 의미는 같아야 한다.

Text
Public:
0-based

Prefix:
[0, endExclusive)

Range:
[startInclusive, endExclusive)

Internal:
1-based

LSB:
positive index에서만 계산

9. 추가로 생각해보기

  • Update와 query 비율이 어느 정도일 때 누적합 배열보다 Fenwick Tree가 실제로 유리한가?

  • 음수 delta를 허용할 때 cumulative rank 검색 기능을 별도로 금지해야 하지 않는가?

  • 여러 thread가 query할 때 lock, copy-on-write snapshot, shard-local tree 중 어느 일관성 모델이 필요한가?

  • 64-bit overflow가 가능한 domain에서 wider integer, saturation, fail-fast 중 어떤 정책이 맞는가?

  • 희소한 timestamp를 coordinate compression하면 새로운 timestamp가 들어올 때 index 재구축 비용을 어떻게 처리할 것인가?

  • 초기 snapshot을 O(N)으로 build하는 복잡성이 실제 rebuild 빈도에서 정당화되는가?


10. 요약

  • Fenwick Tree는 point update와 prefix/range sum을 모두 O(log N)에 수행한다.

  • 외부 index는 0-based로 두되 내부는 반드시 1-based로 변환해야 한다.

  • tree[i]LSB(i) 길이의 구간 합을 저장한다.

  • 구간 API는 [start, end)로 고정해 off-by-one 오류를 줄인다.

  • C++과 C#에서는 overflow 실패 후 부분 mutation이 남지 않도록 사전 검증이 필요하다.

  • Update가 없으면 prefix array가, 일반적인 복합 구간 연산에는 segment tree가 더 적합할 수 있다.

Text
한 값만 바뀌는데 누적합 전체를 다시 쓰지 마라.

각 index가 담당하는 이진 구간만 갱신하고,
prefix 두 개의 차이로 원하는 구간을 계산하라.

각주

  1. "A New Data Structure for Cumulative Frequency Tables"
  2. "Compact Fenwick Trees for Dynamic Ranking and Selection"
  3. "Compact Fenwick Trees for Dynamic Ranking and Selection (arXiv preprint)"
  4. https://doi.org/10.1002/spe.2791 "Compact Fenwick Trees for Dynamic Ranking and Selection"