0001 · 从一道蓝桥杯题说起

从一道蓝桥杯题说起

本节目标:能说出从问题到程序的四步,并在写代码之前判断出数据规模变大后暴力解法会不会失效。

蓝桥杯的填空题里有一类题长这样:

【问题描述】
在 1 到 N 之间,有多少个奇数的平方不超过 N?

【输入格式】
输入一行,包含一个正整数 N。

【输出格式】
输出一行,包含一个整数,表示答案。

【样例输入】
100

【样例输出】
5

【评测用例规模与约定】
对于 60% 的评测用例,1 ≤ N ≤ 10^6;
对于所有评测用例,1 ≤ N ≤ 10^9。

样例只有一行输入、一个输出,看起来比课本里的习题还简单。真正决定这道题难度的是最后两行——N 最大可以到 10^9。这节课要回答的问题就是:末两行为什么能决定一道题的难度。

从问题到程序的四步

拿到上面的题面,要到一份能提交的代码,中间有四步。

第一步,读清楚问题。 题目问的是"奇数 k 的平方不超过 N"的 k 有几个,不是求这些平方的和,也不是求奇数本身的个数。第一遍读题时把"多少"读成"哪些"、把"不超过"读成"小于",都是常事。

第二步,想清楚输入和输出。 输入是一行一个正整数 N,输出是一行一个整数。程序从标准输入读、往标准输出写——蓝桥杯的评测机就是把输入喂进来、拿走输出,跟样例里的样子一致。

第三步,处理。 把"符合条件的有几个"变成一串能执行的动作:从 k = 1 开始一个个试,k 平方不超过 N 就计数加一,超过就停。

第四步,输出。 把计数结果打印出来。

这四步里,第三步是后面每一课要反复练的地方,前两步出错则整道题白做。一种常见的翻车方式是:程序算得又快又对,只是输出多了一行"答案是:"。评测机拿它跟标准答案逐字比对,判零分。判题程序不知道你的提示文字有什么意义,它只比较两份文本。

算法是什么

"算法"这个词来自 9 世纪的波斯数学家花拉子米(al-Khwarizmi)名字的拉丁化写法 algorismus,最初指的就是用阿拉伯数字做算术的那套笔算步骤。这里的"步骤"包含三层要求:

把这三条合起来:算法是解决某一类问题的一套有限、确定的步骤,给定合法输入,按步骤执行必然在有限步内停下并产出结果。 上面的循环求和是一个算法,while True: pass 不是。

按照这个定义,覆盖 10^9 的暴力枚举仍然是算法——它有限、确定,也能产出结果。速度不在这三条要求里,它由评测用例的时限决定:考试只给你一秒或两秒,跑不完的算法就得换掉。

数据规模决定可行性

同一道题,评测用例分档,我们可以把最容易想到的写法(后面统一叫暴力解:不做优化、按定义直接枚举所有可能)拿来分别试。

题面里的 N 有一个数据范围,正是它决定了这个算法能不能跑完。有一道同样说法的题:求 1 + 2 + \dots + N。最容易想到的写法是把 1 到 N 逐个加起来:

n = int(input())
total = 0
for i in range(1, n + 1):
    total += i
print(total)

这段代码只有五行。把 n 换成不同的值,在这台机器上跑出来的结果是:

输入规模 n这段循环的实测耗时
10^51.6 毫秒
10^618.6 毫秒
10^7188 毫秒

每放大十倍规模,耗时也大约放大十倍,这正是我们期望的规律。可问题就在这个规律上:按这个比例,n = 10^9 要跑大约 19 秒,蓝桥杯的 1 秒时限连零头都不够。

再过一遍这段代码的每一步:total += i 是一次加法,range 每轮给出一个 i。整个循环执行了 n 次加法,所以耗时正比于 n——循环次数与耗时成正比,这就是耗时随规模线性增长的原因。

换个思路,这件事其实可以不算那么多次。退一步回到小学奥数就知道的结论:1 + 2 + \dots + n = \dfrac{n(n+1)}{2}。用这个公式,n 取多大都只做几次乘除:

n = int(input())
print(n * (n + 1) // 2)

在这台机器上,这一行代码无论 n 取 10^6 还是 10^9,耗时都在微秒量级(三次测量取最小值 0.2 微秒)。和循环的 10^9 次加法相比,差了若干数量级,被测代码本身的耗时已经和测量误差一个量级。

两个算法的答案一样,耗时却差出几个数量级,差别的来源是基本操作的次数:循环要执行 n 次,公式只执行固定几次,两种写法的操作次数随 n 增长的趋势完全不同。这里用的是 OI Wiki 给出的尺子——实际用时依赖机器、难以在理论上计算,实际测量又麻烦,所以衡量算法效率时数的通常不是秒,而是算法需要执行的基本操作数量。

关于上面那些数字

表格里的耗时不来自任何权威资料,是在本机(Python 3.14.7,free-threading 构建)实测的。"1 秒能跑多少次操作"没有一个跨机器通用的数字:同样的代码换一台机器、换一个 Python 版本都会变,所以后面遇到"这题会不会超时",都要像这样自己量、或者按实测数字类推。

写之前先估一下

看两道改过的题。

第一道:1 \le N \le 10^6,求 1 + 2 + \dots + N。循环要跑 10^6 次,按上面的实测约 19 毫秒,写循环就够了。

第二道:1 \le N \le 10^9,同样的式子。循环要跑 10^9 次,按实测比例推是 19 秒,交上去等来的会是"运行超时"。这时得换做法,把结论直接算出来,而不是逐项累加——比如奇数求和 1 + 3 + \dots + (2m-1) = m^2:它的操作次数不随 N 增长,N 取多大都只做几次乘除。回顾开头那道填空题,N 取 10^9 时的答案也是同一个道理算出来的:先由 k^2 \le N 定出上界 k \le 31622,再数一数这个范围内有多少个奇数,答案是 15811。

真正的坑在第一步:这两道题看着是同一句话,代码也几乎一样,数据范围只差三个数量级。样例永远只有三五个数,永远跑得飞快,所以"样例通过了"这句话对评测结果几乎没有预测力——提交前该做的判断是"我的循环会跑多少次、跑到 10^9 还能不能在一秒内跑完",而这个判断在写代码之前就可以做完。 从下一课起,我们会把它练成一套固定的算法:看数据范围、估操作次数、定复杂度上限。

练习

练习 自己提出一个问题,再动手量一量

这一节的动作都在本机的 Python 里做,新建一个 first.py 就够,不需要其他准备。

第一步 · 把循环跑起来。 把前面那段循环求和抄进 first.py,输入 1000000 跑一次:

500000500000

确认结果和公式 1000000 \times 1000001 \div 2 一致。

第二步 · 让程序自己报耗时。 Python 标准库里的 timeit 就是为这件事准备的,官方推荐的用法是重复计时后取最小值——单次计时会被机器上别的程序干扰,报出来的数不可靠:

import timeit

def loop_sum(n):
    total = 0
    for i in range(1, n + 1):
        total += i
    return total

for n in (10**5, 10**6, 10**7):
    t = min(timeit.repeat(lambda: loop_sum(n), number=1, repeat=5))
    print(n, f"{t:.4f} 秒")

把三个规模各跑一遍,看看耗时是不是随规模一起按倍数变化。

第三步 · 用测出来的数做估算。 拿第二步打印出来的最后一个数字乘 100,就是 10^9 的估计耗时,和蓝桥杯的时限比一比。写出你的结论:这道题在 10^9 这一档还能不能交暴力解。

第四步 · 换一个问题。 想一个你确实想用程序算的真实问题(统计一份名单里的重复项、算自己一个月的开销、把一堆分数排序……),用一句话写下来,再把它拆成三段:输入是什么、处理要做哪些动作、输出是什么。写下之后,再猜一句:数据放大十倍,你的做法还跑得动吗?

这一课用到的 Python

后面每一课都会用到 for、range、变量和输入输出,这里把它们的最小用法对一遍。

要做的事写法边界
从下标 0 数到 n−1range(n)range 不含终点,range(1, 5) 是 1 到 4
从 1 数到 nrange(1, n + 1)想包含 n 就得写成 n + 1
每隔一个数取一个range(2, 11, 2)第三个参数是步长,结果是 2、4、6、8、10
把每个元素做同一件事for i in range(1, n + 1):冒号不能丢,循环体要缩进
循环外只做一次的事顶格写,不缩进缩进了就变成循环体的一部分
读一个整数n = int(input())input() 读回来的是文本,要算数得先转成整数
输出结果print(total)只输出要求的那一行,别加提示文字

课程后面会把这套写法整理成函数——重复出现的处理步骤打包成一个有名字的代码块,调用一次就执行一遍。这一步先不展开,眼下循环和缩进够用了。

这门课最后会做出什么

这门课的检验手段是蓝桥杯真题:真实的数据范围、真实的时限、以及"你到底会不会"的诚实反馈。目标是把原理和复杂度吃透到能讲给别人听的深度,所以后面每一课都会走完"提问题、写代码、估复杂度、验证"这一整套,而不只是背下模板。

配套项目是一个「AI 刷题助手」:输入一段题目文本,由大模型辅助分析题意、给出候选算法,再用对拍器与样例自动验证候选解。它会在这门课的实验课里一段一段长出来——从这一课开始,你练的每一步都是它的一部分。

没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。