[Python] 费马小定理

65 阅读1分钟

前言

您是否注意过以下规律?

  • 自然数 nn 的平方 n2n^2 似乎总是满足以下两个等式中的某一个

    • n20(mod3)n^2\equiv 0 \pmod{3}
    • n21(mod3)n^2\equiv 1 \pmod{3}
  • 自然数 nn44 次方 n4n^4 似乎总是满足以下两个等式中的某一个

    • n40(mod5)n^4\equiv 0 \pmod{5}
    • n41(mod5)n^4\equiv 1 \pmod{5}
  • 自然数 nn55 次方似乎总是满足以下等式

    • n5n(mod10)n^5\equiv n \pmod{10}

如果这些规律成立,我们能否证明它们?如果这些规律不(都)成立,我们可以找到反例吗?

本文会提供可以对此进行探索的 Python\text{Python} 代码(包括图形化版本和非图形化版本)。其中图形化版本的示例效果如下 👇

image.png

本文会在此基础上,对费马小定理进行介绍。

正文

用较小的质数进行试验

不借助图形化界面

我们可以用较小的质数 pp 进行一些试验。我想通过 Python\text{Python} 代码来自动生成下图展示的这种表格 ⬇️

image.png

于是我写了这样的代码 (豆包trae 提供了帮助)

"""
A tool for generating modular powers for a given prime number p.
"""
from typing import List


class SimpleModularPowDisplayer:
    """A class to display modular powers for a given positive integer m.

    Attributes:
        m (int): a positive integer (greater than or equal to 2)
    """

    def __init__(self, m: int) -> None:
        """Initialize the displayer with a positive integer m.
        
        Args:
            m (int): a positive integer
        
        Raises:
            ValueError: when m is not an integer or m < 2
        """
        if not isinstance(m, int) or m < 2:
            raise ValueError("m must be an integer that is greater than or equal to 2")
        self.m = m

    def _get_header_elements(self) -> List[str]:
        """Generate the header elements for the Markdown table.

        Returns:
            List[str]: Header cells for the Markdown table (LaTeX-formatted)
        """
        header = [f"$a$"]
        for exp in range(2, self.m + 1):
            header.append(f"$a^{{{exp}}} \\bmod {self.m}$")
        return header

    def print_header(self) -> None:
        """Print the header row of the Markdown table."""
        elements = self._get_header_elements()
        print(f"| {' | '.join(elements)} |")

    def _get_row_elements(self, a: int) -> List[str]:
        """Generate the elements for a single row of the Markdown table.
        
        Args:
            a (int): the base number (0 <= a < m)
        
        Returns:
            List[str]: Cell values for the row (LaTeX-formatted)
        """
        elements = [f"${a}$"]
        current = a
        for _ in range(2, self.m + 1):
            current = (current * a) % self.m
            elements.append(f"${current}$")
        return elements

    def print_row(self, a: int) -> None:
        """Print a single row of the Markdown table for a given base a.
        
        Args:
            a (int): an integer that satisfies the condition 0 <= a < m
        
        Raises:
            ValueError: If a is outside the range [0, m-1]
        """
        if not 0 <= a < self.m:
            raise ValueError(f"a should be inside [0, {self.m-1}]")
        elements = self._get_row_elements(a)
        print(f"| {' | '.join(elements)} |")

    def show_markdown_result(self) -> None:
        """Print the complete Markdown table for modular powers of m."""
        print(f"#### $m={self.m}$")
        self.print_header()
        # Print the separator row
        separator = ["---"] * len(self._get_header_elements())
        print(f"| {' | '.join(separator)} |")
        # Traverse all numbers from 0 to m-1
        for a in range(self.m):
            self.print_row(a)


if __name__ == "__main__":
    # A list with prime numbers
    PRIME_LIST = [2, 3, 5, 7, 11]
    for prime in PRIME_LIST:
        try:
            displayer = SimpleModularPowDisplayer(prime)
            displayer.show_markdown_result()
            print("\n")
        except ValueError as e:
            print(f"Error occurred when processing p={prime}: {e}")

请将上述代码保存为 print_markdown_table.py。用如下命令可以运行 print_markdown_table.py

python3 print_markdown_table.py

运行结果包含了 55markdown 表格 ⬇️

m=2m=2
aaa2mod2a^{2} \bmod 2
0000
1111
m=3m=3
aaa2mod3a^{2} \bmod 3a3mod3a^{3} \bmod 3
000000
111111
221122
m=5m=5
aaa2mod5a^{2} \bmod 5a3mod5a^{3} \bmod 5a4mod5a^{4} \bmod 5a5mod5a^{5} \bmod 5
0000000000
1111111111
2244331122
3344221133
4411441144
m=7m=7
aaa2mod7a^{2} \bmod 7a3mod7a^{3} \bmod 7a4mod7a^{4} \bmod 7a5mod7a^{5} \bmod 7a6mod7a^{6} \bmod 7a7mod7a^{7} \bmod 7
00000000000000
11111111111111
22441122441122
33226644551133
44221144221144
55446622331155
66116611661166
m=11m=11
aaa2mod11a^{2} \bmod 11a3mod11a^{3} \bmod 11a4mod11a^{4} \bmod 11a5mod11a^{5} \bmod 11a6mod11a^{6} \bmod 11a7mod11a^{7} \bmod 11a8mod11a^{8} \bmod 11a9mod11a^{9} \bmod 11a10mod11a^{10} \bmod 11a11mod11a^{11} \bmod 11
0000000000000000000000
1111111111111111111111
224488551010997733661122
3399554411339955441133
4455993311445599331144
5533449911553344991155
663377991010558844221166
775522331010446699881177
889966441010332255771188
9944335511994433551199
1010111010111010111010111010111010

借助图形化界面

直接看表格,感觉互动性有点差,我用 豆包trae 写了如下的代码

import pygame

# ===================== 1. 初始化配置 =====================
pygame.init()

# 可选的质数 p 列表
PRIMES = [2, 3, 5, 7, 11, 13, 17, 19]
# 初始选中的 p(默认 None,未选择)
selected_p = None

# 基础窗口设置(自适应大小,最小600x600)
MIN_WIDTH, MIN_HEIGHT = 650, 650
screen = pygame.display.set_mode((MIN_WIDTH, MIN_HEIGHT), pygame.RESIZABLE)
pygame.display.set_caption("a^n mod p visualization tool")

# 颜色配置
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
GRAY = (200, 200, 200)
BLUE = (60, 130, 220)    # 按钮颜色
LIGHT_BLUE = (140, 190, 240)
RED = (220, 60, 60)     # 标题颜色
GREEN = (50, 180, 50)   # 结果文字颜色
DARK_BLUE = (30, 80, 180) # a/n标签颜色

def get_fonts(cell_size):
    """根据单元格大小动态调整字体"""
    base_size = max(10, int(cell_size * 0.55))
    font_grid = pygame.font.SysFont("Arial", base_size)
    font_an = pygame.font.SysFont("Arial", base_size)
    return font_grid, font_an

font_btn = pygame.font.SysFont("Arial", 24)
font_label = pygame.font.SysFont("Arial", 26)

def calc_pow(a, n, p):
    """calc a**n mod p"""
    if n == 0:
        return 1
    temp = calc_pow(a, n // 2, p)
    if n % 2 == 0:
        return (temp * temp) % p
    return (temp * temp * a) % p

# ===================== 2. 按钮绘制与点击判断 =====================
def draw_buttons():
    """绘制顶部的p选择按钮"""
    btn_width, btn_height = 60, 40
    start_x = 20
    start_y = 20
    spacing = 10

    for i, p in enumerate(PRIMES):
        btn_x = start_x + i * (btn_width + spacing)
        btn_rect = pygame.Rect(btn_x, start_y, btn_width, btn_height)
        
        # 选中的按钮变色
        color = LIGHT_BLUE if p == selected_p else BLUE
        pygame.draw.rect(screen, color, btn_rect, border_radius=5)
        
        # 按钮文字
        text = font_btn.render(f"p={p}", True, WHITE)
        text_rect = text.get_rect(center=btn_rect.center)
        screen.blit(text, text_rect)

def get_clicked_p(mouse_pos):
    """判断点击了哪个p按钮,返回对应的p值"""
    btn_width, btn_height = 60, 40
    start_x = 20
    start_y = 20
    spacing = 10

    for i, p in enumerate(PRIMES):
        btn_x = start_x + i * (btn_width + spacing)
        btn_rect = pygame.Rect(btn_x, start_y, btn_width, btn_height)
        if btn_rect.collidepoint(mouse_pos):
            return p
    return None

def resize_window(p):
    """根据选择的p自动调整窗口大小"""
    cell_size = min(70, (MIN_WIDTH - 120) // p)
    _, font_an = get_fonts(cell_size)
    max_num_str = str(p)
    max_label_width = font_an.size(max_num_str)[0]
    label_space = max_label_width + 10
    
    needed_width = cell_size * p + label_space + 60
    needed_height = 120 + cell_size * p + label_space + 60
    
    needed_width = max(needed_width, MIN_WIDTH)
    needed_height = max(needed_height, MIN_HEIGHT)
    
    return pygame.display.set_mode((needed_width, needed_height), pygame.RESIZABLE)

# ===================== 3. 网格绘制(显式标注a和n的值) =====================
def draw_grid(p):
    """绘制a-n网格,显式展示a、n和a^n mod p的值"""
    if p is None:
        tip = font_label.render("Please select p value", True, RED)
        screen.blit(tip, (screen.get_width()//2 - tip.get_width()//2, 100))
        return

    cell_size = min(70, (screen.get_width() - 120) // p)
    font_grid, font_an = get_fonts(cell_size)
    
    max_num_str = str(p)
    max_label_width = font_an.size(max_num_str)[0]
    label_space = max_label_width + 10

    total_grid_width = cell_size * p + label_space
    grid_start_x = (screen.get_width() - total_grid_width) // 2 + label_space
    grid_start_y = 120 + label_space
    grid_start_x = (screen.get_width() - total_grid_width) // 2 + label_space
    grid_start_y = 120 + label_space

    # -------- 1. 绘制坐标轴标题 --------
    n_title = font_label.render("n →", True, DARK_BLUE)
    n_title_x = grid_start_x + (cell_size * p) // 2
    n_title_y = 85
    screen.blit(n_title, (n_title_x - n_title.get_width()//2, n_title_y))

    a_title = font_label.render("a ↓", True, DARK_BLUE)
    a_title_x = (screen.get_width() - total_grid_width) // 2 - 25
    a_title_y = grid_start_y + (cell_size * p) // 2
    screen.blit(a_title, (a_title_x, a_title_y - a_title.get_height()//2))

    # -------- 2. 绘制顶部:显式展示所有 n 的值(横坐标) --------
    for i in range(p):
        n = i + 1
        x = grid_start_x + i * cell_size + cell_size//2
        y = grid_start_y - label_space//2
        n_text = font_an.render(str(n), True, DARK_BLUE)
        n_rect = n_text.get_rect(center=(x, y))
        screen.blit(n_text, n_rect)

    # -------- 3. 绘制左侧:显式展示所有 a 的值(纵坐标) --------
    for a in range(p):
        x = grid_start_x - label_space//2
        y = grid_start_y + a * cell_size + cell_size//2
        a_text = font_an.render(str(a), True, DARK_BLUE)
        a_rect = a_text.get_rect(center=(x, y))
        screen.blit(a_text, a_rect)

    # -------- 4. 绘制网格主体 + 计算结果 --------
    for i in range(p):
        n = i + 1
        for a in range(p):
            x = grid_start_x + i * cell_size
            y = grid_start_y + a * cell_size
            cell_rect = pygame.Rect(x, y, cell_size, cell_size)

            pygame.draw.rect(screen, BLACK, cell_rect, 1)

            val = calc_pow(a, n, p)
            val_text = font_grid.render(str(val), True, GREEN)
            val_rect = val_text.get_rect(center=cell_rect.center)
            screen.blit(val_text, val_rect)

# ===================== 4. 主循环 =====================
running = True
while running:
    screen.fill(WHITE)

    # 绘制按钮
    draw_buttons()
    # 绘制网格(含显式a/n标注)
    draw_grid(selected_p)

    # 事件处理
    for event in pygame.event.get():
        # 关闭窗口
        if event.type == pygame.QUIT:
            running = False
        
        # 鼠标点击事件
        if event.type == pygame.MOUSEBUTTONDOWN:
            mouse_pos = pygame.mouse.get_pos()
            # 获取点击的p值
            clicked_p = get_clicked_p(mouse_pos)
            if clicked_p is not None:
                selected_p = clicked_p
                screen = resize_window(selected_p)

    # 刷新界面
    pygame.display.flip()

pygame.quit()

请将以上代码保存为 show_mod_pow.py。使用如下命令可以运行 show_mod_pow.py

python3 show_mod_pow.py

开始时的效果如下 ⬇️

image.png

我们可以选择不同的 pp 值,来查看对应的计算结果。以 p=7p=7 为例,效果如下 ⬇️

image.png

借助图形化界面来探索规律,感觉方便些。

找规律

通过观察以上 55markdown 表格以及使用图形化界面,我们可以发现一些规律 ⬇️ (也许您还能发现其他规律)

  • 对满足 0<a<p0\lt a\lt p 的整数 aa 而言,a2k(pa)2k(modp)a^{2k}\equiv (p-a)^{2k} \pmod {p} 似乎总是成立 (kk 为任意正整数)
  • 对满足 0<a<p0\lt a\lt p 的整数 aa 而言,ap11(modp)a^{p-1}\equiv 1 \pmod {p} 似乎总是成立

我们试试证明它们。

规律一的证明

我们尝试证明以下规律

对满足 0<a<p0\lt a\lt p 的整数 aa 而言,a2k(pa)2k(modp)a^{2k}\equiv (p-a)^{2k} \pmod {p} 总是成立 (kk 为任意正整数,pp 为任意质数)

k=1k=1 的情况最简单,我们先看看这种情况 ⬇️

a2(pa)2=a2(p22pa+a2)=p2+2paa^2-(p-a)^2=a^2-(p^2-2pa+a^2)=-p^2+2pa

因此

p(a2(pa)2)p\mid (a^2-(p-a)^2)

所以

a2(pa)2(modp)a^2\equiv (p-a)^2 \pmod {p}

如果我们能将以下 kk 个等式左边右边各自相乘,就可以得到要证的等式 👉 a2k(pa)2k(modp)a^{2k}\equiv (p-a)^{2k} \pmod {p}

  • a2(pa)2(modp)a^2\equiv (p-a)^2 \pmod {p}
  • a2(pa)2(modp)a^2\equiv (p-a)^2 \pmod {p}
  • \cdots
  • a2(pa)2(modp)a^2\equiv (p-a)^2 \pmod {p}

但是能否这样做乘法呢?答案是肯定的。

如果 xx(modn)x\equiv x' \pmod nyy(modn)y\equiv y' \pmod n 成立,那么以下两个命题成立

  • n(xx)n \mid (x-x')
  • n(yy)n \mid (y-y')

也就是说,存在整数 a,ba,b 使得以下两个等式成立

  • x=x+anx'=x+an
  • y=y+bny'=y+bn

所以

xyxy=xy(x+an)(y+bn)=aynbxnabn2xy-x'y'=xy-(x+an)(y+bn)=-ayn-bxn-abn^2

那么

n(xyxy)n\mid (xy-x'y')

由此可见,在模 nn 运算中,我们确实可以在 \equiv 运算两边做乘法。

于是 a2k(pa)2k(modp)a^{2k}\equiv (p-a)^{2k} \pmod {p} 对任意正整数 kk 都成立。

规律二的证明

说明:本小节的证明来自 灵茶山艾府 所写的 分享丨模运算的世界:当加减乘除遇上取模(模运算恒等式/费马小定理/组合数) 一文(有改动)。由于其中所讲的都是经典的数学知识,我想其中的证明应该不存在版权问题,但引用其中的内容时,应该注明出处。于是我写了这么一段说明

我们的目标是,证明或者证伪以下命题

0<a<p0\lt a\lt p 的整数 aa 而言,ap11(modp)a^{p-1}\equiv 1 \pmod {p}

首先,对任意整数 x,yx,y(x+y)p(x+y)^p 可以用二项式定理进行展开。

(x+y)p=i=0pCpixiypi(x+y)^p=\sum_{i=0}^{p}C_p^i x^i y^{p-i}

Cpi=p!i!(pi)!C_p^i=\frac{p!}{i!(p-i)!}

  • i=0i=0 时,Cp0=1C_p^0=1
  • 0<i<p0\lt i\lt p 时,pp!p\mid p! 成立,由算术基本定理可得,pCpip\mid C_p^i
  • i=pi=p 时,Cpp=1C_p^p=1

所以

(x+y)p=i=0pCpixiypi(x+y)^p=\sum_{i=0}^{p}C_p^i x^i y^{p-i}
=Cp0yp++Cppxp=C_p^0y^p + \cdots + C_p^px^p

其中 \cdots 所表示的各项均可被 pp 整除。

于是我们可以得出

(x+y)pxp+yp(modp)(x+y)^p\equiv x^p + y^p \pmod p

y=1y=1 就会得到 ⬇️

(x+1)pxp+1(modp)(x+1)^p\equiv x^p + 1 \pmod p

x=0x=0 时,0p0(modp)0^p\equiv 0 \pmod p 显然成立。

假设 x=kx=k 时,kpk(modp)k^p\equiv k \pmod p 成立(kk 是某个自然数)。也就是说,p(kpk)p\mid (k^p-k) 成立。

那么 x=k+1x=k+1 时,

(k+1)p(k+1)=kp+1+pN(k+1)(k+1)^p-(k+1)=k^p+1+pN-(k+1)

其中 NN 表示某个整数。继续化简,可以得到 ⬇️

(k+1)p(k+1)=kp+pNk=kpk+pN(k+1)^p-(k+1)=k^p+pN-k=k^p-k+pN

注意到以下两个命题成立

  • p(kpk)p\mid (k^p-k)
  • ppNp\mid pN

那么 p(kpk+pN)p\mid (k^p-k+pN) 成立,也就是说,(k+1)pkp+1(modp)(k+1)^p\equiv k^p + 1\pmod p 成立。

运用数学归纳法,可以得到 xpx(modp)x^p\equiv x \pmod p 对任意自然数 xx 都成立。

  • pxp\mid x 时,xp0(modp)x^p\equiv 0 \pmod p 显然成立
  • pxp\nmid x 时,由于 p(xpx)p\mid (x^p-x),那么 px(xp11)p\mid x(x^{p-1}-1)。利用 算术基本定理 一文中提到的 引理一,可以得出 p(xp11)p\mid (x^{p-1} - 1),即 xp11(modp)x^{p-1}\equiv 1 \pmod p。⬅️ 这就是费马小定理

费马小定理的简单应用

我们回到 背景 中所提到的 33 个问题

问题 1

自然数 nn 的平方 n2n^2 是否总是满足以下两个等式中的某一个

  • n20(mod3)n^2\equiv 0 \pmod{3}
  • n21(mod3)n^2\equiv 1 \pmod{3}

3n3\mid n 时,n20(mod3)n^2\equiv 0 \pmod{3} 显然成立。当 3n3\nmid n 时,由费马小定理可得,n21(mod3)n^2\equiv 1 \pmod{3}。问题 11 解决了。

问题 2

自然数 nn44 次方 n4n^4 是否总是满足以下两个等式中的某一个

  • n40(mod5)n^4\equiv 0 \pmod{5}
  • n41(mod5)n^4\equiv 1 \pmod{5}

5n5\mid n 时,n40(mod5)n^4\equiv 0 \pmod{5} 显然成立。当 4n4\nmid n 时,由费马小定理可得,n41(mod5)n^4\equiv 1 \pmod{5}。问题 22 解决了。

问题 3

自然数 nn55 次方是否总是满足 n5n(mod10)n^5\equiv n \pmod{10}

5n5\mid n 时,n5n=n(n41)n^5-n=n(n^4-1),所以 5(n5n)5\mid (n^5-n),那么 n5n(mod5)n^5\equiv n \pmod{5} 成立。

5n5\nmid n 时,由费马小定理可得 n41(mod5)n^4\equiv 1 \pmod{5},所以 n5n(mod5)n^5\equiv n \pmod{5} 也成立。

也就是说对任意自然数 nnn5n(mod5)n^5\equiv n \pmod{5} 都成立。

考虑到 n5n^5nn 的奇偶性相同,那么 2(n5n)2\mid (n^5-n)

由算术基本定理可知,当 n>1n\gt1 时,n5nn^5-n 的质因数分解结果是唯一的,那么这个分解结果中至少包含一个 22,至少包含一个 55。所以 10(n5n)10\mid (n^5-n)。而 n=0,1n=0,1 时,10(n5n)10\mid (n^5-n) 显然成立。所以对任意自然数 nn 而言,10(n5n)10\mid(n^5-n)。换句话说 n5n(mod10)n^5\equiv n \pmod{10} 对任意整数 nn 成立。

问题 33 解决了。

参考资料