一提到双指针,大多数人脑子里浮现的是“左右夹逼”“滑动窗口”“两数之和”这些经典模板。但 LeetCode 2105 这道题不太一样,它把双指针包装进了一个非常具体的场景:两个人站在河边,一人拎着一个水罐,从一排植物两头同时开始浇水,水不够了就得跑回河边装满再回来继续。题目问的是,整个过程中两个人一共要跑回去装水多少次。
这道题我刷完第一遍之后印象很深。它不考什么高深算法,也没有复杂的数学推导,纯粹考察你能不能把一个带有“状态”的场景稳定地翻译成代码。这种能力在面试里其实很值钱,因为很多实际需求就是这种“规则多、逻辑细、边界杂”的模拟题。如果你是刚开始刷 LeetCode,或者正在系统过一遍双指针题目,这道题值得认真做一遍,做完之后对“状态维护”和“边界处理”的理解会清晰很多。
1. 先读懂题:两个人在河边来回跑的完整过程
1.1 题目到底在描述什么场景
简化一下题目场景:河边从左到右种了一排植物,每株植物需要的浇水量存放在数组 plants 里。Alice 站在最左边,Bob 站在最右边,两个人同时出发,往中间走,每走到一株植物就用自己的水罐给它浇水。
这里有几个关键规则。
第一个规则:Alice 和 Bob 各自有一个水罐容量,也就是初始最大装水量,分别记作 capacityA 和 capacityB,一开始都是满的。每浇一株植物,水罐里的剩余水量就减少 plants[i]。
第二个规则:如果当前这株植物需要的水量比水罐里剩的水多,这个人就必须跑回河边,把水罐重新装满,再跑回来浇水。注意,这里只问“回去装水的次数”,不要求计算跑的路程。所以装水这个操作,只需要在答案上累加 1,然后把剩余水量重置成水罐容量即可。
第三个规则:Alice 和 Bob 是相对走的,一个从左往右,一个从右往左,所以他们最终会在某一株植物处相遇。相遇时,这一株植物由“剩余水量多的人”来浇;如果两个人剩余的水量恰好一样多,则规定由 Alice 来浇。
最后要求返回的是两个人重新装水的总次数。
我第一次看到这个题的时候,第一反应是“这不是很简单吗,直接模拟一遍不就行了”。但真正动手写的时候才发现,规则越简单的题目,边界情况越容易出错。尤其是相遇那一下的处理,很多人都会在这里翻车。
1.2 这道题考什么,适合谁刷
从算法分类上看,这道题属于“模拟 + 双指针”,难度在 LeetCode 上是中等偏下,但它考察的能力非常综合。
第一,它考察阅读理解能力。题面很长,但核心规则其实很少,你需要快速提取出“谁在走、什么时候装水、相遇怎么处理”这三个关键点。
第二,它考察状态维护能力。你需要同时维护 Alice 和 Bob 两个人的剩余水量,还要维护两个指针的位置,以及最后的答案计数。这四个变量在循环里是互相影响的,任何一个更新顺序错了,结果都会变。
第三,它考察边界处理能力。数组长度为 1 怎么办?两个人从出发就相遇怎么办?两个人的水都不够浇同一株植物怎么办?这些都是题目没有明说、但你必须自己处理的地方。
如果你是刚开始刷题,建议先做一下 LeetCode 2079 的“给植物浇水”,那道题是单人的版本,逻辑更简单,能帮你建立“水量不够就回去装水”的直觉。然后再做这道 2105,你会明显感觉到“从一个人变成两个人”之后,难点从“模拟”变成了“状态的同步管理”。如果你已经在备战面试,这道题可以作为模拟题的代表作反复练习,因为它几乎是面试官最爱出的“代码量大但思路简单”的题目类型。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从题意到算法:为什么双指针是显然解
2.1 单人的情况下你会怎么做
我们先退一步,不考虑 Bob,只考虑 Alice 一个人从左边开始浇完整排植物,会怎么做。
思路非常简单:用一个变量 cur 记录当前水罐里的剩余水量,遍历 plants 数组。如果 cur >= plants[i],说明水够,直接浇,cur -= plants[i]。如果 cur < plants[i],说明水不够,需要回河边装一次水,答案加 1,然后 cur = capacity,再 cur -= plants[i]。
这个过程的本质是:当前水量够不够,决定要不要触发“装水”这个动作;装完水之后,剩余水量一定要重置为初始容量,而不是在原有基础上累加。
单人版本的口诀就一句话:先判断够不够,不够就重置,再减去当前消耗。
2.2 双人场景如何变成两个指针
有了单人版的思路,双人版就顺理成章了。Alice 从左往右走,Bob 从右往左走,这不就是两个指针从两端向中间移动吗?
你只需要在单人版的基础上,把“遍历数组”改成“双指针同时移动”,并且维护两个剩余水量变量 curA 和 curB。
具体来说,在每次循环里做四件事:
- 检查 Alice 的剩余水量是否够浇
plants[left],不够就装水,答案加 1,重置curA,然后浇水扣减。 left指针右移一位。- 检查 Bob 的剩余水量是否够浇
plants[right],不够就装水,答案加 1,重置curB,然后浇水扣减。 right指针左移一位。
为什么不需要真的模拟“跑回河边”这个动作?因为题目只关心装水次数。只要当前水不够,就必然需要一次往返,无论你人在哪里。这个操作在代码层面就是一个 ans++; cur = capacity;,非常干净。
这里有一个很重要的认知:这道题并不是真的要你模拟两个人走路的轨迹,而是让你维护一个“水够不够”的状态机。一旦你想通了这一点,代码就很好写了。
2.3 相遇时怎么处理才是对的
前面说了,Alice 和 Bob 是相向而行的,最后一定会停在某一株植物上。如果数组长度是偶数,那么 left 和 right 会在循环中交错,刚好走完所有植物,不会有重叠。但如果数组长度是奇数,循环结束时会有一个 left == right 的情况,也就是说两个人都到达了中间那一株植物。
这时候不能直接套用前面“各自检查不够就装水”的逻辑了,因为中间这株植物只需要浇一次,不能让两个人各浇一次。
题目给出的规则是:相遇时,水量多的人负责浇;如果水量一样多,Alice 负责浇。
用代码表达,就是判断“是否至少一个人当前水量 >= 这株植物所需的水量”。如果是,答案不增加;如果两个人都小于所需水量,那么必然要有一个人回去装一次水,答案加 1。
这里最经典的错误写法是判断 curA + curB >= plants[left]。这看起来好像没问题,但实际上是错的,因为浇花必须由单个人完成,你不能把 Alice 和 Bob 水罐里剩下的水倒在一起去浇一株植物。如果两个人的水都不够,只能让其中一个回去装满,所以这种情况下答案是固定加 1,而不是加 2。
为什么不是加 2?因为只需要一个人回去装水就够浇中间这株植物了,回去装 1 次即可。题目要求的是“重新装水的总次数”,不是“跑回去的次数乘人数”。
下面用几个小例子来验证这个逻辑:
curA = 5, curB = 3, plants[left] = 4:Alice 水够,Bob 不够,Alice 浇,不需要装水,加 0。curA = 2, curB = 3, plants[left] = 3:Alice 不够,Bob 刚好够,Bob 浇,不需要装水,加 0。curA = 2, curB = 2, plants[left] = 3:两个人都不够,任意一个人回去装一次,加 1。curA = 3, curB = 3, plants[left] = 3:两个人水量一样,Alice 浇,不需要装水,加 0。
所以相遇时的代码逻辑就是:只有当 max(curA, curB) < plants[left] 时才需要加 1。这个条件写起来非常简洁,也很好理解。
3. 代码实现:C++ 和 Python 逐行拆解
3.1 C++ 版本:面试中的标准写法
cpp复制class Solution {
public:
int minimumRefill(vector<int>& plants, int capacityA, int capacityB) {
int n = plants.size();
int left = 0, right = n - 1;
int curA = capacityA, curB = capacityB;
int ans = 0;
while (left < right) {
// Alice 浇左边的植物
if (curA < plants[left]) {
ans++;
curA = capacityA;
}
curA -= plants[left];
left++;
// Bob 浇右边的植物
if (curB < plants[right]) {
ans++;
curB = capacityB;
}
curB -= plants[right];
right--;
}
// 相遇时只剩一株植物
if (left == right) {
if (max(curA, curB) < plants[left]) {
ans++;
}
}
return ans;
}
};
我逐段说一下这个代码的细节。
curA = capacityA 和 curB = capacityB 是初始化剩余水量,注意这里用的是初始容量来赋值,不是 0,因为两个人一开始水罐都是满的。
循环里的判断顺序非常关键:先判断“够不够”,如果不够先装水,再浇水扣减。注意不能顺序颠倒,否则就变成了“先扣水再判断够不够”,逻辑就完全错了。比如 curA = 3, plants[left] = 5,如果你先执行 curA -= plants[left],curA 就变成了负数,再判断 curA < 0 再去装水,虽然结果碰巧对,但语义混乱,而且容易在更复杂的变体题里出错。
while (left < right) 这个条件保证了循环只在两指针未相遇时执行。循环结束后如果 left == right,说明中间还剩一株植物没浇。之所以用 max(curA, curB) < plants[left] 来判断,是因为相遇时只需要一个人去浇,只要至少有一个人的水量足够,就不需要装水。
关于类型选择,原题的数据范围是 n <= 10^5,plants[i] <= 10^9,capacity <= 10^9。也就是说 curA 和 curB 最大的时候也就是 10^9,用 int 其实不会溢出。但我在面试里习惯用 long long,因为如果题目后续把容量改成 10^18,或者加一个“累计水量”的变量,int 就很容易爆。写 long long 不算错,是一种防御性写法。
3.2 Python 版本:逻辑清晰但要注意语法差异
python复制class Solution:
def minimumRefill(self, plants: List[int], capacityA: int, capacityB: int) -> int:
n = len(plants)
left, right = 0, n - 1
cur_a, cur_b = capacityA, capacityB
ans = 0
while left < right:
if cur_a < plants[left]:
ans += 1
cur_a = capacityA
cur_a -= plants[left]
left += 1
if cur_b < plants[right]:
ans += 1
cur_b = capacityB
cur_b -= plants[right]
right -= 1
if left == right:
if max(cur_a, cur_b) < plants[left]:
ans += 1
return ans
Python 版本和 C++ 版本在逻辑上一模一样,唯一的区别是语言表达方式。max(cur_a, cur_b) < plants[left] 这种写法在 Python 里非常自然,不需要像 C++ 那样担心类型推导,读起来也很直观。
这里有个 Python 特有的小建议:不要在循环里用 while left <= right,然后试图在循环内部处理相遇的情况。这个写法容易导致中间那株植物被处理两遍,尤其是在你复制粘贴了 Alice 和 Bob 的两段逻辑之后。
3.3 代码细节里的两个小坑
第一个坑是“装水后剩余水量的赋值”。
假设 Alice 当前剩余水量是 2,水罐容量是 5,当前植物需要 4。正确操作是:发现不够,装一次水,curA = 5,然后浇水,curA = 1。有些初学者会写成 curA += capacityA,把原来的剩余水量加上水罐容量变成 7,这显然是错的,因为装水是把水罐装满,不是往现在剩的水里继续加水。
第二个坑是“指针移动的时机”。
一定要在浇水扣减之后才移动指针,不能移动了指针再扣水。虽然在这个题目里,先移动指针再扣水,碰巧也能通过,因为 left 和 right 的值在循环里会正确推进,但这种写法掩盖了“当前处理的对象是哪个位置”这个关键信息。一旦题目改成需要记录浇水顺序、或者需要累计走过的路程,这种隐含的混乱就会变成 bug 的来源。
4. 复杂度与边界情况:把测试用例跑一遍
4.1 时间与空间复杂度
时间复杂度是 O(n),其中 n 是植物数量。因为 left 和 right 两个指针分别从两端向中间移动,每次循环处理两株植物(一株左边一株右边),整个数组最多被完整遍历一遍,不会出现重复访问。无需嵌套循环,也没有额外循环,所以时间复杂度严格来说是 O(n/2),等价于 O(n)。
空间复杂度是 O(1)。我们只用了 left、right、curA、curB、ans 这几个变量,没有创建任何和输入规模相关的数据结构。即使 plants 数组再大,额外空间也是常数级别。
4.2 边界用例演练
边界情况是这种模拟题的灵魂。我把我测试时用过的几个关键用例整理出来,你可以直接拿去验证你的代码。
第一个用例:plants = [2, 2, 3, 3], capacityA = 5, capacityB = 5。数组长度是偶数,两个人刚好碰不到同一株植物。Alice 浇 2 和 2,剩余 1,不需要装水;Bob 浇 3 和 3,需要装一次水。答案是 1。这个用例验证了常规流程和偶数长度的情况。
第二个用例:plants = [2, 2, 3, 3, 5], capacityA = 5, capacityB = 5。数组长度是奇数,中间那株植物需要 5 的水量。Alice 和 Bob 各自浇完两边之后,剩余的 curA = 1, curB = 2,中间需要 5。两个人都不够,所以需要其中一个人回去装一次,答案是 2(Bob 之前装过一次,中间相遇再装一次)。
第三个用例:plants = [5], capacityA = 3, capacityB = 4。数组长度是 1,left == right == 0。两个人的水都不够这株植物所需的水量(3 < 5 且 4 < 5),所以必须让其中一个人回去装一次,答案是 1。这里如果你想验证 n == 1 的情况,这个用例是最直接的。
第四个用例:plants = [1, 1, 1, 1], capacityA = 10, capacityB = 10。所有植物需要的水量都很小,两个人从头到尾都不需要装水,答案是 0。这种用例用来验证“初始容量足够大”的情况。
把这些用例全部跑通,代码的正确性就比较有保障了。
4.3 调试实录:两个我踩过的坑
第一次写这道题的时候,我用了 while (left <= right),然后在循环里分别处理 Alice 和 Bob 两端,同时判断相遇情况。结果中间那株植物被处理了两遍:一次在 Alice 的“浇水”逻辑里,一次在 Bob 的“浇水”逻辑里。答案自然是错的。
后来我把循环条件改成 while (left < right),把相遇的情况单独拎出来处理,代码瞬间就清晰了。我个人建议,处理这种“从两端向中间汇聚”的题目,优先把“相遇”作为循环结束后的一个独立阶段来处理,而不是在循环内部用 if (left == right) 去特判。这样做的好处是,主循环的逻辑保持对称,不容易漏处理或重复处理。
第二个坑比较隐蔽。我在测试的时候发现,如果 curA 刚好等于 plants[left],浇水之后 curA 变成 0,但是不会触发装水。这是正确的。但我在第一次写单人版的时候,下意识地把判断条件写成了 curA <= plants[left],导致水量刚好够的时候也去装水,答案偏大。实际上,水量刚好等于需求时,是能够完成浇水的,不需要额外装水,条件应该写成严格小于 <。
5. 易错点与常见问题速查
5.1 常见错误清单
我整理了一份这道题最常见的错误写法,每一列都有明确的错误原因,你可以对照自查。
| 错误写法 | 错误原因 | 正确写法 |
|---|---|---|
相遇时判断 curA + curB >= plants[left] |
浇水必须由一个人完成,不能合并两人剩余水量 | 判断 max(curA, curB) >= plants[left] |
循环条件写成 while (left <= right) 且内部分别处理两端 |
中间那株植物会被处理两遍 | 使用 while (left < right),循环结束后单独处理相遇 |
水不够时 curA += capacityA |
装水是重置为满罐,不是累加 | curA = capacityA |
判断条件写成 curA <= plants[left] |
水量刚好等于需求时不需要装水 | 使用 curA < plants[left] |
| 装水后先移动指针再扣减水量 | 状态变量和位置变量绑定关系混乱,易在变体题中出错 | 先扣水量再移动指针 |
这张表里最重要的就是第一行。我见过太多人在相遇时条件写错,本质上是没有理解“浇水这个动作必须由一个人独立完成”这个隐含条件。
5.2 这类模拟题通用的避坑心法
模拟题最容易出问题的地方,不是算法复杂度,而是“状态变量的生命周期”。
我自己的习惯是,拿到一道模拟题,先不急着写代码,而是先在草稿纸上把状态变量列出来,比如这道题就是:left 指针、right 指针、curA 剩余水量、curB 剩余水量、ans 答案。每写一段循环体,就对照这个变量清单检查一次,看看哪个变量被更新了、更新的顺序是什么、有没有变量被漏更新。
第二个习惯是“先手动模拟,再写代码”。对于这种规则比较多的题,找一个长度适中的用例,比如长度为 5 的数组,自己在纸上把 Alice 和 Bob 每一步的操作写出来,得到预期的答案,再去对照代码结果。这样做的好处是,你能发现很多“代码看起来合理但实际跑偏”的情况。
第三个习惯是“用极端用例验证边界”。比如 n = 1 时,left 和 right 从一开始就相等,你的代码能不能正确处理?比如 capacity 远大于所有 plants[i] 的和,你的答案是不是 0?这些极端用例往往能暴露出逻辑死角。
6. 同类题目对比:从“给植物浇水”双题看模拟题的套路
6.1 和 2079 单人版的对比
LeetCode 2079 是“给植物浇水”的单人版本,原题是:一个人从左到右给一排植物浇水,水不够就回去装满再回来,问总共需要回去多少次。
这道题的解法非常简单,就是前面提到的单人版模拟:
python复制def minimumRefill(plants: List[int], capacity: int) -> int:
cur = capacity
ans = 0
for need in plants:
if cur < need:
ans += 1
cur = capacity
cur -= need
return ans
从代码量上看,2079 比 2105 短很多。但从思维框架上看,两道题是一样的:先判断“水够不够”,不够就装水并重置,再扣减当前消耗。
2105 相当于 2079 的“双人并行”版本。你在掌握单人版之后,只需要把“一个变量”扩展成“两个变量”,然后处理好相遇时的特判,就能写出正确代码。这也是这道题最值得做的地方:它帮你理解了很多模拟题的本质是“在同一个状态转移框架下,增加参与者或约束条件”。
6.2 变体扩展:如果题目换个场景
刷完这道题之后,你可以试着想一想,如果题目换成下面这些变体,你的解法需要怎么调整。
第一个变体:三人浇水。三个人分别从数组的左端、中间、右端出发,各自维护一个指针,遇到边界条件时做相应处理。核心思路不变,但相遇时的处理会变得复杂,因为你无法保证三个人总是能在合理的位置停下来。
第二个变体:环形浇水。植物围成一圈,Alice 从某个点出发,浇完一圈回到原点。这时候不仅要考虑水量,还要考虑“走了多少路”或者“装水之后回到当前位置的距离”,复杂度会显著提升。
第三个变体:不能提前装水,只能一开始装满然后一路浇过去。这个问题就完全变了,它变成了一个“最多能连续浇多少株植物”的贪心或滑动窗口问题,和原题的模拟思路完全不同。
这些变体看起来各不相同,但它们都在考察同一个底层能力:你能不能把题面描述的场景,准确转换成状态变量的变化过程。一旦你熟练掌握了这类“状态模拟题”的套路,遇到任何类似的场景化题目,都不会再觉得无从下手。
我个人刷这道题最大的体会是:模拟题最忌讳的就是“读题五分钟,写码半小时,调试两小时”。拿到这种规则丰富的题目,先花三分钟把状态变量和流程图画清楚,再动手写代码,反而比直接开写要快得多。这也是为什么我在文章里反复强调“先想清楚再写”的原因。如果你把 2105 这道题吃透了,再去做 2079、或者去挑战更多带状态的双指针模拟题,你会发现自己对“状态维护”这件事有了真正的手感。
