从算法复杂度,到前缀和差分、二分、分治排序、动态规划与指针。
一个学期,十一段旅程——
程序为什么会超时?怎么估算一个算法的快慢?从 TLE 说起,把复杂度的账算明白。
评测只给 1 秒,运算量太大就 TLE——把计算量算出来,是优化的第一步。
不测真实秒数,而是统计“固定操作”的执行次数与变量 n 的关系——这就是时间复杂度。
传统三件套平均 O(n²),快排平均、归并与堆排序最坏 O(n log n)——再用实测数据说话。
KB / MB / GB 怎么换算?数组占多少内存?超了会怎样?——这一部分把空间账也算明白。
斐波那契、重复元素检测、最大子数组和——同一道题,不同代价,亲眼看看差距。
把「区间和」从 O(n) 降到 O(1),把「区间修改」从 O(n) 降到 O(1)——一对互为逆运算的加速器。
从水果商人的收购点说起:前 n 项的和就是前缀和,用数组存起来,后面随便查。
任意区间 [L, R] 的和,不用再一段段累加——s[R] − s[L−1] 一次搞定。
数据一大,暴力加法必然超时——用前缀和写一版标准解法。
同样的套路还能用到矩形区域、乘积和异或上——思路是相通的。
前缀和的反向操作:把每个数变成与前一项的差,区间修改就变成了两个位置的修改。
输入:1≤n≤10⁵、0≤m≤10⁵。
再给 m 行 L R x,其中 1≤L≤R≤n,|x|≤10⁹。
原数组初始全部为 0。
前缀和与差分转一圈就能互相还原——最后把两者的适用场景摆在一起对比。
每次砍掉一半:在有序数组里,约 20 次比较就能替代 100 万次扫描。
我心里想一个 1 ~ 100 的整数,只回答「大了 / 小了 / 猜中」——你最少几次能保证猜中?
数组有序,就相当于答案已经按顺序排好——中间那个元素,就是那声「大了 / 小了」。
循环条件写 L <= R,每轮只会更新 L 或 R——最要小心的是:区间必须真的在缩小。
元素互不重复、已经升序排好——把刚写的模板套进完整程序里跑一遍。
把「等于 target」换成「满足某个条件」,二分就变成了找边界——这也是下一单元「二分答案」的入口。
答案藏在一个范围里,而「试一下就知道行不行」——于是 10 亿次枚举,变成 30 次判断。
3 根木材,长度 8、4、10,要切出 5 段一样长的——每段最长能有多长?
先框出答案的可能范围,再确认「大的可行 ⟹ 小的也可行」这条性质——它就是二分的通行证。
每一轮只做三件事:算 mid、算段数、决定留哪一半。答案用 ans 单独记下来。
求最大:可行就向右找更大值。
求最小:可行就向左找更小值。
check(x) 只负责回答行不行;二分框架只负责缩小范围。拆开之后,换一道题只需要重写 check。
用 found 标记尚未找到;只在已证明上界可行时用 ans=R。
真→假找最后一个真;假→真找第一个真,更新方向相反。
木材 check 除以 x,因此 x 不能为 0;其他题须按题意判断。
上下界应覆盖所有合法答案,区间和 mid 类型要能容纳数值。
切割木材求最大,分书求最小,跳石头求「最大的最小」——模板一个字都不用改。
河长 1≤L≤10⁹,中间 0≤n≤5×10⁴ 块互异石头,位置在 (0,L);最多移走 0≤m≤n 块。求最大最小间距。
把大问题劈成两半,让基准元素一次归位——平均 O(n log n) 的原地排序。
排序 100 万个数很难,排序 1 个数不用排——中间差的,就是「怎么拆」和「怎么拼」。
选一个基准,把「不大于它的」全甩到左边,「大于它的」全甩到右边——它就再也不用动了。
二十行的 qsort:两个指针、三次交换、两次递归——每一行都要看清楚在干什么。
快排的速度全看「分得均不均」:每次对半开是 n log n,每次只切掉一个就退化成 n²。
同一套分区,改一改就能找第 K 小;重复元素多、数组小,也各有对应的优化。
一刀切到单个元素,再两两拼回有序——最坏 O(n log n),且排序稳定,顺手数清逆序对。
8 个数太难排,1 个数不用排。归并排序的「分解」简单到只有一句话:mid 一刀两半。
归并的功夫全在 merge:两个指针各指一段,谁小就把谁抄进临时数组。
算 mid、递归两半、合并——归并排序的主函数短得不像话,因为难点都在 merge 里。
归并的三层递归每层都合并 n 个数,所以最好最坏都是 n log n——代价是要一块临时数组。
暴力两两比较是 O(n²),而归并合并时,每一次「取右边」都白送一批逆序对。
把算过的答案记在表里,用空间换时间——指数级递归,一次循环就能解决。
分治假设子问题互不相干;可现实里子问题常常重叠——算过的又算一遍,时间就这么没了。
DP 的难点不在写循环,而在「定义状态」——把题目翻译成一张表,转移方程自然就出来了。
F[i] = F[i−1] + F[i−2]。一维数组、一个循环,把 2ⁿ 压成 n。
二维表格的第一次登场:每一格的路径数 = 上面那格 + 左边那格。
输入:n m,1<n,m<20。
从 (1,1) 到 (n,m),每次只走右或下一格。输出不同路径数。
黄色路线走 3 次右、2 次下;所有路线共有 10 条。
二维 DP 的招牌题:前 i 件物品、容量 j,能装出的最大价值是多少?
因数成对出现,所以只要枚举到 √n;gcd 一次辗转相除,素数一次试除。
6 ÷ 3 = 2 没有余数,所以 3 是 6 的因数,6 是 3 的倍数——一件事的两种说法。
欧几里得两千年前就发现:gcd(a,b) = gcd(b, a mod b)——不用分解质因数也能求最大公约数。
判素数是数论的基本功:试到 √n 就够;把合数拆成质数相乘,是整个单元的核心工具。
质因数分解是把钥匙:拆开之后,奇偶性、个数、配对关系都一目了然。
正因数成对出现,只有平方根会与自己配成一对。
所以:正整数因数个数为奇数 ⇔ 完全平方数。
不再一个一个判:把合数整批划掉,一次预处理,后面全是查表。
判一个数 O(√n) 不算慢,可 n 个数加起来就是 n√n——10⁶ 时约 10⁹ 次,超时。
从小到大扫,遇到没被划掉的数,它就是素数;顺手把它至少 2 倍的倍数标成非素数;从平方起能减少重复。
用「i × 已有素数」生成合数,一旦 i 能被某个素数整除就停手——保证只由最小质因数生成。
筛法最典型的用法:先把 1 ~ N 的素数表建好,之后每次查询都是 O(1) 查表。
物理第 i 次伤害 2ⁱ⁻¹;魔法最多一次,伤害必须为素数。血量必须恰好减到 0。
输入 T 组 h;本课范围 1≤h≤10⁵,无解输出 −1。
本课约定:0≤L≤R≤10¹²,R−L≤10⁶。
limit=⌊√R⌋,先生成 basePrime。区间表先全部置 true。
变量住在内存的某个门牌号上;指针记门牌,引用就是同一个人的小名。
指针变量的值不是数字,而是另一个变量在内存里的地址——顺着地址就能找到它。
数组本身不是指针;转换后可得到 &a[0]。a[i] 等价于 *(a+i)——两种写法,同一块内存。
C++ 默认「值传递」:函数拿到的是副本。想改原件,就得把地址或引用递进去。
指针给了你直接操作内存的能力,也把所有责任一起交给了你。
让程序从文件读、往文件写;出错的时候,别让整个程序直接崩掉。
标准输入 stdin、标准输出 stdout 本来是键盘和屏幕;重定向,就是把它们指到文件上。
文件打不开、输入不合法——与其让程序默默算错,不如把问题「扔」出来,接住再处理。
重定向只占两行;剩下的坑,基本都和「忘了关」「交错了文件」有关。
标准 I/O 题移除本地调试重定向;文件 I/O 题按指定文件名读写。freopen 失败表示打开失败,不是成功读到空文件。
路径基于进程工作目录。输入与输出都需检查状态,写入和关闭后也可能报告失败。
局部文件流正常析构会关闭;需要及时检查时显式 close。关同步后不提前 fclose(stdout)。
默认流失败未必 throw;整数除零、越界等也不能依靠 catch 修复。