【警示后人】成事不足,败事有余

· · 算法·理论

临近 CSP 2026,特地重写本专栏,以警示后人。

话说这玩意的文风是不是更适合投休闲·娱乐。 ::::info[声明]{open} 本文中的内容仅供参考,请勿将其作为绝对正确的内容看待。欢迎指出文中的错误,这对所有人都是好事。

如果您认为本文的文风不合胃口,您可以选择将其复制给 AI 进行总结。
::::warning[省流]
在一份代码中,同时满足如下三种条件可能造成输出文件中无内容。

也许有时只是自己欠费功夫,一道题都做不来。但有时,便是一些赛后看来不知道何意味的操作造成的了——忘写 freopen,数组少开一个 0,边界写错……这些问题总被反反复复地强调。

然而,有一个问题不是这样。不少 OIer 因这个问题在正赛中爆零,但鲜有人特意强调这件事。本文将着重介绍这个问题,以尽可能让各位,尤其是第一次参加 NOI 系列比赛的 OIer 不中招。

天下无敌

众所周知,在 NOI 系列比赛中,文件 I/O 是我们必须学会的东西。

freopen("XXX.in","r",stdin);
freopen("XXX.out","w",stdout);

一般就是这么神圣的两行代码。它就那样平平静静地躺在 main() 里,准备在被注释掉的时候装糖阴你一手。嗯?什么叫你不会写文件 I/O?想必你一定能取得一个“好成绩”吧。

而同时,cin、cout 的速度拉完了,有时可能会带来美妙的 TLE,与“时间就是金钱,效率就是生命”的 OI 格言相违背。于是 OIer 的大手发力了哈哈!我们经常会解绑 cin、cout,并且关闭同步流,以提升 I/O 效率。

ios::sync_with_stdio(false);
cin.tie(nullptr);

同样的,只是可可爱爱的两行代码,没有什么特别的。

最后,为全面贯彻落实“写东西,一定要有结尾”的重要指示,有些人还会加上最后两句。

fclose(stdin);
fclose(stdout);

想必同时具备这三点的代码一定能在比赛中取得好成绩吧!

在这一刻,你仿佛已经修得 OI 大神的境界,天下无敌,似乎 AK CSP、NOIP 乃至 NOI 只不过是信手拈来。你马上就能看到 IOI 的曙光,站在图灵奖的颁奖台上……

天上来敌

事实上,当你忐忑地点开成绩查询页面,一看到成绩,悬着的心终于放了下来。哦,是放到了谷底。

不巧,谷底裂开了一个大洞,你的心就这么跌到了地府,直接见到了在地府改生死簿的孙悟空。

0

你感到陌生。

你无比疑惑。

你愤怒了!

你充满了不甘!

你随即打算以 9.8\,\text{m/s}^2 的加速度去楼下买辣条。不过在你做出这个举动之前,仅存一丝理智的你想到应该测一测自己的程序,于是删掉了文件相关的部分,然后把它提交到某国内著名 OJ 上一测——

你通过了此题!
恭喜!

此时你的大脑简直与爱因斯坦、牛顿、欧拉、高斯、法拉第、麦克斯韦……马克思、恩格斯、列宁相通!你的思维上升到了一个前所未有的境界!甚至超越了时间,超越了空间,超越了物种的边界,超越了宇宙运行的框架!你的出现顿时让星海黯淡!你的目光如鹰隼般犀利!你的存在让宇宙庄严而缓慢地崩塌!你谱写着最壮丽的史诗!你成了创世主!

于是你用这短暂却又无上的智慧,洞悉了每一个粒子在过去、现在、将来的轨迹,并穷举了漫漫长河中的无数种可能!

不论如何,这里面肯定藏着更多。我们应该看看,究竟是什么导致了这一悲剧。

Hello, World

你选择了从写一个最简单的程序——Hello, World 开始。相比刚开始学 OI 的你,此时你应该可以轻松完成这一任务了。 :::align{center} ::: 这都不会写的只能是大杂鱼了喵!

只是一个很正常的程序。不妨猜猜输出文件里有什么? :::align{center} ::: 什么都没有。

没错,输出文件里居然什么都没有!我们可以写一些别的简单的程序,但似乎最后总没有输出。

爆零的原因已经知道了,是输出的问题。那么,是什么造成了这种情况呢?我们通过实验来进行探究。

:::align{center}

探究导致输出文件中无内容的原因

:::

【提出问题】

为什么输出文件中没有本应出现的输出内容?

【猜想与假设】
  1. 输出文件中无输出,是关闭同步流导致的;
  2. 输出文件中无输出,是解绑了 cin、cout 导致的;
  3. 输出文件中无输出,是 fclose 导致的。

毕竟你也不能怀疑到几乎全国选手都使用的 freopen 头上。对吧?

【准备实验器材】

实验所需器材:配备完整外设的计算机一台;配置好的任意本地 C++ 集成开发环境。

【进行实验】

第一步,创建文件 beta.cpp,在其中填入如下代码:

#include<bits/stdc++.h>
using namespace std;
int main()
{
    freopen("beta.out","w",stdout);
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout<<"Hello, World!";
    fclose(stdout);
    return 0;
}

第二步,将代码 cin.tie(nullptr); 注释后,编译并运行程序,观察文件 beta.out 中的内容;
第三步,取消注释。将 ios::sync_with_stdio(false); 注释后,编译并运行程序,观察文件 beta.out 中的内容;
第四步,取消注释。将代码 fclose(stdout); 注释后,编译并运行程序,观察文件 beta.out 中的内容。

【实验结果】

::cute-table |序号|注释内容|输出结果| |:-:|:-:|:-:| |1|cin.tie(nullptr);|无| |2|ios::sync_with_stdio(false);|Hello, World!| |3|fclose(stdout);|^|

【得出结论】

输出文件中没有输出内容,是关闭同步流操作与 fclose 操作共同引起的。

  1. 本实验中采用的科学探究方法是 __。(选填“等效替代法”“控制变量法”“转换法”)(1 分)
  2. 在向 beta.cpp 中填入的代码中,using namespace std; 的作用是 __。(1 分)
  3. 写出本实验存在的不足之处(任意一点均可)__。(2 分)
  4. 小明在进行该实验时,提示编译出现错误,原因可能是(任意一点均可)__。(2 分)
  5. 该实验得到的结论正确吗?为什么?__。(2 分)

我们通过实验发现,输出文件为空和关闭同步流、fclose 的操作脱不了干系!这一切究竟是怎么一回事呢?

缓冲区

理解缓冲区是找到程序无输出原因的基础。

缓冲区是什么玩意呢?它本质上就是内存中的一块空间,负责缓冲 I/O 中的数据。有了缓冲区,我们就可以减少低效设备的调用次数,提升整体运行效率。或者说,它是一种空间换时间。

我们举个例子。你是一个快递员,现在许多包裹正从传送带上向你奔袭而来!你每接到一个包裹就立马开车去送,累死累活,大半天才送了几件。结果折算下来,今天赚的还不够你晚饭。而你聪明的同事选择把包裹先放到车上,等到车塞不下了再去送,效率大大提高。他不仅没被扣工资,还拿到了高达 0.01 元的奖金,比像区一样爬行的你强了不知道几百倍。

我们把将车上所有包裹送出的操作称之为“刷新”。

在 C++ 中,有两套主要的 I/O 体系。其中一套是 C 风格的 I/O,位于 <cstdio> 中,主要包括 scanf、printf、getchar 等。它拥有三个主要的 I/O 流,stdin、stdout、stderr。

流具体是什么不重要,我们可以认为它就是数据传输的通道。

另一套 I/O 体系则是位于 <iostream> 中的 C++ 风格 I/O,cin、cout、cerr。本质上,它们其实就是 C++ 风格 I/O 中的三个流对象。

虽然它们采用的流看起来不一样,但本质是相同的,它们都可以近似看做文件描述符的封装。文件描述符,把它看作是给对应文件分配的键就行了。它默认被定向到控制台,也就是那个 cmd 窗口或者终端。两套不同的 I/O 体系,标准输出的内容最后都经同一个文件描述符输出,因为显然你不能把标准输出的数据输出到不同的地方或者后室里去。而 freopen 所做的,本质上是将某个流对应的文件描述符从控制台重定向到需要的文件中。

这两套 I/O 体系各自有着它们自己的缓冲区,但是这样做容易造成输出顺序乱成一团。于是伟大的开发者们设计了同步流,效果大致接近于让 C++ 风格 I/O 直接去用 C 风格 I/O 的缓冲区,这样就没有了后顾之忧。

但是 OIer 不高兴了!你这同步流慢的要死,谁让你喂我吃 TLE 的?他们解放思想,开动脑筋,选择了关闭同步流。这样,两套体系的 I/O 改为了使用完全独立的缓冲区。这就是为什么关闭同步流后就应该尽量不混用两种风格的 I/O,因为那样可能会造成各种问题,让你浪费生命中宝贵的几十分钟。

悲剧复盘

那么到此,我们终于可以来看究竟为什么没有输出了。

按时间顺序看看发生了什么。

可以看到,C++ 风格 I/O 的缓冲区只在程序结束时被尝试刷新了一次。但在此之前,文件描述符已经关闭,故刷新失败了。缓冲区中的数据无处可去,只能被当成垃圾丢掉,所以输出文件中才什么都没有。

注意,fclose 所做的是关闭 stdout 及对应的文件描述符,而非将它们重定向到控制台。这意味着,接下来如果不重新重定向,你将无法使用 C 风格 I/O 正常输出。事实上,不只是无法正常输出,因为在关闭 stdout 后再试图操作它是未定义行为(UB)。就算你使用 C++ 风格 I/O,由于文件描述符被关闭,你还是什么都输不出来。

还有一点,正常情况下,程序结束时两种缓冲区都会刷新。此处因为 stdout 被关闭,程序没有刷新 C 风格 I/O 的缓冲区。

综上,fclose 成事不足,败事有余。直接给到拉完了。

解决方案

很简单,不要闲的没事写 fclose,这只会让你多一份“警示后人”的资本。

当然,如果你改不掉这个无比猎奇的习惯,你必须在 fclose 之前对 C++ 风格 I/O 的缓冲区进行手动刷新操作。

cout.flush();
cout<<flush;
cout<<endl;

这些都是可以的,它们都会尝试刷新 C++ 风格 I/O 的缓冲区,其中 cout<<endl; 会附带一个换行。

值得注意的是,直接输出换行符 \n 不会尝试刷新缓冲区,所以请使用 #define endl '\n' 的 OIer 务必警惕这点。

后记

现在你终于知道了为什么会爆零!你斗志昂扬,决定下次一雪前耻。

不过还是先去买辣条吧。