- 이 글은 Part 1 — 이분 탐색 심화에서 다룬 두 번째 예제 문제 棒の出荷(제5회 PAST · M문제)의 코드를 단계별로 분해하여 설명하는 보충 자료입니다.
- 본문에서는 완성된 풀이를 한꺼번에 제시했지만, 여기서는 "왜 이런 접근을 하는가?"에 초점을 맞춰 O(N²) 풀이에서 O(N) 풀이까지 한 단계씩 최적화해 나갑니다.
목차
| Step | 주제 | 핵심 질문 |
| 0 | 문제 이해하기 | 무엇을 구하는 문제인가? |
| 1 | 완전 탐색의 한계 | 직접 풀면 왜 시간 초과인가? |
| 2 | 판정 문제로 변환하기 | 어떤 질문으로 바꿀 수 있는가? |
| 3 | 절단 범위 분석 — 누적합과 [l, r] | 다음 절단 위치를 어떻게 구하는가? |
| 4 | 판정 함수 v1 — O(N²) boolean 배열 | 가장 단순한 구현은? |
| 5 | 판정 함수 v2 — O(N²) 투 포인터 최적화 | l, r의 단조성을 어떻게 활용하는가? |
| 6 | 판정 함수 v3 — O(N) いもす法 | 구간 갱신을 O(1)로 만드는 방법은? |
| 7 | 이분 탐색으로 최적값 찾기 | 전체를 어떻게 조합하는가? |
| 8 | 계산량 분석 & 정리 | 시간 안에 통과하는가? |
Step 0 — 문제 이해하기
문제 요약
棒の出荷 — 제5회 アルゴリズム実技検定(PAST) · M문제
긴 막대를 적절히 잘라서 출하합니다:
- 좌우로 길게 뻗은 막대에 N−1개의 절단 위치가 있음
- 모든 절단 위치에서 자르면, 왼쪽부터 길이 $A_1, A_2, A_3, \ldots, A_N$인 N개의 막대로 나뉨
- N−1개의 절단 위치 중 일부(0개~전부)를 선택하여 절단
- 절단 후 모든 막대의 길이가 L 이하가 되도록 자름
- 막대 길이의 편차를 줄이기 위해, 가장 짧은 막대의 길이를 최대화
- 그 최대값을 출력하라
제약 조건
- $1 \leq N \leq 2 \times 10^5$
- $1 \leq L \leq 10^{15}$
- $1 \leq A_i \leq \min(10^9, L)$
- 모든 값은 정수
입출력 예시
입력:
5 10
3 4 1 2 4
출력:
7
왼쪽에서 2번째 절단 위치만 선택하면, 두 막대의 길이는 각각 $3 + 4 = 7$과 $1 + 2 + 4 = 7$이 됩니다. 가장 짧은 막대의 길이는 7이며, 이것이 최대입니다.
Step 1 — 완전 탐색의 한계
가장 단순한 접근: 가능한 모든 절단 조합을 시도하고, 각 조합에서 가장 짧은 막대의 길이를 계산하여 최댓값을 구합니다.
조합 수 계산
N-1개의 절단 위치에 각각에 대해 "자르다/안 자른다" 2가지 선택이 있으므로:
$\text{전체 조합 수} = 2^{N-1}$
$N = 2 \times 10^5$일 때, $2^{199999}$는 천문학적 수치입니다.
각 조합에 대해 절단 후 막대 길이가 L 이하인지 확인하는 데 O(N)이 필요하므로, 전체 계산량은 $O(N \times 2^N)$입니다. 실행 시간 제한 내에 처리하는 것은 절대 불가능합니다.
교훈
절단 위치의 개수가 매우 많아 모든 부분집합을 시도할 수 없습니다. "최솟값을 최대화하라"는 형태의 문제에서는 이분 탐색을 의심해볼 수 있습니다.
Step 2 — 판정 문제로 변환하기
직접 최대값을 구하기 어려우니, 질문의 형태를 바꿔봅시다.
핵심 아이디어
원래 문제:
"절단 후 가장 짧은 막대 길의의 최대값은 얼마인가?"
이를 다음과 같은 판정 문제(Yes/No 질문)로 바꿉니다:
"절단 후 모든 막대의 길이가 X 이상 L 이하가 되는 절단 방법이 존재하는가?"
이 판정 문제를 빠르게 풀 수 있다면, $X$의 값을 조절하여 최적의 $X$(= 최단 막대 길이의 최대값)를 찾을 수 있습니다.
단조성 확인
이 판정 문제의 답은 $X$의 값에 대해 단조적입니다:
X = 0 → ✅ (절단하지 않아도 길이 ≥ 0)
X = 1 → ✅
X = 5 → ✅
X = 7 → ✅ ← 여기가 최대값!
X = 8 → ❌
X = 10 → ❌
X = 100 → ❌

가능한 구간(✅)와 불가능한 구간(❌)의 경계가 정확히 하나만 존재 → 이분 탐색으로 경계를 찾을 수 있음!
이전 문제(おまかせ)와의 차이는?
おまかせ는 실수 범위 이분 탐색(분수의 최대화)이었지만, 이 문제에서는 모든 값이 정수이므로 정수 이분 탐색을 사용합니다.
Step 3 — 절단 범위 분석: 누적합과 [l, r] 구간
판정 문제를 풀기 위해, "절단 위치 i에서 절단한 뒤, 다음에 절단할 수 있는 위치는 어디인가?"를 분석합니다.
모델링
N-1개의 절단 위치에 왼쪽부터 1번, 2번, ..., N-1번으로 번호를 매깁니다. 추가로:
- 0번째 절단 위치 = 막대의 왼쪽 끝 (반드시 절단)
- N번째 절단 위치 = 막대의 오른쪽 끝 (반드시 절단)
누적합 배열
$S_0 = 0,\quad S_i = A_1 + A_2 + \cdots + A_i$
로 정의하면, 절단 위치 i에서 절단 위치 j까지의 막대 길이는 $S_j - S_i$입니다.
절단 가능 범위 [l, r]
절단 위치 i에서 절단했을 때, 다음으로 절단할 위치 j는 다음 두 조건을 동시에 만족해야 합니다:
- 길이 X 이상: $S_j - S_i \geq X$ → 너무 가까이에서 자르면 짧은 막대가 생김
- 길이 L 이하: $S_j - S_i \leq L$ → 너무 멀리에서 자르면 긴 막대가 됨
따라서 절단 위치 i에서 절단한 뒤, 다음에 절단할 수 있는 범위는:
$l_i \leq j \leq r_i$
여기서:
- $l_i$ = $S_j - S_i \geq X$를 만족하는 최소 j
- $r_i$ = $S_j - S_i \leq L$을 만족하는 최대 j
그림으로 이해하기
예시: $X = 7, L = 10$일 때

절단 위치 0에서 절단 후
- S[j] - S[0] ≥ 7 → j ≥ 2 (S[2]=7 ≥ 7) ∴ l = 2
- S[j] - S[0] ≤ 10 → j ≤ 4 (S[4]=10 ≤ 10) ∴ r = 4
→ 다음에 절단할 수 있는 위치: {2, 3, 4}
핵심: l과 r의 단조 증가
i가 증가하면 $S_i$도 증가하므로:
- $l_i \leq l_{i+1}$ (l은 단조 증가)
- $r_i \leq r_{i+1}$ (r도 단조 증가)
이 성질 덕분에, 모든 i에 대해 l과 r을 구하는 데 합계 0(N)이면 충분합니다 (투 포인터).
Step 4 — 판정 함수 v1: O(N²) boolean 배열
Step 3의 아이디어를 가장 단순하게 코드로 옮깁니다.
아이디어
C[i]= "절단 위치 i에서 절단할 수 있는가?" (boolean)C[0] = true(왼쪽 끝은 반드시 절단)C[i]가 true일 때, 다음에 절단 가능한 모든 j ($l \leq j \leq r$)에 대해C[j] = true- 최종 답:
C[N]이 true인지 확인
Java 코드 예시 - check v1 함수
static boolean checkV1(int N, long L, long[] S, long X) {
boolean[] C = new boolean[N + 1];
C[0] = true;
for (int i = 0; i < N; i++) {
if (!C[i]) continue;
// 절단 위치 i에서 절단할 수 있으므로,
// 다음 절단 가능한 j를 모두 탐색
for (int j = i + 1; j <= N; j++) {
long len = S[j] - S[i]; // i~j 사이 막대 길이
if (len >= X && len <= L) {
C[j] = true;
}
}
}
return C[N];
}
시간 복잡도
바깥 루프 O(N) x 안쪽 루프 O(N) = O(N²)
$N = 2 \times 10^5$일 때, $4 \times 10^{10}$ 연산으로 시간 초과입니다.
이 코드가 중요한 이유
정확성은 보장되므로, 이후 최적화의 기준(baseline)이 됩니다. 최적화한 코드가 이 코드와 같은 결과를 내는지 확인할 수 있습니다.
Step 5 — 판정 함수 v2: O(N²) 투 포인터 최적화
Step 4의 안쪽 루프를 개선합니다. Step 3에서 관찰한 l, r의 단조 증가 성질을 활용합니다.
핵심 변경점
Step 4에서는 각 i마다 j를 1부터 N까지 전부 탐색했습니다. 하지만:
- $l_i \leq l_{i+1}$이므로, l을 매번 처음부터 탐색할 필요가 없음
- $r_i \leq r_{i+1}$이므로, r도 마찬가지
l과 r을 전역 변수로 유지하며, 이전 값에서 이어서 증가시킵니다.
Java 코드 예시 - check v2 함수
static boolean checkV2(int N, long L, long[] S, long X) {
int l = 0, r = 0;
boolean[] C = new boolean[N + 1];
C[0] = true;
for (int i = 0; i < N; i++) {
if (!C[i]) continue;
// l, r은 전역 변수로 유지되므로
// 이전 i에서의 값 이상이 자동으로 보장됨
// l 전진: S[l] - S[i] >= X 를 만족할 때까지
while (l <= N && S[l] - S[i] < X) l++;
// l > N이면 더 이상 절단 불가
if (l > N) break;
// r 전진: S[r+1] - S[i] <= L 을 만족하는 최대 r
while (r + 1 <= N && S[r + 1] - S[i] <= L) r++;
// [l, r] 구간의 모든 j에 대해 C[j] = true
for (int j = l; j <= r; j++) {
C[j] = true;
}
}
return C[N];
}
시간 복잡도 분석
- l, r의 전진: l과 r은 각각 0에서 N까지만 증가하므로, while 루프의 총 실행 횟수는 O(N)
- 내부 for 루프 (C[j] = true): 각 i에 대해 $r - l + 1$번 실행 → 최악의 경우 O(N)
전체: l, r 전진은 O(N)이지만, 내부 for 루프 때문에 여전히 O(N²)입니다.
병목 지점
for (int j = l; j <= r; j++) C[j] = true; 이 구간 일괄 갱신이 O(N²)의 원인입니다. 이것을 O(1)로 만들 수 있다면 전체가 O(N)이 됩니다.
Step 6 — 판정 함수 v3: O(N) いもす法(이모스법) 적용
Step 5의 병목(구간 일괄 갱신)을 いもす法(이모스법)으로 해결합니다.
いもす法이란?
배열의 구간 [l, r]에 일괄적으로 값을 더하는 연산을 효율화하는 기법입니다.
기본 원리
원래 배열 D와 그 누적합 배열 C의 관계를 이용합니다:
$C_i = D_0 + D_1 + \cdots + D_i$
이때 "구간 [l, r]의 모든 C에 1을 더하고 싶다"면, 원래 배열 D의 양 끝만 갱신하면 됩니다:
D[l] += 1 ← 구간 시작
D[r+1] -= 1 ← 구간 끝 다음 (상쇄)
이후 D의 누적합을 구하면, C[l] ~ C[r]에만 1이 더해진 결과를 얻습니다.
예시로 이해하기
길이 4인 배열에서 구간 [1, 2]에 1을 더하고 싶을 때:
일반적인 방법 (O(구간 길이)):
원래 배열: [1, 2, 3, 4]
결과: [1, 3, 4, 4] ← 인덱스 1, 2에 각각 +1
いもす法 (O(1)):
차분 배열 D: [1, 2, 3, 4]
갱신: D[1] += 1, D[3] -= 1
갱신 후 D: [1, 3, 3, 3]
누적합 C: [0, 1, 3, 6, 10] (갱신 전)
→ [0, 1, 4, 7, 10] (갱신 후)
누적합 배열 C의 인덱스 2~3 (원래 배열의 인덱스 1~2에 대응)에만 1이 더해졌습니다.
Step 5 → Step 6 변환
Step 5에서 C[i]는 boolean(도달 가능 여부)이었습니다. 이를 정수로 바꿉니다:
C[i] ≥ 1→ 절단 위치 i에서 절단 가능C[i] == 0→ 절단 불가
구간 갱신 for (j = l; j <= r; j++) C[j] = true를:
D[l] += 1;
D[r+1] -= 1;
로 대체하고, C는 D의 누적합으로 계산합니다.
Java 코드 예시 - check v3 함수 (최종)
static boolean checkV3(int N, long L, long[] S, long X) {
int l = 0, r = 0;
int[] C = new int[N + 1]; // C[i] = 절단 위치 i의 도달 가능 횟수
int[] D = new int[N + 1]; // 차분 배열 (いもす法)
// 초기값: 절단 위치 0은 반드시 절단 가능
D[0] += 1;
if (1 <= N) D[1] -= 1;
for (int i = 0; i < N; i++) {
// ── D의 누적합으로 C[i]를 계산 ──
if (i > 0) {
C[i] = C[i - 1] + D[i];
} else {
C[i] = D[i];
}
// C[i] == 0 이면 이 위치에서 절단 불가 → 스킵
if (C[i] == 0) continue;
// ── l 전진: S[l] - S[i] >= X 를 만족하는 최소 l ──
while (l <= N && S[l] - S[i] < X) l++;
// l > N이면 더 이상 절단 불가
if (l > N) continue;
// ── r 전진: S[r+1] - S[i] <= L 을 만족하는 최대 r ──
while (r + 1 <= N && S[r + 1] - S[i] <= L) r++;
// ── いもす法: 구간 [l, r]에 도달 가능 표시 ──
D[l] += 1;
if (r + 1 <= N) D[r + 1] -= 1;
}
// ── 최종 판정: C[N] > 0 이면 끝까지 도달 가능 ──
C[N] = C[N - 1] + D[N];
return C[N] > 0;
}
| 단계 | 코드 | 의미 |
| 초기화 | D[0]+=1; D[1]-=1; |
절단 위치 0은 항상 도달 가능 |
| 누적합 | C[i] = C[i-1] + D[i] |
차분 배열의 누적합으로 실제 도달 가능 여부 복원 |
| l 전진 | while S[l]-S[i] < X |
최소 길이 X를 보장하는 가장 가까운 다음 절단 위치 |
| r 전진 | while S[r+1]-S[i] ≤ L |
최대 길이 L을 넘지 않는 가장 먼 다음 절단 위치 |
| いもす | D[l]+=1; D[r+1]-=1; |
구간 [l, r] 전체에 도달 가능 표시 (O(1)) |
| 최종 | C[N] > 0 |
오른쪽 끝까지 도달 가능하면 절단 방법 존재 |
왜 break 대신 continue를 쓰는지?
Step 5에서는 l > N이면 break로 루프를 종료했습니다. 하지만 いもす法에서는 C 배열을 끝까지 갱신해야 하므로, continue로 루프를 계속 진행하며 C의 누적합 계산을 완료해야 합니다.
Step 7 — 이분 탐색으로 최적값 찾기
판정 함수 check(X)가 완성되었으므로, 이제 이분 탐색으로 경계(= 최단 막대 길이의 최대값)를 찾습니다.
탐색 범위 설정
- 하한 (
lo): 0 — 최솟값은 항상 0 이상 - 상한 (
hi): $L + 1$ — 모든 막대가 L 이하여야 하므로, X가 L+1 이상이면 반드시 불가능
정수 이분 탐색의 특징
이 문제에서는 모든 값이 정수이므로, 실수 이분 탐색(반복 100회) 대신 정수 이분 탐색 while (hi - lo > 1) 패턴을 사용합니다.
정수 이분 탐색
- lo = 0, hi = L + 1
- check(lo) = true (X=0이면 항상 가능)
- check(hi) = false (X=L+1이면 항상 불가능)
→ lo와 hi가 만날 때까지 반복
완성 코드 예시
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int N = sc.nextInt();
long L = sc.nextLong();
long[] A = new long[N];
for (int i = 0; i < N; i++) A[i] = sc.nextLong();
// ── 누적합 전처리 ──
long[] S = new long[N + 1];
for (int i = 0; i < N; i++) {
S[i + 1] = S[i] + A[i];
}
// ── 이분 탐색: 최솟값의 최대화 ──
long lo = 0, hi = L + 1;
while (hi - lo > 1) {
long mid = (lo + hi) / 2;
if (check(N, L, S, mid)) {
lo = mid; // mid 이상 가능 → 더 큰 값 탐색
} else {
hi = mid; // mid 이상 불가능 → 더 작은 값 탐색
}
}
// ── 출력 ──
System.out.println(lo);
}
static boolean check(int N, long L, long[] S, long X) {
int l = 0, r = 0;
int[] C = new int[N + 1];
int[] D = new int[N + 1];
D[0] += 1;
if (1 <= N) D[1] -= 1;
for (int i = 0; i < N; i++) {
if (i > 0) {
C[i] = C[i - 1] + D[i];
} else {
C[i] = D[i];
}
if (C[i] == 0) continue;
while (l <= N && S[l] - S[i] < X) l++;
if (l > N) continue;
while (r + 1 <= N && S[r + 1] - S[i] <= L) r++;
D[l] += 1;
if (r + 1 <= N) D[r + 1] -= 1;
}
C[N] = C[N - 1] + D[N];
return C[N] > 0;
}
}
이분 탐색 동작 과정 (예제 기준)
N=5, L=10, A=[3,4,1,2,4], S=[0,3,7,8,10,14]
iter 0: lo=0, hi=11, mid=5 → check(5)=true → lo=5
iter 1: lo=5, hi=11, mid=8 → check(8)=false → hi=8
iter 2: lo=5, hi=8, mid=6 → check(6)=true → lo=6
iter 3: lo=6, hi=8, mid=7 → check(7)=true → lo=7
iter 4: lo=7, hi=8 → hi-lo=1 → 종료
출력: 7 ✅
Step 8 — 계산량 분석 & 정리
시간 복잡도
| 부분 | 계산량 |
| 누적합 전처리 | $O(N)$ |
check(X) |
$O(N)$ — l, r 투 포인터 합계 O(N) + いもす法 O(1) × N |
| 이분 탐색 반복 | $O(\log L)$ |
| 전체 | $O(N \log L)$ |
$N = 2 \times 10^5, L = 10^{15}$일 때: $2 \times 10^5 \times 50 = 10^7$ 정도의 연산으로, 시간 제한 내에 여유 있게 통과합니다.
최적화 과정 요약
Step 4: O(N²) boolean 배열
↓ l, r의 단조 증가 성질 활용 (투 포인터)
Step 5: O(N²) — l, r 전진은 O(N)이지만 구간 갱신 for 루프가 병목
↓ いもす法으로 구간 갱신을 O(1)로
Step 6: O(N) — 최종 최적화 완료!
이 문제에서 배운 패턴
"최솟값의 최대화" 문제를 풀 때
- "최솟값이 X 이상 가능한가?" 판정 문제로 변환
- 판정 결과가 단조적인지 확인
- 단조적이면 → 이분 탐색 적용!
🔆 판정 함수 최적화에서:
- 누적합으로 구간 길이를 O(1) 조회
- 투 포인터로 범위 탐색을 amortized O(N)
- いもす法으로 구간 일괄 갱신을 O(1)
핵심 요약
| 항목 | 내용 |
| 문제 유형 | 최솟값의 최대화 (가장 짧은 막대의 길이 최대화) |
| 접근법 | 최적화 → 판정 문제 변환 + 이분 탐색 |
| 판정 함수 | 누적합 + 투 포인터 + いもす法으로 O(N) |
| 이분 탐색 타입 | 정수 이분 탐색 (while (hi - lo > 1)) |
| 계산량 | $O(N \log L)$ |
| 핵심 테크닉 | いもす法(이모스법) — 구간 일괄 갱신을 O(1) |
관련 링크
- Part 1 본문: 이분 탐색 심화 — 이 문제를 포함한 3가지 이분 탐색 기법의 전체 정리
- 문제 URL: [第五回 アルゴリズム実技検定 過去問] — M - 棒の出荷
- 소스코드: tsutaj/pastbook-2-source-code (Github)
다음 파트 예고
Part 1에서는 이 문제 외에도 평균값·중앙값 최대화(Average and Median)를 다루고 있습니다. 모든 내용을 학습한 후, [PAST 알고리즘 시리즈] Part 2 — 동적 계획법 심화 (1)으로 넘어갑니다.