如果有人问我,算法面试和算法比赛准备的第一步到底该刷什么,我永远会首推数组和字符串。原因很简单:数组和字符串是所有算法题的“基础载体”,你后面遇到链表、二叉树、动态规划、滑动窗口,追根究底都是在处理某种数组或字符串的变体。LeetCode上热门100题里,至少一半题目直接或间接依赖数组操作。对零基础的朋友来说,与其一上来就扎进链表和二叉树,不如先把数组和字符串这层地基砸实。
这篇文章我选了三个非常有代表性的入门题:27. 移除元素、344. 反转字符串、121. 买卖股票的最佳时机。三题分别对应三种最核心的算法思维:快慢双指针、相向双指针、贪心策略。把这三题吃透,你不仅掌握了数组和字符串的标准操作姿势,还能提前建立起“双指针”和“贪心”这两大高频算法思想的直觉。文章会以C++为主给出完整代码,但思路对所有语言通用。建议收藏后照着刷,题目不多,但每一道都值得反复咀嚼。
1. 为什么说数组和字符串是算法的“基础载体”
1.1 数组:一切数据结构的地基
数组在内存里是连续存放的一组元素,支持O(1)时间的随机访问。这一条性质,让数组成为几乎所有复杂数据结构的底层实现:链表需要数组存储节点、哈希表需要数组存桶、堆需要数组存二叉树的层序结构、图论里邻接表本质上也是一个二维数组。
从算法的视角看,所谓的“处理数组”,说白了就是搞清楚两个问题:怎么找到我要的元素,怎么移动/删除/替换元素。LeetCode上数组题的技巧体系,基本由双指针、前缀和、差分、模拟、排序、二分这几个词构成。而这些技巧里最入门、最常用、也最容易被忽视的,就是双指针。
我在带新人刷题时发现,很多人上来就背模板、记套路,结果换一道题就懵。根子在于他对数组本身没有建立起“指针视角”——数组不是一堆静态的数字,而是一个你可以随时在任意位置放置“游标”的线性结构。一旦你习惯用指针去描述“当前该看哪里”“下一步该走到哪里”,很多题都会豁然开朗。
1.2 字符串:戴了“字符串”帽子的数组
字符串在底层就是字符数组。C++的std::string,本质是对字符数组做了一层封装,额外提供了长度记录、动态扩容、越界检查等能力。所以字符串题的处理方式,和数组题高度一致:你需要遍历、交换、覆盖、查找子串,思路通通可以迁移。
很多新手容易犯一个错误:拿到字符串题,第一反应是调库函数一把梭,比如遇到“反转字符串”直接用reverse,遇到“移除元素”直接用erase。调库确实能过,但比赛和面试真正考的是你如何理解并实现底层逻辑,更关键的是很多库函数本身要么是O(n)的隐藏开销,要么有迭代器失效的坑。老老实实把数组的“原地操作”练扎实,字符串题就是降维打击。
举一个例子:C语言里最常见的“字符串逆序输出”,教科书标准答案是求长度后for循环倒着打印。但LeetCode上的反转字符串要求原地修改,这就是一个典型的“数组思维”迁移场景。你只有体会到字符串就是字符数组,才能自然地想到双指针交换。
1.3 为什么偏偏选这三道题
零基础刷题最忌讳的就是贪多贪难。我选这三题,是因为它们恰好覆盖了三条最重要的能力线:
| 题目 | 核心方法 | 训练的思维方式 | 复杂度 |
|---|---|---|---|
| 27. 移除元素 | 快慢双指针 | 原地覆盖与“新数组”逻辑 | O(n)时间,O(1)空间 |
| 344. 反转字符串 | 相向双指针 | 对称性与边界控制 | O(n)时间,O(1)空间 |
| 121. 买卖股票的最佳时机 | 贪心策略 | 局部最优与全局最优的关系 | O(n)时间,O(1)空间 |
三道题全都不需要任何前置知识,连数组初始化、遍历这些基础语法都不熟的人也能看懂。但它们背后的思想,却是之后刷中等题、难题的核心武器。你可以把这三题当作“开胃菜”,吃透之后再去碰滑动窗口、双指针进阶、动态规划入门,会顺畅很多。
2. 第27题 移除元素:快慢双指针的启蒙课
2.1 题目到底在说什么
题目要求:给你一个数组nums和一个值val,你需要原地移除所有数值等于val的元素,并返回移除后数组的新长度。不要使用额外的数组空间,只允许O(1)额外空间修改输入数组。元素的顺序可以改变,不需要考虑数组中超出新长度后面的元素。
这句话有几个坑需要注意:
- 原地意味着你不能新建一个数组来避重就轻。
- 返回新长度意味着函数只需要你告诉调用者“有效部分有多长”,至于数组末尾残留的旧值,人家不管。
- 不需要保持顺序,这是个简化条件,但我们的双指针解法即使在需要保持顺序的情况下同样成立。
举个例子,nums = [3,2,2,3], val = 3。最终有效数组应该是[2,2],返回长度2,此时nums前两个位置必须是2和2,后面的[3,3]是什么都无所谓。
2.2 先想暴力,再谈优化
很多初学者第一次做这题,会写出这样的暴力版本:遍历数组,每遇到一个等于val的元素,就把它后面的所有元素整体前移一位,然后把数组长度减1。这样做可以出正确结果,但最坏情况下时间复杂度是O(n²),比如nums全是2、val也是2,每删除一个元素都要搬运后面几乎全部元素。
暴力解法的核心低效在于:删除一个元素时,移动操作和查找操作是分开做的,导致大量重复搬运。如果在LeetCode上提交,n是10^5级别时基本超时。
于是双指针就登场了。双指针的精髓在于:用两个指针分别承担“扫描原数组”和“记录新数组写入位置”两个职责,一次遍历完成全部工作,把两层循环压缩成一层。
2.3 快慢双指针的标准实现
这里用的是“快慢指针”的一种,更准确地说叫“读写指针”:
class Solution { public: int removeElement(vector<int>& nums, int val) { int slow = 0; // slow 指向新数组即将写入的位置 for (int fast = 0; fast < nums.size(); fast++) { if (nums[fast] != val) { nums[slow] = nums[fast]; // 把非 val 元素搬运到新数组位置 slow++; } // 如果 nums[fast] == val,直接跳过,不写入 } return slow; // slow 的值就是新数组的长度 } };我逐行拆一下:
slow是“写入指针”,它始终指向“新数组的下一个空位”。fast是“扫描指针”,它遍历整个原始数组。- 当
fast指向的元素不等于val,说明这个元素要保留,于是把它复制到slow的位置,然后slow右移一格。 - 当
fast指向的元素等于val,说明这个元素要被丢弃,什么都不做,fast继续前进。
因为fast总是走在slow前面或和slow持平,所以nums[slow] = nums[fast]不会覆盖还没读到的数据。这一点是快慢指针能成立的关键。
最终slow的值刚好等于“有效元素个数”,也就是新数组长度。外部再根据这个长度去访问数组,只看到保留的元素。
2.4 三个实际踩过的细节坑
坑1:把 slow 和 fast 的移动逻辑写反。新手容易写成“当 nums[fast] == val 时搬运后面的元素”,这又回到了暴力的思路。记住:双指针法是“不等就搬运,相等就跳过”,不要纠结于“相等时该怎么删除”,因为删除这个动作本质上是“用后面的元素覆盖掉它”,而覆盖由搬运完成。
坑2:返回值到底是多少。慢指针停在最后一个有效位置的下一个下标,所以长度就是slow,不是slow+1也不是slow-1。写完后用空数组验一下:nums为空时循环不执行,返回0,完全正确。
坑3:把“不需要考虑超出新长度后面的元素”理解成“可以随便乱放”。这句话的意思只是不要求你清理尾部残留,但你返回长度范围内的部分必须严格合法。也就是nums前slow个位置必须是删除后的有效元素。
提示:
slow和fast这种“读写分离”的写法,是之后做26. 删除有序数组中的重复项、283. 移动零、844. 比较含退格的字符串等一堆题目的通用骨架。你可以现在就做一个小扩展:如果题目改成“把0移动到末尾”,只需要把“非零元素写入”和“末尾补零”两步分开,思路完全一样。
3. 第344题 反转字符串:相向双指针的对称之美
3.1 从“字符串逆序输出”到“原地反转”
题目给一个字符数组s,要求原地反转。比如输入['h','e','l','l','o'],输出['o','l','l','e','h']。注意是字符数组,不是字符串,这就明确告诉你:想用库函数reverse也行,但最核心的要求是在原地完成对称交换。
如果你会C语言的数组,这道题本质上就是“首尾元素交换,逐步向中间靠拢”。很多人在LeetCode上直接写:
reverse(s.begin(), s.end());一行完事。确实能通过,但这道题的意义在于让你亲自实现对称交换的逻辑,因为这种“左右夹逼”的相向双指针,是后续处理回文串、判断括号匹配、三数之和等题目的基础。建议至少手写一遍核心过程。
3.2 对称是这里的最大线索
任何对称操作,第一反应都应该是“两边同时往中间走”。既然翻转字符串就是把第0个字符和第n-1个字符交换,第1个字符和第n-2个字符交换,那自然需要两个指针:一个从左边出发,一个从右边出发,每次交换后分别右移/左移,直到两个指针相遇。
这里可以对比一下“快慢双指针”和“相向双指针”的区别:
| 类型 | 起始位置 | 移动方向 | 典型应用 |
|---|---|---|---|
| 快慢指针 | 都在头部 | 快指针先走,慢指针后走 | 移除元素、链表环检测 |
| 相向指针 | 一头一尾 | 双向朝中间走 | 反转字符串、回文判断、两数之和(有序) |
| 滑动窗口 | 都在头部 | 右端扩张,左端收缩 | 最长无重复子串 |
掌握这三种双指针模式,基本就把双指针题目的套路摸清了。344题属于第二种,而且是最简单的那种。
3.3 代码实现与循环边界
标准写法如下:
class Solution { public: void reverseString(vector<char>& s) { int left = 0; int right = s.size() - 1; while (left < right) { // 交换 left 和 right 位置的字符 char temp = s[left]; s[left] = s[right]; s[right] = temp; left++; right--; } } };这里我用了临时变量temp完成交换。C++还有std::swap可以直接用:swap(s[left], s[right]);但在面试白板题或比赛环境中,手写交换逻辑能体现对内存操作的理解,所以建议至少知道底层是三步赋值。
循环条件到底写left < right还是left <= right?这是个非常经典的边界问题。
- 如果字符个数是偶数,比如4个,指针变化是 (0,3) → (1,2) → (2,1),第二轮后
left < right已经不成立,如果写成<=就会再多交换一次,把刚换好的元素又换回去。 - 如果字符个数是奇数,比如5个,指针变化是 (0,4) → (1,3) → (2,2),中间那个字符自己不跟自己交换。此时
left == right,写<=就会触发自己跟自己交换,结果不变但做了无用功。
所以left < right是既简洁又正确的边界条件。很多人在第一次写时用<=,测试全过但逻辑上不够干净,这种细节在面试追问时会被问到。
3.4 复杂度分析与扩展思考
时间复杂度O(n),每个字符恰好被交换一次;空间复杂度O(1),只用了常数级别的额外变量。
这道题还有几个衍生版本值得自己做一遍:
- 反转字符串 II:每隔k个翻转一次,本质上只是把“反转区间”封装成一个函数反复调用。
- 反转字符串中的单词:先整体反转,再逐个单词反转,经典的两步法。
- 仅仅反转字母:用相向指针,跳过非字母字符再交换。
我个人的体会是,344题虽然简单,但它是“对称性问题”的标准模板。以后你见到“判断一个字符串是不是回文串”“旋转数组”“反转链表”,核心思想都是相向指针往中间夹逼,只不过操作对象从字符变成了指针、节点或其他结构。把这道题写成肌肉记忆,后面省很多事。
4. 第121题 买卖股票的最佳时机:贪心策略首次实战
4.1 题目的现实背景
给定一个数组prices,prices[i]表示某只股票第i天的价格。你只能选择某一天买入,并在未来的某一天卖出,求你能获得的最大利润。如果无论如何都无法获利,返回0。比如prices = [7,1,5,3,6,4],第2天买入(价格1),第5天卖出(价格6),利润5,这就是答案。
注意约束条件:只能买卖一次。这一点非常重要,决定了这题能用贪心。
4.2 暴力枚举为什么不是好答案
最朴素的想法是枚举所有的买入日、卖出日,计算每一天组合的利润,取最大值。伪代码如下:
int maxProfit = 0; for (int i = 0; i < prices.size(); i++) { for (int j = i + 1; j < prices.size(); j++) { maxProfit = max(maxProfit, prices[j] - prices[i]); } }两层循环,时间复杂度O(n²)。当prices长度到10^5级别时,这个代码在LeetCode上直接超时。
但暴力法给了我们一个重要的观察:最大利润 = 所有“卖出价格 - 买入价格”的最大值。问题在于,我们不能预先知道哪个卖出日配哪个买入日最优,所以需要一种方法,在一次遍历中同时维护“历史上最好的买入价”和“当前能获得的最大利润”。
4.3 贪心策略的推导与直觉
贪心策略的核心是:每一步都做当前看起来最优的选择,并相信局部最优能累积成全局最优。在这道题里,贪心包含两层决策:
买入价越低越好。所以遍历过程中,我不断记录“到目前为止出现过的最低价格”,把它当作潜在的买入点。这个记录是一个局部最优选择——只要今天之前出现了比我手上更低的价格,我就“换一个更好的买入点”来看待。
每天都可以作为卖出点。所以每遍历一天,我就计算一次“今天价格 - 当前已知最低买入价”,把这个差值当作“如果今天卖出能获得的利润”。所有日子里的最大差值,就是全局最大利润。
为什么这个策略有效?因为这笔交易只有一个买入点和一个卖出点,卖出日一定在买入日之后。遍历时,假设我在处理第i天,那么[0, i-1]这个区间内的最低价,一定是当前最优的潜在买入价。而第i天如果卖出,利润就是prices[i] - minPrice。我不需要回溯之前的所有日子,因为minPrice已经包含了至少一个局部最优候选。这就是贪心思想落地为O(n)算法的典型过程。
4.4 代码实现与关键细节
class Solution { public: int maxProfit(vector<int>& prices) { int minPrice = INT_MAX; // 记录历史最低价格 int maxProfit = 0; // 记录最大利润 for (int i = 0; i < prices.size(); i++) { if (prices[i] < minPrice) { minPrice = prices[i]; // 更新潜在买入点 } else { maxProfit = max(maxProfit, prices[i] - minPrice); } // 也可以写成: // minPrice = min(minPrice, prices[i]); // maxProfit = max(maxProfit, prices[i] - minPrice); } return maxProfit; } };这里有个细节值得琢磨:为什么用INT_MAX初始化?因为第一天的价格必须能成为初始的最低买入价。如果用0初始化,当第一天价格是7时,prices[0] < 0不成立,最低价就一直是0,最后利润计算会出错。INT_MAX保证任何价格第一次出现时都能被记录。
另一个细节是我写在注释里的写法:先用min更新最低价,再用当前价格减去最低价更新利润。这种写法更简洁,也更容易体现贪心的两个动作。两者的区别在于:第一种写法只有在遇到更低价格时才更新利润,但本质上一回事,因为价格下跌时卖出利润本来就是一个无效的候选值。
4.5 为什么这题也可以用动态规划的思想理解
很多读者到后面接触动态规划时会困惑:股票问题不是有动态规划解法吗?为什么这里说贪心?
因为在“只能买卖一次”这个限制下,问题退化成“找两个数使后数减前数最大”,这种全局最大值恰好能通过维护前缀最小值来求解。你可以把minPrice看作一个“历史状态”,每一步决策只依赖这个状态,这就具备了贪心选择性质。
如果你之后刷到122. 买卖股票的最佳时机 II,那里允许无限次买卖,贪心策略就变成了“只要今天的价格比昨天高就赚一笔”,本质上还是局部最优去凑全局最优。再往后刷到123题和188题,限制买卖次数,届时就真的需要动态规划了。所以121题是理解“什么时候贪心够用,什么时候必须上DP”的分水岭。
4.6 复杂度与正确性验证
时间复杂度O(n),一次遍历;空间复杂度O(1)。
拿例子验证一下:prices = [7,1,5,3,6,4]
- 第0天,minPrice = 7,maxProfit = 0
- 第1天,1 < 7,minPrice = 1,maxProfit仍为0
- 第2天,5 > 1,maxProfit = max(0, 5-1) = 4
- 第3天,3 > 1,maxProfit = max(4, 3-1) = 4
- 第4天,6 > 1,maxProfit = max(4, 6-1) = 5
- 第5天,4 > 1,maxProfit = max(5, 4-1) = 5
结果5,正确。
特别提示:这题有个非常常见的变体陷阱——“如果股票价格一直下跌怎么办”。比如
prices = [5,4,3,2,1],代入代码,minPrice不断被更新,但maxProfit始终是0,因为没有任何一段是盈利的。返回0是符合题意的,不是错误。别在边界测试时被自己吓到。
5. 三题串联的统一视角与刷题避坑指南
5.1 双指针不是一种,而是三种
刷完27题和344题,如果你能顺手总结出“双指针原来还有快慢和相向之分”,这趟就没白刷。我把三种双指针模式再统一梳理一下:
| 模式 | 初始位置 | 移动特征 | 典型题目 |
|---|---|---|---|
| 快慢指针 | 同侧起点 | 速度不同,一前一后 | 27移除元素、26删除重复项、283移动零、链表环检测 |
| 相向指针 | 两端往中间 | 相遇即结束 | 344反转字符串、125验证回文串、167两数之和II |
| 滑动窗口 | 同侧起点 | 窗口长度动态变化 | 3无重复字符最长子串、76最小覆盖子串 |
做题时先判断“这道题的操作对象是线性结构吗”“是不是需要原地修改”“有没有对称性或区间性”,再决定用哪种双指针。绝大多数数组题,只要你能画出两个指针在数组上的移动轨迹,思路就清晰一大半。
5.2 贪心策略的判断标准
刷121题时,很多人的疑问是:“我怎么知道这题能用贪心?”我的经验是看三点:
- 题目是否有全局最优解的结构。买卖股票一次只能一买一卖,全局最优可以由“每个子区间里的最优”推出来。
- 局部决策是否会影响后续状态。如果你每一步都选当前最低价作为买入候选,没关系,因为卖出日始终在未来,最低买入价永远是局部最优中的更优者,不会让后续的利润变小。
- 能不能举出反例。试图构造一个“贪心失效”的例子,如果构造不出来,那大概率可以贪心。比如“先跌后涨再回撤最终涨更高”,贪心算法依然能捕捉到最低点和最高点,因为minPrice一直在更新。
我强烈建议你拿这个标准去验证之后遇到的贪心题,比如55. 跳跃游戏、435. 无重叠区间、45. 跳跃游戏II。能讲清楚“为什么局部最优等于全局最优”的人,才是真正理解了贪心,而不是只背了一个模板。
5.3 现场实战时的查错清单
在LeetCode上提交代码,最容易因为下面几个原因挂掉:
- 数组越界访问。比如在344题里有的写法是
while (left <= right)导致多交换,或者数组长度为0时直接访问s.size()-1得到无符号大整数。处理空数组最稳妥的方法是在函数开头判断if (s.empty()) return;。 - 覆盖导致数据丢失。做27题时如果把写入方向反了,可能把还没扫描的元素覆盖掉。判断方法很简单:快慢指针的核心是“慢指针永远不会越过快指针”,如果操作后慢指针跑到了快指针前面,逻辑必然出错。
- 返回值语义错误。LeetCode数组题的返回值通常就是要你返回“有效长度”,但有的题要的是“有效数组本身”,比如下一题283移动零要求把0移到末尾。看清题目要求再动手。
我给自己定了一个“三查”习惯:查边界(空数组、单元素)、查重复(全部相同、全是要删的)、查极端(价格一直涨、价格一直跌)。这三查做完,大多数隐藏bug都会现出原形。
5.4 我的做题复盘方法论
最后分享一个个人习惯:每刷完一道题,24小时之内必须重新默写一遍,且不能看题解。如果默写时有任何卡壳,说明这题还没真正长在脑子里,隔天再写第二遍,直到能流畅写出为止。
这三道题都不难,但我见过的很多刷题者败在了“以为看懂了就是会了”。看懂和能写出来之间,隔着一道叫做“肌肉记忆”的鸿沟。数组+字符串的技巧本来就不多,你把这20道以内的基础题写到肌肉记忆的级别,再往后的中等题、难题,至少代码框架不会乱。
另外,备赛算法比赛的朋友可以把这三题当作“签到题”的典型难度来看待:第一题练基本语法和数组读写,第二题练逻辑思维和边界控制,第三题练算法思想中的贪心。比赛的签到题很少跳出这三个范畴。把基础题刷到不假思索就能写出来,比赛时你才能把脑力留给真正有区分度的题目。
下一步你可以按这个顺序继续延伸:26. 删除排序数组中的重复项、283. 移动零、125. 验证回文串、167. 两数之和 II - 输入有序数组、然后进入122. 买卖股票的最佳时机 II。一步步来,数组和字符串这关过了,你的LeetCode之路就已经走稳了一半。