力扣刷题日常(5-6)
第五题: 最长回文子串(中等)
原题:
给你一个字符串 s
,找到 s
中最长的 回文 子串.
示例 1:
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案.
示例 2:
输入:s = "cbbd"
输出:"bb"
提示:
1 <= s.length <= 1000
s
仅由数字和英文字母组成
概念解释:
- 回文性: 如果字符串向前和向后读都相同,则它满足 回文性.
- 子字符串: 子字符串 是字符串中连续的 非空 字符序列.
原题提示(附翻译):
-
How can we reuse a previously computed palindrome to compute a larger palindrome?(我们如何利用之前计算出的回文序列来计算出更大的回文呢?)
-
If “aba” is a palindrome, is “xabax” a palindrome? Similarly is “xabay” a palindrome?(如果“aba”是一个回文,那么“xabax”也是一个回文吗?同样地,“xabay”也是一个回文吗?)
-
Complexity based hint: If we use brute-force and check whether for every start and end position a substring is a palindrome we have O(n^2) start - end pairs and O(n) palindromic checks. Can we reduce the time for palindromic checks to O(1) by reusing some previous computation.(基于复杂度的提示:如果我们采用穷举法,检查每一个起始位置和结束位置之间的子字符串是否为回文串,那么我们将有 O(n^2) 对起始 - 结束位置组合,以及 O(n) 次回文检查.我们能否通过重复利用之前的计算来将回文检查的时间复杂度降低到 O(1) 呢?)
开始解题:
方法一: 暴力法
我们能想到的第一种办法肯定就是暴力破解法了,我们可以找出所有的子串,然后逐个判断它们是不是回文,然后在所有回文中挑一个最长的.
- 第一步: 找出所有子串. 我们可以用两个嵌套循环,一个确定开头位置
i
,一个确认结尾位置j
.这样就能得到s
所有的子串. - 第二步: 判断是否为回文. 对于每个子串,我们再用两个指针,一个从头往后,一个从后往前,看对应字符是否都相等.
- 第三步: 记录最长. 在这个过程中,我们用一个变量记录下最长的那个回文串.
那为什么这个办法不好呢
假设字符串的长度为 n
.
- 找所有子串,时间复杂度为 O(n²)
- 对每个子串判断是否为回文,平均长度均为 n/2,所以判断一次的复杂度为 O(n).
- 总的时间复杂度就是 O(n³)
那对于题目中的条件 n<=1000
,如果按照1000来算,我们就需要计算10亿次,这个计算量肯定会超时的,所以我们必须要进行优化.
再看原题中给我们的提示,我们有没有办法讲 O(n) 的回文检查降低到 O(1) 呢?
方法二: 中心拓展法
这个方法很巧妙,用到了回文串的核心特性: 对称性.
无论多长的回文串,它必定有一个"中心".这个中心可能是单个字符,也可能是两个字符之间的空隙
- 奇数长度的回文串: 中心是唯一的那个字符.
- 偶数长度的回文串: 中心是中键那两个相同字符之间的"缝隙".
核心逻辑:
我们可以遍历每一个可能的"中心",然后从这个中心向两边同时拓展,看看能拓展出多长的回文串.
具体步骤:
-
遍历所有的中心点: 我们从头到尾遍历输入字符串
s
.对于字符串中的每一个为止i
,它都可能成为一个回文串的中心. -
处理两种中心类型:
-
情况一: 以
s[i]
单个字符为中心我们需要设置两个指针
left = i
,right = i
.然后我们向两边扩展:left
想左移动(left--
),right
向右移动(right++
). 只要left
和right
没有越界,并且s[left]
和s[right]
相等, 这就说明我们找到了一个更长的回文串. -
情况二: 以
s[i]
和s[i+1]
之间的空隙为中心我们设置两个指针
left = i
,right = i + 1
. 然后同样向两边扩展.这可以找出所有偶数长度的回文串.
-
-
记录和更新结果:
在每次扩展后,我们都会得到一个以当前中心能扩展出的最长回文串的长度.我们只需要一个变量来记录全局的最长长度,以及对应的起始位置.每次找到一个比当前记录更长的回文串时,就更新记录.
-
得出最终答案:
遍历完所有可能的中心点之后,我们记录下的起始位置和长度对应的子串,就是整个字符串的最长回文子串
这个方法的效率如何?
我们有 n 个单字符中心和 n-1 个双字符中心,总共 2n-1 个中心,对于每个中心,我们最大向外扩展 n/2次. 所以总的时间复杂度是 O(n²),空间复杂度为 O(1)(因为我们只需要几个变量来存储指针和结果).这对于 n=1000 的情况是完全可以接受的.
代码实现:
public class Solution {
public string LongestPalindrome(string s) {
// 边界条件检查
if (string.IsNullOrEmpty(s) || s.Length < 1) {
return "";
}
// 用两个变量记录最长回文子串的起始和结束索引
// 这样做比直接存储字符串更高效,可以避免在循环中频繁创建子字符串对象
int start = 0;
int end = 0;
// 遍历字符串中的每一个字符,以其为中心向两边扩展
for (int i = 0; i < s.Length; i++) {
// 情况一:回文串长度为奇数,中心是 s[i]
int len1 = ExpandAroundCenter(s, i, i);
// 情况二:回文串长度为偶数,中心是 s[i] 和 s[i+1] 之间
int len2 = ExpandAroundCenter(s, i, i + 1);
// 取两种情况中较长的那一个
int len = Math.Max(len1, len2);
// 如果找到了更长的回文串,则更新其起始和结束位置
if (len > end - start) {
// 根据中心点 i 和长度 len 计算新的 start 和 end
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
// 循环结束后,根据记录的 start 和 end 截取最长的回文子串
// Substring 的第二个参数是长度
return s.Substring(start, end - start + 1);
}
private int ExpandAroundCenter(string s, int left, int right) {
// 当指针没有越界,并且左右指针指向的字符相同时,继续扩展
while (left >= 0 && right < s.Length && s[left] == s[right]) {
left--; // 左指针向左移动
right++; // 右指针向右移动
}
// 循环结束时,left 和 right 指向的是回文串边界之外的第一个不匹配字符
// 所以回文串的长度是 right - left - 1
return right - left - 1;
}
}
代码细节讲解:
-
int start = 0; int end = 0;
:- 我们不再保存最长的字符串
longestPalindrome
,而是只保存它的起始和结束索引. - 为什么这样做? 在循环中,如果每次找到更长的回文串都调用
Substring
来创建一个新的字符串对象,会产生大量的内存分配 .对于注重性能的场景(比如Unity游戏循环),这是需要尽量避免的.通过只记录两个int
变量,我们在整个循环过程中零内存分配,直到最后返回结果时才创建一次字符串对象.这是一种非常重要的优化思想.
- 我们不再保存最长的字符串
-
start = i - (len - 1) / 2;
和end = i + len / 2;
:- 这是这段代码中最需要技巧的一步:根据中心
i
和长度len
推算出回文串的起止索引.这个公式对奇数和偶数长度的回文串都适用,非常巧妙. - 举例(奇数):
s="aba"
, 中心i=1
, 长度len=3
.start = 1 - (3 - 1) / 2 = 1 - 1 = 0
end = 1 + 3 / 2 = 1 + 1 = 2
(整数除法,3/2
得1)- 结果是索引
[0, 2]
,正确.
- 举例(偶数):
s="abba"
, 中心在i=1
和i=2
之间, 长度len=4
.start = 1 - (4 - 1) / 2 = 1 - 1 = 0
(整数除法,3/2
得1)end = 1 + 4 / 2 = 1 + 2 = 3
- 结果是索引
[0, 3]
,正确.
- 这是这段代码中最需要技巧的一步:根据中心
-
return right - left - 1;
:- 这是另一个巧妙之处.当
while
循环结束时,left
和right
指向的位置是刚好不满足回文条件的地方. - 举例:
s="aba"
, 中心i=1
.- 初始:
left=1
,right=1
.循环条件满足. - 扩展后:
left=0
,right=2
.s[0]=='a'
,s[2]=='a'
.循环条件满足. - 扩展后:
left=-1
,right=3
.left < 0
,循环终止.
- 初始:
- 此时,
left
为 -1,right
为 3.真正的回文串是索引从 0 到 2.其长度是3
. - 计算
right - left - 1 = 3 - (-1) - 1 = 4 - 1 = 3
.公式正确.
- 这是另一个巧妙之处.当
知识点总结:
1. C#性能细节:&&
的“短路求值”
这是一个非常重要但容易被忽略的性能优化点.
- 对于逻辑与
&&
:如果第一个条件为false
,则整个表达式结果必为false
,C# 不会再去计算第二个条件. - 对于逻辑或
||
:如果第一个条件为true
,则整个表达式结果必为true
,C# 不会再去计算第二个条件. - 应用:在写
if (A && B)
时,应该把计算成本更低或更容易为false
的条件放在前面.这可以帮助程序“跳过”很多昂贵的计算.
2. 内存管理:理解string
的不可变性与GC
这对于Unity开发者来说是至关重要的知识.
- 不可变性 (Immutability):在C#中,
string
类型是不可变的.任何对字符串的修改(如Substring
,Replace
,+
拼接)都不会改变原始字符串,而是会创建一个全新的字符串对象. - GC Allocation:在循环中频繁创建新字符串,会在内存堆上产生大量需要被回收的对象.这会给垃圾回收器(GC)带来压力,在Unity中可能导致游戏画面周期性的卡顿(掉帧).
- 优化策略:在我们的高效解法中,通过只记录
start
和end
两个整数(值类型),避免了在循环中创建任何新的字符串对象,直到最后才生成一次结果.这是典型的用空间换时间、避免GC的优化思路.
3. 双指针技术:一种强大的线性结构处理工具
“中心扩展”和“判断回文”都用到了双指针.这是一种非常通用的算法技巧.
- 核心思想:在数组、列表或字符串等线性结构上,使用两个指针从不同位置(如两端、一快一慢)开始,同步或异步移动,以完成特定的任务.
- 常见应用:反转数组/字符串、寻找有序数组中和为特定值的两个数、滑动窗口问题等.它通常能将O(n²)的复杂度降低到O(n).
练习题:
选择题
1. 观察以下C#代码,PerformExpensiveCheck()
方法会被调用吗?
bool isReady = false;
bool PerformExpensiveCheck()
{
// 假设这是一个非常耗时的操作
Console.WriteLine("昂贵的操作被执行了!");
return true;
}
if (isReady && PerformExpensiveCheck())
{
// ... do something
}
A. 会被调用
B. 不会被调用
C. 取决于编译器版本
D. 代码会报错
2. 在Unity游戏的一个频繁执行的Update
方法中,以下哪种操作最有可能导致性能卡顿问题?
// 假设在一个循环内
string logMessage = "Player " + playerName + " scored " + score + " points.";
A. 整数 score
的加法运算
B. 字符串变量 playerName
的读取
C. 使用 +
号进行多次字符串拼接
D. logMessage
变量的赋值操作
3. “双指针”技术最不适合解决以下哪类问题?
A. 在一个已排序的数组中,找出两个和为100的数.
B. 将一个字符串原地反转.
C. 在一个二叉搜索树中查找一个特定的值.
D. 判断一个链表是否存在环.
简答题
1. 在我们的“中心扩展法”实现中,为什么我们选择在循环里只更新 start
和 end
两个整数索引,而不是直接更新一个 string
类型的变量来保存当前最长的回文串?这种做法在Unity开发中有什么特别重要的意义?
2. 将“从中心扩展”的逻辑封装成一个独立的私有方法 ExpandAroundCenter
有哪些好处?
参考答案:
选择题答案与解析
1. 观察以下C#代码,PerformExpensiveCheck()
方法会被调用吗?
bool isReady = false;
bool PerformExpensiveCheck()
{
// 假设这是一个非常耗时的操作
Console.WriteLine("昂贵的操作被执行了!");
return true;
}
if (isReady && PerformExpensiveCheck())
{
// ... do something
}
答案:B. 不会被调用
解析:
这道题考察的是C#中逻辑与操作符 &&
的 “短路求值” (Short-circuiting) 特性.
if
语句首先检查&&
左边的条件isReady
.isReady
的值是false
.- 因为
false && (任何东西)
的结果都必然是false
,所以C#为了效率,不会再继续向右执行PerformExpensiveCheck()
方法. - 因此,这个昂贵的方法根本不会被调用,控制台也不会打印任何信息.
2. 在Unity游戏的一个频繁执行的Update
方法中,以下哪种操作最有可能导致性能卡顿问题?
// 假设在一个循环内
string logMessage = "Player " + playerName + " scored " + score + " points.";
答案:C. 使用 +
号进行多次字符串拼接
解析:
这道题考察的是对C# string
类型不可变性及其性能影响的理解.
- C#中的
string
是不可变 (immutable) 的. - 每次使用
+
号拼接字符串时,都不是在原地修改,而是会创建一个全新的字符串对象来存储结果. - 上面这行代码实际上会创建多个中间字符串对象(例如
"Player " + playerName
的结果,这个结果再和" scored "
拼接的结果,等等). - 在
Update
这种每帧都执行的方法里,这样做会瞬间产生大量临时的字符串对象.这些对象都需要垃圾回收器 (GC) 来清理,GC的运行会暂停游戏主线程,从而导致性能下降和画面卡顿.在Unity性能分析器(Profiler)中,这会表现为很高的 GC Allocation.
3. “双指针”技术最不适合解决以下哪类问题?
A. 在一个已排序的数组中,找出两个和为100的数.
B. 将一个字符串原地反转.
C. 在一个二叉搜索树中查找一个特定的值.
D. 判断一个链表是否存在环.
答案:C. 在一个二叉搜索树中查找一个特定的值.
解析:
这道题考察的是对“双指针”技术适用范围的理解.双指针通常用于线性数据结构(如数组、链表、字符串).
- A (正确应用): 这是双指针的经典应用.一个指针从头开始,一个指针从尾开始,根据和的大小向中间移动.
- B (正确应用): 也是经典应用.一个指针在头,一个在尾,交换字符并向中间移动.
- D (正确应用): 这是双指针的一种变体,称为“快慢指针”.一个指针每次移动一步,另一个移动两步,如果它们能相遇,就说明链表有环.
- C (不适合): 二叉搜索树是树形(非线性)数据结构.在其中查找值,通常是从根节点开始,根据值的大小关系决定向左子树还是右子树移动,这只需要一个指针或引用来追踪当前节点.它不符合双指针协同工作的模式.
简答题参考答案与解析
- 因为C#中的
string
是不可变的.如果在循环中每次找到更长的回文串时,都通过s.Substring()
来创建一个新的字符串并赋值给结果变量,会频繁地在内存堆上分配新对象.这会导致大量的垃圾回收(GC)开销.在Unity中,GC的执行会暂停主线程,导致游戏画面掉帧和卡顿.通过只记录start
和end
两个轻量的整数(值类型),我们在整个查找过程中避免了任何不必要的内存分配,只在函数最后返回时创建一次最终的字符串对象,从而保证了程序的性能和流畅度. - 主要有三个好处:
- 代码复用 (Reusability):这个逻辑需要被调用两次——一次用于奇数长度的回文(中心为
i, i
),一次用于偶数长度的回文(中心为i, i+1
).封装成方法避免了代码重复. - 可读性和模块化 (Readability & Modularity):主函数
LongestPalindrome
的逻辑变得非常清晰:遍历中心 -> 调用辅助函数获取长度 -> 比较并更新结果.这使得代码更容易理解和维护. - 单一职责原则 (Single Responsibility Principle):
ExpandAroundCenter
方法只负责一件事情——从一个给定的中心点找出最长的回文串.这让代码结构更健康,也更容易进行单元测试.
- 代码复用 (Reusability):这个逻辑需要被调用两次——一次用于奇数长度的回文(中心为
可能的实际应用:
这个算法的实际应用点不多,故直接略过.
第六题: Z 字形变换(中等)
原题:
将一个给定字符串 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
开始解题:
方法一: 模拟法
这道题目考察的是一种"变换",只不过是针对字符在逻辑空间中的位置.
我们先不谈代码,先专注于理解这个变换的逻辑
想象一下,我们并不是在写代码,而是亲自用手排列这些字母,我们应该怎么做
我们的核心目标: 我们需要确定输入字符串 s 中的每一个在字符,最终应该属于Z字形的哪一行. 只要我们能够把所有字符正确地"分拣"到对应的行里,最后再把每一行的字符拼接起来,问题就解决了.
模拟手工分拣过程:
- 准备"行"容器:
- 假设 numRows 为 3 .我们想象我们面前有三个篮子,分别标记为第0行、第1行、第2行.
- 我们的任务就是遍历输入字符串
PAYPALISHIRING
,把每个字母一次放入正确的篮子里.
- 定义移动规则
- 我们的笔尖开始时位于第0行.
- 我们写下第一个字母 P 到第0行.
- 然后笔向下移动到第1行,写下 A.
- 在向下移动到第2行,写下 Y.
- 现在笔已经到了最下面一行(第
numRows - 1
行),它不能继续往下走了.所以,它必须改变方向,开始向上移动. - 笔移动到第1行,写下
P
. - 再向上移动到第0行,写下
A
. - 现在笔又到了最上面一行(第0行),它不能再往上走了.所以,它再次改变方向,开始向下移动.
- 这个“向下-向上-向下”的过程会一直重复,直到所有字符都被写完.
把这个过程抽象成算法逻辑
- 初始化:
- 创建 numRows 个可变的字符串容器(比如 StringBuilder),用来存放每一行的字符
- 我们需要一个变量来追踪当前应该在哪一行添加字符,我们叫它 currentRow,初始值为 0.
- 我们还需要一个变量来表示当前的移动方向,比如一个布尔值 goingDown.
- 遍历与放置:
- 我们从头到尾遍历输入字符串 s 的每一个字符
- 对于当前字符,我们将它追加到
rows[currentRow]
这个容器的末尾. - 放好字符后,我们需要决定下一个字符应该去哪一行.这就要更新 currentRow了.
- 方向判断: 什么时候需要改变方向? 当 currentRow 到达顶部(第0行)或底部(第 numRows - 1 行)时.
- 更新 currentRow:
- 如果当前方向是“向下”(
goingDown
为true
),那么currentRow
就加1. - 如果当前方向是“向上”(
goingDown
为false
),那么currentRow
就减1.
- 如果当前方向是“向下”(
- 收尾:
- 当所有字符都遍历并放置完毕后,我们
numRows
个容器里就分别装好了每一行的内容. - 最后,我们只需要按顺序(从第0个容器到最后一个)将它们的内容拼接成一个最终的字符串,就是我们的答案.
- 当所有字符都遍历并放置完毕后,我们
特殊情况/边缘情况处理:
- 如果
numRows
是1
,那么Z字形就是一条直线,不需要任何变换,直接返回原字符串s
即可. - 如果字符串的长度小于或等于
numRows
,那么它也构不成一个完整的“Z”形,每个字符占一行,同样直接返回原字符串s
.
方法二: 索引计算法
那么这种方法的核心事项就是: 不再模拟字符的Z字形路径,而是通过数学规律,直接计算出每一行所包含的字符在原字符串中的索引,然后按顺序将它们填入一个预先分配好的结果数组中.
这就像在Unity中,我们不去模拟一个物体从A点移动到B点,而是直接计算出B点的坐标,然后设置 transform.position = B
.效率天差地别.
我们还是以 s = "PAYPALISHIRING"
, numRows = 4
为例来分解这个过程.
第一步: 发现规律,定义"周期"
我们把字符和它们在原字符串中的索引画出来:
我们仔细观察,我们会发现一个重复的模式,一个完整的"V"字形(一列垂直向下的,加上一列倾斜向上的)构成了一个周期.
一个周期由两部分组成:
-
一根垂直向下的“竖线”:从第0行到第
numRows - 1
行.它包含的字符数是numRows
个.- 在我们的例子中,是
P(0), A(1), Y(2), P(3)
,共 4 个字符.
- 在我们的例子中,是
-
一根倾斜向上的“斜线”:它连接着V字的底部和下一个V字的顶部.
- 关键点:这条斜线不包含V字的最低点和最高点,因为这两个点已经被“竖线”占据了.
- 所以,它填充的行是从第
numRows - 2
行到第1
行. - 它包含的字符数是
(numRows - 2) - 1 + 1 = numRows - 2
个. - 在我们的例子中,是
A(4)
(在第2行) 和L(5)
(在第1行),共4 - 2 = 2
个字符.
周期总长度 (cycleLen):
一个周期内所有字符的总数 = (竖线字符数) + (斜线字符数)
cycleLen = (numRows) + (numRows - 2) = 2 * numRows - 2
对于 numRows = 4
,cycleLen = 2 * 4 - 2 = 6
.这意味着,在原字符串中,每隔6个字符,模式就会重复一次.P(0)
和 I(6)
就是相隔一个周期.
这部分的逻辑需要下面结合代码讲解
代码实现:
模拟法:
public class Solution {
public string Convert(string s, int numRows) {
// 1. 处理特殊情况(Edge Case)
// 如果行数是1,或者字符串长度不足以进行Z字形排列,
// 那么结果就是原字符串本身.
if (numRows == 1 || s.Length <= numRows) {
return s;
}
// 2. 初始化“行”容器
// 创建一个列表,用来存放每一行的字符.
// 我们使用 List<StringBuilder> 而不是 List<string> 是为了高效拼接字符.
var rows = new List<StringBuilder>();
for (int i = 0; i < numRows; i++) {
rows.Add(new StringBuilder());
}
// 3. 遍历与放置
int currentRow = 0;
// 用一个变量来表示方向,1代表向下,-1代表向上.
// 初始设为-1,是因为我们希望在第一次循环时,
// 当 currentRow == 0 时,方向能立刻变为 1(向下).
int direction = -1;
foreach (char c in s) {
// 将当前字符放入对应的行
rows[currentRow].Append(c);
// 判断是否到达顶部或底部,如果是,则改变方向
if (currentRow == 0 || currentRow == numRows - 1) {
direction *= -1; // 乘以-1可以方便地在 1 和 -1 之间切换
}
// 根据方向更新下一行索引
currentRow += direction;
}
// 4. 收尾:拼接所有行
var result = new StringBuilder();
foreach (var row in rows) {
result.Append(row);
}
return result.ToString();
}
}
代码部分讲解:
StringBuilder
类- 是什么:一个“可变的字符串”.
- 为什么用它:在C#中,
string
类型是“不可变”(immutable)的.这意味着每次你用+
连接两个字符串(例如string myStr = "a" + "b";
),系统实际上是创建了一个全新的字符串对象来存放结果 “ab”,并丢弃了原来的 “a”.在循环中大量执行这种操作会产生很多临时的垃圾对象,影响性能,并可能导致GC(垃圾回收)卡顿. StringBuilder
就是为了解决这个问题而生的.它的Append()
方法是在内部的字符缓冲区上进行修改,而不是每次都创建新对象.这在需要多次拼接字符串的场景下效率极高.- 方法:
new StringBuilder()
: 创建一个实例..Append(value)
: 在末尾追加内容,可以是字符、字符串或其他StringBuilder
..ToString()
: 当所有拼接操作完成后,调用此方法可以得到一个最终的、不可变的string
对象.
索引计算法
public class Solution {
public string Convert(string s, int numRows) {
int len = s.Length;
// 特殊情况处理
if (numRows == 1 || numRows >= len) return s;
// 1. 准备工作:最高效的内存策略
int cycleLen = 2 * (numRows - 1);
char[] result = new char[len]; // 一次性分配最终大小的数组
int idx = 0; // 结果数组的写入指针
// 2. 按行填充:分三段进行
// 2.1 填充第一行 (row = 0)
for (int j = 0; j < len; j += cycleLen) {
result[idx++] = s[j];
}
// 2.2 填充中间行 (row = 1 to numRows - 2)
for (int i = 1; i < numRows - 1; i++) {
// 内层循环处理当前第 i 行的所有字符
for (int j = i; j < len; j += cycleLen) {
// a) 添加“竖线”上的字符
result[idx++] = s[j];
// b) 计算并添加“斜线”上的字符
int step = 2 * (numRows - 1 - i);
int diagIndex = j + step;
if (diagIndex < len) {
result[idx++] = s[diagIndex];
}
}
}
// 2.3 填充最后一行 (row = numRows - 1)
for (int j = numRows - 1; j < len; j += cycleLen) {
result[idx++] = s[j];
}
// 3. 收尾
return new string(result);
}
}
代码结合讲解:
-
准备工作
char[] result = new char[len];
:这是性能的关键.我们预先分配了一块连续的内存,大小正好是结果所需.这避免了任何运行时的动态内存分配和数据复制.int idx = 0;
:这是一个简单的整数,作为指向result
数组下一个可写入位置的指针.它的操作 (idx++
) 非常快.
-
填充过程
-
第一行 (
i=0
): 这一行的字符在原字符串中的索引是0
,cycleLen
,2*cycleLen
, …循环for (int j = 0; j < len; j += cycleLen)
精准地跳跃到这些位置. -
中间行 (
i=1
到numRows-2
): 这是最核心的部分.- 外层循环
for (int i = ...)
确定我们当前正在为哪一行收集字符. - 内层循环
for (int j = i; ...)
开始为第i
行收集.j = i
: 第i
行的第一个字符,其在原字符串中的索引就是i
.result[idx++] = s[j];
: 这是放置**“竖线”**上的字符.j
会以cycleLen
为步长,跳到下一个周期的同一“竖线”位置.- 计算“斜线”字符位置:
int step = 2 * (numRows - 1 - i);
这个公式计算的是从当前“竖线”字符j
到它右侧的“斜线”字符的索引距离.- 推导: 从
j
(在第i
行) 向下走到V字底部 (第numRows-1
行),需要(numRows - 1) - i
步.再从底部斜向上走到第i
行,又需要(numRows - 1) - i
步.所以总距离是2 * (numRows - 1 - i)
. - 验证 (
numRows=4, i=1
):step = 2 * (4 - 1 - 1) = 4
.当j=1
(A
) 时,斜线字符在1+4=5
,即L(5)
.正确. - 验证 (
numRows=4, i=2
):step = 2 * (4 - 1 - 2) = 2
.当j=2
(Y
) 时,斜线字符在2+2=4
,即A(4)
.正确.
if (diagIndex < len)
: 必须检查,防止计算出的索引超出字符串的实际长度.
- 外层循环
-
最后一行 (
i=numRows-1
): 规律和第一行一样简单.它的字符索引是numRows-1
,numRows-1 + cycleLen
, …
-
-
收尾
new string(result)
: 这是从字符数组生成字符串的最高效方法.
总结:
这个方法之所以快,是因为它做了以下几件正确的事:
- 用数学代替模拟:避免了逐字符判断方向的逻辑开销.
- 一次性内存分配:用
char[]
避免了运行时的内存动态调整. - 顺序写入:用
idx++
指针保证了对内存的高效顺序访问. - 简洁的循环:每个循环的目标单一明确,易于编译器优化.
知识点总结:
1. 算法思维:模拟 vs. 数学建模
-
模拟法 (方法一的变体)
- 核心思想:模仿问题描述中的物理过程或人为操作步骤.代码逻辑与现实世界中的行为高度一致.
- 优点:非常直观,容易理解和实现,是解决很多问题的首选思路,尤其是在规律不明显时.
- 缺点:可能会因为包含了大量中间步骤和状态判断而导致性能较低.
- 本题体现:通过
currentRow
和direction
变量,模拟一支笔在纸上“向下再向上”的移动轨迹.
-
数学建模/索引计算法 (方法二)
- 核心思想:分析问题内在的数学规律、周期性或几何特性,推导出可以直接计算结果的公式.
- 优点:性能极高,因为它省去了所有中间过程,直达结果.能体现出更深层次的分析能力.
- 缺点:需要更强的抽象和数学分析能力,规律可能不易发现,代码的直观性可能较差.
- 本题体现:通过发现
cycleLen
这个周期,推导出每个字符在原字符串中的索引公式.
通用启示:面对一个问题,先尝试用“模拟法”构建一个可行的基础版本.然后,思考能否优化,寻找其中的重复模式和数学规律,看是否能升级为“数学建模法”以获得极致性能.
2. C# 性能基础:内存分配与数据结构选择
-
string
vs.StringBuilder
string
是不可变 (immutable) 的.任何修改(如拼接+
)都会创建新的字符串对象,在循环中会产生大量垃圾,引发GC(垃圾回收),影响性能.StringBuilder
是可变 (mutable) 的.它在内部维护一个字符缓冲区,Append
操作是在此缓冲区上进行,效率远高于字符串拼接.当你需要构建一个复杂的或由多部分组成的字符串时,它是不二之选.
-
List<T>
vs.Array (T[])
List<T>
是动态数组.它提供了方便的Add
,Remove
等方法,大小可以动态变化.但这种便利性是有代价的:当内部数组容量不足时,会发生扩容(分配一个更大的新数组,并将旧数据复制过去),这会带来性能开销.Array (T[])
是静态数组.大小在创建时就固定了.它的优点是性能极致:内存一次性分配,访问速度最快(直接通过偏移量计算地址).- 选择原则:当你在程序开始时就能精确知道需要存储多少元素时,优先使用数组
T[]
以获得最佳性能.如果不确定元素数量,或需要频繁增删,则使用List<T>
更方便.
通用启示:性能优化的一个关键方向就是减少运行时的内存分配.预先分配、重复利用(对象池思想)是C#(尤其是在Unity这种对GC敏感的环境中)性能优化的常用手段.
练习题
选择题
1. 在一个需要对大量短字符串进行拼接的循环中,以下哪种方式是最高效的?
A. string result = ""; foreach(var s in strings) { result += s; }
B. StringBuilder sb = new StringBuilder(); foreach(var s in strings) { sb.Append(s); } return sb.ToString();
C. List<string> list = new List<string>(); foreach(var s in strings) { list.Add(s); } return string.Concat(list);
D. string result = string.Empty; foreach(var s in strings) { result = string.Concat(result, s); }
2. 你正在编写一个函数,需要读取一个文件的前1024个字节.你应该选择哪种数据结构来存储这些字节?
A. List<byte>
,因为可能不需要读取全部1024个字节.
B. byte[] buffer = new byte[1024];
C. StringBuilder
,因为字节可以看作字符.
D. Queue<byte>
,因为可以按顺序处理.
简答题
题目:
假设你需要编写一个函数 string ReverseWords(string s)
,该函数接收一个句子(由空格分隔的单词组成),然后反转句子中单词的顺序.
例如,输入 "the sky is blue"
,应输出 "blue is sky the"
.
请思考并回答:
- 你会如何利用本节课学到的数据结构选择知识来解决这个问题?(提示:先拆分,再组合)
- 这个问题更适合用模拟法还是数学建模法来解决?为什么?
参考答案:
选择题答案:
-
答案:B
解析:-
A 和 D 选项都使用了字符串的直接拼接,每次循环都会创建新的字符串对象,性能最差.
-
C 选项虽然比 A/D 好,但它创建了一个字符串列表,增加了内存开销,string.Concat 内部虽然有优化,但仍不如 StringBuilder 直接在缓冲区操作来得高效.
-
B 选项的 StringBuilder 正是为这种场景设计的,它最小化了内存分配和复制,是标准的高性能字符串构建方法.
-
-
答案:B
解析:- 题目明确指出需要存储“1024个字节”,这是一个固定且已知的大小.
- B 选项直接创建了一个大小精确的字节数组 byte[],这是最高效、最直接的方式.
- A 选项 List 适用于大小不确定的情况,这里属于过度设计,且有潜在的性能开销.
- C 选项 StringBuilder 用于处理字符和字符串,不适用于原始字节数据.
- D 选项 Queue 是一种特定的数据结构(先进先出),虽然可以工作,但不如数组直接和高效.
简答题参考答案:
-
数据结构选择:
- 拆分阶段: 首先,我们需要将输入的字符串
s
按空格拆分成单词。C# 的s.Split(' ', StringSplitOptions.RemoveEmptyEntries)
方法会返回一个字符串数组string[]
。这是一个很好的选择,因为一旦拆分,单词的数量就确定了。 - 反转阶段: 我们可以直接对这个
string[]
数组进行反转。可以写一个循环从两端向中间交换元素,或者使用Array.Reverse()
这个内置方法。 - 组合阶段: 最后,我们需要将反转后的单词数组重新组合成一个字符串。这里就应该使用
StringBuilder
。我们遍历反转后的数组,用sb.Append(word)
和sb.Append(" ")
来高效地构建最终结果。最后调用sb.ToString()
并处理掉末尾多余的空格。或者,更简洁地使用string.Join(" ", reversedWordsArray)
,这个方法内部实现已经为性能做了优化。
- 拆分阶段: 首先,我们需要将输入的字符串
-
算法思维选择:
- 这个问题更适合用模拟法。
- 原因: “反转单词顺序”这个任务本身就是一个清晰、具体的操作步骤序列:1. 拆分句子成单词。 2. 将单词的序列反转。 3. 将新序列的单词组合成新句子。这个逻辑流程非常直观,没有复杂的、可供推导的数学/索引公式。我们直接模拟这个过程就是最高效、最清晰的解法。试图为其进行“数学建模”反而会使问题复杂化,没有必要。
可能的实际应用:
- 图像处理/渲染:在游戏开发或图像处理中,对像素数据进行特定的模式重排(如转置、马赛克、滤镜效果),其底层逻辑就和“索引计算法”非常相似.我们需要计算出目标像素应该从源图像的哪个坐标拾取颜色.
- 数据序列化/通信协议:在网络通信中,数据经常需要按照特定格式(协议)进行打包和解包.Z字形变换可以看作一种简单的“数据混淆”或“交错”模式.理解这种模式变换有助于设计或解析自定义的数据流格式.
- 文本编辑器/格式化工具:开发文本格式化工具时,比如将线性文本格式化为多栏布局,就需要计算每个字符或单词应该被放置在哪一栏的哪一行,这与Z字形变换的行分配逻辑异曲同工.
- 性能关键代码的重构:当我们接手一个项目,发现某段代码(比如日志拼接、数据报表生成)因为大量的字符串操作而成为性能瓶颈时,就可以运用今天学到的知识,用
StringBuilder
或预分配数组的方式对其进行重构优化.