P1303 A*B Problem

P1303 A*B Problem

题目背景

高精度乘法模板题。

题目描述

给出两个非负整数,求它们的乘积。

输入格式

输入共两行,每行一个非负整数。

输出格式

输出一个非负整数表示乘积。

输入输出样例 #1

输入 #1

1 
2

输出 #1

2

说明/提示

每个非负整数不超过 \(10^{2000}\)。

解题方法

方法一:基础高精度(逐位乘法)

思想

模拟竖式乘法的过程:

  1. 把两个数字倒序存入数组,低位在前,每一位存 \(0\sim 9\)。
  2. 双重循环,把两位的乘积累加到结果数组的对应位置上。
  3. 统一处理进位,把每个位置的值规范到 \(0\sim 9\)。
  4. 去掉前导零,倒序输出。

时间复杂度

\(O(len_a \times len_b)\),空间复杂度 \(O(len_a + len_b)\)。

C++ 代码

#include <iostream>
#include <vector>
using namespace std;

vector<int> mul(const vector<int>& A, const vector<int>& B) {
    vector<int> C(A.size() + B.size(), 0);
    // 1. 逐位相乘并累加
    for (size_t i = 0; i < A.size(); ++i) // 【格式使用size_t 规范化】
        for (size_t j = 0; j < B.size(); ++j)
            C[i + j] += A[i] * B[j];
    // 2. 统一处理进位
    int carry = 0;
    for (size_t i = 0; i < C.size(); ++i) {
        carry += C[i];
        C[i] = carry % 10;
        carry /= 10;
    }
    // 3. 去除前导零
    while (C.size() > 1 && C.back() == 0)
        C.pop_back();
    return C;
}

int main() {
    string a, b;
    cin >> a >> b;
    if (a == "0" || b == "0") {
        cout << 0 << endl; // 【0特判,提前返回】
        return 0;
    }
    vector<int> A, B;
    A.reserve(a.size()); // 【避免 vector 扩容带来的重复分配】
    B.reserve(b.size());
    // 倒序存储,方便从低位运算
    for (int i = a.size() - 1; i >= 0; --i)
        A.push_back(a[i] - '0');
    for (int i = b.size() - 1; i >= 0; --i)
        B.push_back(b[i] - '0');

    vector<int> C = mul(A, B);
    // 倒序输出
    for (int i = C.size() - 1; i >= 0; --i)
        cout << C[i];
    cout << endl;
    return 0;
}

方法二:压位高精度

思想

传统做法每个 int 只存 0∼90∼9 的一位数字,严重浪费 4 字节空间。

压位的核心是让每个 int 多存几位,比如存 4 位(0∼9999)或 9 位(0∼999999999),这样数字长度直接缩小为原来的 1/4 或 1/9,运算量和复杂度系数成倍缩减。

步骤

  1. 存储方式:从低位到高位,每 9 位数压入一个 int。

  2. 乘法运算:逐位相乘后累加到对应位置,C[i+j] += A[i] * (long long)B[j](必须用 long long 防止溢出)。

  3. 进位处理:模数为 10^9,即 C[i+1] += C[i]/BASE; C[i] %= BASE;。

  4. 输出:最高位直接输出,其余每个数字需要补齐前导 0 到 9 位(用 printf("%09d", num) 或 cout << setw(9) << setfill('0'))。

时间复杂度

\(O((len_a/9) \times (len_b/9))\),常数远小于方法一。

C++ 代码(\(10^9\) 进制)

#include <iostream>
#include <vector>
#include <iomanip>
#include <string>
using namespace std;

const int BASE = 1000000000; // 10^9
const int WIDTH = 9;

// 将字符串转化为压位数组,低位在前
vector<int> to_vector(const string& s) {
    vector<int> res;
    // 从字符串末尾开始,每 WIDTH 位切一段
    for (int i = s.size() - 1; i >= 0; i -= WIDTH) {
        int start = max(0, i - WIDTH + 1);
        string sub = s.substr(start, i - start + 1);
        res.push_back(stoi(sub)); // 子串长度 <= WIDTH,int 安全
    }
    return res;
}

// 高精度乘法(压位,边乘边进位,安全不溢出)
vector<int> multiply(const vector<int>& A, const vector<int>& B) {
    // 用 long long 存储中间结果,确保累加不溢出
    vector<long long> C(A.size() + B.size(), 0);

    for (size_t i = 0; i < A.size(); ++i) {
        for (size_t j = 0; j < B.size(); ++j) {
            // 累加乘积到对应位
            C[i + j] += 1LL * A[i] * B[j];
            // 立即处理当前位的进位,保持 C[i+j] 始终 < BASE
            C[i + j + 1] += C[i + j] / BASE;
            C[i + j] %= BASE;
        }
    }

    // 由于边乘边进位,可能最高位仍有残留进位未处理,统一扫尾
    for (size_t i = 0; i < C.size() - 1; ++i) { // 【要注意为什么要多-1,为了防止溢出】
        C[i + 1] += C[i] / BASE;
        C[i] %= BASE;
    }

    // 去除前导零(结果可能比实际需要的长一位全零)
    while (C.size() > 1 && C.back() == 0)
        C.pop_back();

    // 转为 int 返回
    vector<int> res(C.begin(), C.end());
    return res;
}

int main() {
    // 加速 IO(可选)
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string a, b;
    cin >> a >> b;

    // 乘数中有 0 的情况直接输出 0
    if (a == "0" || b == "0") {
        cout << 0 << '\n';
        return 0;
    }

    vector<int> A = to_vector(a);
    vector<int> B = to_vector(b);
    vector<int> C = multiply(A, B);

    // 输出:最高位直接输出,其余位补足前导零到 WIDTH 位
    cout << C.back();
    for (int i = C.size() - 2; i >= 0; --i) // 【注意为什么这里不使用size_t格式,因为这里如果无符号-1到0下,会变为无穷大】
        cout << setw(WIDTH) << setfill('0') << C[i];
    cout << '\n';

    return 0;
}

说明

  1. 字符串 → 每 9 位切一段 → 倒序压入 vector<int>(低位在前)

  2. 结果数组开 A.size() + B.size() 个 long long

  3. 双重循环:C[i+j] += A[i] * B[j],然后立即 C[i+j+1] += C[i+j] / BASE; C[i+j] %= BASE;

  4. 最后扫一遍进位 + 去前导零

  5. 倒序输出,最高位不补零,其余 setw(9) << setfill('0')


方法三:Karatsuba 分治(课外阅读)

思想

普通乘法复杂度 \(O(n^2)\),Karatsuba 算法利用分治策略将复杂度降至 \(O(n^{\log_2 3})\approx O(n^{1.585})\)。

将大数 \(X,Y\) 各分为两半:

\[X = a \cdot 10^{n/2} + b,\quad Y = c \cdot 10^{n/2} + d \]

则:

\[X\times Y = ac\cdot 10^n + \big((a+b)(c+d) - ac - bd\big)\cdot 10^{n/2} + bd \]

只需计算三次乘法 \(ac\)、\(bd\)、\((a+b)(c+d)\),配合加减法即可。递归到底层数字较小时换用基础乘法。

适用场景

  • 当数字长度超过某一阈值时递归使用 Karatsuba,低于阈值切换为普通 $$N^2$$ 乘法(或压位乘法)。

  • 在某些 OJ 或大型项目中,Karatsuba 与 FFT/NTT 结合使用,相得益彰。

方法四:FFT / NTT 加速(进阶)

思想

高精度乘法与多项式乘法完全等价:

把数字看作多项式 \(P(x)=\sum a_i x^i\)(\(a_i\) 是一位或压位系数),两个多项式相乘的系数就是高精度乘法的结果。

直接计算卷积是 \(O(n^2)\),但用快速傅里叶变换(FFT) 可以在 \(O(n\log n)\) 时间内完成。
FFT 存在浮点精度问题,所以通常用快速数论变换(NTT) 在模大质数下精确计算,适合超大整数运算。

适用场景

数字长度达到 \(10^5\) 及以上,普通压位也无法满足时限要求时,必须使用 FFT/NTT。
洛谷 P4245 是任意模数 NTT 的模板题,适合后续挑战。


总结与学习路径

方法 时间复杂度 空间复杂度 适用数据规模 学习阶段
基础逐位乘法 \(O(n^2)\) \(O(n)\) \(n \le 2000\) 入门,必会
压位高精度 \(O((n/k)^2)\) \(O(n/k)\) \(n \le 10^4\) 提高,建议收入模板
Karatsuba \(O(n^{1.585})\) \(O(n)\) \(n \ge 10^5\) 省选/ACM,理解分治
FFT / NTT \(O(n\log n)\) \(O(n)\) \(n \ge 10^5\) 高阶,多项式运算核心

建议学习顺序:
先彻底掌握基础逐位乘法(方法一),
然后将其优化为压位版本(方法二)并作为自己的常用模板,
之后逐步了解 Karatsuba 和 FFT/NTT 的原理与应用场景。


技巧补充

  1. 循环顺序与缓存
    在双层循环中,让第一层循环遍历较短的数组,可以减少内层循环的长度,对缓存友好。

  2. size_t 回绕风险

    for (size_t i = n.size() - 1; i >= 0; --i)  // 死循环!
    

    应改成 int i 或 for (size_t i = n.size(); i--; )。

  3. 快速字符转数字
    n[i] ^ 48 与 n[i] - '0' 等效,前者在某些编译器上略快。

  4. 输出格式化
    压位输出时使用 printf("%0*d", width, num) 或 cout << setw(width) << setfill('0') 保证补齐前导零。


Python 版本参考

a = int(input().strip())
b = int(input().strip())
print(a * b)

因为 Python 自带大整数,直接乘即可。若想手写,参考基础逐位乘法的结构即可。

重要问题

(1)循环交换与缓存友好性

你的原始代码:

for (size_t i = 0; i < A.size(); ++i)
    for (size_t j = 0; j < B.size(); ++j)
        C[i + j] += A[i] * B[j];

为什么可以交换?
因为加法与乘法满足交换律与分配律,两层循环遍历所有 (i, j) 的组合,最终累加到 C[i+j] 的总和是相同的。

交换后的代码:

for (size_t j = 0; j < B.size(); ++j)
    for (size_t i = 0; i < A.size(); ++i)
        C[i + j] += A[i] * B[j];

这里固定 j,内层 i 遍历 A 和 C 的连续片段。

为什么对缓存友好?

  • 内存中数组 A 的元素是连续存放的(vector 保证连续存储)。
  • 当 j 固定时,内层循环按 i=0,1,2,... 顺序访问 A[i],这正是顺序访问,CPU 的缓存预取机制可以提前把下一块数据加载到高速缓存,大幅减少 cache miss。
  • 同时 C[i+j] 也是连续的:C[0+j], C[1+j], C[2+j]...,写入地址同样连续,能利用写缓存合并(write combining)。

反观原始代码:

  • 固定 i,内层 j 变化,访问 B[j] 是连续的,但 A[i] 在每一次内层循环中是同一个值,这倒还好。
  • 但 C[i+j] 的访问是跨步的(i 固定,j 变化,地址间隔一个 int),连续性不如后者。

当然,在本题中数据规模较小,这两种写法几乎看不出性能差异;但养成关注内存访问模式的习惯,在处理大矩阵、图像处理、科学计算时,会是决定程序快慢的关键因素。


(2)reserve() 的意义与字符串长度

问题回顾:

A.reserve(n.size());

如果输入 n = "100",数字是 100,为何 n.size() 是 3?

解释:

  • n 是 string 类型,存储的是输入数字的字符串表示。
  • "100" 包含三个字符:'1', '0', '0',所以 n.size() 返回的是位数,而不是数值大小。
  • 同理,输入数字有 2000 位时,size() 就是 2000。

reserve 做了什么?

  • vector 是一个动态数组,内部有一块堆内存来存元素。当我们不断 push_back 时,如果已分配的空间不够,它会:
    1. 重新分配一块更大的内存(通常是原来的 2 倍或 1.5 倍)
    2. 把原有元素拷贝或移动到新内存
    3. 释放旧内存
  • 这个过程叫“扩容”,多次扩容会带来不小的开销。
  • reserve(n.size()) 一次性预分配足够的容量,保证在 push_back 不超过这个数量时,一次扩容都不会发生,从而提高效率。

举例:

vector<int> A;
A.reserve(100); // 现容量 ≥ 100
for (int i = 0; i < 100; ++i)
    A.push_back(i);  // 这100次都不会触发重新分配

在你的代码里,预分配 n.size() 个位置保证了把字符串逐位 push_back 时没有额外内存操作。这是一种常数级优化,也是工程上的好习惯。


(3)压位高精度:如何确定 int 能存几位?

这是设计高精度压位模板的核心数学基础。我们一步步推导。

3.1 存储类型与运算类型

  • 我们通常用 int 数组存储压位后的每一位(值 ∈ \([0, \text{BASE}-1]\))。
  • 乘法时,需要计算两个这样的“数字”的乘积,并可能加上进位。
  • 最大乘积为 \((\text{BASE}-1)^2\),如果有进位累积,还可能加上 \((\text{BASE}-1)\) 左右的额外值(进位通常小于 BASE)。
  • C++ 标准保证 int 至少是 16 位,但在现代平台(包括洛谷的评测环境)都是 32 位,最大值为 \(2^{31}-1 \approx 2.147\times 10^{9}\)。显然,\((\text{BASE}-1)^2\) 很容易超过这个值,所以乘法时必须用 long long(64 位,最大约 \(9.22\times 10^{18}\))来保存中间结果。

3.2 安全不等式

设基数为 \(B\),我们要求两个“数字”乘积累加进位后不溢出 long long 的正值范围(实际上保证不超过 \(2^{63}-1\))。

最大可能累加到某一位 C[k] 的值为:

  • 来自乘积的和:最多有 \(\min(len_A, len_B, k+1)\) 对 (i,j) 的乘积会落到该位置。
  • 保守做法是保证中间变量 prod 可以安全存储两个数字的乘积,且后续进位加法不溢出。通常我们控制单次 prod = A[i] * B[j] 不溢出 long long,因为进位是单独处理的。

基本条件:

\[(B-1)^2 < 2^{63}-1 \approx 9.22\times 10^{18} \]

如果只用 long long 存乘积,且不同时加入巨大的累加(可在内层循环中即时处理进位,避免累积过大),这个条件就足够了。

解不等式:

\[B-1 < \sqrt{9.22\times 10^{18}} \approx 3.037\times 10^9 \]

\[B < 3.037\times 10^9 + 1 \]

所以 \(B\) 最大可以取 \(3\times 10^9\)(即 3,000,000,000)。但为了输出方便,通常取 \(10^k\) 形式,并且要留足进位余量。

例如:

  • \(B = 10^9\):\((10^9-1)^2 \approx 10^{18} - 2\times 10^9 + 1 < 9.22\times 10^{18}\),安全。
  • \(B = 10^{10}\):\((10^{10})^2 = 10^{20} > 9.22\times 10^{18}\),会溢出 long long,不行。
  • 所以 \(10^9\) 是安全且舒适的极限。

3.3 如果有进位累加的情况?

一些实现会把累加和进位一起做,比如:

long long sum = (long long)C[i+j] + (long long)A[i] * B[j] + carry;

此时 C[i+j] 可能已经很大(上一轮累加的结果,可能接近几个 \(B\)),所以需要更大的安全范围。若采用逐次进位(每次乘法后立刻将当前位的值处理到 \([0, B-1]\),将进位传到下一位),C[i+j] 就保持在一个很小的范围内,那么条件 \((B-1)^2 + (B-1)\) 仍然在 \(9.22\times 10^{18}\) 以内即可:

\[(10^9-1)^2 + (10^9-1) \approx 10^{18} + 10^9 < 9.22\times 10^{18} \]

依然安全。

3.4 为什么不是 \(10^5\) 或 \(10^6\)?

  • 基数越大,数组越短,循环次数越少,整体运算越快。
  • 但基数受限于相乘不溢出和输出方便性。用 \(10^9\) 可以直接用 printf 的 %09d 补零输出,非常方便。

📝 总结确定 BASE 的步骤

  1. 确定暂存乘积的类型(通常是 long long)。
  2. 确保 \((BASE-1)^2\) 加上可能的最大进位(通常不超过 \(BASE\))小于该类型的最大值。
  3. 优先选 \(10^k\) 以便输出时按位补零。
  4. 对于 64 位有符号整型,\(10^9\) 是最常用、最安全的选择。

(4)n[i] ^ 48 为什么等效且略快?

字符编码背景

字符 '0' 到 '9' 在 ASCII 表中的编码值十进制是 48 到 57。
二进制表示如下:

'0' = 48 = 0011 0000
'1' = 49 = 0011 0001
'2' = 50 = 0011 0010
...
'9' = 57 = 0011 1001

48 的二进制是 0011 0000,高四位 0011,低四位 0000。

n[i] - '0' 的原理

用 ASCII 码值减去 '0' 的码值(48),得到 0~9。这是标准的整型减法。

n[i] ^ 48 的原理

异或运算(XOR)的规则:相同为 0,不同为 1。

  • n[i] 的高四位是 0011,与 48 的高四位 0011 相同,异或结果的高四位变成 0000。
  • n[i] 的低四位恰好就是它代表的数字的二进制值(0~9),例如 '5' 的低四位是 0101。
  • 48 的低四位是 0000,任何数与 0000 异或,保持不变。
  • 所以 n[i] ^ 48 的效果:保留低四位(正好是 0~9),高四位清零。

结果:'0'^48=0, '1'^48=1, ..., '9'^48=9,完美等价于减 48。

为什么“略快”?

  • 位运算(异或)在 CPU 中是非常基础的逻辑门操作,延迟极低,通常只需 1 个时钟周期。
  • 减法需要做借位传递,硬件上比异或复杂一些,虽然现代编译器可能会将 - 48 优化成某种快速操作,但不一定是位运算。
  • 在某些古老或简单的嵌入式编译器中,^48 确实会生成更紧凑的代码。
  • 在竞赛环境中(O2 优化开启),差距微乎其微,两者都可以认为是等价的。

实践中,这种“奇技淫巧”了解即可,用 - '0' 可读性更好,更适合团队协作。但在高强度竞赛中,许多选手仍会保留 ^48 的习惯,作为一种速度上的“心理安慰”和对底层世界的致敬。


判断是否需要高精度,核心就看一件事:运算结果会不会超出语言内置整数类型的范围。以下是 C++ 的实用判断标准,Python 用户可以直接跳过,因为 Python 的 int 自动支持任意大数。


1. 先记下 C++ 内置类型的上限

类型 最大值 大约范围 能安全存储的十进制位数
int (32位) \(2^{31}-1\) \(2.147\times 10^9\) 9 位
unsigned int \(2^{32}-1\) \(4.295\times 10^9\) 9 位
long long (64位) \(2^{63}-1\) \(9.223\times 10^{18}\) 18 位
unsigned long long \(2^{64}-1\) \(1.844\times 10^{19}\) 19 位

规律:数值的位数 > 对应类型的安全位数,就必须用高精度。


2. 加法、减法、乘法的具体判断

✅ 加法(A + B)

  • 只看 两个加数的位数。
  • 如果任一加数的位数 ≥ 19 位(即字符串长度 ≥ 19),unsigned long long 也存不下,直接上高精度。
  • 如果位数都 ≤ 18,可以直接用 long long 计算,但要注意相加可能进位多一位,所以严格说:
    若 max(lenA, lenB) ≤ 18,通常可以用 long long 安全相加;若其中一个是 18 位且最高位较大,稳妥起见用 unsigned long long,否则转高精度。

简记:输入数字的字符串长度 ≥ 19 → 必用高精度。长度 ≤ 18 → 用 long long 加法即可(注意开 long long)。

✅ 减法(A - B)

  • 前提是结果非负(或手动判负号)。规则与加法几乎一致。
  • 输入数字字符串长度 ≥ 19 → 高精度。
  • 长度 ≤ 18 → long long 直接减,但要保证不出现负数越界(题目一般保证 A ≥ B)。

✅ 乘法(A × B)

乘法增长极快,判断标准更严格:两个数的位数之和决定结果大小。

  • 乘积的位数 ≈ lenA + lenB(或 lenA + lenB - 1)。
  • long long 最多安全存储 18 位结果。
  • 因此:如果 lenA + lenB > 18,就必须用高精度(或压位)。
  • 如果 lenA + lenB ≤ 18,可以用 long long 直接乘(注意中间乘积也要用 long long 承接)。

简记:两个数字符串长度相加超过 18 → 必用高精度。否则看情况。


3. 实战速查表

场景 字符串长度条件 能否用 long long
A + B max(lenA, lenB) ≤ 18 ✅ 可以(注意进位可能到 19 位,但极少)
A - B max(lenA, lenB) ≤ 18 ✅ 可以
A × B lenA + lenB ≤ 18 ✅ 可以
以上任一条件不满足 超出 ❌ 必须高精度

特例提醒:

  • 哪怕乘数很小,例如 \(10^{10} \times 10^{10}\) 位数之和 = 20,超出 long long,必须高精度。
  • 阶乘类(如 P1009)几乎必然超出,因为阶乘增长极快,n=13 时 13! 就达到 62 亿,超出 32 位 int;n=21 时 21! 超过 long long 范围。所以阶乘题目 直接上高精度,不必犹豫。

4. Python 用户的一句提醒

a = int(input())
b = int(input())
print(a + b)   # 永远不溢出

Python 的 int 自带高精度,不需要考虑范围。只有当你刻意练习高精度原理时,才需要像 C++ 那样手动模拟。

posted @ 2026-05-10 14:55  炸酱面配小米酒  阅读(39)  评论(0)    收藏  举报