MINT lernen

Übung — AFB III (Verallgemeinern und Reflektieren)

Zehn Aufgaben zum Beweisen, Widerlegen und Entwerfen: Terminierung, Gegenbeispiele, eigene Operationen und ein kritischer Blick auf fremden Code.

Dein Fortschritt:
0 / 0 Aufgaben
3

Aufgabenblock — AFB III

Begründen statt nur ausführen: Terminierung beweisen, Gegenbeispiele finden, eigene Operationen entwerfen und fremden Code beurteilen. Formuliere deine Antwort erst selbst — in ganzen Sätzen oder als Pseudocode —, bevor du die Musterlösung aufklappst.

A1
Terminierung beweisen — Rest durch Abziehen
AFB III

Beweisen Sie, dass der folgende Algorithmus für alle natürlichen Zahlen a ≥ 0 und b ≥ 1 nach endlich vielen Schritten anhält. Beurteilen Sie außerdem, was bei der Eingabe b = 0 geschieht, und geben Sie an, was der Algorithmus berechnet.

Pseudocode
Eingabe: a, b
solange a ≥ b wiederhole
  a ← a − b
ende solange
Ausgabe: a

Hinweis: Suche eine Größe, die in jedem Schleifendurchlauf echt kleiner wird, aber nicht beliebig klein werden kann.

Strategie: Beobachte den Wert von a vor jedem Durchlauf und frage: Um wie viel sinkt er mindestens, und unter welche Grenze kann er nie fallen?Warum so? Ein Terminierungsbeweis läuft fast immer über eine solche „Abstiegsgröße“: Eine natürliche Zahl kann nicht unendlich oft um mindestens 1 kleiner werden.
Lösungsskizze: b ≥ 1 ⇒ a sinkt pro Durchlauf um mindestens 1 · Durchlauf nur, wenn a ≥ b, also a − b ≥ 0 ⇒ a bleibt eine natürliche Zahl · also höchstens a Durchläufe · bei b = 0: Bedingung a ≥ 0 immer wahr, a ändert sich nie ⇒ Endlosschleife.
Musterlösung anzeigen (zählt als erledigt)

Musterlösung: Betrachtet wird der Wert von a. Die Schleife läuft nur, solange a ≥ b gilt; im Rumpf wird a ← a − b ausgeführt. Wegen b ≥ 1 ist a danach um mindestens 1 kleiner als vorher, und wegen a ≥ b ist der neue Wert a − b ≥ 0. Der Wert von a ist also vor jedem Durchlauf eine natürliche Zahl, die bei jedem Durchlauf echt kleiner wird. Eine natürliche Zahl kann nur endlich oft verkleinert werden, ohne negativ zu werden — spätestens nach a Durchläufen ist die Bedingung a ≥ b falsch, und der Algorithmus hält an.

Bei b = 0 lautet die Bedingung a ≥ 0; sie ist für jede natürliche Zahl wahr. Im Rumpf wird a ← a − 0 gerechnet, a ändert sich also nie: Der Algorithmus terminiert nicht. Er ist deshalb nur mit der Vorbedingung b ≥ 1 korrekt; eine robuste Fassung prüft vorher wenn b = 0 dann Ausgabe: „Division durch 0“.

Der Algorithmus zieht b so oft ab, wie es geht — die Anzahl der Durchläufe ist a / b, die Ausgabe ist der Rest a mod b. Beispiel: a = 23, b = 4 liefert nach 5 Durchläufen die Ausgabe 3.

A2
Korrektheit widerlegen — Schaltjahre
AFB III

Nach dem gregorianischen Kalender ist ein Jahr ein Schaltjahr, wenn seine Jahreszahl durch 4 teilbar ist — außer sie ist durch 100 teilbar; durch 400 teilbare Jahre sind aber wieder Schaltjahre. Eine Schülerin implementiert:

Pseudocode
Operation istSchaltjahr(j)
  wenn j mod 4 = 0 dann
    zurück wahr
  sonst
    zurück falsch
  ende wenn

Widerlegen Sie die Korrektheit dieser Operation mit einem Gegenbeispiel, begründen Sie, warum Tests mit den Jahren 2000 und 2024 den Fehler nicht aufdecken, und entwerfen Sie eine korrigierte Fassung.

Hinweis: Welche Jahre behandelt die Kalenderregel anders als die einfache Regel „durch 4 teilbar“?

Strategie: Suche gezielt Eingaben, bei denen die Sonderregeln greifen — also Jahreszahlen, die durch 100 teilbar sind.Warum so? Um einen Algorithmus zu widerlegen, genügt ein einziges Gegenbeispiel. Es findet sich fast immer bei den Randfällen der Aufgabenstellung.
Lösungsskizze: 1900: 1900 mod 4 = 0 ⇒ Operation liefert wahr · aber 1900 mod 100 = 0 und 1900 mod 400 = 300 ⇒ kein Schaltjahr · 2000 ist durch 400 teilbar, also wirklich Schaltjahr; 2024 ist ein gewöhnlicher Fall ⇒ beide Tests stimmen zufällig · Korrektur: alle drei Bedingungen verknüpfen.
Musterlösung anzeigen (zählt als erledigt)

Musterlösung: Gegenbeispiel j = 1900: Es gilt 1900 mod 4 = 0, die Operation gibt also wahr zurück. Da aber auch 1900 mod 100 = 0 und 1900 mod 400 = 300 ≠ 0 gilt, war 1900 nach der Kalenderregel kein Schaltjahr. Damit ist die Operation nicht korrekt (genauso versagt sie für 1800 oder 2100).

Die Tests mit 2000 und 2024 liefern trotzdem das richtige Ergebnis: 2024 ist nicht durch 100 teilbar, dort gilt die einfache Regel; 2000 ist durch 400 teilbar und damit tatsächlich ein Schaltjahr. Zwischen 1901 und 2099 stimmt die fehlerhafte Operation sogar für jedes Jahr — erfolgreiche Tests beweisen eben keine Korrektheit, sie zeigen nur, dass für die getesteten Eingaben kein Fehler auftritt.

Korrigierte Fassung:

Pseudocode
Operation istSchaltjahr(j)
  wenn j mod 400 = 0 dann
    zurück wahr
  ende wenn
  wenn j mod 100 = 0 dann
    zurück falsch
  ende wenn
  zurück j mod 4 = 0
Java
static boolean istSchaltjahr(int j) {
    return (j % 4 == 0 && j % 100 != 0) || j % 400 == 0;
}

Die Bedingungen werden vom speziellsten Fall (400) zum allgemeinsten (4) geprüft; weil zurück die Operation sofort beendet, erreicht ein durch 400 teilbares Jahr die Prüfung auf 100 gar nicht erst.

A3
Algorithmus entwerfen — Quersumme
AFB III

Entwerfen Sie eine Operation quersumme(n), die für eine natürliche Zahl n die Summe ihrer Ziffern zurückgibt, z. B. quersumme(40953) = 4 + 0 + 9 + 5 + 3 = 21. Verwenden Sie dabei keine Zeichenketten, sondern nur / und mod. Begründen Sie anschließend, dass Ihre Schleife für jedes n ≥ 0 terminiert und dass auch n = 0 richtig behandelt wird.

Hinweis: Welche Ziffer einer Zahl erhältst du mit einer einzigen Rechnung — und wie wirst du sie danach los?

Strategie: Zerlege die Zahl von hinten: n mod 10 liefert die letzte Ziffer, n / 10 schneidet sie ab. Sammle die Ziffern in einem Akkumulator.Warum so? Die letzte Ziffer ist die einzige, die man ohne Kenntnis der Stellenzahl direkt „greifen“ kann. Wiederholt man das, kommen alle Ziffern der Reihe nach an die letzte Stelle.
Lösungsskizze: s ← 0 · solange n > 0: s ← s + n mod 10, n ← n / 10 · zurück s · Ablauf für 40953: Ziffern 3, 5, 9, 0, 4 · n wird pro Durchlauf mindestens um den Faktor 10 kleiner.
Musterlösung anzeigen (zählt als erledigt)
Pseudocode
Operation quersumme(n)
  s ← 0
  solange n > 0 wiederhole
    s ← s + n mod 10
    n ← n / 10
  ende solange
  zurück s
Java
static int quersumme(int n) {
    int s = 0;
    while (n > 0) {
        s = s + n % 10;
        n = n / 10;
    }
    return s;
}

Musterlösung: Die Variable s ist ein Akkumulator, der die Ziffern aufsammelt. In jedem Durchlauf wird mit n mod 10 die letzte Ziffer addiert und mit der Ganzzahldivision n / 10 abgeschnitten. Für n = 40953 werden nacheinander die Ziffern 3, 5, 9, 0, 4 addiert; danach ist n = 0 und die Operation gibt 21 zurück.

Terminierung: Solange n > 0 ist, gilt n / 10 < n, der Wert von n wird also in jedem Durchlauf echt kleiner und bleibt dabei eine natürliche Zahl. Nach so vielen Durchläufen, wie n Stellen hat, ist n = 0 und die Bedingung falsch. Randfall n = 0: Die Schleife läuft gar nicht, zurückgegeben wird der Startwert 0 — und das ist die richtige Quersumme von 0.

A4
Algorithmus entwerfen — Palindrom
AFB III

Ein Palindrom ist eine Zeichenkette, die vorwärts und rückwärts gleich lautet, z. B. "reliefpfeiler". Entwerfen Sie eine Operation istPalindrom(s), die dies prüft, ohne eine umgedrehte Kopie von s anzulegen. Beurteilen Sie anschließend, warum Ihre Operation für "Rentner" falsch liefert, und schlagen Sie eine Lösung vor.

Hinweis: Welche Zeichen müssen bei einem Palindrom jeweils übereinstimmen? Denke an die Positionen 0 und länge(s) − 1.

Strategie: Arbeite mit zwei Positionen: i startet links bei 0, j rechts bei länge(s) − 1. Vergleiche s[i] mit s[j] und lass beide aufeinander zulaufen.Warum so? So brauchst du höchstens halb so viele Vergleiche wie Zeichen und kannst beim ersten Unterschied sofort mit zurück falsch abbrechen.
Lösungsskizze: solange i < j: wenn s[i] ≠ s[j] dann zurück falsch; i ← i + 1; j ← j − 1 · nach der Schleife zurück wahr · „Rentner“: 'R' (82) ≠ 'r' (114) · vorher alles in Kleinbuchstaben umwandeln.
Musterlösung anzeigen (zählt als erledigt)
Pseudocode
Operation istPalindrom(s)
  i ← 0
  j ← länge(s) − 1
  solange i < j wiederhole
    wenn s[i] ≠ s[j] dann
      zurück falsch
    ende wenn
    i ← i + 1
    j ← j − 1
  ende solange
  zurück wahr
Java
static boolean istPalindrom(String s) {
    int i = 0;
    int j = s.length() - 1;
    while (i < j) {
        if (s.charAt(i) != s.charAt(j)) {
            return false;
        }
        i++;
        j--;
    }
    return true;
}

Musterlösung: Die Operation vergleicht das erste mit dem letzten Zeichen, das zweite mit dem vorletzten usw. Sobald zwei Zeichen verschieden sind, steht fest, dass s kein Palindrom ist, und zurück falsch beendet die Operation. Treffen sich i und j (oder überholen sich), wurden alle Paare erfolgreich verglichen, und es wird wahr zurückgegeben. Bei ungerader Länge bleibt das mittlere Zeichen unverglichen — es steht ja auf beiden Seiten an derselben Stelle. Randfälle: Für die leere Zeichenkette und für ein einzelnes Zeichen gilt von Anfang an i ≥ j, die Operation liefert wahr, was der Definition entspricht.

Beurteilung „Rentner“: Verglichen werden die Zeichencodes. 'R' hat den ASCII-Wert 82, 'r' den Wert 114 — die Zeichen sind also verschieden, obwohl ein Mensch das Wort als Palindrom erkennt. Abhilfe: Die Zeichenkette vor der Prüfung einheitlich in Kleinbuchstaben umwandeln (Java s.toLowerCase(), Python s.lower()) oder beim Vergleich Großbuchstaben (65–90) durch Addition von 32 in Kleinbuchstaben umrechnen.

A5
Algorithmus entwerfen — Primzahltest
AFB III

Entwerfen Sie eine Operation istPrim(n), die für eine natürliche Zahl n ≥ 1 wahr zurückgibt, wenn n eine Primzahl ist, und sonst falsch — ohne Arrays oder Listen. Begründen Sie, warum es genügt, mögliche Teiler t nur zu prüfen, solange t · t ≤ n gilt, und wie viele Teiler Ihre Operation bei n = 97 tatsächlich testet.

Hinweis: Eine Primzahl hat genau zwei Teiler: 1 und sich selbst. Die 1 selbst ist deshalb keine Primzahl. Teiler treten außerdem immer paarweise auf.

Strategie: Behandle zuerst die Randfälle n < 2. Probiere dann mit n mod t = 0 nacheinander t = 2, 3, 4, … als Teiler aus und brich beim ersten Treffer ab.Warum so? Ein einziger Teiler zwischen 2 und n − 1 reicht als Beweis, dass n keine Primzahl ist — weitersuchen ist überflüssig.
Lösungsskizze: wenn n < 2 → zurück falsch · t ← 2 · solange t · t ≤ n: wenn n mod t = 0 → zurück falsch, t ← t + 1 · zurück wahr · n = t · u mit t ≤ u ⇒ t · t ≤ t · u = n · 97: t = 2 … 9, also 8 Tests.
Musterlösung anzeigen (zählt als erledigt)
Pseudocode
Operation istPrim(n)
  wenn n < 2 dann
    zurück falsch
  ende wenn
  t ← 2
  solange t · t ≤ n wiederhole
    wenn n mod t = 0 dann
      zurück falsch
    ende wenn
    t ← t + 1
  ende solange
  zurück wahr

Musterlösung: Zahlen kleiner als 2 sind keine Primzahlen und werden sofort abgewiesen. Danach werden mögliche Teiler t ab 2 durchprobiert; findet sich einer mit n mod t = 0, ist n zusammengesetzt und die Operation endet mit falsch. Findet sich keiner, ist n eine Primzahl. Für n = 2 und n = 3 ist schon 2 · 2 > n, die Schleife läuft nicht, und es wird richtig wahr zurückgegeben.

Begründung der Grenze: Ist n zusammengesetzt, lässt es sich als n = t · u mit 2 ≤ t ≤ u schreiben. Dann gilt t · t ≤ t · u = n. Der kleinere Faktor eines jeden Teilerpaares erfüllt also t · t ≤ n und wird von der Schleife gefunden (bei 91 = 7 · 13 etwa schon bei t = 7, weil 7 · 7 = 49 ≤ 91). Gibt es bis zu dieser Grenze keinen Teiler, gibt es auch darüber keinen. Die Schleife terminiert, weil t in jedem Durchlauf um 1 wächst, t · t also irgendwann größer als n wird.

Für n = 97 werden t = 2, 3, …, 9 getestet (9 · 9 = 81 ≤ 97, aber 10 · 10 = 100 > 97) — das sind 8 Tests statt 95 bei der Suche bis n − 1.

A6
Lokale oder globale Variable? — Mittelwert
AFB III

Ein Schüler hat eine Operation zur Berechnung des Mittelwerts dreier Noten geschrieben und testet sie im Hauptprogramm:

Java
static int summe = 0;

static double mittelwert(int a, int b, int c) {
    summe = summe + a + b + c;
    return summe / 3.0;
}

// im Hauptprogramm:
System.out.println(mittelwert(2, 4, 6));  // erwartet 4.0
System.out.println(mittelwert(1, 1, 1));  // erwartet 1.0

Beurteilen Sie diese Implementierung: Erklären Sie, welche Ausgaben tatsächlich entstehen und warum, und entwerfen Sie eine verbesserte Fassung. Nehmen Sie dabei Stellung, wann eine globale Variable trotzdem sinnvoll sein kann.

Hinweis: Was steht zu Beginn des zweiten Aufrufs in summe?

Strategie: Spiele beide Aufrufe mit einer kleinen Tracetabelle durch und achte darauf, welche Variable den ersten Aufruf „überlebt“.Warum so? Parameter und lokale Variablen entstehen bei jedem Aufruf neu. Eine globale Variable dagegen behält ihren Wert zwischen den Aufrufen.
Lösungsskizze: 1. Aufruf: summe 0 → 12, Rückgabe 4.0 · 2. Aufruf: summe 12 → 15, Rückgabe 5.0 statt 1.0 · Seiteneffekt: Ergebnis hängt von früheren Aufrufen ab · Lösung: summe als lokale Variable in der Operation.
Musterlösung anzeigen (zählt als erledigt)

Musterlösung: Der erste Aufruf liefert zufällig das richtige Ergebnis: summe ist 0, wird auf 12 erhöht, und 12 / 3.0 = 4,0. Beim zweiten Aufruf steht in der globalen Variable aber noch 12; sie wird auf 15 erhöht, und ausgegeben wird 5,0 statt 1,0. Die Operation hat einen Seiteneffekt: Sie verändert eine globale Variable, sodass ihr Ergebnis nicht nur von den Parametern, sondern von allen früheren Aufrufen abhängt. Das macht Fehler schwer zu finden, weil derselbe Aufruf je nach Vorgeschichte verschiedene Werte liefert.

Java
static double mittelwert(int a, int b, int c) {
    int summe = a + b + c;   // lokal: bei jedem Aufruf neu
    return summe / 3.0;
}

In der verbesserten Fassung ist summe lokal: Die Daten kommen über die Parameter hinein und über return heraus, jeder Aufruf ist unabhängig. Eine globale Variable ist nur dann sinnvoll, wenn ein Wert absichtlich über mehrere Aufrufe hinweg erhalten bleiben soll, etwa die Anzahl aller bisher berechneten Mittelwerte. Auch dann sollte nur eine klar benannte Operation sie verändern.

A7
Parameterreihenfolge — Namen oder Positionen?
AFB III

Gegeben ist die Operation potenz und ein Hauptprogramm, das sie aufruft:

Pseudocode
Operation potenz(basis, exponent)
  ergebnis ← 1
  für i von 1 bis exponent wiederhole
    ergebnis ← ergebnis · basis
  ende für
  zurück ergebnis

// Hauptprogramm
exponent ← 2
basis ← 5
Ausgabe: potenz(exponent, basis)

Ein Mitschüler behauptet: „Das Programm gibt 5² = 25 aus. Die Reihenfolge der Argumente ist egal, denn die Variablen heißen ja genauso wie die Parameter.“ Widerlegen Sie diese Behauptung, begründen Sie, welcher Wert tatsächlich ausgegeben wird, und erläutern Sie, warum ein einzelner Test mit potenz(2, 4) und potenz(4, 2) ihn in seinem Irrtum bestärken würde.

Hinweis: Woran erkennt die Operation beim Aufruf, welcher Wert in welchen Parameter kommt — am Namen oder an der Stelle?

Strategie: Schreibe den Aufruf als Zuweisungen an die Parameter: Das erste Argument geht in den ersten Parameter, das zweite in den zweiten.Warum so? Die Namen im Hauptprogramm und in der Operation gehören zu verschiedenen Gültigkeitsbereichen. Verbunden werden sie allein über die Position im Aufruf.
Lösungsskizze: Aufruf potenz(exponent, basis) ⇒ Parameter basis ← 2, Parameter exponent ← 5 ⇒ 2⁵ = 32 · potenz(2, 4) = 2⁴ = 16 = 4² = potenz(4, 2) ⇒ zufällig gleich.
Musterlösung anzeigen (zählt als erledigt)

Musterlösung: Beim Aufruf werden die Argumente der Reihe nach in die Parameter kopiert — die Namen der Variablen im Hauptprogramm spielen dabei keine Rolle. Der Aufruf potenz(exponent, basis) bewirkt also: Parameter basis ← Wert von exponent = 2 und Parameter exponent ← Wert von basis = 5. Die Schleife multipliziert fünfmal mit 2, ausgegeben wird 2⁵ = 32 und nicht 25. Damit ist die Behauptung widerlegt; korrekt wäre der Aufruf potenz(basis, exponent).

Der Test mit potenz(2, 4) und potenz(4, 2) ergibt in beiden Fällen 16, weil zufällig 2⁴ = 4² gilt. Wer nur diese beiden Aufrufe vergleicht, hält die Reihenfolge fälschlich für egal. Ohne Bedeutung ist die Reihenfolge nur bei symmetrischen Operationen wie einer Summe oder einem Produkt zweier Zahlen — oder wenn beide Argumente denselben Wert haben.

A8
Lexikographischer Vergleich — seltsame Sortierung
AFB III

Ein Programm sortiert Zeichenketten mit compareTo aufsteigend. Aus der Liste "apfel", "Birne", "25", "100", "Apfelmus" wird:

Java
"100", "25", "Apfelmus", "Birne", "apfel"

Begründen Sie mithilfe der ASCII-Werte jede auffällige Stelle dieser Reihenfolge: warum "100" vor "25", warum "Apfelmus" vor "Birne" und warum "apfel" ganz hinten steht. Beurteilen Sie, ob der Vorschlag „vor dem Vergleich beide Zeichenketten in Kleinbuchstaben umwandeln“ alle Auffälligkeiten beseitigt.

Hinweis: Lexikographisch heißt: Zeichen für Zeichen von links; das erste unterschiedliche Zeichen entscheidet — nicht die Länge und nicht der Zahlenwert.

Strategie: Suche bei jedem Paar benachbarter Wörter das erste Zeichen, in dem sie sich unterscheiden, und schlage dessen ASCII-Wert nach.Warum so? Der Computer „kennt“ keine Wörter oder Zahlen in einer Zeichenkette, sondern nur die Codes der einzelnen Zeichen.
Lösungsskizze: '1' = 49 < '2' = 50 ⇒ „100“ vor „25“ · Ziffern (48–57) < Großbuchstaben (65–90) < Kleinbuchstaben (97–122) · 'A' = 65 < 'B' = 66 · 'a' = 97 > alle Großbuchstaben · Kleinbuchstaben-Vergleich behebt nur das Problem mit Groß/klein, nicht das mit den Zahlen.
Musterlösung anzeigen (zählt als erledigt)

Musterlösung: Zeichenketten werden lexikographisch verglichen: Das erste Zeichen, in dem sich zwei Ketten unterscheiden, entscheidet nach seinem ASCII-Wert. Bei "100" und "25" sind das schon die ersten Zeichen: '1' (49) ist kleiner als '2' (50), also steht "100" vorn — obwohl die Zahl 100 größer ist als 25. Ziffern (48–57) haben kleinere Codes als alle Buchstaben, deshalb stehen beide Zahlen am Anfang. "Apfelmus" kommt vor "Birne", weil 'A' (65) < 'B' (66). "apfel" steht hinten, weil der Kleinbuchstabe 'a' den Code 97 hat und damit größer ist als jeder Großbuchstabe (65–90).

Beurteilung: Vergleicht man s.toLowerCase().compareTo(t.toLowerCase()), entsteht "100", "25", "apfel", "Apfelmus", "Birne": Groß- und Kleinschreibung stören nicht mehr, und "apfel" steht als Präfix von "Apfelmus" richtig davor. Das Problem mit den Zahlen bleibt aber bestehen, denn die Ziffern ändern sich beim Umwandeln nicht. Dafür müsste man Zahlen vorher in int umwandeln (Integer.parseInt) und als Zahlen vergleichen. Der Vorschlag ist also eine Verbesserung, aber keine vollständige Lösung.

A9
Zufallszahlenbereich prüfen
AFB III

Für eine Simulation soll eine ganze Zufallszahl von −3 bis +3 erzeugt werden; jeder der möglichen Werte soll gleich wahrscheinlich sein. Zwei Vorschläge:

Java
int  a = (int) (Math.random() * 6) - 3;      // Variante A
long b = Math.round(Math.random() * 6) - 3;   // Variante B

Prüfen Sie beide Varianten, beurteilen Sie, ob sie die Anforderung erfüllen, und entwerfen Sie eine korrekte Anweisung in Java und in Python.

Hinweis: Math.random() liefert eine Kommazahl von 0 (einschließlich) bis 1 (ausschließlich). Wie viele ganze Zahlen liegen von −3 bis +3?

Strategie: Verfolge den Wertebereich Schritt für Schritt: erst nach dem Multiplizieren, dann nach dem Abschneiden bzw. Runden, zuletzt nach dem Verschieben um −3.Warum so? Fehler in Zufallsausdrücken sieht man selten beim Ausprobieren, weil ein fehlender oder seltener Wert kaum auffällt. Nur die Bereichsrechnung deckt sie sicher auf.
Lösungsskizze: A: [0; 6) → (int) 0 … 5 → −3 … 2, die 3 fehlt · B: Runden → 0 … 6 → −3 … 3, aber 0 nur für [0; 0,5) und 6 nur für [5,5; 6) ⇒ Randwerte halb so wahrscheinlich · 7 Werte ⇒ Faktor 7.
Musterlösung anzeigen (zählt als erledigt)

Musterlösung: Variante A: Math.random() * 6 liegt im Bereich [0; 6). Das Abschneiden mit (int) liefert die ganzen Zahlen 0 bis 5, nach Abzug von 3 also −3 bis 2. Der Wert +3 kommt nie vor — die Anforderung ist verletzt.

Variante B: Durch das Runden entstehen die Werte 0 bis 6, nach Abzug von 3 tatsächlich −3 bis +3. Die Werte sind aber nicht gleich wahrscheinlich: Auf 0 wird nur aus dem Intervall [0; 0,5) gerundet, auf 6 nur aus [5,5; 6) — das sind jeweils Intervalle der Breite 0,5, während jeder innere Wert ein Intervall der Breite 1 hat. −3 und +3 treten daher nur mit Wahrscheinlichkeit 1/12 auf, die übrigen Werte mit 1/6. Auch Variante B erfüllt die Anforderung nicht.

Korrekt: Von −3 bis +3 gibt es b − a + 1 = 3 − (−3) + 1 = 7 Werte, also muss mit 7 multipliziert und abgeschnitten werden:

Java
int z = (int) (Math.random() * 7) - 3;   // −3 … 3, alle mit Wahrscheinlichkeit 1/7
Python
import random
z = random.randint(-3, 3)   # beide Grenzen gehören dazu
A10
Struktogramm und Code vergleichen
AFB III

Das Struktogramm beschreibt einen Algorithmus für natürliche Zahlen n ≥ 1. Eine Schülerin hat ihn in Java umgesetzt:

Vorgabe als Struktogramm
Java
int t = 0;
do {
    if (n % 2 == 0) {
        n = n / 2;
    } else {
        n = 3 * n + 1;
    }
    t++;
} while (n > 1);
System.out.println(t);

Beurteilen Sie, ob der Java-Code das Struktogramm korrekt umsetzt. Begründen Sie genau, für welche zulässigen Eingaben sich die Ausgaben unterscheiden, und geben Sie eine korrigierte Fassung an.

Hinweis: Achte auf die Schleifenart: Wo steht die Bedingung im Struktogramm, wo im Code — und was folgt daraus für den ersten Durchlauf?

Strategie: Suche die Eingabe, bei der die Bedingung schon vor dem ersten Durchlauf falsch ist. Spiele sie in beiden Fassungen durch.Warum so? Kopf- und fußgesteuerte Schleife verhalten sich genau dann gleich, wenn die Bedingung vor dem ersten Durchlauf wahr ist. Unterschiede gibt es also nur an diesem Rand.
Lösungsskizze: Struktogramm: kopfgesteuert, n = 1 ⇒ 0 Durchläufe, Ausgabe 0 · Java: fußgesteuert, n = 1 ⇒ 1 → 4 → 2 → 1, Ausgabe 3 · für n ≥ 2 ist n > 1 anfangs wahr ⇒ gleiches Verhalten (z. B. n = 6: beide 8) · Korrektur: while statt do-while.
Musterlösung anzeigen (zählt als erledigt)

Musterlösung: Das Struktogramm enthält eine kopfgesteuerte Schleife: Die Bedingung n > 1 wird vor jedem Durchlauf geprüft. Der Java-Code verwendet dagegen eine fußgesteuerte do-while-Schleife, deren Rumpf mindestens einmal ausgeführt wird. Für jede Eingabe n ≥ 2 ist die Bedingung vor dem ersten Durchlauf wahr; dann laufen beide Fassungen identisch (für n = 6 zum Beispiel beide mit der Ausgabe 8). Für n = 1 dagegen führt das Struktogramm keinen Durchlauf aus und gibt 0 aus, während der Java-Code 1 → 4 → 2 → 1 rechnet und 3 ausgibt. Die Umsetzung ist also nicht korrekt, der Fehler zeigt sich nur beim Randfall n = 1.

Java
int t = 0;
while (n > 1) {
    if (n % 2 == 0) {
        n = n / 2;
    } else {
        n = 3 * n + 1;
    }
    t++;
}
System.out.println(t);

Anmerkung: Dass die Schleife für jedes n ≥ 1 terminiert, ist übrigens bis heute nicht bewiesen (Collatz-Vermutung) — ausprobiert wurde es nur für sehr viele, aber endlich viele Zahlen. Ein Terminierungsbeweis wie in Aufgabe 1 gelingt hier nicht, weil n nicht in jedem Durchlauf kleiner wird.