题目
将一个给定字符串 s
根据给定的行数 numRows
,以从上往下、从左到右进行 Z 字形排列。
比如输入字符串为 "PAYPALISHIRING"
行数为 3
时,排列如下:
P A H N A P L S I I G Y I R
之后,你的输出需要从左往右逐行读取,产生出一个新的字符串,比如:"PAHNAPLSIIGYIR"
。
请你实现这个将字符串进行指定行数变换的函数:
string convert(string s, int numRows);
示例 1:
输入:s = "PAYPALISHIRING", numRows = 3 输出:"PAHNAPLSIIGYIR"
示例 2:
输入:s = "PAYPALISHIRING", numRows = 4 输出:"PINALSIGYAHRPI" 解释: P I N A L S I G Y A H R P I
示例 3:
输入:s = "A", numRows = 1 输出:"A"
提示:
1 <= s.length <= 1000
s
由英文字母(小写和大写)、','
和'.'
组成1 <= numRows <= 1000
代码展示
class Solution {
public:
string convert(string s, int numRows) {
if (numRows == 1 || numRows >= s.size()) return s;
vector rows(min(numRows, int(s.size())));
int n = s.size();
int cycleLen = 2 * numRows - 2; // 一个完整的Z字形周期长度
for (int i = 0; i < n; i++) {
int pos = i % cycleLen;
int row = numRows - abs(numRows - 1 - pos);
rows[row - 1].push_back(s[i]);
}
string ret;
for (const auto& row : rows) {
ret += row;
}
return ret;
}
};
代码的逐行详细解释
在Z字形排列中,当字符串按Z字形排列时,每个字符都会遵循一定的规律。假设我们有numRows
行,那么从第一行到最后一行再回到第二行的路径形成了一个周期。
举例说明
假设我们有numRows = 3
,那么Z字形排列如下:
P A H N
A P L S I I G
Y I R
可以看到,字符的排列顺序是:
- 第一行的字符每隔
2 * numRows - 2
个字符出现一次; - 第二行的字符每隔
numRows - 2
个字符出现一次; - 最后一行的字符同样每隔
2 * numRows - 2
个字符出现一次。
对于第一行和最后一行,它们的周期是一样的,即每隔2 * numRows - 2
个字符出现一次。这是因为:
- 字符从第一行跳转到最后一个字符需要经过
numRows - 1
个空格; - 再从最后一个字符跳转回第一行需要再经过
numRows - 1
个空格; - 因此总长度是
numRows - 1 + (numRows - 1) = 2 * numRows - 2
。
对于中间行,周期则是不同的。中间行的字符会交替出现在两行之间,其间隔会短一些。
数学解释
对于任意的numRows
,我们可以这样理解:
- 每次从第一行跳到最后一行,再到第一行,构成了一个完整的周期;
- 跳跃的总长度是
numRows - 1
(从第一行跳到最后一行)加上numRows - 1
(从最后一行跳回第一行); - 所以,一个完整的周期长度为
2 * (numRows - 1)
,即2 * numRows - 2
。
cycleLen = 2 * numRows - 2
确定了字符在Z字形排列中的周期性规律,使得我们可以简单地通过模运算和除法来确定每个字符应该放置的位置。
解释 pos = i % cycleLen
i
是当前字符在原始字符串中的索引,cycleLen
是一个完整的Z字形周期的长度。i % cycleLen
计算的是当前字符在当前周期内的相对位置。
为什么使用模运算?
模运算 (%
) 可以确保索引不会超出周期长度的范围。例如,在一个周期长度为 cycleLen
的情况下,如果 i
超过了 cycleLen
,模运算就会给出 i
在 cycleLen
范围内的对应位置。这样可以保证我们在计算行数时不会出错。
如何确定行号?
接下来,我们需要根据 pos
来确定当前字符应该放在哪一行。由于Z字形排列的特性,我们可以根据 pos
来计算出当前字符应该位于哪一行。
对于 numRows
行的情况,周期长度为 2 * numRows - 2
,因此:
- 当
pos
在[0, numRows - 1)
范围内时,字符位于从上往下数的第pos
行; - 当
pos
在[numRows, 2 * numRows - 2)
范围内时,字符位于从下往上数的第2 * numRows - 2 - pos
行。
为了简化计算,我们可以统一使用一个公式来确定行号:
int row = numRows - abs(numRows - 1 - pos);
这个公式可以理解为:
- 如果
pos
在[0, numRows - 1)
范围内,那么numRows - 1 - pos
是正数,abs
不影响结果,row
就是pos
; - 如果
pos
在[numRows, 2 * numRows - 2)
范围内,那么numRows - 1 - pos
是负数,abs
会取绝对值,row
就是2 * numRows - 2 - pos
。
在这个例子中,s
是 "PAYPALISHIRING"
,numRows
是 3
。
- 计算周期长度:
cycleLen = 2 * numRows - 2 = 4
。 - 遍历字符串:
- 对于
i = 0
,pos = 0 % 4 = 0
,row = 3 - abs(3 - 1 - 0) = 3 - 2 = 1
,字符'P'
放在第一行。 - 对于
i = 1
,pos = 1 % 4 = 1
,row = 3 - abs(3 - 1 - 1) = 3 - 1 = 2
,字符'A'
放在第二行。 - 对于
i = 2
,pos = 2 % 4 = 2
,row = 3 - abs(3 - 1 - 2) = 3 - 0 = 3
,字符'Y'
放在第三行。 - 对于
i = 3
,pos = 3 % 4 = 3
,row = 3 - abs(3 - 1 - 3) = 3 - 1 = 2
,字符'P'
放在第二行。 - 以此类推,直到字符串遍历结束。
- 对于
最终,rows
中存储了每一行的字符,将这些字符拼接起来就得到了Z字形排列后的字符串。
目标
我们的目标是确定当前字符应该放置在哪一行。我们需要一个公式来根据当前字符在周期内的位置(即pos
)来计算出它所在的行数。
分析
对于一个Z字形排列,我们可以观察到以下规律:
- 当字符处于周期的前半段时(即
pos
小于numRows - 1
),它们从上到下依次放置; - 当字符处于周期的后半段时(即
pos
大于等于numRows - 1
),它们从下往上依次放置。
公式推导
前半段(pos
< numRows - 1
)
对于前半段,pos
表示的是从第一行开始的行数,因此:
- 当
pos
为0时,字符应该放置在第一行; - 当
pos
为1时,字符应该放置在第二行; - ……
- 当
pos
为numRows - 1
时,字符应该放置在最后一行。
因此,对于前半段,行号row
就是pos
。
后半段(pos
≥ numRows - 1
)
对于后半段,我们需要从最后一行开始往回计数,因此:
- 当
pos
为numRows
时,字符应该放置在倒数第二行; - 当
pos
为numRows + 1
时,字符应该放置在倒数第三行; - ……
- 当
pos
为2 * numRows - 2
时,字符应该放置在第一行。
因此,对于后半段,行号row
应该是2 * numRows - 2 - pos
。
统一公式
为了简化代码,我们可以通过一个统一的公式来计算行号row
,无论pos
是在前半段还是后半段。
int row = numRows - abs(numRows - 1 - pos);
公式解析
-
numRows - 1 - pos
:这个表达式给出了一个数值,这个数值的正负取决于pos
是处于前半段还是后半段。- 当
pos
在前半段时,numRows - 1 - pos
为正数; - 当
pos
在后半段时,numRows - 1 - pos
为负数。
- 当
-
abs(numRows - 1 - pos)
:取上述表达式的绝对值,无论pos
处于哪个阶段,这个表达式总是非负的。 -
numRows - abs(numRows - 1 - pos)
:这个表达式确保了row
总是有效的行号。- 当
pos
在前半段时,row
就是pos
; - 当
pos
在后半段时,row
就是2 * numRows - 2 - pos
。
- 当
示例
假设numRows = 3
,周期长度cycleLen = 2 * numRows - 2 = 4
,那么对于pos
的不同值,row
的计算如下:
pos = 0
:row = 3 - abs(3 - 1 - 0) = 3 - 2 = 1
(第一行)pos = 1
:row = 3 - abs(3 - 1 - 1) = 3 - 1 = 2
(第二行)pos = 2
:row = 3 - abs(3 - 1 - 2) = 3 - 0 = 3
(第三行)pos = 3
:row = 3 - abs(3 - 1 - 3) = 3 - 1 = 2
(第二行)
通过这个公式,我们可以统一处理前半段和后半段的情况,从而正确地计算出行号row
。
string ret;
for (const auto& row : rows) {
ret += row;
}
return ret;
代码作用
这段代码的目的是将之前按照Z字形排列分配到各个行的字符重新组合成一个新的字符串。具体来说:
-
初始化结果字符串:
string ret;
创建一个空字符串
ret
,用于存放最终的结果字符串。 -
遍历所有行:
for (const auto& row : rows) { ret += row; }
这里使用了范围基础的
for
循环来遍历rows
向量中的每个字符串。rows
是一个包含每一行字符的vector
。 -
拼接字符串:
ret += row;
对于
rows
中的每一个字符串row
,将其追加到结果字符串ret
的末尾。这样,每一行的字符都会依次被加入到ret
中。 -
返回结果字符串:
return ret;
返回最终的结果字符串
ret
,它包含了按照Z字形排列规则重新组织后的字符序列。
const auto& row : rows
解释
这段代码是一个范围基础的for
循环(range-based for loop),它是C++11引入的一种更加简洁和易读的方式来遍历容器中的元素。
语法解释
-
const
:const
关键字表明在此循环中,row
是一个常量引用,意味着你不能通过row
来修改原始容器中的元素。这样做可以提高效率,因为不需要复制元素。
-
auto
:auto
关键字用来自动推断row
的类型。编译器会根据rows
容器中的元素类型来自动确定row
的类型。这样可以避免手动指定类型,使代码更简洁。
-
&
:&
表示row
是一个引用(reference)。这意味着row
直接引用容器中的元素,而不是复制它们。这样做可以避免在每次循环时复制元素,提高性能。
-
row
:row
是循环体中使用的变量名,代表每次迭代时当前元素的引用。
-
rows
:rows
是我们要遍历的容器(在这里是一个std::vector
)。
逐步解释
-
初始化:
- 在每次循环开始之前,
row
会被初始化为rows
容器中的下一个元素的引用。
- 在每次循环开始之前,
-
循环条件:
- 循环会一直执行,直到所有的元素都被处理完毕。
-
循环体:
- 每次迭代时,
row
代表当前元素的引用。在这个例子中,row
是rows
中的一个字符串。
- 每次迭代时,
-
更新结果字符串:
ret += row;
这一行代码将当前的字符串row
追加到结果字符串ret
的末尾。
示例
假设rows
是一个包含三个字符串的向量:{"PAHN", "YP", "ALSIGYIR"}
。
-
第一次迭代:
row
引用rows[0]
,即"PAHN"
。ret += row;
=>ret = "PAHN"
。
-
第二次迭代:
row
引用rows[1]
,即"YP"
。ret += row;
=>ret = "PAHNY"
。
-
第三次迭代:
row
引用rows[2]
,即"ALSIGYIR"
。ret += row;
=>ret = "PAHNYALSIGYIR"
。
为什么使用const auto&
-
避免复制:
- 使用引用而不是复制元素可以提高性能,特别是在处理较大的对象时。
-
类型推断:
auto
关键字让编译器自动推断类型,减少了代码编写时的工作量,并提高了可读性和维护性。
-
不可变性:
const
关键字确保在循环过程中不会修改容器中的元素,这对于只读操作非常有用。