LeetCode 68. 文本左右对齐:贪心算法的两种实现与深度解析

在文本排版场景中,左右对齐是一项基础且核心的需求,它要求在固定宽度下,通过合理分配空格实现排版美观,同时遵循特定规则。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. 主遍历逻辑:划分行并处理对齐

循环遍历每个单词,通过累计字符数判断是否能加入当前行,分三种情况处理:

  1. 字符数不足maxWidth:将单词和一个空格加入临时字符串,同时更新累计字符数(单词长度+1个空格)。

  2. 字符数超过maxWidth :说明当前单词无法加入本行,需对已选中的单词(startInd到i-1)进行对齐处理:

    处理完本行后,重置临时变量,将当前单词作为下一行的起始。

    • 计算总空格数:blankNum = maxWidth - (累计字符数 - 当前单词长度 - 多余空格数),本质是本行总长度减去所有单词长度,得到需填充的空格总数。

    • 单行仅一个单词:直接在单词后填充剩余所有空格。

    • 多行多个单词:计算平均空格数(everyBlack)和剩余空格数(resBlack),左侧单词间填充平均空格数+1,用完剩余空格后,后续单词间填充平均空格数。

  3. 字符数恰好等于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. 分场景对齐处理

  1. 最后一行:当right === n时,说明是最后一行,将区间内单词用单个空格连接,行尾填充剩余空格,左对齐完成。

  2. 单行仅一个单词:单词左对齐,行尾填充所有剩余空格(numSpaces = maxWidth - sumLen)。

  3. 多行多个单词

    • 计算总空格数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. 方案二特点

优点是逻辑分层清晰(先划分区间,再处理对齐),空格计算精准无误差,避免临时字符串频繁修改,性能更优,可读性远超方案一,是更推荐的实现方式。

四、两种方案对比与总结

对比维度 方案一(逐词拼接) 方案二(区间划分)
核心思路 逐词累加,实时判断行边界 双指针划分区间,批量处理
可读性 一般,空格计算繁琐 优秀,逻辑分层清晰
性能 一般,临时字符串拼接有开销 更优,无冗余拼接操作
适用场景 入门理解题目逻辑 实际开发、算法优化

五、关键知识点提炼

  1. 贪心策略的应用:本题的贪心核心是"每行尽可能多放单词",通过区间划分或逐词累加实现这一目标,是解题的基础。

  2. 空格分配逻辑:当单词间空格无法均匀分配时,左侧多右侧少,本质是"取余优先左分配",这是满足题目排版要求的关键。

  3. 边界处理:最后一行的左对齐、单行仅一个单词的对齐,是容易出错的点,需单独拎出处理,避免通用逻辑覆盖特殊场景。

六、总结

LeetCode 68题的核心的是对贪心策略的灵活运用和边界场景的细致处理。方案二通过区间划分+精准计算,实现了代码的简洁性和高效性,是更值得借鉴的实现方式。在实际开发中,类似的文本排版问题,也可参考"先确定范围,再批量处理"的思路,提升代码的可读性和性能。

希望本文的解析能帮助大家彻底理解这道题,同时掌握贪心算法在实际场景中的应用技巧,遇到类似问题时能快速找到解题思路。

相关推荐
努力学算法的蒟蒻2 小时前
day67(1.26)——leetcode面试经典150
算法·leetcode·面试
iAkuya2 小时前
(leetcode) 力扣100 52腐烂的橘子(BFS)
算法·leetcode·宽度优先
老鼠只爱大米2 小时前
LeetCode经典算法面试题 #148:排序链表(插入、归并、快速等五种实现方案解析)
算法·leetcode·链表·插入排序·归并排序·快速排序·链表排序
ShoreKiten2 小时前
ctfshow-web316
运维·服务器·前端
前端 贾公子2 小时前
release-it 使用指南
前端·javascript
木井巳2 小时前
【递归算法】计算布尔二叉树的值
java·算法·leetcode·深度优先
睡一觉就好了。2 小时前
直接选择排序
数据结构·算法·排序算法
全栈技术负责人2 小时前
前端团队 AI Core Workflow:从心法到落地
前端·人工智能·状态模式