拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

力扣刷题必备:一维指针数组与数组指针辨析实战

力扣刷题必备:一维指针数组与数组指针辨析实战

刷题时把指针和数组搞混,翻车的次数比编译报错还多。今天这篇笔记,我专门整理一下一维指针数组的辨析,结合力扣里那些需要通过指针数组处理字符串、分组、动态内存的场景,把容易踩的坑一次说清楚。

先交代背景:这是我2026年1月23日的力扣刷题记录,主要围绕“一维指针数组”做专项梳理。适合正在学C/C++、刷题时对char *arr[]和char (*arr)[]犯迷糊的朋友,也适合面试前想快速理清指针数组和数组指针区别的人。看完这篇,你至少能明白:什么时候该用指针数组,什么时候该用二维数组,以及为什么力扣某些题目用指针数组会内存泄漏。

1. 从一道题说起:指针数组到底在辨析什么

1.1 力扣里哪些场景会逼你想清楚指针数组

我刷题时第一次被迫认真看指针数组,是在处理字符串分组的题里。比如热词里提到的“力扣1875将雇员相同的分组”,实际是那种vector<string>能轻松解决,但如果你非要用C风格字符串硬刚,就必须面对char* arr[]这种结构。力扣的题目输入往往是vector<string>或vector<vector<char>>,但做题时很多人喜欢把它们拆成C字符串数组,这时候指针数组和数组指针的区别就成了决定生死的问题。

举一个我很常见的操作:我想把一组字符串按首字母分组,比如["apple","banana","avocado","cherry"],如果用C++的string,直接map<char, vector<string>>就完事了。但如果你为了“显得更底层”,写一个char* group[26][100],那就得搞明白:group[i]是一个数组,每个元素是char*,还是group是一个指针数组,指向不同的字符数组?这看起来像文字游戏,但编译器视角完全不一样。

再比如力扣热题100里的“最长公共前缀”,用vector<string>很简单,但用C风格时,你需要一个char* strs[]来存所有字符串的首地址,然后逐个字符比较。这里strs就是一个指针数组,每个元素strs[i]是指向第i个字符串首字符的指针。指针数组的每个元素是指针,指向的内存可以长度不等——这正是它比二维字符数组灵活的地方。

1.2 明明有string,为什么还要纠结指针数组

很多初学者会问:C++里都有std::string和std::vector了,为什么还要学指针数组?说实话,在纯力扣场景下,你确实可以完全不用它。但有几个绕不开的原因:

其一,面试官爱问。尤其是C++岗位,指针数组和数组指针是高频八股,不搞懂容易被一问一个准。其二,很多旧项目、嵌入式代码里C风格字符串满天飞,你读代码时得认识char* argv[]这种形态。其三,力扣的某些题目隐含了要求你理解内存布局,比如“字符串排序后分组”这种题,如果底层用指针数组排序指针,比复制字符串快得多,因为交换指针比复制字符串内容代价小。

所以,我的建议是:刷题时可以用string偷懒,但每次用string解决的问题,都可以问自己一句“如果不用string,我该怎么用char*数组实现?”这样练几次,指针数组就能内化。

2. 一维指针数组的正确打开方式

2.1 它的长相、内存布局和类型本质

先给结论:一维指针数组,声明形式是T *arr[N],含义是“一个有N个元素的数组,每个元素类型是T*”。注意,首先它是一个数组,其次数组里存的是指针。以char *arr[3]为例,它占据的内存是三份指针变量的大小(在64位系统上通常是3×8=24字节),而不是三份字符串的大小。

内存布局上,arr本身是栈上的连续内存,每个元素存一个地址。这个地址可能指向栈上字符数组,也可能指向堆上动态分配的内存,还可能指向字符串常量区。这就带来一个关键辨析:arr[0]是一个char*,你可以修改它指向谁;但你不能修改arr本身的地址,因为数组名不是可修改的左值。

与之对立的“数组指针”长这样:T (*ptr)[N],它是一个指针,指向一个有N个元素的数组。比如int (*p)[5],p指向一个有5个int的数组。一维指针数组和数组指针在声明上只差一个括号,但类型天差地别。判断技巧很简单:看变量名先和谁结合。char *arr[3],arr先跟[3]结合,所以是数组;char (*arr)[3],arr先跟*结合,所以是指针。

我在刷题时经常用IDE的自动补全验证类型,比如在VS Code里把鼠标悬停在变量名上,会显示“char *arr[3]”还是“char (*arr)[3]”。不丢人,初学者就该多靠工具确认,等形成肌肉记忆就不需要了。

2.2 初始化:三种常见方式及其坑

指针数组的初始化有三种典型写法。

第一种,用字符串常量直接初始化:

const char *fruits[3] = {"apple", "banana", "cherry"};

这里我加了const,因为字符串常量存在只读区,如果写成char* fruits[3],在C++里是禁止的——从C++11起,字符串字面量类型是const char[N],不能隐式转换成char*。很多人刷题时忽略这点,编译不过还一脸懵。我建议一律用const char*。

第二种,指向动态分配的内存:

char *lines[2]; lines[0] = new char[10]; lines[1] = new char[20];

这种方式的优点是每行长度可变,内存利用率高。但坑也大:你稍不注意就忘了delete[] lines[i],内存泄漏后在力扣上虽然不会直接报错,但本地压测时内存一直涨,很影响心情。

第三种,指向栈上的字符数组:

char s1[] = "hello"; char s2[] = "world"; char *arr[2] = {s1, s2};

这里s1和s2是栈上的数组,可以直接被指针数组引用。但要注意,s1和s2的生存期必须在arr使用期间有效,如果你在某个函数里定义s1、s2,把它们存入一个全局指针数组,函数返回后指针就成了悬垂指针。

力扣刷题中,我最常用的是第一种和第三种。因为第二种要管内存释放,而在力扣的环境里,如果函数里new不delete,每次测试用例都会泄漏一点,虽然判题系统用隔离进程,但本地调试会非常难搞。

2.3 与二维字符数组的对比:什么时候选谁

二维字符数组char arr[3][20]和指针数组const char* arr[3]是另一个高频混淆点。我用一个表来总结:

维度char data[3][20]const char* data[3]
内存分配连续分配60字节分配3个指针变量,字符串散布在各处
字符串长度每行固定20字节,短的浪费空间长度任意,按实际分配
字符串可修改性可修改每个字符指向字符串常量则不可修改
排序代价交换整行20字节交换一个指针8字节
适用场景固定长度记录,如矩阵、坐标字符串列表、按指针排序

刷题时怎么选?如果字符串就是从外部读入的一组单词,我百分百用vector<string>,这是C++的舒适区。但如果我在做嵌入式风格的模拟题,或者用C语言接口时,偏向用指针数组,因为它能避免“行宽固定”带来的空间浪费。比如力扣上有道题是“按字典序排列字符串数组”,如果你用char arr[][100],排序时需要整行复制,耗时高;如果用const char* arr[],排序时只需要交换指针数组的元素,快一个数量级。

但指针数组也有尴尬的地方:你不能直接对const char* data[3]整体赋值。也就是说,data是数组名,你不能像二维数组那样直接data[0]对应一个固定内存块。如果你需要把一整组字符串整体拷贝或比较,指针数组提供不了,你得手动逐项处理。

3. 力扣实战:用指针数组重写一道简单题

3.1 题目:将一组字符串按首字母分组

我一直认为,搞懂一个知识点最好的方式不是看抽象概念,而是拿一道具体题目动手写。这里我用一道虚拟题举例(为了避免剧透真实力扣题目,我改写了场景,但核心逻辑相同):

给定一个字符串数组words,要求返回一个结果,把相同首字母的单词放在同一个组里。输出顺序不做要求。

输入示例:["apple", "banana", "avocado", "cherry"]输出示例:[["apple", "avocado"], ["banana"], ["cherry"]]

如果用vector<string>,这题太简单了,不适合展示指针数组。所以我给自己加码:不使用std::string,只使用C风格字符串和指针数组来实现。

3.2 底层思路:用指针数组把分组拆成“指针的排列”

核心思路是这样:我先把所有字符串用char*数组存放,然后对每个首字母构建一个指针数组,用来指向属于该组的字符串。但力扣的返回值往往是vector<vector<string>>,我最终还要转换回去。所以我采用一个中间方案:

  1. 将输入的vector<string>转换为const char*数组。
  2. 统计每个首字母的出现次数。
  3. 为每个出现过的首字母动态分配一个指针数组,里面存原const char*数组中的指针。
  4. 遍历原const char*数组,把指针塞到对应的指针数组里。
  5. 最后把每个指针数组的内容转换为vector<string>。

这里有个特别值得说的点:第3步中“为每个首字母动态分配一个指针数组”其实就是在模拟vector<vector<char*>>的底层。我在分配时是这样写的:

// 假设字母总共就26个,统计后得到 count[letter] const char ***groups = new const char**[26]; // 每个元素又是一个 char* 数组 for (int i = 0; i < 26; ++i) { if (count[i] > 0) { groups[i] = new const char*[count[i]]; } }

这里groups本身不是一个一维指针数组,而是“二级指针”,指向若干个“一维指针数组”。但最关键的分组逻辑发生在每个groups[i]内部——它是一个典型的一维指针数组。我在往groups[i]里塞指针时,必须记住当前填充位置:

int index[26] = {0}; for (const char* word : words) { int letter = word[0] - 'a'; groups[letter][index[letter]++] = word; }

注意,如果一开始不统计次数,直接分配一个很大的数组,那就不需要index了,但会浪费空间。这里是典型的时间换空间取舍。

3.3 调试中发现的三个真实陷阱

写完代码运行,我踩了三个坑,每个都很典型。

第一个坑是忘了保持源字符串存活。我把输入vector<string>转成const char*时,用了.c_str()。如果直接写成const char* p = words[i].c_str();,然后继续修改words[i],指针就会失效。我换成先构造const char** arr = new const char*[n]; arr[i] = words[i].c_str();,并且保证在整个分组过程中不修改words向量,这才稳定。

第二个坑是动态分配的指针数组不能直接返回。我在子函数里动态分配了groups[i],想直接返回给调用者,但调用者并不知道每个数组多大。所以我在做力扣题时,通常还是用vector<vector<string>>作为输出容器,动态分配的中间数组用完后就释放,不跨函数传递。这避免了很多内存归属的纠纷。

第三个坑是空字符串问题。如果某个word是空串,word[0]会越界访问。真实力扣数据里可能不出现空串,但作为严谨的程序员,我必须判空。我加了一行:

if (word == nullptr || *word == '\0') continue;

这三个坑让我意识到,指针数组虽然看着底层,但用起来要时刻思考“谁拥有这块内存”、“这块内存是否还存在”。代码能跑,不代表内存管理正确。

3.4 完整可运行的示例代码

我把这段代码整理成可以本地编译验证的版本,模拟力扣的测试框架:

#include <iostream> #include <vector> #include <string> #include <cstring> using namespace std; vector<vector<string>> groupByFirstLetter(const vector<string>& words) { // 1. 转为 const char* 数组 const char** raw = new const char*[words.size()]; for (size_t i = 0; i < words.size(); ++i) { raw[i] = words[i].c_str(); } // 2. 统计首字母频次 int count[26] = {0}; for (size_t i = 0; i < words.size(); ++i) { char c = *raw[i]; if (c >= 'a' && c <= 'z') count[c - 'a']++; else if (c >= 'A' && c <= 'Z') count[c - 'A']++; // 忽略非字母开头,也可以特殊处理 } // 3. 为每个字母分配指针数组 const char*** groups = new const char**[26]; for (int i = 0; i < 26; ++i) { groups[i] = count[i] > 0 ? new const char*[count[i]] : nullptr; } // 4. 填充 int idx[26] = {0}; for (size_t i = 0; i < words.size(); ++i) { char c = *raw[i]; if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))) continue; int letter = (c >= 'a' && c <= 'z') ? c - 'a' : c - 'A'; groups[letter][idx[letter]++] = raw[i]; } // 5. 转换回 vector<vector<string>> vector<vector<string>> result; for (int i = 0; i < 26; ++i) { if (count[i] == 0) continue; vector<string> group; for (int j = 0; j < count[i]; ++j) { group.emplace_back(groups[i][j]); } result.push_back(move(group)); } // 6. 释放内存 for (int i = 0; i < 26; ++i) { if (groups[i]) delete[] groups[i]; } delete[] groups; delete[] raw; return result; } int main() { vector<string> words = {"apple", "banana", "avocado", "cherry", "Cherry2"}; auto ans = groupByFirstLetter(words); for (auto& g : ans) { for (auto& s : g) cout << s << " "; cout << endl; } return 0; }

这段代码用到了const char***这种东西,看着吓人,但拆开就是“指针数组的数组”。刷题时如果玩不转,完全可以退回到vector。我之所以写这段,是为了让你看清楚指针数组在内存操作层面的具体形态。

4. 指针数组与函数参数:力扣函数签名背后的门道

4.1 为什么力扣里很少直接见到指针数组参数

如果你翻力扣的C++题解,会发现大多数函数的形参是vector或string,而不是char* arr[]。原因很简单:力扣接口是为了让更多人用更安全的方式做题,vector自带长度和内存管理,不用关心越界和释放。但这不代表你不能在函数内部使用指针数组,只是函数边界上要转换。

比如有些题目要求实现一个函数,输入参数是int* nums和int numsSize,这是C风格的接口。你拿到int* nums后,如果想按值排序,可以创建一个指针数组int *ptrs[1000],把每个元素地址存进去,然后排序ptrs,最后按指针顺序输出。这时候ptrs就是典型的一维指针数组。我经常用这种方法避免直接修改原数组,同时又不复制整个数组。

4.2 用“指针数组实现索引排序”的实战思路

写一种经典场景:力扣里有“按分数排序后输出姓名”的题。输入是两个数组:vector<int>& scores和vector<string>& names。要求按分数降序输出姓名,分数相同则按姓名升序。最简单的做法是把下标放到vector<int>里排序,或者把指针放到指针数组里排序。这里我用指针数组思路。

由于names和scores是两个独立的数组,但它们在逻辑上是配对的。我创建一个数组:

const char** namePtrs = new const char*[n]; for (int i = 0; i < n; ++i) namePtrs[i] = names[i].c_str();

然后我其实不需要对namePtrs排序,因为名字和分数要一起排。更好的做法是对下标排序,但如果你想展示指针数组,可以对pair<const char*, int>排序,不过这就不纯粹了。所以更贴合指针数组的场景是:你有一个对象数组,你只对其中某个字符串字段的指针排序。

例如,有一个结构体数组:

struct Student { const char* name; int score; }; Student students[100];

如果我想按名字字典序排序结构体,可以创建:

const Student* ptrs[100];

然后对ptrs排序,比较时用strcmp(ptrs[i]->name, ptrs[j]->name),交换时只交换指针,不移动结构体。这就是“指针数组排序对象”的经典用法。在力扣中,当你手头是vector时,sort自带定制函数,效率也不差,但在底层结构体很大的时候,指针数组排序的价值就出来了。

4.3 传递指针数组时的退化和二义性

大家一定听过“数组作为函数参数会退化为指针”,一维指针数组也不例外。如果你写:

void func(char *arr[10]) { ... }

实际上等价于:

void func(char **arr) { ... }

[10]里的10是形式上的,编译器不会检查数组边界。这导致一个很隐蔽的问题:你在函数内用sizeof(arr)想计算数组大小,得到的是指针大小8,而不是数组总大小80。我见过太多人在这里翻车。

情况更复杂的是,如果你把指针数组作为二维数组传参,比如:

void func(char *arr[][10]) { ... }

退化后变成char* (*arr)[10],它是一个数组指针,指向“有10个char*元素的数组”。这和二维字符数组传参类似,但维度中的第一个大小会丢失。刷题时我一般避免传整个指针数组,要么传char**加长度,要么传vector<char*>。

经验之谈:在力扣接口里,如果必须接收C风格的多行数据,最稳的是用vector<vector<int>>这种。如果非要传指针数组,那一定要有个单独的size参数,否则函数无法知道数组长度。

5. 常见错误大盘点与力扣本地调试技巧

5.1 高频Bug速查表

我把刷题过程中撞见的指针数组相关问题整理成一张表,方便你排查:

症状可能原因解决方案
编译报错“cannot convert from const char* to char*”用字符串常量初始化char* arr[]改用const char* arr[]
程序崩溃,退出码非0指针数组元素未初始化,或指向悬垂内存初始化每个元素,或使用nullptr并判空
输出全变同一个字符串指针数组所有元素指向同一块可变内存,比如循环复用一个char buf[]每个元素指向独立内存,或改用vector<string>
函数内sizeof(arr)结果不对数组退化为指针用size参数,不要用sizeof
修改字符串常量导致段错误const char*指向只读区,强转换成char*后修改不要修改字符串常量
delete[]时崩溃对非动态分配的内存调用delete[]记录哪些是new来的,逐个判断
字符串内容在函数返回后失效c_str()返回的指针失效在底层容器改变前使用指针,或拷贝字符串

这张表是我长期积累的浓缩版。每次我用指针数组做题时,都会对照检查一遍,能省下不少调试时间。

5.2 本地复现力扣内存泄漏与越界的方法

力扣判题时,每跑一个用例通常是在独立进程里执行的,所以你在解法里new了不delete,并不会影响下一个测试用例,甚至不会直接导致判题失败。但如果你在本地做压力测试,连续跑一万个用例,内存泄漏就会让进程膨胀,最后触发本地的内存限制。这时候怎么排查?

我常用的一种方法是:在代码里加全局计数。

static int alloc_count = 0; // 每次 new 后 ++alloc_count,每次 delete 后 --alloc_count

然后在每个用例结束和整体结束时,断言alloc_count == 0。我发现,只要指针数组用得多,这个断言经常失败。失败点往往是那些需要动态分配char*数组但中途continue跳过释放的分支。修复方法一般是把释放代码放在RAII包装里,或者干脆用vector替代。

另一个很实用的技巧是,用AddressSanitizer编译本地测试。力扣的编译器未必开ASAN,但你可以本地开:

g++ -fsanitize=address -g main.cpp -o main

然后运行,越界、悬垂、泄漏都会给出详细报告。我在写指针数组相关代码时,一定会用ASAN跑一遍,因为C风格的内存错误往往不报错,而是随机崩溃或输出错误结果,不借助工具很难定位。

5.3 一个特别容易误导的写法:char* p[2]和char (*p)[2]

在力扣讨论区我经常看到有人贴出这两行的区别,但还是会猜错。我提供一个记忆方法:变量名先靠近谁,谁就是主体。

  • char* p[2]:p紧挨着[2],所以p是一个数组,数组元素的类型是char*。
  • char (*p)[2]:p紧挨着*,所以p是一个指针,指向一个char[2]数组。

用一个自测题检验:

char arr[][2] = {{'a','b'},{'c','d'}}; char (*p)[2] = arr; // p指向含有2个char的数组

如果我想用一个指针数组来存arr的两行首地址,应该这么写:

char* ptrs[2] = {arr[0], arr[1]};

这两者很容易混,我建议在纸上画一下内存图:p是一个方块,指向一行;ptrs是两个方块,每个方块是一个地址。画十次你就分清了。

这个知识点虽然基础,但在力扣某些多维动态规划题里,会遇到“用指针数组指向二维数组的行”,比如你要对每一行排序,可以创建行指针数组,排序后输出。一旦把指针数组和数组指针弄混,代码就会变得十分诡异,编译报错都读不懂。

6. 从刷题到工程:一维指针数组的迁移价值

6.1 力扣题目之外,指针数组常见于哪些真实代码

可能有人觉得,刷题用指针数组只是自虐,现实中没人用。其实不然。在Linux系统编程里,main(int argc, char* argv[])的argv就是一个典型的一维指针数组,每个元素指向一个命令行参数。在处理命令行选项时,你经常要遍历argv,过滤掉某些参数,最自然的做法就是创建一个新的const char* args[],把要保留的参数指针放进去。

在嵌入式开发中,串口命令解析也常用指针数组。比如一个表:

static const char* cmd_table[] = {"help", "reset", "status"};

然后你可以写一个函数,遍历这个指针数组,用strcmp匹配用户输入。这里指针数组好处是,可以很方便地用数组下标或二分查找,不需要每个命令定长存储。

在数据库或网络编程里,解析HTTP头时,常常把多个字符串的首地址存入一个指针数组,然后用两层循环处理。你会发现,力扣里那些“处理字符串列表”的逻辑在真实代码里一样常见,只是被封进了vector、map这些容器中。理解指针数组能让你在跨语言或C接口的场景中不慌。

6.2 用规律刷题法巩固指针数组:给自己设计小目标

如果你真心想把这个知识点练到肌肉记忆,我给你三个可以自己设计的小任务,每天十分钟:

任务一:把vector<string>转换为const char*数组并反向输出,要求每次修改vector后观察指针失效现象。任务二:写一个函数,接收int* arr和长度n,用指针数组按升序索引,输出原数组元素但要求不修改原数组。任务三:创建两个字符串指针数组A和B,将A按字典序排序后,把结果存入B,要求B的元素指向原字符串,但不能复用同一块缓冲区。

这三个任务做完,你对指针数组的分配、初始化、失效、排序、释放就都有体感了。我自己刷题时,会把类似的小任务放在大题的间隙做,效果比直接背概念强得多。

6.3 关于“用还是不用”的个人建议

最后说点实际的。很多人陷入一个误区,觉得刷题时用了vector就是不硬核,非要写char*。我的看法是:能解决问题的工具就是好工具。在大部分力扣题里,vector和string是更高效、更不容易出错的选择。我的建议是,会指针数组、懂指针数组、能在必要时用指针数组,但不要为了用而用。

刷题的核心目标是把算法逻辑练熟,而不是跟内存管理较劲。碰到字符串分组、动态规划里需要索引排序时,如果指针数组会让代码复杂度爆炸,我就老老实实用vector。但我会在题解里额外写一段“如果用C风格实现,核心区别是什么”,这样既不影响刷题进度,又能锻炼底层思维。

6.4 结尾分享一个调试小经验

我在刷力扣时,有一件事让指针数组相关的错误率直线下降:写代码前先花三十秒在注释里写出内存布局图。比如要处理一个字符串分组,我会先写:

// words[0] -> "apple" (在code区) // words[1] -> "banana" // ptrs[0] = words[0].c_str(); // ptrs[1] = words[1].c_str();

别小看这几行注释,它能帮你把“指针指向谁”这件事钉死。很多报错都是因为脑子里想的和代码实际做的对不上,画一遍内存关系,基本就避开了大多坑。我个人实际操作中的体会是,指针数组的难点从来不是语法,而是内存归属和生存期。只要把这两个问题想清楚,哪怕const char***这种三重指针也能拆解得明明白白。

返回列表