☰
数组刷题从入门到不慌:二分查找与移除元素的边界与指针解析
2026/10/22 10:48:56 网站建设 项目流程

训练营 Day01 的标题是“数组 part01”,不少同学点进来之前觉得这题也太简单了:数组谁不会啊,int a[10],从头到尾遍历不就行了?可实际带训的时候,第一天打回来的作业一大半是栽在两个地方:704 二分查找写着写着开始死循环,27 移除元素写完倒是能跑,一看空间复杂度笑出声,或者干脆遍历时把下标搞越界。数组这一节是整个刷题链条里最容易被低估的一课,因为它表面上考的是“你会不会写循环”,实际考的是你懂不懂“区间到底该怎么定义”。

这篇内容我按训练营的 Day01 路线完整梳理一遍:先讲数组在内存里的真相,再逐行拆解二分查找的两种区间写法,最后把移除元素的暴力解和快慢指针解都写出来对比。面向的是刚开始刷题的人,也适合那些刷了几十题但边界条件老是靠背、过后就忘的同学。把这些“为什么”弄明白,比闷头刷十道重复题目都值。

1. 数组的底层逻辑:第一天为什么要从内存讲起

1.1 数组是一排连续的“标准间”

数组的定义可以浓缩成一句话:相同类型的元素,连续存放在一段连续的内存地址里。这句话每个字都有代价。“相同类型”意味着每个元素占的内存大小完全一致,编译器才能用“首地址 + 下标 × 元素大小”这个公式直接算出任意元素的地址。这也是数组下标从 0 开始而不是从 1 开始的原因——下标 0 的元素地址就是首地址,不需要再做一次减法。在底层硬件上,少一次计算就是实打实的性能节省。

打个比方,数组就像一栋每层户型完全相同的公寓,房间号从 0 开始编排。你要找第 k 间房,不需要从 0 号房一路走过去,直接根据“楼栋入口在哪儿 + 第 k 间的偏移量”算出来就行。这种随机访问能力是 O(1) 的,是数组最大的本钱。但它也有代价:你想在中间某个位置插进一个住户,后面所有房间的人都得搬一次家,所以数组的插入和删除是 O(n) 的。很多新手把“数组查找快”和“数组操作快”划等号,这是第一天就要纠正的概念。

1.2 三种语言里数组的脾气完全不一样

同样是数组,不同语言底层的“性格”差别很大,直接用 C++ 模板做题的人切到 Python 会很不适应,原因就在这。

C/C++ 里数组是真正分配在栈或堆上的连续内存,int a[5] 就是 20 个字节的连续空间,你可以拿指针自增去移动,也可以直接对下标取地址。数组大小固定,越界访问编译器不拦,运行时会读出一堆随机数据,这是无数神秘乱码和崩溃的源头。

Java 的数组是一个对象,int[] arr = new int[5] 会在堆上开辟一块连续内存,arr 变量只保存引用。好处是有 length 属性、有越界检查;坏处是“数组变量”和“数组本体”是分开的,传参传的是引用,函数里改数组,外面跟着变——这个特性刷题时经常被用来“偷懒”,但也会让不熟悉引用语义的人困惑。

Python 的 list 更特殊,它底层其实是个“指针数组”,每个槽位存的并不是元素本身,而是指向真实对象的引用。所以 Python list 能装任意混合类型,代价是每个元素多一次指针间接访问,存储小整数等场景下比 C++ 数组更耗内存。Python list 自带 append、pop 这些动态扩容能力,看着方便,但刷算法题时要清醒:list.remove(val) 本身是 O(n) 的线性查找加搬移,在循环里反复 remove 很容易写出 O(n²) 而不自知。

第一天就把这些差异想清楚,后面做“原地修改、返回新长度”这类题时会少走很多弯路。思路是一样的,只是语言表达差异大,不能指望把 C++ 解法照搬到 Python 上一点不改。

1.3 数组没有真正的删除

数组还有一个反直觉的事实:它没有真正的“删除”。删除的本质是“把后面的元素往前覆盖”,数组本身长度不变,只是逻辑上的有效部分变短了。比如 [3, 2, 2, 3],你想删掉所有 3,一次覆盖之后数组还是 4 个长度,只不过下标 0、1、2 位置变成了 [2, 2, 3],下标 3 的位置还残留一个没人管的 3。算法题里处理这种情况,通常要求你“返回新的有效长度”,而不是真的把数组缩成三格。

理解这一点对 27 题至关重要,也能解释为什么很多同学“用库函数也能过,面试官却摇头”——因为题目考的就是你会不会在原地用覆盖思想完成 O(1) 额外空间的操作,而调用语言自带的删除接口,既不是这个空间复杂度,也没有体现算法思路。

2. 704 二分查找:把“区间不变性”刻进脑子

2.1 二分查找到底在干什么

二分查找(704 题)是几乎所有题目集里的“入门主题曲”,原因不是它难,而是它能用最小规模考察你对循环不变量的掌握程度。题目本身很简单:在升序整数数组里找一个目标值,存在返回下标,不存在返回 -1。暴力做是 O(n),二分做是 O(log n),数据规模到 10 亿时,差距是 30 次和 10 亿次。

二分的核心前提有两个:数组有序,且基础版里不要求处理重复元素。为什么必须有序?因为每一步都在利用“中间值的大小能判断目标在左半边还是右半边”这个单调性。没有这个前提,你砍掉一半搜索空间的依据不成立,二分就退化成盲猜。

每次取 mid = (left + right) / 2,比较 nums[mid] 和 target:相等直接返回;target 更大说明目标在右半边,左边界收紧到 mid + 1;target 更小说明在左半边,右边界收紧。这个“边界怎么收”的具体写法,完全取决于你对区间的定义。这是二分查找唯一的核心分歧点,也是所有 bug 的源头。

2.2 左闭右闭 [left, right] 的写法与边界

标准写法一:区间定义为 [left, right],也就是 left 和 right 都是“取得到”的。代码长这样:

// 区间定义为 [left, right],左右边界都取得到 int search(vector<int>& nums, int target) { int left = 0; int right = nums.size() - 1; // 关键1:right 是最后一个有效下标 while (left <= right) { // 关键2:left == right 时区间还有一个元素要查 int mid = left + (right - left) / 2; // 防溢出写法 if (nums[mid] == target) return mid; if (nums[mid] < target) left = mid + 1; else right = mid - 1; // 关键3:mid 已经查过,直接排除 } return -1; }

这里三个“关键”是一套完全联动的逻辑。因为 right 取得到,所以初始 right = size - 1;因为 [left, right] 在 left == right 时仍然包含一个元素,比如区间 [5, 5] 里的 5 是有效下标,所以循环条件必须是 left <= right;因为 mid 已经检查过且不匹配,新的搜索区间必须把它排除,所以 right = mid - 1 而不是 right = mid。如果写成 right = mid,配合 left <= right 的条件,会出现死循环——区间一直包含已经排除的 mid,左右边界反复横跳。

为了方便记忆,可以把这三条绑定在一起:取得到 → 循环等号可入 → 右边界敢减一。Python 版本只是语法差异:

def search(nums: list[int], target: int) -> int: left, right = 0, len(nums) - 1 while left <= right: mid = left + (right - left) // 2 if nums[mid] == target: return mid elif nums[mid] < target: left = mid + 1 else: right = mid - 1 return -1

2.3 左闭右开 [left, right) 只需改两处

第二种写法:区间定义为 [left, right),左闭右开,right 这个下标本身取不到。它的代码长这样:

// 区间定义为 [left, right),right 取不到 int search(vector<int>& nums, int target) { int left = 0; int right = nums.size(); // 关键1:right 取不到,所以从 size 开始 while (left < right) { // 关键2:left == right 表示空区间,不进入循环 int mid = left + (right - left) / 2; if (nums[mid] == target) return mid; if (nums[mid] < target) left = mid + 1; else right = mid; // 关键3:mid 已被排除,[left, mid) 不包含 mid } return -1; }

这一套的绑定规则是:取不到 → 空区间跳出循环 → 右边界直接等于 mid。

两套写法看着只差“right 初始化减不减一”“while 里有没有等号”“right = mid 还是 mid - 1”三处,实际上是两套完全自洽的逻辑。新手最常见的死法就是把它们混着写:初始化按 [left, right],循环条件却写成 left < right,或者右边界写成 right = mid。单看每一处似乎都“有点道理”,合在一起就必然导致漏查或死循环。

我的建议是,刚开始只背熟一套。我个人习惯左闭右闭 [left, right],因为它的语义更符合直觉:区间左右都包含,和“查找范围”的日常认知一致。等这一套熟练到能解释每一个等号和减一,再回头对照左闭右开,一天补一个就够。

2.4 二分最容易翻车的三个现场

二分查找翻车现场,我几乎每一期都能见到同样几个,列出来供大家自查:

第一,死循环。特征是不报错,程序卡住不动。常见原因一个是区间混用,一个是把 left 更新成了 mid。为什么不能写 left = mid?因为在某些区间状态下 mid 会等于 left,边界不动,循环永远出不去。记住:基础二分里搜索区间每一次都必须严格缩小,左边界至少加 1,右边界至少减 1,或者取 mid 但不含 mid。

第二,越界访问。比如 nums[mid] 里的 mid 算出负数或超出 size,通常也是区间混用导致。还有一种隐蔽情况:数组只有一个元素时,如果循环条件写错,初始 mid 不参与判断就直接返回 -1,查不到本来存在的答案。

第三,溢出。int mid = (left + right) / 2,当 left 和 right 都接近 int 上限时,left + right 会溢出成负数,mid 变负,直接越界。普通测试数据一般触发不了,但面试官特别喜欢追问这个。标准安全写法是 mid = left + (right - left) / 2,数学上等价,但不会溢出;位运算写法 mid = left + ((right - left) >> 1) 也常见,效果一样。

很多同学背模板只关心“怎么让样例通过”,但训练营第一天真正要练的是“能不能用一句话解释为什么这样写就不会错”。二分查到最后拼的不是代码量,而是对区间不变量的理解。解释不出来,换一道差不多的题(比如搜索插入位置)照样卡壳。

3. 27 移除元素:快慢指针的第一次实战

3.1 题目问的是“长度”,不是“删除”

27 题移除元素,要求是:给你一个数组 nums 和一个值 val,原地移除所有数值等于 val 的元素,并返回移除后数组的新长度。不需要考虑数组中超出新长度后面的元素。

这句话有三层含义要读明白。第一,“原地”意味着你不能新建一个数组,把不等于 val 的元素过滤出来再赋回去——那样额外空间是 O(n),和题目考察的 O(1) 空间原地操作相去甚远。第二,返回的是新长度,不是新数组,所以数组末尾残留旧值也无所谓,只要有效前缀正确。第三,基础版不要求保持元素相对顺序,这给了实现更大自由度。

做题之前先在纸上走一遍过程,感觉会完全不一样。比如 nums = [0, 1, 2, 2, 3, 0, 4, 2],val = 2。你手动把每个 2 划掉,剩下的依次往前挪,最后有效部分就是 [0, 1, 3, 0, 4],长度 5。这个过程其实就是快慢指针在干的事。

3.2 暴力双重循环慢在哪

先看暴力解。思路很直接:从头遍历,发现 nums[i] == val,就让 i 之后的所有元素整体前移一位,同时有效长度减一。因为 i 位置被后面元素覆盖了,还要 i-- 回头重新检查这个新搬来的元素。代码长这样:

int removeElement(vector<int>& nums, int val) { int n = nums.size(); for (int i = 0; i < n; i++) { if (nums[i] == val) { for (int j = i + 1; j < n; j++) { nums[j - 1] = nums[j]; } i--; // 回头检查覆盖过来的元素 n--; // 有效长度减一 } } return n; }

这个解法正确性没问题,但每次遇到 val 都要搬移后面所有元素,最坏情况(数组全是 val)下是 O(n²)。更关键的是,i-- 这个细节特别容易写丢。丢了会怎样?i 直接跳过覆盖过来的新元素,连续重复值时就会漏删。很多同学样例能过、提交就挂,基本都挂在这个 i-- 上。

暴力解的意义在于帮你理解“覆盖”这个动作本身:它不是把 val 从内存里抹掉,而是把后面的元素逻辑前移、有效长度收缩。理解了这一点,再看快慢指针就顺理成章。

3.3 快慢指针为什么能做到 O(n)

快慢指针的核心思想一句话概括:让一个快指针负责“发现值得保留的元素”,让一个慢指针负责“记录下一个该覆盖的位置”。两个指针都从 0 出发,快指针每次前进一格,慢指针只在发生覆盖时前进。

int removeElement(vector<int>& 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; // slow 就是新长度 }

为什么这个写法是对的?可以把它理解成“慢指针左边永远是已经筛选好的、全部不等于 val 的前缀”。快指针遍历整个数组,每遇到一个不等于 val 的元素,就复制到慢指针的位置并让慢指针前进;遇到等于 val 的元素就跳过,不产生任何复制。最终慢指针停在最后一个保留元素的下一个位置,它的数值恰好等于保留元素的个数,也就是新长度。

这个过程每个元素最多被复制一次,所以时间复杂度 O(n),额外空间只有两个下标,O(1)。相比暴力解,它把“遇到 val 就搬移后面所有元素”这个高成本操作,换成“遇到非 val 就拷贝一次到前面”,从推式变成拉式,这是双指针手法非常典型的思维转变。

Python 版本:

def remove_element(nums: list[int], val: int) -> int: slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow

注意 Python 里 list 的 len 是动态的,但这里的循环用 range(len(nums)) 在开始时固定了快指针的遍历范围。因为我们是原地覆盖、不实际删除元素,nums 的长度始终不变,所以这样遍历是安全的。如果哪天真调用了 pop 或 remove,循环范围就会出问题。这也是我强调用指针写法而不是依赖语言自带删除接口的原因。

3.4 这一套模板能直接迁移的题型

快慢指针这套模板价值极高,第一天学会它,后面至少三道题可以无缝套用。

一是有序数组去重。给定有序数组,原地删除重复元素,使每个元素只出现一次,返回新长度。思路就是把“跳过等于 val”改成“跳过和前一个保留元素相同的值”,慢指针维护的仍然是“不重复前缀”。

二是移动零。把数组里所有 0 移到末尾,同时保持非零元素相对顺序。这题可以把 0 当成要“移除”的 val,第一遍快慢指针把所有非零往前排,剩下的位置补 0,边界用新维护的长度截断即可。

三是有序数组的平方。先找到正负分界点,再用双指针从两端向中间归并平方结果。这个虽然和快慢指针略有不同,但核心还是“两个指针互相配合维护结果数组”。

能看出规律吗?快慢指针适合所有“原地筛选 + 返回有效长度/前缀”的题目。遇到这类题,先问自己三个问题:快指针负责什么?慢指针维护什么语义?什么条件下慢指针前进?三个问题想清楚,代码基本就出来了。

4. 第一天的坑位速查表与复盘方法

4.1 报错症状对照速查表

把第一天最常见的报错和原因整理成一张表,排查时对号入座:

症状常见原因修复方向
二分查找死循环循环条件与区间定义不匹配;left 更新成 mid统一用一套区间写法;左边界至少加 1
二分查找漏掉最后一个元素左闭右闭却用了 left < right左闭右闭必须用 left <= right
mid 计算出负数或越界(left + right) 溢出改成 left + (right - left) / 2
移除元素漏删暴力解忘记 i--覆盖后必须回头检查,或改用双指针
移除元素结果多算/少算返回了原长度而不是 slow新有效长度就是 slow 值
样例通过但大数据超时写成了 O(n²) 暴力换成快慢指针 O(n) 解法

这张表建议保存下来,第二天做新题之前先扫一遍。很多人的二分 bug 不是不会写,而是几种写法在脑子里打架,最后缝合成一个四不像。每次提交前,先明确告诉自己“我现在用的是哪一套区间定义”,比反复试错高效得多。

4.2 第一天怎么安排练习节奏

训练营第一天的量其实不大,核心就两题加理论基础,但节奏感很重要。我的建议是这样安排:

先花 30 到 40 分钟把数组理论基础和两种二分写法看清楚,不要急着做题。重点不是记住代码,而是能解释“为什么 right = mid - 1”“为什么 while 里要等号”这类问题。然后花 20 分钟手写一遍左闭右闭版本,注意是手写,别开着模板直接抄,肌肉记忆和眼睛记忆完全不是一回事。

写完二分,再花 30 分钟做移除元素。先自己写暴力解法,把坑踩一遍(比如忘写 i--),再切换到双指针。我不反对先暴力后优化,因为暴力的失败过程能让你真正理解为什么要优化。最后留 15 分钟,把两题的复杂度分析写一遍:时间、空间、为什么这样是最优的。

第二天一定要回头重做这两题,而不是急着学新题。间隔一天之后的默写,才是真正属于你自己的模板。

4.3 实操里的几个加分细节

最后分享三个我在实际练习中发现很有用的小习惯。

第一个,二分模板用注释固定区间定义。我自己的写法会在 right 初始化的那一行写注释:// 区间 [left, right] 左闭右闭。每次检查代码时,所有边界条件都对齐这套定义,不容易漂移。等你会写第二种区间后,也可以在注释里标清楚,两个模板并用时不容易拿混。

第二个,移除元素这类题的输出检查方法。本地调试时把结果数组的前 newLength 个元素打印出来,确认它们全不等于 val,再看数组末尾残留下的旧值,不用管。如果你直接打印整个数组,看到末尾有残留 val 会以为是 bug,其实那是正常现象。

第三个,关于“一题多解”的投入节奏。第一遍不要追求把所有解法都看完,先把一种解法完全吃透、能默写、能讲清楚,再看第二种。贪多嚼不烂,区间边界这种东西,两个版本同时学特别容易混。等左闭右闭已经成了条件反射,再去补左闭右开,一天补一个就够。

数组 part01 的真正收获,不在你做对了那两道题,而在你开始认真对待“边界”。边界问题在后面的链表、二叉树、滑动窗口里会以各种变形反复出现,第一天建立起来的区间意识和指针分工意识,到最后会发现是所有复杂算法题的地基。这也是训练营把数组放在第一天的原因——先爬坡,后面才走得稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询