ARTICLE DETAIL

资讯详情

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

数组操作全指南:初始化、增删改查、排序去重到树状数组实战

数组操作全指南:初始化、增删改查、排序去重到树状数组实战 我曾经在排查一段 C 服务端代码时遇到一个非常诡异的现象同一个数组在本地环境运行一切正常放到服务器上却偶尔会在初始化阶段产生垃圾值。查到最后问题出在没搞清“数组初始化”在不同语言标准下的真正语义上。那之后我养成一个习惯——拿到一个数组先弄清它的“创建方式、长度计算、遍历边界、内存布局”再动手写业务逻辑。数组这东西看起来是编程里最基础的数据结构但越基础的东西越容易在细节上翻车。这篇文章就围绕“数组的相关常用操作”展开覆盖从基础初始化、增删改查、排序去重到切片分割、指针数组、二维数组再到树状数组这类高阶数据结构。我会尽量把每个操作背后的原理和常见的坑都讲透语言上兼顾 C、C、Python、JavaScript 和 VBA 等不同语言的习惯写法。无论是刚入门的学生还是工作中需要频繁处理数组的开发者应该都能从里面找到用得上的东西。1. 数组初始化的细节不同语言的规则差异决定了你第一行代码怎么写数组初始化的坑十个人里有八个人踩过。C 语言里int a[5];和int a[5] {0};的区别很多人背过但真到代码里还是会写错。C 的std::array和原生数组的初始化行为又不一样。Python 里[0] * 5和[[0] * 5] * 3的语义更是经常让人懵。1.1 C/C 初始化规则未初始化的局部数组是“随机值”全局数组才是零C/C 的局部数组如果只声明不初始化里面存的是栈上的残留值也就是“野值”。这个东西在 Debug 版本里可能表现为0xcccccccc在 Release 版本里就是完全随机的数据。如果你写的是int a[5] {0};编译器会把数组全部清零但如果你只写int a[5] {1, 2, 3};剩下的两个元素会被自动补零这是 C 标准的默认行为。这里有个容易被忽略的点int a[5] {0};只在初始化时有效它和int a[5]; memset(a, 0, sizeof(a));在行为上等价但在语义上有区别。前者是编译期初始化后者是运行期赋值。如果在构造函数里忘了初始化而成员变量是原生数组时你会得到一份“不确定值”的数组——这就是线上偶发 bug 的重灾区。我的习惯是只要涉及原生数组一律显式初始化别依赖编译器心情。1.2 Python 列表初始化乘法语法在二维场景下的陷阱Python 没有原生“数组”常用的 list 在初始化上有一个著名陷阱matrix [[0] * 5] * 3这行代码创建的不是三行独立的列表而是三个指向同一块内存的引用。当你执行matrix[0][0] 1时三行都会变成[1, 0, 0, 0, 0]。正确写法是matrix [[0] * 5 for _ in range(3)]每次循环都新建一个独立的子列表。这个问题在面试里出现频率极高在真实业务里也容易出现——比如你要用二维数组存 Excel 表格数据用乘法初始化然后逐行填值填充结果会“串行”排查起来很花时间。1.3 JavaScript 数组初始化Array(n)和fill的组合拳JS 的数组初始化坑在Array(5)只创建了一个长度为 5 的空数组但没有填充任何值map直接跳过这些空槽位。正确做法是Array(5).fill(0)或者用[...Array(5)].map(...)。// 错误示范map 不会执行 const arr Array(5).map((_, i) i); // 正确姿势 const arr [...Array(5)].map((_, i) i); const arr2 Array.from({ length: 5 }, (_, i) i);Array.from是初始化带规律数组比较优雅的方式它接收一个类数组对象和映射函数语义比展开运算符更清晰。1.4 数组长度计算sizeof、len()、length这些写法别搞混C 语言里sizeof(arr) / sizeof(arr[0])是经典写法但它只对“数组本身”有效。一旦数组作为函数参数传递它就退化成指针sizeof(arr)就变成指针大小。C 里推荐用std::size()或模板推导来规避这个问题。Python 和 JS 里长度计算没有悬念分别是len(arr)和arr.length。但 Python 的len()是 O(1) 操作因为 list 内部维护了长度字段JS 的length同样如此。如果你在处理高频循环时反复调用len()虽然性能影响可以忽略但更好的习惯是在循环前用变量缓存长度。提示C 语言中int a[5] {0};是“值初始化”的惯用写法但如果你写的是int a[5]; a[0] 0;而没有初始化其余 4 个元素这属于未定义行为读它们的结果是随机值。2. 增删改查与动态扩容从静态的枷锁到动态的自由数组的“增加”和“删除”是相对昂贵的操作因为数组在内存中是连续存储的。如果你想在中间插入一个元素后面的所有元素都要向后移动一位。理解了这一点你就知道为什么“动态数组”和“链表”在不同场景下各有优势。2.1 静态数组与动态数组的选择逻辑C 语言的原生数组是静态的大小在编译期就固定了。C 的std::vector是动态数组在堆上分配内存支持自动扩容。Python 的 list 底层也是动态数组扩容策略通常是指数增长倍数扩容。JavaScript 的数组本质上是对象但引擎内部对其做了大量优化可以把它当作动态数组使用。选择静态还是动态取决于你对数据规模的预判数据量小且固定比如一个 3x3 的坐标矩阵——静态数组就够了省去动态分配的开销。数据量未知、需要频繁追加——必须用动态数组但要理解扩容机制。std::vector扩容时会申请一块更大的内存把旧元素拷贝过去再释放旧内存。如果你在循环里反复push_back就会反复触发扩容。优化方式是提前reservestd::vectorint v; v.reserve(10000); // 提前分配足够空间避免扩容 for (int i 0; i 10000; i) { v.push_back(i); }2.2 循环队列的经典实现q[m]数组 rear和length热搜词里有一个非常经典的数据结构题假设以数组q[m]存放循环队列中的元素同时以rear和length分别指示环形队列中的队尾元素位置和队列中元素个数。这个设计里不需要front指针只要知道rear和length就能推出队头位置// 队头位置 (rear - length m) % m int front (rear - length m) % m;入队时q[rear] x; rear (rear 1) % m; length;出队时front (rear - length m) % m; x q[front]; length--;这里取模运算的意义在于让数组下标在逻辑上“绕圈”。很多人写循环队列容易忽略 m这个步骤导致rear - length是负数时取模结果变成负数。这个细节在面试手写代码时是加分项在实际嵌入式开发里用数组模拟环形缓冲区更是常见需求。2.3 Python 列表的增删操作对比Python 的append、insert、pop在时间复杂度上有本质区别append(x)— O(1) 均摊因为动态数组尾部插入通常不需要移动元素。insert(0, x)— O(n)在头部插入所有元素都要后移。pop()— O(1)pop(0)— O(n)所以如果业务逻辑中经常需要在头部操作使用collections.deque或维护一个“反转后的列表”会更合适。一个真实场景处理日志流时要把最新一条日志放在最前面。用insert(0, msg)在数据量大时性能很差改成append(msg)然后展示时reversed(logs)就高效得多。3. 排序与去重业务开发中出现频率最高的两类操作说数组操作里排序和去重是“半壁江山”一点不过分。前端要排序展示、后端要处理去重、算法竞赛里排序是基础。但这里面的细节很多一个是“排序稳定性”一个是“去重对象数组时到底以什么为基准”。3.1 JS 数组排序sort()默认转字符串数字排序必须传比较函数JavaScript 的Array.prototype.sort()在默认情况下把元素转为字符串比较。这意味着[10, 9, 100].sort()的结果是[10, 100, 9]而不是[9, 10, 100]。正确写法// 升序 arr.sort((a, b) a - b); // 降序 arr.sort((a, b) b - a);sort在 V8 引擎中对于小规模数组使用插入排序规模大时使用 TimSort是稳定排序。这一点很有价值如果你先按姓名排序再按年龄排序第二次排序不会破坏第一次排序的结果。3.2 数组去重Set、filter、reduce 各有优劣最简洁的数组去重方式const unique [...new Set(arr)];但这种方式对对象数组无效因为对象引用不同Set比较的是引用地址而非内容。对象数组去重要么用Map以某个字段为键const map new Map(arr.map(item [item.id, item])); const unique [...map.values()];要么用filter配合 findIndexconst unique arr.filter((item, index, self) self.findIndex(i i.id item.id) index );filter这种写法在数组很大时有性能问题因为findIndex是 O(n)整体是 O(n^2)。大数据量还是用Map方案稳妥。3.3 “数组分割并显示包含某一字符”的实现思路这个热搜词指向的需求很场景化假设你有一组字符串想按分隔符拆成数组然后筛选出包含某个特定字符的元素。在 Python 里一条链式操作就完成data apple,banana,grape,watermelon # 分割 items data.split(,) # 筛选包含 ap 的元素 result [item for item in items if ap in item]在 Excel 场景下VBA 里对应的是Split函数和Filter函数的组合Dim arr As Variant arr Split(apple,banana,grape,watermelon, ,) Dim result As Variant result Filter(arr, ap, True)直接在内层循环里先判断再入数组会比先完整分割、再完整过滤少一次遍历。4. 数组切片与多维访问Python、Excel、MATLAB 里的高频操作切片slice是 Python 数组操作里极具表现力的功能而多维数组的访问方式在 C、MATLAB、Excel 里各有各的“方言”。掌握这些差异可以帮助你在多语言开发中互相翻译思路。4.1 Python 数组切片[start:stop:step]的完整语义基础用法a [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] a[2:5] # [2, 3, 4] a[:3] # [0, 1, 2] a[5:] # [5, 6, 7, 8, 9] a[::2] # [0, 2, 4, 6, 8] a[::-1] # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] 反转很多人只记了最常见的[start:stop]但切片中有一个隐藏语义stop是“排除边界”即左闭右开区间。a[2:5]拿到的下标是 2、3、4不含 5。刚接触时经常在这里出现 off-by-one 错误。切片还有一个特点它返回的是新列表不是视图。这意味着修改切片后的新列表不会影响原列表。但如果元素本身是可变对象比如嵌套列表切片内元素和原列表共享引用修改子元素会同时反映到原列表上。Python 的切片在多维场景下配合 NumPy 更加威力十足import numpy as np mat np.arange(12).reshape(3, 4) # array([[ 0, 1, 2, 3], # [ 4, 5, 6, 7], # [ 8, 9, 10, 11]]) mat[:, 1:3] # 取所有行的第 1 到 2 列4.2 Excel/VBA 里“提取前两列匹配的数据成一个数组”热搜词里有一条很具体的需求“excel 提取前两列匹配的数据成一个数组”。这个在数据处理中非常常见比如有两个 sheet一个存用户 ID 和姓名另一个只有用户 ID你想匹配出姓名。使用 VBA 数组方式先把数据读入数组避免逐单元格访问Dim arr As Variant arr Range(A1:B100).Value 一次性读入二维数组 Dim dict As Object Set dict CreateObject(Scripting.Dictionary) Dim i As Long For i 1 To UBound(arr, 1) dict(arr(i, 1)) arr(i, 2) Next i字典对象在这里的作用是建立“按键查找”的哈希表查询复杂度 O(1)比嵌套循环快得多。如果数据量是几万行逐单元格匹配会卡到怀疑人生而数组加字典的方式几乎是瞬时的。4.3 MATLAB 数组多列提取与维度排列MATLAB 里数组默认列主序和 Python 的行优先完全不同% 取出矩阵的第 2 列和第 4 列 data rand(10, 5); cols data(:, [2 4]);这里的:表示取所有行[2 4]是列索引向量。这个语法与 Python 的data[:, [1, 3]]几乎一一对应只是索引从 1 开始。如果你在 MATLAB 和 Python 之间切换最容易出的错就是索引从 0 开始还是从 1 开始以及end关键字在不同语言里怎么处理。4.4 数组转字符串与字符串转数组Python:.join(arr)和s.split(,)JavaScript:arr.join()和s.split(,)C: 手动std::ostringstream或用std::string循环拼接C 里循环拼接在数据量大时性能差因为std::string可能反复重新分配内存。可以用reserve预留空间std::string s; s.reserve(total_len); for (const auto str : arr) { s str; }5. 指针数组、数组指针与多维数组C/C 里的拦路虎C/C 的数组复杂在“数组名即指针”这个模糊地带。热搜词里“指针数组”“数组指针”“C 多维数组指针”“指针数组存放字符串”放一起正好把这块知识串成一条线。5.1 指针数组 vs 数组指针一个星号位置的差别天壤之别指针数组int *arr[5]— 数组里有 5 个元素每个元素是一个int*指针。数组指针int (*arr)[5]— arr 是一个指针指向一个含有 5 个int的数组。在写法上[]的优先级高于*所以int *arr[5]先被解释成“arr 是数组”元素是指针而int (*arr)[5]加了括号后arr 先被解释为“指针”指向的对象是大小为 5 的 int 数组。5.2 指针数组存放字符串命令行参数的经典实现“指针数组存放字符串”的典型场景就是main(int argc, char *argv[])。这里的argv就是一个指针数组每个元素指向一个以\0结尾的字符串int main(int argc, char *argv[]) { for (int i 0; i argc; i) { std::cout argv[i] std::endl; } return 0; }注意argv[0]是程序名本身argv[1]才是第一个参数。另一个场景是在函数内部维护一个固定大小的字符串字典const char *colors[] {red, green, blue};这里数组元素是const char*字符串常量存放在只读区不能通过数组元素去修改内容但可以修改指针让它指向别的字符串。5.3 C 多维数组指针二维数组如何被访问定义一个二维数组int arr[3][4];arr数组名在表达式中会退化为指向“包含 4 个 int 的数组”的指针即int (*)[4]。如果定义一个数组指针来操作int (*p)[4] arr; // 访问 arr[1][2] p[1][2]; // 等价于 *(*(p 1) 2)这里p 1在指针运算中加了sizeof(int[4])个字节也就是一行的大小。理解维度的“跳跃步长”是多维数组指针的关键。同样的逻辑也存在于函数传参时void printMatrix(int (*mat)[4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { std::cout mat[i][j] ; } std::cout \n; } }如果不能确定中间维度的长度可以用二维 vector 或一维数组加手动索引。5.4 C 语言数组变量的类型转换热搜词里有一条“c语言数组变量的类型转换”这个需要特别提醒数组名在大多数表达式中会“退化”成指向首元素的指针但有两个例外——sizeof和运算符。sizeof(arr)返回整个数组的大小而sizeof(ptr)返回指针的大小。arr的类型是int (*)[N]指向整个数组而arr的类型是int*指向首元素。以下两个地址值相同但类型不同指针运算步长也不同int arr[5] {0}; int *p1 arr; // p1 1 加 1 个 int int (*p2)[5] arr; // p2 1 加 5 个 int如果要对数组进行“整体类型转换”C 语言中直接的(int*)arr是允许的它把数组指针强制转换成 int 指针。但这样做等于绕过了编译器的类型检查跨维度地解读内存应谨慎使用。大部分情况下你并不需要做这种转换而是应该用结构体或std::array来保留维度信息。6. 从基础操作到算法进阶排序之外的数组高阶玩法热搜词里有几条明显带有算法竞赛色彩“树状数组模板”“三个数组最大的乘积”“如何确定数组中的哪些数据和等于固定值”“二维数组 一列数 已知固定数值”。这些内容放到一起正好构成“数组从工具到算法”的进阶路径。6.1 树状数组单点修改、前缀和查询的分治思想热搜词里有一个具体问题树状数组维护长度 n 16 的序列。查询前缀和 sum(11) 与单点修改 add(3, x) 分别...树状数组Fenwick Tree的核心操作就是这两个add(i, x)单点更新、sum(i)前缀和查询。它利用lowbit将一个长度为 n 的数组划分成一棵“虚拟的树”。lowbit(i) i (-i)表示 i 的二进制中最低位的 1 所对应的值。典型实现int bit[17]; // 下标从 1 开始n 16 int n 16; void add(int idx, int delta) { while (idx n) { bit[idx] delta; idx idx (-idx); } } int sum(int idx) { int res 0; while (idx 0) { res bit[idx]; idx - idx (-idx); } return res; }当 n 16 时查询sum(11)的过程是这样的11 的二进制是 1011lowbit(11)是 111 - 1 1010 的二进制是 1010lowbit(10)是 210 - 2 88 的lowbit是 88 - 8 0。所以sum(11)是bit[11] bit[10] bit[8]的和。add(3, x)的更新过程则是反向的3 - 4 - 8 - 16 的节点加上 x。这个数据结构在算法竞赛中很常用但实际业务里如果你需要频繁区间求和、动态修改数据它也比普通数组高效得多。理解它的关键在于掌握lowbit的规律而不是死记代码。6.2 组合和问题从数组中挑出哪些数字之和等于固定值“已知固定数值如何确定数组中的哪些数据和等于固定值”这种需求对应的是“子集和”问题也是背包问题的变种。一个常见场景是财务对账有一组账单金额想找出哪些账单组合出某个汇总金额。最简单但仅适用于小数据量的方法是递归回溯def find_sum(nums, target): res [] def dfs(index, current_sum, path): if current_sum target: res.append(path[:]) return if current_sum target or index len(nums): return # 不选当前数 dfs(index 1, current_sum, path) # 选当前数 path.append(nums[index]) dfs(index 1, current_sum nums[index], path) path.pop() dfs(0, 0, []) return res数据量在 20 以内时回溯法可行。数据量上百时就必须用动态规划。这本质上是 0-1 背包问题的“恰好装满”变体。实际项目中我通常会先看目标值和数组规模再决定用哪种策略避免一上来就写一个 O(2^n) 的递归把自己卡死。6.3 三个数组最大的乘积贪心与排序的结合“三个数组最大的乘积”也是一道常考算法题。如果只从单一数组中取三个数求最大乘积答案只取决于排序后最大三个数的乘积或者最小两个负数与最大正数的乘积nums.sort() prod1 nums[-1] * nums[-2] * nums[-3] prod2 nums[0] * nums[1] * nums[-1] print(max(prod1, prod2))为什么还要考虑两个负数因为负数乘负数为正两个绝对值大的负数乘一个最大的正数可能比三个正数的乘积还大。如果题目说的是“三个数组”中分别取一个数求最大乘积那思路类似但要先对每个数组排序或维护最值。这道题的价值在于提示我们——看到“最大乘积”时不要只盯着正数负负得正是常见突破口。6.4 VBA 数组对比最快方法字典查找索引热搜词“vba数组对比最快”指向的场景通常是两个数组互相匹配。比如 A 数组有 50000 个 IDB 数组有 50000 个 ID要找交集或差异。最朴素的嵌套循环是 O(n^2)甚至会卡死 Excel。最快的做法是把其中一个数组放进字典哈希表然后遍历另一个数组查询Dim dict As Object Set dict CreateObject(Scripting.Dictionary) For i LBound(arr1) To UBound(arr1) dict(arr1(i)) True Next i Dim result As Variant ReDim result(1 To UBound(arr2) - LBound(arr2) 1) Dim cnt As Long cnt 0 For j LBound(arr2) To UBound(arr2) If dict.exists(arr2(j)) Then cnt cnt 1 result(cnt) arr2(j) End If Next j这套思路在任何语言里都一样减少遍历嵌套用哈希结构换取时间。数组操作里的性能瓶颈绝大多数不是数组本身慢而是你用错了查找方式。7. 实操中的几个“血泪教训”与经验建议聊了这么多基础与进阶操作最后分享几个我在实际项目里踩过的坑和总结出的经验。7.1 数组下标越界的问题总是“延迟爆发”C/C 里数组越界是未定义行为但它不会立刻崩溃而是可能在你操作了另一个变量之后才炸。因为越界写的是相邻内存。这个非常坑因为问题往往不在崩溃点而在越界时就已经被污染。用std::vector的at()方法可以捕获越界但它有性能开销。我的建议是在 Debug 阶段尽量开启编译器的越界检查开关比如-fsanitizeaddress跑一轮测试把钱全花在这里再切到 Release。7.2 数组去重时不要只看“外观相等”如果一个数组内部是复杂对象两个对象即使看起来字段差不多也可能因为内存地址不同而被Set去重失败。这时候要明确定义“相等”的标准是 ID 相同还是所有字段相同。JavaScript 的深度比较需要自己实现Python 可以通过重写哈希或使用不可变类型来正确处理。7.3 用数组存数据前先想好“频率”同样是读取一组数据如果只需要读一次挨个遍历即可如果需要对同一数据多次随机访问数组的 O(1) 随机访问很有优势如果需要频繁插入和删除多考虑链表或哈希表。实际上我自己处理日志数据时就经常在数组和哈希表之间切换因为日志去重用哈希快按时间排序用数组方便。7.4 不要排斥把一维数组当二维用C/C 的二维数组在内存中本来就是连续的你可以用一维索引访问二维逻辑下标int arr[3][4] {0}; // 等价于 int *p arr[0][0]; p[1 * 4 2] 10; // 逻辑上访问 arr[1][2]在性能敏感场景下这种写法能避免多层指针解引用。很多图像处理库的内部都是这么干的一行一行的连续存储用行数和列数计算偏移量。它的正确性依赖你精确掌握行优先存储的顺序出错时也比较隐蔽所以我在工程中一般先用二维写法保证正确性profile 之后确认瓶颈再改一维。7.5 树状数组的边界条件先画图再写代码我第一次写树状数组的add和sum时背的是模板代码写完之后能跑但换个题就懵。后来把 n 8 的树画出来把lowbit路径一条一条写出来才真正理解为什么add是从当前节点向上加到 nsum是从当前节点向下减到 0。凡是这类有位运算技巧的数据结构建议先在小样例上手动推演多推几次就会形成肌肉记忆。数组这个主题往浅了说就是“连续内存 下标访问”往深了说能延伸到指针、内存模型、算法优化。实际工作中你会发现把数组的基础操作打磨到条件反射的程度可以省下大量排错时间。无论是用 C 处理底层数据用 Python 做数据分析用 JavaScript 操作前端列表还是用 VBA 批量处理表格核心的“增删改查、排序去重、切片映射”都是同一套逻辑。语言只是表达差异思维方式是共通的。
返回列表