在文本排版场景中,左右对齐是一项基础且核心的需求,它要求在固定宽度下,通过合理分配空格实现排版美观,同时遵循特定规则。LeetCode 68题正是对这一场景的算法抽象,要求我们用贪心策略解决文本对齐问题。本文将深入剖析题目逻辑,拆解两种不同实现方案的思路,对比其优劣,帮助大家彻底掌握这道经典算法题。
一、题目核心要求梳理
在动手分析代码前,我们先明确题目中的关键约束和目标,这是理解算法逻辑的前提:
-
贪心策略优先:每行尽可能多放单词,这是排版的核心原则,决定了我们的行划分逻辑。
-
空格分配规则:单词间空格需尽可能均匀;若无法均匀分配,左侧空格数多于右侧。
-
特殊行处理:最后一行左对齐,单词间仅保留一个空格,剩余空格填充在行尾。
-
边界条件:单词长度大于0且不超过maxWidth,数组至少含一个单词,无需处理空单词或超长单词场景。
二、方案一:逐词遍历+临时拼接
第一种实现采用逐词遍历的方式,通过临时变量记录当前行的单词拼接结果、字符总数,实时判断是否能容纳下一个单词,进而处理行对齐逻辑。我们逐段拆解代码逻辑:
1. 核心变量定义
typescript
/**
* 方案一:逐词遍历+临时拼接实现文本左右对齐
* @param words 待对齐的单词数组
* @param maxWidth 每行最大字符数
* @returns 对齐后的文本行数组
*/
function fullJustify_1(words: string[], maxWidth: number): string[] {
const wL = words.length; // 单词总数,用于控制遍历边界
const lines: string[] = new Array(); // 存储最终对齐后的所有文本行
let startInd = 0; // 当前行起始单词在words数组中的索引
let lineChNum = 0; // 当前行已累计的字符数(包含单词长度和已添加的空格)
let tempStr = ''; // 临时拼接当前行的文本内容
// 遍历所有单词,逐词构建每行文本
for (let i = 0; i< wL; i++) {
// 累加当前单词长度,判断是否能加入当前行
lineChNum += words[i].length;
// 情况1:累计字符数小于maxWidth,可继续添加单词(加1个空格分隔)
if (lineChNum < maxWidth) {
lineChNum++; // 预留1个空格的位置,更新累计字符数
tempStr += words[i] + ' '; // 拼接单词和空格
}
// 情况2:累计字符数超过maxWidth,当前单词无法加入本行,需处理本行对齐
else if (lineChNum > maxWidth) {
// 计算本行需填充的总空格数:maxWidth - 本行所有单词总长度(扣除当前单词,修正空格误差)
const blankNum = maxWidth - lineChNum + words[i].length + (i - startInd);
// 子情况2.1:本行仅1个单词,直接在单词后填充所有剩余空格
if (i - 1 === startInd) {
tempStr = words[startInd] + ' '.repeat(blankNum >= 0 ? blankNum : 0);
}
// 子情况2.2:本行多个单词,均匀分配空格(左侧多于右侧)
else {
const gapCount = i - 1 - startInd; // 单词间的间隔数(单词数-1)
const everyBlack = Math.floor(blankNum / gapCount); // 每个间隔基础空格数
let resBlack = blankNum % gapCount; // 无法均匀分配时,剩余需左偏的空格数
tempStr = ''; // 重置临时字符串,重新拼接对齐后的内容
// 遍历本行单词,按规则填充空格
for (; startInd < i - 1; startInd++) {
// 左侧间隔填充基础空格数+1,用完剩余空格后仅填基础空格数
tempStr += words[startInd] + ' '.repeat(everyBlack) + (resBlack-- > 0 ? ' ' : '');
}
tempStr += words[i - 1]; // 拼接本行最后一个单词(无后续空格)
}
lines.push(tempStr); // 将对齐后的本行加入结果数组
// 重置变量,将当前单词作为下一行的起始
tempStr = words[i] + ' ';
lineChNum = words[i].length + 1; // 初始化下一行累计字符数(单词+1个空格)
startInd = i;
}
// 情况3:累计字符数恰好等于maxWidth,本行完整拼接完成
else {
tempStr += words[i]; // 拼接最后一个单词(无额外空格)
lines.push(tempStr); // 加入结果数组
// 重置变量,准备下一行
tempStr = '';
lineChNum = 0;
startInd = i + 1;
}
}
// 处理最后一行(遍历结束后未被处理的剩余内容,左对齐)
if (lineChNum !== 0) {
const blackNum = maxWidth - lineChNum; // 最后一行需补充的空格数
if (blackNum >= 0) {
lines.push(tempStr + ' '.repeat(blackNum)); // 行尾填充空格
} else {
lines.push(tempStr.slice(0, maxWidth)); // 极端情况截取(题目约束下基本不会触发)
}
}
return lines; // 返回最终对齐结果
}
2. 主遍历逻辑:划分行并处理对齐
循环遍历每个单词,通过累计字符数判断是否能加入当前行,分三种情况处理:
-
字符数不足maxWidth:将单词和一个空格加入临时字符串,同时更新累计字符数(单词长度+1个空格)。
-
字符数超过maxWidth :说明当前单词无法加入本行,需对已选中的单词(startInd到i-1)进行对齐处理:
处理完本行后,重置临时变量,将当前单词作为下一行的起始。
-
计算总空格数:
blankNum = maxWidth - (累计字符数 - 当前单词长度 - 多余空格数),本质是本行总长度减去所有单词长度,得到需填充的空格总数。 -
单行仅一个单词:直接在单词后填充剩余所有空格。
-
多行多个单词:计算平均空格数(everyBlack)和剩余空格数(resBlack),左侧单词间填充平均空格数+1,用完剩余空格后,后续单词间填充平均空格数。
-
-
字符数恰好等于maxWidth:直接将单词加入临时字符串,完整一行拼接完成,存入结果数组并重置变量。
3. 处理最后一行
遍历结束后,若临时变量不为空(说明存在未处理的最后一行),则左对齐拼接:在临时字符串后填充剩余空格,确保总长度为maxWidth。
4. 方案一特点
优点是逻辑直观,逐词处理易理解;缺点是临时字符串拼接可能产生额外性能开销,且空格数计算逻辑稍显繁琐,需手动修正累计误差,可读性一般。
三、方案二:区间划分+精准计算
第二种实现更优雅,先通过双指针划分出当前行的单词区间(left到right-1),再根据区间特点计算空格分布,避免了临时字符串的频繁拼接,逻辑更清晰。
1. 核心变量与区间划分
typescript
const ans = [];
let right = 0, n = words.length;
while (true) {
const left = right; // 当前行的第一个单词在 words 的位置
let sumLen = 0; // 统计这一行单词长度之和
while (right < n && sumLen + words[right].length + right - left<= maxWidth) {
sumLen += words[right].length;
right++;
}
// 当前行是最后一行:单词左对齐,且单词之间应只有一个空格,在行末填充剩余空格
if (right === n) {
const s = words.slice(left).join(' ');
ans.push(s + blank(maxWidth - s.length));
break;
}
const numWords = right - left;
const numSpaces = maxWidth - sumLen;
// 当前行只有一个单词:该单词左对齐,在行末填充空格
if (numWords === 1) {
ans.push(words[left] + blank(numSpaces));
continue;
}
// 当前行不只一个单词
const avgSpaces = Math.floor(numSpaces / (numWords - 1));
const extraSpaces = numSpaces % (numWords - 1);
const s1 = words.slice(left, left + extraSpaces + 1).join(blank(avgSpaces + 1)); // 拼接额外加一个空格的单词
const s2 = words.slice(left + extraSpaces + 1, right).join(blank(avgSpaces)); // 拼接其余单词
ans.push(s1 + blank(avgSpaces) + s2);
}
return ans;
// 辅助函数:生成n个空格组成的字符串
const blank = (n: number): string => {
return new Array(n).fill(' ').join('');
}
区间划分的关键条件:sumLen + words[right].length + right - left <= maxWidth。其中right - left是本行单词间需至少保留的空格数(每个单词间一个空格),确保加入下一个单词后不会超出宽度,完美体现贪心"尽可能多放单词"的原则。
2. 分场景对齐处理
-
最后一行:当right === n时,说明是最后一行,将区间内单词用单个空格连接,行尾填充剩余空格,左对齐完成。
-
单行仅一个单词:单词左对齐,行尾填充所有剩余空格(numSpaces = maxWidth - sumLen)。
-
多行多个单词:
-
计算总空格数numSpaces = maxWidth - sumLen。
-
平均空格数avgSpaces = Math.floor(numSpaces / (numWords - 1)),剩余空格数extraSpaces = numSpaces % (numWords - 1)。
-
前extraSpaces+1个单词间填充avgSpaces+1个空格(s1),剩余单词间填充avgSpaces个空格(s2),拼接s1和s2即完成本行对齐。
-
3. 辅助函数blank
单独封装空格生成函数,通过数组fill+join实现,比字符串repeat更易读,也便于复用和修改。
4. 方案二特点
优点是逻辑分层清晰(先划分区间,再处理对齐),空格计算精准无误差,避免临时字符串频繁修改,性能更优,可读性远超方案一,是更推荐的实现方式。
四、两种方案对比与总结
| 对比维度 | 方案一(逐词拼接) | 方案二(区间划分) | |
|---|---|---|---|
| 核心思路 | 逐词累加,实时判断行边界 | 双指针划分区间,批量处理 | |
| 可读性 | 一般,空格计算繁琐 | 优秀,逻辑分层清晰 | |
| 性能 | 一般,临时字符串拼接有开销 | 更优,无冗余拼接操作 | |
| 适用场景 | 入门理解题目逻辑 | 实际开发、算法优化 |
五、关键知识点提炼
-
贪心策略的应用:本题的贪心核心是"每行尽可能多放单词",通过区间划分或逐词累加实现这一目标,是解题的基础。
-
空格分配逻辑:当单词间空格无法均匀分配时,左侧多右侧少,本质是"取余优先左分配",这是满足题目排版要求的关键。
-
边界处理:最后一行的左对齐、单行仅一个单词的对齐,是容易出错的点,需单独拎出处理,避免通用逻辑覆盖特殊场景。
六、总结
LeetCode 68题的核心的是对贪心策略的灵活运用和边界场景的细致处理。方案二通过区间划分+精准计算,实现了代码的简洁性和高效性,是更值得借鉴的实现方式。在实际开发中,类似的文本排版问题,也可参考"先确定范围,再批量处理"的思路,提升代码的可读性和性能。
希望本文的解析能帮助大家彻底理解这道题,同时掌握贪心算法在实际场景中的应用技巧,遇到类似问题时能快速找到解题思路。