Gauß auf Java
Ein mathematischer Reisebericht
$\newcommand{\hateq}{\mathrel{\widehat{=}}}$ $\newcommand{\I}{\:{\rm i}}$ $\newcommand{\eqndot}{\:{\rm .}}$ $\newcommand{\eqncomma}{\:{\rm ,}}$

Das Java Programm

In diesem Kapitel soll das Gaußsche Kreisteilungsverfahren als Java-Programm implementiert werden. Der vollständige Quellcode und die jar-Files können hier heruntergeladen werden. In diesem Kapitel werden nur Auszüge des Programms erläutert und zwar hauptsächlich die, die mit dem Gaußschen Verfahren zu tun haben.

Eigentlich handelt es sich um zwei Programme Hermes und Kompressor. Das Programm Kompressor wird hier aber nicht weiter behandelt, denn es komprimiert, wie der Name schon sagt, den Output des ersten Programms. Insbesondere werden alle Wurzelausdrücke gelöscht, die in den endgültigen Ausdruck nicht eingesetzt werden. Beim 257-Eck kann man auf diese Weise 89 von 129 Ausdrücken weglassen, beim 65537-Eck sogar 8009 von 10114 (die gar nicht erst berechneten Ausdrücke nicht mitgezählt). Ferner teilt das Programm die beim 65537-Eck doch sehr länglichen Wurzeln auf mehrere Zeilen auf.

Da man mit Hermes gleich LaTeX-Notation erzeugen kann, um sie unverändert in das Kap.8 bzw. in das voluminöse PDF zu übernehmen, wurde Kompressor nur für das LaTeX-Format angepasst.

Beide Programme sind Java-Konsol-Anwendungen, es gibt also keine aufwändige grafische Oberfläche. Beide Programme wurden bewusst spartanisch gehalten, damit das Wesentliche, nämlich der Algorithmus, nicht in einem Wust von Trivialitäten untergeht. So verzichten beide Programme sogar auf die Ausgabe in eine Datei und geben die Listen auf System.out aus (Kompressor kommt natürlich um eine Datei-Eingabe  nicht herum). Es sollte daher tunlichst der Ausgabestrom auf eine Datei umgelenkt werden, wenn man die Berechnung für das 65537-Eck durchführen möchte.

Installation

Zur Installation kopiert man die Dateien Hermes.jar und Kompressor.jar einfach in ein beliebiges Verzeichnis. Zum Ablauf ist das Java® SE Runtime Environment (JRE) Version 7 (oder höher) erforderlich.

Benutzeroberfläche

Der Aufruf erfolgt unter der Eingabeaufforderung  bzw. von einer Linux-Konsole aus mit

java -jar Hermes.jar [num] [opt]
bei Auswertung für das 65537-Eck empfiehlt sich der Aufruf mit
java -jar -Xms512m -Xmx768m -XX:MaxPermSize=256m Hermes.jar [num] [opt]

sonst könnte der Platz in der Heap knapp werden. Dabei können für [num] die Werte 5, 17, 257, 65537 angegeben werden. Auf eine Berechnung für das Dreieck wurde verzichtet, weil dann an einigen Stellen unschöne Ausnahmebehandlungen hätten eingebaut werden müssen.

Das Argument [opt] dient zur Steuerung des Ausgabe-Layouts:

-tex     : Ausgabe der Wurzelausdrücke im LaTeX-Format
-ascii   : Ausgabe der Wurzelausdrücke im ascii-Format
-num     : Ausgabe der Wurzelausdrücke mit eingesetzten numerischen Werten
-val     : Ausgabe des numerischen Werts der Periode
-ref     : Ausgabe des numerischen Werts der Periode plus Referenzwert und Abweichung
-roots   : Ausgabe der Exponenten der Einheitswurzeln in der Periode
-sorted  : Sortierte Ausgabe der Exponenten der Einheitswurzeln in der Periode

Die einzelnen Optionen können kombiniert werden, dann werden die unterschiedlichen Ausgabeformate gemischt. Die Ausgabe des Programms erfolgt, wie oben erwähnt, auf System.out.

Der Aufruf des Nachbereitungsprogramms Kompressor erfordert zwei Argumente:

java -jar Kompressor.jar [num] [file]

Für [num] können nur die Werte 17, 257, 65537 angegeben werden – beim Fünfeck gibt es nichts zu komprimieren. Die Angabe wird gebraucht, um den Startpunkt für das Einsetzen der Wurzelausdrücke zu finden. Beim 17-Eck ist dies die Periode $p_{3,0},$ beim 257-Eck die Periode $p_{7,0}$ und beim 65537-Eck die Periode $p_{15,0}.$ In einem ersten Durchlauf wird nämlich z.B. beim 65537-Eck zunächst ermittelt, welche Perioden kleineren Levels zur Darstellung der Periode $p_{15,0}$ benötigt werden, anschließend werden rekursiv rückwärts die für die Darstellung der dabei gefundenen Perioden benötigten Perioden kleineren Levels ermittelt, bis man schließlich bei der Periode $0$-ten Levels angekommen ist. Alle gefundenen Perioden werden als notwendig gekennzeichnet. In einem zweiten Durchlauf werden nur die so gekennzeichneten Perioden – diesmal in der richtigen Reihenfolge – ausgegeben und alle nicht benötigten Perioden weggelassen.

Das Argument [file] übergibt an den Kompressor den Namen der Eingabedatei, die ihrerseits eine Ausgabe von Hermes enthalten muss, die mit der Option -tex erzeugt wurde. Wie bei Hermes erfolgt die Ausgabe auf System.out.

Hermes im Schnelldurchlauf

In Prosa lässt sich die Arbeitsweise des Programms wie folgt zusammenfassen: Zunächst wird die nullte Gaußsche Periode durch fortlaufendes Potenzieren des primitiven Elements (im Programm grundsätzlich $3$) erzeugt (siehe fill). Die Perioden der Stufe $1$ werden durch Aufspalten (siehe split) der nullten Periode erzeugt und allgemein die Perioden der Stufe $n+1$ durch Aufspalten der Perioden der $n$-ten Stufe.

Die beiden Tochterperioden einer aufgespalteten Periode werden miteinander multipliziert – um die Addition muss man sich nicht kümmern, denn dabei entsteht, wie wir wissen, nur die schon bekannte Elternperiode. Bei der Multiplikation (siehe mult) wird einfach, analog wie wir es für das Siebzehneck gemacht haben, eine Multiplikationstabelle aufgebaut. Dabei sind lediglich die Exponenten der primitiven Elemente zu addieren und gleich $\pmod p$ zu reduzieren. Es wird hier gleich etwas Buchführung betrieben, indem man für jeden der dabei entstehenden Exponenten einen Zähler versorgt, der die Vielfachheit des jeweiligen Exponenten angibt. Das ist ganz ähnlich wie beim Siebzehneck, wo wir ja ebenfalls in der Multiplikationstabelle gezählt haben, wie oft jeder Exponent vorkommt.

Mit diesen Werten kommt man zum Kern des ganzen Algorithmus. Es wird im Elternlevel nach den Perioden gesucht , aus denen sich das Multiplikationsergebnis als Linearkombination zusammensetzen lässt. Findet man einen Exponenten (aus der Multiplikationstabelle) in einer Periode des Elternlevels, wird die gesamte gefundene Periode zu der Linearkombination hinzugenommen und alle  Exponenten aus dieser Periode werden in der Multiplikationstafel in ihrer Vielfachheit um eins vermindert. Dass dies funktionieren muss , ohne dass negative Vielfachheiten auftreten oder Exponenten in der Multiplikationstafel übrig bleiben, haben wir im theoretischen Teil mit viel Mühe bewiesen. Natürlich wird das im Programm zusätzlich noch überprüft.

Nach Ende dieser Prozedur hat man die Koeffizienten für die Linearkombination gefunden, und es wird klar, warum wir soviel Wert darauf gelegt haben, dass diese Koeffizienten natürliche Zahlen sind. Andernfalls wäre nämlich der Ansatz, die Koeffizienten durch Zählen zu bestimmen, von vornherein zum Scheitern verurteilt.

Summe und Produkt der aktuell betrachteten Perioden sind nun als Linearkombination aus Perioden des Vorgängerlevels dargestellt und können in dieser Form in die Formel für die Lösung der quadratischen Gleichung eingesetzt werden, siehe compute. Anschließend kann man zum nächsten Tochterlevel übergehen, indem man die aktuellen Perioden wieder aufspaltet. Dieses Verfahren (Aufspalten - Multiplizieren - Koeffizienten suchen - Wurzelausdruck berechnen) wird fortgesetzt, bis die Periodenlänge zwei  erreicht wird.

Das Programm erzeugt eine fortlaufende Ausgabe von Wurzelausdrücken, wobei in jedem Ausruck nur Summen und Produkte von vorher ausgegebenen Wurzelausdrücken vorkommen. Da der Wert der nullten Gaußschen Periode immer $-1$ ist, hat man somit für jede Periode eine Darstellung aus Summen und Produkten rationaler Zahlen und ineinander geschachtelter Quadratwurzeln. Das ist aber nichts anderes als die Konstruktionsbeschreibung  für das gerade betrachtete regelmäßige Vieleck.

Soweit der Überblick, nun zu den Einzelheiten:

Grundstruktur des Programms Hermes

Das Programm Hermes ist auf oberstem Level wie folgt gegliedert:

package hermes;

class Period {
   ...
}

class Hermes {

  public static void main(String[] args) {
     ...
  }

}

Die main-Methode der Klasse Hermes wertet die Argumente aus und baut eine verkettete Liste von Instanzen der Klasse Period auf, die – natürlich – die Gaußschen Perioden repräsentieren. Die Verkettung erfolgt dabei einmal horizontal innerhalb aller Perioden der gleichen Stufe und einmal vertikal, so dass man von jeder Periode zu der Periode zurückgehen kann, aus der sie durch Aufspaltung entstanden ist.

Die Klasse Period ist das Kernstück des ganzen Algorithmus, daher wollen wir damit beginnen, sie zu analysieren. Fangen wir mit den Datenstrukturen an:

class Period {

    int len;          // length of this period
    int modulus;      // it's either 5, 17, 257 or 65537
    int level;        // level of the period
    int twoPowLevel;  // 2^level
    int seqnr;        // sequent. number within level
    int nr;           // number of period within level
                      // all exponents are 3^k with
                      // k = nr (mod 2^level)
    int primroot = 3;         // primitive root 3

    int[] exponents = null;   // exponents of the roots of unity
    int[] exponents_sorted = null;  // the same sorted

    Period leftSplit = null;  // used for splitting
    Period rightSplit = null; // the same
    Period father = null;     // vertical link
    Period neighbour = null;  // horizontal link
    Period mostleft = null;   // to 1st period in level
    Period[] msummands = null; // representation as lin. comb. of periods
    int[] mfactors = null;     // with these factors

    char sign;              // sign of the squareroot
    double theP;            // used for solving the
    double theQ;            // quadratic equation
    double numericValue;    // value of period by sqareroots
    double referenceValue;  // value of period by cosinus
    String name;            // name of period p_{m,n}

Hier treffen wir viele Größen wieder, die bei der Behandlung der Gaußschen Perioden eine Rolle gespielt haben. Die Länge len der Periode, die bei der Aufteilung jeweils halbiert wird und in der Stufe (dem level) $0$ den Wert $p-1$ bzw. $2^N$ hat. Es folgen einige Größen, die bei der Nummerierung der Perioden, wie wir sie im vorigen Kapitel erläutert haben, eine Rolle spielen.

Die wichtigste Größe ist aber das Array exponents, denn dies ist  ja praktisch die Gaußsche Periode. Wir hinterlegen dort natürlich nur die Exponenten  der Einheitswurzeln und zwar in der Reihenfolge, wie sie durch fortlaufende Potenzierung der primroot bzw. durch den Aufspaltungsprozess entstanden sind. Das Array exponents_sorted wird zum einen für die sortierte Ausgabe der Exponenten benötigt, zum anderen bei der Suche nach einem bestimmten Exponenten in dieser Periode.

Die folgenden Pointer realisieren die vertikale und horizontale Verkettung unter den Perioden. Man kann also ein level von links (mostleft) via neighbour nach rechts durchsuchen. Das ist z. B. erforderlich, wenn für das Produkt zweier Perioden die Darstellung als Linearkombination ermittelt werden soll.

Diese Linearkombination aus Perioden der Vorgängerstufe wird in einem Array namens msummands abgespeichert, wobei die Vielfachheit der beteiligten Perioden, m.a.W. der Koeffizient, mit dem jede Periode in der Summe zu multiplizieren ist, parallel in mfactors mitgeführt wird.

Die folgenden double-Werte werden für die Berechnung des numerischen Werts der Periode benötigt. In numericValue wird dieser Wert durch Lösen der quadratischen Gleichung ermittelt, in referenceValue geschieht dies durch direktes Aufsummieren der entsprechenden Cosinus-Werte.

Schließlich bekommt jede Periode noch einen name, der nach den Konventionen gebildet wird, wie sie im vorigen Kapitel erläutert wurden. Der Name wird gleich in LaTeX-Notation hinterlegt.

Konstruktor und Initialisierungen

Der Konstruktor für die Klasse Period ist klein gehalten, weil fast alle Instanzen von Period durch Aufspaltung zustande kommen und ihre Daten vom father erben. Deswegen ist initialize vom Konstruktor abgespalten.

public Period(String name, int len, int modulus, int primroot) {
  this.len = len;
  this.modulus = modulus;
  this.name = name;
  this.primroot = primroot;
  exponents = new int[len];
}

public void initialize(Period leftest, double numV, int level, int nr) {
  this.level = level;
  this.seqnr = nr;
  this.twoPowLevel = 1;
  for (int i=0;i < level;i++)
      this.twoPowLevel*=2;
  this.mostleft = leftest;
  this.numericValue = -1.0d;
}

Exponenten berechnen

Interessanter ist die folgende Methode, die die Gaußsche Periode erzeugt:

public void fill(int primitive) {
  exponents_sorted = null;
  exponents[0] = 1;
  for (int i = 1; i < len; i++) {
    exponents[i] = (exponents[i-1]*primitive) % modulus;
  }
  setReferenceValue();
}

Hier wird das Array exponents durch fortlaufendes Potenzieren des primitiven Elements primitive gefüllt. Dabei wird bei jedem Schritt sofort die Restklasse nach dem modulus gebildet. Zum Abschluss wird der Referenzwert durch Aufruf von setReferenceValue berechnet. Diese Methode ist wie folgt implementiert:

private void setReferenceValue() {
  double re = 0.0d;
  for (int i = 0; i < this.len; i++) {
    int exp = this.exponents[i];
    re += Math.cos(2 * exp * Math.PI / modulus);
  }
  this.referenceValue = re;
}

Es wird der Realteil der jeweiligen Einheitswurzel berechnet und die Summe gebildet. Es wird m.a.W. der Ausdruck \[ \sum_{p_{n,m}} \Re(\zeta^{3^k}) = \sum_{p_{n,m}} \cos(\frac{2\pi 3^k}{p}) \] berechnet, wobei exp $=3^k$ durch alle Exponenten der jeweiligen Gaußschen Periode läuft. Der Referenzwert dient vor allem dazu, für den Quadratwurzelausdruck das richtige Vorzeichen zu finden.

Aufspalten der Periode

Die folgende Methode gehört zu den Kernmethoden des Algorithmus. Sie realisiert das Aufspalten einer Gaußschen Periode in zwei neue Perioden von jeweils halber Länge.

public Period split(int which, int level, int seqnr) {
  if (leftSplit == null) {
    int l = this.len / 2;
    int index = this.nr;
    leftSplit = new Period("p_{" + level + "," + index + "}",l, modulus,primroot);
    leftSplit.father = this;
    leftSplit.level = level;
    leftSplit.seqnr = seqnr;
    leftSplit.nr = index;
    leftSplit.twoPowLevel = this.twoPowLevel * 2;
    seqnr++;
    index += leftSplit.twoPowLevel/2;
    rightSplit = new Period("p_{" + level + "," + index + "}",l, modulus,primroot);
    rightSplit.father = this;
    rightSplit.level = level;
    rightSplit.seqnr = seqnr;
    rightSplit.nr = index;
    rightSplit.twoPowLevel = leftSplit.twoPowLevel;
    int j = 0;
    for (int i = 0; i < l; i++) {
      leftSplit.exponents[i] = this.exponents[j];
      j++;
      rightSplit.exponents[i] = this.exponents[j];
      j++;
    }
    leftSplit.setReferenceValue();
    rightSplit.setReferenceValue();
  }
  if (which == 0) {
    return leftSplit;
  }
  return rightSplit;
}

Die Methode liefert je nach dem Wert des Parameters which die linke , d.h. die die den ersten Exponenten der alten Periode enthält, oder die rechte  neue Periode zurück. Es werden aber schon beide neuen Perioden gebildet, die rechte sozusagen auf Vorrat . Etwas kompliziert ist die Namensbildung der rechten Periode, denn nach der Konvention des vorigen Kapitels wird ja die Periode $p_{n-1,m}$ in $p_{n,m}$ und $p_{n,m+2^{n-1}}$ aufgespalten. Die entsprechende Berechnung findet in der Zeile index += leftSplit.twoPowLevel/2 statt.

Die Methode trägt ihre eigene Instanz this als father für die beiden neuen Perioden ein. Außerdem wird noch eine laufende Nummer seqnr mitgeführt. Diese dient hauptsächlich dazu, bei den großen Moduli, 257 oder 65537 die Berechnung einer Periode abzubrechen, wenn klar ist, dass die Werte später nicht mehr gebraucht werden. Das eigentliche Aufteilen des father auf seine Kinder in der for-Schleife erfolgt ganz kanonisch.

Multiplizieren

Es gibt noch eine Methode add, die zwei Perioden addiert und die nur im Rahmen eines Plausibilitätschecks verwendet wird. Diese Methode ist nicht besonders interessant und soll hier nicht weiter erläutert werden. Komplizierter und wichtiger ist die Multiplikation zweier Perioden.

public boolean mult(Period other) {
  int[] temp = new int[modulus];
  for (int i = 0; i < this.len; i++) {
    for (int j = 0; j < other.len; j++) {
      int result = (this.exponents[i] + other.exponents[j]) % modulus;
      temp[result]++;
    }
  }
  int le = modulus / (this.len + this.len);
  msummands = new Period[le];
  mfactors = new int[le];
  Period start = this.father.mostleft;
  int k = 0;
  while (start != null) {
    msummands[k] = start;
    k++;
    start = start.neighbour;
  }
  for (int i = 0; i < modulus; i++) {
    while (temp[i] > 0) {
      int j;
      // search exponent i in msummands
      for (j = 0; j < msummands.length; j++) {
        if (msummands[j] != null && msummands[j].contains(i)) {
          break;
        }
      }
      if (j < msummands.length) {
        for (int l = 0; l < msummands[j].len; l++) {
          temp[msummands[j].exponents[l]]--;
        }
        mfactors[j]++;
      }
      else
      {
        System.out.println("% Root "+i+" = "+temp[i]+"not resolved");
        return false;
      }
    }
  }
  // plausibilty check
  for (int i = 0; i < modulus; i++) {
    if (temp[i] != 0) {
      return false;
    }
  }
  simplify();
  other.msummands = this.msummands;
  other.mfactors = this.mfactors;
  compute(other);
  return true;
}

Zunächst wird ein Hilfs-Array temp angelegt, das das Ergebnis der Multiplikation vorübergehend aufnimmt. Die Multiplikation selbst erfolgt in der Schleife für jedes Paar von Summanden der beiden Perioden this und other, wobei die Exponenten addiert werden. Die Restklassenbildung mit dem modulus erfolgt sofort nach der Addition. Im Array temp wird mitgezählt, wie oft jeder Exponent als Ergebnis vorkommt.

Nun kommt der wesentliche Schritt, denn jetzt muss das Ergebnis der Multiplikation als Linearkombination von Perioden des Vorgänger-Levels dargestellt werden. Dazu dienen die Arrays msummands und mfactors, die in der Länge modulus/2*len angelegt werden. In der while-Schleife werden die potenziellen Summanden in das Array msummands eingetragen. Dazu wird start auf die erste, linkeste  Periode des Vorgänger-Levels gesetzt, nämlich this.father.mostleft und über den Link zum neighbour das ganze Level abgearbeitet.

Die nächsten beiden geschachtelten Schleifen, die äußere for-Schleife und die innere while-Schleife realisieren die Suche nach den Exponenten, die durch die Multiplikation entstanden sind, in den Perioden des Vorgänger-Levels. Die for-Schleife arbeitet sukzessive jeden Exponenten aus temp ab, die while-Schleife berücksichtigt die Vielfachheit, mit der der Exponent auftritt.

Die mit dem Kommentar search exponent i in msummands  gekennzeichnete Schleife tut genau das, sie sucht den Exponenten, der ja Ergebnis der Multiplikation ist, in den Perioden des Vorgänger-Levels, die wir alle in msummands hinterlegt haben. Dazu wird die Methode contains benutzt, die wir später kurz vorstellen werden. Wenn die Suche erfolgreich ist, und sie muss  aufgrund unseres Beweises erfolgreich sein, wird die innere Schleife mit break verlassen. Die Sicherheitsabfrage msummands[j] != null ist übrigens erforderlich, weil wir manche Level nicht vollständig berechnen, dazu später mehr.

Wenn die Schleife verlassen wird, muss j < msummands.length sein. Der else-Zweig der entsprechenden Abfrage wird nur dann durchlaufen, wenn wir beim Abbruch der Rechnung innerhalb eines Levels einen Fehler gemacht haben, d.h. wenn Perioden nicht berechnet wurden, obwohl sie später noch gebraucht werden. Nach dem Debuggen des Programms sollte das nicht mehr vorkommen. Im if-Zweig vertrauen wir nun wieder auf unsere Theorie: Wir gehen davon aus, dass das Multiplikationsergebnis, wenn es den Exponenten i aus der Periode msummands[j] enthält, alle  Exponenten aus msummands[j] enthält. Wir zählen für alle diese Exponenten in dem Array temp die Vielfachheit um eins herunter und notieren in mfactors, dass wir die gesamte Periode mit der Vielfachheit eins als Bestandteil der Linearkombination erkannt haben.

Die abschließende, als plausibilty check  gekennzeichnete Schleife überprüft, ob tatsächlich alle Exponenten aus temp einer Periode zugeordnet werden konnten. Mit Aufruf der Methode simplify wird versucht, in der gerade gefundenen Linearkombination Summanden zusammenzufassen. Dann wird die vereinfachte Linearkombination auch der zweiten an der Multiplikation beteiligten Periode other zugewiesen. Die Darstellung als Linearkombination gehört ja zum Produkt  von this und other, ebenso wie als Summe von this und other in beiden Perioden der gleiche father eingetragen ist. Schließlich wird noch compute aufgerufen, um die quadratische Gleichung wirklich numerisch zu lösen – dazu gleich mehr.

Die Methode mult stellt somit den im vorigen Kapitel erwähnten experimentellen Beweis für die Tatsache dar, dass das Ergebnis der Multiplikation der beiden Perioden $p_{n,m} \cdot p_{n,m+2^{n-1}}$ eine Linearkombination aus Perioden des Vorgänger-Levels ist.

Es soll nicht verschwiegen werden, dass man mit etwas mehr Aufwand, sowohl bei der zugrunde liegenden Theorie als auch bei der Implementierung, für die Linearkombinationen einfachere Ausdrücke erhalten kann, bei denen z.T. negative Koeffizienten vorkommen. Näheres dazu findet sich in [Bracke 2011]. Unser Anliegen war jedoch, sowohl die benutzte Theorie wie die Implementierung so einfach und durchsichtig wie möglich zu halten.

Vereinfachen und Vorzeichen setzen

In dem Programm wird mit der Methode simplify versucht, den Ausdruck, der die Linearkombination darstellt, zu vereinfachen, allerdings nur auf sehr primitivem Niveau:

private void simplify() {
  for (int i = 0; i < msummands.length - 1; i++) {
    if (mfactors[i] != 0 && msummands[i].seqnr % 2 == 0 && msummands[i + 1].seqnr == msummands[i].seqnr + 1) {
      if (mfactors[i] == mfactors[i + 1]) {
        if (msummands[i].father != null) {
          mfactors[i + 1] = 0;
          msummands[i + 1] = null;
          msummands[i] = msummands[i].father;
        }
      }
    }
  }
}

Es wird lediglich überprüft, ob zwei unmittelbar aufeinanderfolgende Perioden vom gleichen father abstammen und mit der gleichen Vielfachheit vorkommen, dann wird die Summe dieser Perioden durch ihren father, der ja die Summe darstellt, ersetzt. Die Abfrage erfolgt hier durch Vergleich der seqnr, man könnte zwar gleich die Links zum father vergleichen, müsste dann aber die Sicherheitsabfrage father != null zusätzlich für den Fall vornehmen, dass wegen ungleicher Vielfachheit eine Vereinfachung gar nicht infrage kommt.

Es kann kein Zweifel bestehen, dass die beschriebene Methode bestenfalls einen ersten Ansatz zur Vereinfachung der Ausdrücke darstellt. Wenn jemand Lust hat, hier einen ausgefeilteren Algorithmus zu implementieren, nur zu! Im Erfolgsfall, wäre ich für eine kurze Mitteilung sehr dankbar. Immerhin schafft es aber auch das simple simplify beim 65537-Eck ca. 3400 Paare von Perioden zusammenzufassen.

Die Methode compute rechnet den Wert der beiden Perioden, die wir gerade multipliziert haben, als Wurzelausdruck aus. Wir erinnern uns, dass sich der Wert der Perioden als Lösung einer quadratischen Gleichung ergibt: \[ x_{0;1} = \frac{(x_0+x_1) \pm \sqrt{(x_0+x_1)^2-4x_0 x_1}}{2}\eqndot \] wobei $x_0+x_1$ der Wert der Summe der Perioden, also der Wert des father ist und wir $x_0 x_1$ gerade mit einiger Mühe als Linearkombination aus Perioden dargestellt haben, deren Werte ebenfalls bekannt sind. In der Methode compute wird der Wert $x_0+x_1$ mit $p$ bezeichnet und der Wert des Produktes $x_0 x_1$ mit $q,$ wie in der Lösungsformel für quadratische Gleichungen üblich.

public void compute(Period other){
  double p = father.numericValue;
  double q = 0.0d;
  for (int i = 0; i < msummands.length; i++) {
    if (mfactors[i] != 0) {
        q = q + mfactors[i] * msummands[i].numericValue;
    }
  }
  double D = p * p - 4 * q;
  double root1 = (p + Math.sqrt(D)) / 2;
  double root2 = (p - Math.sqrt(D)) / 2;
  this.theP = p;
  this.theQ = q;
  if (Math.abs(root1 - this.referenceValue) < Math.abs(root2 - this.referenceValue)) {
    this.numericValue = root1;
    other.numericValue = root2;
    this.sign = '+';
    other.sign = '-';
  } else {
    this.numericValue = root2;
    other.numericValue = root1;
    this.sign = '-';
    other.sign = '+';
  }
}

Der Wert von p ergibt sich unmittelbar aus dem father.numericValue. Der Wert von q wird durch Summieren der Werte der in der Linearkombination enthaltenen Perioden, jeweils in der richtigen Vielfachheit, gebildet. Für die beiden Wurzeln root1 und root2 muss nur noch die Lösungsformel für die quadratische Gleichung angewendet werden.

Anschließend wird der schon erwähnte Trick zur Bestimmung des richtigen Vorzeichens angewendet, indem der soeben aus dem Wurzelausdruck errechnete Wert mit dem über Cosinus vorher berechneten Referenzwert verglichen wird. Je nach Ergebnis des Vergleichs bekommt entweder die Periode this oder die Periode other das positive bzw. negative Wurzelvorzeichen zugeordnet. Der Trick wurde übrigens bereits von Gauß selbst empfohlen (siehe [Gauß 1801]):

Hat man diese aus den Sinustafeln auf nur wenige Stellen berechnet, namlich soweit dass man entscheiden kann, welche größer, welche kleiner sind, so kann kein Zweifel mehr übrig sein, durch welche Bezeichnungen die einzelnen Wurzeln der Gleichung (A) zu unterscheiden sind.

Die Differenzen zwischen root1 bzw. root2 und dem referenceValue liegen am Anfang der Berechnung in der Größenordnung von $10^{-12},$ beim 65537-Eck schaukeln sich mit der Zeit aber Rundungsfehler auf, so dass bei den höheren Leveln Differenzen von ca. $10^{-4}$ auftreten. Dazu später mehr.

Hilfs- und Druckroutinen

Die kleine Hilfsroutine contains stellt fest, ob ein gesuchter Exponent unter den Exponenten dieser Periode vorhanden ist. Die Exponenten werden dazu zunächst sortiert. Das mag aufwendig erscheinen, aber da contains evtl. sehr oft aufgerufen wird und die Routine sort nur sortiert, wenn exponents_sorted noch null ist, lohnt sich dieser Aufwand doch.

}
public boolean contains(int what) {
  this.sort();
  for (int i : this.exponents_sorted) {
    if (i == what) {
        return true;
    }
    if (i > what) {
        break;
    }
  }
  return false;
}

Eine weitere kleine Hilfsroutine, die ebenfals die sortierten Exponenten voraussetzt, ist equals. Sie stellt fest, ob zwei Perioden gleich sind. Die Routine wird nur für Plausibilitätschecks benutzt.

public boolean equals(Period other) {
  if (this.len != other.len) {
    return false;
  }
  if (this.modulus != other.modulus) {
    return false;
  }
  this.sort();
  other.sort();
  for (int i = 0; i < this.len; i++) {
    if (this.exponents_sorted[i] != other.exponents_sorted[i]) {
      return false;
    }
  }
  return true;
}

Unter den Druckroutinen soll hier nur der Zweig für die Ausgabe von LaTeX-Ausdrücken genauer betrachtet werden:

public void print(String arg) {
  switch (arg) {
  case "-tex":
    String texstring0 = "\\[%s = %.12f\\]%n";
    String texstring = "\\[%s = \\tfrac{%s %s \\sqrt{%s^2 - 4(%s)}}{2}\\]%n";
    if (this.father == null){
      System.out.printf(texstring0,this.name, this.numericValue);
      break;
    }
    System.out.printf(texstring, this.name, father.name, this.sign, father.name, this.toString());
    break;
    ...
  }
}

Der Formatstring texstring formatiert den Quadratwurzelausdruck dieser Periode im LaTeX-Format. Man sieht, dass für den Summanden vor  der sqrt sowie für den zu quadrierenden Summanden unter der sqrt jeweils der Name des father eingesetzt wird, denn der father ist die Summe  der beiden Perioden, von denen eine hier betrachtet wird. Das Produkt  der beiden Perioden wird durch die Linearkombination dargestellt, die in der Routine mult aufgebaut wurde. Die Ausgabe dieser Linearkombination erfolgt durch Aufruf der toString-Methode für diese Periode:

public @Override
String toString() {
  StringBuilder buf = new StringBuilder();
  if (msummands != null) {
    for (int i = 0; i < msummands.length; i++) {
      if (mfactors[i] != 0) {
        if (mfactors[i] > 1) {
          buf.append(Integer.toString(mfactors[i]));
        }
        buf.append(msummands[i].name);
        buf.append("+");
      }
    }
  }
  buf.setLength(buf.length() - 1);
  return buf.toString();
}

Der Aufbau des String erfolgt ebenfalls völlig kanonisch: Aus msummands werden die Namen der an der Linearkombination beteiligten Perioden ermittelt und mit dem Faktor aus mfactors versehen in den Summenausdruck aufgenommen. In der vorletzten Zeile wird das überflüssige letzte Pluszeichen beseitigt. Beim 65537-Eck kann der Ausdruck sehr  lang werden. Glücklicherweise sind wir aber nie an die Grenzen der Kapazität eines StringBuilder gestoßen. Für die Druckaufbereitung wird dieser lange String durch das schon erwähnte separate Programm Kompressor aufgeteilt.

Nicht beschriebene Methoden von Period

Einige Methoden der Klasse Period werden hier nicht näher beschrieben. Dazu zählen die Standardalgorithmen, die in sort und quicksort Anwendung finden, sowie die Routine cleanUp, die ursprünglich dazu gedacht war beim 65537-Eck Heap-Speicher freizugeben, falls es Engpässe geben sollte. Es hat sich aber gezeigt, dass bei Aufruf mit -Xms512m -Xmx768m -XX:MaxPermSize=256m, wie oben beschrieben, keine Probleme auftreten. Die Laufzeit hält sich ebenfalls in Grenzen: Das Programm wurde ursprünglich auf einem recht schwachbrüstigen Rechner (Baujahr 2008; 3 GHz Doppelkern-CPU mit 2GB RAM) entwickelt. Die in Kap.7 bzw. im Original-Output des Programms zu findende CPU-Zeit von ca. 33 Sek. beim 65537-Eck bezieht sich auf diesen Rechner. Auf einem etwas aktuelleren Rechner (Baujahr 2016; 3.3 GHz Core i5-6500 mit 8 GB RAM) benötigt die Berechnung für das 65537-Eck rund 10 Sek.

Ebenfalls nicht beschrieben wird die Routine refactor. Sie ist eine Kopie von compute, benutzt aber zur Berechnung statt der Werte numericValue des father bzw. der Perioden aus msummands, sondern referenceValue. Wie erwähnt, liegen die Rundungsfehler bei den letzten Perioden des 65537-Eck in der Größenordnung von $10^{-4},$ was normalerweise nicht tragisch wäre, doch ausgerechnet bei der allerletzten benötigten Periode $p_{15,0}$ führt dies dazu, dass der Wert unter der Wurzel negativ wird und als Ergebnis daher NaN herauskommt. Daher muss für die allerletzte Periode mittels refactor neu aufgesetzt werden. Näheres dazu wird ausführlich im Kap.8 zum 65537-Eck geschildert.

Das Hauptprogramm

Das Hauptprogramm analysiert zunächst die mitgelieferten Argumente, das muss hier nicht weiter erläutert werden. Dann wird die Gaußsche Periode der Stufe $0$ (hier p_base genannt) initialisiert und durch Aufruf von p_base.fill(3) mit den Exponenten gefüllt. Hier ist das primitive Element $3$ standardmäßig vorgegeben. Nun folgt die Haupt-Schleife, in der die vertikal und horizontal verlinkten Perioden aufgebaut werden:

public static void main(String[] args) {
  long startTimeNano = System.nanoTime();
  System.err.println("% Job started.");
  System.out.print("% Running with arguments: ");
  for (String arg : args) {
    System.out.print(arg + " ");
  }
...
  Hermes cyc = new Hermes();
  int base = fermatPrime - 1;
  Period p_base = new Period("p_{0,0}", base, fermatPrime, 3);
  p_base.initialize(p_base, -1.0d, 0, 0);
  p_base.fill(3);
  int periodLen = base;
  int level = 0;
  Period actV = p_base;
  p_base.print(args);
  while (periodLen > 2) {
    periodLen /= 2;
    Period actH = actV;
    Period baseH = null;
    Period leftest = null;
    level++;
    int nr = 0;
    while (actH != null) {
      Period left = actH.split(0, level, nr);
      if (baseH != null) {
        baseH.neighbour = left;
      } else {
        leftest = left;
      }
      baseH = left;
      left.mostleft = leftest;
      Period right = actH.split(1, level, nr);
      if (!actH.equals(left.add(right))) {
        System.err.println("% Error when splitting!");
      }
      baseH.neighbour = right;
      baseH = right;
      right.mostleft = leftest;
      // the kernel of the whole program
      if (left.mult(right)) {
        left.print(args);
        right.print(args);
      } else {
        System.err.println("Error in multiplication!");
      }
      if (periodLen == 2) {
        finalPrint(left, right, fermatPrime, args);
        break;
      }
      actH = actH.neighbour;
      nr += 2;
      if (level == 14 && nr > 16) {
          break;
      }
      if (level == 13 && nr > 1900) {
          break;
      }
    }
    actV = leftest;
  }
  long taskTimeNano = System.nanoTime() - startTimeNano;
    System.out.println("% Time used: "
                + taskTimeNano / 1000000000.0d + "sec");
    System.err.println("% Job finished.");
}

Die beiden Perioden actV für vertikal  und actH für horizontal  werden benutzt, um durch die im Aufbau befindlichen Listen zu navigieren. Die äußere while-Schleife bewegt sich vertikal, also von Stufe zu Stufe, die innere while-Schleife erzeugt innerhalb der Stufe durch Halbieren der Perioden aus der Vorgänger-Stufe die neuen Perioden und verkettet sie über neighbour horizontal. Dabei ist der direkte Zugriff auf neighbour und mostleft, die sich ja in einer fremden Klasse befinden, sicher nicht lupenreines Java, aber hier setter-Methoden bereitzustellen würde den Code nur aufblähen und vom Wesentlichen ablenken.

Das Wesentliche wird etwas pathetisch durch den Kommentar the kernel of the whole program  gekennzeichnet. Hier werden die soeben durch Aufspalten erzeugten Perioden left und right miteinander multipliziert, wobei wir gesehen haben, was alles hinter dieser Multiplikation steckt.

Anschließend wird gleich die Ausgabe für die beiden Perioden angestoßen. Das liegt einerseits nahe, weil alle benötigten Informationen zu diesem Zeitpunkt vollständig vorhanden sind – es wird halt sukzessiv  gearbeitet, andererseits bestand zunächst die Sorge, dass beim 65537-Eck der Heap-Speicher nicht ausreichen könnte. Die Information sollte rasch aufs Papier , damit man sie so schnell wie möglich dem Garbage-Collector vorwerfen kann.

Damit ist die wesentliche Arbeit geleistet, es kommen nur noch einige Sonderbehandlungen. Für die letzte Stufe mit der Periodenlänge $2$ müssen wir nur die erste Periode berechnen, denn deren Wert ist, wie wir gesehen haben, $2\cos(2\pi/p).$ Also wird bei periodLen == 2 nur noch die finale Ausgabe angestoßen und die innere Schleife verlassen. Weil die Periodenlänge $2$ erreicht ist, führt das zum Verlassen der äußeren Schleife und damit zum Ende des Programms.

Die beiden if-Abfragen auf level == 14 bzw. level == 13 sollen die Datenflut beim 65537-Eck etwas eindämmen. Diese Begrenzungen wurden erst nach dem ersten Probelauf des Programms eingebaut, nachdem man feststellen konnte, dass die Perioden, die hier weggelassen werden, für die Berechnung von $p_{15,0}$ nicht gebraucht werden. Man spart damit immerhin in Level 13 die Ausgabe von mehr als 6000 Zeilen, in Level 14 sogar mehr als 16000 Zeilen. Wir haben schon erwähnt, dass im Programm Überprüfungen stattfinden, die fälschlicherweise entfernte Perioden entdecken. Man beachte, dass als Kriterium für den Abbruch die laufenden Nummernr herangezogen wird. Die letzten berechneten Wurzeln  im jeweiligen Level sind nach dem verwendeten Nummerierungs-Schema $p_{13,3164}$ und $p_{14,3072}.$

Bleibt noch die Routine finalPrint nachzutragen:

public static void finalPrint(Period left, Period right,
                          int fermatPrime, String[] args){
  System.out.println("% 1/2 * " + left.name + " = " + left.numericValue / 2);
  System.out.println("% cos(2*pi/" + fermatPrime + "): " + Math.cos(2 * Math.PI / fermatPrime));
  if (Double.isNaN(left.numericValue)) {
    System.out.println("% Taking reference values!");
    left.refactor(right);
    left.print(args);
    System.out.println("% 1/2 * " + left.name + " = " + left.numericValue / 2);
  }
}

Es wird der über Wurzelausdrücke berechnete Wert der Periode ausgegeben und zum Vergleich der über Cosinus berechnete Wert. Anschließend erfolgt die Sonderbehandlung, falls die Rundungsfehler dazu geführt haben, dass der Wert der letzten Periode NaN ist. In diesem Fall wird die Methode refactor aufgerufen und der Wert noch einmal ausgegeben. Es sei nochmals bemerkt, dass diese Sonderbehandlung nur bei der allerletzten Periode des 65537-Ecks stattfindet.