핵심 답부터 보기
모든 원소가 양수라면 오른쪽 포인터로 합을 늘리고, 합이 M보다 클 때만 왼쪽 포인터를 옮깁니다. 조정이 끝난 뒤 합이 정확히 M일 때 한 번 세면 전체를 O(N)에 구할 수 있습니다. M=5, [1, 3, 1, 2, 3]의 정답은 [1, 3, 1]과 [2, 3], 총 2개입니다. 0이나 음수가 들어오면 이 단조성이 깨지므로 누적합 빈도 Map으로 전환해야 합니다.
문제와 양수 조건

N개의 자연수로 이루어진 배열에서 합이 정확히 M인 연속 부분수열의 개수를 구합니다.
제약
1 ≤ N ≤ 100,000
1 ≤ M ≤ 100,000,000
각 원소는 1 이상 1,000 이하인 자연수
예시
N = 5, M = 5
배열 = 1 3 1 2 3
정답 구간
[1, 3, 1]
[2, 3]
출력
2
이 문제에서 가장 중요한 조건은 원소가 모두 양수라는 점입니다. 오른쪽에 원소를 더하면 합은 반드시 커지고, 왼쪽 원소를 빼면 합은 반드시 작아집니다. 이 단조성 덕분에 이미 지나온 시작점을 다시 확인하지 않아도 됩니다.
연속 부분수열은 원소를 건너뛰지 않습니다. 시작 인덱스와 끝 인덱스를 고르면 그 사이 원소를 모두 포함한 하나의 구간이 됩니다. 부분집합이나 일반 부분수열 문제와 구분해야 합니다.
O(N²) 완전탐색부터 확인하기
function countByBruteForce(target, numbers) {
let count = 0;
for (let start = 0; start < numbers.length; start++) {
let sum = 0;
for (let end = start; end < numbers.length; end++) {
sum += numbers[end];
if (sum === target) count++;
if (sum > target) break; // 양수 배열에서만 가능한 조기 종료
}
}
return count;
}
완전탐색은 모든 인덱스를 시작점으로 잡고 오른쪽 끝을 하나씩 늘립니다. 구현이 직관적이고 작은 입력의 정답을 검증하는 기준 함수로 유용하지만, 최악에는 N개의 시작점마다 N개에 가까운 원소를 확인하므로 O(N²)입니다. N이 100,000이면 제출 풀이로 사용하기 어렵습니다.
처음 합을 0으로 두고 현재 원소부터 더하면 [M]처럼 단일 원소 하나가 답인 경우도 빠뜨리지 않습니다. 합이 M을 넘은 뒤 조기 종료하는 규칙은 뒤에 음수가 없다는 조건에서만 안전합니다.
O(N) 투 포인터 정확합 풀이

function countPositiveSubarraysSum(target, numbers) {
let count = 0;
let left = 0;
let sum = 0;
for (let right = 0; right < numbers.length; right++) {
sum += numbers[right];
while (sum > target) {
sum -= numbers[left++];
}
if (sum === target) count++;
}
return count;
}
console.log(countPositiveSubarraysSum(5, [1, 3, 1, 2, 3])); // 2
오른쪽 포인터는 새 원소를 창에 넣습니다. 합이 목표보다 커지면 왼쪽 원소를 하나씩 빼서 다시 M 이하로 만듭니다. 조정이 끝난 시점의 합이 M이면 현재 right에서 끝나는 정답 구간을 하나 찾은 것입니다.
모든 원소가 양수이므로 같은 right에 대해 합이 M인 시작점은 최대 하나입니다. 왼쪽을 더 옮기면 합은 반드시 작아지고, 덜 옮기면 M보다 큰 상태이기 때문입니다. 그래서 sum === target일 때 한 번만 증가시키면 됩니다.
M=5 예제 이동 추적표
| right | 추가한 값 | M 초과 조정 | 조정 후 left·sum | 누적 정답 |
|---|---|---|---|---|
| 0 | 1 | 없음 | left=0, sum=1 | 0 |
| 1 | 3 | 없음 | left=0, sum=4 | 0 |
| 2 | 1 | 없음 | left=0, sum=5 | 1: [1, 3, 1] |
| 3 | 2 | 1, 3을 차례로 제거 | left=2, sum=3 | 1 |
| 4 | 3 | 1 제거 | left=3, sum=5 | 2: [2, 3] |
이 추적에서 정답은 두 구간뿐입니다. 예시 출력은 2여야 하며, 10은 정확합 문제의 결과가 아닙니다.
왜 정확하고 O(N)인가
정확성
right가 이동할 때 합은 커집니다. 합이 M보다 큰 동안 left를 옮기면 합은 엄격하게 감소합니다. 반복이 끝나면 현재 합은 M 이하이고, 양수 조건 때문에 방금 지나친 어떤 left도 M인 구간이 될 수 없습니다. 현재 합이 M이면 그 구간 하나가 정답이고, M보다 작으면 현재 right에서 끝나는 정답 구간은 없습니다.
시간과 공간
right는 0부터 N-1까지 한 번만 이동합니다. left도 뒤로 가지 않고 최대 N번 이동합니다. 각 원소는 창에 한 번 들어오고 최대 한 번 빠지므로 전체 연산은 N에 비례해 O(N)입니다. 별도 자료구조를 만들지 않으므로 추가 공간은 O(1)입니다.
경계 테스트로 구현 검증하기
| M | 배열 | 기대값 | 확인 목적 |
|---|---|---|---|
| 5 | [1, 3, 1, 2, 3] | 2 | 본문 오류 회귀 테스트 |
| 6 | [1, 2, 1, 3, 1, 1, 1, 2] | 3 | 여러 길이의 정답 구간 |
| 3 | [1, 1, 1, 1] | 2 | 겹치는 구간 |
| 10 | [1, 2, 3] | 0 | 정답 없음 |
| 5 | [5] | 1 | 단일 원소 |
const assert = require("node:assert/strict");
const positiveCases = [
{ target: 5, numbers: [1, 3, 1, 2, 3], expected: 2 },
{ target: 6, numbers: [1, 2, 1, 3, 1, 1, 1, 2], expected: 3 },
{ target: 3, numbers: [1, 1, 1, 1], expected: 2 },
{ target: 10, numbers: [1, 2, 3], expected: 0 },
{ target: 5, numbers: [5], expected: 1 },
];
for (const testCase of positiveCases) {
assert.equal(
countPositiveSubarraysSum(testCase.target, testCase.numbers),
testCase.expected,
);
}
검증일은 2026년 7월 19일이며 Node.js v24.15.0에서 위 양수 케이스 5개를 실행했습니다. 완전탐색 함수의 결과와도 모두 대조했습니다.
0·음수가 있으면 누적합 빈도 Map
0이나 음수가 들어오면 창을 늘렸을 때 합이 항상 커지지 않습니다. 또한 0 때문에 같은 right에서 합이 M인 시작점이 여러 개일 수도 있습니다. 이때 투 포인터 한 번 카운트 방식은 정답을 놓칠 수 있으므로 누적합의 등장 횟수를 저장합니다.
function countSubarraysSum(target, numbers) {
let count = 0;
let prefix = 0;
const frequency = new Map([[0, 1]]);
for (const value of numbers) {
prefix += value;
count += frequency.get(prefix - target) ?? 0;
frequency.set(prefix, (frequency.get(prefix) ?? 0) + 1);
}
return count;
}
console.log(countSubarraysSum(1, [1, -1, 1])); // 3
console.log(countSubarraysSum(0, [0, 0])); // 3
현재 누적합이 prefix라면 이전 누적합이 prefix – target인 위치마다 그 사이 구간의 합이 target입니다. 같은 누적합이 여러 번 나올 수 있으므로 존재 여부가 아니라 빈도를 더합니다. 시작점이 0인 구간도 세기 위해 Map을 [[0, 1]]로 초기화합니다.
이 방법은 0과 음수를 포함해도 평균 O(N)에 동작하며, 서로 다른 누적합을 저장하므로 공간은 O(N)입니다. JavaScript Map API는 MDN Map 문서에서 확인할 수 있습니다.
const generalCases = [
{ target: 1, numbers: [1, -1, 1], expected: 3 },
{ target: 0, numbers: [0, 0], expected: 3 },
{ target: 3, numbers: [1, 2, 3, -3, 3], expected: 5 },
];
for (const testCase of generalCases) {
assert.equal(
countSubarraysSum(testCase.target, testCase.numbers),
testCase.expected,
);
}
합이 정확히 M과 M 이하인 문제는 다르다
양수 배열에서 합이 M 이하인 모든 구간을 세는 문제는 합을 M 이하로 줄인 뒤, 현재 right에서 끝나는 가능한 시작점 수인 right - left + 1을 누적할 수 있습니다. 그러나 이 수에는 합이 M보다 작은 구간도 들어 있습니다.
따라서 합이 정확히 M인 문제에는 그 개수 공식을 사용하지 않습니다. 이 글의 양수 풀이처럼 sum === target인 창만 한 번 세거나, 0·음수까지 허용되면 누적합 Map을 사용해야 합니다.
FAQ
Q. while 조건은 sum > M인가요, sum >= M인가요?
이 글의 정확합 구현은 sum > M인 동안만 줄인 뒤 sum === M을 검사합니다. 합이 M인 창을 검사하기 전에 제거하지 않도록 실행 순서를 함께 봐야 합니다.
Q. 양수 배열에서 같은 right로 끝나는 정답이 왜 하나뿐인가요?
왼쪽 원소를 하나 뺄 때마다 합이 반드시 작아집니다. 따라서 특정 right에서 합이 M인 시작점이 하나 나오면 그보다 오른쪽 시작점의 합은 모두 M보다 작습니다.
Q. 배열에 0만 있어도 투 포인터를 그대로 쓰면 안 되나요?
0을 빼도 합이 변하지 않아 같은 right에 여러 정답 시작점이 생길 수 있습니다. 정확한 개수를 세려면 누적합 빈도 Map이 안전합니다.
Q. 구간 자체도 반환하려면 어떻게 하나요?
양수 풀이에서 합이 target일 때 [left, right]를 저장하면 됩니다. 다만 문제에서 개수만 요구하면 구간을 저장하지 않아야 추가 공간 O(1)을 유지할 수 있습니다.
함께 풀면 좋은 문제
- 정렬된 두 배열을 합치는 투 포인터 문제: 포인터가 뒤로 가지 않는 공통 원리를 비교합니다.
- 고정 길이 최대 매출 슬라이딩 윈도우: 창의 길이가 고정된 유형과 현재 가변 길이 유형을 구분합니다.
검증 기록: 2026-07-19, Node.js v24.15.0. 양수 5개와 0·음수 포함 4개 사례를 완전탐색 결과와 대조했습니다.
풀이 전에 확인할 순서
- 입력값과 출력값을 한 문장으로 다시 적습니다.
- 반복할 대상과 비교·저장할 값을 정합니다.
- 필요한 자료구조와 시간복잡도를 예상합니다.
- 코드를 보기 전에 손으로 작은 예제를 계산합니다.
힌트: JavaScript 투 포인터: 합이 M인 연속 부분수열 개수
정답 코드를 바로 따라 쓰기보다, 본문에서 값이 갱신되는 조건과 반복 범위를 먼저 찾으세요. 반복 한 번마다 반드시 유지되어야 하는 값이 무엇인지 적으면 풀이의 중심 변수를 고르기 쉽습니다.
테스트 확인
- 가능한 가장 작은 입력
- 같은 값이나 문자가 반복되는 입력
- 정답이 처음 또는 마지막 위치에서 결정되는 입력
- 입력 제한에 가까운 경우의 실행 시간
확인 결과: 본문의 예제뿐 아니라 위 경계 사례에서도 예상값과 실제 출력이 같아야 풀이가 완료됩니다.
이 글이 도움이 되었나요?
코딩테스트 JavaScript 학습 순서
필수 49개 · 전체 49개
읽음 기록 관리
전체 과정 목차 (49개)
- 필수 길잡이 · 코딩테스트 JS 자료구조 로드맵: 배열, 해시, 스택, 투 포인터 순서
- 필수 학습 · 세 수 중 최솟값 JavaScript 조건문 풀이 정리
- 필수 학습 · 삼각형 판별하기 JavaScript 풀이
- 필수 학습 · 연필 개수 JavaScript 풀이
- 필수 학습 · 1부터 N까지 합 출력하기 JavaScript 풀이
- 필수 학습 · 최솟값 구하기 JavaScript 풀이|배열 순회와 비교 갱신 원리
- 필수 학습 · 홀수 JavaScript 풀이: 조건 판별과 결과 처리 정리
- 필수 학습 · 10부제 JavaScript 풀이: 끝자리 비교로 위반 차량 수 세기
- 필수 학습 · A를 #으로 JavaScript 풀이: 문자열 순회와 치환
- 필수 학습 · 문자 찾기 JavaScript 풀이: 문자열 순회로 개수 세기
- 필수 학습 · 대문자 찾기 JavaScript 풀이
- 필수 학습 · 대문자로 통일 JavaScript 풀이
- 필수 학습 · 대소문자 변환 JavaScript 풀이
- 필수 학습 · 일곱 난쟁이 JavaScript 풀이: 두 명을 제외하는 완전탐색
- 필수 학습 · 코딩테스트 JS Map 풀이: 학급 회장 득표수 세기
- 필수 학습 · 코딩테스트 JS 스택 풀이: 올바른 괄호 검증하기
- 필수 학습 · 코딩테스트 JS 스택 풀이: 괄호문자 제거하기
- 필수 학습 · 코딩테스트 JS 스택 풀이: 크레인 인형뽑기 처리법
- 필수 학습 · 코딩테스트 JS 스택 풀이: 후위식 연산 계산하기
- 필수 학습 · 코딩테스트 JS 스택 풀이: 쇠막대기 레이저 절단 개수 세기
- 필수 학습 · 코딩테스트 JS 투 포인터 풀이: 두 정렬 배열 합치기
- 필수 학습 · 코딩테스트 JS 투 포인터 풀이: 공통 원소 추출하기
- 필수 학습 · 코딩테스트 JS 슬라이딩 윈도우 풀이: 최대 매출 구간 합 계산하기
- 필수 학습 · JavaScript 투 포인터: 합이 M인 연속 부분수열 개수 현재 글
- 필수 학습 · 코딩테스트 JS 해시 풀이: 모든 아나그램 찾기
- 필수 학습 · 가장 긴 문자열 JavaScript 풀이
- 필수 학습 · 가운데 문자 출력 JavaScript 풀이
- 필수 학습 · 중복문자제거 JavaScript 풀이
- 필수 학습 · 코딩테스트 JS 고급: 최소 힙으로 다익스트라 최단 경로 구하기
- 필수 학습 · 코딩테스트 JS Union-Find: 연결 성분 수와 크기 구하기
- 필수 학습 · 코딩테스트 JS Trie: 접두사에 맞는 단어 수 세기
- 필수 학습 · 코딩테스트 JS Fenwick Tree: 값 갱신과 구간 합 처리
- 필수 학습 · 코딩테스트 JS 세그먼트 트리: 단일 대입과 구간 합
- 필수 학습 · 코딩테스트 JS LRU 캐시: 지도 타일 재사용 기록
- 필수 학습 · 코딩테스트 JS AVL 트리: 기준 이상 최솟값 찾기
- 필수 학습 · 코딩테스트 JS 큐: 상담 창구 대기열 명령 처리
- 필수 학습 · 코딩테스트 JS 연결 리스트: 재생 대기 목록 관리
- 필수 학습 · JavaScript 원형 덱 연습: 최근 기록 창과 되돌리기
- 필수 학습 · JavaScript 해시 테이블 연습: 정규화 문자열 빈도와 등장 순서
- 필수 학습 · JavaScript 트리 순회 연습: 깊이별 노드 묶기
- 필수 학습 · JavaScript BST 연습: 닫힌 구간의 중복 키 보고서
- 필수 학습 · JavaScript 최소 힙 연습: 동률 순서를 지키는 작업 스케줄러
- 필수 학습 · JavaScript 그래프 연습: 연결 구역 크기를 작은 순서로 출력하기
- 필수 학습 · 중복단어제거 JavaScript 풀이
- 필수 학습 · TypeScript 이진 탐색 연습: 숫자 카드 존재 여부 확인
- 필수 학습 · 큰 수 출력하기 JavaScript 풀이
- 필수 학습 · 보이는 학생 JavaScript 풀이
- 필수 학습 · 가위바위보 JavaScript 풀이
- 필수 학습 · 점수계산 JavaScript 풀이
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.