Skip to content

CSES Stick Differences

The Problem That Motivated This Chapter

Problem (CSES 3401). You have \(N\) sticks with lengths \(a_1, a_2, \ldots, a_N\). You must make exactly \(K\) cuts total (each cut splits one piece into two). After all cuts, minimize the difference between the longest and shortest piece across all sticks. Output the answer for each \(K = 1, 2, \ldots, M\).

The Greedy Choice: Don't try to minimize the spread directly. Instead, build two independent simulations — one that greedily minimizes the maximum piece, another that tracks the worst-case minimum degradation — and sweep them against each other.

This is rated 3000+ on CSES. It's the problem from the course research that showed why decoupled sweeps exist as a technique. Fixing one bound loses — minimizing the maximum and maximizing the minimum are two conflicting objectives. The moment you try to optimize both simultaneously, you get buried in casework.


The Two Traps

Before diving into the solution, we must understand why standard techniques fail on this problem.

Trap 1: Binary Search on Answer. A common instinct is to binary search the target spread \(\Delta\), and check if it's achievable in \(\leq K\) cuts. To check a spread \(\Delta\), you would need to fix a minimum piece size \(X\), forcing the maximum piece size to be \(X + \Delta\). You would then iterate over all sticks, making enough cuts to ensure no piece exceeds \(X + \Delta\), while verifying no piece falls below \(X\).

The flaw is that there is no single monotonic property to binary search over. Because \(X\) is not fixed, you would have to sweep over all possible values of \(X\) for every binary search step. You quickly end up juggling \(O(N)\) possible minimums inside an \(O(\log(\text{Max Spread}))\) search, bloating the complexity.

Trap 2: Dynamic Programming. A standard knapsack-style DP would define a state like DP(index, remaining_cuts). But to calculate the spread, you need to know the exact maximum and minimum pieces generated across the entire array.

This forces you to add them to the state: DP(index, remaining_cuts, current_min, current_max). Because stick lengths can be up to \(10^9\), tracking current_min and current_max causes a massive state-space explosion.

The Golden Rule: When tracking global bounds (like min and max) makes DP intractable, look for a way to decouple the bounds and sweep them against each other.


The Mathematics of Decoupling

The hardest part of this problem is the rigid global constraint: you must make exactly \(K\) cuts. Every cut given to one stick is a cut stolen from another. The decoupled sweep algorithm works by effectively shattering this global constraint.

Let \(K\) be the total cuts available. If we allocate \(k\) cuts to reduce the maximum piece, we have \(K - k\) cuts remaining to manage the degradation of the minimum piece.

Let \(F_{\max}(k)\) be the maximum piece size after optimally making \(k\) cuts to minimize the upper bound. Let \(F_{\min}(x)\) be the worst-case minimum piece size after making \(x\) cuts across the array.

Our objective function becomes:

\[\text{Spread}(k) = F_{\max}(k) - F_{\min}(K - k)\]

By defining \(F_{\max}\) and \(F_{\min}\) as completely independent functions, Timeline A pretends it has infinite cuts to minimize the max, and Timeline B pretends it has infinite cuts to preserve the min. \(F_{\max}(k)\) is strictly non-increasing. \(F_{\min}(K - k)\) is also non-increasing. This monotonic nature guarantees that a sliding window multiset will find the global minimum of \(\text{Spread}(k)\).


Timeline A: Minimize the Maximum Piece

Timeline A heap trace: sticks being greedily split to minimize the maximum piece

Simulate greedily: always cut the longest current piece. Use a max-heap of PieceState(maxPieceLength, numPieces, stickIndex).

At each cut \(k\): 1. Pop the state with the largest max piece 2. Split that stick into one more piece: new max \(= \lceil a[\text{idx}] / (\text{pieces} + 1) \rceil\) 3. Push the updated state back 4. Record seg[k] = (currentMin, currentMax) where currentMax = new heap top

Why the Greedy Heap Works: If a stick of length \(L\) is cut into \(P\) pieces, the optimal strategy to minimize the maximum piece is to make them as equal as possible. By the Pigeonhole Principle, the maximum piece will be exactly \(\lceil L / P \rceil\). Therefore, to optimally reduce the global maximum, we always pop the stick currently contributing the largest \(\lceil L / P \rceil\) and increment its pieces.


Timeline B: Worst-Case Minimum Degradation

Simulate independently: always process the stick whose floor-division is largest (the one that degrades the minimum most gracefully).

Reset the heap. Push \((a[i] / 2, 2, i)\) for each stick with \(a[i] > 1\).

At each step \(k\): 1. Pop the largest floor-division value 2. Update worstCaseMin = min(worstCaseMin, popped value) 3. Record low[k] = worstCaseMin 4. Push the next split if the stick can be divided further

Why the Greedy Heap Works: Conversely to Timeline A, if we are forced to cut a stick of length \(L\) into \(P\) pieces, the smallest resulting piece will be exactly \(\lfloor L / P \rfloor\). To delay the degradation of our global minimum for as long as possible, we must always choose to cut the stick that yields the highest possible \(\lfloor L / P \rfloor\) value.

The "Length 1" Singularity: Timeline B completely ignores sticks of length 1. If you make a cut on a stick of length 1, you are forced to create a piece of length 0. If a piece of length 0 exists, the global minimum is permanently locked to 0, and your spread is instantly maximized. Timeline B's purpose is to degrade the minimum gracefully, so the optimal strategy mathematically forbids cutting a stick of length 1 unless absolutely forced to do so.


The Merge: Sliding Window with Multiset

Two-timeline merge: seg and low arrays swept with sliding window to find minimum spread

For each number of cuts \(k\), some cuts go to Timeline A (reducing the max), the rest degrade the min via Timeline B.

The key insight: seg[j].max is monotonically decreasing, and low[k] is monotonically decreasing. As \(k\) increases, we can absorb more entries from seg whose max is at most the current max, and we must eject entries from seg whose min exceeds low[k].

This is a sliding window over seg entries, maintained with a multiset keyed on spread values. At each \(k\), the answer is the minimum spread in the active window (or the boundary case seg[shrinkPointer - 1].max - low[k]).


Full Trace: \(N = 3\) sticks, \(K = 3\) cuts

Sticks: \(a = [10, 6, 4]\). Global min \(= 4\).

Timeline A — greedily minimize the maximum piece

The max-heap starts with \(\{(10, 1, 0),\; (6, 1, 1),\; (4, 1, 2)\}\).

Cut \(k\) Pop Action Push back New max currentMin seg[\(k\)]
0 \((10, 1, 0)\) Split stick 0 into 2: \(\lceil 10/2 \rceil = 5\) \((5, 2, 0)\) \(6\) \(\min(4,\; 10/2) = 4\) \([4, 6]\)
1 \((6, 1, 1)\) Split stick 1 into 2: \(\lceil 6/2 \rceil = 3\) \((3, 2, 1)\) \(5\) \(\min(4,\; 6/2) = 3\) \([3, 5]\)
2 \((5, 2, 0)\) Split stick 0 into 3: \(\lceil 10/3 \rceil = 4\) \((4, 3, 0)\) \(4\) \(\min(3,\; 10/3) = 3\) \([3, 4]\)

seg timeline: \([[4,6],\; [3,5],\; [3,4]]\).

Timeline B — worst-case minimum degradation

Push \((a[i]/2, 2, i)\) for sticks with \(a[i] > 1\): \(\{(5, 2, 0),\; (3, 2, 1),\; (2, 2, 2)\}\).

Cut \(k\) Pop worstCaseMin low[\(k\)] Push back
0 \((5, 2, 0)\) \(\min(4, 5) = 4\) \(4\) \((10/3=3, 3, 0)\)
1 \((3, 2, 1)\) \(\min(4, 3) = 3\) \(3\) \((6/3=2, 3, 1)\)
2 \((3, 3, 0)\) \(\min(3, 3) = 3\) \(3\) \((10/4=2, 4, 0)\)

low timeline: \([4, 3, 3]\).

Merge — sweep \(k = 0, 1, 2\)

\(k\) seg[\(k\)] low[\(k\)] Best from window Best from boundary Answer
0 \((4, 6)\) \(4\) \(6 - 4 = 2\) 2
1 \((3, 5)\) \(3\) \(5 - 3 = 2\) \(5 - 3 = 2\) 2
2 \((3, 4)\) \(3\) \(4 - 3 = 1\) \(4 - 3 = 1\) 1

Output: 2 2 1

Manual Verification (\(k = 2\))

3 cuts on sticks \([10, 6, 4]\). Split 10 into \([4, 3, 3]\) (2 cuts), split 6 into \([3, 3]\) (1 cut), leave 4 as is. All pieces: \(\{4, 3, 3, 3, 3, 4\}\). Max \(= 4\), min \(= 3\), spread \(= 1\). Correct.


The Code

#include <bits/stdc++.h>
using namespace std;

struct PieceState {
    int maxPieceLength;
    int numPieces;
    int stickIndex;

    bool operator<(const PieceState& other) const {
        return maxPieceLength < other.maxPieceLength;
    }
};

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int numSticks, totalCuts;
    cin >> numSticks >> totalCuts;

    vector<int> stickLengths(numSticks);
    int globalMinLength = 2e9;

    for (int i = 0; i < numSticks; i++) {
        cin >> stickLengths[i];
        globalMinLength = min(globalMinLength, stickLengths[i]);
    }

    auto ceilDivide = [](int numerator, int denominator) {
        return (numerator + denominator - 1) / denominator;
    };

    auto buildTimelineA = [&]() {
        vector<array<int, 2>> segTimeline(totalCuts);
        priority_queue<PieceState> maxPieceHeap;

        for (int i = 0; i < numSticks; i++) {
            maxPieceHeap.push({stickLengths[i], 1, i});
        }

        int currentMinPiece = globalMinLength;

        for (int k = 0; k < totalCuts; k++) {
            PieceState topState = maxPieceHeap.top();
            maxPieceHeap.pop();

            int nextMaxPiece = ceilDivide(stickLengths[topState.stickIndex], topState.numPieces + 1);
            maxPieceHeap.push({nextMaxPiece, topState.numPieces + 1, topState.stickIndex});

            currentMinPiece = min(currentMinPiece, stickLengths[topState.stickIndex] / (topState.numPieces + 1));
            int currentMaxPiece = maxPieceHeap.top().maxPieceLength;

            segTimeline[k] = {currentMinPiece, currentMaxPiece};
        }
        return segTimeline;
    };

    auto buildTimelineB = [&]() {
        vector<int> lowTimeline(totalCuts);
        priority_queue<PieceState> maxPieceHeap;

        for (int i = 0; i < numSticks; i++) {
            if (stickLengths[i] > 1) {
                maxPieceHeap.push({stickLengths[i] / 2, 2, i});
            }
        }

        int worstCaseMin = globalMinLength;

        for (int k = 0; k < totalCuts; k++) {
            if (maxPieceHeap.empty()) {
                lowTimeline[k] = worstCaseMin;
                continue;
            }

            PieceState topState = maxPieceHeap.top();
            maxPieceHeap.pop();

            worstCaseMin = min(worstCaseMin, topState.maxPieceLength);
            lowTimeline[k] = worstCaseMin;

            if (stickLengths[topState.stickIndex] > topState.numPieces) {
                maxPieceHeap.push({
                    stickLengths[topState.stickIndex] / (topState.numPieces + 1),
                    topState.numPieces + 1,
                    topState.stickIndex
                });
            }
        }
        return lowTimeline;
    };

    vector<array<int, 2>> segTimeline = buildTimelineA();
    vector<int> lowTimeline = buildTimelineB();

    auto computeSpread = [](array<int, 2> segment) {
        return segment[1] - segment[0];
    };

    multiset<int> activeSpreadValues;
    int expandPointer = 0, shrinkPointer = 0;

    for (int k = 0; k < totalCuts; k++) {
        while (expandPointer < totalCuts && segTimeline[expandPointer][1] >= segTimeline[k][1]) {
            activeSpreadValues.insert(computeSpread(segTimeline[expandPointer]));
            expandPointer++;
        }

        while (shrinkPointer < totalCuts && segTimeline[shrinkPointer][0] > lowTimeline[k]) {
            auto it = activeSpreadValues.find(computeSpread(segTimeline[shrinkPointer]));
            if (it != activeSpreadValues.end()) {
                activeSpreadValues.erase(it);
            }
            shrinkPointer++;
        }

        int bestSpread = 2e9;
        if (!activeSpreadValues.empty()) {
            bestSpread = *activeSpreadValues.begin();
        }
        if (shrinkPointer > 0) {
            bestSpread = min(bestSpread, segTimeline[shrinkPointer - 1][1] - lowTimeline[k]);
        }

        cout << bestSpread << (k == totalCuts - 1 ? "\n" : " ");
    }

    return 0;
}

Complexity: \(O(M \log N)\) for the heap simulations + \(O(M \log M)\) for the two-pointer multiset merge.


Try It

Test case 1:

Input:
3 3
10 6 4
Output: 2 2 1

Before running, predict: after cut 0, what's the maximum piece? Which stick gets cut first?

Test case 2:

Input:
2 4
7 3
Output: 1 1 0 0

After 3 cuts: split 7 into \([2, 2, 3]\) (2 cuts), split 3 into \([1, 2]\) (1 cut). Pieces: \(\{2, 2, 3, 1, 2\}\). Spread \(= 3 - 1 = 2\). But with 4 cuts we can do better — trace both timelines.

Challenge: What happens when all sticks have the same length? Does Timeline A become trivial? What about Timeline B?


Problems

Problem Link Difficulty
CSES — Stick Differences cses.fi/problemset/task/3401 Hard (3000+)