LeeCode 006-Z 字形变换
- 题目地址:6. Z 字形变换(中等)
- 标签:字符串
- 题目描述:
将一个给定字符串 s 根据给定的行数 numRows ,以从上往下、从左到右进行 Z 字形排列。
比如输入字符串为 “PAYPALISHIRING” 行数为 3 时,排列如下:
之后,你的输出需要从左往右逐行读取,产生出一个新的字符串,比如:“PAHNAPLSIIGYIR”。
string convert(string s, int numRows);
理解题意
- 根据官方的例子,我们首先要明白题中的z字形是怎么来的。
- 上方是博主绘制的第一个z字形循环,符合题意描述的从上至下,从左到右。(其实从视觉上看是顺时针旋转90度的z)
- 进一步理解:z的边长其实就是题中所给的numRows,且根据题中所绘制的图案不难发现,z字的三个边长是等长的。
解题思路
构建矩阵
- z字绘制流程清晰之后,便是将流程数字化找到其循环规律:
首先找到循环的一个周期,虽然题目引导我们绘制z,但是我们发现再绘制第一个z(PAYPALI)后,第二个z的起点又要回到A,是有字母重叠的,因此我们在一个周期里可以将z的最后一笔去掉,那么一个周期循环就变成了一个类似斜v,见下图。
- 绘制规律找到后,思路蓝图基本出来:最直接想到的便是创建一个矩阵,根据绘制流程,向矩阵中填入字符串,最后遍历矩阵,过滤掉无用的占位数字。
- 构建矩阵的第一步便是找到矩阵的长宽,宽就是题目所给参数numRows,长度不难发现就是循环周期的个数*一个周期的长度、周期个数=字符串长度/一个周期的字母数。
- 理清思路开始赋值即可
// 字符串长度 const len = s.length // 一个周期的字符串个数(一共2笔边长,减去底部交点,减去右上角-因为是下一个周期的起点) const cycle = 2 * numRows - 2 // 一共有n个周期(这里加上cycle-1的含义是避免有剩下的字母不够一个周期)见下图 let n = Math.floor((len + cycle -1)/ cycle)
可以看到最后一个黄色的周期,已经只剩下两个字母(N、G)不够一个周期,如果直接Math.floor((len/ cycle)则会漏掉这个周期的字母,因此为了避免剩余字母,加上一个周期的字母数-1,譬如上述例子:2+cycle-1 = cycle +1 这样在运算时,就会增加一个周期。同时如果本来不多字母的话,加上(cycle-1)/cycle 之后小于1,Math.floor也无法进位。
// 矩阵的列数(周期数*一个周期的列数)
const columns = n * (numRows - 1)
- 至此矩阵的行数、列数便已清晰,之后便需要构造初始全是0占位的矩阵。
// 根据矩阵的列数与行数生成矩阵,并初始化填充0
const initial = new Array(numRows).fill(0).map(() => new Array(columns).fill(0))
- 初始矩阵构造之后,便要根据绘制流程,将指定位置的0替换成字符串中的字母。(先向下x++,再右上x–、y++)根据i%cycle与numRows的大小判断下一步移动。
// 根据z字形将字符串填充至矩阵
let x = 0, y = 0
for (let i = 0; i < len; i++) {
initial[x][y] = s[i]
// i模cycle小于行数时,x++,即向下移动
if (i%cycle < numRows - 1) {
x++
} else {
// 反之,y++,即向右上移动
x--
y++
}
}
- 至此得到了结果矩阵,只需将其遍历过滤占位0即可。
let result = ''
for (let i = 0; i < numRows; i++) {
for (let y = 0; y < columns; y++) {
if (initial[i][y] !== 0) {
result += initial[i][y]
}
}
}
return result
- 完整代码
/**
* @param {string} s
* @param {number} numRows
* @return {string}
*/
var convert = function (s, numRows) {
// 字符串长度
const len = s.length
// 注意特殊情况,只有一行或者一列时直接返回字符串
if (numRows === 1 || numRows >= len) {
return s;
}
// 一个周期的字符串个数
const cycle = 2 * numRows - 2
// 一共有n个周期
let n = Math.floor((len)/ cycle)
// 矩阵的列数(周期数*一个周期的列数)
const columns = n * (numRows - 1) + cycle -1
console.log(cycle,n, columns)
// 根据矩阵的列数与行数生成矩阵,并初始化填充0
const initial = new Array(numRows).fill(0).map(() => new Array(columns).fill(0))
// 根据z字形将字符串填充至矩阵
let x = 0, y = 0
for (let i = 0; i < len; i++) {
initial[x][y] = s[i]
// x小于行数时,x++,即向下移动
if (i%cycle < numRows - 1) {
x++
} else {
// 反之,y++,即向右上移动
x--
y++
}
}
// 遍历矩阵,过滤占位数字0,得到结果
let result = ''
for (let i = 0; i < numRows; i++) {
for (let y = 0; y < columns; y++) {
if (initial[i][y] !== 0) {
result += initial[i][y]
}
}
}
return result
};
复杂度分析
- 时间复杂度:O(numRows⋅n),n 为字符串 s 的长度。时间主要消耗在矩阵的创建和遍历上,矩阵的行数为 numRows,列数可以视为 O(n)。
- 空间复杂度:O(numRows⋅n)。矩阵需要O(numRows⋅n) 的空间。
优化-压缩矩阵
- 上述构造的矩阵不难发现,使用了大量的无用空间0,那么优化的第一个思路便是能否将占位0全部优化?
- 观察最后矩阵填入字符的过程:每次是都向该行的最后push新字母。(可以观察博主上方绘制的周期示意图,每行的最新字母都在本行末尾)
- 所以思路很清晰了,只需要构造给定行数的空列表,根据构造位移规律,依次向目标行push字母即可。
// 根据矩阵的行数生成压缩矩阵,并初始化构造空列表
const initial = new Array(numRows)
for (let i = 0; i < numRows; ++i) {
initial[i] = [];
}
// 根据z字形将字符串填充至矩阵,此时无需y,只需找到x位置即可(上下移动)
let x = 0
for (let i = 0; i < len; i++) {
initial[x].push(s[i])
// 模小于行数时,x++,即向下移动
if (i%cycle < numRows - 1) {
x++
} else {
// 反之,x++,即向上移动
x--
}
}
// 遍历矩阵,过滤占位数字0,得到结果
const res = [];
for (const row of initial) {
res.push(row.join(''));
}
return res.join('')
(为了让大家看懂没有优化代码,只提供思路,其实可以将构造矩阵代码优化,譬如:不用循环初始数组,直接fill字符串,每次遍历追加。)
复杂度分析
- 时间复杂度:O(n)。
- 空间复杂度:O(n)。压缩后的矩阵需要 O(n) 的空间。
优化-直接构造法
-
纯找规律的数学公式法:直接找到矩阵的每个非空字符会对应到 s 的哪个下标(记作 idx),从而直接构造出答案。
-
由于 Z 字形变换的周期为 t=2r−2,因此对于矩阵第一行的非空字符,其对应的 idx 均为 t 的倍数,即 idx≡0(modt);同理,对于矩阵最后一行的非空字符,应满足 idx≡r−1(modt)。
-
对于矩阵的其余行(行号设为 i),每个周期内有两个字符,第一个字符满足 idx≡i(modt),第二个字符满足 idx≡t−i(modt)。
-
上面是官方的原话,博主感觉很明了就直接cv过来了,有的小伙伴可能不太理解,其实一张图就可以解释。
其实官方说的两个字符的含义是两种(个)字符,第一种是在z竖直边的字符,第二种是在z的斜边。(每行的每个周期)
-
第一种(个)字符所在行数就是字符下标 i 模行数的结果(本质就是当前的行数+n个周期) idx≡i(modt)
-
第二种(个)用逆向思维,一个周期的最后一个字符是下一个周期的第一个字母下标i-1的结果,也就是nt-1,斜边的倒数第二个呢?没错,就是nt-2 =》 归纳:在斜边的第i行 字母下标(本质就是n个周期-当前行数): idx≡t−i(modt)
-
通过上述归纳,其实在结果中每个周期内的每行中只有两种,也分别找到了数学表达式,那么开始编程。
/**
* @param {string} s
* @param {number} numRows
* @return {string}
*/
var convert = function(s, numRows) {
let len = s.length
if (numRows === 1 || numRows >= len) {
return s;
}
// 一个周期所含字符个数
let cycle = 2*numRows -2
let res = []
// 遍历行数
for(let i =0; i <numRows;i++){
// 遍历不同周期起点
for(let y = 0;y<len;y+=cycle){
// 第一个字符是直边上的
res.push(s[y+i])
if (0 < i && i < numRows - 1 && y + cycle - i < len) {
//第二个字符在斜边(本周期可能在当前行没有第二个字符)
res.push(s[y + cycle - i]);
// 当前周期的第二个字符(为什么+t,已经解释了:逆向思维,下一个周期-行数)
}
}
}
return res.join('');
};
复杂度分析
- 时间复杂度:O(n),其中 n 为字符串 s 的长度。s 中的每个字符仅会被访问一次,因此时间复杂度为 O(n)。
- 空间复杂度:O(1)。返回值不计入空间复杂度。