算法学习笔记(11): 原根

算法,学习,笔记 · 浏览次数 : 64

小编点评

```cpp #include #include #include #define phi(p) prm.find(p) #define notp(p) prm.find(~p) using namespace std; void getPrm() { phi[1] = 1; for (int i = 2; i < N; ++i) { if (!notp[i]) prm.push_back(i), phi[i] = i - 1; for (const int &j : prm) { if (i * j > N) break; notp[i * j] = true; if (i % j == 0) { // i | n && i^2 | n => phi(n) = phi(n / i) * i phi[i * j] = phi[i] * j; break; } else { // i | n && not i^2 | n => phi(n) = phi(n / i) * (i - 1) phi[i * j] = phi[i] * phi[j]; } } } // end getPrm for } void getExists() { exists[2] = exists[4] = true; for (int i = 1; i < (int)prm.size(); ++i) { int p = prm[i]; for (int q = p; q < N; q *= p) { exists[q] = true; if (q * 2 < N) exists[q * 2] = true; } } // printf(\"Exists init!\\");} } void factorize(int x, vector& & v) { v.push_back(1); for (const int &prm) { if (p > x) break; if (x % p == 0) v.push_back(x / p); } } void getAll(int p, vector& & v) { // no answer if (!exists[p]) return; int ph = phi[p]; int fst, cur; vector factors; factors.clear(); factorize(ph, factors); // enum i which gcd(i, m) == 1 // find first element i suit i^ph = 1 mod p for (int i = 1; ; ++i) { if (gcd(i, ph) == 1) v.push_back(cur); cur = cur * fst % p; } } int main() { // ... return 0; } ```

正文

原根

此文相对困难,请读者酌情食用

在定义原根之前,我们先定义其他的一点东西


通俗一点来说,对于 \(a\) 在模 \(p\) 意义下的就是 \(a^x \equiv 1 \pmod p\)最小正整数解 \(x\)

或者说,\(a\) 在模 \(p\) 意义下生成子群的阶(群的大小)

再或者说,是 \(a\) 在模 \(p\) 意义下的循环节的大小

循环节,生成子群……真绕……其实两者很类似

两者的大小也就是在模 \(p\) 意义下集合 \(\{a^1, a^2, a^3,\dots,a^k\}\) 不重复元素的个数

可以通过欧拉定理可知 \(a^{\varphi(p)} \equiv a^0 \equiv 1 \pmod p\) 也就是说存在一个数 \(k\),使得 \(a^k \equiv a \pmod p\)

所以,就有了循环……

我们将 \(a\) 在模 \(m\) 意义下的记作 \(ord_ma\)

显然,无论是根据群论还是什么,\(ord_ma\) 一定是 \(\varphi(p)\) 的因数

特别的,当 \(ord_ma = \varphi(p)\) 时,称 \(a\) 为模 \(p\) 意义下的一个原根

那么原根的定义出来了……


原根

上述定义或许不是那么简单,我们换一种说法

\[\begin{aligned} \forall x \in [1, \varphi(p)), a^x \not\equiv 1 \pmod p \\ a^{\varphi(p)} \equiv 1 \pmod p \end{aligned} \]

满足上述两个条件的数 \(a\) 就是模 \(p\) 意义下的一个原根

这两个条件也是我们在程序中验证原根的方法 QwQ

而对于原根来说,\(a^1, a^2,\dots,a^{\varphi(p)}\)\(m\) 下各不相同,他们就是最短的循环节,也是模 \(p\) 意义下的完全剩余系,或者说原根的生成子群


但是,并不是每一个数都存在原根

例如 \(\varphi(8) = 4\),但是没有任何数在模 \(8\) 下的阶为 \(4\)

为判断一个数是否有原根,我们有一个重要的定理:

正整数 \(k\) 有原根的充要条件为 \(k\) 能表示成 \(2, 4, p^n, 2p^n\) 中的任何形式之一,其中 \(p\)为奇素数

由于证明比较复杂,若感兴趣可以参见这篇博客 原根证明


那么如何求出一个数 \(p\) 有多少个原根

假设我们已经求出了一个原根 \(g\)

由于原根一定存在与 \(p\) 的完全剩余系中,而 \(g\) 的生成子群与之等价,也就是说,我们需要在

\[\{g^k|k \in [1, \varphi(p))\} \]

这个集合中寻找所有的原根

所以,考虑构造出判断阶的方法。我们令 \(d = gcd(k, \varphi(p))\)

那么

\[g^{k\frac {\varphi(p)} d} \equiv g^{\varphi(p) \frac kd} \equiv 1^{\frac kd} \equiv 1 \pmod p \]

那么 \(\frac k{gcd(k, \varphi(p))}\) 也就是 \(g^k\) 的阶

若需要满足原根的定义,我们必须使得 \(gcd(k, \varphi(p)) = 1\)

同时考虑 \(g = g^1\)\(gcd(1, \varphi(p)) = 1\),也就是说有总共有 \(\varphi(\varphi(p))\) 个原根


这同时启发我们,只要我们找到了任意一个模 \(p\) 意义下的原根,我们就可以求出所有原根。

至于如何找到原根,选择暴力枚举即可

有证明:如果一个数 \(n\) 有原根,则其最小原根渐近意义下不大于 \(\sqrt[4]n\) 级别,所以直接枚举是没有任何问题的


那么我们总结一下求 \(n\) 的原根的所有步骤

  • 预处理

    • 利用线性筛求出所有的质数以及每一个数的 \(\varphi\)

    • 对每一个筛出的质数,标记出所有 \(p^q\)\(2p^q\) (别忘了\(2, 4\))

  • 判断 \(n\) 是否有原根

  • 最小原根

    • 求出 \(\varphi(m)\) 的所有质因数 \(i\),记录 \(m/i\) (记得将 \(1\) 也记录进去)

    • 枚举一个数 \(g\),对于每一个记录的数 \(j = m/i\) 分别计算 \(g^j\),如果\(i^j \equiv 1 \pmod m\) ,说明 \(i\) 不是原根 (这里再下文中有解释)

    • 重复第二部,直到找到一个原根 \(g\) 为止

  • 所有原根

    • 枚举 \(k \in [1, \varphi(m))\)

    • 如果 \(gcd(k, \varphi(m)) = 1\),则 \(g^k\) 一个原根,记录下他

解释:求最小原根的判断

为什么我们只需要枚举 \(m/i\) 就行了?

考虑我们将 \(m\) 分解成 \(m = i_1^{a_1} i_2^{a_2}\dots i_k^{a_k}\)

由于如果需要满足 \(i^j \equiv 1 \pmod p\),一定有 \(j|\varphi(p)\)

那么对于 \(m/i = kj\),一定有 \(i^{m/i}\equiv 1 \mod p\)

也就是说,所有 \(m/i\) 就包含了所有情况了

那么为什么要加上 \(1\) 呢,这就交给读者消化思考喽 _

参考代码如下:真的只供参考,这种写法特别慢

template<typename T>
inline T gcd(T x, T y) {
    T z;
    while (y) z = x % y, x = y, y = z;
    return x;
}

template<typename T>
inline T qpow(T a, T x, T p) {
    T r(1); a %= p;
    while (x) {
        if (x & 1) r = (r * a) % p;
        a = (a * a) % p, x >>= 1;
    }
    return r;
}

int phi[N], notp[N];
std::vector<int> prm;
void getPrm() {
    phi[1] = 1;
    for (int i = 2; i < N; ++i) {
        if (!notp[i]) prm.push_back(i), phi[i] = i - 1;

        for (const int &j : prm) {
            if (i * j >= N) break;
            notp[i * j] = true;
            if (i % j == 0) {
                // i | n && i^2 | n => phi(n) = phi(n / i) * i
                phi[i * j] = phi[i] * j; break;
            } else {
                // i | n && not i^2 | n => phi(n) = phi(n / i) * (i - 1)
                phi[i * j] = phi[i] * phi[j];
            }
        }
    } // end getPrm for
}

bool exists[N];
void getExists() {
    exists[2] = exists[4] = true;
    for (int i = 1; i < (int)prm.size(); ++i) {
        int p = prm[i];
        for (int q = p; q < N; q *= p) {
            exists[q] = true;
            if (q * 2 < N) exists[q * 2] = true;
        }
    }
    // printf("Exists init!\n");
}

void factorize(int x, vector<int> & v) {
    v.push_back(1);
    for (const int &p : prm) {
        if (p >= x) break;
        if (x % p == 0) v.push_back(x / p);
    }
}

void getAll(int p, vector<int> & v) {
    // no answer
    if (!exists[p]) return;

    int ph = phi[p];
    int fst, cur;
    vector<int> factors; factors.clear();
    factorize(ph, factors);

    // enum i which gcd(i, m) == 1
    // find first element i suit i^ph = 1 mod p
    for (int i = 1; ; ++i) {
        if (gcd(i, p) != 1) continue;
        // if (qpow(i, ph, p) != 1) continue;

        bool valid = true;
        // we need i only if i^ph = 1 mod p, not other numbers.
        for (auto &e : factors) {
            if (e != ph && qpow(i, e, p) == 1) {
                valid = false; break;
            }
        }

        if (valid) {
            fst = cur = i; break;
        }
    }

    for (int i(1); i <= ph; ++i) {
        if (gcd(i, ph) == 1) v.push_back(cur);
        cur = cur * fst % p;
    }
}

考虑模板可能有爆 int 的风险,请参考者合理使用 long long

这样通过 getAll 得出的 vector 是乱序的,需要再排序一次

与算法学习笔记(11): 原根相似的内容:

算法学习笔记(11): 原根

原根 此文相对困难,请读者酌情食用 在定义原根之前,我们先定义其他的一点东西 阶 通俗一点来说,对于 $a$ 在模 $p$ 意义下的阶就是 $a^x \equiv 1 \pmod p$ 的最小正整数解 $x$ 或者说,$a$ 在模 $p$ 意义下生成子群的阶(群的大小) 再或者说,是 $a$ 在模

算法学习笔记(27): 后缀排序

后缀排序 本文不作为教学向文章。 开篇膜拜 Pecco:算法学习笔记(84): 后缀数组 - 知乎 (zhihu.com) 有些时候,其实 \(O(n \log^2 n)\) 的排序也挺好。又短又简单。 其中 \(rk[i]\) 表示从第 \(i\) 个字符开始的后缀的排名,\(sa[i]\) 表示

算法学习笔记(6): 树链剖分

树链剖分 树链剖分是一个很神奇,但是在树上可以完成一些区间操作问题 简单来说,就是把一棵树分成一条条的链,通过维护链上的信息来维护整棵树的信息 基础知识可以参考我的另外一篇博客:算法学习笔记(5): 最近公共祖先(LCA) 这里假设你已经掌握了上述博客中的所有相关知识,并清晰了其背后的原理 性质?发

算法学习笔记(30):Kruskal 重构树

Kruskal 重构树 这是一种用于处理与最大/最小边权相关的一个数据结构。 其与 kruskal 做最小生成树的过程是类似的,我们考虑其过程: 按边权排序,利用并查集维护连通性,进行合并。 如果我们在合并时,新建一个节点,其权值为当前处理的边的权值,并将合并的两个节点都连向新建的节点,那么就可以得

算法学习笔记(3.1): ST算法

ST表 在RMQ(区间最值)问题中,著名的ST算法就是倍增的产物。ST算法可以在 \(O(n \log n)\) 的时间复杂度能预处理后,以 \(O(1)\) 的复杂度在线回答区间 [l, r] 内的最值。 当然,ST表不支持动态修改,如果需要动态修改,线段树是一种良好的解决方案,是 \(O(n)\

C++算法之旅、09 力扣篇 | 常见面试笔试题(上)算法小白专用

算法学习笔记,记录容易忘记的知识点和难题。详解时空复杂度、50道常见面试笔试题,包括数组、单链表、栈、队列、字符串、哈希表、二叉树、递归、迭代、分治类型题目,均带思路与C++题解

C++算法之旅、08 基础篇 | 质数、约数

算法学习笔记,记录容易忘记的知识点和难题。试除法、分解质因数、筛质数、约数个数、约数之和、最大公约数

算法学习笔记(1): 欧几里得算法及其扩展

扩展欧几里得算法详解 在了解扩欧之前我们应该先了解欧几里得算法 欧几里得算法 这是一个递归求最大公约数(greatest common divisor)的方法 $$ gcd(a, b) = gcd(b, a % b) $$ 可以通过一个类似的简单公式推导而来 好像叫做辗转相减法来着? $$ gcd(

算法学习笔记(3): 倍增与ST算法

倍增 目录倍增查找 洛谷P2249重点变式练习快速幂ST表扩展 - 运算扩展 - 区间变式答案倍增更多的用法优化矩形查询优化建图优化 DP作者有话说 倍增,字面意思即”成倍增长“ 他与二分十分类似,都是基于”2“的划分思想 那么具体是怎么样,我们以一个例子来看 查找 洛谷P2249 依据题面,我们知

算法学习笔记(4): 并查集及其优化

# 并查集 并查集,Disjoint-Set,或者通俗一点,叫做MergeFind-Set,是一种可以动态维护若干个不重叠的集合,并支持集合之间的合并与查询的数据结构。 集体来说,并查集支持下列两个操作: - Find,查询元素所属集合 - Merge,将两个元素所属集合合并 一般来说,为了具体实现