Knuth-Plass 算法详解 (optimalLayout)
概述
optimalLayout() 是整个排版系统的核心,实现了经典的 Knuth-Plass 行break算法。与浏览器贪婪策略不同,它通过动态规划找到段落的最优断点组合,使整段文本的间距分布最均匀。
核心思想对比
浏览器贪婪算法(简单但质量差)
flowchart LR
A[填满当前行] --> B[计算剩余空间]
B --> C[均分到所有词间距]
C --> D[换行]
D --> A
问题: 每行独立优化,忽略后续影响,导致间距参差不齐。
Knuth-Plass 全局优化(复杂但质量高)
flowchart TD
A[构建所有可行断点图] --> B[计算每条边的badness]
B --> C[最短路径搜索]
C --> D[回溯得到最优break序列]
style A fill:#e3f2fd
style C fill:#c8e6c9
style D fill:#fff3e0
完整算法流程
graph TD
subgraph 输入
A["prepared<br/>segments + widths"] --> B["maxWidth 容器宽度"]
A --> C["NORMAL_SPACE_W 正常空格宽度"]
A --> D["HYPHEN_WIDTH 连字符宽度"]
end
subgraph 断点候选构建
B --> E["初始化breakCandidates<br/>从segIndex 0开始"]
E --> F{"遍历所有segments"}
F -->|遇到软连字符| G["添加候选:下个seg<br/>isSoftHyphen=true"]
F -->|遇到空格| H["添加候选:下个seg<br/>isSoftHyphen=false"]
F -->|其他| I["继续"]
G --> J
H --> J
I --> F
J --> K["末尾添加end标记"]
end
subgraph DP计算
K --> L["dp[0]=0, 其余=∞"]
L --> M{"遍历j从1到numCandidates"}
M --> N{"遍历i从j-1到0"}
N --> O["计算info<br/>wordWidth + spaceCount"]
O --> P{"total > maxWidth×2"}
P -->|是| Q["break跳出内循环"]
P -->|否| R["cost = dp[i] + lineBadness"]
R --> S{"cost < dp[j]"}
S -->|是| T["dp[j] = cost<br/>prev[j] = i"]
S -->|否| U["保持"]
T --> V["i--"]
U --> V
V --> N
N -->|i<0| W["j++"]
W --> M
M -->|完成| X["开始回溯"]
end
subgraph 回溯构建lines
X --> Y["cur = numCandidates-1"]
Y --> Z{"prev[cur] !== -1"}
Z -->|是| AA["breaks.push cur"]
AA --> AB["cur = prev[cur]"]
Z -->|否| AC["cur--"]
AB --> Z
AC --> Z
Z -->|cur<=0| AD["breaks.reverse"]
AD --> AE["构建lines数组<br/>segments + y + lineWidth"]
end
style M fill:#fff3e0
style R fill:#ffcdd2
style T fill:#c8e6c9
style AD fill:#e8f5e9
代码逐段解析
第一阶段:构建断点候选
断点候选数据结构:
为什么空格后面才是断点?
英文排版约定:行尾不能是空格,所以断点应在下一个单词的起始位置。
第二阶段:计算行信息
关键细节:
| 场景 | 处理逻辑 |
|---|---|
| 软连字符 | continue 不占宽度,但 endsWithHyphen=true 时加 HYPHEN_WIDTH |
| 行末尾空格 | spaceCount-- 排除,不参与间距计算 |
| 空格数量 | 决定了行内有多少个"可拉伸"的间距位置 |
第三阶段:行质量评估(核心!)
Badness 公式图解:
flowchart LR
subgraph 间距评估
A[js = (maxWidth - wordWidth)<br/>/ spaceCount] --> B{js vs 阈值}
B -->|过紧<br/>js < 0.4×normal| C[1e8 严惩]
B -->|正常| D[计算badness<br/>ratio³×1000]
B -->|过松| E[河流检测]
E -->|有河流<br/>js > 1.5×normal| F[river惩罚<br/>river²×1e4+5000]
E -->|无河流| G[基本badness]
D --> H[总计惩罚值]
F --> H
G --> H
end
style C fill:#ffcdd2
style F fill:#fff3e0
style H fill:#c8e6c9
第四阶段:动态规划求解
DP状态转移图:
flowchart LR
subgraph DP过程
A[dp[0]=0] --> B[计算dp[1]]
B --> C[dp[1]=dp[0]+badness<br/>prev[1]=0]
C --> D[计算dp[2]]
D --> E[尝试dp[0]→dp[2]]
D --> F[尝试dp[1]→dp[2]]
E --> G[取最小者]
F --> G
G --> H[...]
end
第五阶段:回溯构建结果
回溯过程图解:
与浏览器排版的关键差异
| 特性 | 浏览器贪婪 | Knuth-Plass |
|---|---|---|
| 考虑范围 | 单行 | 全段落 |
| 间距均匀度 | 差 | 优 |
| 河流抑制 | 无 | 有 |
| 连字符使用 | 少 | 多 |
| 计算复杂度 | O(n) | O(n²) |
性能优化技巧
1. 提前终止 (total > maxWidth * 2)
这行代码至关重要:
- 当行宽度超过容器2倍时,必然不是最优解
- 跳过对后续i的探索,将复杂度从 O(n³) 降至接近 O(n²)
2. Infinity 剪枝
不可达状态直接跳过,避免无效计算。
3. 倒序遍历i
从右向左遍历可以在找到超宽行时立即 break。
扩展讨论:如何优化更多场景
1. 孤立词惩罚(Orphan control)
2. Widow/Orphan 行数控制
3. 多段落联合优化
当前实现按段落独立优化,可扩展为跨段落考虑,使页面整体更均衡。