[Python] 扩展欧几里得算法

81 阅读1分钟

背景

[Python] 体验用欧几里得算法计算最大公约数的过程 一文中,我们用 Python\text{Python} 实现了可以高效计算最大公约数的欧几里得算法(Euclidean Algorithm\text{Euclidean Algorithm})。在本文中,我们会探索扩展欧几里得算法(Extended Euclidean Algorithm\text{Extended Euclidean Algorithm})。本文提供了用图形化界面展示扩展欧几里得算法计算结果的完整 Python\text{Python} 代码。

正文

观察线性组合的值

说明:本小节参考了 A Friendly Introduction to Number Theory 中的第 66 章(在 Chapter 1~6 里可以看到第 11 章到第 66 章的内容)

借助图形化界面来进行观察

对正整数 a,ba,b,我们可以构造出它们的线性组合 ax+bya x + b y 。我用 豆包 写了如下的 Python\text{Python} 程序,借助它我们可以看到小范围的 ax+bya x + b y 的值(整数 aabb 满足 1a,b501\le a,b\le 50xxyy 的范围满足 5x,y5-5\le x,y \le 5)。

import pygame

# ===================== 基础配置 =====================
pygame.init()

# 【关键1】大幅扩大窗口,给-5~5网格留足宽松空间
WINDOW_WIDTH = 800
WINDOW_HEIGHT = 800
screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
pygame.display.set_caption("ax + by")

# 颜色定义
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
LIGHT_GRAY = (220, 220, 220)
LIGHT_GREEN = (144, 238, 144)
GRAY = (200, 200, 200)
BLUE = (50, 100, 200)

# 数学坐标配置:x、y 范围 -5 到 5
x_vals = range(-5, 6)
y_vals = range(-5, 6)
a = 6
b = 8
MIN_VAL = 1
MAX_VAL = 50

# 【关键2】缩小格子大小,彻底解决拥挤
CELL_SIZE = 55
CENTER_X = WINDOW_WIDTH // 2
CENTER_Y = WINDOW_HEIGHT // 2 + 30  # 微调中心位置,布局更均衡

# 字体设置(保持清晰,不偏大)
font_num = pygame.font.SysFont(None, 30)    # 格点数值
font_scale = pygame.font.SysFont(None, 26)  # 刻度文字
font_ctrl = pygame.font.SysFont(None, 32)  # 控件文字
font_btn = pygame.font.SysFont(None, 40)   # 按钮文字

# ===================== 按钮控件定义(顶部宽松布局)=====================
a_label_x, a_val_x, a_sub_x, a_add_x = 180, 230, 270, 310
b_label_x, b_val_x, b_sub_x, b_add_x = 420, 470, 510, 550
ctrl_y = 40
btn_size = 35

# ===================== 坐标转换函数 =====================
def math_to_pixel(x, y):
    px = CENTER_X + x * CELL_SIZE
    py = CENTER_Y - y * CELL_SIZE
    return px, py

# ===================== 绘制控件函数 =====================
def draw_controls():
    # 绘制a选择区
    screen.blit(font_ctrl.render("a =", True, BLACK), (a_label_x, ctrl_y))
    screen.blit(font_ctrl.render(f"{a}", True, BLUE), (a_val_x, ctrl_y))
    pygame.draw.rect(screen, GRAY, (a_sub_x, ctrl_y, btn_size, btn_size))
    screen.blit(font_btn.render("-", True, BLACK), (a_sub_x+12, ctrl_y+2))
    pygame.draw.rect(screen, GRAY, (a_add_x, ctrl_y, btn_size, btn_size))
    screen.blit(font_btn.render("+", True, BLACK), (a_add_x+10, ctrl_y+2))

    # 绘制b选择区
    screen.blit(font_ctrl.render("b =", True, BLACK), (b_label_x, ctrl_y))
    screen.blit(font_ctrl.render(f"{b}", True, BLUE), (b_val_x, ctrl_y))
    pygame.draw.rect(screen, GRAY, (b_sub_x, ctrl_y, btn_size, btn_size))
    screen.blit(font_btn.render("-", True, BLACK), (b_sub_x+12, ctrl_y+2))
    pygame.draw.rect(screen, GRAY, (b_add_x, ctrl_y, btn_size, btn_size))
    screen.blit(font_btn.render("+", True, BLACK), (b_add_x+10, ctrl_y+2))

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

    # 1. 绘制浅网格线(底部留足空间给x轴刻度,不覆盖)
    for x in x_vals:
        px, _ = math_to_pixel(x, 0)
        # 竖线底部缩短,不遮挡x轴刻度
        pygame.draw.line(screen, LIGHT_GRAY, (px, 80), (px, WINDOW_HEIGHT-50), 1)
    for y in y_vals:
        _, py = math_to_pixel(0, y)
        # 横线左侧缩短,不遮挡y轴刻度
        pygame.draw.line(screen, LIGHT_GRAY, (50, py), (WINDOW_WIDTH-50, py), 1)

    # 2. 绘制格点数值(宽松间距,无拥挤)
    for y in y_vals:
        for x in x_vals:
            val = a * x + b * y
            px, py = math_to_pixel(x, y)
            text = font_num.render(f"{val}", True, BLACK)
            text_rect = text.get_rect(center=(px, py))
            screen.blit(text, text_rect)

    # 3. 【关键3】修复x轴刻度:位置调至可见区域,彻底显示
    for x in x_vals:
        px, py = math_to_pixel(x, -6)  # 精准定位,不超出窗口
        text = font_scale.render(f"x={x}", True, BLACK)
        text_rect = text.get_rect(center=(px, py))
        bg_rect = pygame.Rect(text_rect.left - 4, text_rect.top - 2, 
                             text_rect.width + 8, text_rect.height + 4)
        pygame.draw.rect(screen, LIGHT_GREEN, bg_rect)
        screen.blit(text, text_rect)

    # 4. 【关键4】优化y轴刻度:位置宽松,不拥挤
    for y in y_vals:
        px, py = math_to_pixel(-6, y)  # 精准定位,完全可见
        text = font_scale.render(f"y={y}", True, BLACK)
        text_rect = text.get_rect(right=px, centery=py)
        bg_rect = pygame.Rect(text_rect.left - 4, text_rect.top - 2, 
                             text_rect.width + 8, text_rect.height + 4)
        pygame.draw.rect(screen, LIGHT_GREEN, bg_rect)
        screen.blit(text, text_rect)

    # 5. 绘制标题
    title = font_ctrl.render("a * x + b * y", True, BLACK)
    screen.blit(title, (WINDOW_WIDTH//2 - title.get_width()//2, 80))

    # 6. 绘制a、b选择控件
    draw_controls()

    # 7. 刷新屏幕
    pygame.display.flip()

    # ===================== 事件处理 =====================
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        
        # 鼠标点击控制a/b数值
        if event.type == pygame.MOUSEBUTTONDOWN:
            mx, my = event.pos
            if a_sub_x <= mx <= a_sub_x+btn_size and ctrl_y <= my <= ctrl_y+btn_size:
                a = max(MIN_VAL, a - 1)
            elif a_add_x <= mx <= a_add_x+btn_size and ctrl_y <= my <= ctrl_y+btn_size:
                a = min(MAX_VAL, a + 1)
            elif b_sub_x <= mx <= b_sub_x+btn_size and ctrl_y <= my <= ctrl_y+btn_size:
                b = max(MIN_VAL, b - 1)
            elif b_add_x <= mx <= b_add_x+btn_size and ctrl_y <= my <= ctrl_y+btn_size:
                b = min(MAX_VAL, b + 1)

pygame.quit()

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

python3 show.py

aabb 的初始值分别是 6688 ⬇️ 我们可以通过点击 - 按钮和 + 按钮来调整它们的值。

image.png

可能注意到的结果

调整 aabb 的值若干次之后,您也许会注意到,格点处的值似乎总是 gcd(a,b)gcd(a, b) 的倍数。我们可以证明它。位于 (x,y)(x,y) 处的值是 ax+bya x + b y 由于 gcd(a,b)gcd(a,b)aabb 的最大公约数(所以也是它们的公约数),那么

  • gcd(a,b)agcd(a,b)\mid a
  • gcd(a,b)bgcd(a,b)\mid b

所以 gcd(a,b)(ax+by)gcd(a, b)\mid (a x + b y),也就是说任意格点处的值都是 gcd(a,b)gcd(a, b) 的倍数。

另一个观察是,似乎总能找到某个格点,那个格点的值 vv 满足 v=gcd(a,b)v=gcd(a, b)。以 a=6,b=8a=6,b=8 为例,可以找到满足 6x+8y=26x+8y=2 的格点 ⬇️

image.png

这一点可以通过扩展欧几里得算法来证明。

我们先看看简单的情况。如果 b=0b=0,那么 gcd(a,b)=agcd(a,b)=a (注意:b=0b=0 时,a0a\ne 0)。此时容易验证 1×a+0×b=a=gcd(a,b)1 \times a + 0 \times b=a=gcd(a,b),也就是说,格点 (1,0)(1,0) 处的值,一定是 gcd(a,b)gcd(a,b)。之后我们再看看一般的情形。

a,ba,b,我们想找到整数 x,yx,y 使得 ax+by=gcd(a,b)a x + b y = gcd(a,b) 成立。假设我们已经为 b,amodbb,a \bmod b 找到了对应的整数 x,yx',y' 使得 bx+(amodb)y=gcd(a,b)b x' + (a \bmod b) y'= gcd(a,b)。我们从这个等式出发,做些处理。

bx+(amodb)y=gcd(a,b)b x' + (a \bmod b) y'= gcd(a,b)

其实就是

bx+(aa/bb)y=gcd(a,b)b x' + (a - \lfloor a / b \rfloor b) y'= gcd(a,b)

整理一下,可以变为

ay+b(xa/by)=gcd(a,b)a y' + b (x'-\lfloor a/b\rfloor y')= gcd(a,b)

所以可以这样选择 xxyy ⬇️

  • x=yx=y'
  • y=xa/byy=x'-\lfloor a/b \rfloor y'

这个过程展示了扩展欧几里得算法的核心思想。

Python\text{Python} 代码来实现扩展欧几里得算法

我们可以用 Python\text{Python} 代码来实现上述逻辑。我写了以下 Python\text{Python} 代码来实现欧几里得算法,并进行了对应的测试 ⬇️

def extended_euclidean(a, b):
    if (a, b) == (0, 0):
        raise ValueError("a and b cannot be both 0")
    if b == 0:
        return (1, 0, a)
    x, y, g = extended_euclidean(b, a % b)
    return (y, x - a // b * y, g)

for a in range(100):
    for b in range(100):
        if (a, b) == (0, 0):
            continue
        (x, y, g) = extended_euclidean(a, b)
        if a * x + b * y != g:
            raise ValueError("extend_euclidean failed")
        else:
            print(f"{a} * {x} + {b} * {y} = {g}")

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

python3 extended_euclidean_algorithm.py 

运行结果有 99999999 行,这里就不展示完整结果了 😂

扩展欧几里得算法计算器

基于上一小节提供的代码,我们可以更进一步,用 Pygame\text{Pygame} 生成一个图形化界面,来展示扩展欧几里得算法的结果。我用 豆包 生成了和 Pygame\text{Pygame} 有关的代码,然后做了些小调整,最终得到的结果如下

import pygame
import sys

def extended_euclidean(a, b):
    if (a, b) == (0, 0):
        raise ValueError("a and b cannot be both 0")
    if b == 0:
        return (1, 0, a)
    x, y, g = extended_euclidean(b, a % b)
    return (y, x - a // b * y, g)

# Pygame 初始化
pygame.init()
WIDTH, HEIGHT = 600, 400
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Extended Euclidean Algorithm Calculator")
clock = pygame.time.Clock()

# 颜色定义
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
GRAY = (200, 200, 200)
BLUE = (0, 100, 255)
RED = (220, 30, 30)

# 字体
font = pygame.font.SysFont("arial", 24)
small_font = pygame.font.SysFont("arial", 20)

# 输入框配置
input_a = ""
input_b = ""
active_box = 0  # 0:无,1:a输入框,2:b输入框
result_text = ""
error_text = ""

# 输入框矩形
box_a = pygame.Rect(120, 80, 180, 36)
box_b = pygame.Rect(120, 140, 180, 36)
calc_btn = pygame.Rect(120, 200, 180, 40)

def reset_result():
    global result_text, error_text
    result_text = ""
    error_text = ""

while True:
    screen.fill(WHITE)

    # 事件处理
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()

        # 鼠标点击切换输入框/触发计算
        if event.type == pygame.MOUSEBUTTONDOWN:
            mx, my = event.pos
            reset_result()
            if box_a.collidepoint(mx, my):
                active_box = 1
            elif box_b.collidepoint(mx, my):
                active_box = 2
            elif calc_btn.collidepoint(mx, my):
                # 执行计算
                try:
                    a = int(input_a)
                    b = int(input_b)
                    if (a, b) == (0, 0):
                        raise ValueError("a and b cannot be both 0")
                    x, y, g = extended_euclidean(a, b)
                    # 构造输出文本
                    eq = f"{a} * ({x}) + {b} * ({y}) = {g}"
                    result_text = f"For a={a}, b={b}, below condition is true:\n{eq}"
                except ValueError:
                    if (a, b) == (0, 0):
                        error_text = "a and b cannot be both 0"
                    else:
                        error_text = "Input must be integers!"
            else:
                active_box = 0

        # 键盘输入
        if event.type == pygame.KEYDOWN and active_box != 0:
            key = event.key
            # 退格删除
            if key == pygame.K_BACKSPACE:
                if active_box == 1 and len(input_a) > 0:
                    input_a = input_a[:-1]
                elif active_box == 2 and len(input_b) > 0:
                    input_b = input_b[:-1]
            # 数字
            elif key in (pygame.K_0, pygame.K_1, pygame.K_2, pygame.K_3, pygame.K_4,
                         pygame.K_5, pygame.K_6, pygame.K_7, pygame.K_8, pygame.K_9):
                char = event.unicode
                if active_box == 1:
                    input_a += char
                else:
                    input_b += char

    # 绘制文字标签
    label_a = font.render("a =", True, BLACK)
    label_b = font.render("b =", True, BLACK)
    screen.blit(label_a, (60, 82))
    screen.blit(label_b, (60, 142))

    # 绘制输入框
    pygame.draw.rect(screen, GRAY, box_a)
    pygame.draw.rect(screen, BLUE if active_box == 1 else BLACK, box_a, 2)
    pygame.draw.rect(screen, GRAY, box_b)
    pygame.draw.rect(screen, BLUE if active_box == 2 else BLACK, box_b, 2)

    # 输入框文字
    text_a = font.render(input_a, True, BLACK)
    text_b = font.render(input_b, True, BLACK)
    screen.blit(text_a, (box_a.x + 8, box_a.y + 4))
    screen.blit(text_b, (box_b.x + 8, box_b.y + 4))

    # 绘制计算按钮
    pygame.draw.rect(screen, BLUE, calc_btn)
    btn_text = font.render("Calculate", True, WHITE)
    screen.blit(btn_text, (calc_btn.x + 12, calc_btn.y + 6))

    # 绘制错误提示
    if error_text:
        err_surf = small_font.render(error_text, True, RED)
        screen.blit(err_surf, (120, 250))

    # 绘制计算结果(分行显示)
    y_offset = 280
    if result_text:
        lines = result_text.split("\n")
        for line in lines:
            line_surf = small_font.render(line, True, BLACK)
            screen.blit(line_surf, (60, y_offset))
            y_offset += 30

    pygame.display.flip()
    clock.tick(60)

请将以上代码保存为 ee_calculator.py (意为扩展欧几里得算法计算器)。用以下命令可以运行 ee_calculator.py

python3 ee_calculator.py

运行效果

运行效果如下图所示 ⬇️

image.png

我们输入 aabb 之后,再点击 Calculate 按钮,Python\text{Python} 代码就会用扩展欧几里得算法计算出满足 ax+by=gcd(a,b)a x + b y = gcd(a, b) 的一组 (x,y)(x,y) 值。示例效果如下 ⬇️

image.png image.png image.png

参考资料