Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Sequence DP: the Diff Engine  ›  Lesson

Longest Increasing Subsequence & the Block Reorderer

Longest Increasing Subsequence 30 minFind the longest rising run hidden in a list, first in O(n²), then in O(n log n)
You're building a piece ofQuarry — version history
This piece — fewest_moves(): Counts the fewest stored blocks to move so a scrambled index is back in order.
Scenario A restore from backup leaves Quarry's block index scrambled. The reorderer finds the longest group of blocks already in order, leaves them in place and moves only the rest.
Your task
Build fewest_moves(order). order is a list of distinct block numbers in their current storage order. One move takes a block out and puts it back anywhere. Return the fewest moves that leave the list in ascending order: every block on a longest increasing subsequence can stay, and every other block moves. Use the tails row with bisect so it stays fast. Example: fewest_moves([4, 1, 6, 2, 7, 3, 8, 5]) → 4.

Longest Increasing Subsequence & the Block Reorderer

In short: the longest increasing subsequence (LIS) is the longest run of elements that keeps the original order but may skip some. A quadratic table, best[i] = the longest rising run ending at i, finds it in O(n²). A faster version keeps one number per length — the smallest possible ending value, the tails row — and finds the length alone in O(n log n) with binary search. The tails row is not the subsequence itself; only its length is the answer.

Four card piles, each showing a buried larger card under a smaller top card; the pile tops read 1, 2, 3, 5 left to right, which is the tails row

Quarry keeps every stored document as a list of numbered blocks, and its index expects those numbers in ascending order. After a restore from backup, the index comes back scrambled: [4, 1, 6, 2, 7, 3, 8, 5]. Rewriting a block is slow, so Quarry wants to leave as many blocks where they are as it can and move the rest. The blocks that can stay are the ones that are already in order relative to each other. The longest such group is the longest increasing subsequence, and everything outside it has to move.

This section ends with Quarry's diff engine, which shows what changed between two revisions of a document. Every lesson fills a table, starting here with the smallest: a single row.

Subsequence, not subarray

A subarray is a contiguous slice. A subsequence keeps the original order but may skip elements. In [4, 1, 6, 2, 7, 3, 8, 5], the blocks 1, 2, 3, 5 form a rising subsequence even though other blocks sit between them. They can all stay put: move the other four blocks into the gaps and the index is sorted.

The brute-force plan is to try every subsequence and keep the longest rising one. Each element is either in or out, so a list of n elements has 2ⁿ subsequences. Count just the rising ones in a short list first:

What does this print?
from itertools import combinations

blocks = [4, 1, 6, 2]
rising = 0
for size in range(1, len(blocks) + 1):
    for pick in combinations(blocks, size):
        if all(a < b for a, b in zip(pick, pick[1:])):
            rising += 1
print(rising)

The quadratic table: best run ending here

Instead of searching over subsequences, ask a smaller question about every position:

best[i] = the length of the longest rising subsequence that ends at position i.

A run that ends at i is either nums[i] alone (length 1), or a run ending at some earlier j with nums[j] < nums[i], extended by one. So:

best[i] = 1 + max(best[j] for j < i if nums[j] < nums[i]), or 1 when no such j exists.

The answer is the largest value anywhere in best, not the last one, because the longest run can end anywhere.

python

Watch the row fill. Each arrow points from the earlier run that a cell extends:

watch it move · grid

Each best[i] reads every earlier cell with a smaller block and extends the longest one, which is why this version costs O(n²).

Block 4 has nothing before it, so the best run ending there is just itself: 1. 2 by 8 table. At row 1, column 0 — value 1. 9 of 16 cells filled.

1 / 9

Each cell looks at every cell to its left, so the total work is 0 + 1 + 2 + … + (n − 1), about n²/2 comparisons: O(n²) time. The only storage is the best row itself, one number per position: O(n) space.

What does this print?
nums = [3, 1, 4, 1, 5]
best = [1] * len(nums)
for i in range(len(nums)):
    for j in range(i):
        if nums[j] < nums[i] and best[j] + 1 > best[i]:
            best[i] = best[j] + 1
print(best)

Here's a first attempt that reads the answer from the wrong cell:

broken — fix it

The longest rising run in [2, 5, 3, 7, 1] is 2, 5, 7 (or 2, 3, 7), length 3, but this prints 1. Fix the return so it reports the best run wherever it ends.

❓ Cross-question: "Why not walk left to right and keep each block that's bigger than the last one kept?" Because one early block can block the rest:

What does this print?
blocks = [9, 1, 2, 3, 4]
kept = []
for b in blocks:
    if not kept or b > kept[-1]:
        kept.append(b)
print(len(kept))

The tails row: O(n log n)

The quadratic table stores one number per position. A faster idea stores one number per length:

tails[k] = the smallest block that ends any rising run of length k + 1 seen so far.

A small tail is better than a big one, because more future blocks can follow it. Two facts make this fast. First, tails is always sorted. Second, a new block x changes exactly one cell. It replaces the first tail that is ≥ x, since x makes a smaller ending for that length. If no tail is ≥ x, it extends the longest run, so it's appended. Finding "the first tail ≥ x" in a sorted list is binary search, which the bisect module already does. (Deal the blocks onto piles like a card game and the pile tops are this row, which is why it's also called patience sorting.)

watch it move · array

Every block either overwrites one tail with something smaller or appends a new longest length. The row's length is the answer, but the row itself is not a subsequence.

Block 4 starts the row: the best run of length 1 ends in 4. 1 cell. At index 0.

1 / 9
python

Each of the n blocks does one binary search over a row no longer than itself: O(n log n) time, and the row holds at most n numbers: O(n) space, same as the quadratic version — the speed-up is in time, not memory.

Step through the row on a short input. Watch k, and notice that tails never gets longer unless k equals its length:

step through it
1from bisect import bisect_left
2tails = []
3for x in [5, 2, 8, 6, 9]:
4 k = bisect_left(tails, x)
5 if k == len(tails):
6 tails.append(x)
7 else:
8 tails[k] = x
9print(len(tails))

The commonest mistake with this algorithm is to print tails and call it the subsequence:

What does this print?
from bisect import bisect_left
tails = []
for x in [3, 5, 7, 1]:
    k = bisect_left(tails, x)
    if k == len(tails):
        tails.append(x)
    else:
        tails[k] = x
print(tails)

Strictly rising, or allowed to repeat?

bisect_left finds the first tail ≥ x, so an equal value replaces a tail and can't extend a run. That is strictly increasing. For a non-decreasing run, where equal values may sit next to each other, use bisect_right, which finds the first tail > x. Then an equal value lands after its twin and extends. That one-word change is the build exercise below.

Your own fill, drawn

Edit nums and run it. The second row is best, filled by your own loop. Try a list that only falls, one that only rises, and one with repeats.

your code, drawn · grid

Your loop fills best one cell at a time. The answer is the biggest cell, wherever it sits.

How much faster is the tails row?

The table does about n²/2 comparisons and the tails row about n · log₂ n. Their ratio grows with n:

blocks n table (n²/2) tails row (n · log₂ n) ratio
100 5,000 664 7.5×
1,000 500,000 9,966 50×
1,000,000 5 × 10¹¹ about 20 million 25,000×

At a million blocks that's the gap between instant and a very long wait.


Idioms & real-world patterns

Fewest moves to sort

Any element not on a longest rising run has to move, and every element on it can stay. So "fewest single moves to sort a list" is n − LIS: the Quarry block reorderer you are about to build.

Two keys at once

When two things must grow together, such as boxes that are both wider and deeper, sort by the first key and run LIS on the second. Ties on the first key are the trap in the boss exercise.

Where knapsack fits

Every DP in this section makes a choice at each cell: extend this run or start a new one, match or skip, insert or delete. The plain "take this item or leave it" choice under a budget is knapsack. You built it in section 6 as Tunebox's best playlist, and it returns in Planning DP. This section doesn't repeat it. Here the choices are about order.


Quick practice

What does this print?
from bisect import bisect_left
tails = []
for x in [2, 2, 2]:
    k = bisect_left(tails, x)
    if k == len(tails):
        tails.append(x)
    else:
        tails[k] = x
print(tails)
broken — fix it

The longest strictly rising run in [3, 3, 3, 4] is 3, 4, length 2, but this prints 4. Fix the bisect call.

🎯 Your turn

Write fewest_moves(order). order is a list of distinct block numbers in their current storage order. One move takes a block out and puts it back anywhere. Return the fewest moves that leave the list in ascending order.

  • fewest_moves([4, 1, 6, 2, 7, 3, 8, 5]) → 4 (keep 1, 2, 3, 5)
  • fewest_moves([1, 2, 3]) → 0
  • fewest_moves([]) → 0

Use the tails row so that it stays fast on a long index.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green means this piece of the app is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upThe quadratic table

Write lis_length(nums): the length of the longest strictly increasing subsequence, using the one-row table best[i] = the longest rising run that ends at position i. Return 0 for an empty list. lis_length([3, 1, 4, 1, 5, 9, 2, 6]) → 4.

dptabulation
DrillShow me the tails

Write tails_row(nums) returning the final tails row of the O(n log n) algorithm for a strictly increasing subsequence: tails[k] is the smallest value that ends a rising run of length k + 1. tails_row([4, 1, 6, 2, 7, 3, 8, 5]) → [1, 2, 3, 5].

bisectbinary-search
BuildRepeats allowed

Write longest_non_decreasing(nums): the length of the longest subsequence where each value is greater than or equal to the one before. Make it O(n log n). longest_non_decreasing([3, 3, 1, 2, 2, 5]) → 4 (1, 2, 2, 5).

bisectdp
BossCrates inside crates

Quarry's warehouse ships archive tapes in crates. Each crate is [width, depth], and a crate fits inside another only if it is strictly smaller in both. Write nest_crates(crates): the largest number of crates you can nest one inside the next. nest_crates([[5, 4], [6, 4], [6, 7], [2, 3]]) → 3 ([2, 3] inside [5, 4] inside [6, 7]).

dynamic-programmingsorted-keybinary-search
CapstoneUp the hill and down again

Quarry charts a document's size over its revisions. Write longest_peak(heights): the length of the longest subsequence that rises strictly to a peak and then falls strictly. Either side may be empty, so a purely rising or purely falling run also counts. longest_peak([1, 11, 2, 10, 4, 5, 2, 1]) → 6 (1, 2, 10, 4, 2, 1).

dynamic-programmingdp
ExplainRight length, wrong list

A teammate prints the tails row for [3, 5, 7, 1], gets [1, 5, 7], and says the algorithm is broken because 1 comes last in the input. Explain what the tails row does and doesn't promise, and why its length is still the right answer.

fewest_moves.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
fewest_moves(order) → intThe fewest blocks to move so the index is sorted.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.