Fenwick Tree: 필요한 합을 작은 구간 묶음에서 꺼냅니다
여덟 칸의 담당 구간을 먼저 그려 Fenwick Tree를 배웁니다. prefix(7)의 구간 분해와 lowbit, 조회·갱신 방향을 작은 코드로 확인합니다.
먼저 작은 예제로 원리를 익히고, 마지막에 전체 구현을 펼쳐 보세요. 앞쪽 예제는 각각 독립적으로 브라우저 개발자 도구 Console에서 실행합니다. 같은 이름을 다시 선언했다는 오류가 나오면 새로고침 후 해당 예제를 실행하세요. 출력 뒤에 콘솔이 별도로 보여주는 undefined는 마지막 명령의 반환값일 수 있습니다.

1. 여덟 선반 중 앞의 일곱 선반을 더해 봅니다
선반 여덟 개에 물건이 [3, 1, 4, 2, 5, 1, 2, 6]개씩 있습니다. 앞의 일곱 선반에 있는 물건 수를 물으면 3+1+4+2+5+1+2=18입니다. 한 번만 물으면 이렇게 모두 더해도 충분합니다. 그런데 수량이 자주 바뀌고 앞부분 합도 계속 묻는다면 이미 더한 작은 묶음을 재사용할 수 있을까요?
Fenwick Tree는 몇 개 선반의 합을 미리 적어 놓고 필요한 묶음만 더하는 방법입니다. 우선 비트 연산을 모르더라도 아래 묶음 표를 읽을 수 있으면 됩니다. 손으로 셀 때는 선반 번호를 1부터 8까지 쓰겠습니다. JavaScript 배열의 0~7번 인덱스와는 하나씩 차이가 납니다.
| 손으로 세는 선반 번호 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| JavaScript 배열 인덱스 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 물건 수 | 3 | 1 | 4 | 2 | 5 | 1 | 2 | 6 |
2. 모든 구간 대신 이 여덟 묶음만 저장합니다
tree[i]에는 i번째 선반에서 끝나는 묶음의 합을 저장합니다. 어떤 묶음은 한 칸, 어떤 묶음은 두 칸이나 네 칸입니다. 다음 표가 이 예제의 저장 내용 전체입니다. tree[0]은 사용하지 않으므로 값 0을 두어도 합계에 넣지 않습니다.
| 저장 칸 | 담당 선반 | 담당 길이 | 저장할 합 |
|---|---|---|---|
| tree[1] | 1 | 1 | 3 |
| tree[2] | 1~2 | 2 | 3+1 = 4 |
| tree[3] | 3 | 1 | 4 |
| tree[4] | 1~4 | 4 | 3+1+4+2 = 10 |
| tree[5] | 5 | 1 | 5 |
| tree[6] | 5~6 | 2 | 5+1 = 6 |
| tree[7] | 7 | 1 | 2 |
| tree[8] | 1~8 | 8 | 24 |
앞의 일곱 칸을 더할 때는 tree[7]의 7번 하나를 먼저 받습니다. 이제 1~6번이 남으므로 tree[6]의 5~6번을 받습니다. 마지막으로 tree[4]의 1~4번을 받습니다. 서로 겹치지 않는 세 묶음이 1~7번을 빠짐없이 덮습니다. 2+6+10=18입니다.
아래 짧은 코드들은 서로 독립적인 예제입니다. 각 블록 전체를 브라우저 Console에 붙여 넣거나 파일로 저장해 Node.js로 실행하세요. 같은 변수를 다시 선언했다는 콘솔 오류가 나오면 새로고침한 뒤 해당 블록을 실행합니다. 코드 바로 아래 상자가 실제 console.log 출력입니다.
const values = [3, 1, 4, 2, 5, 1, 2, 6];
const block7 = values[6];
const block6 = values[4] + values[5];
const block4 = values[0] + values[1] + values[2] + values[3];
console.log(block7);
console.log(block6);
console.log(block4);
console.log(block7 + block6 + block4);
2
6
10
18
block7은 배열 6번, 즉 일곱 번째 선반 하나입니다. block6은 배열 4·5번의 합이며 다섯·여섯 번째 선반입니다. block4는 배열 0~3번의 합입니다. 마지막 줄에서 세 묶음을 더하면 일곱 칸을 직접 더한 것과 같은 결과를 얻습니다. 이 코드는 묶음을 손으로 고른 실험이며 아직 일반적인 조회 함수는 아닙니다.
묶음이 겹치면 같은 물건을 두 번 세게 됩니다. 예를 들어 tree[4]와 tree[2]를 함께 받아 앞의 네 칸 합이라고 하면 1~2번을 중복해서 셉니다. 필요한 영역 중 아직 받지 않은 앞부분으로만 이동해야 합니다.
3. 7 → 6 → 4를 계산하는 작은 규칙
각 저장 칸의 담당 길이를 구하는 이름이 lowbit입니다. 이 예제에서는 “번호를 나눌 수 있는 가장 큰 2의 거듭제곱”으로 이해해도 됩니다. 6은 2로 나누어지지만 4로는 나누어지지 않으므로 길이가 2입니다. 7은 홀수라 길이 1, 4는 길이 4입니다.
| 번호 | 2진수 | 가장 오른쪽 1의 자리값 | 담당 길이 |
|---|---|---|---|
| 7 | 0111 | 0001 = 1 | 1 |
| 6 | 0110 | 0010 = 2 | 2 |
| 4 | 0100 | 0100 = 4 | 4 |
| 8 | 1000 | 1000 = 8 | 8 |
그래서 조회할 때 “현재 번호에서 방금 받은 길이만큼 뺀다”는 규칙을 쓰면 됩니다. 7−1=6, 6−2=4, 4−4=0입니다. 0이 되면 남은 앞부분이 없으므로 끝냅니다. 이 규칙에 맞춰 담당 구간을 정했기 때문에 이전 묶음과 겹치지 않고 바로 이어집니다.
코드에서는 i & -i가 그 길이를 계산합니다. &는 두 숫자의 비트가 모두 1인 자리만 남기는 연산입니다. 32비트 정수에서 -i와 함께 계산하면 오른쪽 끝 1만 남습니다. 음수 표현의 전체 원리를 지금 외우기보다, 아래 반복문에서 이 값이 “이번 묶음의 길이”라는 역할을 확인하세요.
const tree = [0, 3, 4, 4, 10, 5, 6, 2, 24];
let i = 7;
let sum = 0;
while (i > 0) {
console.log(i);
sum += tree[i];
i -= i & -i;
}
console.log(sum);
7
6
4
18
tree는 위 표를 그대로 적은 배열입니다. i=7에서 시작하고, sum += tree[i]로 현재 묶음을 받습니다. 그 뒤 i -= i & -i로 받은 길이만큼 앞쪽으로 갑니다. 순서를 바꾸어 먼저 i를 줄이면 7번 묶음을 빠뜨립니다. while (i > 0)은 받을 묶음이 남아 있을 때만 반복한다는 뜻입니다.
| 반복 | 받는 값 | 지금까지 합 | 다음 i |
|---|---|---|---|
| i=7 | tree[7]=2 | 2 | 6 |
| i=6 | tree[6]=6 | 8 | 4 |
| i=4 | tree[4]=10 | 18 | 0, 종료 |
4. 한 선반이 바뀌면 어느 묶음을 고칠까요?
다섯 번째 선반에 물건이 2개 더 들어왔습니다. 기존 수량 5가 7이 됩니다. 담당 표에서 5번 선반을 포함한 묶음만 찾아보세요. tree[5], tree[6], tree[8]입니다. tree[4]에는 다섯 번째 선반이 없으므로 고치지 않습니다.
| 저장 칸 | 변경 전 | 변경 후 | 고치는 이유 |
|---|---|---|---|
| tree[5] | 5 | 7 | 5번 선반 하나를 포함 |
| tree[6] | 6 | 8 | 5~6번 선반에 5번 포함 |
| tree[8] | 24 | 26 | 1~8번 선반에 5번 포함 |
조회는 받은 구간을 제외하려고 번호를 줄였습니다. 갱신은 바뀐 선반을 포함하는 더 큰 묶음으로 가려고 번호를 늘립니다. 5+1=6, 6+2=8, 8+8=16입니다. 저장 범위는 8까지라 16에 도착하면 멈춥니다. 서로 반대 방향인 이유가 단순한 암기 규칙은 아닙니다.
const tree = [0, 3, 4, 4, 10, 5, 6, 2, 24];
let i = 5;
while (i <= 8) {
console.log(i);
tree[i] += 2;
i += i & -i;
}
console.log(tree[5]);
console.log(tree[6]);
console.log(tree[8]);
5
6
8
7
8
26
i=5는 손으로 세는 다섯 번째 선반입니다. tree[i] += 2로 수량 증가분만 더하고 i += i & -i로 다음 포함 묶음으로 갑니다. 7을 더하면 “새 수량 7로 교체”가 아니라 기존 수량에 7을 추가한 결과가 되므로 틀립니다. 전체 구현의 add도 새 값이 아니라 증가량을 받습니다.
5. 앞부분 합을 두 번 구하면 중간 구간도 구할 수 있습니다
전체 구현의 prefix(7)은 앞의 일곱 원소, 즉 JavaScript 인덱스 0~6의 합입니다. 7번 인덱스의 원소까지 포함한다는 뜻이 아닙니다. sum(4,7)은 인덱스 4,5,6의 합이며 prefix(7)에서 prefix(4)를 뺍니다. 앞의 네 원소를 빼면 원하는 뒤의 세 원소만 남습니다.
갱신 전의 여덟 선반에서 sum(4,7)은 얼마일까요? 다섯 번째 선반에 2를 더한 뒤에는 어떻게 바뀔까요?
답과 이유 확인하기
처음에는 5+1+2=8입니다. 앞부분 합으로 쓰면 18−10=8입니다. 갱신 후에는 7+1+2=10이고 20−10=10입니다. 오른쪽 끝 7은 포함하지 않으므로 배열 7번의 값 6은 더하지 않습니다.
sum(4,4)처럼 양쪽 경계가 같으면 원소가 하나도 없는 구간이라 0입니다. 내부 선반 번호와 외부 배열 인덱스를 섞지 않는 것이 비트 식을 외우는 것보다 먼저입니다.
6. 전체 구현을 읽을 때는 이 세 곳을 연결하세요
constructor는 tree를 0으로 채웁니다. 앞의 짧은 예제는 표의 완성 값을 직접 적었지만 전체 버전은 각 원소에 add(index,value)를 호출해 채웁니다. add는 외부 인덱스에 1을 더해 내부 선반 번호로 바꾸고, prefix는 “앞에서 몇 개”를 그대로 시작 번호로 사용합니다.
add의 반복문은 방금 본 5 → 6 → 8 방식, prefix의 반복문은 7 → 6 → 4 방식입니다. sum은 두 prefix의 차이를 반환합니다. 이 세 역할을 찾은 다음 범위 검사와 숫자 한계를 읽으세요. i=0에서 갱신하면 더할 길이도 0이라 이동하지 않으므로 add가 index+1에서 시작하는 이유도 이제 확인할 수 있습니다.
짧은 예제는 여덟 칸의 작은 정수에만 적용한 관찰용 코드입니다. 전체 구현은 크기와 인덱스를 검사하고 음수 증가량도 지원하지만, 정수 합이 안전하게 표현되는 범위를 지켜야 합니다. 무제한 크기의 비트 인덱스, 중간 원소 삽입, 구간 최솟값에는 이 코드를 그대로 사용하지 않습니다. 자세한 비용과 한계는 아래 심화 설명에서 이어집니다.
관련 코딩테스트 문제로 이어서 연습합니다
이 자료구조의 블로그 연습 문제와 해설에서 배운 동작을 적용해 보세요. 먼저 작은 예시를 직접 처리한 뒤 입력 전체를 다루는 코드로 확장하면 됩니다. 아래 전체 구현은 연결된 문제의 기존 메서드와 반환 형식을 유지합니다.
전체 구현 · 상세 설명 · 예외와 성능 분석 펼치기
여기부터는 필요한 기능을 골라 읽는 참고 영역입니다. 새로운 파일로 전체 코드를 실행할 때는 아래 Node.js 실행 안내를 따르세요. 앞쪽의 작은 브라우저 실습과 전체 파일을 한 콘솔에 이어 붙이지 마세요. 기능별 입력 조건과 반환 형식은 아래 설명을 기준으로 합니다.
문제 상황과 API 계약
접두사 합 배열은 구간 합을 O(1)에 답하지만 원소 하나가 바뀌면 뒤쪽 합계도 모두 수정해야 합니다. 반대로 원래 배열만 두면 갱신은 빠르고 질의가 느립니다. Fenwick Tree는 길이가 2의 거듭제곱인 일부 구간 합을 저장해 두 연산을 O(log n)으로 맞춥니다. 단일 원소 증가와 합계에 집중할 때 세그먼트 트리보다 짧고 저장 공간의 상수도 작습니다.
외부 API add(index,delta)는 현재 값에 delta를 더합니다. 새 값으로 교체하는 API가 아닙니다. prefix(end)는 [0,end), sum(left,right)는 [left,right)의 합입니다. right를 포함하지 않으므로 전체 합은 sum(0,n), 빈 구간은 sum(i,i)입니다. 초기 배열은 모두 0이며 별도 값이 있으면 각 위치에 add로 넣습니다.
교육용 n 제한은 0~1,000,000입니다. Number의 비트 연산은 32비트 부호 정수로 변환하므로 무제한 큰 인덱스에 i & -i를 적용하면 안 됩니다. 이 제한에서는 갱신 경로의 다음 값도 양의 32비트 범위에 남습니다. tree의 값은 비트 연산하지 않으며 일반 Number 덧셈으로 보관합니다.
정확성을 지키는 불변식
내부 인덱스 i는 외부 index+1입니다. lowbit(i)=i & -i는 가장 낮은 1비트의 값입니다. tree[i]는 내부 [i−lowbit(i)+1,i]의 합을 저장합니다. i=6이면 lowbit=2이므로 내부 5,6번 두 원소의 합을 보관합니다. 같은 구간을 무작정 겹쳐 더하는 것이 아니라 질의 때 서로 겹치지 않게 선택합니다.
prefix(end)는 내부 i=end에서 시작합니다. tree[i]를 더한 뒤 i−=lowbit(i)로 방금 더한 블록을 건너뛰면 아직 더하지 않은 앞부분만 남습니다. 끝의 1비트를 하나씩 없애므로 O(log n)개 블록 뒤 0이 됩니다.
add는 바뀐 원소를 포함하는 저장 블록을 향해 i+=lowbit(i)로 올라갑니다. 외부 index를 내부로 바꾸지 않아 i=0에서 시작하면 lowbit(0)=0이어서 루프가 멈추지 않습니다. 이 코드가 index+1을 사용하는 핵심 이유입니다. 구간 합은 prefix(right)−prefix(left)로 앞부분을 상쇄합니다.
전체 구현과 실행
Node.js 24의 CommonJS 환경을 기준으로 합니다. 아래 전체 코드를 fenwick.cjs로 저장하고 파일이 있는 폴더에서 node fenwick.cjs를 실행하세요. 외부 패키지는 필요하지 않습니다. module.exports는 다른 파일에서 가져올 때 사용하며 require.main 조건 안의 호출은 직접 실행할 때만 작동합니다.
class Fenwick {
constructor(n) {
if (!Number.isInteger(n) || n < 0 || n > 1_000_000) {
throw new RangeError('n은 0~1,000,000 정수여야 합니다.');
}
this.n = n;
this.tree = Array(n + 1).fill(0);
}
boundary(i) {
if (!Number.isInteger(i) || i < 0 || i > this.n) {
throw new RangeError('경계는 0~n 정수여야 합니다.');
}
}
add(index, delta) {
this.boundary(index);
if (index === this.n) throw new RangeError('갱신 인덱스는 n보다 작아야 합니다.');
if (!Number.isSafeInteger(delta)) throw new TypeError('증가량은 안전한 정수여야 합니다.');
for (let i = index + 1; i <= this.n; i += i & -i) {
this.tree[i] += delta;
}
}
prefix(end) {
this.boundary(end);
let sum = 0;
for (let i = end; i > 0; i -= i & -i) sum += this.tree[i];
return sum;
}
sum(left, right) {
this.boundary(left);
this.boundary(right);
if (left > right) throw new RangeError('left는 right 이하여야 합니다.');
return this.prefix(right) - this.prefix(left);
}
}
module.exports = { Fenwick };
if (require.main === module) {
const f = new Fenwick(4);
[3, 1, 4, 2].forEach((value, index) => f.add(index, value));
console.log(f.sum(1, 4));
f.add(2, -3);
console.log(f.sum(1, 4));
}
직접 실행 출력:
7
4
boundary는 누적 합의 끝점에 n을 허용합니다. add는 같은 검사를 재사용한 뒤 index===n만 따로 거부합니다. 조회 끝점과 원소 인덱스의 허용 범위가 다르기 때문입니다. n=0에서는 sum(0,0)만 정상이며 어떠한 add도 할 수 없습니다.
prefix의 반복문이 선택하는 블록은 길이가 서로 다를 수 있습니다. 예를 들어 end=7이면 7→6→4→0으로 이동해 길이 1,2,4 블록을 더합니다. 데이터 값이 음수라도 이동 경로는 인덱스에 의해 결정되므로 합계 질의는 그대로 동작합니다.
add에서 delta가 안전한 정수인지는 검사하지만 모든 누적값의 오버플로를 다시 계산하지는 않습니다. 호출자는 언제나 원소들의 절댓값 합과 업데이트 뒤 모든 중간 합이 Number.MAX_SAFE_INTEGER 이하라는 계약을 지켜야 합니다. 정수 범위를 벗어나는 장부에는 별도의 BigInt 합계 버전이 필요합니다.
상태 변화 따라가기
| 상태 / 작업 | 내부 tree[1..4] | 조회한 블록 | 결과 |
|---|---|---|---|
| [3,1,4,2] 저장 | [3,4,4,10] | — | 각 칸은 원본 값이 아니라 블록 합 |
| prefix(4) | 같음 | tree[4] | 10 |
| prefix(1) | 같음 | tree[1] | 3 |
| sum(1,4) | 같음 | prefix(4)−prefix(1) | 7 |
| add(2,-3) | [3,4,1,7] | 내부 3→4 갱신 | 외부 원본 [3,1,1,2] |
| sum(1,4) | [3,4,1,7] | 7−3 | 4 |
복잡도와 적용하지 말아야 할 경우
| 작업 | 시간 | 설명 |
|---|---|---|
| 0으로 생성 | 최악 O(n) | n+1칸 할당 |
| n개 값을 add로 초기화 | 최악 O(n log n) | 이 예제 방식; 선형 빌드는 미구현 |
| add / prefix / sum | 최악 O(log(n+1)) | 비트 위치 수만큼 이동; 상환·평균 가정 불필요 |
| 저장 / 한 작업 공간 | O(n+1) / O(1) | 원본 배열을 별도 복제하지 않음 |
대입 갱신이 필요하면 원래 값을 별도로 보관해 delta=newValue−oldValue를 계산해야 합니다. 현재 값 대신 newValue를 그대로 add하면 중복 누적됩니다. 반대로 단일 증가만 필요한데 대입 배열까지 유지하면 불필요한 상태가 생깁니다.
구간 최솟값은 prefix(right)−prefix(left)처럼 앞부분을 취소할 수 없으므로 이 합계 코드를 단순히 Math.min으로 바꿔서는 안 됩니다. 결합 연산이 달라지거나 단일 대입·범용 구간 질의가 필요하면 세그먼트 트리를 검토하세요.
원소를 중간에 삽입해 인덱스가 밀리는 목록이나 10억 단위의 희소 좌표에는 이 배열을 그대로 할당하지 마세요. 좌표가 사전에 알려졌다면 좌표 압축을 먼저 할 수 있습니다. 음수가 포함된 합계는 지원하지만 누적합이 단조임을 이용하는 k번째 원소 탐색은 이 글의 범위가 아닙니다.
연습으로 확인하기
처음 0인 선반 장부에 보정량이 들어올 때마다 특정 구간의 순합을 반환해 보세요. 음수 보정과 빈 구간을 넣으면 인덱스 계약을 제대로 이해했는지 드러납니다.
AtCoder B – Fenwick Tree — 단일 증가와 구간 합을 다루는 공식 연습입니다. 외부 문제의 입력 규모와 숫자 범위를 별도로 확인하고 구현의 Number 계약과 비교하세요.
공식 자료
공식 자료 확인일: 2026년 9월 10일. 구현은 이 글의 JavaScript 계약에 맞춰 독립적으로 작성했습니다. 다른 언어 라이브러리의 API와 숫자 범위가 그대로 적용되는 것은 아닙니다.
직접 실습: 연산 비용으로 구조를 설명합니다
실습 주제: JavaScript Fenwick Tree: lowbit로 구간 합과 단일 증가 갱신 구현
- 본문 구현에서 저장되는 값과 연결 관계를 그림으로 적습니다.
- 조회·삽입·삭제 중 이 구조가 가장 자주 수행할 연산을 고릅니다.
- 연산 전후에도 유지되어야 하는 규칙을 한 문장으로 적습니다.
- 배열이나 Map 같은 다른 구조로 바꿨을 때 시간·공간 비용을 비교합니다.
풀이 기준과 확인 결과
메서드 이름만 외우지 말고 한 번의 연산에서 어떤 값과 연결이 바뀌는지 추적하세요. 빈 구조, 원소 한 개, 중복값, 연속 삽입·삭제를 실행했을 때 본문이 설명한 불변식이 유지되면 성공입니다.
테스트 체크리스트
- 빈 구조에 대한 조회·삭제 처리
- 첫 원소와 마지막 원소 변경
- 중복값 또는 동일 우선순위 처리
- 입력 크기가 커졌을 때 예상 복잡도 유지
이 글이 도움이 되었나요?
자료구조 학습 순서
필수 18개 · 전체 18개
읽음 기록 관리
전체 과정 목차 (18개)
- 필수 학습 · 자료구조 선택 가이드: 연산 비용으로 배열·스택·큐·Set 고르기
- 필수 학습 · JavaScript 배열: 인덱스 조회와 삽입·삭제 비용
- 필수 학습 · JavaScript Map·Set: 값 조회와 중복 제거 실습
- 필수 학습 · 자료구조 스택 쉽게 이해하기: push pop으로 문제 풀이 감 잡기
- 필수 학습 · 큐와 FIFO: head 인덱스로 JavaScript 대기열 만들기
- 필수 학습 · 단방향 연결 리스트: head·tail 삽입과 삭제
- 필수 학습 · JavaScript 원형 덱 구현: 양끝 삽입·삭제와 고정 용량 버퍼
- 필수 학습 · JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화
- 필수 학습 · 트리 자료구조 차이: 이진 트리 BST MST 구분하기
- 필수 학습 · JavaScript 이진 탐색 트리 구현: 중복 키와 세 가지 삭제 처리
- 필수 학습 · JavaScript 최소 힙 구현: 우선순위 큐의 push·pop과 비교 함수
- 필수 학습 · JavaScript 그래프 구현: 인접 리스트·인접 행렬 비교와 BFS
- 필수 학습 · JavaScript Union-Find: 경로 압축과 크기 합치기로 연결 상태 관리하기
- 필수 학습 · JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현
- 필수 학습 · JavaScript Fenwick Tree: lowbit로 구간 합과 단일 증가 갱신 구현 현재 글
- 필수 학습 · JavaScript 반복형 세그먼트 트리: 구간 합·단일 대입·결합 순서
- 필수 학습 · JavaScript LRU 캐시: Map과 이중 연결 리스트의 불변식
- 필수 학습 · JavaScript AVL 트리: 높이 불변식과 LL·RR·LR·RL 삽입 회전
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.