ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数组底层原理与刷题套路:从越界到双指针滑动窗口

数组底层原理与刷题套路:从越界到双指针滑动窗口 记录一下我在看代码随想录数组章节时的一些整理和踩坑包括数据结构的底层理解、刷题时常用的几个操作套路以及我在不同语言里写数组代码遇到的差异。这篇笔记适合正在刷LeetCode的朋友也适合准备考研408或者在准备面试时复习基础数据结构的同学文章会把“数组”这个主题从底层内存布局一路聊到实操里的常见报错包括二分查找、双指针、滑动窗口、数组去重、多维数组和指针的辨析再顺手整理几个典型问题的排查思路。先说一个我自己的感受数组是所有数据结构里最“简单”但也是最容易出细节问题的一个几乎每一道算法题都会用到它。很多人在初学阶段觉得自己会了等到做题或者写项目时才发现问题恰恰都出在越界、初始化和传参这几个地方。1. 数组的基础认知从内存布局聊起1.1 数组为什么从 0 开始编号我第一次接触编程时一直不理解为什么数组下标非要从头 0 开始直接从 1 开始不是更符合日常习惯吗研究过一层之后明白了数组本质上是“一段连续的内存空间”下标在这里承担的是“偏移量”而不是“序号”的角色。如果用数组的首地址作为基地址数组元素 a[i] 的地址计算公式就是地址 首地址 i × 单个元素占用字节数从 0 开始编号第一个元素的偏移量就是 0地址直接等于首地址不需要再做任何减 1 的计算硬件层面也更好实现。不少语言提供从 1 开始的数组那是语法层面做了封装但底层逻辑还是从 0 偏移。所以刷题时千万别把数组下标简单理解成“第几个位置”它在内存层面就是“距离首地址的偏移量”这个认知会直接影响你对边界条件的判断。比如 C 语言里 a[5] 实际上是在首地址上偏移 5 个元素大小如果数组只有 5 个元素那么合法下标是 0 到 4a[5] 已经越界了。1.2 越界行为到底会发生什么数组越界是我做 C/C 题目时遇到最多的一个问题而且它的表现真的很“阴”。在 C 语言中数组越界属于未定义行为也就是说编译器不会一定给你报错。有时候你越界读了一个元素发现值很正常你就会误以为没问题有时候越界写了一个数据恰好把相邻变量的值改掉了程序跑着跑着突然出现一个很不可思议的结果。举个我实际调试过的例子int main() { int arr[3] {1, 2, 3}; int x 10; arr[3] 20; printf(x %d\n, x); return 0; }这段代码在不同编译器、不同优化级别下x 的打印结果可能完全不一样。有些环境里因为栈布局的关系arr[3] 恰好就把 x 覆盖成了 20有些环境则不会这类问题在大型项目里排查起来极为痛苦。所以我在刷题时给自己定了一条规矩所有涉及数组下标的循环都必须确认边界是否闭合。用 Python 刷题时也要注意列表切片同样存在边界问题后文再细说。1.3 数组长度与动态数组的取舍数组中还有一个经常被问到的概念——数组的长度是不是固定的静态数组在创建时就确定了长度比如 C 语言中的 int arr[10]这个 10 是编译阶段就定下来的。所以你会经常看到面试题里提到“数组无法动态增加元素”需要自己实现扩容逻辑。这个“扩容”过程值得好好理解因为 Java 里的 ArrayList、C 里的 vector、Python 里的 list 底层都跑过类似流程申请一块更大的连续内存通常是原来容量的 1.5 到 2 倍把原数组中的元素逐个拷贝过去释放旧内存在新内存上继续添加元素。这个扩容过程的时间复杂度是 O(n)但如果整个添加过程中扩容发生得并不频繁均摊下来的时间复杂度就接近 O(1)。这也是为什么“动态数组”看起来非常灵活但我们在算法题里分析复杂度时依然要关注最坏情况。C 的 vector 在扩容时有一个比较实用的点如果你能提前预估元素数量建议直接使用 reserve 预留空间。我踩过几次坑比如往 vector 里不断 push_back 十万个元素频繁扩容导致运行时间明显变长后来加上 reserve 之后速度提升很明显。原因很简单——减少了多次内存分配和数据拷贝。2. 多维数组与指针躲不开的 C/C 硬骨头2.1 二维数组的存储方式一开始我学二维数组时总喜欢把它想象成一个“表格”或者“矩阵”。这个类比方便理解但会导致一个错误认知以为二维数组在内存里也是按行和列交叉存储的。实际上无论是 C、C、Java 还是 Python 中的嵌套列表多维数组在底层都遵循“行优先”存储原则。也就是说一个 int a[2][3] 的数组在内存中会按照 a[0][0]、a[0][1]、a[0][2]、a[1][0]、a[1][1]、a[1][2] 的顺序紧密排列。这也是为什么有时候一维数组可以“伪装”成二维数组。比如一段连续的 6 个 int 空间你既可以直接当作一维数组用也可以把它强制转换成二维指针访问成 2 行 3 列的数据。从这个角度去理解 C 语言里 int arr[2][3] 和 int (*p)[3] 之间的兼容关系就顺理成章了。2.2 数组指针和指针数组的区别这个知识点在网上争论很多但拆开看并不复杂关键看你先读谁。“数组指针”的英文描述是 pointer to array它本质上是一个指针指向的是一个数组。最常见的声明写法是int (*p)[3];p 是一个指针这个指针指向的类型是“包含 3 个 int 的数组”。当你想让一个指针指向二维数组的一整行时用这种类型。“指针数组”的英文描述是 array of pointers它本质上是一个数组数组里的每个元素都是指针。声明写法是int *p[3];由于运算符优先级的缘故p 先和 [3] 结合所以它是一个长度为 3 的数组数组中的每个元素类型是 int*也就是每个元素都指向一个 int 变量。我经常用一个简单的方法记忆看变量名先跟谁结合。跟 * 先结合的是指针跟 [] 先结合的是数组。在刷题时指针数组最常见的应用场景是“字符串数组”的实现。比如 main 函数的 char *argv[]本质上就是一个指针数组每个元素指向一个字符串。顺着这个思路再看“指针数组存放字符串”“C 字符串数组初始化”之类的关键词就能对上了。2.3 多维数组作为函数参数时的退化问题C/C 的函数参数传递中数组有一个“退化”规则一维数组作为参数时会退化成指向首元素的指针。比如void printArray(int arr[], int n);这里的 int arr[] 和 int *arr 在参数层面是完全等价的。所以你在函数内用 sizeof(arr) 得到的不是整个数组的大小而是指针的大小。我当时就踩过这样的坑在函数内部计算数组长度结果发现结果不正确。后来学老实了要么在函数外先算好长度传进去要么使用 C 的 std::array 或 std::vector 这类现代化容器避免数组退化的问题。二维数组作为参数时稍微复杂一点它退化成“指向数组的指针”。比如void printMatrix(int mat[][3], int rows);等效于void printMatrix(int (*mat)[3], int rows);也就是说第二维度必须明确这样编译器才能知道每一行有多少个元素、如何计算地址偏移。2.4 初始化与常见错误数组初始化也是个容易被新手忽略的细节。局部数组如果不显式初始化C 语言里它的内容是“未知”的也就是随机值。这不同于全局变量和静态局部变量它们默认初始化为 0。写代码时建议这样操作int arr[10] {0}; // 将所有元素初始化为 0 int arr2[10] {}; // 部分编译器支持全部初始化 0对于字符串数组要特别注意结尾的 \0。使用 char str[10] 时如果你要存储一个长度为 9 的字符串最后一个位置必须留给 \0否则打印时会出现乱码或者在边界之外读取。后来我大多用 std::string 而不是裸的 char 数组主要是为了省去手动处理结尾标志的麻烦同时避免字符串函数越界读取的风险。C 里还有一种初始化方式值得掌握std::arrayint, 5 arr {1, 2, 3, 4, 5}; 这种写法保留了定长数组的性能优势又带上了 STL 的接口还避免了 C 数组退化成指针的问题在一些对性能要求较高的刷题场景中很实用。3. 刷题时最高频的数组操作套路3.1 二分查找里的边界难题代码随想录的数组章节里二分查找是重点直接关系到我后面刷题的基本功。二分查找本身并不难难的是边界条件的处理也就是 while 里是 left right 还是 left right以及更新区间时 middle 是加 1 还是减 1。这里必须建立一个核心概念“区间定义”。我常用的两种定义方式左闭右闭区间 [left, right]while (left right) { int middle left (right - left) / 2; if (nums[middle] target) { right middle - 1; } else if (nums[middle] target) { left middle 1; } else { return middle; } }左闭右开区间 [left, right)while (left right) { int middle left (right - left) / 2; if (nums[middle] target) { right middle; } else if (nums[middle] target) { left middle 1; } else { return middle; } }第二种写法里right 指向的元素是不包含在搜索区间里的所以更新 right 时直接等于 middle不需要减 1。这里记不牢就会出问题。还有一个细节计算 middle 时用 left (right - left) / 2 而不是 (left right) / 2主要是为了防止 left right 直接溢出。刷题网站的测试数据里确实可能出现很大的边界值我是被卡过一次才彻底改掉这个习惯。3.2 双指针法为什么快数组章节里另一个高频套路是“双指针法”。它解决的核心问题是在数组遍历过程中用一个指针记录结果位置另一个指针负责探查元素从而把时间复杂度从 O(n^2) 降到 O(n)。最经典的应用是“移除元素”。给你一个数组要求原地删除等于指定值的所有元素不使用额外数组空间。常规思维是找到要删除的元素然后把后面的元素整体前移复杂度高双指针的做法是“快慢指针”int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; } }这样一趟循环就完成了“筛选”slow 最终指向的正好是新数组的长度。整个过程没有额外的内存开销逻辑上也很好理解快指针负责往前找“要留下来的元素”慢指针负责把这些元素按顺序放在前面。这个思路不仅能用来移除元素还能用于数组去重。如果数组本身是有序的快慢指针就可以在原地完成去重。相关热搜词里反复出现的“数组去重”问题很多解法其实都基于这个思想。3.3 滑动窗口的精髓右边界动左边界跟滑动窗口可以看作双指针的一种特化区别在于两个指针之间维护的是一个“连续区间”而不是两个独立的索引位置。以“长度最小的子数组”为例给定一个正整数数组和一个目标值 s找到满足其和大于等于 s 的最小连续子数组。如果用暴力两层循环外层定起点内层逐个累加复杂度为 O(n^2)。滑动窗口的思路则是右指针不断向右扩展把元素加入窗口同时累加窗口和当窗口和大于等于 s 时记录当前窗口长度然后移动左指针缩小窗口并更新窗口和重复这个过程直到右指针走完整个数组。代码大致是这样int left 0; int sum 0; int result INT_MAX; for (int right 0; right nums.size(); right) { sum nums[right]; while (sum s) { result min(result, right - left 1); sum - nums[left]; } }这样的复杂度同样是 O(n)因为 left 和 right 分别最多移动 n 次。很多初学者问“为什么 while 里面不减一次然后 right 继续向前而感到不够直观”我的理解是窗口和一旦达到目标就不要继续扩展右边界了因为继续扩展只能让长度更长不会得到更优解此时应该收缩左边界尝试在更短的窗口内保持和达标。3.4 从一列数里找出若干元素的和等于固定值热搜词里有一句“一列数已知固定数值如何确定数组中的哪些数据和等于固定值。”这个问题在面试和业务中都很常见。如果只要求判断是否存在两个数之和等于 target经典做法是用哈希表遍历数组时把已经看过的元素存到哈希表里每检查一个新元素时只要 target - 当前元素 已经在表中就说明存在这样的组合。unordered_setint seen; for (int num : nums) { if (seen.count(target - num)) { return true; } seen.insert(num); }如果要求找“若干个数”的组合并且数组元素不能重复使用那就变成回溯/组合问题。比如一列正数中找若干个数使它们的和等于某个给定值。这类问题的常见搜索框架是先排序再利用回溯依次尝试选择或者不选某个值同时用剪枝提前结束不可能的分支。不过这里要特别注意如果题目允许同一个数被重复使用那就需要调整递归参数。代码随想录组合总和那节也有专门梳理建议把“不可重复取”和“可重复取”两个变体放在一起对比着刷这样记忆更牢。3.5 分块思想与树状数组热搜词里还有“树状数组维护长度 n 16 的序列。查询前缀和 sum(11) 与单点修改 add(3, x)”以及“树状数组模板”。虽然树状数组不完全是“数组基础”但名字带数组我也顺带提一下。树状数组Binary Indexed Tree解决的核心问题是在数组频繁发生单点修改的前提下快速查询前缀和。普通数组修改是 O(1)查询前缀和是 O(n)前缀和数组刚好反过来修改后重建是 O(n)查询是 O(1)。树状数组用 lowbit 运算把两个操作都变成 O(log n)。比如查询前缀和 sum(11)本质是把 11 拆成若干段区间并累计。11 的二进制是 1011lowbit 依次提取11 - 10 - 8 - 0对应的树状数组区间分别是 [11,11]、[9,10]、[1,8]累加即可。单点修改 add(3, x) 则是从 3 开始不断向右上跳到父节点更新所有覆盖该点的区间。这个数据结构不需要死记模板更重要的是理解 lowbit 运算法则和“每个下标管辖区间长度等于它的 lowbit”这个结论。考研 408 里也会涉及相关概念但更多停留在理论基础层面。4. 不同语言里的数组到底差在哪4.1 Python 列表与切片的高效玩法Python 里的列表 memory layout 是一个“对象数组”存的是指向各个元素对象的引用而不是元素本身这一点和 C 语言数组有本质区别。Python 最常用也最容易出问题的操作是切片。arr[1:4] 会创建一个新列表包含下标 1、2、3 这三个元素。切片遵循左闭右开原则也就是右边界的元素不包含在内。我一开始经常把 arr[1:4] 当成取下标 1 到 4结果多取了一个元素还找了半天 bug。还有一个必须记住的点切片操作会复制列表内容所以如果用切片来“删掉数组中间的一部分”要明白这会产生新的列表而不是原地修改原列表。如果数组很大这个复制动作会带来不少时间开销。处理“数组分割并显示包含某一字符”这类需求时Python 的列表推导式非常方便。比如有一个字符串列表想筛出包含“abc”的项result [s for s in arr if abc in s]如果要扩展到“数组分割”还可以配合 split 与切片实现分页比如取第 2 页每页 10 条page arr[10:20]4.2 Java 与 C# 的数组和集合差异Java 中数组是引用类型声明方式有 int[] arr new int[5]; 一旦创建长度固定。如果确实需要动态长度优先考虑 ArrayList底层同样是数组扩容机制。Java 刷题时有个易混淆点二维数组 int[][] matrix new int[3][4]; 本质上是“数组的数组”每一行都是一个独立的一维数组对象所以并不是一整块连续内存。如果要遍历matrix.length 是行数matrix[0].length 是第一行的列数。C# 与 Java 类似但 C# 多了“交错数组”和“多维数组”两种写法。int[,] 是真正的二维矩形数组int[][] 是数组的数组两者性能表现和内存布局不一样。在写 Unity 相关代码时我发现用交错数组有时候反而更灵活但多维数组在某些算法题中可以更直观地表示矩阵下标。C# 的数组也有一个常用的方法叫 Array.Exists、Array.FindAll、Array.Sort做筛选和排序比手写循环方便。另外有同学问“C# 不同的 class 可以组成数组吗”答案是当然可以比如定义一个基类再用基类数组存放不同子类对象Shape[] shapes new Shape[3]; shapes[0] new Circle(); shapes[1] new Rectangle();这个用法在多态场景中很常见。4.3 VBA 数组对比与性能和技巧VBA 里数组相关的热搜词也不少包括“数组分割并显示包含某一字符”“vba数组对比最快”“数组增加”。VBA 中动态数组需要先 Dim arr()再用 ReDim Preserve 扩展长度。但 ReDim Preserve 有个限制只能改变最后一维的大小多维数组扩展时只能动最后一个维度。这个限制我在实际处理表格数据时被卡过很多次。“VBA 数组对比最快”这个问题我试过几种方案发现把数据整体读入数组、用循环遍历比较比直接在 Excel 单元格区域之间逐格比较快很多。因为 Excel 单元格对象的访问开销非常大而数组操作完全在内存中运行。一个高效对比的例子arr1 Range(A1:A1000).Value arr2 Range(B1:B1000).Value For i LBound(arr1) To UBound(arr1) If arr1(i, 1) arr2(i, 1) Then 做对应处理 End If Next i这里 arr1 会得到一个二维数组下标从 1 开始即使你取的是一列数据它也是 (1 to 1000, 1 to 1) 的形状所以访问时要用 arr1(i, 1)。如果数组是单独的字符串数组且希望快速判断某个元素是否存在可以用字典或者使用更极限的方式把数组拼接成字符串后用 InStr 判断。4.4 JSON 数组与对象数组的互转现在的开发场景中数组经常以 JSON 形式传输。“json数组”“对象数组去重”也是高频搜索词。JSON 数组在 JS 中就是 Array在 Java 中用 Gson 或者 Jackson 可以很方便地转成对象数组。比如 GsonListMyClass list gson.fromJson(jsonString, new TypeTokenListMyClass(){}.getType());对象数组去重是我在业务开发里经常处理的问题。如果按某个字段去重最直接的思路是使用 Map 按该字段作为 key后写入的值覆盖先写入的值。ES6 里可以用 Map 或者 Set 结合展开运算符实现const unique [...new Map(arr.map(item [item.id, item])).values()];如果要去重的字段是一个组合可以将 key 拼接成一个字符串再放进 Map比如 item.type _ item.code。这种方式很简单业务上也稳定。PHP 中处理数组的函数比较多array_column 可以从二维数组里抽出一个字段作为新数组配合 array_merge 或者 array_unique 可以解决很多二维数组去重问题。最近有人问到“php二维数组改变键值”可以用 array_column 以某个字段作为键名比如$newArr array_column($arr, value, key);这样得到的数组以 key 字段作为新数组的键value 字段作为值处理某些接口返回时非常顺手。5. 常见问题与排查技巧实录5.1 数组越界的常见原因我在实际刷题和项目排错中总结出数组越界的三种常见来源一是循环边界写错比如使用 导致多走了一次循环。解决方法是画一张表格把循环变量每次取值列出来尤其注意最后一次循环是否符合预期。二是数组长度未更新比如执行删除操作后还按原来的长度遍历。这个问题在双指针和滑动窗口相关的题目里很常见很多初学者在一个循环里改了数组长度下一个循环仍用旧长度。三是多维数组的第二维长度混淆。C/C 和 C# 中访问 matrix[i][j] 时如果 i 和 j 弄反不一定立刻报错可能只是读取随机值这是最坑的情况。5.2 数组与指针混用后的诡异表现数组和指针混用的报错通常不像“越界”那样明显最常见的几个轻量陷阱包括对数组名进行 或赋值操作在 C 语言中数组名是常量指针不能修改int arr[5]; arr 会编译失败使用 sizeof(arr) 求数组长度参数传递后误用指针使用指针遍历字符串时漏掉结尾 \0 导致多读一个字符经常得到乱码。排查这类问题的好方法是在关键位置打印指针地址、元素地址和相对偏移量。比如打印 arr[0]、arr、arr[2] 的值确认是否连续。很多内存布局问题只要把地址打印出来规律基本一目了然。5.3 动态数组扩容失败“数组增加”这个操作在静态数组里非常受限。如果你发现自己不断往一个定长数组里塞数据程序突然崩溃可能在写入越界位置。建议换成 vector 或 ArrayList并在循环之前 reserve 或预估容量。C vector 的一个隐藏误区是在遍历 vector 时不断 push_back可能使迭代器失效。原因是 vector 扩容后原有迭代器指向的内存地址已经变化。正确的做法是改用索引访问或者提前用 reserve 避免扩容。5.4 几个复用性高的模板思路针对刷题和日常脚本我整理了几条比较顺手的模板有序数组去重快慢指针即可不用额外空间对象数组按字段去重优先考虑 Map key 拼接二分查找模板先确定区间是否闭合再决定 while 条件和 right 更新方式二维数组遍历先取行数再取列数确认每个子数组长度一致数组转字符串如果是对象数组先映射到基本字段再 join 或 toString。另外强调一个细节数组转字符串在 C/C 中需要手动拼接非常容易在结尾多写或少写分隔符在 JavaScript 中直接用 arr.join(,) 就行在 Python 中如果元素是数字需要先 map 成字符串再 join。不同语言的实现差异挺大的最好根据场景选择合适的方式。5.5 宏定义数组与编译期常量的坑“宏定义数组”这个热搜词也值得聊一句。C 语言里使用 #define ARRAY_SIZE 10 定义数组长度是常见做法。但要小心宏展开时的运算优先级问题比如#define N 12 int arr[N]; // 编译器可能解读为 int arr[12]这里虽然恰好得到 3但如果在更复杂的表达式中使用 N例如 N * 2就变成了 12*2 5而不是期望的 6。推荐将常量写成枚举或者 constexpr 而不是宏C/C 项目里这是更稳妥的选择。代码随想录里对数组的讲法核心其实是从“抽象”到“具体”再到“应用”的层层递进这篇笔记看起来在谈“数组”实际上已经牵涉到内存布局、语言实现差异和算法模式。我在实际刷题中还有一个体会数组类问题即使换了一层壳比如接雨水、螺旋矩阵、买卖股票的最佳时机底层终究逃不开索引遍历和区间维护。只要把数组的底层概念、常用循环边界、双指针与滑动窗口这些基本功练熟遇到大多数题目都能很快找到切入点。最后再分享一个操作习惯我现在刷数组类题目时会刻意在草稿纸上把数组的下标变化画出来特别是指针移动和边界更新这两部分画完之后代码几乎一次就能写对。这个习惯帮我减少了很多不必要的调试时间。如果你在数组问题上反复出错不妨也试试这个办法。
返回列表