
文章目录
-
- 第一讲:从"愚公移山"到"细胞分裂"
-
- [1. 前提小叙:我们在算什么?](#1. 前提小叙:我们在算什么?)
- [2. 痛点:普通方法的极限在哪里?](#2. 痛点:普通方法的极限在哪里?)
- [3. 破局:聪明人的"细胞分裂"法](#3. 破局:聪明人的“细胞分裂”法)
- [4. 数学上的"细胞分裂":底数平方,指数折半](#4. 数学上的“细胞分裂”:底数平方,指数折半)
- 第一讲:高频面试题与解析
- [第二讲:魔法背后的齿轮 ------ 二进制如何拯救奇数](#第二讲:魔法背后的齿轮 —— 二进制如何拯救奇数)
-
- [1. 前提小叙:商人与他的神秘砝码](#1. 前提小叙:商人与他的神秘砝码)
- [2. 遭遇奇数:提取"零头"放入总库](#2. 遭遇奇数:提取“零头”放入总库)
- [3. 惊人的巧合:与二进制的完美重合](#3. 惊人的巧合:与二进制的完美重合)
- 第二讲:高频面试题与解析
- [第三讲:化繁为简 ------ 位运算与 C++ 代码实现](#第三讲:化繁为简 —— 位运算与 C++ 代码实现)
-
- [1. 前提小叙:程序员的"读心术"与"断舍离"](#1. 前提小叙:程序员的“读心术”与“断舍离”)
- [2. 实战:拼装最优雅的 C++ 模板](#2. 实战:拼装最优雅的 C++ 模板)
- [3. 字符画图解:机器是如何运转的](#3. 字符画图解:机器是如何运转的)
- 第三讲:高频面试题与解析
- [第四讲:带着镣铐跳舞 ------ 快速幂取模(大数安全)](#第四讲:带着镣铐跳舞 —— 快速幂取模(大数安全))
-
- [1. 前提小叙:可怕的指数爆炸与变量的悲歌](#1. 前提小叙:可怕的指数爆炸与变量的悲歌)
- [2. 苹果装箱法则:同余定理的通俗理解](#2. 苹果装箱法则:同余定理的通俗理解)
- [3. 字符画图解:砍掉多余的枝蔓](#3. 字符画图解:砍掉多余的枝蔓)
- [4. 实战:加入取模后的 C++ 模板](#4. 实战:加入取模后的 C++ 模板)
- 第四讲:高频面试题与解析
第一讲:从"愚公移山"到"细胞分裂"
1. 前提小叙:我们在算什么?
在数学中,我们经常需要计算 a a a 的 b b b 次方,记作 a b a^b ab。
比如 2 3 = 2 × 2 × 2 = 8 2^3 = 2 \times 2 \times 2 = 8 23=2×2×2=8。
这里,2 是底数,3 是指数。
2. 痛点:普通方法的极限在哪里?
假设现在老板交给你一个任务:"去帮我复印一份文件,我要 16 份!"
普通人(循环相乘法)的思路是"愚公移山":
拿原件复印第 1 份,再印第 2 份,再印第 3 份......直到第 16 份。
如果用计算机算 3 16 3^{16} 316,就是让计算机做 16 次乘法。
如果算 3 1000000000 3^{1000000000} 31000000000(10亿次方),计算机就要傻傻地乘 10亿次。这在实际编程中(如你参加的各类算法竞赛或项目开发)会直接导致运行超时(Time Limit Exceeded)。
3. 破局:聪明人的"细胞分裂"法
我们换一种思路,假设你面前有一台带"合并"功能的复印机:
- 第 1 步: 你先印 1 份。现在手里有 2 份(原件+复印件)。
- 第 2 步: 你把这 2 份 同时放进机器,直接变成 4 份!
- 第 3 步: 把 4 份 同时放进去,直接变成 8 份!
- 第 4 步: 把 8 份 同时放进去,直接变成 16 份!
你看,本来需要操作 16 次的活儿,现在只用了 4 次就搞定了!
我们用字符画来看看这两种方式的对比:
text
【普通相乘:线性增长】
(3) -> (3x3) -> (3x3x3) -> (3x3x3x3) ... 经历 16 步 ... 累死 🐢
【快速幂思想:指数爆炸】
[3]
| (自身相乘)
[3^2]
| (自身相乘)
[3^4]
| (自身相乘)
[3^8]
| (自身相乘)
[3^16] ... 仅需 4 步 ... 起飞 🚀
4. 数学上的"细胞分裂":底数平方,指数折半
将上面的比喻还原成数学公式,核心法则只有八个字:底数平方,指数折半。
以计算 3 16 3^{16} 316 为例:
- 3 16 3^{16} 316 看起来很大,但我可以把它变成 ( 3 2 ) 8 (3^2)^8 (32)8 ,也就是 9 8 9^8 98。
- 9 8 9^8 98 可以变成 ( 9 2 ) 4 (9^2)^4 (92)4 ,也就是 81 4 81^4 814。
- 81 4 81^4 814 可以变成 ( 81 2 ) 2 (81^2)^2 (812)2 ,也就是 6561 2 6561^2 65612。
- 最终只需算 6561 × 6561 6561 \times 6561 6561×6561 就能得出结果。
指数从 16 → 8 → 4 → 2 → 1 16 \rightarrow 8 \rightarrow 4 \rightarrow 2 \rightarrow 1 16→8→4→2→1,每一次操作都砍掉了一半的工作量。
这就是快速幂的灵魂:通过不断把底数变大(平方),来换取指数的快速缩小(除以2)。
第一讲:高频面试题与解析
面试题:
"在计算 x n x^n xn 时,为什么我们不直接写一个
for循环乘 n n n 次?请简述快速幂算法相较于暴力循环的优势,并说明两者的时间复杂度。"
详细解析与答案:
- 缺陷剖析: 直接使用
for循环连乘 n n n 次,意味着算法的执行步数与指数 n n n 完全成正比。如果 n n n 非常大(例如 10 9 10^9 109 级别),循环将会执行十亿次,远远超出了现代计算机在 1 秒内能处理的指令条数(通常在 10 7 ∼ 10 8 10^7 \sim 10^8 107∼108 级别),必然导致程序超时。 - 快速幂优势: 快速幂采用了分治(折半)的思想。它每次将当前的底数进行平方,同时将指数除以 2。这样可以将原本线性的计算步骤急剧压缩。
- 时间复杂度对比:
- 暴力循环法的时间复杂度为 O ( n ) \mathcal{O}(n) O(n)。
- 快速幂算法的时间复杂度为 O ( log n ) \mathcal{O}(\log n) O(logn)。以 n = 10 9 n=10^9 n=109 为例,暴力法需要算 10 , 000 , 000 , 000 10,000,000,000 10,000,000,000 次,而快速幂大约只需要算 ≈ 30 \approx 30 ≈30 次,性能提升是降维打击级别的。
第一讲的逻辑链到这里就梳理完毕了。但你可能会敏锐地发现一个漏洞:刚才的例子里,指数 16 , 8 , 4 , 2 16, 8, 4, 2 16,8,4,2 全都是偶数 ,每次都能完美除以 2。那如果我让你算 3 11 3^{11} 311,11 除以 2 除不尽,该怎么办?
能敏锐地发现"奇数无法完美折半"这个问题,说明你已经真正把第一讲的"底数平方,指数折半"逻辑在脑子里跑通了。
这节我们就来破解这个局。我们要搞清楚:当遇到奇数时,我们该把多出来的那份"零头"怎么办?这背后,其实藏着计算机世界最底层的美学------二进制。
第二讲:魔法背后的齿轮 ------ 二进制如何拯救奇数
1. 前提小叙:商人与他的神秘砝码
在正式讲解前,我们先讲一个故事,顺便复习一下十进制与二进制的映射关系。
假设有一个聪明的商人,他卖东西从不用 1克、2克、3克......这样按顺序准备一堆砝码。他只准备了重量为 1, 2, 4, 8, 16, 32... 克的砝码(每个重量只有一个)。
如果顾客要买 11 克的东西,商人怎么称?
他不会去凑,他一眼就能看出:11 = 8 + 2 + 1 。
他只需要把 8克、2克、1克 的砝码放上去,就能完美称出 11 克。
这就是二进制的核心本质:世界上任何一个整数,都可以被拆分成几个互不重复的 2 的幂次方之和。
把 11 拆成 8 + 2 + 1,写成二进制就是 1011(代表 8 那个位置是 1,4 是 0,2 是 1,1 是 1)。
2. 遭遇奇数:提取"零头"放入总库
回到我们的快速幂。老板让你算 3 的 11 次方(3^11)。
我们有一个"当前底数"(一开始是 3),我们要对它进行不断地平方。同时,我们准备一个【答案总库】(初始值为 1),专门用来收集结果。
遇到奇数怎么办?很简单:把多出来的那 1 份,先乘到【答案总库】里去,剩下的偶数继续折半!
让我们一步步演示 3^11 是怎么"剥洋葱"的:
- 第 1 步:指数 11 是奇数
它没法直接除以 2。我们就把它拆成3^1 * 3^10。
把3^1提取出来,乘到【答案总库】里。
剩下的3^10,底数平方(3 变 9),指数折半(10 变 5)。 - 第 2 步:指数 5 是奇数
剩下的任务是算 9^5。又是奇数!拆成9^1 * 9^4。
把9^1提取出来,乘到【答案总库】里。
剩下的9^4,底数平方(9 变 81),指数折半(4 变 2)。 - 第 3 步:指数 2 是偶数
完美!不需要提取任何零头。
直接底数平方(81 变 6561),指数折半(2 变 1)。 - 第 4 步:指数 1 是奇数
剩下的任务是算 6561^1。拆成6561^1 * 6561^0。
把6561^1乘到【答案总库】里。
指数变成 0,计算彻底结束!
我们用字符画把这个过程具象化:
text
计算 3^11 的过程记录:
初始状态:【答案总库】= 1 【当前底数】= 3 【指数】= 11
-----------------------------------------------------------------
(1) 遇到 11 (奇数)
-> 提取 3 放入总库: 【答案总库】= 1 * 3
-> 底数平方,指数变偶折半:【当前底数】= 9 【指数】= 5
(2) 遇到 5 (奇数)
-> 提取 9 放入总库: 【答案总库】= 3 * 9
-> 底数平方,指数变偶折半:【当前底数】= 81 【指数】= 2
(3) 遇到 2 (偶数)
-> 完美偶数,不提取: 【答案总库】= 3 * 9 (不变)
-> 底数平方,指数折半: 【当前底数】= 6561 【指数】= 1
(4) 遇到 1 (奇数)
-> 提取 6561 放入总库: 【答案总库】= 3 * 9 * 6561
-> 指数清零,任务结束! 【当前底数】= ... 【指数】= 0
-----------------------------------------------------------------
最终结果:3 * 9 * 6561 = 177147
3. 惊人的巧合:与二进制的完美重合
现在,让我们见证奇迹的时刻。
看看我们最终乘进【答案总库】里的三个数:3,9,6561 。
它们分别是原底数 3 的:1 次方,2 次方,8 次方 。
加起来凑成了什么? 3^1 * 3^2 * 3^8 = 3^11!
还记得前面商人的砝码吗?11 = 1 + 2 + 8 。
这绝不是巧合!
- 当指数是奇数时,意味着当前它的二进制位是
1,我们就把当前的底数"收集"起来。 - 当指数是偶数时,意味着当前它的二进制位是
0,我们就不收集,只让底数继续自我平方。 - 底数不断平方的过程(3 -> 9 -> 81 -> 6561),其实就是在不断生成 3^1, 3^2, 3^4, 3^8 这些"魔法砝码"!
总结成一句话:快速幂的本质,就是把指数转化为二进制,然后把二进制位是 1 的地方对应的"底数砝码"乘起来!
第二讲:高频面试题与解析
面试题:
"在实现快速幂算法时,为什么要判断指数是奇数还是偶数?这种判断和整数的二进制表示有什么内在联系?"
详细解析与答案:
- 为何判断奇偶: 在折半的过程中,如果指数是偶数,可以直接平滑地将底数平方、指数除以 2(例如 a 4 = ( a 2 ) 2 a^4 = (a^2)^2 a4=(a2)2);但如果指数是奇数,直接除以 2 会丢失一次乘积,因此需要将其拆分为 a n = a × a n − 1 a^n = a \times a^{n-1} an=a×an−1,先将分离出来的这一个 a a a(当前的底数状态)累乘到最终的结果变量中,使剩下的指数变为偶数,从而继续折半操作。
- 与二进制的内在联系: 判断奇偶并除以 2 的过程,本质上就是十进制转二进制时的"除 2 取余法"。
- 指数为奇数,余数为 1,等同于该数在当前二进制最低位为
1。这表示我们需要这个权重对应的"底数累乘值"。 - 指数为偶数,余数为 0,等同于该数在当前二进制最低位为
0。这表示我们不需要这个权重。 - 底数每次自我平方,正好对应了二进制位权重的成倍递增( a 1 , a 2 , a 4 , a 8 ... a^1, a^2, a^4, a^8 \dots a1,a2,a4,a8...)。
到这里,快速幂的数学逻辑和底层思想你已经完全打通了。接下来,我们要将这些优雅的数学思想,翻译成你在计算机世界里最熟悉的语言:C++。
终于来到了最激动人心的一步。在上一讲中,我们弄明白了"奇数提取零头"和"二进制位拆分"的深刻联系。现在,我们要把这种数学上的美感,毫无保留地刻印到代码中。
这节我们会抛弃那些笨重的常规数学符号,用计算机最喜欢的"底层语言"来重构这套逻辑。
第三讲:化繁为简 ------ 位运算与 C++ 代码实现
1. 前提小叙:程序员的"读心术"与"断舍离"
在把逻辑翻译成代码之前,我们需要掌握两个 C++ 中的位操作符。这是因为在上一讲中,我们核心只做了两件事:判断奇偶 和除以 2 。
常规做法是 n % 2 == 1 和 n = n / 2,但这在底层的效率不够高。既然我们已经知道这是二进制的游戏,为什么不直接操作二进制呢?
- 操作一:读心术
& 1(按位与)
任何一个整数在计算机里都是一串 0 和 1。一个数是奇数还是偶数,完全由它的最后一位(最低位)决定 。最低位是 1 就是奇数,是 0 就是偶数。
&操作就像是一个带有透视功能的扫描仪。当我们写下n & 1时,就是拿000...001去和n的二进制对齐相乘。前面的所有位都被 0 屏蔽了,只暴露出最后一位!
结论:用if (n & 1)代替if (n % 2 == 1),极其高效。 - 操作二:断舍离
>> 1(右移一位)
十进制里,把123抹掉最后一位变成12,就相当于除以 10。
二进制里,把1011向右推挤,掉出悬崖丢掉最后一位变成101,就相当于除以 2 !
结论:用n >>= 1代替n = n / 2,直接从物理层面抹掉一半。
2. 实战:拼装最优雅的 C++ 模板
现在,我们把上一讲的"答案总库"和"当前底数"全部转化为代码变量。
ans:这就是我们的【答案总库】,一开始什么都没乘,所以初始值为 1。base:这就是我们的【当前底数】,它负责不断自我平方膨胀。exp:这就是【指数】,它负责不断被查探最低位,然后右移缩小。
整个流程就成了一台精密的自动售货机:
只要指数还没有被榨干(大于0) -> 看看当前最低位是不是 1 -> 是 1 就把底数收入总库 -> 底数自我膨胀 -> 指数右移一位。
来看看这段堪称艺术品的 C++ 模板:
cpp
long long fastPower(long long base, long long exp) {
long long ans = 1; // 答案总库,初始值为 1
while (exp > 0) { // 只要指数还没有被榨干
// 步骤 1:读心术,探查最低位
if (exp & 1) {
ans = ans * base; // 如果最低位是 1,提取当前底数放入总库
}
// 步骤 2:底数自我膨胀(准备下一个二进制位的砝码)
base = base * base;
// 步骤 3:断舍离,指数右移一位(相当于除以 2)
exp >>= 1;
}
return ans; // 返回最终累乘结果
}
3. 字符画图解:机器是如何运转的
让我们用上帝视角,重新跑一遍 fastPower(3, 11) 的代码执行轨迹。
注意看 exp 的二进制是如何一点点被剥离的:
text
初始:ans = 1, base = 3, exp = 11 (二进制 1011)
【第一轮循环】
|-- exp = 101[1] -> 发现最低位是 1!触发 (exp & 1)
|-- 动作1:提取放入总库 -> ans = 1 * 3 = 3
|-- 动作2:底数膨胀 -> base = 3 * 3 = 9
|-- 动作3:断舍离 -> exp 右移一位变成 101 (十进制 5)
【第二轮循环】
|-- exp = 10[1] -> 发现最低位是 1!触发 (exp & 1)
|-- 动作1:提取放入总库 -> ans = 3 * 9 = 27
|-- 动作2:底数膨胀 -> base = 9 * 9 = 81
|-- 动作3:断舍离 -> exp 右移一位变成 10 (十进制 2)
【第三轮循环】
|-- exp = 1[0] -> 发现最低位是 0!跳过 (exp & 1)
|-- 动作1:ans 不变,依然是 27
|-- 动作2:底数膨胀 -> base = 81 * 81 = 6561
|-- 动作3:断舍离 -> exp 右移一位变成 1 (十进制 1)
【第四轮循环】
|-- exp = [1] -> 发现最低位是 1!触发 (exp & 1)
|-- 动作1:提取放入总库 -> ans = 27 * 6561 = 177147
|-- 动作2:底数膨胀 -> base = 6561 * 6561 = 43046721
|-- 动作3:断舍离 -> exp 右移一位变成 0
【结束】 exp = 0,循环终止。返回 ans = 177147。
看,没有任何多余的动作,每一次循环都极其干净利落。
第三讲:高频面试题与解析
面试题:
"在书写快速幂的代码时,为什么资深工程师通常更倾向于使用位运算(
& 1和>> 1)来代替取模(% 2)和除法(/ 2)?"
详细解析与答案:
- 指令层级的降维打击: 在计算机底层的 ALU(算术逻辑单元)中,加减法和位运算是最基础、执行最快的指令,通常只需要 1 个 CPU 时钟周期。而整数的除法和取模运算是由微代码或者多步电路实现的复杂操作,往往需要几十个时钟周期。使用位运算是在用 CPU 的"母语"和它交流,能榨干最后的性能。
- 编译器的友好性: 虽然现代优秀的编译器(如 GCC/Clang 在 -O2 优化下)遇到
n / 2并且已知n是无符号整数时,会自动帮你优化成右移。但作为算法开发者,主动书写位运算能保证在任何编译环境下(包括未优化的环境),代码都能以最优性能运行。 - 语义更契合: 快速幂的本质就是在遍历指数的二进制位,使用位操作符(按位与、右移)在逻辑语义上比算术操作符(取模、除法)更加贴切,能够直接彰显算法的本质思想。
到了这一步,你已经掌握了一把异常锋利的宝剑。但是,这把剑在遇到现实世界的问题时,可能会"自毁"。因为指数爆炸太快,long long 类型最多只能装下大概 18 位十进制数字,稍微大一点的计算就会导致"数据溢出",变成一堆负数或乱码。
要解决这个问题,我们需要给这把剑套上一个坚固的"剑鞘" ------ 取模运算。
在日常刷题提交评测时,你一定经常碰到题目最后附带这样一句话:"由于结果过大,请将最终答案对 10 9 + 7 10^9+7 109+7 取模"。
这就是我们要戴上的"镣铐"。为什么要有这道镣铐?又该如何在镣铐下优雅地跳舞?这就是第四讲要彻底搞懂的内容。
第四讲:带着镣铐跳舞 ------ 快速幂取模(大数安全)
1. 前提小叙:可怕的指数爆炸与变量的悲歌
在 C++ 中,即使是我们能用的最大整数类型 long long,它的极限大约是 9 × 10 18 9 \times 10^{18} 9×1018。看起来很大对吧?
但是,如果是计算 3 40 3^{40} 340,结果就已经逼近 long long 的极限了。如果你想算 3 100 3^{100} 3100,变量会直接"撑爆"(也就是内存溢出),多出来的数据会被截断,最终输出一堆毫无意义的乱码或者负数。
为了防止溢出,算法题目通常会给一个"模数"(比如 10 9 + 7 10^9+7 109+7)。取模(%)的意思就是求余数。
问题来了:我们是应该全部算完最后再取模 ,还是边算边取模?
2. 苹果装箱法则:同余定理的通俗理解
全部算完再取模,绝对会死在半路上 ,因为中间的底数膨胀时,早把变量撑爆了。
所以,我们必须边算边取模 。但这样做结果还准确吗?这就引出了数学中非常重要的同余定理。
我们用"装箱子"来打个比方:
假设每个箱子最多装 10 个苹果(相当于对 10 取模)。
- 张三有 12 个苹果,装满 1 箱,剩下 2 个散装的。
- 李四有 13 个苹果,装满 1 箱,剩下 3 个散装的。
现在他们想把苹果数量相乘,看看总共能剩下几个散装的。
- 方法一(老实人): 12 × 13 = 156 12 \times 13 = 156 12×13=156。把 156 个苹果去装箱,装满 15 箱,剩下 6 个。
- 方法二(聪明人): 反正整箱的最后还是整箱的,对余数没有影响。我们直接把他们各自的散装苹果相乘: 2 × 3 = 6 2 \times 3 = 6 2×3=6。不用去算庞大的 156,也能得到余数 6!
用严谨的公式表达,就是:
( A × B ) m o d M = ( ( A m o d M ) × ( B m o d M ) ) m o d M (A \times B) \bmod M = ((A \bmod M) \times (B \bmod M)) \bmod M (A×B)modM=((AmodM)×(BmodM))modM
这就是在镣铐下跳舞的秘诀:在乘法的每一步之后,立刻砍掉溢出的"整箱",只带着"散装余数"轻装上阵进入下一步!
3. 字符画图解:砍掉多余的枝蔓
我们以计算 3 8 m o d 10 3^8 \bmod 10 38mod10 为例,看看"不取模"和"步步取模"的区别:
text
【不取模的狂飙:体积几何级膨胀】
3 -> 9 -> 81 -> 6561 (越往后数字越大,随时面临撑爆风险) 💥
【步步取模的修剪:永远保持小巧】 (模数 = 10)
[3]
| 平方,即 3*3 = 9,取模 10
[9]
| 平方,即 9*9 = 81,取模 10,砍掉 80
[1]
| 平方,即 1*1 = 1,取模 10
[1] ... (永远在安全范围内) 🛡️
4. 实战:加入取模后的 C++ 模板
基于第三讲的模板,我们只需要在每一次发生乘法的地方 ,加上 % mod 就可以了。请看这段经得起任何测试数据考验的安全代码:
cpp
long long fastPowerMod(long long base, long long exp, long long mod) {
long long ans = 1;
base = base % mod; // 防御性动作:防止一开始的底数就已经大于模数
while (exp > 0) {
if (exp & 1) {
// 动作1:提取放入总库时,做一次乘法,立刻取模
ans = (ans * base) % mod;
}
// 动作2:底数自我膨胀时,做一次乘法,立刻取模
base = (base * base) % mod;
// 动作3:指数右移不变
exp >>= 1;
}
return ans;
}
注意到了吗?代码的逻辑框架完全没有变,只是在乘法运算后加了一把名为 % mod 的"安全锁"。
第四讲:高频面试题与解析
面试题:
"在实现带取模的快速幂时,常见的模数(如 10 9 + 7 10^9+7 109+7)有什么特殊含义?在上述模板中,
ans * base这两项在相乘的一瞬间,是否有可能造成long long溢出?"
详细解析与答案:
- 模数为何经常是 10 9 + 7 10^9+7 109+7: 第一,它是一个足够大的质数,在后续学习组合数学、哈希表、求逆元时,质数能保证良好的数学性质(如满足费马小定理)。第二,它的大小极具讲究: 10 9 + 7 10^9+7 109+7 接近 10 9 10^9 109。
- 乘法瞬间的溢出分析: 在我们严格执行"步步取模"的策略下,
ans和base两个变量的值永远不会超过模数 10 9 + 7 10^9+7 109+7。
当它们在代码中执行ans * base或base * base时,可能发生的最大相乘情况约为:
10 9 × 10 9 = 10 18 10^9 \times 10^9 = 10^{18} 109×109=1018。
前面提到过,C++ 中 64 位整型long long的最大上限约为 9 × 10 18 9 \times 10^{18} 9×1018。
因此, 10 18 10^{18} 1018 刚好稳稳地落在long long的安全接球区内!这正是算法设计者选择 10 9 + 7 10^9+7 109+7 的良苦用心------它既足够大,又保证了平方时绝对不会引发 64 位整型的溢出。