Classic Iterated Function Systems

Larry Riddle, Agnes Scott College

Python Code

Here is a Python script to verify that for every level-n small triangle in the Sierpinski gasket "chaos game" challenge, there exists a sequence of exactly n+2 moves that places an initial point starting at one of the vertices into its interior.

Download Python code

"""
Sierpinski Gasket - Level-n Construction with Chaos Game Points
===================================================
Draws the level-n Sierpinski gasket (n = 2..5) and plots all chaos game
points from (2+n)-tuples of moves {1,2,3} that land inside the interior of a small triangle
after starting from a vertex V_k.

Barycentric coordinates:
  V_1 = (1, 0, 0)  [top]
  V_2 = (0, 1, 0)  [lower left]
  V_3 = (0, 0, 1)  [lower right]

Chaos game rule: each move goes halfway toward the chosen vertex.
After (2+n) moves starting from V_k, the denominator of the
barycentric coordinates is 2^(n+2) = D.

  P = V_k/D + sum_{i=1}^{n+2}  V_{M[i]} / 2^(n+3-i)

A point (with coords expressed as integers over D) is in the interior of a 
small triangle iff none of its barycentric coordinates is a multiple
of 1/2^n, i.e iff no numerator is divisible by D/2^n = 4.
"""

# ── imports ─────────────────────────────────────────
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from fractions import Fraction
from itertools import product as iproduct

# ── user input ────────────────────────────────────────
while True:
    try:
        n = int(input("Enter level n (2 to 5): "))
        if 2 <= n <= 5:
            break
        print("  Please enter an integer between 2 and 5.")
    except ValueError:
        print("  Invalid input – please enter an integer.")
while True:
    try:
        initV = int(input("Enter initial vertex (1,2,or 3): "))
        if 1 <= initV <= 3:
            break
        print("  Please enter an integer between 1 and 3.")
    except ValueError:
        print("  Invalid input – please enter an integer.")

# ── geometry helpers ────────────────────────────────────

def bary_to_cart(lam, V):
    """Barycentric (3,) → Cartesian (2,) using triangle vertices V (3×2)."""
    return lam[0]*V[0] + lam[1]*V[1] + lam[2]*V[2]

# Equilateral triangle vertices (Cartesian)
V_cart = np.array([
    [0.5,  np.sqrt(3)/2],   # V1 – top
    [0.0,  0.0          ],   # V2 – lower left
    [1.0,  0.0          ],   # V3 – lower right
])

# ── build all triangles by recursive subdivision ────────────────────

# Barycentric vertices as exact Fractions
V1b = (Fraction(1), Fraction(0), Fraction(0))
V2b = (Fraction(0), Fraction(1), Fraction(0))
V3b = (Fraction(0), Fraction(0), Fraction(1))

def midpoint(A, B):
    return tuple((a + b) / 2 for a, b in zip(A, B))

def subdivide(tri):
    """Split triangle into 3 corner sub-triangles + 1 removed centre."""
    A, B, C = tri
    AB, BC, CA = midpoint(A, B), midpoint(B, C), midpoint(C, A)
    return [(A, AB, CA), (AB, B, BC), (CA, BC, C)], (AB, BC, CA)

def build_gasket(n):
    """Return (solid_list, removed_list) of triangles (barycentric) at level n."""
    solid   = [(V1b, V2b, V3b)]
    removed = []
    for _ in range(n):
        next_solid = []
        for tri in solid:
            corners, centre = subdivide(tri)
            next_solid.extend(corners)
            removed.append(centre)
        solid = next_solid
    return solid, removed   # 3^n solid,  (3^n - 1)/2 removed

solid_triangles, removed_triangles = build_gasket(n)

# ── draw the gasket ─────────────────────────────────────

fig, ax = plt.subplots(figsize=(7,6.5))
ax.set_aspect('equal')
ax.axis('off')
ax.set_xlim(-0.06, 1.06)
ax.set_ylim(-0.10, 0.99)

LIGHT_GRAY  = '#c8c8c8'
WHITE       = '#ffffff'
EDGE_COLOR  = '#444444'
POINT_COLOR = '#c0392b'

def draw_tri(tri_bary, fc, ec=EDGE_COLOR, lw=0.6, zorder=1):
    pts = np.array([
        bary_to_cart(np.array([float(v) for v in vert]), V_cart)
        for vert in tri_bary
    ])
    ax.add_patch(mpatches.Polygon(
        pts, closed=True, facecolor=fc, edgecolor=ec, linewidth=lw, zorder=zorder
    ))

# Outer boundary
draw_tri((V1b, V2b, V3b), fc=LIGHT_GRAY, lw=1.2, zorder=0)

# White removed regions (paint over the gray background)
for tri in removed_triangles:
    draw_tri(tri, fc=WHITE, ec=WHITE, lw=0, zorder=2)

# Solid small triangles with edges
for tri in solid_triangles:
    draw_tri(tri, fc=LIGHT_GRAY, ec=EDGE_COLOR, lw=max(0.3, 0.7 - 0.1*n), zorder=3)

# ── chaos game: all 3^(n+2) tuples of (n+2) moves ────────────────

num_moves = n + 2
D = 2 ** (n + 2)   # denominator

V_bary = {
    1: (Fraction(1), Fraction(0), Fraction(0)),
    2: (Fraction(0), Fraction(1), Fraction(0)),
    3: (Fraction(0), Fraction(0), Fraction(1)),
}

plotted_points = []   # Cartesian coords for plotting
bary_records  = []   # (moves, (a,b,c) integer numerators over D)
count = 0

for moves in iproduct([1, 2, 3], repeat=num_moves):
    # Starting contribution: initV / D
    coords = [Fraction(0), Fraction(0), Fraction(0)]
    coords[initV-1] = Fraction(1,D)

    # Move i (1-indexed): contribute V_{M[i]} / 2^(n+3-i)
    for i, m in enumerate(moves, start=1):
        factor = Fraction(1, 2 ** (n + 3 - i))
        vb = V_bary[m]
        for k in range(3):
            coords[k] += vb[k] * factor

    # Express as integer numerators over D
    int_nums = [int(c * D) for c in coords]
    assert sum(int_nums) == D, f"Coords don't sum to 1: {int_nums}"

    # Plot iff NO numerator is divisible by 4
    if all(x % 4 != 0 for x in int_nums):
        cart = bary_to_cart(np.array([float(c) for c in coords]), V_cart)
        plotted_points.append(cart)
        bary_records.append((moves, tuple(int_nums)))
        count += 1

# ── plot the points ────────────────────────────────────────

if plotted_points:
    pts = np.array(plotted_points)
    pt_size = 60 - 10 * n
    ax.scatter(pts[:, 0], pts[:, 1],
               s=pt_size, color=POINT_COLOR, zorder=5,
               linewidths=0, alpha=0.85)

# Vertex labels
for label, vc, ha in [('$V_1$', V_cart[0], 'center'),
                       ('$V_2$', V_cart[1], 'right'),
                       ('$V_3$', V_cart[2], 'left')]:
    dx = 0 if ha == 'center' else (-0.01 if ha == 'right' else 0.01)
    dy = 0.035 if ha == 'center' else -0.01
    ax.text(vc[0]+dx, vc[1]+dy, label, ha=ha, va='center', fontsize=14)

total_tuples = 3 ** num_moves
minMoves = 2+n
ax.set_title(
     f'Level-{n} Sierpinski Gasket with {minMoves} moves starting at $V_{initV}$\n'
     f'{count} interior points out of {total_tuples} possible points',
     fontsize=11, pad=10
 )

plt.tight_layout()
filename = f'sierpinski_level{n}-{initV}.png'
plt.savefig(filename, dpi=150, bbox_inches='tight')
plt.show()

print(f"\nLevel n            : {n}")
print(f"Number of moves    : {num_moves}  (= n + 2)")
print(f"Total tuples (3^{num_moves}) : {total_tuples:,}")
print(f"Solid triangles    : {len(solid_triangles):,}  (= 3^n)")
print(f"Points plotted     : {count:,}")
print(f"Saved to           : {filename}")

Credit: This Python code was developed with the assistance of Anthropic Claude.