☰
顺序表算法题通用解题框架:四步法+五类高频题型模板
2026/10/13 3:52:46 网站建设 项目流程

刷顺序表算法题,最怕的不是题难,而是每道题都感觉是新的。今天把顺序表(也就是数组)相关算法题的通用思路彻底拆开讲清楚——从题型判断、遍历策略、搬移方式到边界自查,一套方法论吃透80%的常考题,配合C语言风格的代码模板,每题都能快速找到下手点。

1. 先想清楚:顺序表题为什么值得总结一套通用思路

1.1 顺序表题的三个“死穴”:元素搬移、长度动态变化、边界条件

顺序表本质上就是连续内存上的数组,底层结构极其简单,但算法题里它却是最容易让人翻车的类型之一。原因在于所有操作都被三个问题卡住了:第一是元素搬移成本,数组中间插入或删除一个元素,需要把后面所有元素整体前移或后移,这个动作一旦写错方向,要么越界,要么覆盖掉还没处理的数据;第二是长度动态变化,题目里常常要求“原地修改”且返回值是新长度,这一步意味着遍历过程中不能依赖原长度做全局判断,每个循环的退出条件都要重新想清楚;第三是边界条件密集,最典型的就是left <= right还是left < right,二分查找里差一个等号,结果就完全不同。

这三个问题的存在,决定了顺序表题不可能靠死记硬背来解决。你背下来了“删除重复元素”的代码,换个“原地移除指定值”的题,稍微改一下条件就不知道快慢指针该什么时候挪了。所以真正需要建立的是框架,是面对任意一道顺序表题都能快速回答“这个题属于哪种操作模式、该用什么遍历方向、搬移策略是什么”的能力。

1.2 通用思路的底层逻辑:存量搬移 + 指针游走 + 状态压缩

我刷了大概两百多道顺序表相关题目后,总结出了一个三要素模型:存量搬移、指针游走、状态压缩。

存量搬移解决的是“数据往哪放”的问题。数组不同于链表,没办法真正删除某个节点,所谓的删除、去重、筛选,本质上都是“把需要保留的数据向前覆盖,把无效数据甩到后面不再管”。一旦想通这一点,所有删除类题目就统一成了一个模式:用两个指针,一个探测、一个写入。

指针游走解决的是“数据从哪读”的问题。顺序表的所有操作都可以归结为指针的移动规则——从头到尾、从尾到头、二分跳跃、快慢步差。绝大多数题目用的是单向遍历,但有些场景需要反向处理,比如合并两个有序数组,如果从前往后会覆盖掉还没参与比较的元素,从后往前反而刚刚好。

状态压缩解决的是“信息如何记录”的问题。有时你需要在遍历过程中记录某些状态,比如是否出现过某个值,但题目要求O(1)空间,这时候就需要思考能否利用数组本身的性质做状态压缩——例如把访问过的位置上的值变成负数来标记,这样既不额外开哈希表,又能完成信息记录。这套三要素组合起来,就形成了后面要讲的四步法框架。

2. 通用解题四步法:从读题到AC的固定流程

2.1 第一步:判断题型,锁定操作对象和约束条件

拿到一道顺序表题,先别急着写代码,花三十秒问自己三个问题:题目要求对元素本身做什么?允许额外的空间复杂度是多少?操作后返回的是什么?

这三个问题的答案直接决定后续所有选择。如果题目说“移除所有值为val的元素”,那操作对象是元素本身,属于筛选类,通常用双指针覆盖;如果题目说“旋转数组”,那操作对象是整体顺序,属于翻转类,往往用逆置操作组合;如果题目说“查找目标值”,那操作对象是查询,属于搜索类,有序表直接二分,无序表只能线性扫描。

同时一定要留意约束条件的隐含信息。比如“原地修改”“O(1)额外空间”“不要求保持相对顺序”“可以修改数组内容”,这些字眼都是出题人的暗示,翻译成人话就是:允许覆盖、不能用哈希表、可以把无效元素换到后面、可以破坏原有顺序。这些约束越严格,解法反而越明确。

2.2 第二步:确定遍历方向,是从头到尾还是反向处理

很多人写顺序表题习惯性从前往后遍历,这是本能反应,但不是最优反应。判断遍历方向有一个核心标准:从某个方向处理时,是否会覆盖掉尚未被读取的数据?

举个最典型的例子,合并两个有序数组到第一个数组中,要求最终结果保存在第一个数组里,且第一个数组预留有足够空间。如果从前往后合并,每次放入一个小元素到前面,都会把第一个数组中还没参与比较的原始元素覆盖掉,数据就丢了。如果从后往前合并,从两个数组的尾部开始取较大值放到最终位置的尾部,之前的数据天然已经被处理过,不可能被误覆盖。这就是“反向遍历”的经典场景。

还有一个判断标准是题目是否涉及前后关联。比如统计每个元素右侧比它小的元素个数,这类题天然需要从右往左扫描,因为信息流向是从右向左的。顺序表题里,“从后往前 + 大数优先”组合通常解决合并类问题,“从前往后 + 覆盖写入”组合通常解决筛选类问题,先确定这个方向,代码骨架基本就出来了。

2.3 第三步:选择搬移策略,覆盖式更新还是尾部追加

搬移策略是顺序表题的灵魂,也是最能拉开解题水平的地方。我把常见策略归纳为两种:覆盖式更新和尾部追加。

覆盖式更新的典型代表是快慢指针。慢指针指向“下一个待写入的位置”,快指针负责“扫描原数组”,快指针每遇到一个符合条件的元素,就把它写到慢指针所指位置,然后慢指针前进一格。整个过程不需要挪动批量元素,每个元素只被处理一次,时间复杂度稳定在O(n),额外空间O(1),这是顺序表删除类、去重类、筛选类题目的通用解法。

尾部追加则是从空结果集开始,逐步把符合条件的元素追加到数组头部区域(因为要原地操作,追加到头部其实也是覆盖式更新,只是语义上更接近“构建新数组”)。尾部追加的方式在理解上更直观,但不适合所有场景——比如需要保持原数组尾部数据的题,或者需要利用原数组尾部的额外空间做大规模归并的题,覆盖式更新就更合适。选择策略时永远先问:被覆盖的位置上的旧数据还要不要?不要,就放心覆盖;要,就换一种写法。

2.4 第四步:边界条件自查,三个最容易出错的点

写完之后别急着提交,做一遍边界自查。根据我的经验,顺序表题的bug有80%集中在三个点:

第一个是空数组和单元素数组。很多代码在处理大数组时正确,一到空数组就崩溃或返回错误值。比如二分查找,空数组直接返回-1;去重代码,需要判断数组长度是否为0,否则访问nums[0]就越界。

第二个是循环结束条件。写while (i < j)还是while (i <= j),看似差不多,实际差很多。二分查找里,区间定义是“左闭右开”还是“左闭右闭”,直接决定等号怎么处理。我的习惯是统一用“左闭右闭”,初始化right = len - 1,循环条件left <= right,中间值mid = left + (right - left) / 2,这样整个二分的边界逻辑最清晰。

第三个是指针到达末尾后的处理。双指针题最容易遗忘的就是循环结束后还有剩余的尾巴没有处理。比如合并两个有序数组,一个数组已经遍历完,但另一个数组还有剩余元素,此时需要单独补一个循环把所有剩余元素搬过去。这个尾巴不处理,答案就是错的。

实操心得:我写顺序表题有一个红灯习惯——任何一个指针在自增之后,一定下意识问一句“它会不会越界?越界后循环条件能不能正确退出?”这种条件反射养成之后,提交通过率能明显提高一大截。

3. 五类高频题型拆解与对应模板

3.1 查找类:有序表二分框架

顺序表查找类题目,只要数据有序,第一反应就应该是二分查找,不要犹豫。二分查找的框架看起来简单,实际上有太多细节容易出错,我直接把最稳妥的模板放在这里,可以照抄。

int binarySearch(int* nums, int numsSize, int target) { int left = 0; int right = numsSize - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] == target) { return mid; } else if (nums[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }

这里有个细节值得注意:mid的计算我故意用了left + (right - left) / 2,而不是直接写(left + right) / 2。原因在于后者在数组极大时可能导致整数溢出,虽然刷题时很少遇到那么大的数组,但养成这个习惯没有坏处。更新边界时,left = mid + 1和right = mid - 1必须同时修改,否则可能出现死循环——当区间只有两个元素时,如果只改一头,mid永远等于原来的位置,程序就卡死了。

查找类的变体很多,比如查找第一个等于目标值的位置、查找最后一个等于目标值的位置、查找第一个大于目标值的位置,它们的核心都是二分,区别只在于nums[mid] == target时的收敛方向。想找最左边的目标值,相等时不能立刻返回,要把right收缩到mid - 1继续找;想找最右边的,则要收缩left。建议把这道变体都写一遍,彻底吃透二分边界。

3.2 删除类:双指针“快慢”覆盖框架

删除类题目是顺序表题中最常见的一类,包括删除重复元素、删除指定值、删除元素后保证相对顺序不变等等。核心思路都是快慢指针覆盖法。

int removeElement(int* nums, int numsSize, int val) { int slow = 0; for (int fast = 0; fast < numsSize; fast++) { if (nums[fast] != val) { nums[slow++] = nums[fast]; } } return slow; }

这段代码是“原地移除指定值”的解法,但它稍作修改就能处理很多变体:把nums[fast] != val改成nums[fast] != nums[fast - 1](配合对第一个元素的特殊处理),就是有序数组去重的写法;把条件改成“保留满足特定谓词的元素”,就成了筛选题的通用解法。慢指针slow最终的数值就是新数组的长度,同时新数组的长度也恰好是慢指针下标,一箭双雕。

这个框架容易出错的地方在于:如果题目要求的是“删除重复元素且最多保留两个”(经典题“删除有序数组中的重复项II”),快慢指针框架就需要增加一个计数器来记录当前元素连续出现的次数,条件判断会稍微复杂一些。我的建议是先掌握基础框架,再逐步叠加计数逻辑,不要一上来就背整段代码。

3.3 合并类:从后往前的大数归并框架

合并类题目的代表是“合并两个有序数组”,通常第一反应是新建一个临时数组,或者先合并再排序,但这两种都不是最优解。最优解是充分利用第一个数组预留的尾部空间,从后往前归并。

void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n) { int i = m - 1; int j = n - 1; int k = m + n - 1; while (i >= 0 && j >= 0) { if (nums1[i] > nums2[j]) { nums1[k--] = nums1[i--]; } else { nums1[k--] = nums2[j--]; } } while (j >= 0) { nums1[k--] = nums2[j--]; } }

从后往前归并的核心思路是:每次比较两个数组当前指针所指的元素,把较大的元素放到结果数组从末尾开始的位置上。因为 nums1 的原始元素只存在于前 m 个位置,后半部分是预留空位,从后往前放数据时不可能覆盖还没处理的原始元素,这是这个方案安全的原因。

为什么最后的while循环只处理 j 而不处理 i?因为如果 nums1 中还有剩余元素,它们已经处在正确的位置上,不需要搬动;而 nums2 中若还有剩余,则需要全部补到 nums1 前面。这个细节是合并类题目最容易忽略的尾巴处理,务必记住。

3.4 翻转/旋转类:三段逆置框架

旋转类题目,比如“将数组中的元素向右旋转k个位置”“翻转字符串中的单词”,它们的本质都是数组片段的重排,常用的解法是多次逆置。记住一个经典场景:数组整体向右旋转 k 步,等价于先把整个数组逆置,再把前 k 个元素逆置,最后把剩余元素逆置。这个“三段逆置”是旋转类题目的标准解法。

void reverse(int* nums, int left, int right) { while (left < right) { int temp = nums[left]; nums[left] = nums[right]; nums[right] = temp; left++; right--; } } void rotate(int* nums, int numsSize, int k) { k = k % numsSize; reverse(nums, 0, numsSize - 1); reverse(nums, 0, k - 1); reverse(nums, k, numsSize - 1); }

这里踩过坑的朋友应该不少。第一个坑是必须先对k做取模运算,因为旋转步数可能超过数组长度,不取模会导致越界访问;如果k是0或正好等于数组长度,数组相当于没有旋转,此时取模后为0,reverse(nums, 0, -1)这样的调用会因为left < right不成立而自动跳过,逻辑上安全,但最好还是提前判空。第二个坑是三段逆置的区间划分要准确,[0, k-1]和[k, n-1]之间的边界不能重叠也不能遗漏。

三段逆置之所以通用,是因为它把“循环移动”转化成了“数组片段逆置”这种同质的操作,代码好写也好理解。类似的,如果题目要求的是“反转每个长度为k的子数组”,也可以用同样的思路写作循环,只是每次逆置的区间不同。

3.5 去重类:有序表相邻去重框架

有序数组去重是考研和面试的热门题,因为它的优化思路非常典型——从“每次删除都要搬移 O(n) 元素”到“一次遍历 O(n) 解决”。

int removeDuplicates(int* nums, int numsSize) { if (numsSize == 0) return 0; int slow = 1; for (int fast = 1; fast < numsSize; fast++) { if (nums[fast] != nums[fast - 1]) { nums[slow++] = nums[fast]; } } return slow; }

这段代码里,slow从1开始而不是0,是因为第一个元素无论如何都会被保留,不需要比较和搬移。快指针从1开始,每次和它前面的元素nums[fast-1]比较,如果不同,说明遇到了新元素,放入慢指针位置。因为数组是有序的,相同的元素必然连续出现,所以只需要和相邻元素比较即可判断是否重复。

去重类题目经常伴随“最多保留两个重复元素”的变体,这时候需要在快慢指针之外增加一个计数器,记录当前慢指针对应元素连续出现的次数,遇到第三个相同元素就直接跳过。这种变体的思路是从“相邻是否相同”升级为“相邻连续出现次数”,逻辑复杂度上升了一档,建议大家拿纸笔跑一遍完整流程再上机验证。

4. 手把手实战:用同一套思路解决三道真题

4.1 实战一:删除有序数组中的重复项

这道题完美对应第3章的3.5小节。拿到题目后按四步法走一遍:第一步判断题型,是去重类;第二步确定遍历方向,因为需要保留数据在前,所以从前往后;第三步选择搬移策略,快慢指针覆盖式更新;第四步处理边界,数组为空直接返回0。

代码就是上一章的模板,这里重点讲一下它的执行过程,因为理解了执行过程,才能在变体中灵活调整。假设输入是[1, 1, 2, 3, 3, 4],初始化slow=1, fast=1。fast=1时,nums[1]=1等于nums[0]=1,跳过;fast=2时,nums[2]=2不等于nums[1]=1,把2写入nums[1],slow 变为2;fast=3时,3不等于2,把3写入nums[2],slow 变为3;fast=4时,3等于3,跳过;fast=5时,4不等于3,把4写入nums[3],slow 变为4。最终返回 slow=4,数组前半部分是[1, 2, 3, 4],完全正确。

这道题的复杂度分析也很漂亮,时间O(n)、空间O(1),没有额外的临时数组,是标准的原地算法。面试时如果能随口说出这个复杂度分析,会是一个加分项,因为多数面试官会继续追问“为什么空间是O(1)”,就可以顺势解释覆盖式更新的原理。

4.2 实战二:合并两个有序数组

这道题对应3.3小节,但不是简单套公式就能写对。我先说一下一个非常常见的错误版本:新建一个临时数组,把两个数组合并进去,再整体拷贝回 nums1。这个版本逻辑正确,但空间复杂度是O(n),不是最优解。

按照四步法重新分析:第一步判断题型,是合并类;第二步确定遍历方向,必须是反向遍历,因为正向会覆盖数据;第三步选择搬移策略,从后往前的大数优先;第四步关注尾巴处理。接着上一章的代码往下走流程,假设 nums1 =[1, 2, 3, 0, 0, 0],m=3,nums2 =[2, 5, 6],n=3。初始时 i=2, j=2, k=5。比较 nums1[2]=3 和 nums2[2]=6,6更大,写入 nums1[5],j变为1;比较 3 和 5,5更大,写入 nums1[4],j变为0;比较 3 和 2,3更大,写入 nums1[3],i变为1,此时 j 已经小于0,循环退出。剩下的 while(j>=0) 不需要执行,因为 nums2 已经处理完了。合并结果是[1, 2, 2, 3, 5, 6]。

如果调换一下数据,比如 nums2 中所有元素都比 nums1 小,那么循环会先把 nums1 中的大元素全部放到后面,然后 nums2 的元素全部补到前面。最后的 while 循环就会发挥作用,把 num2 的剩余元素逐个拷入 nums1 的前面空位。这就是“尾巴处理”的实际意义,漏掉它,前面数据就会被覆盖。

4.3 实战三:旋转数组的最小值

这道题的背景是:一个原本有序的数组经过若干次旋转后,找出数组中的最小值。要求用二分查找实现,时间复杂度 O(log n)。它对应3.1小节的查找类框架,但在二分的基础上增加了一个维度——判断最小值位于左半段还是右半段。

int findMin(int* nums, int numsSize) { int left = 0; int right = numsSize - 1; while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] > nums[right]) { left = mid + 1; } else { right = mid; } } return nums[left]; }

这个写法有几个值得深究的地方。第一,循环条件是left < right而不是left <= right,因为目标是收敛到一个点,而不是寻找特定值。第二,比较对象是nums[mid]和nums[right],不是nums[mid]和nums[left],原因是旋转后的数组,右半段一定存在一个“断点”性质的元素,用右边界做参照能稳定判断最小值在哪一侧。第三,right = mid而不是right = mid - 1,因为当nums[mid] <= nums[right]时,mid本身可能就是最小值,直接跳过会导致结果错误。

这道题是二分查找中比较进阶的用法——不止是在有序数组中查找,而是在“半有序”数组中通过性质判断收敛方向。但它仍然完全嵌套在四步法框架之内:题型是查找类,遍历方向是二分跳跃,搬移策略不涉及覆盖,边界条件处理循环停止时机。

5. 常见错误与排查技巧实录

5.1 边界条件类错误:空数组和指针越界

顺序表题边界条件出错的比例非常高,尤其是空数组、单元素数组、双元素数组。我整理了一个自查清单,每次写完代码对照过一遍,基本能消灭八成bug:

边界场景常见错误正确做法
空数组直接访问 nums[0] 导致越界开头判断 numsSize==0,直接返回
单元素数组快慢指针初始化错误确认快慢指针起始位置后,循环条件能否覆盖长度为1的情况
双元素数组二分死循环确保每次循环 left 或 right 至少前进一格
长度变化后访问仍用旧长度做循环条件所有循环统一使用动态维护的指针或新长度
取模运算缺失k大于数组长度时越界先做 k %= numsSize

这个表格里的最后一行是旋转类题目最容易犯的错误,我之前在某一次实际项目中处理轮转队列时,就是因为遗漏了取模导致数组访问越界,整个程序直接崩溃,教训很深。所以不管是刷题还是写工程代码,涉及环状移动的场景,取模不能省。

5.2 复杂度不达标类错误:优化思路被惯性思维限制

顺序表题从暴力解法到最优解,往往只需要一个思维转换。最典型的就是合并两个有序数组,很多人第一反应是先合并再排序,这样时间复杂度是O((m+n)log(m+n));有些人会新建临时数组,时间复杂度O(m+n)但空间复杂度O(m+n);只有从后往前归并,才能做到时间O(m+n)、空间O(1)。

训练这种优化直觉有一个笨办法但很有效:每次写完暴力解之后,刻意问自己三句话——能不能去掉额外数组?能不能只用一次遍历?能不能利用数组本身的有序性或位置关系?这三句话问完,大多数顺序表题的解法都能被逼到最优。我在带新人时也经常用这个方法,比直接给最优解更能帮助他们理解解题思路的来源。

5.3 做题时真正有用的几个习惯

第一个习惯是先在草稿纸上画数组图。不要只盯着代码想,把数组画成格子,用箭头标出指针位置,手动模拟一遍算法流程。我第一次理解快慢指针就是靠画图,画了三遍之后,整个覆盖过程就刻在脑子里了。

第二个习惯是关注“循环不变量”。每次循环开始前,某一段数据一定满足某个性质,比如“慢指针之前的所有数据都已经处理好了”,这个不变量一旦明确,写循环的退出条件和边界处理就有据可依,不容易乱。

第三个习惯是做变体题而非死磕同一道题。顺序表题换一个条件可能就是另一种解法,比如删除指定值改成删除重复项,快慢指针框架不变但条件变了;比如二分查找改成查找左右边界,等号处理就变了。把一道基础题的三个变体各写一遍,比重复刷十道类似的题更有效。

避坑经验:我见过不少同学在删除类题目上喜欢用“从后往前删除”的策略来避免元素搬移,实际上这个思路是不对的。数组删除元素,从后往前只是避免了搬移过程中的索引错乱,但每次删除仍然需要搬移后续所有元素,时间复杂度还是O(n^2)。正确做法永远是快慢指针覆盖,一次遍历完成,这才是顺序表原地修改的精髓。

结尾

这套四步法加五类题型框架,我大概是从刷到第五十道顺序表题时才慢慢总结出来的,之前一直处于“看一道题学一个解法”的低效循环里,直到某天突然发现很多题的代码写出来都长得差不多,才意识到背后有一套固定的模式和判断逻辑。如果你正在被顺序表题折磨,我的建议是别急着刷题量,先把这套框架套进去,每做一题都问自己“这属于哪一类、应该用哪种遍历方向、搬移策略选覆盖还是追加”,练习十到十五道题之后,你会明显感觉到写题的速度和正确率都有提升。等这套框架用顺手了,你会发现不只是顺序表,链表、字符串里很多题也共用着同一套设计思想——数据和操作分离、指针与覆盖配合、边界先行——数据结构题的底层逻辑,其实比我们想象中要统一得多。

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

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

立即咨询