很多学 C 语言的人都会在第一章遇到这样一道题写一个程序统计输入中每个单词的长度并打印直方图。题目里还特别点了一句水平方向的直方图比较容易垂直方向就要困难一些。我第一次做的时候觉得这句话有点故弄玄虚不就是换个方向打印几根柱子吗真动手写才发现难的不是画柱子而是搞清楚“从上往下画”这件事跟“数据统计结果”之间到底该怎么对齐。这篇就按照一个过来人的视角把这道题的拆解思路、完整代码、常见坑和扩展方向一次讲透。这道题最典型的场景是练习三件事第一用 getchar 从头到尾处理字符流而不是用 scanf 把整个输入读下来第二用一个状态变量来标记当前是否处于“某个单词内部”从而正确识别单词边界第三用数组的下标来映射长度用数组的值来记录频数。这三个点恰好覆盖了字符输入、布尔状态、环形计数和数组建模这几个 C 语言基础能力。后面直方图的打印本质上是把数组内容转换成可视化的格式输出水平版是“顺着数组一路打星号”垂直版则要动一下脑筋你得先知道所有柱子里面最高的那根有多高才能从最顶行开始画。1. 题目在考什么拆掉练习 1-13 的骨架1.1 输入、输出和中间数据长什么样题目说的“输入”不是命令行参数也不是某个固定字符串而是程序运行后你在终端里敲进去的任意字符流。程序要逐个字符地读直到遇到 EOF 为止。这个设定决定了你没法先把所有单词都保存下来再处理——理论上可以但数组长度不好定而且就练习初衷来说逐字符处理才是这一章希望训练的核心。“输出”则是一个直方图。横向版本里每一行对应一个长度值星号数量表示这个长度出现了多少次。纵向版本里列是长度值列上的星星层数表示数量。两者表达的信息完全一样区别只在于你从哪个方向看这份“统计报表”。中间数据应该存成什么样我推荐用“下标即长度值即频数”的数组方案也就是wl[len]表示“长度为 len 的单词出现了 wl[len] 个”。长度为 0 的单词没有意义所以wl[0]放着不用从wl[1]开始统计。假设最长单词不超过 10 个字符开一个wl[11]的数组就行。固定上限的好处是初学者不需要碰动态内存分配坏处是如果测试输入里出现一个超长单词程序就得处理“超出范围”的情况否则数组越界会带来隐藏在暗处的数据踩踏问题。1.2 为什么用“数组下标 长度”来存数据这个问题值得多说两句。很多初学者会尝试用另一个结构先开一个大数组把每个单词的长度按出现顺序存下来比如lens[0]3, lens[1]5, lens[2]1然后打印时再统计每种长度出现多少次。这当然也能做但麻烦得多长度顺序存下来了可要画直方图时还得再遍历一遍而且你还得提前知道一共有多少个单词否则第二遍统计时照样要处理扩容问题。反过来用wl[len]直接做频数统计统计和展示的衔接会顺畅很多。画水平直方图时for (len 1; len MAXLEN; len)然后打星号画垂直直方图时外层循环控制高度内层循环遍历len判断当前这个高度上对应的柱子要不要画星。核心思路是把“频数”和“坐标”在数组下标这一层就映射好。这种下标语义化的建模方式在以后接触更复杂的数据结构时也会反复用到。1.3 状态机别让连续空格骗了你的程序在动手写代码前还有一个绕不开的点怎么判断一个单词何时结束。最直觉的做法可能是“遇到空格就算一个单词结束”但输入里常有多个连续空格或者一行末尾紧跟着换行如果每次遇到空格都切分就会出现空单词、长度为 0 的假记录。正确做法是维护一个状态变量。我用两个常量来表示#define IN 1 #define OUT 0初始时处于OUT状态。读到一个非空白字符就把状态置为IN同时把当前单词长度nc加一。读到空白字符时如果当前状态是IN说明确实有一个单词结束了这时才统计长度然后把状态重置为OUT。如果当前状态本来就是OUT说明这是一个连续空白符直接忽略。这个道理和“进门出门”很像你说“出门”的前提是之前已经“进门”了连按两次关门铃不代表你出两次门。状态机虽然听起来像玄学但落到字符处理上就是这样一个朴素的判断。2. 统计逻辑先把长度分布数据做干净2.1 核心循环的写法和状态切换细节这一节先只做“统计”不画图。把数据统计对了后面画图才不是空中楼阁。统计部分的骨架如下#include stdio.h #define MAXLEN 10 #define IN 1 #define OUT 0 int main(void) { int c, nc, state; int wl[MAXLEN 1]; int i; for (i 0; i MAXLEN; i) wl[i] 0; state OUT; nc 0; while ((c getchar()) ! EOF) { if (c || c \t || c \n) { if (state IN) { if (nc MAXLEN) wl[nc]; else wl[MAXLEN]; nc 0; state OUT; } } else { state IN; nc; } } // 如果输入在单词中间结束也要把这个单词补上 if (state IN) { if (nc MAXLEN) wl[nc]; else wl[MAXLEN]; } return 0; }这段代码里的关键点在于计数不是在你读到空格的那一瞬间做的而是在你“知道一个单词结束”的时候做的。state IN这个条件能保证只有“确实在单词内部遇到空白”才会触发统计连续空格不会误判。每次处理完一个单词后nc清零状态回到OUT等待下一个单词开头。非空白字符的处理相对简单只要不是空格、制表符、换行都算作单词的一部分。这里要注意的是标点符号也会被算进单词长度里比如hello,会被当成一个长度为 6 的单词。教材里的单词定义通常就是“不含空白符的字符序列”所以这不算 bug但这个特点要在心里有数否则测试时容易被输出里的“超长单词”吓一跳。2.2 边界条件逐个过末尾单词、连续空格、超长单词边界条件是这类字符统计题最容易翻车的地方而且翻车时机往往是你以为程序已经写完、准备提交的时候。第一个高频坑是文件末尾的单词没有被统计。假如输入是一行hello最后以 EOF 结束没有换行符。上面的循环读到hello的最后一个o以后下次getchar返回 EOF循环直接退出state还是IN如果没有 flush 逻辑这个单词就丢掉了。所以循环结束后必须再补一次“如果还在单词里就统计长度”的操作。第二个坑是连续空白符。你用一段文本测试时可能不觉得但如果你手滑多敲了一个空格比如hello world两个空格中间夹着的状态变化是读到第一个空格时state为IN统计hello状态置为OUT读到第二个空格时state已经是OUT什么都不做。这是状态机的功劳。反过来如果代码里不小心把state IN写成state IN那第一个空格后状态变成了IN第二个空格就会被误判成“单词结束”从而多出一个长度为 0 的计数。这种赋值与相等比较混淆的错误在 C 语言练习阶段几乎人人踩过建议编译时加上警告选项。第三个坑是超长单词。MAXLEN 10时如果输入里混入一个长度 20 的单词直接执行wl[nc]就会越界写入破坏数组后面相邻变量的值。程序可能当时不报错但直方图会出现诡异数字。稳妥做法是把超长单词统一归入MAXLEN这一类或者干脆单独开一组“溢出计数”来提示数据被截断。我做这道题时选择的是前者够用也简单。3. 水平直方图先易后难把输出框架搭起来3.1 输出逻辑与格式化要点水平直方图之所以简单是因为打印顺序和数组存储顺序天然一致。数组wl[1]到wl[MAXLEN]依次是长度 1 到 10 的频数那么打印时只要外层循环按长度从小到大跑每一行先输出长度编号再输出对应数量的星号即可。有一点容易被忽略如果某个长度根本没有对应单词wl[i]是 0这一行不应该打印任何星号但可以保留长度编号行这样直方图的横轴信息是完整的。如果所有长度都很稀疏直方图会出现大量空行观感不好所以很多实现里会再加一个条件只有wl[i] 0时才打印这一行。两种写法都对看你想要“完整坐标”还是“紧凑视图”。还有一个值得考虑的点是是否需要缩放。如果某个长度出现了 100 次直接打 100 个星号也能打出来但终端宽度有限直方图会折行反而没法看。更专业的做法是先遍历一次数组找到最大频数再约定“每个星号代表多少次”比如最大频数是 200每一颗星代表 2 次则打印 50 颗星。对练习 1-13 来说不做缩放不影响学习目标但建议把这个思路记下来后面做真实数据可视化时一定会用到。3.2 完整代码与实测效果水平直方图版本只需要在统计代码后面接上打印逻辑#include stdio.h #define MAXLEN 10 #define IN 1 #define OUT 0 int main(void) { int c, nc, state; int wl[MAXLEN 1]; int i, j; for (i 0; i MAXLEN; i) wl[i] 0; state OUT; nc 0; while ((c getchar()) ! EOF) { if (c || c \t || c \n) { if (state IN) { if (nc MAXLEN) wl[nc]; else wl[MAXLEN]; nc 0; state OUT; } } else { state IN; nc; } } if (state IN) { if (nc MAXLEN) wl[nc]; else wl[MAXLEN]; } for (i 1; i MAXLEN; i) { printf(%2d | , i); for (j 0; j wl[i]; j) putchar(*); putchar(\n); } return 0; }用一行简单输入做测试a bb ccc dddd输出效果大致是1 | * 2 | * 3 | * 4 | * 5 | 6 | 7 | 8 | 9 | 10 |这里每个长度恰好出现一次所以看起来像楼梯。如果把输入换成更像真实文本的句子长度分散程度会明显一些。%2d的作用是让长度编号右对齐星号起始位置一致直方图整体不会歪歪扭扭。别小看这种“对齐”的细节真正到垂直直方图时列对齐问题会被放大底部标签和星号错位一眼就能看出来。4. 垂直直方图难就难在“从上面往下打印”4.1 垂直绘制的核心矛盾题目说垂直方向更困难很多人以为难在“计数”其实计数跟水平版本完全一样难在“输出顺序”。让我们想一下终端输出只能一行接一行地往下走而垂直直方图的第一行是柱子的顶部不是柱子的底部。在你画第一行时就必须知道每一个柱子最高能到哪一层否则你根本不知道第一行应该往上空多少。打个比方水平直方图像你在纸上从左往右一条条画线画到哪算哪垂直直方图则像你站在楼下往楼顶看打算从上往下给这栋楼画轮廓——可你还没爬到楼顶怎么可能知道每根柱子对应哪一层有没有楼板所以垂直绘制的第一步必须是先遍历一次统计结果求出最高的柱子高度即频数的最大值maxHeight。然后外层循环从maxHeight往下走到1每一行判断“当前高度层”上第 i 根柱子是否延伸到这一层。如果wl[i] 当前行号就打一个星号否则打空格。这个过程本质上是把数组里的频数当作柱子的“海拔”一层层地往下挖着看。4.2 逐行扫描法完整代码与拆解完整代码如下#include stdio.h #define MAXLEN 10 #define IN 1 #define OUT 0 int main(void) { int c, nc, state; int wl[MAXLEN 1]; int maxHeight, i, row; for (i 0; i MAXLEN; i) wl[i] 0; state OUT; nc 0; while ((c getchar()) ! EOF) { if (c || c \t || c \n) { if (state IN) { if (nc MAXLEN) wl[nc]; else wl[MAXLEN]; nc 0; state OUT; } } else { state IN; nc; } } if (state IN) { if (nc MAXLEN) wl[nc]; else wl[MAXLEN]; } maxHeight 0; for (i 1; i MAXLEN; i) if (wl[i] maxHeight) maxHeight wl[i]; for (row maxHeight; row 1; --row) { for (i 1; i MAXLEN; i) { if (wl[i] row) printf( * ); else printf( ); } putchar(\n); } for (i 1; i MAXLEN; i) printf( %2d , i); putchar(\n); return 0; }外层循环的价值要反复看row从maxHeight一路减到 1代表从天空往地面一层层扫描。内层循环遍历每一个长度桶比较wl[i]和当前row的关系。如果柱子的高度不低于当前行说明这一层有实心部分打印星号否则打印空格。底部再单独循环一次打印长度标签。注意我这里没有用wl[0]所以两个循环都从 1 开始。这个“先算最大值再从上往下画”的思路其实可以推广到很多字符画题目里。以后你画柱状图、画波形图、画日历格子只要涉及“纵向高度”大概率都会用到这个模式先找全局上限再决定每一行的内容。4.3 坐标轴、缩放与外观处理上面的代码是最简陋的垂直直方图很多细节都还没处理。第一是纵轴刻度现在只画了柱子和底部标签顶部没有标注每一行代表多少数量。要加的话可以在外层循环里打印行号比如printf(%2d | , row)然后在标签行前留出对应的空格宽度。这样能读得更直白但会让代码的对齐逻辑稍微复杂一点因为柱子区域每列占 5 个字符轴本身还要占 6 个字符。我第一次写的时候没加轴后来看着看着总觉得少点什么加完才顺眼。第二是柱子的宽度和间距。上面用 * 和 占 5 列柱宽 1 列间距 4 列比较宽松适合长度桶数量不超过 10 个的情形。如果MAXLEN扩大到 30每列 5 字符会导致直方图严重超宽。这时可以改成“柱宽 1、间距 1”的紧凑格式比如用* 和 或者干脆每个柱子之间不加空格。结论是可视化里空间和可读性的平衡没有标准答案但一定要根据数据规模主动去调。第三是帽子问题。柱子的顶端如果你用星号视觉效果还行如果你用井号#柱体面积更大视觉上更接近“实心柱”。我推荐初学者先统一用*等垂直版本跑通了再改成#或|试试效果。也可以把柱体用#、顶端用*区分但那样一个列要打两个字符宽度计算会麻烦不少。这道题我建议不要一上来就做复杂样式先保证“数据、坐标、柱高”三样东西肉眼可见地一致再谈美观。5. 踩坑清单统计和显示两个阶段的常见问题5.1 统计阶段的典型错误这个阶段我见过的错误五花八门但归类后主要是三类。第一类是丢失末位单词也就是 EOF 前没有 flush。对付这类问题最好的方法不是“想着加一段 flush 代码”而是先跑一个没有换行结尾的输入。比如用printf hello | ./a.out测试如果直方图完全空白那必然是 flush 逻辑缺失。第二类是空单词计数。这个多半出在状态变量更新顺序上。标准流程是“遇到空格且状态为 IN 时才结束单词”如果你把状态置为OUT的代码放在条件外面或者漏掉了state IN判断连续空格就会产生多个长度为 0 的假象。有个小技巧可以自检把所有统计结果打印出来观察wl[0]是否大于 0。如果大于 0说明你的逻辑把分隔符当成单词了赶紧回看状态判断。第三类是数组越界。当nc MAXLEN时wl[nc]已经越界。C 语言对数组越界不报错只会往相邻内存里写入垃圾值轻则数组值错乱重则程序崩溃。想稳妥一点可以在统计前加一句提示else { /* 超长单词归入 MAXLEN 桶 */ wl[MAXLEN]; }这种“至少不让程序爆炸”的兜底处理是在真实项目中也要养成的习惯。5.2 显示阶段的问题显示阶段最容易出问题的是对齐。水平直方图用%2d可以应对垂直直方图里如果长度标签用%2d但柱体区是 5 个字符宽底部标签行和柱体行的宽度就对不齐看起来像锯齿。最省力的办法是让底部标签也用同样宽度的格式打印比如每个标签占 5 个字符printf( %2d , i)。这样星号区 5 列标签区 5 列上下对齐就不用伤脑筋了。另一个常见问题是高度为 1 的单薄柱子。如果输入文本很短比如只有一个单词a那maxHeight 1垂直直方图只剩底部一行星号几乎看不出“图”的效果。这不算 bug但会降低你调试时的视觉反馈。建议测试时用一个有多个单词且长度分布多样的输入比如一段真实英文句子效果会直观很多。还有个别同学会把外层循环写成for (row 0; row maxHeight; row)从 0 开始往上打。这样打出来的其实是倒着的直方图最低的柱子反而出现在屏幕顶部最高的柱子出现在屏幕底部。要想明白行号越大代表海拔越高所以必须从最大的行号开始打印row maxHeight是第一行row 1是最后一行。这个顺序反了垂直直方图就是镜像的。5.3 一套可复用的调试思路遇到这类题目我建议你按“三段式”去调试第一段用最简单输入验证统计正确性。输入a bb ccc期望wl[1]1, wl[2]1, wl[3]1。第二段验证水平直方图输出是否正确因为它的结构直观容易肉眼判断。第三段再切换到垂直直方图看柱子是否与水平版本一一呼应。如果你在垂直版本里看到柱高分布和水平版本不一致问题大概率出在统计阶段而不是画图阶段。一个特别实用的技巧是临时在代码里加一行打印把wl[i]和maxHeight的值打到屏幕上确认数据后再删掉调试代码。这个“打点看数据”的习惯比胡乱怀疑“是不是数组越界”“是不是状态机错了”要高效得多。6. 做完这题之后三个可以继续玩的方向6.1 把统计维度换掉练习 1-13 统计的是单词长度但“统计并绘制直方图”这个套路完全通用。你可以把输入文本里每个单词的首字母单独拎出来做一个“首字母分布直方图”也可以统计每一行的字符数画一个“行宽分布图”还可以统计某个字符在每一段里出现的密度把文本的字符节奏可视化出来。改统计维度时核心逻辑几乎不用动确定一个维度把每个样本映射成整数落到数组或哈希表里再做同样的行扫描输出。这个思维迁移比题目本身更有价值。6.2 加入缩放和排序当数据分布跨度很大时直接画原始频数会把直方图的柱子拉得太高。最简单的缩放做法是先找出最大频数maxHeight设定一个目标高度targetHeight比如 20 行。然后计算比例因子scale maxHeight / targetHeight打印时每个柱子显示的星号数量为wl[i] / scale。这样尺度统一的直方图在数据汇报里更实用。排序则更好玩很多语言的图表库默认按从小到大排柱子你也可以按频数从高到低排这样一眼就能看出最常出现的是哪些长度。修改也很简单就是把wl数组做一次索引排序打印时按排序后的索引遍历。6.3 换语言重写一遍C 语言里要自己管理数组、状态变量、缩进换到 Python 这类脚本语言过程会轻松一些但“从顶行开始画”的核心逻辑完全一样。用 Python 重写时可以用collections.Counter来统计长度分布再用列表推导生成每一行的内容。JavaScript 则在浏览器里可以直接用 CSS flex 布局画柱子或者用 Canvas 绘制输出形态完全不同但对数据组织的理解是相通的。我建议你至少用另一种语言把这道题重写一遍。一遍 C 语言让你看清楚底层每一步在做什么第二遍用脚本语言让你把注意力放到算法结构本身两相对照很多原来模糊的地方会突然透亮。最后再说几句个人体会。我第一次做这道题的时候水平直方图半小时写完垂直直方图磨了两天最后看到正确答案才知道自己连“先求最大值再从上往下打”这个方向都没想对。后来我在别的项目里画资源占用趋势图发现用的还是这套行扫描思路当时就觉得那道练习没有白做。这道题的魅力不在于直方图本身而在于逼着你从“数据”到“呈现”之间建一座桥。你如果正在学 C 语言建议别只看别人的代码亲手敲一遍、跑一个完整样本、故意制造几个 bug 再修好这个过程比任何讲解都管用。