본문으로 건너뛰기

같은 구간을 반복해 묻는다면 - 이름과 비용: 자료구조 ep.05

같은 질문이 반복되면 답을 미리 만들어 둘 수 있습니다. 전처리는 미리 계산하는 비용을 내고 질의마다 드는 비용을 줄이는 거래입니다.


한 번과 십만 번은 다른 문제입니다

배열에서 인덱스 3부터 7까지의 합을 구합니다. 반복문 한 번이면 됩니다. 원소 다섯 개를 더하는 O(N) 작업입니다.

이 질문이 십만 번 들어오면 상황이 바뀝니다. 배열 크기가 십만이고 질의도 십만 개라면 곱해서 백억입니다. ep.01 크기가 접근법을 정한다의 기준선으로 보면 통과할 수 없는 규모입니다.

질의 하나하나는 최적이었는데 전체가 실패합니다. 문제의 단위가 질의 하나가 아니라 질의 묶음이었기 때문입니다. 같은 배열의 범위를 반복해서 묻는 이런 문제를 구간 질의(range query) 라고 합니다.


누적합은 뺄셈으로 답합니다

누적합(prefix sum) 은 배열을 한 번 훑으면서 처음부터 각 위치까지의 합을 미리 저장합니다. 배열이 3, 1, 4, 1, 5, 9, 2라면 누적합은 앞에 0을 하나 두고 차례로 더한 0, 3, 4, 8, 9, 14, 23, 25입니다.

인덱스01234567
arr3141592-
prefix03489142325

구간 [2, 4]의 합을 구한다고 해봅시다. 반복문은 4 + 1 + 5를 직접 더해 10을 얻고, 덧셈 횟수가 구간 길이만큼 듭니다. 누적합은 인덱스 5까지의 합 14에서 인덱스 2 앞까지의 합 4를 빼서 같은 10을 얻고, 구간이 아무리 길어도 뺄셈 한 번입니다.

누적합으로 구간 합을 뺄셈 한 번으로 바꾸는 과정을 보여주는 다이어그램. 위쪽 패널에는 원본 배열 arr의 일곱 칸(인덱스 0부터 6까지 값 3, 1, 4, 1, 5, 9, 2)과 그 아래 누적합 prefix의 여덟 칸(인덱스 0부터 7까지 값 0, 3, 4, 8, 9, 14, 23, 25)이 나란히 놓여 있음. arr의 인덱스 2, 3, 4 칸(값 4, 1, 5)이 초록으로 칠해지고 그 위에 「구간 2에서 4」라는 괄호 라벨이 있음. prefix의 인덱스 2 칸(값 4)과 인덱스 5 칸(값 14)이 주황으로 칠해져 있음. 오른쪽 주석은 prefix의 0번째는 0으로 시작한다는 것, prefix의 i + 1번째는 prefix의 i번째에 arr의 i번째를 더한 값이라는 식, 색칠된 두 칸을 빼면 구간 2에서 4의 합이 나온다는 것임. 아래쪽에는 두 패널이 나란히 있음. 왼쪽 「반복문 · 직접 더하기」에는 「4 + 1 + 5 = 10」, 「덧셈 3번 · 구간 길이만큼 늘어나 O(N)」, 「질의 십만 번이면 최대 백억 번」이 적혀 있고, 오른쪽 「누적합 · 뺄셈 한 번」에는 prefix의 5번째에서 2번째를 뺀 「14 - 4 = 10」, 「뺄셈 1번 · 구간 길이와 무관해 O(1)」, 「질의 십만 번이면 전처리 N번에 뺄셈 십만 번」이 적혀 있음. 다이어그램 제목은 「구간 합을 뺄셈 한 번으로 바꾸는 방법」, 부제는 「배열 3 1 4 1 5 9 2 · prefix의 i번째는 앞에서부터 i개를 더한 값입니다」이고, 하단에 구간 l에서 r의 합은 항상 prefix의 r + 1번째에서 l번째를 뺀 값이고, prefix의 0번째가 0이라 구간이 0부터 시작해도 같은 식으로 계산한다는 문장이 있음.

// prefix[i] = arr[0] + arr[1] + ... + arr[i-1]
function buildPrefix(arr) {
  const prefix = new Array(arr.length + 1).fill(0);
  for (let i = 0; i < arr.length; i++) {
    prefix[i + 1] = prefix[i] + arr[i];
  }
  return prefix;
}

// 구간 [l, r]의 합을 뺄셈 한 번으로
function rangeSum(prefix, l, r) {
  return prefix[r + 1] - prefix[l];
}

인덱스를 한 칸 밀어 prefix[0]을 0으로 두면 편합니다. 구간이 0부터 시작하는 경우를 따로 처리하지 않아도 됩니다.

전처리에 O(N)을 쓰고, 이후 질의는 뺄셈 한 번이므로 O(1)입니다. 질의가 Q개면 전체가 O(N + Q)로 떨어집니다. 백억이 이십만이 됩니다.

반복문누적합
전처리없음O(N), 한 번 훑기
질의 한 번O(N), 구간 길이만큼 덧셈O(1), 뺄셈 한 번
질의 십만 번 (N이 십만)최대 백억 번십만 + 십만 = 이십만 번

2차원에서도 같은 원리입니다

격자에서 직사각형 영역의 합을 구할 때도 같은 방식이 통합니다. 다만 뺄셈이 한 번으로 끝나지 않습니다.

왼쪽 위 모서리부터 각 지점까지의 합을 저장해두면, 원하는 직사각형은 큰 사각형에서 위쪽과 왼쪽을 빼고 두 번 빠진 부분을 다시 더해 얻습니다.

// sum[i][j] = (0,0)부터 (i-1,j-1)까지의 합
const area =
  sum[r2 + 1][c2 + 1] -
  sum[r1][c2 + 1] -   // 위쪽 제거
  sum[r2 + 1][c1] +   // 왼쪽 제거
  sum[r1][c1];        // 두 번 빠진 겹침 복구

포함 배제(inclusion-exclusion) 라고 부르는 계산입니다. 차원이 늘어도 원리는 같습니다.


갱신이 끼어들면 무너집니다

누적합의 전제는 원본 배열이 바뀌지 않는다는 것입니다.

중간의 값 하나가 바뀌면 그 뒤의 누적합이 전부 어긋납니다. 다시 만들려면 O(N)입니다. 갱신이 자주 일어나면 전처리 비용을 매번 다시 내는 셈이라 이득이 사라집니다.

질의와 갱신이 섞여 들어오는 상황이 실제로는 더 흔합니다. 값을 고치면서 통계를 계속 조회하는 화면이 그렇습니다.


세그먼트 트리는 구간을 나눠 갖습니다

세그먼트 트리(segment tree) 는 배열을 절반씩 쪼개면서 각 노드가 담당 구간의 합을 들고 있는 이진 트리입니다.

루트는 전체 구간을, 그 자식들은 왼쪽 절반과 오른쪽 절반을, 잎은 원소 하나를 맡습니다.

세그먼트 트리 구조와 두 가지 연산을 보여주는 다이어그램. 위쪽은 트리 구조로, 원소 다섯 개짜리 배열 위에 루트 노드가 구간 0에서 4, 합계 15를 담고 있음. 루트 아래 왼쪽 자식은 구간 0에서 2, 합계 6이고 오른쪽 자식은 구간 3에서 4, 합계 9임. 그 아래로 잎 노드까지 두 단계가 더 이어지며 각 노드에 담당 구간과 합계가 표시됨. 왼쪽 아래는 질의 과정으로, 구간 1에서 3을 물었을 때 방문하는 노드 세 개가 강조 색으로 표시되고 각 노드 위에 담당 구간이 작은 글씨로 적혀 있고, 완전히 포함된 구간 1에서 1, 2에서 2, 3에서 3의 노드는 색칠, 걸쳐서 자식으로 내려가는 구간 0에서 4, 0에서 2, 3에서 4, 0에서 1의 노드는 점선 테두리, 겹치지 않는 구간 0에서 0과 4에서 4의 노드는 흐리게 표시됨. 아래에 「2 + 3 + 4 = 9 · 노드 세 개만 봅니다」와 「색칠: 완전히 포함되어 값을 그대로 씀 · 점선: 걸쳐서 자식으로 내려감 · 흐림: 겹치지 않아 무시」라는 범례가 붙음. 오른쪽 아래는 갱신 과정으로, 잎 노드 하나가 바뀔 때 루트까지 올라가는 경로 세 개의 노드가 강조되고 위로 향하는 화살표로 연결됨. 갱신으로 루트 합은 15에서 18로, 오른쪽 자식 합은 9에서 12로 바뀌며 「잎에서 루트까지 세 개만 고칩니다」, 「바뀐 잎의 조상만 다시 계산하면 됩니다」라는 라벨이 붙어 있음. 다이어그램 제목은 「구간을 미리 접어 두면 구간 질의가 O(log N)이 됩니다」, 부제는 「배열 1 2 3 4 5 · 각 노드는 담당 구간과 그 구간의 합을 들고 있습니다」임. 질의 패널 머리글은 「QUERY · 구간 1에서 3의 합」, 갱신 패널 머리글은 「UPDATE · 4번 원소를 5에서 8로」임. 트리 옆 주석은 「잎은 원소 하나, 부모는 두 자식의 합입니다」, 「노드 수는 원소 수의 약 네 배로 잡습니다」, 「구간 합을 매번 다시 더하면 O(N)입니다」, 「미리 접어 두면 갱신도 질의도 O(log N)이 됩니다」임. 하단 문장은 「배열은 갱신이 O(1)이지만 구간 합이 O(N)입니다. 누적 합은 반대입니다. 세그먼트 트리는 둘 다 O(log N)으로 맞춥니다」임.

질의가 들어오면 루트에서 내려가면서 노드마다 세 가지 중 하나를 판정합니다.

  • 완전히 포함: 노드의 담당 구간이 질문 구간 안에 다 들어가면 그 노드의 합을 그대로 쓰고 더 내려가지 않습니다
  • 전혀 안 겹침: 담당 구간이 질문 구간 밖이면 무시합니다
  • 걸침: 일부만 겹치면 양쪽 자식으로 나눠 내려가서 같은 판정을 반복합니다

배열 1, 2, 3, 4, 5에서 구간 [1, 3]의 합을 묻는 경우를 따라가면 이렇습니다. 루트 [0, 4]는 걸치므로 자식 [0, 2]와 [3, 4]로 내려갑니다. [0, 2]도 걸쳐서 [0, 1]과 [2, 2]로 나뉘고, [2, 2]는 완전히 포함되어 값 3을 그대로 씁니다. [0, 1]은 또 걸쳐서 [0, 0]과 [1, 1]로 나뉘는데, [0, 0]은 겹치지 않아 무시하고 [1, 1]은 포함되어 값 2를 씁니다. 오른쪽 [3, 4]는 [3, 3]과 [4, 4]로 나뉘어 [3, 3]의 4를 쓰고 [4, 4]는 무시합니다. 값을 실제로 가져온 노드는 [1, 1], [2, 2], [3, 3] 셋이고 합은 2 + 3 + 4 = 9입니다.

이 가지치기 덕분에 방문하는 노드가 O(log N)개로 제한됩니다.

갱신은 반대 방향입니다. 값이 바뀐 잎에서 루트까지 올라가면서 조상 노드들의 합을 다시 계산합니다. 경로 길이가 트리 높이이므로 역시 O(log N)입니다.

class SegmentTree {
  #n;
  #tree;

  constructor(arr) {
    this.#n = arr.length;
    this.#tree = new Array(this.#n * 2).fill(0);
    // 뒤쪽 절반에 원본을 두고 앞으로 접어 올린다
    for (let i = 0; i < this.#n; i++) this.#tree[this.#n + i] = arr[i];
    for (let i = this.#n - 1; i > 0; i--) {
      this.#tree[i] = this.#tree[i * 2] + this.#tree[i * 2 + 1];
    }
  }

  update(index, value) {
    let i = index + this.#n;
    this.#tree[i] = value;
    while (i > 1) {
      i >>= 1;
      this.#tree[i] = this.#tree[i * 2] + this.#tree[i * 2 + 1];
    }
  }

  // 구간 [l, r] 합
  query(l, r) {
    let result = 0;
    let lo = l + this.#n;
    let hi = r + this.#n + 1;
    while (lo < hi) {
      if (lo & 1) result += this.#tree[lo++];
      if (hi & 1) result += this.#tree[--hi];
      lo >>= 1;
      hi >>= 1;
    }
    return result;
  }
}

합 대신 최솟값이나 최댓값을 넣어도 그대로 작동합니다. 두 자식의 결과를 하나로 합치는 연산이 결합법칙을 만족하면 됩니다.


무엇을 고를 것인가

상황선택전처리질의갱신
질의 한두 번그냥 반복문없음O(N)즉시
질의 많고 갱신 없음누적합O(N)O(1)O(N)
질의와 갱신이 섞임세그먼트 트리O(N)O(log N)O(log N)

세그먼트 트리가 항상 유리한 건 아닙니다. 갱신이 없다면 누적합의 O(1) 질의가 더 빠르고 구현도 짧습니다. 구조가 복잡할수록 좋다는 판단은 여기서 통하지 않습니다.


자료구조 시리즈를 마치며

여기까지가 데이터를 담고 정리하는 쪽입니다. 배열과 해시가 위치를 어떻게 찾는지, 정렬이 무엇을 보장하는지, 트리의 모양이 성능을 어떻게 결정하는지, 그리고 반복되는 질문에 전처리로 답하는 방법을 봤습니다.

담는 방식이 정해지면, 그 안에서 무엇을 어떻게 찾을지를 다음에 다룹니다.


참고 자료

  • Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press.
  • Halim, S., & Halim, F. (2013). Competitive Programming 3. Lulu.

다음 편 예고

ep.00 - 전부 해보는 것과 그러지 않는 것

2부 「이름과 비용: 알고리즘」이 시작됩니다. 모든 경우를 확인하는 방법이 기준선이고, 나머지 모든 알고리즘은 그 기준선을 어떤 근거로 줄였는지에 대한 답입니다.