LeetCode 6. Z 字形变换:两种解法深度解析与优化
在字符串处理类算法题中,Z 字形变换是一道经典的「规律提炼 + 代码优化」双重点题型,频繁出现在面试与算法练习中。它的核心难点不在于实现功能,而在于如何精准捕捉 Z 字形排列的周期性规律,同时兼顾代码的可读性与执行效率。本文将从题目理解出发,详细拆解两种主流解法,对比其优劣,并补充关键注意事项,帮助大家彻底掌握这道题。
一、题目核心解析
题目描述
给定一个字符串 s 和一个整数 numRows,将字符串按从上往下、从左到右的顺序排列成 Z 字形,再从左到右逐行读取字符,返回拼接后的新字符串。
示例演示
输入:s = "PAYPALISHIRING", numRows = 3
Z 字形排列效果:
P A H N
A P L S I I G
Y I R
逐行读取结果:"PAHNAPLSIIGYIR"
核心要求
实现函数 convert(s: string, numRows: number): string,高效完成字符串变换,需重点关注边界场景与性能。
二、解法一:数学周期法(公式推导型)
核心思路
Z 字形排列的本质是「周期性重复」,我们先提炼其周期规律:
-
一个完整的 Z 字周期包含
2*(numRows-1)个字符(下行阶段占numRows个,上行阶段占numRows-2个,不含首尾重复行); -
首行与末行:每个周期内仅存在「垂直列」字符,无斜列字符;
-
中间行(非首非末):每个周期内同时存在「垂直列」和「斜列」两个字符,需分别计算位置。
基于此规律,我们可通过数学公式直接定位每个字符在结果中的位置,无需模拟完整 Z 字形排列。
代码实现与逐行解读
function convert_1(s: string, numRows: number): string {
// 边界条件1:行数为1时,无法形成Z字,直接返回原字符串
if (numRows === 1) return s;
const sL = s.length;
// 计算一个完整Z字周期的字符数(核心变量)
const groupNum = (numRows - 1) * 2;
let res = '';
// 外层循环:逐行读取结果
for (let i = 0; i < numRows; i++) {
// 内层循环:逐周期遍历,定位当前行的字符
for (let j = 0; j < Math.ceil(sL / groupNum); j++) {
// 1. 定位当前周期内当前行的垂直列字符索引
const verticalIdx = i + j * groupNum;
if (verticalIdx >= sL) break; // 超出字符串长度,终止当前行遍历
res += s[verticalIdx];
// 2. 仅中间行需要补充斜列字符
if (i > 0 && i < numRows - 1) {
const diagonalIdx = groupNum - i + j * groupNum;
if (diagonalIdx >= sL) break; // 斜列字符超出范围,跳过
res += s[diagonalIdx];
}
}
}
return res;
}
关键注意事项
-
边界处理:必须优先判断
numRows === 1,否则groupNum会计算为 0,导致循环逻辑异常; -
索引合法性:每次计算垂直列、斜列索引后,需判断是否超出字符串长度,避免越界报错;
-
斜列字符范围:仅中间行存在斜列字符,首行与末行需跳过此步骤,否则会重复拼接字符。
三、解法二:方向模拟法(直观优化型)
优化思路
数学周期法虽空间效率高,但公式推导对新手不友好,且 JavaScript 中字符串不可变,频繁使用res += 会创建大量临时字符串,存在性能损耗。方向模拟法通过「模拟字符移动轨迹」简化逻辑,同时用数组优化字符串拼接:
-
用数组
rows存储每一行的字符,避免频繁字符串拼接; -
定义
currentRow跟踪当前字符所在行,step控制移动方向(1 向下,-1 向上); -
当到达首行或末行时,反转移动方向,实现 Z 字形的「下落-上升」循环。
代码实现与逐行解读
function convert_2(s: string, numRows: number): string {
// 边界条件优化:行数为1 或 行数≥字符串长度,直接返回原字符串
if (numRows === 1 || numRows >= s.length) return s;
// 初始化每行存储容器,用数组避免频繁字符串拼接
const rows: string[] = new Array(numRows).fill('');
const cycleLen = 2 * (numRows - 1); // 周期长度(仅作逻辑参考,非必需)
let currentRow = 0; // 当前字符所在行
let step = -1; // 移动方向:-1向上,1向下
// 遍历每个字符,分配到对应行
for (const char of s) {
rows[currentRow] += char;
// 到达首行/末行,反转移动方向
if (currentRow === 0 || currentRow === numRows - 1) {
step = -step;
}
currentRow += step;
}
// 拼接所有行字符,得到最终结果
return rows.join('');
}
关键注意事项
-
边界条件补充:新增
numRows >= s.length判断,此时字符串无法形成 Z 字,直接返回原字符串,减少无效计算; -
方向初始化:
step初始值设为 -1,是因为首次进入循环后,currentRow为 0,会触发方向反转,使第一步移动方向为向下(step=1); -
数组优化:JavaScript 中数组拼接效率远高于字符串累加,尤其适合长字符串场景,这是该解法的核心性能优势。
四、两种解法对比与场景选择
| 维度 | 数学周期法 | 方向模拟法 |
|---|---|---|
| 时间复杂度 | O(n)(每个字符仅访问一次) | O(n)(每个字符仅访问一次) |
| 空间复杂度 | O(1)(仅用临时变量,无额外存储) | O(n)(需数组存储每行字符) |
| 可读性 | 较差,需理解公式推导逻辑 | 优秀,模拟过程直观易懂 |
| 性能表现 | 长字符串场景下,因频繁字符串拼接略逊 | 数组优化拼接,性能更稳定 |
| 适用场景 | 低内存环境、对空间效率要求极高的场景 | 日常开发、面试答题(兼顾可读性与性能) |
五、测试验证与常见问题
测试用例覆盖
// 测试用例1:基础场景
console.log(convert_1("PAYPALISHIRING", 3)); // 输出:PAHNAPLSIIGYIR
console.log(convert_2("PAYPALISHIRING", 3)); // 输出:PAHNAPLSIIGYIR
// 测试用例2:行数为4
console.log(convert_1("PAYPALISHIRING", 4)); // 输出:PINALSIGYAHRPI
console.log(convert_2("PAYPALISHIRING", 4)); // 输出:PINALSIGYAHRPI
// 测试用例3:边界场景(行数=1)
console.log(convert_1("A", 1)); // 输出:A
console.log(convert_2("A", 1)); // 输出:A
// 测试用例4:边界场景(行数≥字符串长度)
console.log(convert_2("ABCDE", 5)); // 输出:ABCDE
常见问题排查
-
字符重复/缺失:大概率是斜列字符判断逻辑错误,需检查
i > 0 && i < numRows - 1条件是否遗漏; -
越界报错:未判断索引合法性,需在拼接字符前校验
verticalIdx和diagonalIdx是否小于字符串长度; -
方向异常:
step初始化错误或方向反转逻辑缺失,需确保到达首行/末行时反转方向。
六、总结
Z 字形变换的核心是「抓住周期性」,两种解法均基于这一核心规律,只是实现路径不同:数学周期法偏重于公式推导,追求极致空间效率;方向模拟法偏重于直观模拟,用合理的空间换可读性与性能。
在面试中,推荐优先使用方向模拟法,其逻辑清晰、不易出错,能快速向面试官展现思路;若面试官追问空间优化,可再补充数学周期法的思路。同时,边界条件的全面覆盖的是这道题的得分关键,需牢记行数为 1、行数≥字符串长度这两个特殊场景。
掌握这道题的思考方式,能为后续解决字符串、数组类的规律型题目提供借鉴,核心是学会从复杂排列中提炼简化规律,再通过代码优化平衡效率与可读性。