ARTICLE DETAIL

资讯详情

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

数组高频操作与算法实战:从初始化、排序去重到性能优化

数组高频操作与算法实战:从初始化、排序去重到性能优化 数组算是我这么多年写代码下来最常打交道也最容易被忽视的数据结构。无论是后端处理一批订单前端操作一组 DOM还是脚本里汇总数据“高频”两个字放在数组前面一点不过分——笔试、面试、日常开发里它的出现频率几乎碾压其他数据结构。这篇文章我结合自己踩过的坑和带新人时反复讲的点把数组从初始化到高阶算法实战从头捋一遍适合正在准备面试的开发者也适合想把手头代码写得更稳的工程人员。别觉得数组简单真正能把下标、边界、默认值、深浅拷贝这些细节说清楚的人其实没那么多。我见过不少工作两三年的同学写个简单的数组去重都能翻车。所以这篇文章不会只堆语法我会把每个操作背后的原理、常见坑、以及我在实际项目里用的方案一起写出来尽量让不同语言背景的人都能对上号。1. 数组初始化与底层存储的高频细节1.1 各语言数组初始化的默认值与推导规则数组初始化是最高频但也是错误率最高的点。不同语言规则差异很大而且这些规则直接影响你后面所有的操作。先说 C 语言。int arr[10] {0};看起来是给第一个元素赋值为 0实际上 C 标准规定如果初始化列表中的元素个数少于数组长度剩余元素会自动补 0。所以int arr[10] {1};的结果是第一个元素为 1后 9 个全是 0而不是像很多新手想的“全变成 1”。再看 C 里的std::arrayint, 10 arr{};同样是全 0 初始化但如果写std::arrayint, 10 arr;并且是局部变量那元素的值是不确定的可能是残留内存里的随机数。这种差异在嵌入式开发和底层系统编程时特别容易踩坑一旦在正式环境里读到“脏数据”很难排查。Java 就好很多int[] arr new int[10];默认全部为 0boolean[]默认是false引用类型默认是null。这个我经常提醒准备面试的同学Java 的数组是对象有默认初始化但二维数组的“不规则数组”需要逐行new而不是传统意义上一次性分配连续大块。JS 的坑则藏在new Array(3)里。这个操作会创建一个长度是 3 的数组但里面是三个empty槽位不是undefined。如果直接调用map会跳过这些空槽结果还是一个空数组。正确做法是Array.from({length: 3})或者[...Array(3)]。Python 的[0] * 5适用于不可变类型但对列表这种可变类型[[0] * 3] * 4就是一个经典陷阱——它创建了 4 个指向同一个子列表的引用。你改了arr[0][0]会发现arr[1][0]、arr[2][0]全变了。要创建独立子列表必须用列表推导式[[0] * 3 for _ in range(4)]。对于 C 语言里用宏定义数组长度比如#define ARR_SIZE 10这个本身没什么问题但要注意宏不检查类型也不占内存。实际工程里更推荐用const int或constexprC这样能利用编译期类型检查减少隐患。1.2 数组越界无提示的系统性风险数组越界在 Java、Python、JS 等高级语言里会直接抛异常相对安全但在 C/C 中越界读是未定义行为越界写更是灾难。我调试过很多次“程序莫名崩溃”最后定位到都是数组下标写错一位悄悄覆盖了相邻变量甚至破坏了栈上的返回地址。有一个真实案例一个后台服务偶尔在特定数据量下崩溃复现困难。排查了半个月最后发现是某个结构体里的数组长度是 64代码里却允许下标到 64写入的数据正好把相邻指针字段的低位字节冲掉了导致后续 free 一个非法地址。这种问题用 ASanAddressSanitizer能快速发现所以我在带团队时都会要求 C/C 项目的调试构建必须默认打开 -fsanitizeaddress。即使是高级语言也要注意边界条件的把握。比如二分查找里的mid (left right) / 2当left right超过 int 上限时会溢出这也是数组下标问题的变体。真正的经验是任何使用下标的代码上手先确认“最小合法下标”和“最大合法下标”再写循环。2. 数组高频操作排序、去重、切片与转换2.1 排序不止是调用 sort数组排序是出现频率极高的需求但很多人在“手写排序”上翻车。先说 JSarr.sort()默认是把元素转成字符串再按字典序排所以[10, 9, 100].sort()的结果是[10, 100, 9]不是数值顺序。必须传比较函数arr.sort((a, b) a - b)才得到升序。这个细节几乎每次前端面试都会问。C 中的std::sort是不稳定排序时间 O(n log n)但要求随机访问迭代器如果需要保持相等元素原有相对顺序用std::stable_sort。另外sort传入的比较函数必须满足严格弱序返回值必须是true/false如果写return a - b;在大于小于时没问题但相等时返回 0在 C 中是合法的但如果是自定义结构体排序比较函数写不好会触发未定义行为严重时会直接抛异常。Python 的list.sort()是稳定排序reverse参数控制升降序。但 Python 里排序的“key”参数是个好习惯比如按字符串长度排arr.sort(keylen)避免引入自定义比较函数导致性能下降。VBA 数组没有内置排序方法很多人会循环冒泡数据量一大就卡死。我的方法是把数组塞进ArrayListCreateObject(System.Collections.ArrayList)调用它的Sort方法速度比手写冒泡快不止一个量级。如果真的需要高性能可以写快速排序但注意 VBA 中递归深度有限数据量几千条时没问题几万条就会爆栈。2.2 数组去重从一行代码到性能权衡数组去重这个高频需求在不同语言里有不同的“最优解”。ES6 里[...new Set(arr)]一行搞定不仅简洁而且时间复杂度 O(n)。arr.filter((item, index) arr.indexOf(item) index)是很多教程里写的经典方法但它是 O(n^2)数据量一上万就会明显卡顿。还有一个坑如果数组里有对象Set的去重是基于引用而不是内容所以想对“对象数组”按某个字段去重得用Mapconst seen new Map(); const unique arr.filter(item { const key item.id; return !seen.has(key) seen.set(key, true); });Java 里如果想保持插入顺序去重用LinkedHashSet最省事如果性能优先且不关心顺序用HashSet。但注意HashSet的哈希冲突严重时会退化成链表所以重写好hashCode()和equals()很重要。Python 中保序去重推荐dict.fromkeys(arr)因为 Python 3.7 的 dict 是插入有序的而且 key 自动去重。不保序的直接list(set(arr))。C 中有两种常见思路先sort再unique缺点是改变了原顺序且只去相邻重复用unordered_set来过滤可以保序相对原顺序但要注意占用额外空间。我一般更推荐unordered_set尤其是数据量较大且不需要稳定次序的时候。2.3 删除、切片、合并与数组转字符串JS 删除指定元素有三个常见方法很容易搞混splice(index, 1)会修改原数组并返回被删元素slice(begin, end)返回一个新数组不改原数组filter根据条件生成新数组更适合“删除满足某条件的元素”。实项目里如果只是删一个元素splice性能最好但如果在循环里删除多个元素务必注意删除后下标会改变应该从后往前删或者用filter。Python 切片是数组操作中的神器arr[1:5]取下标 1 到 4arr[-1]取最后一个元素arr[::2]取偶数位arr[::-1]反转数组。有人记不住步长负数时是“反向取”其实只要牢牢记住切片左闭右开且start省略时由step方向决定起点就不会错。切片返回的是新列表但元素是浅拷贝如果列表里是可变对象修改引用仍然会影响原列表。数组转字符串也很常见。JS 是arr.join(,)如果元素本身含逗号最好用JSON.stringify(arr)。Python 需要,.join(map(str, arr))不能直接.join(arr)除非元素全是字符串。Java 8 之后可以String.join(,, Arrays.stream(arr).map(String::valueOf).toArray(String[]::new))写起来啰嗦但效果稳定。数组合并方面JS 用concat或扩展运算符[...a, ...b]后者更受现代代码风格欢迎Python 直接a b如果想逐个追加用a.extend(b)。在 C 中合并两个vector一般用insert或std::merge但注意vector的扩容和拷贝可能带来额外开销高帧率循环里最好提前reserve。3. 指针数组、多维数组与动态数组的核心区别3.1 指针数组 vs 数组指针这次彻底分清C/C 里有两个长相接近但完全不同的概念int *p[10]是指针数组意思是数组里有 10 个int*指针元素int (*p)[10]是数组指针指向一个含有 10 个 int 的数组。定义哪一个取决于p先和谁结合下标[]的优先级高于解引用*所以int *p[10]先看p[10]说明p是数组再看int *说明数组元素是 int 指针。而(*p)[10]中括号强制p先解引用所以p是指针指向一个长度为 10 的 int 数组。二维数组和多维指针之间的关系也经常让初学者头晕。int a[3][4]中a指向“第一行这个一维数组”所以a 1的步进是 4 个 int而不是 4 个字节。要取第 i 行第 j 列元素可以写a[i][j]也可以写*(*(a i) j)。我建议在面试时如果面试官问这个先画一张内存图再回答直观又不易出错。多维数组在内存中是连续存储的C/C 是行主序Fortran 等是列主序理解这个差异对性能优化很重要。3.2 动态数组与数组扩充的实现权衡C 语言的标准库没有提供动态数组需要自己管理内存。常用malloc申请一片内存当元素数量超出容量时用realloc扩容但realloc只能用于之前由malloc/calloc/realloc分配的内存否则是未定义行为。扩容策略一般是倍增比如容量 4 变成 8避免每次都重新分配导致 O(n^2) 的复制开销。C 的std::vector已经帮你处理好了这一切。但你要知道它的内部机制当元素数量达到capacity()时会分配一块更大的内存然后把旧元素逐个拷贝或移动过去再释放旧内存。反复插入大量元素前显式调用reserve(n)能避免多次扩容。如果自己实现一个类似 ArrayList 的结构还要考虑“缩容”问题。很多人在删除元素后不缩容大量场景下内存白白占着这在服务器端可能造成隐性内存压力。合适的做法是在 size 小于 capacity 的四分之一时缩容但注意要权衡频繁缩容带来的性能问题。二维字符数组是另一个高频考点。char names[3][20]能存 3 个字符串每个最长 19 字符留一个位置给 \0。它和char *names[3]不同前者是连续内存复制字符串必须用strcpy不能直接后者每个指针指向独立的字符常量或字符数组指针可以重新指向别的字符串但指向字符串字面量时不能修改内容。很多 C 语言题目专门考这个区别一旦用错轻则内容不对重则段错误。3.3 字符串数组与字符指针的转换细节实际开发中字符串和字符数组的互转也很高频。C 里没有真正的字符串类型只能用字符数组或指针。C 里std::string转const char*用c_str()但要注意c_str()返回的指针在字符串对象被修改或析构后失效。MFC 环境下CString转char数组也是老生常谈。最简单的方式是用CW2A转换宏CString str _T(hello); char buf[64] {0}; memcpy(buf, CW2A(str), str.GetLength());但这里有个细节如果CString是 Unicode 而项目字符集是多字节GetLength()返回的字符数可能和CW2A转换后的字节数不一致稳妥的做法是使用WideCharToMultiByte或者干脆用CStringA存储。我在自己写的工具里一般直接用std::wstring和std::filesystem但老项目维护时这种转换坑确实不少。4. 数组算法高频面试题实战拆解4.1 区间最大值从暴力到树状数组模板数组求区间最大值的题高频到几乎每个面试者都碰到过。最常见的是静态数组多次查询[l, r]范围内的最大值。如果每一次查询都遍历一遍复杂度 O(nq)数据量稍大就超时。我的首选方案是 ST 表Sparse Table预处理 O(n log n)查询 O(1)适合“数组不变、查询很多”的场景。原理是倍增思想st[i][j]表示从下标 i 开始长度为 2^j 的区间的最大值。查询时取k log2(r - l 1)答案是max(st[l][k], st[r - (1k) 1][k])。但 ST 表不支持更新所以动态更新的场景要换线段树或树状数组。树状数组适合“单点更新 区间求和”如果只是求区间最大值树状数组也可以做到 O(log n) 更新和查询。我给出一个常用的求区间最大值的树状数组模板下标从 1 开始#include bits/stdc.h using namespace std; const int N 1e5 10; int n, q; int bit[N]; int a[N]; int lowbit(int x) { return x (-x); } void update(int idx, int val) { a[idx] val; for (int i idx; i n; i lowbit(i)) { bit[i] max(bit[i], val); } } int query(int l, int r) { int ans a[r]; // 至少包含 a[r] while (l r) { // 当 r-lowbit(r)1 l 时可以直接取 bit[r] while (r - lowbit(r) 1 l) { ans max(ans, bit[r]); r - lowbit(r); } // 否则单独比较 a[r]然后 r-- ans max(ans, a[r]); r--; } return ans; }注意传统的树状数组求和模板里query是前缀查询但求区间最大值时不能用“前缀最大值”做差因为最大值不满足可减性所以要配合上面这个从右往左的区间查询逻辑。实际面试时我更推荐先讲线段树因为思路更通用代码虽然长但逻辑清楚树状数组则适合强调常数小、代码短。4.2 三个数组最大乘积别漏掉负负得正题目是给定一个整数数组找出三个数的最大乘积。多数人第一反应是把数组排序取最后三个最大的数相乘。但数组里如果有负数最大乘积可能是两个最小的负数绝对值很大乘以最大的正数。比如[-10, -10, 1, 2]三个最大乘积是(-10) * (-10) * 2 200而不是1 * 2 * (-10)。正确解法是先排序比较arr[n-1] * arr[n-2] * arr[n-3]与arr[0] * arr[1] * arr[n-1]取较大值即可。如果不允许修改原数组可以用一次扫描找出最大的三个数和最小的两个数同样可以 O(n) 解决。这道题考察的其实就是分类讨论和边界意识建议写代码前把正数、零、负数几种情况都跑一遍测试用例。4.3 子集和等于固定值的组合查找“一列数已知固定数值如何确定数组中的哪些数据和等于固定值”这个问题实际上就是经典子集和问题。严格意义上它是 NP 完全问题但在小规模数据下可以用回溯搜索。我的经验是先排序然后 DFS 剪枝一旦当前和大于目标值或者剩余数字全部加上也不够就立即回溯。如果目标值和数组大小都有限可以用 DP 的 bitset 优化。Python 里一个很简洁的写法是用位运算表示可达和def can_partition(nums, target): bits 1 # 二进制第 i 位表示和为 i 是否可达 for x in nums: bits | bits x return (bits target) 1 1但 bitset 只能判断是否可达不能输出具体组合。要输出组合还是得写成回溯def combination_sum(nums, target): nums.sort() res [] path [] def dfs(start, remain): if remain 0: res.append(path[:]) return for i in range(start, len(nums)): if i start and nums[i] nums[i - 1]: continue # 去重前提是数组已排序 if nums[i] remain: break path.append(nums[i]) dfs(i 1, remain - nums[i]) path.pop() dfs(0, target) return res这段代码同时处理了“每个元素只能使用一次”和“结果不包含重复组合”两个要求。注意去重时的条件要看清楚i start而不是i 0因为同一层递归的重复数字才需要跳过不同层可以选相同数字只要它们在原数组的不同位置。4.4 2 的幂判断与滑动窗口高频题判断一个数是否是 2 的幂最简单的位运算方法是n 0 (n (n - 1)) 0。原理是2 的幂次方的二进制表示只有一个 1减去 1 之后这个 1 变成 0后面的位全变成 1做按位与结果必然是 0。这个技巧在数组题里经常作为前置条件比如“在 2 的幂数组中查找某个目标值”。滑动窗口是数组里另一类高频题比如“和大于等于 target 的最短子数组”。核心思想是维护左右指针右指针扩展窗口直到满足条件然后左指针收缩更新最短长度。时间复杂度 O(n)空间 O(1)比暴力枚举所有子数组高效得多。写这类题目时务必注意窗口内元素满足条件后先更新答案再移动左指针。如果数组有负数滑动窗口失效因为窗口和不是单调的需要换前缀和或单调队列。5. 常见问题排查与性能优化实录5.1 数组操作中我踩过的五个坑第一个坑是循环删除导致元素跳过。用for循环从前往后遍历并splice时删除后下一个元素会顶上来下标已经后移结果就是漏删。正确做法是倒序遍历或者每次删除后下标减一但倒序最直观。第二个坑是排序修改原数组。JS 的sort、splice是修改原数组的而 Python 的list.sort()也是修改原数组sorted()才返回新数组。团队协作时如果公共函数无意中修改了传入数组常常会引发隐蔽的 bug。我的习惯是函数签名里明确标记参数是否会被修改或者直接创建副本再操作。第三个坑是深拷贝和浅拷贝。Java 里Arrays.copyOf对一维数组是深拷贝元素是引用类型时拷贝的是引用但对二维数组只是浅拷贝内层数组仍然是同一个引用。修改复制后的二维数组的某个元素会影响到原数组。要真正深拷贝必须逐层copyOf。第四个坑是int溢出。数组元素求和、最大值乘积、乘法结果都可能超出 int 范围。比如三个 10^9 的数相乘结果是 10^27远超 int 上限必须用long或 Python 的无限整数。Java 里如果直接用int会得到荒谬的负数。第五个坑是数组与集合转换后的不可变视图。Java 的Arrays.asList返回的是定长列表看起来是 List但不能add/remove否则抛UnsupportedOperationException。很多人拿它创建列表后顺手就add结果运行时才炸。要可变应该new ArrayList(Arrays.asList(...))。5.2 VBA 数组对比最快的实践心得VBA 在办公场景中处理数据数组是绕不开的。很多人习惯直接读写单元格几百行数据还行上万行就开始卡得不能忍。我的做法是先把区域一次性读入二维数组再把数组一次性写入区域这比循环逐格操作快几个数量级。VBA 数组对比最快的方式其实很有讲究。如果你需要比较两个数组是否相等最直接的方式是转成字符串再比较比如Join(Application.Transpose(arr1), |)但要注意 Join 只支持一维数组二维数组得先转换。更严谨的做法是利用 Excel 自身函数比如Match或Application.Index但性能最好的还是用Scripting.Dictionary或者Collection做哈希匹配。我自己做过一个几千行数据的查重对比需求用双层循环对比 O(n^2) 花了十几秒改用 Dictionary 将 O(n^2) 降为 O(n) 后瞬间完成。经验就是在 VBA 里尽量避免对单元格的频繁访问尽量把数据搬到内存中的数组里处理最后一次性写回。5.3 数组性能优化和内存管理建议数组的性能优化可以从三个层面理解。第一层是空间局部性。数组在内存中连续存储CPU 缓存友好。遍历二维数组时按行遍历通常比按列遍历快得多因为按列遍历会跳着访问内存缓存命中率极低。之前我优化过一个图像处理函数只是把内层循环的顺序从列优先改成行优先耗时降低了 40%。第二层是减少拷贝。在 C 函数中如果只是读取数组参数尽量写成const vectorint避免值传递时整个数组复制一遍。C11 之后可以用移动语义配合return std::move(localVec)实际上编译器会自动 NRVO减少临时对象。第三层是避免动态扩容带来的多次内存分配。给 vector 插入大量数据前先计算好大概长度调用reserve。同样在 JS 中如果频繁push到数组V8 引擎会自动扩容虽然比不上 C 那么明显但大数据量时也有影响。还有一个经常被忽略的经验在 Python 里对于纯数字数组用array.array或numpy.ndarray比list更省内存也更快但numpy的切片返回的是视图而不是拷贝修改视图会影响原数组。如果不希望原数组被修改使用.copy()。这一点我在数据清洗时吃过亏想保留原始数据结果切片时改到了原数组。注意在 C/C 里对数组的越界写不会立刻报错它可能先破坏相邻内存等到某个时刻才引起崩溃或数据错误。因此程序里所有数组下标都必须做边界检查或者使用安全的容器和迭代器。最后再分享一个小技巧。面试或者 LeetCode 刷题时遇到数组相关的题先别急着写代码花 30 秒确认三件事数组长度是否可能为 0元素是否可能是负数或者溢出遍历时是否需要同时修改原数组把这三个问题的答案在代码里体现出来基本就不会犯低级错误。数组这个东西说起来简单但能在一线代码里活下来靠的往往就是这些不起眼的边界意识。
返回列表