news 2026/11/5 10:08:16

双指针原地划分算法:从数组划分题到快速排序partition的通用模板

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
双指针原地划分算法:从数组划分题到快速排序partition的通用模板

1. 从一道“数组划分”题说起:为什么它值得反复琢磨

算法题里有一类题目,看起来条件简单到让人怀疑是不是漏了什么,但真正动手写的时候,才发现边界情况多到让人头皮发麻。“数组划分”就是这类题目的典型代表。题目描述通常只有一句话:给定一个整数数组和一个整数k,把数组划分成左右两部分,使得左边所有元素都小于k,右边所有元素都大于等于k,返回划分后的位置索引。没有要求排序,没有要求稳定,甚至没有要求左右两部分内部有序——听起来是不是特别简单?

但恰恰是这种“简单”的题目,最能检验一个人对双指针、原地操作、边界处理的理解深度。我在带新人的时候特别喜欢拿这道题当试金石,因为它能在十分钟内暴露出一个人的编码习惯:有人上来就开新数组,有人写双指针但边界一团糟,有人能一遍过但完全没考虑空数组和全满足的情况。这篇文章我会把这道题从思路到实现到踩坑完整拆一遍,不管你是刚刷题的新手还是想复习双指针的老手,应该都能从中找到一些之前忽略的细节。

这道题的核心价值不在于它本身有多难,而在于它是一类“原地划分”问题的模板。快速排序的partition步骤、荷兰国旗问题的简化版、甚至某些数据清洗场景中的分类操作,底层思路都跟它一脉相承。你把这题吃透了,后面遇到“把奇数放左边偶数放右边”“把负数放左边正数放右边”“把满足某条件的放左边”这类变体,基本就是换个判断条件的事。

提示:这道题在多个在线判题平台上都有收录,题号可能不同,但核心描述一致。本文的解法不依赖特定平台,任何支持基础数组操作的编程语言都可以直接套用。

2. 题目核心需求拆解与常见误区

2.1 题目到底在要求什么

先把题目用最直白的话翻译一遍。你有一个数组,比如[3, 2, 2, 1, 5, 6, 7, 4],再给你一个目标值k,比如4。你需要把数组重新排列,让所有小于4的元素都跑到数组左边,所有大于等于4的元素都跑到右边。最终数组长什么样不重要,重要的是“分界线”在哪里——也就是第一个大于等于k的元素的位置索引。

拿上面的例子来说,划分后可能变成[3, 2, 2, 1, 5, 6, 7, 4],此时索引4的位置是5,大于等于4,左边[3,2,2,1]全部小于4,所以返回4。也可能变成[1, 2, 2, 3, 4, 7, 6, 5],此时索引4的位置是4,同样满足条件,返回4。注意,两种划分都合法,题目不要求唯一答案。

这里有一个容易被忽略的点:返回的索引是“第一个大于等于k的元素的位置”,也就是说这个索引左边的所有元素都严格小于k,这个索引及其右边的所有元素都大于等于k。如果数组中所有元素都小于k,那应该返回数组长度;如果所有元素都大于等于k,应该返回0。

2.2 新手最容易踩的三个坑

第一个坑是试图排序。很多人一看“左边小右边大”,下意识就想排序。排序当然能满足条件,但时间复杂度从O(n)直接飙到O(n log n),而且完全没必要。面试中如果写出排序解法,面试官大概率会让你优化。

第二个坑是开新数组。用两个额外数组分别存小于k和大于等于k的元素,然后拼起来,这确实能过,但空间复杂度是O(n)。题目通常会有“原地划分”的隐含要求,即使没有明说,面试官也会追问能不能不用额外空间。

第三个坑是边界处理。空数组返回什么?只有一个元素返回什么?所有元素都相等且等于k返回什么?这些情况如果不提前想清楚,代码跑起来就会各种越界或者死循环。

注意:虽然题目没有明确要求“原地”,但在实际面试和工程场景中,原地操作是这类问题的默认期望。养成原地操作的习惯,对后续学习快速排序等算法也有直接帮助。

3. 双指针解法的完整推导与实现

3.1 为什么双指针是这道题的最优解

双指针的核心思想是:用两个指针从数组两端向中间靠拢,左指针找“不该在左边”的元素(即大于等于k的),右指针找“不该在右边”的元素(即小于k的),找到一对就交换,直到两个指针相遇。这样每个元素最多被访问一次,时间复杂度O(n),空间复杂度O(1),完美符合题目要求。

这个思路之所以高效,是因为它利用了划分问题的一个关键性质:我们不需要知道每个元素最终的具体位置,只需要保证“左边的都小于k,右边的都大于等于k”。交换操作直接把两个错位的元素一次性归位,比逐个移动高效得多。

我用一个具体例子来演示这个过程。数组[3, 2, 2, 1, 5, 6, 7, 4],k=4:

初始状态:left=0,right=7。

  • left指向3,小于4,合法,left右移到1。
  • left指向2,小于4,合法,left右移到2。
  • left指向2,小于4,合法,left右移到3。
  • left指向1,小于4,合法,left右移到4。
  • left指向5,大于等于4,不合法,停住。
  • right指向4,大于等于4,合法,right左移到6。
  • right指向7,大于等于4,合法,right左移到5。
  • right指向6,大于等于4,合法,right左移到4。
  • 此时left=4,right=4,相遇,循环结束。
  • 返回left=4。

再看一个需要交换的例子:[5, 1, 2, 3, 4],k=3。

  • left=0指向5,大于等于3,停住。
  • right=4指向4,大于等于3,right左移到3。
  • right=3指向3,大于等于3,right左移到2。
  • right=2指向2,小于3,停住。
  • left=0 < right=2,交换arr[0]和arr[2],数组变成[2, 1, 5, 3, 4]。
  • left右移到1,指向1,小于3,left右移到2。
  • left=2指向5,大于等于3,停住。
  • right左移到1,此时left=2 > right=1,循环结束。
  • 返回left=2。验证:左边[2,1]都小于3,右边[5,3,4]都大于等于3,正确。

3.2 代码实现与逐行注释

def partition_array(nums, k): """ 将数组划分为左边小于k、右边大于等于k两部分 返回第一个大于等于k的元素索引 """ if not nums: return 0 left, right = 0, len(nums) - 1 while left <= right: # 左指针找第一个大于等于k的元素 while left <= right and nums[left] < k: left += 1 # 右指针找第一个小于k的元素 while left <= right and nums[right] >= k: right -= 1 # 如果两个指针没有相遇,交换这对错位元素 if left < right: nums[left], nums[right] = nums[right], nums[left] left += 1 right -= 1 return left

这段代码有几个细节值得展开说。第一,外层循环条件是left <= right而不是left < right,因为当left和right相等时,那个位置的元素还需要判断一次。第二,内层两个while循环都带了left <= right的保护条件,防止指针越界。第三,交换后left和right都移动了一步,因为交换后的两个位置都已经满足条件了,不需要再检查。

3.3 另一种写法:快慢指针

除了左右双指针,还有一种快慢指针的写法。慢指针指向“下一个小于k的元素应该放的位置”,快指针遍历整个数组,遇到小于k的元素就交换到慢指针位置,慢指针右移。这种写法在某些场景下更直观,尤其是当你需要保持左边部分的相对顺序时(虽然本题不要求)。

def partition_array_slow_fast(nums, k): if not nums: return 0 slow = 0 for fast in range(len(nums)): if nums[fast] < k: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1 return slow

这种写法的时间复杂度同样是O(n),空间O(1),但交换次数可能比左右双指针多。左右双指针每次交换能解决两个错位元素,快慢指针每次只能解决一个。不过快慢指针的代码更简洁,不容易出边界错误,新手可以先从这个版本入手。

实操心得:如果面试官没有特别要求“交换次数最少”,快慢指针版本其实更稳妥,因为它的边界条件更简单,不容易写出死循环。我在实际面试中如果时间紧张,会优先写快慢指针版本,确认正确后再根据面试官要求优化。

4. 边界情况全梳理与测试用例设计

4.1 必须覆盖的六种边界情况

这道题的边界情况比想象中多,我整理了一个表格,把每种情况的表现和预期结果都列出来:

场景输入示例k值预期返回说明
空数组[]50直接返回0
单元素小于k[3]51唯一元素在左边
单元素大于等于k[7]50唯一元素在右边
全部小于k[1,2,3]103返回数组长度
全部大于等于k[5,6,7]30返回0
全部等于k[4,4,4]40所有元素都在右边

这六种情况覆盖了指针可能出现的所有极端位置。特别是“全部等于k”这种情况,很多人会误以为应该返回中间某个位置,但实际上所有元素都大于等于k,所以第一个大于等于k的位置就是索引0。

4.2 随机测试与对数器验证

写完代码后,怎么确认它真的正确?我习惯写一个暴力解法作为对数器,然后随机生成大量测试用例对比结果。暴力解法很简单:开两个新数组,遍历原数组,小于k的放左边数组,大于等于k的放右边数组,最后拼起来,返回左边数组的长度。

import random def brute_force(nums, k): left_part = [x for x in nums if x < k] right_part = [x for x in nums if x >= k] return len(left_part) def random_test(): for _ in range(10000): n = random.randint(0, 20) nums = [random.randint(-10, 10) for _ in range(n)] k = random.randint(-10, 10) nums1 = nums.copy() nums2 = nums.copy() result1 = partition_array(nums1, k) result2 = brute_force(nums2, k) if result1 != result2: print(f"不一致!nums={nums}, k={k}, 双指针={result1}, 暴力={result2}") return False # 验证划分性质 for i in range(result1): if nums1[i] >= k: print(f"左边出现大于等于k的元素!nums={nums}, k={k}") return False for i in range(result1, len(nums1)): if nums1[i] < k: print(f"右边出现小于k的元素!nums={nums}, k={k}") return False print("全部通过") return True random_test()

这个对数器做了两层验证:第一层对比返回值是否与暴力解法一致,第二层验证划分后的数组是否真的满足“左边全小于k,右边全大于等于k”。两层验证都通过,才能说明代码是正确的。

4.3 性能测试与优化空间

虽然这道题的理论最优解就是O(n)时间O(1)空间,但不同写法在实际运行时的常数因子差异还是值得关注的。我做过一组简单的性能对比,在100万个随机整数上运行:

写法耗时(毫秒)交换次数
左右双指针约45约25万
快慢指针约52约50万
排序后二分约180不适用

左右双指针在交换次数上有明显优势,因为每次交换解决两个错位元素。快慢指针虽然交换次数多,但代码更简单,实际差异在大多数场景下可以忽略。排序解法虽然也能得到正确答案(排序后第一个大于等于k的位置就是分界点),但时间复杂度高了一个量级,不推荐。

注意:上面的性能数据是基于特定硬件和编程语言的粗略测试,不同环境下数值会有差异,但相对关系基本一致。重点不是具体数字,而是理解不同写法在常数因子上的差异来源。

5. 从这道题延伸出去:原地划分的通用模板

5.1 快速排序中的partition

这道题的解法其实就是快速排序中partition步骤的简化版。快速排序的partition需要选一个pivot,然后把小于pivot的放左边,大于pivot的放右边,最后返回pivot的最终位置。区别在于快速排序还需要把pivot本身放到正确位置,而这道题不需要。

如果你理解了这道题的双指针写法,快速排序的partition代码基本就是在这基础上加几行:

def partition_for_quicksort(nums, left, right): pivot = nums[right] # 选最右元素作为基准 i = left for j in range(left, right): if nums[j] < pivot: nums[i], nums[j] = nums[j], nums[i] i += 1 nums[i], nums[right] = nums[right], nums[i] return i

这段代码用的是快慢指针思路,最后把pivot换到分界点。你看,核心逻辑跟上面的partition_array_slow_fast几乎一模一样,只是多了最后一步pivot归位。

5.2 荷兰国旗问题的三路划分

如果题目变成“把数组分成小于k、等于k、大于k三部分”,那就是经典的荷兰国旗问题。解法是用三个指针:low指向小于区域的右边界,mid遍历数组,high指向大于区域的左边界。这个问题的难度比两路划分高一个档次,但底层思路仍然是双指针的扩展。

def three_way_partition(nums, k): low, mid, high = 0, 0, len(nums) - 1 while mid <= high: if nums[mid] < k: nums[low], nums[mid] = nums[mid], nums[low] low += 1 mid += 1 elif nums[mid] == k: mid += 1 else: nums[mid], nums[high] = nums[high], nums[mid] high -= 1 return low, high

三路划分在工程中很有用,比如数据清洗时把数据按“合格、待定、不合格”分类,或者按优先级把任务分成三档。你把这题的两路划分吃透了,三路划分也就是多一个指针的事。

5.3 实际工程中的应用场景

原地划分在工程中出现的频率比想象中高。举几个我实际遇到过的场景:

第一个是日志分类。服务器日志按级别划分,ERROR级别的日志需要单独处理,其他级别的正常归档。用原地划分可以在一次遍历中把ERROR日志移到数组前部,然后只对前部进行处理,避免全量扫描。

第二个是数据预处理。机器学习训练数据中,有些样本需要特殊处理(比如缺失值过多的样本),可以用原地划分把这些样本移到一边,后续只对正常样本做特征工程。

第三个是内存优化。在嵌入式设备或内存受限的环境中,开新数组可能直接导致内存不足,原地划分是唯一可行的方案。这也是为什么面试官特别看重O(1)空间解法——它代表了你对资源约束的敏感度。

实操心得:在实际工程中,如果数据量不大且内存充足,开新数组的写法其实更安全,因为不容易出bug。原地划分的价值主要体现在性能敏感和内存受限的场景。选择哪种写法,要根据具体场景权衡,不要为了“炫技”而强行原地操作。

6. 常见问题排查与调试技巧实录

6.1 死循环问题

双指针写法最容易出的问题是死循环。典型症状是程序跑着跑着卡住了,或者在某些输入下永远不返回。原因通常是内层while循环没有正确移动指针,或者外层循环条件写错。

排查方法很简单:在循环里打印left和right的值,观察它们是否在正常移动。如果发现某次迭代后left和right都没变,那就是死循环了。常见的原因包括:内层while条件写成了nums[left] <= k(应该是< k),或者交换后忘记移动指针。

# 错误示范:交换后没有移动指针 if left < right: nums[left], nums[right] = nums[right], nums[left] # 忘记 left += 1 和 right -= 1,导致死循环

6.2 返回值差一问题

返回值差一是另一个高频错误。有人返回left - 1,有人返回right,结果在某些边界情况下跟预期差1。记住一个原则:循环结束时,left指向的就是第一个大于等于k的位置。因为left在移动过程中,要么因为nums[left] >= k停住,要么因为left > right停住。无论哪种情况,left左边的所有元素都已经确认小于k。

如果你不确定,用“全部小于k”这个用例验证一下:数组[1,2,3],k=10。left会一直右移到3,right从2开始一直左移,最终left=3,right=-1,循环结束,返回3。正确。

6.3 交换后数组状态不符合预期

有时候代码不报错,返回值也对,但划分后的数组不满足“左边全小于k,右边全大于等于k”。这种情况通常是交换逻辑有问题,比如交换后没有正确更新指针,导致某些元素被跳过或重复处理。

排查方法是写一个验证函数,在每次划分后检查数组是否满足性质:

def validate(nums, k, idx): for i in range(idx): if nums[i] >= k: return False for i in range(idx, len(nums)): if nums[i] < k: return False return True

把这个验证函数加到随机测试里,一旦发现不满足的情况就打印出错的输入,然后手动模拟一遍,很快就能定位问题。

6.4 常见问题速查表

问题现象可能原因排查方法解决方案
死循环指针未移动或循环条件错误打印left/right值检查内层while条件和交换后的指针移动
返回值差1返回了left-1或right用全小于k的用例验证统一返回left
数组状态错误交换逻辑有误加验证函数检查交换后指针是否正确更新
空数组崩溃未处理空数组加空数组测试用例开头加if not nums: return 0
全部等于k时返回错误边界条件未覆盖加全部等于k的测试用例确认返回0而非其他值

提示:调试这类指针问题时,最有效的方法不是盯着代码看,而是拿一张纸画出数组和指针的位置,一步一步手动模拟。我见过太多人对着屏幕发呆半小时,不如动手画五分钟。

7. 不同语言的实现差异与注意事项

7.1 Python的交换与切片

Python的交换写法a, b = b, a非常简洁,不需要临时变量。但要注意,Python的列表是可变对象,函数内修改会影响外部。如果你不希望修改原数组,需要先nums = nums.copy()。

另外,Python的切片操作虽然方便,但在原地划分场景下要慎用。比如nums[:idx]会创建一个新列表,如果在大循环里反复切片,性能会急剧下降。

7.2 Java的数组与引用

Java中数组是引用类型,传参时传的是引用的拷贝,但指向的是同一个数组对象,所以函数内修改数组元素会影响外部。Java没有Python那样的多变量交换语法,需要显式用临时变量:

int temp = nums[left]; nums[left] = nums[right]; nums[right] = temp;

Java的数组长度是nums.length(属性),不是方法,不要写成nums.length()。

7.3 C++的指针与引用

C++中可以用指针或引用来操作数组。用vector<int>& nums传引用可以避免拷贝,用swap(nums[left], nums[right])交换元素。C++的vector有size()方法,注意不要跟数组的sizeof混淆。

C++的一个优势是可以直接用指针运算,但在现代C++中更推荐用索引或迭代器,可读性更好,也不容易出错。

7.4 JavaScript的数组操作

JavaScript的数组是动态的,交换可以用解构赋值[nums[left], nums[right]] = [nums[right], nums[left]]。注意JavaScript的数组索引越界不会报错,只会返回undefined,所以边界检查要格外小心。

function partitionArray(nums, k) { if (nums.length === 0) return 0; let left = 0, right = nums.length - 1; while (left <= right) { while (left <= right && nums[left] < k) left++; while (left <= right && nums[right] >= k) right--; if (left < right) { [nums[left], nums[right]] = [nums[right], nums[left]]; left++; right--; } } return left; }

不管用哪种语言,核心逻辑完全一致。语言差异只影响语法细节,不影响算法思路。如果你在面试中遇到这道题,用你最熟悉的语言写就行,面试官看重的是思路清晰和边界处理完整,而不是语言特性。

8. 刷题之外的思考:这道题教会我的事

这道题我前前后后在不同场合写过不下二十遍,每次都有新的体会。最开始我觉得它太简单,不值得花时间;后来带新人时发现,正是这种“简单”题目最能看出一个人的基本功。一个人如果能把这道题的边界情况全部考虑到、代码一遍过、还能说清楚为什么这样写,那他的编码习惯一定不会差。

另一个体会是,很多算法题的价值不在于题目本身,而在于它背后的模式。这道题背后的“双指针原地划分”模式,在快速排序、荷兰国旗问题、数据分类、内存优化等场景中反复出现。你掌握了一个模式,就能解决一类问题,这比刷一百道互不相关的题目效率高得多。

最后说一个实际工作中的小技巧。我在处理数据清洗任务时,经常需要把满足某些条件的数据移到一边。以前我会用filter或者列表推导式开新数组,后来发现数据量大时内存吃不消。改用原地划分后,内存占用直接降了一个数量级。这个技巧我用了好几年,每次都能帮上忙。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 3:35:44

PyCharm使用全攻略:社区版与专业版对比及实战技巧

简介&#xff1a;面向PyCharm使用者的全版本激活指南项目源码包&#xff0c;围绕2018至2024各版本激活难题&#xff0c;提供无限重置试用、永久补丁、无限重置30天三种方案&#xff0c;覆盖Windows/macOS/Linux三大系统&#xff0c;并明确了旧版与新版分别适用的激活策略&#…

作者头像 李华
网站建设 2026/11/4 20:41:24

JEECG集成Flowable流程引擎:版本选型、用户体系与五大避坑实践

简介&#xff1a;面向Java后端与前端开发者的JEECG集成Flowable工作流引擎源码包&#xff0c;解决在JEECG平台快速引入流程审批与BPMN2.0标准流程管理等能力的问题。资源共15个文件&#xff0c;以7个Java文件为核心&#xff0c;配合yml数据库配置、xml权限与流程定义文件、vue组…

作者头像 李华
网站建设 2026/10/31 21:19:52

B站粉丝画像

有没有长期画像 目前的粉丝兴趣画像&#xff0c;口径是近 30 天的——平台这边"粉丝爱看什么"这个维度&#xff0c;就是按最近 30 天粉丝在站内的观看行为算的&#xff0c;暂时没有更长周期&#xff08;比如半年、全周期的&#xff09;版本。 不过想看更长期的粉丝特…

作者头像 李华
网站建设 2026/11/1 1:53:40

微信小程序仿智行火车票:离线模拟查票闭环开发指南

简介&#xff1a;本资源是一套完整的微信小程序仿智行火车票12306系统实战项目&#xff0c;面向Java后端与小程序前端初学者及进阶开发者&#xff0c;聚焦铁路订票类业务场景的全栈开发实践。项目采用SSM&#xff08;SpringSpringMVCMyBatis&#xff09;框架构建稳定后端服务&a…

作者头像 李华
网站建设 2026/11/4 16:32:18

OpenClaw引擎重实现生态全解析:从281个仓库到游戏复活实战

最近我干了一件挺费眼睛的事&#xff1a;把能搜到的全网 OpenClaw 相关仓库挨个翻了一遍&#xff0c;按 Star 从高到低排了一张长表&#xff0c;最后留下来仔细读的有 281 个。整个过程占了我一个完整周末&#xff0c;中间一度怀疑自己是不是在纯浪费时间&#xff0c;但看完之后…

作者头像 李华
网站建设 2026/11/1 23:55:07

开题写到头秃?能源互联网工程人的 AI 搭子挑选指南 [特殊字符]

先自报家门&#xff1a;工学电气类&#xff0c;能源互联网工程&#xff0c;一个把电力系统、新能源、储能、电力电子、信息通信和能源市场揉在一起学的"交叉缝合专业"。别的专业写论文可能是一条线&#xff0c;我们写论文往往是一张网——光伏出力波动要建概率模型&a…

作者头像 李华