
写这篇指南之前先说个很多入门选手都会踩的坑一上来就刷热门一百题结果链表、回溯、动态规划轮番上阵每题都卡在“看题解秒懂、关掉题解手生”反复几次心态直接崩了。我自己带过几个准备算法比赛的新人最终发现真正有效的路径反而是老老实实从数组和字符串开始——因为绝大多数复杂算法最后都会被拆解成对数组索引的移动、对字符串某个字符的存取、对区间内元素顺序的调整。数组和字符串不是“简单题”而是整个算法地基的承重墙。这篇文章围绕 LeetCode 零基础快速入门来写以“27. 移除元素”“344. 反转字符串”“121. 买卖股票的最佳时机”三道题作为主干把数组操作最核心的双指针技巧、索引边界控制以及贪心策略入门的完整思考链一次讲透。这不是题解搬运而是一个能直接套进你日常训练节奏的实操路径。无论你是在准备蓝桥杯、ACM校赛初筛还是单纯想把 LeetCode 刷明白这套思路都适用。1. 为什么数组和字符串是算法的“基础载体”很多人不理解明明字符串和数组的题在 LeetCode 上被标成 Easy为什么还要花专门时间反复练。我的回答是正因为它们足够基础反而最容易暴露你对内存布局、索引边界、指针移动这些底层概念的模糊理解。算法比赛的进阶题本质上是把各种复杂操作包装在简单数据结构上包装拆到最后落点往往就是“这一格要不要覆盖”“这个指针什么时候向前走”“这个边界条件漏没漏”。1.1 数组操作的核心索引即一切数组在内存中是连续存放的访问第 i 个元素的时间复杂度是 O(1)。这个特性是所有数组类算法的大前提。很多人刷题时只记结论“双指针可以 O(n)”却不理解为什么双指针能工作——因为数组支持随机访问你可以同时维护两个下标从不同方向逼近同一个区间而不需要额外复制一份数据。实际刷题中最容易出问题的不是算法本身而是索引边界。举一个最简单的例子for (int i 0; i nums.length; i)和for (int i 0; i nums.length - 1; i)在逻辑上等价但在某些极端情况下比如数组长度为 0后者如果写成nums.length - 1且没有加括号很容易出现负数下标的隐患。C 里nums.size()返回的是size_t无符号整数如果直接写nums.size() - 1而数组恰好为空会得到一个巨大的正数循环直接越界访问。这种问题在 LeetCode 上会直接报 Runtime Error但在比赛现场它可能让你在调试上浪费二十分钟。所以我的建议是每一道数组题都要养成先问三个问题的习惯——数组可能为空吗指针会不会走到数组外面去当两个指针相遇时当前这个位置到底算不算有效位置这三个问题想清楚代码基本就稳了。1.2 字符串题本质是带限制的数组题字符串的难点不在于“字符串本身”而在于不同语言对字符串的实现差异。C 的std::string可以直接通过下标修改字符Java 的String是不可变对象Python 的字符串同样是不可变的只能通过切片或list()转换后再赋值。同一个算法思路在不同语言里写出来的代码差异极大。因此我强烈建议入门阶段选定一门主语言把字符串的常用 API 全部过一遍取长度、取子串、遍历、拼接、比较、翻转。不需要背 API 文档但至少要清楚“当前语言里字符串底层是连续内存还是对象数组”“修改字符串是否需要额外空间”。LeetCode 的 344. 反转字符串就是一个典型的“字符串题当数组题做”的例子——题目要求原地修改这就在提醒你不要想着开新数组倒着填回去而是要在原数组上直接操作。2. 27. 移除元素快慢指针从理解到熟练题目描述很简单给你一个数组 nums 和一个值 val你需要原地移除所有数值等于 val 的元素并返回移除后数组的新长度。不要使用额外的数组空间必须原地修改输入数组。这道题之所以适合入门是因为它把“双指针”中最基础的一种模型——快慢指针——展示得非常清楚。你不需要考虑复杂的排序规则只需要一个指针负责往前探路另一个指针负责记录“下一个有效位置应该写在哪里”。2.1 快慢指针的思考过程我第一次做这道题时第一反应是找到等于 val 的元素删掉它然后把后面的元素整体往前挪。这个思路在逻辑上没错但时间复杂度是 O(n²)——每次删除都要搬移后续所有元素。对于比赛场景这种写法一旦数据规模到 10⁵ 以上直接超时。快慢指针的思路完全不同慢指针 slow 指向“当前已处理好的有效序列的末尾”快指针 fast 负责遍历整个数组。每遇到一个不等于 val 的元素就把它复制到 slow 指向的位置然后 slow 前移一步。fast 走完整个数组后slow 的值就是新数组的长度且 nums[0] 到 nums[slow-1] 都是有效元素。C 参考实现class Solution { public: int removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } return slow; } };Java 参考实现class Solution { public int removeElement(int[] nums, int val) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } return slow; } }代码只有这么几行但里面藏着三个必须想明白的细节第一为什么可以直接覆盖 nums[slow]因为 slow 一定小于等于 fast当 slow 和 fast 指向同一个位置时覆盖是不影响结果的当 slow 小于 fast 时nums[slow] 这个位置上的值要么已经被处理过、要么就是等于 val 的旧值覆盖它不会丢失任何有效信息。第二为什么返回 slow 而不是 slow1因为 slow 指向的是“下一个空位”的下标同时也是新数组的长度。举个例子数组为 [3, 2, 2, 3]val3处理完后 slow2新数组是 [2, 2]长度为 2。如果你在循环结束时让 slow 加一就错了。第三题目要求“移除后返回新长度”但并没有要求你把后面多余的元素清零。LeetCode 只检查前 slow 个元素所以后面残留的旧值不影响结果。2.2 边界情况与常见错误最容易翻车的边界情况有两个数组本身就是空数组此时 fast 循环根本不执行slow 为 0返回 0。这个要保证代码不要因为数组为空就崩。数组里所有元素都等于 val比如 [3, 3, 3]val3循环里一个 if 都进不去slow 始终为 0返回 0。这是正确结果但很多新手会觉得奇怪以为至少要返回点什么。另一个常见错误是把条件写反写成if (nums[fast] val)然后试图在循环里删除元素。在 C 的 vector 里用 erase 倒是能跑但 erase 会让后面的迭代器失效处理不当很容易越界在 Java 的数组里你根本无法直接删除元素只能覆盖。所以记住这道题的正确姿势就是“不等于才复制”不要试图去“删除”任何东西。2.3 同类变体的迁移能力这道题一旦吃透整个“原地操作数组”系列都能顺势拿下来删除有序数组中的重复项把“不等于 val”换成“不等于前一个元素”即可几乎一样。移动零把“不等于 val”换成“不等于 0”再把最后几个位置补零即可。剑指 Offer 21. 调整数组顺序使奇数位于偶数前面用对撞指针而不是快慢指针。我在实际训练中会把这类题打包在同一天刷完因为它们本质上是同一套肌肉记忆。如果你能在一小时内连做四道同类型的题并且不看题解写出来说明这个知识点真的内化了而不是背住了代码。3. 344. 反转字符串对撞指针与原地修改的边界感知反转字符串的题干非常短编写一个函数其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出要求原地修改。为什么强调“以字符数组形式给出”因为如果是真正的字符串Java 和 Python 里的 String 是不可变的你没法原地改而以char[]给出就扫清了这层障碍题目考察的重点就落在“如何用最少的操作完成交换”上。3.1 对撞指针的思路与代码反转字符串最朴素的做法是新建一个等长的字符数组从后往前遍历填入然后再复制回去。这个做法正确但空间复杂度为 O(n)。题目既然强调了原地修改就直接封死了这条路。对撞指针的做法是维护两个指针 left 和 right初始分别指向数组首尾。交换 s[left] 和 s[right]然后 left 右移一位、right 左移一位直到 left right 时停止。C 参考实现class Solution { public: void reverseString(vectorchar s) { int left 0; int right s.size() - 1; while (left right) { swap(s[left], s[right]); left; right--; } } };Java 参考实现class Solution { public void reverseString(char[] s) { int left 0; int right s.length - 1; while (left right) { char temp s[left]; s[left] s[right]; s[right] temp; left; right--; } } }循环终止条件为什么是left right而不是left right因为当 left 和 right 相等时中间的那个字符不需要交换——自己和自己交换没有意义。这个细微差别在代码层面少一次无谓操作更重要的是它帮你建立了“区间端点”的敏感度。对称的题在回文串判断、二分查找里反复出现每一次都要确认结束条件是相遇还是交错。3.2 字符串题里最容易忽略的语言陷阱如果你用 Java需要格外注意s.length有没有括号。数组是属性length字符串是方法length()写混了编译都过不去。这种低级错误在比赛里不算算法问题但真的会让你烦躁。如果你用 Python标准解法会更简短class Solution: def reverseString(self, s: List[str]) - None: s.reverse()这行代码直接调用列表的reverse()方法原地反转。但我不建议入门阶段第一遍就用这个写法——你确实会做这道题了但你对“双指针如何工作”的理解并没有提升。我的建议是先用双手指针法手写一遍跑通后再去看 Python 的reverse()实现这样你才真正明白这个内置方法底层做了什么。比赛时用内置函数是合理的但训练时一定要先裸写一遍。另外如果你后续做的是“反转字符串里的单词顺序”这类变形题就需要先对整个字符串反转再对每个单词单独反转。一旦遇到这种题之前的对撞指针基础就会派上用场——因为它本质上就是“局部对撞”嵌套在“全局对撞”里。3.3 反转之外的字符串常用操作清单刷完反转字符串后建议顺手把以下字符串基础操作过一遍这些都是后续滑动窗口、动态规划、字符串匹配的前置技能遍历字符for (char c : s.toCharArray())或for (int i 0; i s.length(); i)字符串转字符数组Java 用s.toCharArray()Python 用list(s)字符数组转回字符串Java 用new String(charArray)Python 用.join(charList)判断字符类型Character.isLetter(c)、Character.isDigit(c)对应的是 125. 验证回文串 的前置处理字符串比较Java 用equals()C 直接Python 直接注意 Java 用比较的是引用而非内容这些操作每一项单独看都很普通但它们组合起来的题难度会直接翻倍。比如 125. 验证回文串先要把大写转小写、过滤非字母数字字符再双指针判断前后是否相同。没有上面这些基础操作你连预处理都写不利索。4. 121. 买卖股票的最佳时机贪心策略从直觉到证明买卖股票的最佳时机是 LeetCode 上非常经典的入门级别题目但它在“零基础”和“有经验”两拨人眼里完全不是同一个难度。零基础看到题第一反应是“找最低点和最高点”有经验的人看到题会立刻意识到这是一个“怎么用一次遍历完成最大差值计算”的问题而且能顺手给出贪心策略的完整理由。题目描述给定一个数组 prices它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。你只能选择某一天买入并在未来某一天卖出设计一个算法来计算你所能获取的最大利润。如果你不能获取任何利润返回 0。4.1 暴力解到贪心解的思维演进绝大多数新手的第一个想法是双重循环枚举买入日 i 和卖出日 jj i计算 prices[j] - prices[i]取最大值。这个思路完全正确但时间复杂度是 O(n²)。当 n 达到 10⁵ 时就已经很难在比赛中过关了。关键的问题来了能不能在遍历一遍的过程中同时记住“到当前位置为止见过的最低价格”可以。你从第 0 天走到第 i 天如果有一个变量minPrice记录了前 i-1 天的最低价格那么第 i 天卖出时最大利润就是prices[i] - minPrice。你不需要知道具体是哪一天买入的只要知道“之前最便宜的一天在哪一天的价格是多少”就够了。这是因为利润只与卖出价和买入价之差有关在卖出日固定的情况下买入价越低利润越高所以历史最低价就是最优买入价。这个思路就是贪心策略的雏形——每一步都只看当前局部最优不关心未来因为这个局部最优决策不会阻碍后续的全局最优。换句话说如果你今天卖出历史最低点买入一定是最优解而你保留这个历史最低点的信息也不会影响后续任何一天作为卖出日的计算。C 参考实现class Solution { public: int maxProfit(vectorint prices) { int minPrice INT_MAX; int maxProfit 0; for (int price : prices) { minPrice min(minPrice, price); maxProfit max(maxProfit, price - minPrice); } return maxProfit; } };Java 参考实现class Solution { public int maxProfit(int[] prices) { int minPrice Integer.MAX_VALUE; int maxProfit 0; for (int price : prices) { minPrice Math.min(minPrice, price); maxProfit Math.max(maxProfit, price - minPrice); } return maxProfit; } }4.2 为什么“找最低点再找最高点”不一定对很多人会问那我直接找到整个数组的最小值再在它之后找最大值不就行了吗这个想法很诱人但它在一种情况下会失效——全局最低点出现在数组末端而真正能产生最大利润的买入点并不是全局最低点。举个例子prices [2, 7, 1, 4]。全局最低点是 1但在 1 之后只有 4利润是 3实际最大利润是第 0 天买入 2、第 1 天卖出 7利润是 5。如果你先找最低点再找后面的最高点就错过了这个正确答案。而贪心遍历的做法不会出错因为它不是“先定位最低点再找最高点”而是“在遍历过程中不断用最新价格作为卖出价计算出历史最低买入价与当前卖出价的差值”。只要当前价格高于历史最低价就尝试更新最大利润如果当前价格比历史最低价还低就更新历史最低价。每走一步它都在做一次局部判断而这次判断不会影响之前记录下来的最优值。4.3 贪心策略的边界为什么这道题可以用贪心初学者最困惑的一点是贪心到底什么时候能用什么时候不能用如果后面学到 122. 买卖股票的最佳时机 II可以多次交易就会知道那道题也可以用贪心每天只要价格上涨就累加差价但一旦到 123. 买卖股票的最佳时机 III限制交易次数为 2 次贪心就失效了必须用动态规划。判断标准只有一个局部最优决策是否能推导出全局最优且不会排除其他更优路径。对于 121 这道题当前这一步的“更新最低价”和“尝试卖出”是完全独立的两个操作。更新最低价不会覆盖掉已经获得的利润记录尝试卖出也不会让你失去未来更低买入的机会。因为在遍历到第 i 天时你已经知道了前 i 天的所有价格未来任何一天的更大利润都可以用未来那天的价格减去当前已有的最低价来尝试更新不需要回退。所以一步步做出的局部最优决策最终就是全局最优。4.4 从买卖股票延伸开的贪心应用场景121 题刷完之后可以立刻接上两道同源题买卖股票的最佳时机 II多次交易贪心解法是只要后一天价格高于前一天就累加差值因为哪怕前一次卖早了后一次买回来也仍然赚。最大子数组和同样是一遍遍历维护“当前子数组和”和“历史最大子数组和”遇到负和就重置。它的思考方式和 121 题几乎是一个模子刻出来的。这三道题放在一起练你会发现它们底层的“一次遍历 维持状态 逐步更新答案”模式完全一样。这就是为什么我说入门阶段不要贪题多而要把同模式的题连着刷透——一旦你识别出“这个题只需要一遍遍历维护一个状态量”你离想出解法就不远了。5. 刷题节奏与比赛备赛的衔接建议很多准备算法比赛的人会忽略一个事实比赛不只是“会不会解”还包括“快不快、稳不稳、能不能在高压下保持不犯低级错误”。LeetCode 的题解环境比较理想化而比赛需要的是你在 30 分钟内完成从读题、建模、编码、测试到提交的完整闭环。5.1 入门阶段的每日训练安排如果时间和精力允许我建议按下面的节奏执行前 7 天只刷数组双指针题。27、26、283、344、125每天 2 到 3 道目标是看到题目就能条件反射地想到“快慢指针”还是“对撞指针”。第 8 到 14 天只刷一遍遍历维持状态的题。121、122、53、118 杨辉三角重点是解释清楚“自己为什么这么设计状态变量”。第 15 天起开始做混合训练任意抽 5 道 Easy 题限时 40 分钟做完。如果超时不急着看题解先自己用注释写出思路再对照答案。这个节奏的核心目的只有一个在简单题里把代码习惯打磨好。比如变量命名是left/right还是i/j、边界条件是还是、循环里能不能提前 return——这些细节在简单题阶段不修正到难题阶段会放大成灾难。5.2 比赛中的时间和空间复杂度敏感度LeetCode 上很多题目的数据范围会给到 10³ 甚至更小O(n²) 的暴力解也能过。但比赛不是这样——蓝桥杯省赛、ACM 校赛里很多题默认就是 10⁵ 到 10⁶ 的数据规模O(n²) 直接超时。所以从入门第一天起我建议每写完一题都顺手写下时间复杂度分析不用写长一行就行。用本文三道题举例移除元素O(n) 时间O(1) 空间。快指针遍历一次慢指针同步移动没有额外数组。反转字符串O(n) 时间O(1) 空间。交换操作耗时 O(1)左右指针总共移动 n/2 次。买卖股票的最佳时机O(n) 时间O(1) 空间。一遍遍历两个变量。三道题全部是 O(n)O(1)这其实也是入门数组题的标准形态。如果你以后碰到一道数组题读完题发现可以只用常数个变量配合一次遍历就完成那大概率就是一个贪心或双指针的模型。5.3 调试技巧print 流比断点更快在 LeetCode 上提交前我会习惯性在草稿纸上推演一个极小的用例。例如反转字符串就写[a, b, c, d]手动模拟 left0、right3 和 left1、right2确认中间的字符不需要处理。这个习惯在比赛里特别有用因为比赛环境往往不提供断点调试你只能靠打印中间变量来判断问题在哪。如果某段逻辑怎么都想不通我常用的方法是临时加一个System.out.println(left left , right right);或者cout slow slow fast fast endl;把每一轮循环的状态打出来。打印的时候建议同时输出指针位置和当前数组的完整内容否则只看指针位置很难发现问题。而且刷题阶段的代码一定要在本地 IDE 里配好测试用例再贴到 LeetCode。本地能跑通不代表线上能跑通因为 LeetCode 的判题机在内存不足时行为可能和你本地不一致但本地能跑通至少能筛掉一大半的语法错误和逻辑错误。5.4 对零基础选手的几个具体建议最后说几个我在带人过程中反复强调的点第一不要背代码。背代码最典型的特征是换一个数字、换一个变量名你就不会写了。正确做法是背思路比如“快慢指针快指针负责找有效元素慢指针负责存储位置对撞指针一左一右往中间逼近贪心遍历时维护历史最优状态”。第二遇到卡壳超过 30 分钟的题直接看题解但看完必须合上题解自己重写一遍。重写不出来就再看再重写直到能不看题解写出来为止。这里的关键是“合上题解”而不是“照着题解敲一遍”差别非常大。第三准备一个错题本不需要多精美记录三件事题目编号、卡住的原因是没思路还是边界写错、下一次要注意什么。比赛前翻一遍错题本比刷十道新题管用得多。第四不要完全避开题意复杂的题。比如 8. 字符串转换整数 (atoi) 这种题虽然思路不难但全是边界处理正负号、溢出、前导空格、非法字符。它考察的就是你在工程细节上的耐心。刷这种题能帮你建立“把题目要求一条条列出来再一条条翻译成代码逻辑”的习惯这个习惯在备赛过程中价值极高。6. 从这三道题延伸出的算法思维主线刷完这三道题你已经在这个阶段拥有了三把基础武器快慢指针处理覆盖型数组问题、对撞指针处理对称型区间问题、一次遍历加状态维护处理最值型问题。接下来要做的是沿着这三条主线继续深化。6.1 双指针家族链表中也有双指针数组里的快慢指针放到链表里同样成立。LeetCode 876. 链表的中间结点、141. 环形链表都是快慢指针的经典应用。区别在于数组的指针是下标链表的指针是节点引用数组可以随机访问链表只能逐个 next。但“快指针走两步、慢指针走一步”的核心思想完全一致。如果你准备比赛链表和数组的双指针题混合刷可以建立更强的抽象能力——同一个算法思想在不同数据结构上的呈现方式不同你能识别出底层的共同模式就说明你是真的理解了。6.2 贪心策略一次遍历维持状态的广泛应用买卖股票这道题的贪心解法背后是一种更普适的思维模式在遍历数据流时维持一个“到当前位置为止的最优状态”每来一个新数据就尝试用这个状态更新答案。这个模式直接延伸到的题目包括最大子数组和维护当前子数组和 curSum如果 curSum 小于 0 就重置为 0一直更新全局最大值。买卖股票的最佳时机维护历史最低价 minPrice每天尝试卖出更新最大利润。判断子序列双指针一个指针指向源字符串另一个指向目标子序列按顺序匹配。这些题的共同特征都是你要找到那个“随着遍历推进可以被维护的状态量”而不是反复嵌套循环去枚举所有可能。6.3 比赛常见的数据规模与策略参考不同比赛的数据范围差别很大我根据自己的比赛经历列一张表方便你刷题时对照数据规模可接受的复杂度典型策略n ≤ 20O(2ⁿ) 甚至 O(n!)暴力枚举、DFS 全排列n ≤ 10³O(n²)双重循环、朴素 DPn ≤ 10⁵O(n log n) 或 O(n)二分、排序、双指针、贪心n ≤ 10⁶O(n)哈希表、双指针、线性 DPn ≥ 10⁷O(n) 以下通常要数学推导找规律、数论降维、矩阵快速幂LeetCode 的 Easy 题大多属于 n ≤ 10⁵ 这一档所以 O(n) 的解法基本就是最优解。但比赛有它的残酷性即使你想到了 O(n log n) 的排序解法如果常数因子太大也可能在极端数据下超时。这就要靠刷题时对“常数级优化”保持敏感——比如能用数组下标映射就不要用哈希表能用基本类型就不要用封装类型。6.4 一个建议的进阶路线图如果你完成了本文三个专项的训练下一步我建议按照“查找 → 排序 → 哈希 → 递归 → 动态规划”的顺序继续推进。数组和字符串永远是这些进阶知识的载体你在 27 题里练熟的双指针后面在二分查找里还会频繁出现你在 121 题里练熟的贪心后面会演变成动态规划中的状态转移思想。一些可以直接接在本文后面的题目二分查找类35. 搜索插入位置、704. 二分查找哈希表类1. 两数之和、242. 有效的字母异位词滑动窗口类209. 长度最小的子数组、76. 最小覆盖子串前缀和类560. 和为 K 的子数组、303. 区域和检索 - 数组不可变字符串处理类7. 整数反转、8. 字符串转换整数 (atoi)、151. 反转字符串中的单词这些题的共同点依然是建立在“数组索引操作”和“字符串预处理”这两块地基上。地基打得越稳后面建楼越快。最后分享我个人的一点体会刷题初期不要被“刷了多少题”绑架。我见过太多人一个月刷一百多题但问到 27 题为什么返回 slow 而不是 slow1还是会愣住。与其追求数量不如把每道题背后的思维模式拆干净。比如今天这三道题你如果能在不看任何资料的情况下清晰讲出双指针的三种形态同向快慢、异向对撞、双端探测能把贪心策略在 121 题上的“为什么局部最优等于全局最优”讲明白那我敢说你比那些“刷了两百题但只看题解”的人离真正的算法能力更近。