数组这玩意儿,表面上看是每种语言入门第一课就教的东西,但真到项目里用起来,坑一个接一个。我在后端处理接口数据、前端操作表格、甚至用Excel VBA整理报表的时候,都吃过数组的亏。前一篇写了基础的定义、遍历和下标访问,这篇《数组(二)》咱们直接进入真实使用场景,聊聊切片取值、指针数组、对象去重、动态扩展,最后拆一道和数组配对相关的构造题。内容偏实战,适合已经能写for循环、但对数组的“进阶操作”还不太有把握的人。
1. 切片与取值:语言的设计哲学决定你的写法
1.1 Python切片的“半开区间+步长”到底怎么记
Python的切片语法是arr[start:stop:step],其中stop位置是不包含的。很多人一开始不习惯“取不到结尾”这个设定,但你只要记住一个生活类比:半开区间[start, stop)就像“从第start个人开始点名,点到第stop-1个人为止,第stop个人不点”。这个设计其实是为了让arr[:n]和arr[n:]能无缝拼成完整数组,也让len(arr[:i])天然等于i,写代码时少一堆+1、-1的纠结。
具体到实操,几个高频切片写法我直接列出来:
arr[::-1]:整个数组逆序,效果等同reversed(arr),但返回的是新列表。arr[::2]:取偶数下标元素,也就是每隔一个取一个。arr[1:7:2]:从下标1到下标6(注意7取不到),步长为2。- 二维数组取列:
matrix[:, 1]是NumPy的写法,原生Python列表只能写成[row[1] for row in matrix]。
这里有一个特别容易踩的坑:Python列表切片出来的新列表,里层元素还是引用。比如b = a[1:3],如果a里存的是可变对象(比如子列表),那b里改子元素,a也会跟着变。而NumPy的切片更狠,它直接返回原数组的视图,连新数组都不算,改视图就是改原数组。我当年刚接触NumPy时在这个上面翻过车,所以现在凡是做切片后要原地修改的,都会先确认一下是视图还是副本,不行就显式调用.copy()。
1.2 JS的slice、splice、split三兄弟,别再混了
JavaScript里跟“切数组”相关的有三个方法,名字长得像,行为差很多:
slice(start, end):不修改原数组,返回一个从start到end-1的新数组,end缺省时取到末尾。splice(start, count, ...items):直接修改原数组,从start开始删掉count个元素,还可以插入新元素。返回值是被删掉的元素组成的数组。split:这是字符串的方法,不是数组的,作用是把字符串按分隔符拆成数组。
“js怎么取出数组”这种需求,实际项目里最常见的是提取数组的一部分。正确姿势是arr.slice(1, 4),而不是arr.splice(1, 4),后者会把原数组改掉,容易引发连锁副作用。ES6之后,还经常用解构来取数组对象里的某些字段,比如从对象数组里提取所有id组成新数组:
const ids = arr.map(item => item.id); const firstThree = arr.slice(0, 3);如果要把数组转成字符串,也值得单独说一下。arr.join(',')是按指定分隔符拼字符串,String(arr)等价于arr.join(','),但嵌套数组转出来会带逗号,格式可能不是你要的。想要调试时看全貌,JSON.stringify(arr)最靠谱,尤其是数组里套对象的时候。我见过有人用arr.toString()去拼SQL查询参数,结果是对象数组全变成[object Object],排查半天才发现是这里出了问题。
1.3 MATLAB取多列:一种矩阵思维的降维打击
Python和JS都是“行优先”视角,取一行很容易,但取一列就得想办法。MATLAB不一样,它天生就是矩阵思维,A(:, 2:4)直接取出所有行的第2到第4列,返回一个子矩阵。这个语法用起来是真的爽,但移植到别的语言时容易水土不服——Python里没有对应的原生语法,NumPy里则是arr[:, 1:3],底层是切片视图。
我把三种语言的常用取值方式整理成了一张表,做跨语言开发时对照着看很方便:
| 场景 | Python | JavaScript | MATLAB |
|---|---|---|---|
| 取前n个元素 | arr[:n] | arr.slice(0, n) | A(:, 1:n)(按列) |
| 逆序 | arr[::-1] | arr.slice().reverse() | flip(A) |
| 每隔一个取 | arr[::2] | arr.filter((_, i) => i % 2 === 0) | A(:, 1:2:end) |
| 取第2列 | [row[1] for row in arr] | arr.map(row => row[1]) | A(:, 2) |
这里有一个通用经验:切片的本质是对“数组的视图”进行操作,而不同语言的视图机制完全不同。Python原生切片是副本,NumPy切片是视图,MATLAB切片多数是值拷贝。写跨语言脚本时,第一步就该确认目标语言是值语义还是引用语义,否则后面全是在debug。
2. 数组与指针:C语言里你避不开的那些细节
2.1 指针数组、数组指针和函数指针数组,谁是谁
C语言的数组一旦和指针搅在一起,就变成劝退现场。先说最常见的两个:指针数组和数组指针。
- 指针数组:
int *p[3],它是一个数组,数组里每个元素是int*。[]的优先级高于*,所以p先和[3]结合成数组,再和*结合表示元素是指针。 - 数组指针:
int (*p)[3],它是一个指针,指向一个长度为3的int数组。因为加了括号,p先和*结合成指针,再指向一个“有3个int的数组”。
指针数组最常见的用途是存放字符串。比如const char *names[] = {"Alice", "Bob", "Cindy"};,这里names是一个指针数组,每个元素指向一个字符串字面量。遍历的时候就是:
for (int i = 0; i < 3; i++) { printf("%s\n", names[i]); }函数指针数组也是同样的思路,它本质是一个元素为函数指针的数组,常用来实现“转移表”,替代一堆if-else。举个例子:
int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int (*ops[2])(int, int) = {add, sub}; int result = ops[0](3, 5); // 调用add(3, 5)实际项目里,命令解析器特别喜欢用这种结构:命令编号作为下标,直接定位处理函数,比一长串switch清爽得多。不过写之前一定要记得数组下标越界就是未定义行为,转移表的边界检查要做在入口。
2.2 字符数组与字符串初始化:char s[]和char *s不是一回事
“c++字符串数组初始化”是新手高频问题,但很多人没搞清底层差异。看这两行:
char s1[] = "hello"; // 字符数组,内容可修改 const char *s2 = "hello"; // 指针指向字符串字面量,内容只读s1是一个有6个元素的数组(结尾隐含'\0'),sizeof(s1)等于6,修改s1[0]没问题。s2是一个指针,sizeof(s2)在64位系统上是8,指向的字符串字面量位于只读区,试图修改*s2会导致运行时错误。我在教学时经常说:char *s是“拿着纸条找到别人的家门”,char s[]是“把字刻在自己家的墙上”。前者方便指向和传递,后者才是真正拥有这份数据。
二维字符数组也很常用,比如存储一批姓名:
char names[3][10] = {"Alice", "Bob", "Cindy"}; // 每行最多9个有效字符它和const char *names[3]的区别在于:二维字符数组在连续内存里存放,每行长度固定,适合排序、交换行;指针数组每行可以指向不同长度的字符串,适合只读场景,交换元素只换指针,效率更高。
再说说“如何输入char数组”。用scanf("%s", buf)最省事,但它会读到空白字符就停,而且不校验长度,输入一长就缓冲区溢出。推荐用fgets(buf, sizeof(buf), stdin),它能限制读取长度,把安全边界控制住。实测中很多人不读fgets的返回值,导致输入失败时还在处理旧数据,这个小细节能在关键时刻省你两小时调试时间。
2.3 数组名的衰减:为什么arr和&arr[0]长得一样
C语言里,数组名在绝大多数表达式里会“衰减”成指向首元素的指针。所以sizeof(arr)能算出整个数组的大小,但一旦把arr作为参数传给函数,在函数内部sizeof(arr)就变成指针的大小了,因为参数的数组写法本质上还是指针。
void print_len(int arr[]) { int n = sizeof(arr) / sizeof(arr[0]); // 错误!arr已经衰减成指针 }正确的做法是额外传一个长度参数。这就是为什么C语言里凡是“数组作为函数参数”的标准写法都是void f(int *arr, int n),你写int arr[]只是给人看的,编译器眼里是一样的。
关于“c语言数组指针移动指定位输出字符”,核心就是指针算术:char *p = str + offset;然后从p开始输出。这里有个容易错的地方,指针加法是按元素大小来移动的,char类型移动1就是1个字节,int指针移动1就是4个字节。我当年做协议解析时想跳过N个字节,直接int *p = base + N,结果跳了4N个字节,数据全错位了。后来就养成了习惯:先确认指针的基类型,再决定偏移量。
还有数组变量的类型转换。C语言里数组没有直接的赋值语义,你不能写arr1 = arr2,但可以用指针指向它,也可以把数组强制转换成字节指针来做序列化:
int arr[] = {1, 2, 3}; unsigned char *bytes = (unsigned char *)arr; // 按字节访问底层存储这种用法在嵌入式通信、网络协议栈里很常见。但要注意机器字节序的问题:大端和小端机器上,同一个int的字节排列是不同的。跨平台发送数据时,最好用显式的位运算组装字节,而不是靠强转。
顺带提一句“结构体定义数组”和“宏定义数组”。结构体数组很简单:struct Student stu[10];就是数组里每个元素都是一个结构体,初始化可以写成struct Student stu[2] = {{"Alice", 18}, {"Bob", 20}};。宏定义数组就是#define MAX_SIZE 10配合int arr[MAX_SIZE];,好处是改一个宏定义就能全局调整容量,坏处是宏不检查类型,#define SIZE 10u这种写法更稳妥。
3. 去重这项基本功:从Set到对象数组
3.1 简单数组去重与Set的适用边界
数组去重大概是前端面试里出现频率最高的题。最简单的方式是用Set:
const unique = [...new Set(arr)];Set底层是哈希结构,长度n的去重复杂度接近O(n),比filter加indexOf的O(n^2)好得多。“数组去重”看着简单,但有两个边界问题:
NaN在Set里被认为等于它自己,所以能被正确去重;而indexOf查找NaN永远返回-1,filter方案会保留多个NaN。- 两个内容相同但引用不同的对象,在
Set里是不相等的,所以Set对对象数组去重无效。
Python里类似的去重就是list(set(arr)),但同样只对可哈希元素有效。如果元素是列表(不可哈希),就得写成[dict(t) for t in {tuple(d.items()) for d in arr}]之类的式子,或者自己写循环。
3.2 对象数组去重:Map才是正解
“对象数组去重”是真正业务里最常见的需求:后端返回一批数据,每一条有一个唯一的id,要根据这个id去重。这时候Set派不上用场,因为每个对象引用都不同。正确姿势是用Map:
const arr = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 1, name: 'Alice2' } ]; const map = new Map(); arr.forEach(item => { if (!map.has(item.id)) { map.set(item.id, item); } }); const unique = [...map.values()];这个思路的本质是:用一个“主键”把对象映射成唯一标识,再靠Map的哈希能力去重。如果数据量小,也可以写成一行:
const unique = [...new Map(arr.map(item => [item.id, item])).values()];我在实际开发中更推荐前一种写法,因为后面大概率还要加“保留最后一条还是第一条”之类的规则,写循环更好维护。ES6里提取数组对象的一部分字段也常用到Map,比如arr.map(item => ({ id: item.id, name: item.name })),这本质就是“投影”。
3.3 接口数据里的数组处理:JSON数组与PHP对象数组
跨语言处理数组,最典型的就是接口返回JSON数组。前端拿到[{"id":1,"name":"Alice"}, ...],要提取某一列、去重、分组。JS里的map、filter、reduce就是干这个的。分组需求用reduce很顺手:
const grouped = arr.reduce((acc, item) => { (acc[item.category] = acc[item.category] || []).push(item); return acc; }, {});服务端如果用的是PHP,入口是json_decode($json, true),得到的就是关联数组。PHP的数组其实是个“有序字典”,既能当下标数组用,也能当对象用。从数据库查出来的记录集,配合array_column($list, 'name')可以直接提取某一字段为数组,array_map则可以对数组做批量回调处理。Java那边则常用Collectors.groupingBy配合Stream流做分组:
Map<String, List<Item>> grouped = list.stream() .collect(Collectors.groupingBy(Item::getCategory));“json数组”、“php接口数组对象”、“group()+数组java”这几个热词背后其实是同一个场景:跨语言处理结构化的数组数据时,先确定数组元素的“形状”,再选择对应的批量操作方法。数据形状变了,操作方式立马跟着变。
4. 让数组活起来:动态数组、VBA数组与树状数组
4.1 VBA数组为什么总被吐槽“长度写死”
VBA里的数组,默认你得先声明大小:
Dim arr(1 To 10) As Integer这个固定大小让很多人头疼,因为Excel的数据行数是不确定的。解决办法是ReDim Preserve:
ReDim arr(1 To n)但ReDim Preserve有个限制:只能改变最后一维的大小。想往数组里动态追加元素,每次都得手动检查当前容量、Preserve重新声明,再填值。这就是为什么VBA写数组操作总是又臭又长。原因在于VBA的数组是静态内存模型,不像Python的列表会自动扩容。动态数组的本质是“容量不足时重新申请一块更大的内存,把旧数据搬过去”,只是高级语言把这一步封装好了。
Oracle里的变长数组VARRAY也是类似的思路:它在定义时给一个上限,但使用时长度可以变化,相当于数据库层面的“动态数组”。和VBA不同的是,VARRAY主要存在数据库里,适合存储固定顺序的小规模数据,不适合做大量增删。
4.2 动态数组在Python、C++、JS中的表现
真正要高效地“往数组里增加元素”,还得看现代语言的内置结构:
- Python:
list.append()是摊销O(1)时间,因为它内部有成倍扩容策略。pop()、insert()也都很方便。 - C++:
std::vector<T>,push_back()在容量不足时会重新分配内存并把旧元素拷贝或移动过去。C++11之后移动语义可以减少拷贝,性能提升很明显。 - JavaScript:
Array本身就是动态的,push()、splice()随便用,不需要声明长度。 - C语言:没有内置动态数组,要么用
malloc+realloc自己管理内存,要么借用第三方的容器库。
数组初始化这里也值得多说一句。Python里[0] * n会生成n个0的列表,注意里层元素如果是对象,[obj] * n出来的n个元素是同一个引用,修改任意一个等于改全部。C++里vector<int> v(n, 0)就是n个0。JS里new Array(n).fill(0)才是安全的,光写new Array(n)会得到n个空位,map都不会遍历空位。
4.3 树状数组:用一个数组做区间查询
讲一讲“树状数组模板”,这算是数组操作里的算法进阶。树状数组(Fenwick Tree)用一个数组tree来维护原数组a的前缀信息,支持两类操作:单点修改、区间查询。
核心是lowbit(x) = x & (-x),它取出x最低位的1所表示的数值。修改时,从下标i开始,每次i += lowbit(i)更新父节点;查询前缀和时,从下标i开始,每次i -= lowbit(i)累加。模板如下(C++):
int n; int tree[100005]; void add(int idx, int val) { while (idx <= n) { tree[idx] += val; idx += idx & (-idx); } } int prefixSum(int idx) { int res = 0; while (idx > 0) { res += tree[idx]; idx -= idx & (-idx); } return res; } // 区间[l, r]的和 int rangeSum(int l, int r) { return prefixSum(r) - prefixSum(l - 1); }用树状数组做单点修改+区间查询,单次操作是O(log n),比普通数组O(n)求区间和快得多。我第一次看树状数组时觉得特别反直觉,后来想通了一个类比:它就像你记账时把账目分成几叠,每叠的金额已经汇总好放在文件夹上,你查总账时不用一张张数,只需要翻几个文件夹加一下。需要统计某个区间的数据动态变动的场景,比如求逆序对、实时排行榜、频率统计,树状数组都很好用。
5. 一道构造题说透数组的“奇偶配对”:2n个正整数求gcd>1
5.1 题面拆解与为什么答案一定存在
来看一道比较完整的数组构造题,热词里“小美的数组删除”也是类似风格:
给定一个由2n个正整数组成的数组a。你要先恰好舍弃其中两个元素,再将其余元素两两配对。每对元素的和构成新数组b的一个元素,因此b恰好有n-1个元素。构造一种配对方式,使b中所有元素的最大公约数大于1。可以证明,答案一定存在。
这道题乍一看吓人,本质上考查的是奇偶性分析。两个整数相加的奇偶性规律只有三条:
- 奇数 + 奇数 = 偶数
- 偶数 + 偶数 = 偶数
- 奇数 + 偶数 = 奇数
要让每一项的和都有公约数,最简单的方法是让每一项都是偶数,这样每一项都能被2整除,整个数组的最大公约数至少是2。这就把问题转化成了“如何舍弃两个元素,使剩下的所有数能两两配对成奇-奇或偶-偶”。
关键在于剩余元素里奇数的个数必须是偶数。如果剩余奇数个数为奇数,无论怎么配对,必然有一对是奇-偶组合,配对结果是奇数,就破坏“全都为偶数”的构造了。
5.2 奇偶分离构造法
先统计原数组中奇数的个数cnt_odd,偶数的个数cnt_even = 2n - cnt_odd。分两种情况处理:
cnt_odd是偶数:只要保证舍弃两个元素后奇数个数仍是偶数即可。最简单的是舍弃两个偶数(如果偶数数量足够);如果偶数不够2个,即数组几乎全是奇数,那就舍弃两个奇数,剩余奇数个数依旧为偶数。cnt_odd是奇数:舍弃一个奇数和一个偶数,剩余奇数个数就从奇数减1变成偶数。
舍弃完成后,把剩余元素分成两个集合:奇数集合和偶数集合。然后在每个集合内部两两配对。奇数+奇数是偶数,偶数+偶数也是偶数,所以新数组b的所有元素都是偶数,最大公约数至少是2,条件达成。
这个构造里最妙的一点是:我们根本不需要关心具体谁和谁配对,只要保证“奇数个数为偶数”这个前提。因为同一个集合内部元素数量是偶数时,总能全部两两配对,不会剩下落单的。手算几个例子验证,比如a = [1, 2, 3, 4](n=2,cnt_odd=2是偶数),舍弃两个偶数2和4,剩下[1, 3],配对和是4,偶数,gcd=4>1。如果舍弃两个奇数,剩下[2, 4],配对和6,一样成立。
5.3 完整代码与类似题的延伸
代码实现很直接,Python版本如下:
def solve(a): odd = [x for x in a if x % 2 == 1] even = [x for x in a if x % 2 == 0] if len(odd) % 2 == 0: if len(even) >= 2: a.remove(even[0]) a.remove(even[1]) else: a.remove(odd[0]) a.remove(odd[1]) else: a.remove(odd[0]) a.remove(even[0]) odd = [x for x in a if x % 2 == 1] even = [x for x in a if x % 2 == 0] pairs = [] for i in range(0, len(odd) - 1, 2): pairs.append((odd[i], odd[i + 1])) for i in range(0, len(even) - 1, 2): pairs.append((even[i], even[i + 1])) return pairs注意代码里要先根据奇偶个数决定舍弃谁,舍弃之后必须重新收集奇偶分组,因为原来的列表引用已经变了。这道题能推广到一类构造题:题目给一个数组,要求经过某些操作后满足某个整体性质,优先往奇偶性、同余、整除这些数论特征上想。类似风格的还有“2的幂数组”问题,本质也是找一个统一的整除因子;“是否同构”类的题目则往往先把条件写成数学表达式,再判断是否存在某个整数x满足关系,核心思路是化简条件后用取模或差分统一处理。
这类构造题在实际开发中可能不会天天遇到,但做一道能帮你建立“从宏观性质入手,而不是急着模拟过程”的思维习惯。我在公司带新人时发现,很多人拿到题就开始模拟配对流程,各种边界条件处理不完;反过来先问“目标是什么、什么情况下必然满足”,思路一下子就清晰了。数组这个东西,基础语法只是入口,真正拉开差距的恰恰是这种“站在性质层面看待数据”的视角。