DSA DSA In Depth
0%
case knuth-plassline-breakingcode-formatters

Production Case Study: Knuth-Plass Line Breaking in TeX & Code Formatters (prettier, rustfmt)

7 min read Module 6.2: Dynamic Programming Foundations & Classical Archetypes Updated Oct 23, 2026
📄 Cheat Sheet
Text Justification & Word Wrap by Dynamic Programming (Knuth-Plass Formulation)
Tushar Roy - Coding Made Simple Text Justification & Word Wrap by Dynamic Programming (Knuth-Plass Formulation) Watch on YouTube ↗

Derives the badness cost matrix and O(N²) DP state transition table that minimizes cubic whitespace penalties across justified paragraph lines.

0:00 / --:--

1. Why Greedy Word Wrapping Fails & The Knuth-Plass Badness Function

Given $n$ words of widths $w_0, w_1, \dots, w_{n-1}$ and target line width $L$, a greedy line breaker (used in basic web browsers and early word processors) packs words onto the current line until the next word exceeds $L$. This local myopia often packs $99\%$ of lines tightly and then encounters a long URL or technical identifier, leaving a massive gap on the preceding line.

In 1981, Donald Knuth and Michael Plass revolutionized digital typography in $\TeX$ by optimizing global paragraph demerits across all lines simultaneously:

  • The width of placing words $i \dots j-1$ on a single line (with 1 unit space between adjacent words) is $W(i, j) = \sum_{k=i}^{j-1} w_k + (j - 1 - i)$ (computable in $O(1)$ via prefix sums).
  • The badness (or slack penalty) $\text{cost}(i, j)$ of a line spanning words $[i, j)$ is: $$\text{cost}(i, j) = \begin{cases} +\infty & \text{if } W(i, j) > L \\ 0 & \text{if } j = n \text{ (last line of paragraph is left-aligned)} \\ (L - W(i, j))^p & \text{otherwise } (p = 2 \text{ or } p = 3 \text{ in } \TeX) \end{cases}$$

Core Invariant — Suffix DP Recurrence & Sparse Active Breakpoint Window

Let $dp[i]$ be the minimum total badness to typeset the suffix of words $i \dots n-1$:
$$dp[i] = \min_{i < j \le n,\; W(i, j) \le L} \big(\text{cost}(i, j) + dp[j]\big), \qquad dp[n] = 0$$ Because every word has width $w_k \ge 1$ plus a space of width $1$, how many words $j - i$ can possibly fit on a single line of width $L$? At most $\lceil L / 2 \rceil$! As soon as $W(i, j) > L$, we break out of the inner $j$-loop immediately. Thus the time complexity is not $O(n^2)$, but $O(n \cdot W_{\text{max\_words\_per\_line}}) = O(n \cdot L)$—which is linear $O(n)$ for a fixed page width $L \approx 80$!

2. From $\TeX$ Paragraphs to Code Formatters (prettier, clang-format, rustfmt)

Modern source code formatters (clang-format, Wadler/Oppen pretty-printers in prettier and rustfmt) extend Knuth-Plass from flat word arrays to Abstract Syntax Trees (ASTs):

  • Each token break has a syntactic penalty weight (breaking after a , or ; is cheap; breaking inside a + b is expensive) and an indentation level $I$.
  • Because indentation changes the remaining line budget $L - I$ for subsequent tokens, the state in clang-format is (token_index, column_offset, paren_stack_state) explored via Dijkstra / $A^*$ on the formatting DAG.
flowchart LR W0["Word 0"] -->|"(L - W(0,3))^3"| W3["Word 3"] W0 -->|"(L - W(0,4))^3"| W4["Word 4"] W3 -->|"(L - W(3,6))^3"| W6["Word 6 (End, cost=0)"] W4 -->|"(L - W(4,6))^3"| W6

Hardware Reality — Concave Monge Property & $O(N)$ SMAWK / Convex Hull Acceleration

What if line width $L$ is huge (so $O(N \cdot L)$ is too slow)? Because the penalty function $f(x) = x^2$ or $x^3$ is convex, the transition matrix $C[i][j] = \text{cost}(i, j) + dp[j]$ satisfies the Quadrangle Inequality (Monge Property): optimal split points are monotonic ($\text{opt}[i] \le \text{opt}[i+1]$). Using Wilber's algorithm or Hirschberg-Larmore's Convex Hull / Monge deque, Knuth-Plass line breaking runs in strict $O(N)$ time regardless of $L$!

3. Complexity Matrix

Hirschberg O(min(N,M))-Space Midpoint Split & Levenshtein DP
O(N·M) Time · O(min(N,M)) Space

Visualize how Hirschberg runs forward 2-row DP on the top half and reverse 2-row DP on the bottom half to locate the optimal midpoint crossing (M/2, k*) in linear memory.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Hirschberg Midpoint Theorem: max_k (LCS(X[1..m/2], Y[1..k]) + LCS(rev(X[m/2+1..m]), rev(Y[k+1..n]))) = LCS(X, Y).
Forward 2-Row DP: X[0..M/2] → fwd[k] = [0, 1, 2, 2, 3] Reverse 2-Row DP: X[M/2..M] ← rev[k] = [4, 3, 2, 1, 0] k* = argmax(fwd[k] + rev[k]) = 2 (LCS = 4)
Midpoint Row
i = M / 2
Optimal Split Column
k* = 2 (fwd[2]+rev[2] = 4)
Work / Space
∑ Area = 2·N·M, Space = O(N)
[Step 1] Found midpoint crossing (M/2, k*=2); recursing on top-left (M/2 × k*) and bottom-right (M/2 × (N-k*)) halves.
Line-Breaking Algorithm Time Complexity Space Complexity Global Optimality
Greedy Word Wrap (Browser / Notepad) O(N) O(1) Suboptimal (Creates loose lines before long words)
Naive All-Pairs Suffix DP O(N^2) O(N) Exact Global Optimum
Knuth-Plass DP with Early W(i,j) > L Break O(N * (L / avg_w)) O(N) Exact Global Optimum (Linear for fixed $L$)
Monge / CHT Accelerated Knuth-Plass ($p=2$) O(N) O(N) Exact Global Optimum for arbitrary $L$

4. Reference C++20 Implementation

#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>

struct LineBreakPlan {
    long long total_demerits;
    // Pairs of [start_word_idx, end_word_idx_exclusive) for each formatted line
    std::vector<std::pair<int, int>> lines;
};

// Knuth-Plass Optimal Paragraph Line Breaking with Cubic Badness Penalty
LineBreakPlan knuth_plass_line_break(const std::vector<int>& word_widths, int line_width) {
    const int n = static_cast<int>(word_widths.size());
    if (n == 0) return { 0, {} };

    std::vector<int> pref(n + 1, 0);
    for (int i = 0; i < n; ++i) pref[i + 1] = pref[i] + word_widths[i];

    auto line_len = [&](int i, int j) -> int {
        return (pref[j] - pref[i]) + (j - 1 - i);
    };

    constexpr long long INF = 1LL << 60;
    std::vector<long long> dp(n + 1, INF);
    std::vector<int> next_break(n + 1, n);
    dp[n] = 0;

    // Evaluate suffixes i = n-1 down to 0
    for (int i = n - 1; i >= 0; --i) {
        for (int j = i + 1; j <= n; ++j) {
            int width = line_len(i, j);
            if (width > line_width) {
                // Only exception: a single word wider than line_width must still be placed on its own line
                if (j == i + 1) {
                    dp[i] = dp[j];
                    next_break[i] = j;
                }
                break; // Monotonicity of width: no larger j can ever fit on this line!
            }
            long long slack = line_width - width;
            // Last line (j == n) has 0 badness penalty in standard typography
            long long badness = (j == n) ? 0LL : (slack * slack * slack);
            if (badness + dp[j] < dp[i]) {
                dp[i] = badness + dp[j];
                next_break[i] = j;
            }
        }
    }

    // Reconstruct optimal line breaks from parent pointers next_break
    LineBreakPlan plan{ dp[0], {} };
    for (int cur = 0; cur < n; cur = next_break[cur]) {
        plan.lines.emplace_back(cur, next_break[cur]);
    }
    return plan;
}

Production Pitfall — Penalizing the Final Line ($j = n$) or Crashing on Overlong Words ($w_i > L$)

In real-world typesetting and code formatting, two edge cases must be handled explicitly: First, the final line of a paragraph ($j = n$) is ragged-right and should incur $0$ badness penalty for leftover slack (otherwise the solver will artificially pull words down from earlier lines just to pad the last line!). Second, if a single word or URL has $w_i > L$, breaking immediately when width > line_width at $j = i + 1$ leaves dp[i] = INF; a production formatter must allow single overfull tokens ($j = i + 1$) as a fallback.

Check yourself Question 1 of 4
Concept Check Knuth-Plass Final Line Badness Rule recall

In the Knuth-Plass line-breaking DP for paragraph justification, why is the slack penalty (badness) of the **final line** of the paragraph ($j = n$, provided its width is $\le L$) defined to be $0$ instead of $(L - W(i, n))^3$?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

When extending Knuth-Plass from flat prose paragraphs to source code formatters (`clang-format`, `prettier`, `rustfmt`), why is the 1D state index $i$ (token index) no longer sufficient to satisfy the Markovian Optimal Substructure property?

Key Takeaways

Knuth-Plass line breaking formulates paragraph justification as a DAG shortest path problem over word boundaries with quadratic or cubic slack penalties.
Terminating the inner loop as soon as W(i, j) > L reduces runtime from O(N^2) to O(N * max_words_per_line), which is O(N) for standard column widths.
The final line of a paragraph (j == n) has 0 slack penalty so earlier lines are not distorted to pad the last line.
Modern code formatters (clang-format, prettier, rustfmt) generalize Knuth-Plass to AST token streams with syntax-weighted break demerits and indentation states.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue