种花问题是我在题库和面试里见过很多次的经典贪心题。题目会给你一个花坛,用一个整数数组表示,1代表已经种了花,0代表空位,规则是任意两朵花不能种在相邻位置。现在你手里有n棵花苗,要判断能不能全部种下去。听起来很像“数一数空位够不够”的小学算术,但真正动手写就会意识到,边界怎么处理、能不能重复计数、为什么从左往右扫一遍就能得到最优解,每一处都藏着坑。这篇文章我会从题目拆解、贪心原理、代码实现、变体扩展到调试经验完整过一遍,适合正在准备算法面试的人,也适合想看明白贪心算法到底在贪什么的新手。
如果你只是急着过用例,可以直接跳到第三章抄代码。但如果你愿意花十分钟理解“能种就种”为什么成立,以后再遇到类似的区间冲突题会轻松很多。顺便说一句,这道题不需要任何数据结构基础,数组、循环、布尔判断就够,所以也是新手入门贪心的绝佳样本。我下面写的所有代码都以“不违反相邻规则”为前提,假设输入里已经种下的花本身是合法的,这样我们可以把注意力完全放在“如何再塞n朵花”上。
1. 题目到底在考什么
1.1 问题描述再翻译一遍
把题目里的花坛当成一排连续的格子。数组flowerbed的长度为m,每个位置只能是0或1。0代表这个格子空着,1代表已经种了花。现在要把n朵新花种进去,规则是任意两朵花不能相邻,也就是说,如果某个位置种了花,它左边和右边紧挨着的位置都不能再种。最后要返回一个布尔值,表示能不能做到。
举个例子,flowerbed = [1,0,0,0,1],n=1时,可以在中间三个0的任意一个位置种下一朵,返回true。n=2时,三个连续空位只能容纳一朵,因为种在中间位置后,左右两个位置都会变成禁区,所以返回false。再比如 [0,0,0,0,0],n=3,可以种在位置0、2、4,返回true;但n=4就做不到。
从算法题的角度看,这是一个典型的“可行性判断”问题。它不要求你输出具体种在哪,也不要求求出最大数量,只需要回答yes或no。这类问题通常可以用贪心、双指针或数学统计来解决,而种花问题正好是贪心最容易讲明白的题目之一。
1.2 题目给的隐含条件
很多题解没有强调的一点是:输入中已经种下的花不会违反“不能相邻”的规则。比如不会出现 [1,1,0,0] 这样的数组。这个条件意味着我们不需要去修正已有花的状态,只需要在0的位置上做加法。这一点在实际写代码时非常重要,因为你判断“位置i是否可以种花”时,只需要看它相邻的两个位置,完全不用回溯检查更远的历史。
另一个隐含条件是n可能为0。当n=0时,不需要种任何花,直接返回true。这一点看似废话,却决定了一些剪枝代码能否提前返回。还有数组长度可能是1,比如 [0] 时n=1能种,[1] 时n=1不能种。如果代码里不加边界判断,很容易在这类用例上翻车。
另外,题目通常不会限制是否允许修改输入的flowerbed数组。很多人在面试时默认原地修改,其实更好的做法是先问清楚。如果面试官说“可以修改”,那么原地写法最简洁;如果说不可以,就需要用不修改数组的版本。下面我会把两种写法都展开讲。
1.3 三种典型的错误直觉
我第一次做这道题时,也想当然地认为只要统计空位数就行。统计0的总数后除以2,结果在不少用例上是错的。比如 [0,0,0,0,0] 长度5,最多能种3朵,但5//2只能得到2。原因是花朵之间的间隔规则不是简单地“两个空位一朵花”,边界位置可以省掉一个间隔。
第二种错误是看着连续0段的长度套“除以2”的公式。比如 [0,0,1,0] 开头的两个0虽然连续,但第一个0紧贴边界,可以种在位置0;如果机械地认为中间连续段公式(k-1)//2 = 0,就会漏掉这一朵。更麻烦的是整个数组都是0的情况,它没有“左有1”或“右有1”来界定边界,很多人会把它误当成普通连续段。
第三种错误也是最隐蔽的:扫描时看到某个空位左右都是空位,就认为可以种,但没有把这个位置“变成已种植状态”,导致后面相邻空位又被重复计数。比如 [0,0,0],如果只是判断而不更新数组,位置1在位置0种下后本来不能种,但代码会因为它左边是0就再给它一朵,最后错误地算出3朵。这个问题在“不修改数组”的写法里需要通过一个变量来避免,本质上都是状态管理的问题。
这一节总结起来就一句话:边界和状态更新,是种花问题的两个最容易失分点。想明白这两点,代码怎么写都不会跑偏。
2. 贪心策略:能种就种,为什么是最优
2.1 直观理解
从左到右扫描花坛,遇到一个空位,如果它左边没有花、右边也没有花,就种下一朵。这种策略叫“能种就种”,它是不是太贪心,会不会因为占了前面的位置而导致后面种得更少?直觉上不会,因为一朵花最多影响它左右两个相邻位置。如果你选择不在当前空位种,而是把它留到后面,那么后面你能选的位置要么不变,要么更靠右,而更靠右意味着占用了更靠后的空间,显然不会让总数变多。
用一个通俗类比:你在一排空座位上选座,如果某个座位两边都没人,你坐下后只会影响旁边座位;如果你非要不坐这里,而坐到更后面,旁边座位的可用性并不会变好,反而可能把后排本可以坐的位置占了。所以“看到能坐就坐”在这个场景下是最优的。
在种花问题里,“能坐”的判断严格一些:当前空位左侧为空或左边界,右侧为空或右边界。满足这个条件就种,不满足就跳到下一个位置。
2.2 一个更严谨的替换论证
如果想在面试中把贪心证明讲得滴水不漏,可以用反证加替换。假设存在一个最优方案,在从左到右遇到的第一个可种位置i处没有种花。那么最优方案里,位置i以及i+1中至少有一个位置要被后续的某些操作占用,否则我们完全可以在i处种一朵,然后去掉一个已有的花,得到相同数量的方案。更具体地说,最优方案中第一个种在i右侧的位置记为j。由于i+1与i相邻,如果j=i+1,那么把j处的花移到i处,不会与位置i-1冲突,因为i处可种就说明i-1空;移动后j处空出来,也不会让任何花相邻,因为原方案中j和j+1之间需要隔一个空位,现在j空着,j+1更安全。如果j>i+1,直接把j处的花移到i处,同样不会造成冲突,因为i左右都空,而j原来的位置空出来不会破坏任何规则。替换后的方案花数不变,却与“最优方案在i处不种”矛盾。
这个论证的本质是:越早种植,占用的后续资源越少。因此从左到右的贪心扫描一定能得到最大可种数量。面试时不需要背这段话,只要理解逻辑,用自己的话讲出来即可。
2.3 边界上的特殊待遇
线性花坛的最左边和最右边只有一个邻居。位置0只需要右边是空或右边界就能种,不要求左边有什么;位置m-1只需要左边是空或左边界。很多第一次写代码的人会把条件写成“flowerbed[i-1] == 0 and flowerbed[i+1] == 0”,结果在端点直接越界,或者在端点处漏掉可种的机会。
处理边界有两种常见姿势。第一种是在每个位置判断时加上边界条件:left_ok = (i == 0) or (flowerbed[i-1] == 0),right_ok = (i == m-1) or (flowerbed[i+1] == 0)。第二种是给数组两边各补一个0,让所有真实位置都有完整的左右邻居,判断逻辑就统一成“左右都为0”。我推荐初学者先写第一种,因为边界条件看得见摸得着;等熟悉之后,再用哨兵法简化代码。
2.4 时间与空间复杂度
无论用上面哪种写法,只需要遍历一次数组,时间复杂度都是O(m)。空间复杂度取决于是否修改输入:原地修改是O(1),用哨兵数组是O(m),不修改数组且只用变量记录是O(1)。在面试场景中,我会主动说“原地修改只适用于允许改动输入的情况,如果输入需要复用,我会用变量记录最近花的版本”。这种细节虽然不影响最终正确性,但能让面试官看到你具备工程意识。
3. 代码实现:从最简到可扩展
3.1 最直接的原地修改版
先看Python版本:
def can_place_flowers(flowerbed, n): count = 0 m = len(flowerbed) for i in range(m): if flowerbed[i] == 0: left_ok = (i == 0) or (flowerbed[i - 1] == 0) right_ok = (i == m - 1) or (flowerbed[i + 1] == 0) if left_ok and right_ok: flowerbed[i] = 1 count += 1 if count >= n: return True return count >= n这段代码的关键点有三个。第一个是left_ok和right_ok的短路顺序,必须先判断边界,再去访问数组,否则在端点会越界。第二个是当条件满足时,立刻把flowerbed[i]改成1,这一步不只是在模拟种花,更是为了阻止后面相邻位置重复计数。第三个是提前返回,count已经达到n就不用再遍历,能省下不少无谓循环。
如果换成C++,逻辑完全一样,只是需要手动管理索引类型。需要注意的是,C++数组没有Python那样的负索引,越界访问会直接产生未定义行为,所以边界判断更加重要。下面是一个可运行版本:
bool canPlaceFlowers(vector<int>& flowerbed, int n) { int count = 0; int m = flowerbed.size(); for (int i = 0; i < m; ++i) { if (flowerbed[i] == 0) { bool leftOk = (i == 0) || (flowerbed[i - 1] == 0); bool rightOk = (i == m - 1) || (flowerbed[i + 1] == 0); if (leftOk && rightOk) { flowerbed[i] = 1; if (++count >= n) return true; } } } return count >= n; }C++里把count++和判断写在一起是常见写法,但要注意++count返回的是自增后的值,如果这里写count++判断就会出错。很多人面试时手写C++容易在这种细节上翻车,建议写清楚两行,不要为了炫技合并。
3.2 不修改原数组的写法
有些场景明确要求不能修改输入数组,这时可以用一个变量prev记录“左边最近一朵花的位置”。初始值设为-2,表示左边很远都没有花。每遇到一个位置i,如果当前是1,就把prev更新为i;如果当前是0,并且i - prev >= 2,说明左侧至少隔了一个空位,可以尝试种花。但右侧也必须是空的,所以还需要检查i+1越界或flowerbed[i+1]==0。如果右侧为空,就种下并把prev更新为i;如果右侧为1,则不能种,也不更新prev,等遍历到右侧的1时再更新。
def can_place_flowers(flowerbed, n): count = 0 prev = -2 m = len(flowerbed) for i in range(m): if flowerbed[i] == 1: prev = i elif i - prev >= 2: if i + 1 == m or flowerbed[i + 1] == 0: count += 1 if count >= n: return True prev = i return count >= n这个版本里,prev不仅记录原始已经种下的花,还记录我们新种下的花。每次种下后更新prev,效果等同于把当前位置“标记为1”,但没有真正改动原数组。这种写法比原地修改稍微难理解,但它在保护输入数据的同时,仍然保持O(1)空间,是很值得掌握的技巧。
3.3 加哨兵的统一写法
如果觉得边界判断太啰嗦,可以在原数组左右各补一个0。补出来的0不是真实地块,而是“虚拟空地”,它们让第一个和最后一个真实位置都有了左右邻居。这样判断就可以统一成:当前是0,左边是0,右边是0,就种。实现如下:
def can_place_flowers(flowerbed, n): extended = [0] + flowerbed + [0] count = 0 for i in range(1, len(extended) - 1): if extended[i] == 0 and extended[i - 1] == 0 and extended[i + 1] == 0: extended[i] = 1 count += 1 if count >= n: return True return count >= n哨兵法的优点是代码短、不容易漏边界,缺点是新建了一个长度为m+2的数组,空间变成O(m)。在笔试环境里完全够用,但如果面试官追问空间优化,再改成前面的原地版本即可。还有一个小细节:补了两个0后,最后一个真实位置右边是0,左边为空时就能种;第一个真实位置左边是0,右边为空时也能种,正好满足线性边界规则。
3.4 三种写法对比
三种写法各有适用场景,我用一张表总结:
| 写法 | 是否修改输入 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 原地修改 | 是 | O(1) | 最直观,好调试 | 改变了原数组 |
| 不修改原数组 | 否 | O(1) | 保护输入,适合工程场景 | 需要理解prev变量 |
| 哨兵法 | 否(新建数组) | O(m) | 边界统一,代码短 | 多花一份空间 |
我个人建议:初次学习用原地修改版,因为它最贴近直觉;面试时如果不确定能否修改,就写成“不修改原数组”的版本,然后主动提一句“如果允许修改,还可以更快地原地实现”;笔试追求过用例时,哨兵法最省心。
4. 模式识别:连续空段与数学解法
4.1 把问题拆成连续0段
除了贪心,种花问题还有一个常见的数学视角。因为题目保证了已种的花不相邻,所以数组可以看成若干个连续的0段,段与段之间被1隔开。每一段内部能种多少花,只由段长和它是否在边界决定。
先说中间段。比如 [1,0,0,0,1],中间连续三个0,左右都是1,这个段最多能种1朵。因为三个空位中,如果种在中间那个0,左右都会被禁;种在最左或最右,也会让相邻位置被禁。再比如 [1,0,0,0,0,1],中间四个0,最多也只能种1朵。可以试着种在第二个0或第三个0,但无论如何只能种下一朵。当段长变成5时,比如 [1,0,0,0,0,0,1],最多能种2朵。规律是:中间段长度为k时,最多可种 (k-1)//2 朵。
边界段不一样。数组开头的连续0段,因为最左边没有真正的1来限制,第一个位置可以直接种。比如 [0,0,1,0,0],开头两个0后接1,可以在位置0种一朵,所以这个段的可种数是1。如果开头是 [0,0,0,1],可以在位置0和位置2各种一朵,可种数是2。规律是:开头段长度为k时,最多可种 k//2 朵。末尾段同理。
如果整个数组全是0,没有1来分段,那就相当于左右两端都是边界,长度为k时可种 (k+1)//2 朵。这个式子可以记忆为“边界段加一,中间段减一”。但正因为有全0这种特例,直接套公式容易遗漏,我建议初学者还是先走贪心。
4.2 公式背后的推导
为什么中间段是 (k-1)//2?可以想象在一段长度为k的连续空位里,第一朵花必须种在段内某个位置,而它会把左右各一个位置占掉。为了让数量最多,我们应该隔一个空位种一朵。比如位置从0到k-1,第一个可选位置是0,种下后位置1不能种,下一朵从位置2开始。可种位置的下标就是0,2,4...,但还要保证不超过k-1且不与右边的1相邻。如果段右侧有1,那么最后一个可种位置最多是k-2,因为k-1紧贴右侧的1。所以可种数量是0到k-2之间偶数下标的个数,也就是 (k-1)//2。
边界段右侧没有1,只有左侧有1,所以最后一朵可以种到k-1,也就是说可种位置下标取0,2,4...直到k-1,数量是k//2。全0数组两侧都没有1,可种位置下标也是0,2,4...直到k-1,但还需要考虑首尾不相邻,所以数量是(k+1)//2。如果觉得这些推导太绕,记住贪心代码就够了。
4.3 公式与贪心怎么选
做算法题时,我不建议一上来就用公式,原因有三个。第一,公式需要先分段,分段本身就要处理边界,容易错;第二,题目问的是“能否种下n朵”,用公式算出最大数量后还要比较,并不比贪心快;第三,面试官更想听到的是“为什么能种就种是正确”的推理,而不是一个记忆型公式。公式可以作为验证手段,比如写完贪心后用几个例子手算对比,确认代码没有明显问题。
5. 环形花坛与更多变体
5.1 环形花坛的额外约束
有的面试官会把题目升级成“花坛首尾相连”。所谓首尾相连,就是位置0和位置m-1相邻,整个花坛排成一个环。这样一来,原来的线性边界条件全都变了:位置0不再天然可种,得看位置m-1是否为空;位置m-1也一样。
解决环形问题的一个通用思路是拆成线性问题。由于额外冲突只发生在首尾之间,可以分成两种互不重叠的情况:第一种,位置0不种花,那么环形就退化成一条从位置1到位置m-1的线性花坛,可以用标准贪心处理。第二种,位置0种花,那么位置1和位置m-1都被占用,不能种,剩下的区间[2, m-2]又是一条线性花坛。取两种情况下可种数量的最大值,就是整个环形花坛的最大种植数量。
如果数组长度是1,环形和线性没有区别,直接判断这个位置是否为空。如果长度是2,两个位置互为邻居,最多只能种一朵。这些特例在面试时要主动提出来,免得写代码时陷入无限循环。
5.2 求具体方案与在线处理
原题只需要返回布尔值,但面试官可能追加要求:“请把种完后的数组输出”。这时只要把贪心代码里的return改成修改数组,最后返回数组即可。比如原地修改版已经修改了flowerbed,如果遍历结束时count>=n,直接返回flowerbed;如果count<n,说明无法完成任务,可以返回null或者原数组。注意,如果要求“必须种下n朵并输出方案”,但最大可种数小于n,就需要提前判断并抛出异常,而不是返回一个没种满的数组。
另一个扩展是数据流式到达。假设花坛的位置不是一个数组一次性给你,而是每秒告诉你一个位置的状态,你能在不知道未来位置的情况下做决策吗?能。因为贪心只依赖“左边最近一朵有效花”和“当前位置的右邻居”,你维护一个prev变量,每次读入新的位置时,如果当前位置为空且与prev距离足够,就可以判断能否种下,再继续读下一个位置。这种在线处理能力在真实系统中很有意义,比如在铺满传感器的花园里,某个位置的土壤湿度数据到达后,系统需要立即决定要不要在附近浇水或者补种。
5.3 与贪心类题目的共性
种花问题非常适合用来建立贪心思维。它和另一类经典题“会议室安排”很相似:每朵花占用一个位置,还会占用左右各一个“缓冲区”,你希望安排尽可能多的事件,核心是让每次选择都尽量靠前、占用尽量少的后续资源。再比如“跳跃游戏”,每步选择跳得最远的位置,本质上也是局部最优能推出全局最优的例子。
如果规则变得更复杂,比如“每个位置周围两格内都不能有花”,贪心就不一定对了,因为一朵花会影响更远的范围,局部最优可能堵塞后面的路。遇到这种变化,通常要考虑动态规划或者搜索。所以面试时如果能主动说一句“这道题能用贪心,是因为花的影响范围只有相邻两格”,会显得你对问题本质有理解。
6. 调试记录:我踩过的三个坑
6.1 越界访问
我最早写Python版本时,图省事直接写if flowerbed[i] == 0 and flowerbed[i-1] == 0 and flowerbed[i+1] == 0,结果在 i=0 时,flowerbed[-1] 访问到了数组最后一个元素。Python的负索引会自动绕到尾部,导致第一个位置被误判为“左侧有花”,测试用例 [0,0,0] 直接返回false。这个例子很反直觉:不是越界报错,而是安静地给出了错误结果,特别难排查。从那以后我写这种左右邻居判断,一定先把边界条件放在and的最前面。
在C++里同样的问题会更直接,越界访问是未定义行为,有时崩溃有时不崩,跑几组用例完全看不出问题,到大数据量才暴露。所以无论用什么语言,边界判断都必须显式写清楚。
6.2 只计数没更新状态
第二个坑出现在 [0,0,0] 上。我第一版没有把种下的位置改成1,只是 count 加一,然后继续遍历。当处理位置1时,它左边位置0是0,右边位置2是0,于是又加一;处理位置2也加一,最终得到3,而正确答案是2。问题出在位置0种花后,位置1已经不能被种,但我的代码不知道。
这个坑让我意识到,凡是“扫描过程中会根据当前决策改变后续状态”的问题,必须立刻更新状态,或者用一个变量记住决策。在种花问题里,原地修改数组就是最直接的更新方式;不修改数组时,prev变量也承担了这个责任。
6.3 数学公式漏了全0段
有段时间我喜欢用连续0段公式,因为可以快速手算。结果在 [0,0,0,0,0] 上翻车了。我的分段逻辑只考虑了“左右都有1的中间段”和“只有一边有1的边界段”,全0数组没有1,既不是中间段也不是边界段,于是被当成普通中间段处理,算出 (5-1)//2 = 2,实际答案是3。后来我在所有公式类题的测试列表里加了一个“全零用例”,专门用来拦截这种特殊情况。
其实公式本身没错,错的是对特殊情况的分类不完整。任何分段统计的算法,都要在动手前问自己:“这个数组可能完全由一种元素组成吗?”养成这个习惯后,不只种花问题,很多边界相关的bug都能提前避免。
6.4 现场自测清单
每次写完代码,我会用下面这组用例快速验证:
| 输入 | n | 期望输出 |
|---|---|---|
| [0] | 1 | true |
| [1] | 0 | true |
| [1,0,0,0,1] | 2 | false |
| [0,0,0,0,0] | 3 | true |
| [0,0,1,0,0] | 2 | true |
| [1,0,0,0,0,1] | 2 | false |
这组用例虽然简单,但覆盖了长度1、边界段、中间段、全0这四类最容易出问题的场景。如果代码能全部通过,再提交错误率就会低很多。你也可以再补几个随机的大数组用例,用暴力枚举去对拍,不过那属于进阶操作了。
写到这里,种花问题的代码和原理就都讲完了。我个人做这类题最大的体会是:不要因为题目短就轻视边界条件,也不要因为贪心代码看起来简单就跳过证明。能写对代码只是第一步,能在面试现场把“为什么能种就种一定最优”讲清楚,才是真正掌握这类题目的标志。如果你还愿意多练一步,可以试着把环形花坛的实现写完整,或者改为输出种植方案,练完之后再遇到类似的区间冲突题,心里会踏实很多。