尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
C/C++实现单词与句子统计:状态机解决字符串处理边界问题
1. 从一道“看起来很简单”的题目说起“统计一句话中有多少单词以及一段话里有多少单词和句子”——这个需求第一次看到的时候很多人脑子里蹦出来的第一反应是这有什么难的一个循环遇到空格就加一遇到句号就加一完事。我当年也是这么想的直到真正动手写、真正拿各种“脏数据”去测才发现这里面藏着的坑比想象中多得多。空格不止一种、标点不止一类、缩写、连字符、换行、连续空格、空字符串、纯标点输入……每一个都能让一个“看起来没问题”的版本当场翻车。这篇文章就是围绕这个题目展开的。我会用C和C两种语言分别实现把从最朴素的版本到相对健壮的版本一步步拆开讲清楚。核心关键词就三个单词统计、句子统计、字符串处理。它适合刚学完数组和循环、想找一个练手项目的同学也适合已经工作但想回头把字符串边界处理这块补扎实的开发者。读完之后你应该能做到拿到任意一段英文文本能准确说出它有多少个单词、多少个句子并且清楚自己的程序在什么情况下会出错、为什么出错、怎么修。我先把结论性的东西摆出来方便你建立整体印象。这个任务本质上是一个状态机问题我们不是在“数空格”而是在判断“当前字符处于单词内部还是单词外部”。一旦把思维从“数分隔符”切换到“跟踪状态”很多边界问题就迎刃而解了。句子统计同理它跟踪的是“是否已经进入了一个句子”。下面我会把这个思路贯穿到每一段代码里。2. 需求拆解与整体设计思路2.1 到底什么算一个“单词”这是整个项目里第一个必须拍板的问题也是最容易被忽略的问题。如果不定清楚规则代码写出来就是一笔糊涂账。我一般会先跟需求方或者自己确认下面这几条连续的非空白字符算一个单词。这是最通用的定义hello、world、abc123、dont都算一个单词。空白字符包括空格、制表符\t、换行\n、回车\r、换页\f、垂直制表\v。C标准库里的isspace()正好覆盖这些所以直接用它最省事。标点符号怎么处理这里有两种流派。一种是把标点当作单词的一部分hello,和hello算不同的东西另一种是把标点剥离hello,里的hello才算一个单词。对于“统计单词数”这个目标来说两种流派数出来的个数通常是一样的因为标点要么紧贴单词、要么单独成串。但如果标点单独出现比如hello , world第一种流派会把,也算成一个单词第二种不会。我个人的选择是只按空白切分标点不特殊处理因为这样实现最简单也最符合“单词数”这个语义的直觉。提示如果你的场景是自然语言处理标点处理会复杂得多需要考虑缩写、连字符、所有格等。但那是另一个量级的任务本文不展开。2.2 什么算一个“句子”句子的判定比单词更主观。常见的做法是以.、!、?作为句子结束标志连续出现的结束标点比如...或?!算作一个句子结束。这里有几个必须提前想清楚的边界Mr. Smith里的.不是句子结束。但要做对这一点需要缩写词典成本很高。对于练手项目我建议不做缩写识别接受这个误差并在文档里写清楚。小数点3.14里的.也不是句子结束。这个可以通过“点号前后都是数字则跳过”来简单规避属于性价比很高的优化。句末没有标点的文本怎么算比如Hello world没有句号。我的处理是如果扫描结束后状态显示“还有未结束的句子内容”就补计一句。这样Hello world算1句空字符串算0句。2.3 为什么用状态机而不是计数器很多人第一版会写成这样遍历字符串遇到空格就wordCount。这个写法在hello world上没问题但在hello world两个空格上就会多算一次在hello开头有空格上也会多算。根本原因是它把“分隔符出现次数”和“单词个数”混为一谈了。正确的模型是维护一个布尔状态inWord表示“当前是否正在一个单词内部”。遇到非空白字符如果inWord是 false说明一个新单词开始了wordCount然后把inWord置为 true。遇到空白字符把inWord置为 false。这个模型天然处理了连续空格、开头空格、结尾空格因为计数只发生在“从非单词进入单词”的那一次跳变上。句子统计用的是完全一样的思路只不过状态变量换成inSentence触发条件换成句末标点。把这两个状态机想明白代码就是水到渠成的事。3. C语言版本从朴素实现到健壮版本3.1 最朴素的版本长什么样先看一个能跑但很脆弱的版本目的是让你看清问题在哪#include stdio.h #include string.h int main(void) { char text[1024]; fgets(text, sizeof(text), stdin); int words 0, sentences 0; for (int i 0; text[i] ! \0; i) { if (text[i] ) words; if (text[i] .) sentences; } printf(words%d sentences%d\n, words 1, sentences); return 0; }这段代码的问题一抓一大把只认空格不认制表符和换行连续空格会重复计数开头有空格会多算words 1这个补丁在空输入时会输出1句号在3.14里也会被算成句子结束。它能通过“hello world.”这种理想输入但稍微换个输入就崩。我把它放出来不是让你学而是让你对照着看后面的版本改进了什么。3.2 用状态机重写单词统计下面是我实际会用的版本核心就是前面说的inWord状态#include stdio.h #include ctype.h int main(void) { char text[4096]; if (fgets(text, sizeof(text), stdin) NULL) { printf(words0 sentences0\n); return 0; } int words 0, sentences 0; int inWord 0; int inSentence 0; for (int i 0; text[i] ! \0; i) { unsigned char c (unsigned char)text[i]; if (isspace(c)) { inWord 0; } else { if (!inWord) { words; inWord 1; } inSentence 1; } if (c . || c ! || c ?) { if (inSentence) { sentences; inSentence 0; } } } if (inSentence) sentences; printf(words%d sentences%d\n, words, sentences); return 0; }这段代码有几个关键点值得说。第一isspace的参数必须是unsigned char转成int直接传char在遇到高位字符时是未定义行为这是个很多人不知道的坑。第二句子计数放在单词处理之后并且用inSentence做守卫这样...这种连续标点只会算一句。第三循环结束后检查inSentence处理没有句末标点的收尾情况。3.3 处理小数点这个高频误判3.14被算成一句是很多人会遇到的尴尬。修法很简单在判断句末标点之前先看这个点号是不是夹在数字中间。if (c .) { int prevIsDigit (i 0 isdigit((unsigned char)text[i-1])); int nextIsDigit (text[i1] ! \0 isdigit((unsigned char)text[i1])); if (prevIsDigit nextIsDigit) { continue; // 小数点跳过句子判定 } }把这段插到句末标点判断之前就行。注意text[i1]要先判\0否则越界。这个改动成本极低但能挡掉一大类误判性价比很高。至于Mr. Smith这种缩写我前面说了不做因为要做对需要词典投入产出比不划算。3.4 输入方式的坑fgets 与换行用fgets读一行它会把结尾的\n也读进来。这个\n是空白字符会被isspace正确处理不影响单词和句子计数所以不用特意去掉。但如果你用scanf(%s)它遇到空格就停根本读不到一整句话这个任务里绝对不能用。如果文本可能超过缓冲区大小fgets会截断这时候要么把缓冲区开大要么用循环分块读取并维护状态跨块延续。对于练手4096字节够用了但你要知道这个限制的存在。注意gets这个函数已经被标准废弃任何情况下都不要用它没有任何边界检查是缓冲区溢出的经典来源。4. C版本用标准库把代码写短写稳4.1 用 std::string 和 std::getline 读整行C的第一层改进是输入。std::getline(std::cin, line)读一整行配合std::string自动管理内存不用再操心缓冲区大小。如果要读多行直到文件结束可以循环getline并把内容拼起来或者逐行处理、把状态变量提到循环外面。#include iostream #include string #include cctype int main() { std::string text, line; while (std::getline(std::cin, line)) { text line; text \n; } // 后续处理 text }这里把每行拼成一个带换行的大字符串换行本身是空白不影响计数但能保证跨行的单词不会被错误地粘在一起其实粘在一起也不影响单词数但保留换行更符合直觉。4.2 用 std::isspace 和范围 for 循环C里遍历字符串最舒服的写法是范围 forint words 0, sentences 0; bool inWord false, inSentence false; for (size_t i 0; i text.size(); i) { unsigned char c static_castunsigned char(text[i]); if (std::isspace(c)) { inWord false; } else { if (!inWord) { words; inWord true; } inSentence true; } if (c . || c ! || c ?) { if (c . i 0 i 1 text.size() std::isdigit(static_castunsigned char(text[i-1])) std::isdigit(static_castunsigned char(text[i1]))) { continue; } if (inSentence) { sentences; inSentence false; } } } if (inSentence) sentences;逻辑和C版本一模一样只是用了std::string和std::isspace。这里用索引而不是范围 for是因为小数点判断需要看前后字符索引更方便。static_castunsigned char那两处转换同样不能省原因和C版本一样。4.3 把逻辑封装成函数真正写项目的时候我会把统计逻辑抽成一个函数输入是const std::string输出是一个结构体struct TextStats { int words 0; int sentences 0; }; TextStats analyze(const std::string text) { TextStats stats; bool inWord false, inSentence false; for (size_t i 0; i text.size(); i) { unsigned char c static_castunsigned char(text[i]); if (std::isspace(c)) { inWord false; } else { if (!inWord) { stats.words; inWord true; } inSentence true; } if (c . || c ! || c ?) { if (c . i 0 i 1 text.size() std::isdigit(static_castunsigned char(text[i-1])) std::isdigit(static_castunsigned char(text[i1]))) { continue; } if (inSentence) { stats.sentences; inSentence false; } } } if (inSentence) stats.sentences; return stats; }封装成函数的好处是可以单独写测试用例可以在不同输入源之间复用主函数只负责读输入和打印结果。这是从“写个能跑的脚本”到“写一段能维护的代码”的分界线。4.4 C与C版本怎么选如果你的环境是纯C比如嵌入式、某些竞赛平台用C版本注意手动管理缓冲区和unsigned char转换。如果是日常开发、刷题、写工具C版本更省心std::string和std::getline能帮你挡掉大量内存和输入问题。两者核心算法完全一致学会一个另一个就是语法差异。我个人的习惯是算法逻辑用C写一遍确保理解透彻实际项目用C写一遍确保可维护。5. 边界情况与常见问题排查5.1 一张表看清所有坑下面这张表是我在实际测试中积累的左边是输入右边是期望输出和容易出错的地方。你可以直接拿它当测试用例集。输入期望 words期望 sentences常见错误hello world21忘记补计无标点收尾hello world21连续空格重复计数hello11开头空格多算hello.11正常hello...11连续标点算成3句3.14 is pi.31小数点算成句子结束Hi! How are you?42正常00空输入输出100空字符串崩溃或输出1a\tb\nc31只认空格不认制表符换行5.2 空输入和纯空白输入空字符串和纯空白是最容易被忽略的。用状态机版本空字符串循环一次都不进words和sentences都是0inSentence是 false不会补计输出正确。纯空白同理所有字符都是isspaceinWord一直是 false不计数。如果你用的是words 1那种补丁写法这里就会输出1这就是为什么我一直强调不要用补丁要用状态机。5.3 标点紧贴单词的情况hello,world这种没有空格的输入按“非空白即单词”的规则会被算成1个单词。这符合大多数人的直觉吗其实有争议。有人觉得逗号应该分隔单词应该算2个。我的处理是保持规则简单一致只按空白切分并在文档里写明。如果你确实需要按标点也切分那就在isspace判断之外再加一个“是否标点”的判断把标点也当作分隔符。但要注意这样dont会被切成don和t反而更糟。所以规则的选择取决于你的实际场景没有绝对的对错。5.4 多行文本的状态延续如果文本跨多行而你是逐行处理、每行单独调用统计函数那么状态会在行与行之间丢失。比如第一行结尾是hello没有标点第二行开头是world.逐行处理会把它们算成两个不完整的片段。正确做法是把状态变量提到循环外面或者先把所有行拼成一个大字符串再统一处理。我一般选后者代码更简单代价是内存占用对于正常文本完全可接受。提示如果文本非常大比如几百MB的日志拼成一个大字符串会爆内存这时候必须用流式处理把inWord和inSentence作为跨块状态传递。这是进阶话题练手阶段不用考虑。5.5 中文和UTF-8的注意事项这个题目说的是“单词”默认是英文语境。如果输入包含中文按字节遍历会把一个汉字拆成多个字节每个字节都不是空白会被算成多个“单词”结果完全不对。要正确处理中文需要按UTF-8解码成码点再判断码点范围。这超出了本文范围但你必须知道当前所有版本都只适用于ASCII英文文本。如果你的场景有中文要么先明确需求中文按字算还是按词算要么引入专门的文本处理库。6. 实测记录与性能观察6.1 测试环境与数据我在一台普通开发机上做了几组测试编译器用gcc -O2和g -O2。测试数据分三档短句几十字节、段落几KB、长文本约1MB的英文文章拼接。统计逻辑本身是 O(n) 的每个字符只做常数次判断所以性能瓶颈几乎全在输入读取上。1MB文本用C版本处理耗时在毫秒级肉眼无法感知差异。C版本因为std::string的拼接和可能的重新分配会略慢一点但同样在毫秒级。6.2 一个容易忽略的性能点如果你用text line在循环里拼接大量行std::string会多次重新分配内存。对于1MB以内的文本无所谓但如果要处理几十MB建议先reserve一个预估大小或者改用流式状态机。我实测过拼接100万行短行时不reserve的版本比reserve的版本慢好几倍。这个细节在练手时不用管但你要有这个意识字符串拼接是有成本的。6.3 状态机版本为什么更稳我把朴素版本和状态机版本跑了同一组边界测试。朴素版本在10个用例里错了6个状态机版本全对。差距不在代码长度而在模型是否正确。状态机版本多出来的代码主要是inWord和inSentence两个布尔变量和几行判断但它把“计数”这个动作绑定到了“状态跳变”上从根本上杜绝了重复计数和漏计。这个思路可以迁移到很多类似问题统计连续重复字符、解析简易格式、词法分析入门都是同一套东西。7. 可以继续扩展的方向这个项目本身很小但它是一个很好的起点。如果你想继续深挖有几个方向值得尝试。第一把统计逻辑做成一个命令行工具支持从文件读入、输出单词数句子数再加一个-v参数输出每个单词。第二加入更聪明的句子边界识别比如处理Mr.、Dr.、etc.这些常见缩写做法是维护一个小型缩写表遇到点号时查表决定是否算句末。第三支持UTF-8按码点遍历这样中文、日文、emoji都能正确处理。第四写一套单元测试把第5节那张表里的用例全部自动化每次改代码跑一遍保证不回归。我个人的体会是这类“看起来简单”的字符串处理题最能暴露一个人对边界条件的敏感度。能把单词和句子统计写对、写稳、写清楚为什么这么写的人在处理更复杂的文本解析任务时通常也不会差。反过来如果这里含糊过去后面遇到真正的解析需求坑会以更大的代价还回来。所以别嫌它小把它吃透。
RELATED

相关推荐

C4.5决策树信息增益率特征选择原理与Python实现

C4.5决策树信息增益率特征选择原理与Python实现

1. 从一棵树讲起:为什么特征选择是决策树的命门做机器学习项目的人都有一个共识:数据和特征决定了模型效果的上限,而算法只是在逼近这个上限。决策树这类模型尤其如此——它本身就是靠一个个特征把样本空间一刀刀切开,切得好不好&…

📅 2026/10/14 0:59:55
059、低功耗模式唤醒延迟对连接事件的影响

059、低功耗模式唤醒延迟对连接事件的影响

一个让人抓狂的"丢包"现场 去年做一个无线传感节点的项目,设备平时跑在深度睡眠模式,靠定时器或者外部中断唤醒,醒来后跟中心节点完成一次数据交互,然后立刻回到睡眠。前后折腾了两周,反复出现一个怪现象&am…

📅 2026/10/14 0:59:55
Struts+Hibernate在线考试系统源码解析:组卷判分与事务实践

Struts+Hibernate在线考试系统源码解析:组卷判分与事务实践

简介:在线考试系统完整源码采用StrutsHibernate框架,面向Java Web学习者、毕业设计开发者以及需要快速搭建考试类项目的技术人员。系统覆盖教师管理考生、上传试题、设定考试参数、发布在线考试,以及考生登录答题、自动评分、成绩查看等完整流…

📅 2026/10/14 0:54:55
MORE NEWS

更多资讯

📰

SQL秒变REST API:SQLREST让后端接口开发效率提升10倍

1. 为什么我会盯上这个“SQL 变 API”的思路:传统接口开发到底慢在哪1.1 一次常规报表接口的开发时间账先说我前段时间接到的一个需求:业务方要一个订单明细查询接口,要求支持按订单状态筛选、按创建时间范围过滤,还要能分页&…

📰

【WorkBuddy从入门到精通实战教程】实战案例 第 91 章 AI 直播间装修:视觉呈现怎么设计

【WorkBuddy从入门到精通实战教程】实战案例 第 91 章 AI 直播间装修:视觉呈现怎么设计 一、花了两万装修直播间,用户还是划走 一位做服饰带货的老板,投入两万块装修直播间:定制背景墙、专业灯光、高清摄像头。 上线后发现:平均停留时长只有 23 秒,没什么改善。 他去…

📰

AI推理时突然“想起“最初的问题,却总被系统忘记

你有没有过这样的经历:写一篇长文章写到中间,突然要回头翻看开头列的提纲,确认自己没跑题?大语言模型在做复杂推理的时候,也会做同样的事情。而这篇论文要讲的,就是当AI也需要"翻回去看看开头"时…

📰

Quaoar 靶机渗透测试(Vulnhub Writeup)

Quaoar 靶机渗透测试(Vulnhub Writeup)一、靶机与环境介绍本次实战目标为 Vulnhub 平台的 Quaoar 靶机,靶机名取自柯伊伯带天体。该靶机同时开放 Web、DNS、邮件(POP3/IMAP)与 SMB 等多类服务,核心路径为 W…

📰

迪拜面向全球推出总奖金超过250万迪拉姆的“AI智能体创作大赛”

迪拜推出了“AI智能体创作大赛”(Create AI Agents Championship),此全球性竞赛邀请创新者开发实用且可扩展的AI智能体解决方案,以解决现实世界中的实际难题,赛事总奖金池逾250万迪拉姆。 迪拜王储、阿联酋副总理兼国…

📰

SpringBoot+Vue3+MyBatis美食网站系统开发实战

去年秋天接了个私房菜馆老板的单子,对方想做一套在线点餐和菜品展示系统,方便顾客在手机上翻菜单、提前下单、预约到店。我一听需求,脑子里冒出的第一套方案就是 Java SpringBoot Vue3 MyBatis MySQL 的经典前后端分离组合。折腾了一个多月…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬