
50道复习题做到第21题很多人的感受是前面20道还顺一到字符串和指针就开始拉胯。这很正常如果连着的字符串反转、字符统计、手写拼接都写不顺后面链表会崩得更厉害。今天这篇文章就专门把第21-30题完整过一遍。题目来自那套流传很广的C/C复习题范围集中在指针、字符串、链表、查找排序和动态内存。不管你是期末考前抱佛脚还是准备第一轮面试手撕代码这几道题都值得反复写。我会按知识块拆开讲每道题给一种能直接落地的参考写法再把边界条件、常踩的坑列出来。你可以先收藏然后找一整块时间照着写一遍效果一定比光刷视频强。1. 第21-30题的整体分布与复习思路1.1 这10道题覆盖了哪些核心考点先看整体布局。第21-30题几乎把C/C笔试里最常翻车的几个点都塞进来了字符串处理、指针传参、链表操作、查找排序、动态内存。我按常见版本整理成了一张表你可以对照着排查自己哪里薄弱。题号核心考点推荐练习次数21字符串原地反转3遍以上22字符频率统计2遍23字符串拼接与容量检查3遍24回文判断与字符过滤2遍25指针/引用交换函数2遍26单链表反转5遍以上27删除链表中间节点3遍28二分查找3遍29快速排序partition4遍30动态数组扩容3遍这个分布不是随口排的。字符串考的是循环索引和对结束符的敏感度指针考的是间接访问和地址传递链表考的是指针之间的链接关系排序和查找考的是递归与区间控制动态内存考的是内存生命周期。你会发现每道题都在反复测试同一件事你是否知道数据存放在哪里、如何访问、生命周期是多长。这在日常写业务代码时不容易察觉但一旦开始调试崩溃、内存泄漏基本功的差异就全出来了。1.2 做题的正确姿势我见过不少人拿到这套题先看答案然后觉得自己看懂了一合上文档大脑空白。问题出在输入方式看代码是“被动输入”而手写代码是“主动输出”。建议你先把题目抄下来不看参考实现自己写完再对照。哪怕写出来的代码很丑都没关系关键在于先暴露错误。我习惯把每道题拆成三个问题来问自己输入是什么输出是什么边界在哪里。以字符串反转为例输入可以是空串、单字符、偶数长度、奇数长度输出必须都是正确反转且保留结束符。能把这几类情况都考虑到说明你不是在背题而是真的理解了数组下标和终止符的关系。后面第21-30题的讲法也会按这个思路展开。2. 字符串操作类第21、22、23题拆解2.1 第21题手写字符串反转题目描述是写一个函数void reverse(char s[])在不使用库函数的前提下原地反转字符串。这道题看似基础却能一次性暴露两个问题不会手写长度统计或者把结束符\0当成普通字符一起交换。#include stdio.h void reverse(char s[]) { int len 0; while (s[len] ! \0) { len; } int i 0; int j len - 1; while (i j) { char tmp s[i]; s[i] s[j]; s[j] tmp; i; j--; } }先用自己的循环算长度而不是调strlen是因为题目明确说了“不使用库函数”。长度算出来后j必须是len - 1因为字符数组下标从0开始最后一个有效字符在len - 1位置。如果写成int j len;第一步就会把\0换到数组开头结果整个字符串变成空串查半天都不知道为什么。循环条件写成i j最稳。写成i j也能得到相同结果只不过当长度为奇数时中间那个字符会和自己交换一次纯属浪费。等到你后来学异或交换时这个区分就很重要了当i j时a ^ b会把对应字符变成0直接破坏数据。注意测试这个函数时不要用字符串字面量比如reverse(abcde)会导致段错误。字符串字面量一般存放在只读区修改它属于非法操作。正确做法是声明成char str[] abcde; reverse(str);。这道题对面试的启发是面试官不关心你能不能背出反转代码关心的是你能不能解释清楚为什么j要从len - 1开始以及如果从len开始会出现什么现象。能把这两个问题讲清楚代码其实无所谓。2.2 第22题统计字符出现频率题目通常这样描述给定一个字符串找出出现次数最多的字符并返回该字符和出现次数。这道题的核心思路是“用数组下标当作字符编号用数组值当作计数器”。void findMaxChar(const char *s, char *resultChar, int *resultCount) { int freq[256] {0}; int i; for (i 0; s[i] ! \0; i) { freq[(unsigned char)s[i]]; } *resultChar 0; *resultCount 0; for (i 0; i 256; i) { if (freq[i] *resultCount) { *resultCount freq[i]; *resultChar (char)i; } } }频率数组长度选256是因为char类型只有8位理论上能表示256个不同的值。对ASCII可见字符来说26个英文字母、10个数字、标点符号都落在0到127范围内但普通字符数组里可能出现高位为1的扩展字符所以循环里要写成freq[(unsigned char)s[i]]。这里有一个隐蔽的Bug如果char在编译器下是有符号类型s[i]为负数时直接用freq[s[i]]会导致数组下标为负程序跑到一半可能崩溃也可能悄悄污染栈内存。强转成unsigned char后下标范围就安全了。边界情况是空字符串。空串没有“出现次数最多的字符”所以我在函数里把resultChar初始化为0resultCount初始化为0。如果你在实际题目中遇到“多个字符次数相同”的情况还要确认取ASCII最小那个还是按原字符串中先出现的那个。我这里遍历0到255只处理不处理所以默认取ASCII较小者。题目没有说明时建议先问清楚或者用注释标明你的处理规则。扩展一下如果字符串可能包含中文字符这种char数组方案就不够用了。中文在UTF-8编码下每个字占多个字节不能把一个字节当作一个字符处理。这时候该用C的std::mapstd::string, int或std::unordered_map统计的是子串而不是单字节。2.3 第23题实现字符串拼接与边界安全题目要求是自己写一个strcat把src拼接到dest末尾。很多同学一上来就写循环却漏掉结束符拷贝。先看最朴素的版本。void my_strcat(char *dest, const char *src) { while (*dest ! \0) { dest; } while (*src ! \0) { *dest *src; dest; src; } *dest \0; }第一步先让指针移动到dest的结束符处第二步把src逐字符复制过去最后补一个结束符。为什么不写while (*dest)因为这种写法会让指针在循环结束后越过\0的位置后续还要再减一次很容易绕晕。先判断再移动逻辑更直白。真正的考点在于“安全”。C语言的strcat不检查dest空间是否足够一旦src太长就发生缓冲区溢出。这是一个非常经典的漏洞来源。面试官如果追问你要能给出一个带容量检查的版本int my_strcat_safe(char *dest, size_t destSize, const char *src) { size_t destLen 0; while (dest[destLen] ! \0) destLen; size_t srcLen 0; while (src[srcLen] ! \0) srcLen; if (destLen srcLen 1 destSize) { return -1; } for (size_t i 0; i srcLen; i) { dest[destLen i] src[i]; } return 0; }这个版本最关键的地方是判断条件destLen srcLen 1 destSize加1是为了给最后的\0留位置。循环条件写成i srcLen这样把src的结束符也一并拷贝过去不用额外补一次。两个容易犯的错误第一忘记补结束符拼接后的字符串后面残留旧数据第二源和目标重叠比如my_strcat(str, str 3)这种情况下两个指针指向同一块内存复制过程可能无限循环。标准的strcat本来也不建议处理重叠区域你写实现时留意这一点就行。3. 指针与函数传参第24、25题3.1 第24题回文判断第24题常常写成这样判断字符串是否为回文忽略大小写和非字母数字字符。比如A man, a plan, a canal: Panama是回文因为过滤掉空格、逗号、冒号之后忽略大小写从前往后和从后往前读一样。#include ctype.h #include string.h int isPalindrome(char *s) { int left 0; int right strlen(s) - 1; while (left right) { while (left right !isalnum((unsigned char)s[left])) left; while (left right !isalnum((unsigned char)s[right])) right--; if (tolower((unsigned char)s[left]) ! tolower((unsigned char)s[right])) { return 0; } left; right--; } return 1; }双指针从两端往中间走。指针跳过非字母数字时内部循环的条件里必须再写一次left right。为什么假设字符串全部是空格外层循环条件成立内部跳过循环如果只写成!isalnum(s[left])left会一路往后冲直到越过数组末尾造成越界访问。加上left right之后跳过循环在左右指针相遇时自动停止外层循环也自然结束。isalnum和tolower的参数都应该转成unsigned char。我反复提这个是因为C标准里这些函数要求参数必须是unsigned char或者是EOF。如果传入一个负的char属于未定义行为在不同平台上表现还不一样属于那种“在自己电脑上跑得好好的交上去判题就错”的隐蔽问题。很多同学在这里还会漏掉大小写转换。直接比较s[left] ! s[right]遇到A和a就会误判为非回文。正确写法是两边都转成小写再比较。为了效率你也可以只把一边转小写另一边也转小写两边必须使用同一个转换规则。3.2 第25题交换两个整数第25题是典型的“会者不难难者不会”要求写一个函数交换两个整数并说明为什么要在函数间传递指针或引用。void swapPtr(int *a, int *b) { if (a NULL || b NULL) { return; } int temp *a; *a *b; *b temp; }如果只写成void swap(int a, int b)函数内部交换的是形参副本函数结束后实参一点变化都没有。想要影响外部变量必须拿到它的地址也就是指针。调用时写swapPtr(x, y)。指针版本有一种常见风险调用者可能传NULL比如误写swapPtr(NULL, y)函数内解引用就会空指针崩溃。所以在函数开头加空指针检查这是“防御式编程”的体现。C的引用版本则没有这个问题。void swapRef(int a, int b) { int temp a; a b; b temp; }引用天然不能为空而且可以像普通变量一样直接使用不用每次解引用。C里推荐优先用引用那为什么还需要指针因为指针可以被重新赋值指向别处可以参与数组遍历可以跟既有C代码交互还可以表示“可能不存在”的对象。面试问到这题基本就是希望你把这些区别说出来。这里还有一个经典坑用异或交换也就是a ^ b; b ^ a; a ^ b;。这种写法看似省了一个临时变量但如果你传入同一个变量的地址比如swapPtr(x, x)异或之后x会变成0。用临时变量永远不会踩这个坑。能够说出这个细节说明你不仅会写代码还了解底层运算的本质。4. 链表经典操作第26、27题4.1 第26题单链表反转链表操作是复习重点中的重点。第26题要求反转单链表返回新的头节点。这道题建议至少独立手写五遍因为它是后续很多复杂链表题的基座。struct ListNode { int val; struct ListNode *next; }; struct ListNode *reverseList(struct ListNode *head) { struct ListNode *prev NULL; struct ListNode *curr head; while (curr ! NULL) { struct ListNode *next curr-next; curr-next prev; prev curr; curr next; } return prev; }核心思想是维护三个指针prev指向已经反转部分的头节点curr指向当前待处理节点next保存老链表中尚未处理的后继。为什么必须用next先保存curr-next因为一旦执行curr-next prev当前节点的后继指针就断了如果不提前保存后面节点全找不回来。最容易犯的错是循环条件写成curr-next ! NULL。这样会在最后一个节点处停下没有把它接到新链表上最终返回的是倒数第二个节点。反向思考一下循环应该处理完所有节点才算结束所以条件是curr ! NULL。另一个常见错误是最后返回curr。循环结束后的curr已经是NULL正确的新头是prev。这道题还有递归版本面试时经常问struct ListNode *reverseListRecursive(struct ListNode *head) { if (head NULL || head-next NULL) { return head; } struct ListNode *newHead reverseListRecursive(head-next); head-next-next head; head-next NULL; return newHead; }递归的思路是先把head后面的链表整个反转得到一个新头newHead。此时head-next指向老链表中的下一个节点而这个节点经过反转后它的next应该指向head所以写head-next-next head。最后把head-next置空防止成环。递归的问题在于链表过长时会消耗大量栈空间但作为思路训练非常有价值。我建议你画一张三个节点的链表图手动走一遍迭代过程。指针的指向变化只能用图和手写来固化光看代码脑子记不住。4.2 第27题删除链表中间节点第27题有一个很经典的变种只给一个指向待删除节点的指针要求在O(1)时间内删除它。如果是常规遍历删除不难难就难在“只给节点本身”单链表没有前驱指针怎么删思路是“值替换法”void deleteNode(struct ListNode *node) { if (node NULL || node-next NULL) { return; } struct ListNode *next node-next; node-val next-val; node-next next-next; free(next); }具体操作是把node下一个节点的值复制到node中然后让node-next跳过下一个节点指向下下个节点最后释放下一个节点。从外部看node这个地址还在但它的值已经变成了原来下一个节点的值相当于把“下一个节点”变成“当前节点”再把不需要的节点删除。这种办法不会破坏链表的连续性。为什么要求node-next不为空因为如果待删节点是最后一个节点它后面没有节点可以复制值也就无法在O(1)时间内完成删除。这种情况下只能从头遍历找前驱时间复杂度退化为O(n)。很多题目会明确说“假设该节点不是尾节点”原因就在这里。你要能主动指出这个限制面试官会觉得你考虑问题全面。如果题目给的是链表头节点和要删除的值那么需要维护一个prev指针一边遍历一边记录前驱。删除头节点时还要把外部头指针更新为下一个节点。这里我踩过的坑是写删除逻辑时只想着把node释放了却忘了更新prev-next结果链表从这里断开后面的节点全部丢失。free之后这块内存已经归还给堆管理器再访问就是“使用已释放指针”属于未定义行为。所以好习惯是释放后顺手把指针置为NULL。虽然某些场景下不会立刻崩溃但你已经站在了悬崖边上。5. 查找排序与动态内存第28、29、30题5.1 第28题二分查找第28题是二分查找在有序数组中找目标值下标找不到返回-1。这道题代码不长但边界条件极其容易出错。几乎所有死循环都出在区间更新上。int binarySearch(int *a, int n, int target) { int left 0; int right n - 1; while (left right) { int mid left (right - left) / 2; if (a[mid] target) { return mid; } else if (a[mid] target) { left mid 1; } else { right mid - 1; } } return -1; }中点计算不要写(left right) / 2。当left和right都很大时left right可能超过int上限产生整型溢出。写成left (right - left) / 2先把区间长度减半再加到左边界上既安全又准确。循环条件是left right含义是当前搜索区间仍非空。区间更新时left mid 1和right mid - 1必须带偏移因为mid已经被比较过不需要再留在区间里。如果写成right mid同时循环条件还是left right区间永远无法缩小死循环就出现了。递归版本也很常见int binarySearchRecursive(int *a, int left, int right, int target) { if (left right) { return -1; } int mid left (right - left) / 2; if (a[mid] target) { return mid; } else if (a[mid] target) { return binarySearchRecursive(a, mid 1, right, target); } else { return binarySearchRecursive(a, left, mid - 1, target); } }递归出口写left right和迭代的! (left right)完全对应。二分查找真正的练习价值在于变体找第一个大于等于目标值的位置、找最后一个小于等于目标值的位置。把这些变体都写一遍你对“区间不变式”的理解会上升一个台阶。5.2 第29题快速排序的 partition 实现第29题要求实现快速排序写出核心的partition函数。快排的关键不是递归本身而是如何把数组分成“左边都小于基准右边都大于等于基准”。这里用的是Lomuto分区方式代码比较短适合手写。int partition(int a[], int low, int high) { int pivot a[high]; int i low - 1; for (int j low; j high; j) { if (a[j] pivot) { i; int tmp a[i]; a[i] a[j]; a[j] tmp; } } int tmp a[i 1]; a[i 1] a[high]; a[high] tmp; return i 1; } void quickSort(int a[], int low, int high) { if (low high) { int pivotIndex partition(a, low, high); quickSort(a, low, pivotIndex - 1); quickSort(a, pivotIndex 1, high); } }理解这个partition的核心是搞清楚变量i的含义。在整个扫描过程中a[low...i]是所有已经发现的、小于基准值的元素j负责从low扫到high - 1。每当遇到一个小于基准值的元素就让i前进一位把这个元素交换到“小值区”末尾。扫描结束后下标i 1所在位置就是基准值应该在的位置把它和最后一个元素交换就完成了分区。如果数组已经有序还固定选最后一个元素当基准每次分区都极不平衡复杂度会退化成O(n²)。面试时你可以提一句随机选基准可以明显降低这种退化概率。实现方式是在partition之前随机选一个下标和high交换。这道题的扩展价值很大partition也可以用来找第K大元素不用完全排序只需要不断缩小左侧或右侧区间。如果你能把快排的partition写熟后面学堆排序、归并排序也会顺手很多。5.3 第30题动态数组扩容与 realloc第30题不再是单纯的数据结构题而是内存管理题。要求用malloc和realloc实现一个自动扩容的整数数组初始容量为4满了就翻倍。typedef struct { int *data; int size; int capacity; } IntArray; void initArray(IntArray *arr) { arr-capacity 4; arr-size 0; arr-data (int *)malloc(arr-capacity * sizeof(int)); if (arr-data NULL) { exit(1); } } void append(IntArray *arr, int value) { if (arr-size arr-capacity) { int newCapacity arr-capacity * 2; int *tmp (int *)realloc(arr-data, newCapacity * sizeof(int)); if (tmp NULL) { return; } arr-data tmp; arr-capacity newCapacity; } arr-data[arr-size] value; } void freeArray(IntArray *arr) { free(arr-data); arr-data NULL; arr-size 0; arr-capacity 0; }扩容为什么要翻倍而不是每次加1因为翻倍后均摊到每次插入的时间复杂度是O(1)。如果每次只增加一个元素的空间那么插入n个元素需要重新分配n次内存总代价接近O(n²)数据量大时性能惨不忍睹。动态数组实际扩容时会先把老数据复制到新空间再释放旧空间realloc把这几步合并了但使用者还是要小心返回值。我最想强调的坑是不要直接写arr-data realloc(arr-data, newCapacity * sizeof(int))。如果realloc失败它会返回NULL但原来那块内存仍然有效。这时如果直接覆盖arr-data原指针就丢了既无法继续使用旧数据也无法释放它属于双重灾难。正确做法是先用一个临时变量接收返回值判断不是NULL后再赋值给arr-data。扩容失败时函数直接return数组还是旧容量、旧数据不会丢失。虽然这里没有打印错误但至少不会造成更严重的破坏。释放内存之后把data置为NULL也很重要这样即使后面误用也会马上看到空指针崩溃而不是神秘的野指针行为。C里写这道题通常用std::vector它内部就是类似这样的扩容逻辑。很多人只知道push_back好用却不知道为什么频繁插入还能保持高效。把这题亲手写一遍以后遇到“reserve有什么意义”“为什么提前指定容量更快”这样的问题你就能从底层回答。6. 刷题过程中的常见问题与排错清单6.1 三个高频Bug我把第21-30题里常见的错误归成三类这三类我在不同人身上至少见过十次以上。数组越界字符串反转把\0当成普通字符字符统计时char下标为负回文判断内部跳过循环没写left right二分查找right初始化写成n。指针悬挂和内存泄漏链表反转没保存next导致断链删除节点后继续访问已释放内存realloc返回值直接覆盖原指针导致原地址丢失。边界条件缺失空字符串、单字符、空指针、删除头节点、只剩一个节点的链表这些情况最容易翻车也最容易在判题系统里扣分。排查问题最好的办法不是盯着代码看而是用小而全的用例去跑。反转用、a、ab、abc二分用数组长度1、目标在开头、目标在结尾、目标不存在链表用空链表、一个节点、两个节点。这些用例体积小但能把大多数边界问题逼出来。6.2 复习建议与检查清单第21-30题我建议分三轮来刷。第一轮不限时间对着知识点慢慢写目标是理解每个函数的语义第二轮限时30分钟训练手感和熟练度第三轮只看题目名称在纸上直接默写关键函数。到第三轮如果还能把链表反转、二分查找、快排partition写得流畅考试或面试基本就不慌了。每道题最好掌握至少两种实现方式字符串反转的原地版和拷入新数组版链表反转的迭代版和递归版二分的迭代版和递归版。不同写法能强迫你从不同角度理解同一份数据流动而不是拿着一种写法背到底。编译阶段建议同时用GCC和G试一遍C和C的写法。遇到段错误用gcc -g编译后配合调试器查看崩溃行号也可以开启地址消毒器直接把越界或者释放后使用的问题报出来。工欲善其事必先利其器这些工具能省下大量排查时间。这批题我第一次做的时候第26题链表反转整整调了一个小时最后发现循环条件少写了curr ! NULL。后来我养成了一个习惯每道题都先画图让指针的名字跟着图走。我个人觉得第21-30题比后续那些偏算法的题目更重要因为它们检验的是你对计算机底层部件的直觉。这种直觉不是看视频能得到的只能靠手写、编译、调试、出错、改正来积累。如果你复习时间有限至少把今天这10道题完整写一遍比盲目刷量管用得多。