
不用刻意写一个完整的大标题直接从第一篇的学习日志切入。这个“Day10”不是某一天的流水账而是一个C语言初学者在语法关和思维关交界处的真实记录前面学了变量、分支、循环、函数到了第十天终于要正面碰数组、字符串和指针。今天这篇就把我当天学到的核心内容、踩过的坑和顺手做掉的小练习题完整复盘一遍。1. Day10在C语言学习路线里的位置告别“玩具代码”的分水岭前九天我基本是按照市面上最常见的C语言课程节奏在走先搞懂数据类型和变量定义再啃printf和scanf的格式控制然后是if/else、switch、while和do-while的区别、for循环的嵌套最后学了函数的基本写法。说实话到昨天为止我写出来的所有程序都还是“顺序执行分支循环”的套路处理的数据量最多也就是几个变量来回倒腾。第十天之所以特殊是因为我要第一次面对真正意义上的“批量数据”和“内存操作”了。从这一天开始程序不再只是“输入一个数、算一下、输出一个结果”而是要处理一整组数、一整串字符甚至是直接去读写内存地址。如果你也在学C语言你会发现一个规律前面几天的内容再怎么学总感觉还是在用“高级语言思维”写程序只有当你开始碰数组和指针你才真正开始用“C语言思维”写程序。今天的核心任务其实可以拆成三块数组的定义与遍历、字符串的本质字符数组、指针的入门概念。这三块内容在谭浩强第六版里差不多是第5章到第8章的内容但在翁恺老师的课程里第十天左右恰好正是数组和指针交替出现的高峰期。如果你用的是其他教材大体节奏也类似——语法上它们互相依赖学习上也必须放在一起理解。我之前一直有个误解觉得数组很简单不就是“一堆同类型变量的集合”吗真上手才发现数组的难点根本不在“定义”和“初始化”而在“下标到底从几开始”“数组越界了会怎样”“数组名和指针到底什么关系”这些细节上。今天一天下来我的直观感受是数组这个知识点本身不难但它牵扯出来的内存布局和指针概念才是真正的门槛。这也是为什么几乎所有C语言学习路线都会把Day10前后安排成“理解程序的存储方式”的关键节点。如果你现在正好也学到这里我建议你先有个心理准备从今天起单纯“背语法”已经不够了你得学会在脑子里画内存图。每定义一个变量都要知道它在内存里占了几个字节、地址是多少、能存什么范围的值。这种思维一旦建立起来后面的链表、结构体、文件操作都会顺很多。2. 数组定义与遍历从“一个变量”到“一整组数据”的思维切换2.1 定义分类和初始化方式先搞清楚有多少种写法今天上午第一件事就是系统过了一遍数组的“定义分类”。C语言里数组定义虽然看着简单但实际组合起来有几种容易混淆的变体一维数组的基本定义int arr[10];——定义一个能存10个int的数组下标从0到9。指定长度的初始化int arr[5] {1,2,3,4,5};——长度由初始化列表决定。部分初始化int arr[5] {1,2};——后面的三个元素自动补0。不写长度的初始化int arr[] {1,2,3,4,5};——编译器根据初始化列表自动确定长度为5。字符数组的特殊写法char str[] hello;——注意这里实际占了6个字节末尾自动加\0。这些写法你要是第一次看肯定会觉得“这不是差不多的意思吗为什么要有这么多种”实际上它们对应的是不同的应用场景。比如部分初始化在写计数器数组时特别常用——你需要一个长度为26的int数组统计每个字母出现的次数直接int count[26] {0};一行搞定不用写循环去清零。而“不写长度”的初始化方式在字符串处理里几乎是标配因为字符串本来就不想被固定长度束缚。今天光是数组定义我就整理了满满一页笔记后来发现核心其实就一句话数组名是这块连续内存的起始地址中括号里的数字是元素的个数或下标数组的大小在定义时必须是编译期能确定的常量C99之前或者用变长数组C99之后。初学者最容易栽的跟头就是把arr[10]里那个10当作最大下标明明定义了10个元素却去访问arr[10]结果编译器不报错程序运行起来要么输出一个莫名其妙的值要么直接崩溃。这就是典型的“数组越界”是C语言最臭名昭著的问题之一也是很多安全漏洞的根源。2.2 遍历与下标偏移为什么我建议你亲手画一遍内存格子数组的遍历是今天的第一个实操练习。一个简单的需求输入10个数按逆序输出。我的第一版代码是这样的#include stdio.h int main() { int arr[10]; for (int i 0; i 10; i) { scanf(%d, arr[i]); } for (int i 9; i 0; i--) { printf(%d , arr[i]); } return 0; }代码本身没什么问题但我在写的时候脑子里其实模糊了一下倒序输出的循环变量到底是从9开始还是从10开始为什么结束条件是i 0而不是i 0为了彻底搞明白我在纸上画了这样一个表格下标0123456789地址偏移基准0基准4基准8基准12基准16基准20基准24基准28基准32基准36这里有个很重要的细节数组元素的下标对应的是“从首地址开始的第几个元素”而实际上编译器计算地址时是“首地址 下标 × 每个元素的大小”。比如int通常是4字节那么arr[5]的地址就是基地址 5*4 基地址 20。我头一次真正理解数组时就是这个“乘以元素大小”的瞬间——原来数组下标不是简单的“第几个”而是“偏移了多少个字节”。一旦你理解了偏移量后面学指针、学结构体数组、学二维数组全都一通百通。我当时为了验证理解还写了一段小代码打印每个元素的地址#include stdio.h int main() { int arr[5] {10, 20, 30, 40, 50}; for (int i 0; i 5; i) { printf(arr[%d] %d, address %p\n, i, arr[i], arr[i]); } return 0; }输出结果大概是arr[0] 10, address 0x7ffd...每次地址加4。亲眼看到地址跳变之后数组的“连续内存”概念才真正在我脑子里立住了。我强烈建议每个初学者都做一遍这个实验光凭想象去理解“连续内存”远不如直接打印地址来得直观。2.3 数组变量与普通变量的类型差异几个容易混淆的概念今天还有一个特别容易混淆的点就是“数组变量”和“普通变量”在赋值和传参上的行为完全不一样。普通变量可以直接赋值int a 10; int b a;但数组不能整体赋值int arr1[5]; int arr2[5]; arr2 arr1;这种写法是编译错误的。原因是数组名在表达式里会“退化”为指向首元素的指针本质上是一个地址值而不是一个可以整体移动的“数据块”。要想把一个数组的内容复制到另一个数组必须用循环逐个元素赋值或者用memcpy函数。这种“退化为指针”的规则是C语言里让新人最崩溃的一条。一个简单的性子int arr[5] {1,2,3,4,5}; int *p arr; // 合法arr退化为指向arr[0]的指针后面学了指针之后回头看数组和指针的关系可以用一句话概括数组是“一段内存的声明”指针是“指向这段内存的变量”。它们在访问形式上几乎可以互换但在语义上完全不同。今天先把“数组不能整体赋值”这个坑记下来后面学函数传参时还会碰到——函数参数里写int arr[]本质是int *arr那时候再回头看这句话会更有感触。3. 字符串与字符数组\0这个看不见的哨兵决定了你的bug数量3.1 字符串的本质它不过是一个以空字符结尾的字符数组今天的重头戏之一是“字符串”。学之前我以为字符串是一种独立的类型学完发现C语言里根本没有字符串类型所谓字符串其实就是char数组只不过约定最后一个元素是\0空字符ASCII码为0。这个设计在当年是为了节省内存和简化语言实现但代价就是所有处理字符串的函数都要自己判断终止位置。这里有一个所有新手都会掉的坑定义一个字符数组来存字符串时长度到底该给多少比如要存hello你需要char str[6]而不是char str[5]因为5个字符后面还要留一个位置给\0。如果你写了char str[5] hello;编译器会直接报错因为存不下6个字节。但如果你用char str[] hello这种写法编译器会自动算好长度这也是我推荐初学者优先使用的写法——让编译器帮你数数别自己去数数错的概率实在太高了。我实际验证过一次把字符串逐个字符输出看看到第几个字符会出现\0#include stdio.h int main() { char str[] hello; for (int i 0; i 10; i) { printf(%d: %c (code: %d)\n, i, str[i], str[i]); } return 0; }输出到第5个字符之后str[5]的代码就是0再往后打印出来是空白或乱七八糟的东西。这个实验帮我理解了一个重要事实printf遇到字符串时用%s输出它会一直读内存直到碰到\0才停下来。如果你构造字符串时忘了加\0printf(%s, str)就不知道什么时候停会一口气把后面内存里的随机字节全打印出来直到运气好碰到一个0为止。这个“缓冲区读取越界”的问题是所有字符串处理bug的根源。3.2 字符串函数库里的常用操作strlen并不“算长度”下午做练习时我把常用的字符串函数逐个梳理了一遍主要是这几个strlen、strcpy、strcmp、strcat。每个函数都有坑strlen(str)返回的是字符串长度但不包含末尾的\0。也就是说hello的strlen是5。这个函数是逐个字符扫描直到遇到\0为止时间复杂度是O(n)如果你在一个大循环里反复调用strlen性能会肉眼可见地变差。strcpy(dest, src)把src的内容复制到dest包括末尾的\0。这里最容易犯的错误是dest空间不够大结果把数据写到dest后面去了——所谓的“缓冲区溢出”。我建议在复制之前先用strlen(src)确认一下长度或者直接用strncpy指定最大复制长度。strcmp(a, b)逐个字符比较两个字符串返回值是第一个不相等字符的ASCII码差值而不是简单的“大于0就是a大”。初学者经常会误以为它返回1或0。strcat(dest, src)把src接到dest末尾它会自动找到dest的\0位置然后从那里开始追加。同样的缓冲区溢出风险。我顺手写了一个常见的“字符串逆序”练习很多学校PTA和OJ上都有这道题。我的第一版代码是这样的#include stdio.h #include string.h int main() { char str[100]; gets(str); int len strlen(str); for (int i len - 1; i 0; i--) { putchar(str[i]); } return 0; }这里有个老生常谈的问题gets函数已经被C11标准废除了因为它没有办法限制输入长度非常容易造成缓冲区溢出。正确做法是用fgets(str, sizeof(str), stdin)——它会读入最多sizeof(str)-1个字符并自动在末尾补\0。但fgets有个小坑如果输入不足一行它会把末尾的换行符也保留下来。这个换行符经常导致字符串比较失败或输出多空一行解决办法是手动把最后一个字符换成\0fgets(str, sizeof(str), stdin); str[strcspn(str, \n)] \0; // 把换行符替换成字符串结束符说真的字符串这块内容看起来不难但它是后续所有C项目里bug的高发区。今天如果能把\0这个概念彻底吃透后面学文件读写、学链表存储用户数据时会少踩很多坑。3.3 字符串逆序之外的另一个小练习字符频率统计下午为了巩固数组和字符串的组合使用我又做了一个经典的统计练习输入一行字符串统计每个英文字母出现的次数。核心逻辑就是数组下标和字符ASCII码之间的转换#include stdio.h #include string.h #include ctype.h int main() { char str[200]; int count[26] {0}; fgets(str, sizeof(str), stdin); for (int i 0; str[i] ! \0; i) { if (isalpha(str[i])) { char lower tolower(str[i]); count[lower - a]; } } for (int i 0; i 26; i) { if (count[i] 0) { printf(%c: %d\n, a i, count[i]); } } return 0; }这个练习的价值在于lower - a这个操作充分利用了字符在ASCII表中连续排列的特性。你不需要去背a到底是多少97只要知道字母之间是连续的用相对偏移量就能作为数组下标。这种“字符转数值下标”的思路是后面各种查表法、哈希表初阶的基础。比如以后做词频分析、压缩算法基本思路都和这个一模一样。4. 指针初体验从“有两个变量”到“两个变量都指向同一个内存”的震惊4.1 为什么第十天必须引入指针它是C语言的灵魂很多人会问第十天就上指针是不是太快了我的观点是不快而且正好。因为如果你已经理解了数组在内存中的连续布局再学“指针就是保存地址的变量”这个概念就只是顺水推舟。指针本身并不难难的是从“值”的思维切换到“地址”的思维。指针的定义形式五花八门但核心就一句话int *p;——p是一个变量它里面存的不是int值而是一个int变量的地址。你可以用取地址运算符取一个变量的地址也可以用*解引用运算符通过地址访问它指向的变量。我今天的第一个指针练习是这样写的#include stdio.h int main() { int a 42; int *p a; printf(a %d\n, a); printf(a %p\n, a); printf(p %p\n, p); printf(*p %d\n, *p); *p 100; printf(after *p 100, a %d\n, a); return 0; }运行之后我盯着输出看了好一会儿*p 100之后a的值也变成了100。我明明没有给a赋值为什么它的值变了因为p存着a的地址*p就是通过地址直接操作a本身。这就像你手上有一把钥匙钥匙上写着某个储物柜的编号你用钥匙打开柜子换了里面的东西柜子里的东西当然就变了而不是“另有一个柜子被改了”。这个“通过地址间接修改变量”的机制是后面学函数参数传递传值vs传址、链表、动态内存分配时最基础的思想。4.2 指针类型到底有什么意义决定你读写几个字节今天最颠覆我认知的一个知识点是“指针的算术运算”。刚开始我以为指针就是一个地址数字那p 1就应该等于地址值加1呗。实际跑了一下才发现完全不是int arr[5] {10, 20, 30, 40, 50}; int *p arr; printf(p %p\n, p); printf(p 1 %p\n, p 1);输出结果是如果p等于0x7ffd...0那么p 1等于0x7ffd...4而不是...1。因为编译器知道p是一个int*所以p 1在原地址基础上加了“一个int的大小”也就是4个字节。同理char*的p 1只加1double*的p 1加8。指针类型的意义在于告诉编译器“这个地址上的数据占多大空间、该怎样解释”。它决定了当你读写这个地址时是取1个字节还是一个int是把它当作有符号数还是无符号数。这也是为什么C语言里指针类型转换比如把int*强转为char*会从根本上改变对同一块内存的解释方式。这里我遇到一个特别反直觉的例子用一个int*去遍历字符串能输出吗试试看char str[] abc; int *p (int *)str; // 强制把char*转成int* printf(%c\n, *p); // 会输出a吗结果是完全没把握。因为int*一次会读4个字节然后把那4个字节解释成一个int值你再用%c输出输出的其实是这个int值的最低1个字节的ASCII字符。虽然运气好可能还是a但本质上已经是“对内存的不同解释”。搞懂这个例子之后我彻底明白了“指针类型决定解释方式”这句话的含义。这也是为什么C语言不做强制类型检查很容易出错但也正是这种灵活性给了底层编程嵌入式、操作系统、网络协议栈极大的控制能力。4.3 指针与函数参数为什么交换两个变量的值需要传指针今天另一个经典练习是“交换两个变量的值”。如果写成这样void swap(int a, int b) { int temp a; a b; b temp; }在主函数里调用swap(x, y)你发现x和y根本没有变化。原因是C语言函数的参数传递是“传值”的函数内部拿到的a和b是主函数里x和y的拷贝函数里修改拷贝根本不影响原始变量。如果要真正改变主函数里的变量必须传入指针void swap(int *a, int *b) { int temp *a; *a *b; *b temp; }调用时写swap(x, y)。这里的关键区别swap内部拿到的不是x和y的值而是它们的地址。通过地址去操作才能真正修改原始变量的内存单元。这个知识点如果你今天没想明白后面学链表的增删节点、学二叉树的插入、学文件指针的移位全部都会卡壳。因为所有“需要修改调用方数据”的场景都必须通过指针传递。今天我在笔记本上记了一句话“传值改不了原值传地址才能改原值。”虽然直白到有点像口诀但真的实用。5. 今天做过的练习与踩坑翁恺练习题、PTA乙级、乱码与缓冲区5.1 一道翁恺风格的练习题输入日期算这是第几天今天做完基础练习之后我挑了一道典型的日期计算题输入一个年月日计算并输出这是这一年的第几天。这道题在很多OJ上都有变体翁恺老师的练习题里也包含类似逻辑。我一开始用了最笨的方法判断月份累加前面所有月份的天数最后再加上当月天数。然后又想“优化”成switch的方式。实际写下来最简洁的做法是先把每个月的天数放到一个数组里int days_in_month[] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};然后判断闰年如果是闰年且月份大于2总天数再加1。这个解法用到了今天的数组知识点比堆一长串if/else清爽多了。题目本身不难但我在这里踩了个隐蔽的坑数组下标从0开始而月份从1开始。访问days_in_month[month]时如果忘了减1就会取到下一个月的天数。这种“下标偏移一”的问题在真实项目中也是常见的bug来源往往肉眼检查半天发现不了调试器一看到month2取到31才恍然大悟。5.2 PTA乙级1037“在霍格沃茨找零钱”与进制换算思路今天练习题里的另一个老朋友是PTA乙级的“1037 在霍格沃茨找零钱”。题目大概是说在魔法世界里货币单位是Galleon、Sickle、Knut进制关系和现实世界不太一样17 Knut 1 Sickle29 Sickle 1 Galleon让你计算找零。这道题其实和进制转换、借位减法有关。我的第一反应是直接按位减法结果发现借位处理特别繁琐。后来我换了个思路把所有金额统一换算成最小的Knut单位做一次整数减法再换算回去。这样写不仅代码量少而且不容易出错#include stdio.h int main() { int g1, s1, k1, g2, s2, k2; scanf(%d.%d.%d %d.%d.%d, g1, s1, k1, g2, s2, k2); int total1 g1 * 17 * 29 s1 * 29 k1; int total2 g2 * 17 * 29 s2 * 29 k2; int diff total2 - total1; if (diff 0) { printf(-); diff -diff; } printf(%d.%d.%d\n, diff / (17 * 29), diff % (17 * 29) / 29, diff % 29); return 0; }这个思路在“时间差”“日期差”等场景里其实是通用的先全部换算成最小单位再相减最后格式化输出。我在书上还看到过类似的题算日期差、算两个时刻的时间差本质都是一样的。学C语言到一定阶段你会慢慢发现很多题刚开始觉得是“算法题”实际上只是“单位换算思路”的应用。这个“统一到最小单位再计算”的思想值得记在笔记本上。5.3 实操中的常见坑scanf缓冲区、乱码、vscode配置今天在代码运行过程中还遇到了几个环境层面的问题顺手记录一下。第一个是scanf的缓冲区问题。我写了一个循环读取多个整数运行后发现第一次输入后程序会跳过后续输入或多读一个空行。这是因为scanf在处理完数据后缓冲区里可能还留着换行符紧接着的下一次%c会把它读走。解决办法是在%c前加空格比如scanf( %c, ch)或者用getchar()把换行符吃掉。另外一个问题是乱码。我用VSCode写C语言时程序里含中文注释一编译运行中文就变成乱码。原因是Windows下的VSCode终端默认编码可能是GBK而源代码文件保存成了UTF-8。解决办法有两个一是把文件保存成GBK编码二是在程序开头设置setlocale(LC_ALL, zh_CN.UTF-8);并包含locale.h头文件。更省事的方案是把代码文件统一为UTF-8在VSCode设置里把终端编码改成UTF-8。这个问题在学习阶段特别容易遇到但它不是C语言本身的问题而是编码环境的经典坑。第三个是VSCode怎么运行C语言代码。我这里用最简单的方式说明一下装好C/C扩展和Code Runner插件后新建.c文件点击右上角的运行按钮Code Runner会调用gcc编译并运行。如果你用的是虚拟机里的Ubuntu那就更简单了终端里直接gcc -o hello hello.c ./hello我自己的建议是不管用不用IDE都要自己能在命令行里用gcc编译一次这样你对“源文件 → 目标文件 → 可执行文件”这条链路会有直观认识。环境配置这块内容虽然不算C语言语法本身但它直接影响你是不是能顺利跑通练习题所以每次都值得多花十分钟把它调好。5.4 今天用过的调试手段printf大法与gdb简单操作截图说到调试今天还真帮我抓住了一个隐蔽bug。我在写字符统计程序时输出结果里多了很多空白字符我就一个个加printf看每个步骤的变量值这招俗称“printf大法”虽然土但在学习阶段足够用。不过遇到真正难以定位的内存问题还是得上调试器。今天试了一下gdb在虚拟机的Ubuntu里最基本的操作流程是gcc -g -o debug debug.c # 编译时加-g参数保留调试信息 gdb ./debug break main # 在main函数设置断点 run # 运行到断点 print a # 查看变量a的值 next # 单步执行 continue # 继续运行我首次跑gdb时最大的困惑是不知道print到底应该打什么后来明白了print 变量名直接看变量值print 变量名看地址print *指针看指针指向的值。调试器这东西刚开始用会觉得很麻烦但当你面对“程序崩溃但不知道在哪一行”的情况时它几乎是唯一的高效手段。今天我用gdb定位了一个数组越界的问题——程序跑完后崩溃backtrace命令直接指到越界的那一行循环效率比肉眼查代码快太多了。6. Day10后的三个自我提醒与扩展学习方向6.1 数组和指针不是一码事别再被“指针就是数组”带偏今天的学习内容下来我最大的感受是数组和指针的关系非常微妙但千万不能简单粗暴地理解为“等价”。数组是内存里的一段连续空间数组名在大多数表达式里退化为指向首元素的指针但在sizeof运算里数组名代表的是整个数组的大小而不是一个指针的大小。比如int arr[10] {0}; printf(%zu\n, sizeof(arr)); // 输出4010 * 4字节 printf(%zu\n, sizeof(arr 0)); // 输出8指针大小这两个结果不一样这个差异特别能帮人理解“数组是一个实体指针是一个指向实体的变量”。我建议你亲手跑一次这段代码体会一下两个输出值为什么不同。6.2 记住三个“万万不可”的常见错误今天一天下来我给自己总结了三条必须牢记的红线字符串数组长度不够塞不下结尾的\0造成缓冲区溢出。数组下标从0开始循环遍历时注意边界条件的i nではなくi n这种错误。用gets读取字符串几乎必定造成未定义行为现在全用fgets代替。这三条是我实际踩过的坑写在这里提醒自己也提醒一起学C语言的各位。C语言不像Java或Python那样会帮你在运行时报一个“越界异常”它会在你越界后静默地读取或写入相邻内存后果可能是乱码、崩溃也可能是程序看似正常但数据已经悄悄被改坏了。这种“静默错误”最难排查所以最好的策略是从一开始就写出防守型的代码——比如严格检查循环边界、给数组多留一点空间、使用带长度限制的字符串函数。6.3 后面几天的学习线索从数组到结构体、函数递归与文件Day10之后我的学习路线大致是这样规划的第11天到第12天把指针的算术运算、指针和数组的混合使用二维数组、指针数组、数组指针彻底打牢第13天开始学函数指针和递归第14天左右会接触结构体把多个不同类型的数据打包成一个整体第15天到第16天进入动态内存分配malloc/free和链表。再往后就是文件操作了。这些内容其实都和今天的知识挂钩。比如结构体的本质不过是一个“更大块的内存”而链表节点之间的连接靠的就是指向下一个节点的指针。今天打好的底子直接决定了后面能不能跟上。所以我的经验是第十天到第十二天这段时间宁可放慢一点把数组、字符串、指针这三个东西在纸面上反复画内存图也不要赶进度。很多C语言学到一半放弃的人基本都是在这几天没有把指针想清楚后面一路稀里糊涂。我个人学习时还有一个习惯就是每道练习题做完后在代码旁边写一行注释说明“这道题我用到数组的哪个特性”或者“这里为什么要用指针”。看似多花了半分钟但回头复习的时候你能很快从一堆代码里回忆出每道题的核心考点。这个方法帮我撑过了后面好几周的密集训练也推荐给正在啃C语言的你。Day10这一天学完我对C语言有了完全不同的感觉原来写程序不是在“告诉机器要做什么”而是在“安排数据的存放与流转”。这个转变不发生在第一天也不发生在写了大几百行代码之后恰恰就是发生在你第一次认真研究数组和指针的那几天。今天把这些笔记整理出来既是给自己的复盘也希望给正在同样进度上挣扎的你一点参照别怕卡壳把内存图画明白慢慢就通透了。