Musterlösung Praktikum 4

This commit is contained in:
Oliver Hofmann 2026-05-11 15:17:34 +02:00
parent 064d392d98
commit 5aca875fa6
5 changed files with 299 additions and 0 deletions

View File

@ -0,0 +1,38 @@
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
from utils.algo_context import AlgoContext
from utils.algo_array import Array
from vorlesung.L05_binaere_baeume.bin_tree import BinaryTree
ctx = AlgoContext()
values = Array.from_file('data/seq0.txt', ctx)
tree = BinaryTree(ctx)
for cell in values:
tree.insert(cell.value)
# graph_traversal() erzeugt eine DOT-Datei und rendert sie als PDF.
# Die Datei landet im Verzeichnis, aus dem das Skript gestartet wird.
tree.graph_traversal()
# ── Antworten zu den Beobachtungsfragen ──────────────────────────────────────
# Welcher Knoten ist die Wurzel?
# Das erste eingefügte Element wird immer die Wurzel, da der Baum anfangs
# leer ist. Hier ist das -59 (erste Zeile von seq0.txt).
print("Wurzel:", tree.root.value) # -59
# Wie viele Blattknoten hat der Baum?
# Ein Blattknoten hat weder ein linkes noch ein rechtes Kind.
blattknoten = []
def merke_blatt(node):
if node.left is None and node.right is None:
blattknoten.append(node.value)
tree.in_order_traversal(merke_blatt)
print("Blattknoten:", blattknoten) # [-77, -50, 15, 48, 51, 58] → 6 Blätter
# Wie groß ist die Höhe des Baums?
# height() zählt rekursiv die längste Kante von der Wurzel zu einem Blatt.
# Mit 14 Elementen und zufälliger Reihenfolge ergibt sich hier Höhe 6.
print("Höhe:", tree.root.height()) # 6

View File

@ -0,0 +1,60 @@
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
from utils.algo_context import AlgoContext
from utils.algo_array import Array
from vorlesung.L05_binaere_baeume.bin_tree import BinaryTree
ctx = AlgoContext()
values = Array.from_file('data/seq0.txt', ctx)
tree = BinaryTree(ctx)
for cell in values:
tree.insert(cell.value)
# ── in_order_traversal ───────────────────────────────────────────────────────
print("In-Order:")
tree.in_order_traversal(lambda node: print(node.value, end=" "))
print()
# Ausgabe: -87 -77 -59 -50 14 15 34 46 47 48 50 51 52 58
#
# Warum sortiert?
# Die BST-Eigenschaft garantiert: linker Teilbaum < Wurzel < rechter Teilbaum.
# In-Order besucht genau in dieser Reihenfolge (links → Wurzel → rechts),
# was rekursiv angewendet eine aufsteigende Sortierung ergibt.
# In-Order-Traversal eines BST entspricht damit einer Sortierung der Schlüssel.
# ── level_order_traversal ────────────────────────────────────────────────────
print("\nLevel-Order:")
tree.level_order_traversal(lambda node, level: print(f"Ebene {level}: {node.value}"))
# Ausgabe: Wurzel zuerst, dann alle Knoten der Ebene 1, dann Ebene 2, …
#
# Was zeigt level_order, was in_order nicht zeigt?
# level_order_traversal entspricht einer Breitensuche (BFS). Sie zeigt,
# auf welcher Tiefenebene sich jeder Knoten befindet, und damit die
# tatsächliche Struktur des Baums nicht nur die sortierte Schlüsselfolge.
# Zwei BSTs mit denselben Schlüsseln liefern identische in_order-Ausgaben,
# aber unterschiedliche level_order-Ausgaben, wenn ihre Struktur verschieden ist.
# ── graph_traversal ──────────────────────────────────────────────────────────
# Relevante Stelle in bin_tree.py:
#
# def graph_traversal(self):
# def define_node(node, level, line):
# node.graphviz_rep(level, line, dot) ← Knoten positionieren
# ...
# self.tree_structure_traversal(define_node) ← Traversierung
# _rec(self.root) ← Kanten eintragen
#
# graph_traversal nutzt intern tree_structure_traversal eine In-Order-
# Traversal, die zusätzlich zu jedem Knoten seine Tiefe (level) und seine
# In-Order-Position (line) mitliefert.
#
# level → y-Koordinate im Graphviz-Layout: je tiefer im Baum, desto weiter
# unten in der Abbildung (negiert: pos=f"{col},{-row}!")
# line → x-Koordinate: die In-Order-Position ergibt automatisch eine
# kollisionsfreie horizontale Anordnung ohne überlappende Knoten.
#
# tree_structure_traversal existiert also nicht als eigenständige Ausgabe-
# Traversal, sondern als Hilfsmittel für die Positionsberechnung in graph_traversal.
# Die Traversierungsreihenfolge ist identisch mit in_order_traversal.

View File

@ -0,0 +1,57 @@
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
from utils.algo_context import AlgoContext
from vorlesung.L05_binaere_baeume.bin_tree import BinaryTree
# ── Entarteter BST: sortierte Eingabe ────────────────────────────────────────
ctx = AlgoContext()
tree_sorted = BinaryTree(ctx)
for v in range(1, 9):
tree_sorted.insert(v)
tree_sorted.graph_traversal() # → entarteter Baum sichtbar: eine gerade Kette
print("Höhe (sortierte Eingabe):", tree_sorted.root.height()) # 8
ctx.comparisons = 0
tree_sorted.search(8)
print("Vergleiche search(8) im entarteten Baum:", ctx.comparisons) # 16
# Jeder Knoten (1 bis 8) erfordert zwei Vergleiche: value < node? (nein)
# und value > node? (ja, bis der gesuchte Knoten gefunden ist).
# Das entspricht einer linearen Suche: O(n).
# ── Referenz: ausgewogene Einfügereihenfolge ─────────────────────────────────
ctx2 = AlgoContext()
tree_balanced = BinaryTree(ctx2)
for v in [4, 2, 6, 1, 3, 5, 7, 8]: # Mitte zuerst → ausgewogener Baum
tree_balanced.insert(v)
print("\nHöhe (ausgewogene Eingabe):", tree_balanced.root.height()) # 4
ctx2.comparisons = 0
tree_balanced.search(8)
print("Vergleiche search(8) im ausgewogenen Baum:", ctx2.comparisons) # 8
# ── Antworten zu den Analysefragen ───────────────────────────────────────────
# Wie viele Vergleiche benötigt search(8) im entarteten Baum?
# 16 der Baum entartet zur verketteten Liste, jeder Knoten wird besucht.
# Wie viele im ausgewogenen Baum mit 8 Knoten?
# 8 die Höhe beträgt 4, jede Ebene kostet 2 Vergleiche (< und >).
# Welche Eingabereihenfolge erzeugt immer einen ausgewogenen BST?
# Median-First: stets den Median der aktuellen Teilfolge zuerst einfügen.
# Das setzt voraus, dass alle Elemente bereits bekannt sind bei dynamischen
# Einfügungen (ein Element nach dem anderen, ohne Vorwissen) ist das nicht
# möglich. Deshalb reicht es nicht, nur die Einfügereihenfolge zu steuern.
# Was muss eine Datenstruktur garantieren?
# Sie muss die Baumhöhe nach jeder Einfüge- oder Löschoperation automatisch
# auf O(log n) begrenzen unabhängig von der Eingabereihenfolge.
# AVL-Bäume erreichen das, indem sie nach jeder Operation den Balance-Faktor
# prüfen und bei Bedarf Rotationen durchführen.

View File

@ -0,0 +1,59 @@
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
from utils.algo_context import AlgoContext
from vorlesung.L05_binaere_baeume.avl_tree import AVLTree
ctx = AlgoContext()
tree = AVLTree(ctx)
for v in [10, 5, 15, 3, 7, 12, 20, 1, 4, 6, 8]:
tree.insert(v)
print("Ausgangszustand:")
tree.in_order_traversal(lambda n: print(n.value, end=" "))
print()
tree.graph_traversal()
# ── Fall 1: Blattknoten löschen (1 ist Blatt) ────────────────────────────────
tree.delete(1)
print("\nNach Löschen von 1 (Blatt):")
tree.in_order_traversal(lambda n: print(n.value, end=" "))
print()
tree.graph_traversal()
# ── Fall 2: Knoten mit einem Kind löschen (3 hat jetzt nur noch Kind 4) ──────
tree.delete(3)
print("\nNach Löschen von 3 (ein Kind):")
tree.in_order_traversal(lambda n: print(n.value, end=" "))
print()
tree.graph_traversal()
# ── Fall 3: Knoten mit zwei Kindern löschen (10 hat linkes Kind 5, rechtes 15)
tree.delete(10)
print("\nNach Löschen von 10 (zwei Kinder):")
tree.in_order_traversal(lambda n: print(n.value, end=" "))
print()
tree.graph_traversal()
# Inorder-Nachfolger von 10 ist 12 (kleinstes Element im rechten Teilbaum).
# 12 übernimmt den Platz von 10, anschließend wird rebalanciert.
# ── Antworten zu den Verständnisfragen ───────────────────────────────────────
# Warum muss node.parent = parent nach dem Löschen neu gesetzt werden?
#
# BinaryTree kennt keine parent-Zeiger — delete() gibt lediglich
# (nachfolgender_knoten, elternknoten) als plain-Python-Tupel zurück.
# AVLTree pflegt parent-Zeiger, weil balance() sie benötigt, um den Pfad
# von einem Knoten aufwärts zur Wurzel zu traversieren.
# Ohne die Aktualisierung würde balance() an einem veralteten parent-Zeiger
# hängen und die Rebalancierung unvollständig oder fehlerhaft ausführen.
# Warum beginnt die Rebalancierung mit balance(parent) statt balance(node)?
#
# Das Löschen verändert die Höhe des Teilbaums, in dem der Knoten stand.
# Diese Höhenänderung kann den Balance-Faktor des Elternknotens (und aller
# Vorfahren bis zur Wurzel) aus dem Gleichgewicht bringen.
# Der gelöschte (oder ersetzte) Knoten selbst ist entweder weg oder ein Blatt
# — er ist kein sinnvoller Startpunkt für die Aufwärts-Traversal.
# balance(parent) startet genau dort, wo die Höhenänderung zuerst spürbar ist.

View File

@ -0,0 +1,85 @@
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
import random
from utils.algo_context import AlgoContext
from vorlesung.L05_binaere_baeume.bin_tree import BinaryTree
from vorlesung.L05_binaere_baeume.avl_tree import AVLTree
# ── Theoretische Herleitung ───────────────────────────────────────────────────
# AVL-Baum / ausgeglichener BST
# Bei jedem Schritt der Suche wird genau ein Teilbaum mit (ungefähr) halber
# Größe weiterverfolgt:
#
# T(n) = T(n/2) + O(1)
#
# Master-Theorem: a=1, b=2, f(n)=O(1)
# log_b(a) = log_2(1) = 0 → f(n) = O(n^0) = O(n^log_b(a))
# → Fall 2: T(n) = O(log n)
# BST Worst Case (sortierte Eingabe, entarteter Baum)
# Der Baum ist eine verkettete Liste; jeder Schritt reduziert das Problem um 1:
#
# T(n) = T(n-1) + O(1)
#
# Kein Master-Theorem (b > 1 gefordert). Direkte Auflösung durch Entfalten:
# T(n) = T(n-2) + 2·c = … = T(0) + n·c → O(n)
# BST zufällige Eingabe (Expected Case)
# Bei zufälliger Einfügereihenfolge teilt die Wurzel den Schlüsselraum im
# Erwartungswert in zwei ungefähr gleich große Hälften analog zum
# durchschnittlichen Fall bei Quicksort. Daraus folgt eine erwartete Höhe
# von O(log n), empirisch ca. 2,5 · log₂(n). Ein formaler Beweis erfordert
# eine Wahrscheinlichkeitsanalyse über alle n! Permutationen.
# ── Empirische Messung ───────────────────────────────────────────────────────
SIZES = [10, 100, 500]
print(f"{'n':>5} {'BST zufällig':>14} {'BST sortiert':>14} {'AVL sortiert':>14}")
print("-" * 55)
for n in SIZES:
# BST, zufällige Eingabe
ctx = AlgoContext()
tree = BinaryTree(ctx)
values = random.sample(range(n * 10), n)
for v in values:
tree.insert(v)
ctx.comparisons = 0
tree.search(values[-1])
cmp_random = ctx.comparisons
# BST, sortierte Eingabe
ctx = AlgoContext()
tree = BinaryTree(ctx)
for v in range(1, n + 1):
tree.insert(v)
ctx.comparisons = 0
tree.search(n)
cmp_sorted = ctx.comparisons
# AVL-Baum, sortierte Eingabe
ctx = AlgoContext()
tree = AVLTree(ctx)
for v in range(1, n + 1):
tree.insert(v)
ctx.comparisons = 0
tree.search(n)
cmp_avl = ctx.comparisons
print(f"{n:>5} {cmp_random:>14} {cmp_sorted:>14} {cmp_avl:>14}")
# ── Abschlussfrage ────────────────────────────────────────────────────────────
#
# Stimmen die Messwerte mit den theoretischen Komplexitätsklassen überein?
#
# BST sortiert wächst linear mit n (2·n Vergleiche für das letzte Element),
# was die hergeleitete O(n)-Komplexität bestätigt.
#
# BST zufällig und AVL sortiert wachsen logarithmisch: für n=500 liegen die
# Vergleiche bei ca. 20 bzw. 18 weit unter dem linearen Worst Case.
# Das bestätigt O(log n) für beide, wobei der AVL-Baum die Schranke
# garantiert und der zufällige BST sie nur im Erwartungswert einhält.