每次讲到《C语言程序设计》里的指针章节总有学生举手问我“老师在数组里查找一个元素用for循环加a[i]不就行了代码短也好懂为什么教材非要引入指针”这个问题我特别喜欢因为它恰好问到了指针这一章存在的意义。以“在数组中查找指定元素”这个几乎每本C语言书都会讲的任务为例你用下标写和用指针写表面上只是语法不同背后却是两种完全不同的思维模型。本文以何钦铭、颜晖《C语言程序设计第四版》第八章指针相关内容为主线把“指针 数组查找”这个组合彻底讲透。不管你是正在准备期末考试的学生、做课程设计的新手还是工作多年想回头补一补底层概念的程序员都可以把这里面的代码和排查思路直接拿去用。我会从教材为什么这么设计讲起到指针遍历数组的三个关键点再到函数接口怎么设计才优雅最后补充二分查找、二维数组、字符串表查找等进阶玩法。保证每一段都有能落地的代码也有教科书上不会写的大实话。1. 教材为何把数组查找放进指针章节一场地址思维训练1.1 数组名到底是什么一个装着地址的“常量指针”很多人学指针先学int *p然后学数组int a[10]两者似乎是分开的概念。但教材在讲指针时偏偏要把数组拉进来因为C语言里数组和指针的联系比你想的紧密得多。数组名a在表达式中作为右值使用时代表的是数组首元素的地址也就是a[0]。你可以把它理解成一个“只读的指针变量”——它本身存储着地址但这个地址不能像指针那样被重新赋值。下面这段代码是初学者最容易迷惑的地方#include stdio.h int main(void) { int a[10] {0}; printf(a %p\n, (void*)a); printf(a[0] %p\n, (void*)a[0]); printf(a %p\n, (void*)a); printf(a1 %p\n, (void*)(a 1)); printf(a1 %p\n, (void*)(a 1)); printf(sizeof(a) %zu\n, sizeof(a)); return 0; }在我常用的64位Linux环境上a、a[0]、a三个值打印出来往往一模一样但它们属于三种不同的类型a在大多数表达式中会“退化”成int *指向首元素a[0]类型同样是int *而a是指向整个数组的指针类型是int (*)[10]。最直观的差异是步长a 1跳过4个字节一个inta 1跳过40个字节整个数组。这就是为什么教材喜欢在指针章节讲数组操作——搞懂数组的地址模型你才算真正理解指针不是“随随便便一个地址”而是“带着类型信息的地址”。1.2 同一个查找任务下标写法和指针写法差在哪以“在长度为 n 的整型数组中查找指定元素 x 并返回下标”为例下标写法大家都很熟int search_index(int a[], int n, int x) { for (int i 0; i n; i) { if (a[i] x) { return i; } } return -1; }指针写法长这样int search_ptr(int *a, int n, int x) { int *p; for (p a; p a n; p) { if (*p x) { return (int)(p - a); } } return -1; }两种写法放到一起对比你就能看出教材的意图维度下标版指针版访问元素a[i]通过索引计算偏移*p直接通过地址取值移动逻辑i让下标加1p让指针越过一个元素终止条件i n下标和长度比p a n地址和尾后地址比返回位置return ireturn p - a指针减指针得到元素个数从结果来看两段代码完全等价甚至下标版读起来更亲切。但指针版的优势在于它把循环变量从“一个不断变化的整数”变成了“一个不断移动的地址”。这个过程强迫你思考——数组在内存里到底是什么样子的元素之间到底挨得多近变量在内存里是以什么形式组织起来的1.3 教材真正想训练的一种思维何钦铭、颜晖《C语言程序设计第四版》第八章以指针为核心数组查找只是一个载体。教材用这个任务让你完成三件事第一理解指针算术。p 1不是简单地给地址值加1而是加上sizeof(数组元素类型)。这个规则在所有数组操作中都成立。第二理解指针比较。p a n这种写法依赖的是C语言的一个规则同一数组内元素的地址之间可以进行关系运算比较结果表示元素在内存中的先后顺序。第三理解指针作为函数参数。search_ptr(int *a, int n, int x)这个形参写法其实和int a[]完全等价数组在传参时退化成了指针。这是C语言里“按引用传递”效果的核心机制。只要把这三件事在一个查找任务里练熟后面学链表、学字符串处理、学指向多维数组的行指针都会顺畅很多。2. 指针遍历数组的三件套起点、步长和停下来的条件2.1 起点和步长p 到底跳了几步任何指针遍历数组的循环都要先回答三个问题从哪开始每次怎么走什么时候停起点很简单p a让指针指向数组首元素。也可以写成p a[0]两者完全等价。步长是新手最容易忽略的细节。p看起来只是地址加1实际上编译器会按照指针的基类型计算偏移。不同类型的指针自增时的跳跃字节数完全不同#include stdio.h int main(void) { char c[4]; short s[4]; int i[4]; double d[4]; printf(char p 跳跃 %zu 字节\n, (char*)c[1] - (char*)c[0]); printf(short p 跳跃 %zu 字节\n, (char*)s[1] - (char*)s[0]); printf(int p 跳跃 %zu 字节\n, (char*)i[1] - (char*)i[0]); printf(double p 跳跃 %zu 字节\n, (char*)d[1] - (char*)d[0]); return 0; }常见结果如下类型一次 p 跳跃的字节数char *1short *2int *4double *8这就是为什么指针必须“带类型”。一个void *类型指针无法做加法和自增因为它不知道自己的步长应该取多少毫木。所以你在写查找函数时传入的指针一定要和数组元素类型严格匹配。2.2 终止条件为什么是 p a n 而不是 p a n数组长度为 n 时有效元素的下标是 0 到 n-1。用下标写循环大家习惯写i n一旦写成i n就会越界访问一个不存在的元素。指针写法对应的是p a n。这个条件里的a n是“尾后指针”——指向数组最后一个元素后面那个假想位置。这个位置可以参与比较但不能解引用因为那个地址上并没有数组元素。“尾后指针”这个设计非常精妙它让查找循环可以统一写成for (p a; p a n; p) { if (*p x) { return (int)(p - a); } }当p走完整个数组最终会等于a n此时循环条件p a n为假循环自然停止。你不用额外再处理“最后一个元素已经检查过”的特殊逻辑。值得强调的是你千万不要写成p a n。因为当p来到a n的那一刻条件依然为真循环体会执行一次if (*p x)而*p访问的已经是数组之外的地址了。在C语言里这是未定义行为程序可能崩溃可能返回垃圾值也可能“恰好没事”——但无论如何它都是错的。2.3 一个能直接跑的完整查找程序下面这段代码是我在讲这一章时常让学生抄下来运行的版本它展示了查找函数从定义到调用的完整链路#include stdio.h #define SIZE 10 int find_in_array(int *begin, int *end, int x) { int *p; for (p begin; p end; p) { if (*p x) { return (int)(p - begin); } } return -1; } int main(void) { int a[SIZE] {23, 45, 12, 67, 8, 90, 34, 56, 78, 11}; int target 67; int pos find_in_array(a, a SIZE, target); if (pos 0) { printf(找到 %d下标为 %d\n, target, pos); } else { printf(数组中没有 %d\n, target); } return 0; }注意我在函数形参里使用了int *begin和int *end的写法。begin指向数组开头end指向数组末尾的下一个位置。这样设计的好处是循环里只做指针比较和解引用返回时也能用p - begin直接算出下标代码语义清晰。运行这段程序输出是找到 67下标为 3。你可以试试把target改成100就会走 “没有找到” 分支。这个程序虽然小但已经把“指针遍历数组”三件套全部用上了起点是begin步长靠p终止靠p end。3. 查找函数接口设计返回下标还是返回指针3.1 两种返回值的取舍很多教材习题要求“返回下标或输出-1”因为下标对初学者更友好。但实际工程里查找函数返回指针更常见尤其是和动态分配、结构体数组打交道的场景。两种设计的差别值得想想返回类型优点缺点返回下标int直观读位置方便找不到时只能用 -1 这种约定值数组地址改变了下标失效返回指针int *可直接操作该元素找不到用NULL语义清晰配合指针算术灵活想拿下标还得自己做减法p - a我的建议是课程作业和考试里按教材要求返回下标自己写代码时优先写返回指针的版本。因为返回指针的函数天然支持“链式操作”——比如你找到数组中的目标元素后还能基于返回的指针继续往后看几个元素或者修改它。返回下标你至少还要再写一次a[i]才能拿到地址多一层转换。3.2 begin/end 风格查找函数的完整实现上面第2节的那个find_in_array函数其实已经是一个不错的接口雏形。我再给一个稍微完整一点的版本它用NULL表示“未找到”并额外提供一个“查找最后一个匹配元素”的变体#include stdio.h int *find_first(int *begin, int *end, int value) { int *p; for (p begin; p end; p) { if (*p value) { return p; } } return NULL; } int *find_last(int *begin, int *end, int value) { int *p; int *last NULL; for (p begin; p end; p) { if (*p value) { last p; } } return last; } int main(void) { int a[8] {3, 5, 7, 5, 9, 5, 11, 13}; int *pos1 find_first(a, a 8, 5); int *pos2 find_last(a, a 8, 5); if (pos1) { printf(第一次出现 5 的下标%d\n, (int)(pos1 - a)); } if (pos2) { printf(最后一次出现 5 的下标%d\n, (int)(pos2 - a)); } return 0; }输出结果第一次出现的下标是1最后一次出现的下标是5。注意我用了p - a来计算下标这是因为两个指针指向同一个数组时相减的结果正好是它们之间相隔的元素个数这个念头比逐次累加一个计数器还要准确。实际上这种“返回指针 指针减法算下标”的模式就是C语言算法接口的经典打法你以后看bsearch、qsort等标准库调用时也会碰到类似的思路。3.3 边界情况的测试清单写查找函数跑通一条正常路径不算完。以我的经验下面这些场景必须都测一遍才能在考试和面试里站稳数组为空传入begin end循环一次都不执行返回NULL或 -1不能崩。目标在数组开头第一次比较就命中确保返回正确。目标在数组末尾指针要走到最后一个元素循环终止条件不能提前拦掉它。目标不存在指针顺利越过最后一个有效元素停在end返回未找到状态。数组中有多个相同目标find_first返回第一个find_last返回最后一个逻辑不能混。数组元素包含0、负数、最大整数值这些值都必须能正常参与比较。我在测这些边界时有一个习惯不管代码多简单都在每个分支里加一句printf跑一遍。比如把target分别设成a[0]、a[7]、不存在值、a[3]这四个档位输出结果肉眼核对一遍。不要怕麻烦查找函数是所有算法函数里复用率最高的一次测安稳后面能省很多事。4. 指针查找的翻车现场四个高频Bug和完整排查链路4.1 野指针声明之后直接拿来用我见过太多次这种代码了int *p; int a[5] {1, 2, 3, 4, 5}; for (p a; p a 6; p) { /* 已经错了但先看这里 */ printf(%d , *p); }比边界错得更隐蔽的是这样int *p; int a[5] {1, 2, 3, 4, 5}; p[0] 100; /* p 从未指向任何有效内存直接解引用 */这段代码让p成为一个野指针。它一开始是个随机地址没有指向任何数组元素你往p[0]写值等于往一个不确定的内存位置写数据。程序可能立刻崩溃也可能当时没事、过一会儿变量被覆盖时才崩。内存问题就是这样如果它每次都能立刻崩反而好排查怕的就是“偶尔崩一次”。排查思路很简单打开调试器在写入p[0]那一行打断点看p的值是什么。如果是一个你完全不认识、绝不是数组首地址的数字基本可以断定是野指针。修复办法要么让p指向已有的数组比如p a要么用malloc分配内存后让p指向它。查指针题的第一条军规——任何指针在解引用之前你都要能回答一个问题“它指向的这块内存是谁分配出来的”4.2 数组参数退化在函数里 sizeof(a) 得到的不是数组大小这是指针章节最经典的坑。很多学生写出这样的查找函数int wrong_size_find(int a[], int x) { int n sizeof(a) / sizeof(a[0]); for (int i 0; i n; i) { if (a[i] x) return i; } return -1; }看起来天衣无缝先算数组长度再遍历。但实际跑起来n根本不对。原因在于函数形参列表里的int a[]在编译时会被调整为int *a数组名退化成指针后sizeof(a)拿到的是指针类型的大小。64位系统上一个指针是8字节假设原来数组大小40字节现在sizeof(a)不是40而是8于是n 8 / 4 2查找范围从10个元素缩水到2个。理解这一点你就能明白为什么我的查找函数都要显式传一个长度参数或者传begin和end两个指针。你不能依靠sizeof在函数内部还原数组长度这是C语言数组参数机制的先天限制。碰到这种题正确做法是让调用方把长度传进来或者把尾后指针传进来。调试时怎么确认在函数第一行把sizeof(a)打印出来和调用方的sizeof(数组)一对比立刻就能看出问题。如果你在写一个通用函数永远不要假设“我能在这个函数里推断出数组长度”。4.3 越界一格循环条件一处之差地址直接跑到数组外面看这一段int a[5] {10, 20, 30, 40, 50}; int *p; int count 0; for (p a; p a 5; p) { if (*p 30) { printf(找到了下标 %d\n, (int)(p - a)); break; } count; }循环条件写的是p a 5意味着当p指向a 5时还会进入循环体执行一次解引用。而a 5正好是数组 5 个元素之后的那个地址也就是尾后位置这个位置没有元素。这次解引用属于未定义行为——在某些环境下你读到的是一个垃圾值在某些环境下程序直接崩溃。排查这种问题是所有指针错误里最好定位的因为它的症状很典型循环多执行了一次或者程序在循环末尾崩溃。在调试器里单步走到最后一个有效元素后下一跳不跳出循环反而继续执行那基本就是终止条件写成了。换成立刻解决。4.4 指针类型不匹配步长跟着类型走别乱转换还有一类问题出现在指针类型不匹配上。如果你把一个int *强转成char *然后照常做p它每次只跳1个字节而不是4个字节。这样遍历数组自然是错的int a[4] {100, 200, 300, 400}; char *p (char *)a; for (int i 0; i 4; i) { printf(p%d 指向的字节内容十六进制%02x\n, i, *(unsigned char *)(p i)); }这不是为了找你想要的整数而是想告诉你同一块内存用不同指针类型看是完全不同的画面。遇到这种诡异的查找结果先返回去检查指针声明。int *就用int *遍历double *就用double *遍历除非你有充分理由做逐字节处理。如果你发现查找函数明明逻辑没问题但找到的数据不对那么优先级第一的怀疑对象就是指针类型被“污染”了。我把这几类坑汇总成一个排查顺序先看指针有没有初始化野指针再看循环边界越界再看形参传参方式退化问题最后看类型匹配。按这个顺序走八成以上的指针查找问题都能定位到底。5. 二分查找的指针版从逐个扫描到折半跳跃5.1 二分查找里指针的用武之地线性查找适合无序数组但数组一旦有序还用逐个扫描就太亏了。二分查找每次把搜索区间砍一半时间复杂度从O(n)降到O(log n)。比如一个100万个元素的有序数组线性查找最多可能要比较100万次二分查找最多20次就出结果。二分查找的三个指针关键点low指向当前区间的第一个可能位置。high指向当前区间的最后一个可能位置。mid用low (high - low) / 2算出指向中间元素。这里有一个很多指针新手会踩的坑不要写成(low high) / 2。两个指针相加在C语言里是无意义的运算编译器直接报错就算你强行强转整型来算一旦low和high相隔很远整数加法还可能溢出。而high - low是合法的它算出两指针之间的元素个数再除以2得到相对偏移加回到low上就是中间位置。这个写法既安全又符合指针语义。5.2 指针版二分查找完整代码下面的binary_search接收有序数组的首尾指针返回目标元素的指针找不到则返回NULL#include stdio.h int *binary_search(int *begin, int *end, int value) { int *low begin; int *high end - 1; /* 指向最后一个有效元素 */ while (low high) { int *mid low (high - low) / 2; if (*mid value) { return mid; } else if (*mid value) { low mid 1; } else { high mid - 1; } } return NULL; } int main(void) { int a[] {2, 5, 8, 12, 16, 23, 38, 56, 72, 91}; int *pos binary_search(a, a 10, 23); if (pos ! NULL) { printf(找到 23下标为 %d\n, (int)(pos - a)); } else { printf(未找到 23\n); } return 0; }用这个程序去查 23得到下标5。中间过程我建议你在纸上手推一遍第一次mid指向下标4元素1616 23所以low mid 1指向下标5第二次mid指向下标7元素5656 23所以high mid - 1指向下标6第三次mid指向下标5命中。整个过程中mid的取值完全由指针算术驱动不依赖数组名做下标运算。5.3 指针版二分查找的边界验证我把二分查找可能出现的边界情况列个清单你写完后照着跑一遍数组只有一个元素low high第一次循环就命中或直接返回NULL。目标比数组最小元素还小每次都是*mid valuelow不断右移最终low超过high退出循环返回NULL。目标比数组最大元素还大同理high不断左移最终正常退出。目标恰好在a[0]或a[n-1]二分查找都要能命中首尾元素这是考验low (high-low)/2写法的典型场景。数组元素重复二分查找返回的是“某一个”匹配位置不保证是第一个。如果你需要第一个出现的位置还得额外往左搜索。还有一个实用经验end - 1这个写法一定得放在begin已经验证非空的前提下。如果数组长度为0end beginend - 1就跑到数组外面了此时应该直接返回NULL。更稳妥的写法是在函数入口判断一下begin end就返回NULL。6. 指针查找的延伸应用二维数组、字符串表和函数指针6.1 在二维数组里用指针查找行指针和一维数组指针二维数组的指针操作比一维复杂但原理还是同一套。假设有一个int a[3][4]a[i][j]在指针视角下等价于*(*(a i) j)。先解释分解过程a i指向第 i 行类型是int (*)[4]*(a i)解引用得到第 i 行这个一维数组的首地址类型是int *再加j跳到该行第 j 个元素。如果你要在二维数组里查找某个整数可以按行遍历每一行内用指针从头扫到尾#include stdio.h #define ROWS 3 #define COLS 4 int find_in_2d(int matrix[][COLS], int rows, int value) { int i, j; for (i 0; i rows; i) { int *p; int *row_begin matrix[i]; /* 第 i 行的首元素地址 */ for (p row_begin; p row_begin COLS; p) { if (*p value) { return i * COLS (int)(p - row_begin); } } } return -1; } int main(void) { int a[ROWS][COLS] { {11, 22, 33, 44}, {55, 66, 77, 88}, {99, 10, 20, 30} }; int pos find_in_2d(a, ROWS, 30); if (pos 0) { printf(找到 30一维序号为 %d\n, pos); } else { printf(未找到 30\n); } return 0; }这个写法里matrix[i]取到的是第 i 行的行首指针它在类型上等价于int *。用这个指针遍历一整行行内的逻辑和一维数组查找完全一样。这种“把二维问题拆成一维问题”的做法在不少教材习题里会出现值得记住。6.2 指针数组查找字符串元素字符串表操作的经典形态指针数组在字符串处理中特别常用比如char *words[]这种写法数组里装的是字符串常量地址。要在这种结构里查找指定字符串遍历方式和解引用目标都和整型数组不同#include stdio.h #include string.h char *find_word(char **begin, char **end, const char *target) { char **p; for (p begin; p end; p) { if (strcmp(*p, target) 0) { return *p; } } return NULL; } int main(void) { char *words[] {apple, banana, cherry, date, elderberry}; char *result find_word(words, words 5, cherry); if (result ! NULL) { printf(找到单词%s\n, result); } else { printf(未找到目标单词\n); } return 0; }这里的char **是双重指针每一层含义都要理解清楚数组名words退化成char **指向第一个元素char **p取出的char *是字符串的首地址strcmp(*p, target)才是真正比较字符串内容。如果你误把*p target拿来直接比较那你比较的是指针值也就是两个字符串常量的地址哪怕内容相同地址不同也一样不相等。这是字符串查找里最容易翻车的地方。6.3 把查找函数当作参数传递函数指针的入门实战学完指针章节教材通常会提一句函数指针。查找函数这个场景很适合演示它的价值你写一个通用的“结果打印”函数接收不同查找函数作为参数就能用同一套逻辑调度多个查找策略。#include stdio.h int *find_linear(int *begin, int *end, int value); int *find_binary(int *begin, int *end, int value); /* 假设数组有序 */ void show_result(int *(*find_func)(int *, int *, int), int *begin, int *end, int value) { int *r find_func(begin, end, value); if (r ! NULL) { printf(找到 %d下标 %d\n, value, (int)(r - begin)); } else { printf(未找到 %d\n, value); } } int main(void) { int a[] {2, 5, 8, 12, 16, 23}; show_result(find_linear, a, a 6, 12); show_result(find_binary, a, a 6, 12); return 0; }函数指针的声明int *(*find_func)(int *, int *, int)看着吓人拆开读也简单(*find_func)说明find_func是指针它指向一个函数这个函数接收三个参数类型分别是int *、int *、int函数返回值是int *。通过这种机制你可以在运行时决定采用线性查找还是二分查找而不需要改任何处理结果的代码。先定义 find_linear 和 find_binary 两个函数再在主函数中分别传给 show_result这种写法在课程设计里很加分也让你对函数指针的恐惧感瞬间少一半。实际工作中常见的回调函数、钩子函数原理都和这个演示一模一样。我个人在教学里反复体会到一个现象学生能把“下标思维”切换成“地址思维”的那一天往往就是第八章学完的那一周。切换到指针视角之后再看链表、看动态内存分配、看字符串操作很多东西都会豁然开朗。如果你正在读这一章我特别建议你把书里所有涉及数组的题目都用指针重写一遍不要只看不练。写不出来的地方回头看这篇文章里的完整示例抄几遍、跑几遍再自己默写几遍。等你真的做到用p而不是a[i]去思考的时候你会觉得整个内存世界都在你手里握着那种感觉非常爽。
阅读完成 · 觉得有帮助?