Algorithmen & Datenstrukturen

Julian Huber & Matthias Panny

Algorithmen

🎯 Lernziele

  • Studierende wissen was Algorithmen sind
  • Studierende wissen wie Algorithmen beschrieben werden
  • Studierende kennen verschiedene Klassifizierungen von Algorithmen

Beispiel: Rucksackproblem

  • Wie viel Wert passt maximal in den Rucksack, wenn das Gewicht die Auswahl limitiert? Welche GegenstĂ€nde mĂŒssen mitgenommen werden?
  • Abstraktion realer Probleme in der Mechatronik, Logistik, Finanzwesen, etc:
    • Auswahl von Lieferungen bei Luftfracht
    • Scheduling von Berechnungen auf Mikroprozessoren bei gegebenem Speicherplatz, Energieverbrauch, etc.

Bild: https://de.wikipedia.org/wiki/Datei:Knapsack.svg

Lösung des Rucksackproblems

  • Bei einer kleinen Anzahl von GegenstĂ€nden kann das Problem durch Ausprobieren aller möglichen Kombinationen gelöst werden

  • Bei einer großen Anzahl von GegenstĂ€nden ergeben sich einige Fragen die sich stellen

Fragen die sich stellen:

  • Sollten wir systematisch vorgehen?
  • Können wir die optimale Lösung finden?
  • Können wir abschĂ€tzen, wie lange wir dafĂŒr brauchen?

→ beschĂ€ftigen mit Algorithmen um diese Fragen zu beantworten

Begriffe

Algorithms + Data Structures = Programs
— Niklaus Wirth

Ein Algorithmus ist ein Verfahren zur Lösung eines bestimmten Problems in einer endlichen Anzahl von Schritten fĂŒr eine endlich große Eingabe.

Beispiel - Finde die grĂ¶ĂŸte Zahl in einer Liste

  • Gewisse Eigenschaften des Algorithmus sind bereits bekannt
  • Algorithmus soll nun prĂ€zisiert werden

Finde die grĂ¶ĂŸte Zahl in einer Liste

  • Problem: Was ist die grĂ¶ĂŸte Zahl
  • Eingabe: Liste (von Zahlen)
  • Lösung: GrĂ¶ĂŸte Zahl
  • Schritte: ?

Algorithmus zum Auffinden der grĂ¶ĂŸten Zahl in einer Liste

  • Wir wollen die grĂ¶ĂŸte Zahl in einer Liste von Zahlen finden
  • Welche "Bausteine" muss unser Algorithmus haben?

Beispiel - largest_number.py

def find_largest_number(a_list: list[int]) -> int:
    largest_number = a_list[0]

    for number in a_list:
        if number > largest_number:
            largest_number = number
    return largest_number

Bausteine

Definierter Input

  • Vordefinierte Eingangsparameter z.B. die Liste → wie bei einer Funktion
  • ZusĂ€tzliche Anforderungen z.B. ist die Liste vorsortiert? → denn der Algorithmus zum Finden der grĂ¶ĂŸten Zahl einer absteigenden sortierten Liste ist trivial

Definierter Output

  • Zusicherung, dass der Output bestimmten Anforderungen entspricht z.B. Output ist eine Zahl und tatsĂ€chlich die GrĂ¶ĂŸte

Endliche Sequenz eindeutiger Instruktionen

  • unabhĂ€ngig von der Implementierung (Programmiersprache)
  • Neben In- und Output können auch dabei Hilfsvariablen angelegt werden

Eigenschaften

Terminierung

  • Der Algorithmus muss nach endlich vielen Schritten terminieren

Korrektheit

  • Ein korrekter Algorithmus stoppt (terminiert) fĂŒr jede
    Eingabeinstanz mit der durch die Eingabe-Ausgabe-Relation
    definierten Ausgabe
  • Ein inkorrekter Algorithmus terminiert gar nicht oder mit einer nicht durch die Eingabe-Ausgabe-Relation vorgegebenen Ausgabe

Effizienz

  • Bedarf an Speicherplatz und Rechenzeit
  • Wachstum (Wachstumsgrad, Wachstumsrate) der Rechenzeit bei
    steigender Anzahl der Eingabe-Elemente (LaufzeitkomplexitÀt)

Beschreibungslevel

High Level Beschreibung

  • Gibt das grobe Vorgehen an → verbal oder grafische
  • nicht alle Variablen sind zwangsweise definiert

Beispiel

  • Wenn es keine Zahlen in der Liste gibt, dann gibt es auch keine höchste Zahl
  • Nimm zunĂ€chst an, die erste Zahl in der Liste ist die grĂ¶ĂŸte Zahl in der Liste
  • FĂŒr jede verbleibende Zahl in der Liste gehe wie folgt vor:
    Wenn diese Zahl grĂ¶ĂŸer ist als die aktuell grĂ¶ĂŸte Zahl, wird diese Zahl als grĂ¶ĂŸte Zahl der Liste betrachtet
  • Wenn es keine Zahlen mehr in der Liste gibt, ĂŒber die man iterieren kann, wird die aktuell grĂ¶ĂŸte Zahl als grĂ¶ĂŸte Zahl der Liste betrachtet

Beschreibungslevel

Low Level Beschreibung

  • Formale Beschreibung
  • Definiert alle relevanten ZustĂ€nde
  • z.B. als Pseudo-Code

Beispiel Pseudo-Code

Algorithm LargestNumber

Input: A list of numbers L.
Output: The largest number in the list L.

if L.size = 0 return null
largest ← L[0]
for each item in L, do
    if item > largest, then
        largest ← item
return largest

Andere Implementierung

  • Die identische Problembeschreibung kann auch zu anderen Umsetzungen fĂŒhren

Beispiel - largest_number_std.py

  • Implementierung mit Hilfe der Python-Standardfunktionen & Exception Handling
  • Der implementierte Algorithmus ist wesentlich komplexer → die KomplexitĂ€t wird aber mit den Standardfunktionen von Python verborgen
    my_list = [9, 42, 3, 8]
    
    def find_largest_number(a_list: list[int]) -> int:
        if a_list:
            a_list.sort()
            return a_list[-1]
        else:
            raise IndexError("Provided list is empty")
    
    def find_largest_number_simple(a_list: list[int]) -> int:
        if a_list:
            return max(a_list)
        else:
            raise IndexError("Provided list is empty")
    

Klassifizierung von Algorithmen

Klassifizierung von Algorithmen

  • Algorithmen lassen sich unterschiedlich klassifizieren
  • Algorithmen können auch mehreren Klassen angehören

Klassifizierung nach Art der Implementierung

Brute Force

  • Sowohl das Rucksackproblem als auch das Finden der grĂ¶ĂŸten Zahl in einer Liste, lassen sich durch vollstĂ€ndiges Ausprobieren aller Möglichkeiten lösen
  • → Einsatz von (möglichst viel) Rechenleistung: Brute Force
  • je lĂ€nger die Liste wird, desto mehr Rechenleistung wird benötigt (die LaufzeitkomplexitĂ€t wird spĂ€ter bei den Datenstrukturen noch eine Rolle spielen)

Klassifizierung nach Art der Lösung

Exakte Algorithmen

  • sind in der Lage sind, fĂŒr alle Inputs eines Problems eine optimale Lösung zu finden
  • z.B. Rucksackproblem: Durch brute force können alle möglichen Kombinationen ausprobiert werden, um die optimale Lösung zu finden und dazu die exakt benötigten GegenstĂ€nde zu bestimmen

Lösung des Rucksackproblems mittels Brute Force

  • Opt. Lösung durch vollstĂ€ndiges Absuchen des Lösungsraums
  • Problem: Anzahl an Kombinationen steigt exponentiell mit der Anzahl der GegenstĂ€nde

Pseudo-Code

ALGORITHMUS optimaleLoesung:
    Übergabe: Liste von GegenstĂ€nden, Grenzgewicht des Rucksacks
    erzeuge eine erste Kombination, z.B. '00000'
    maxKombination = Kombination
    maxWert = Gesamtwert von Kombination
    SOLANGE noch nicht alle Kombinationen durchlaufen sind:
        erzeuge eine neue Kombination
        WENN der Gesamtwert von Kombination > maxWert und
             das Gesamtgewicht von Kombination <= grenzgewichtRucksack:
            maxKombination = Kombination
            maxWert = Gesamtwert von Kombination
    RĂŒckgabe: (maxKombination, maxWert, Gesamtgewicht von maxKombination)
  • Es gibt Probleme die mathematisch beweisbar lösbar sind, aber praktisch nicht lösbar sind → Zahl an Rechenschritten steigt zu schnell

Klassifizierung nach Art der Lösung

  • In der theoretischen Informatik beschĂ€ftigt sich die KomplexitĂ€tstheorie damit, ob und wie effizient bestimmte Probleme algorithmisch gelöst werden können

Approximative Algorithmen

  • in der Praxis sind Probleme hĂ€ufig mit vertretbarem Ressourceneinsatz nur approximativ zu lösen
  • nĂ€hern die exakte Lösung an → oft sinnvoller, als z.B. alle möglichen Kombinationen auszuprobieren
  • dabei kommen hĂ€ufig Abbruchkriterien zum Einsatz (vgl. tol, max_iter)
  • Eine wichtige Gruppe von approximativen Algorithmen sind Heuristiken

Klassifizierung nach Art der Implementierung

Iteration

  • Nutzt Schleifenstrukturen und/oder Datenstrukturen (Stack, Queue, etc.) → wiederholtes AusfĂŒhren der gleichen Anweisungen
  • Jeder rekursive Algorithmus kann als iterativer Algorithmus implementiert werden und umgekehrt

Beispiel - Newton-Raphson-Verfahren

  • Finden von Nullstellen von Funktionen iterativ & approximativ

Klassifizierung nach Art der Implementierung

Newton-Raphson-Verfahren - newton_raphson.py

def newton_raphson(func, func_deriv, init_guess, tol=1e-6, max_iter=100):
    """Use Newton-Raphson to find the root of a function."""
    x = init_guess
    iteration = 0
    while iteration < max_iter:
        f_x = func(x)
        f_prime_x = func_deriv(x)

        if abs(f_prime_x) < tol:
            raise ValueError("Derivative is too close to zero")

        x_new = x - f_x / f_prime_x

        if abs(x_new - x) < tol:
            return x_new

        x = x_new
        iteration += 1
    raise Exception("Newton-Raphson did not converge")

# Define the function and its derivative
def f(x):
    return x**2 - 4
def f_derivative(x):
    return 2 * x
# Call the Newton-Raphson function to find the root
root = newton_raphson(f, f_derivative, init_guess=1.5)
print("Approximate root:", root)

Beispiel

Wo sind die Nullstellen von

Klassifizierung nach Art der Implementierung

Rekursion - Fibonacci-Folge - fibonacci.py

  • 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...
  • Algorithmus ruft sich selbst wieder auf → Abbruch bei Erreichen einer Grundbedingung
def generate_fibonacci_recursive(n):
    """Generate the n-th Fibonacci number using recursion."""
    if n <= 1:
        return n
    else:
        return generate_fibonacci_recursive(n - 1) + generate_fibonacci_recursive(n - 2)

def gen_fibonacci_list(n):
    """Generates a list of the first n Fibonacci numbers."""
    fibonacci_sequence = []
    for i in range(n):
        fibonacci_sequence.append(generate_fibonacci_recursive(i))
    return fibonacci_sequence

Dyn. Prog. - Fibonacci-Folge - fibonacci_dyn_prog.py

  • wollen wir nun die Fibonacci-Folge fĂŒr n=4 berechnen, so ergibt sich folgender Rekursionsbaum:
1. gfr(4)
2. gfr(3) + gfr(2)
3. (gfr(2) + gfr(1)) + gfr(2)
4. ((gfr(1) + gfr(0)) + gfr(1)) + gfr(2)
5. ((gfr(1) + gfr(0)) + gfr(1)) + (gfr(1) + gfr(0))
  • → gfr(2) wird z.B. mehrfach berechnet

  • Mittels dynamischer Programmierung bauen wir die Ergebnisse von unten nach oben auf und speichern sie fĂŒr spĂ€tere Berechnungen:

def fib(n):
    if n == 0:
        return 0
    else:
        previous_fib, current_fib = 0, 1
        for _ in range(n - 1):
            new_fib = previous_fib + current_fib
            previous_fib = current_fib
            current_fib = new_fib
        return current_fib

Klassifizierung nach Umgang mit Teilproblemen

Greedy (gierige) Algorithmen

  • Gruppe von iterativen Algorithmen
  • bei jedem Schritt eine Entscheidung fĂŒr das lokale Optimum getroffen → keine BerĂŒcksichtigung der zukĂŒnftigen Konsequenzen

Beispiele - Newton-Raphson-Verfahren & Rucksackproblem

  • Newton-Raphson-Verfahren:
    • es wird immer nur die aktuelle Ableitung betrachtet ohne z.B. die grundlegende Gestalt der Funktion zu berĂŒcksichtigen
  • Rucksackproblem:
    • Gegenstand mit dem höchsten Wert pro Gewichtseinheit zuerst einpacken → greedy_knapsack.py
    • Dies kann zu einer suboptimalen Lösung fĂŒhren

Klassifizierung nach Umgang mit Teilproblemen

Zerlegung

  • Zerlegung des Gesamtproblems in kleinere/einfachere Teilproblemen → z.B Divide-and-Conquer → meist rekursive Implementierungen

Zwischenspeicherung

  • Systematisches Speicherung und Nutzen von Zwischenergebnissen → z.B. dynamische Programmierung
  • z.B. beim Lösen des Rucksackproblems mittels Brute Force (oben), berechnen wir fĂŒr jede mögliche Kombination erneut den Wert und das Gewicht → mittels dynamischer Programmierung können wir Zwischenergebnisse speichern und wiederverwenden

đŸ€“ Weitere Klassifizierungen

đŸ€“ Weitere Klassifizierungen

Serielle Algorithmen

  • Jede Anweisung wird nacheinander ausgefĂŒhrt
  • meist das Standardvorgehen

Parallelen Algorithmen

  • das Problem in Teilprobleme aufgeteilt und auf verschiedenen Prozessoren ausgefĂŒhrt wird
  • Wenn parallele Algorithmen auf verschiedene Maschinen verteilt werden, nennt man sie verteilte Algorithmen
    • vgl. Forks und Joins beim Activity Diagramm (VL 3.1)
    • Beliebte Pakete multiprocessing (meistens) und threading (manchmal) in Python

đŸ€“ Weitere Klassifizierungen

đŸ€“ Backtracking:

  • fĂŒr kombinatorische Probleme, die nur eine einzige Lösung haben. Solche Probleme haben mehrere Stufen und in jeder Stufe gibt es mehrere Optionen.
    Jede verfĂŒgbare Option ist in jeder Phase einzeln zu untersuchen.
  • z.B. Lösen eines Sudoku oder finden eines Pfads (mehr dazu in der Graphentheorie)

đŸ€“ Klassif. nach Lösungsansatz

đŸ€“ Divide and Conquer (Teilen und Bezwingen):

  • Bei der Divide-and-Conquer-Strategie wird das Problem in Teilprobleme aufgeteilt, rekursiv gelöst und dann wieder zusammengefĂŒgt, um die endgĂŒltige Antwort zu erhalten.
  • z.B. Merge sort, Quick sort (spĂ€ter mehr)

đŸ€“ Reduction (Transform and Conquer):

  • schwieriges Problem wird in bekanntes Problem umgewandelt
  • z.B. Finden des Medians in einer Liste: 1. Liste sortieren 2. Wert bei halber Listen-LĂ€nge (vgl. HausĂŒbung 1)

🏆 Hausaufgabe

  • Lösen Sie das oben abgebildete Rucksackproblem: Wie viel Wert passt maximal in den Rucksack? Welche GegenstĂ€nde mĂŒssen mitgenommen werden?
  • Stellen Sie den Lösungsansatz als UML-Activity-Diagramm dar und fĂŒgen Sie dieses im Notebook mit ein
  • Lösen Sie das Problem dann in Python z.B. mittels Brute Force

@startuml start :start largest_number = a_list[0]; note right: Initialize largest_number\nwith the first element of the list :Iterate over each number in a_list; repeat :Check if number > largest_number; if (number > largest_number?) then (yes) :Update largest_number to number; else (no) :Do nothing; endif repeat while (there are more numbers in the list?) :return largest_number; stop

@startuml start if (L.size = 0) then (true) :return null; stop else (false) :largest ← L[0]; endif :i = 0; repeat :item = L[i]; if (item > largest) then (true) :largest ← item; endif :i = i + 1; repeat while (i < L.size) stop @enduml