Lerninhalte finden
Features
Entdecke
© StudySmarter 2024, all rights reserved.
Laufzeitanalyse von Algorithmen
Während des NWERC Praktikums hast Du verschiedene Aspekte der Laufzeitanalyse von Algorithmen kennengelernt. Dazu gehören die asymptotische Notation, die Klassifizierung der Laufzeit in Best-Case, Average-Case und Worst-Case Szenarien, sowie das Verständnis und die Anwendung unetrschiedlicher Laufzeitkomplexitätsklassen wie Polynomialzeit, NP und NP-Vollständigkeit. Ein weiteres wichtiges Werkzeug war das Master-Theorem zur Lösung von Rekursionsgleichungen der Form T(n) = aT(n/b) + f(n).
In der folgenden Aufgabe wirst Du diese Konzepte anwenden, um die Effizienz verschiedener Algorithmen zu analysieren und zu bewerten.
1. Betrachte den folgenden rekursiven Algorithmus:
def example_algorithm(n): if n <= 1: return 1 else: return 2 * example_algorithm(n//2) + n
Nutze das Master-Theorem, um die Laufzeit T(n) dieses Algorithmus zu bestimmen. Gehe dabei systematisch vor und identifiziere die Werte von a, b und f(n). Bestimme anschließend die asymptotische Laufzeit.
Lösung:
1. Betrachte den folgenden rekursiven Algorithmus:
def example_algorithm(n): if n <= 1: return 1 else: return 2 * example_algorithm(n//2) + n
Um die Laufzeit T(n) des Algorithmus mittels des Master-Theorems zu analysieren, gehen wir systematisch vor:
In unserem Algorithmus haben wir:
Wir haben also die rekursive Gleichung:
T(n) = 2T(n/2) + n
Jetzt wenden wir das Master-Theorem an. Es gibt drei Fälle zu berücksichtigen:
Zur Berechnung von log_b(a):
Nun vergleichen wir die Funktion f(n) = n mit n^log_b(a) = n^1 = n.
Nach dem Master-Theorem haben wir im zweiten Fall für c = log_b(a):
Die asymptotische Laufzeit des Algorithmus ist daher:
2. Angenommen, Du hast drei Algorithmen A, B und C mit den folgenden Laufzeiten:
Analysiere und vergleiche die Effizienz dieser Algorithmen in den verschiedenen Laufzeitszenarien (Best-Case, Average-Case, Worst-Case). Erkläre, welcher Algorithmus unter verschiedenen Eingabebedingungen bevorzugt werden sollte, und warum.
Lösung:
2. Analysieren und Vergleichen der Algorithmen A, B und C
Um die Effizienz dieser Algorithmen in verschiedenen Laufzeitszenarien zu analysieren und zu vergleichen, betrachten wir die Best-Case, Average-Case und Worst-Case Laufzeit. Der Focus liegt auf der Vergleichbarkeit der asymptotischen Notation (Big-O) der gegebenen Laufzeiten.
Zusammenfassend:
Greedy-Algorithmen sind Algorithmusparadigmen, die in jeder Phase die lokal optimale Wahl treffen, mit der Hoffnung, die global optimale Lösung zu finden. Die Komplexität ist oft geringer als bei dynamischer Programmierung, und sie werden eingesetzt, um Optimierungsprobleme zu lösen. Beispiele für Greedy-Algorithmen sind Prim's Algorithmus, Kruskal's Algorithmus und die Huffman-Codierung. Diese Algorithmen bauen ihre Lösungen auf vorherigen Entscheidungen auf und sind besonders geeignet für Probleme, die die Greedy-Eigenschaft (Optimalitätsprinzip) besitzen.
(a) Betrachte das folgendes Problem: Du möchtest ein Mindest-Spannbaum (Minimum Spanning Tree, MST) für einen gegebenen zusammenhängenden, ungerichteten Graphen mit den Knoten
Lösung:
Um den Mindest-Spannbaum (MST) eines Graphen mit Kruskal's Algorithmus zu berechnen, müssen wir wie folgt vorgehen:
Im gegebenen Graphen haben wir folgende Kanten und deren Gewichte:
Nun sortieren wir die Kanten nach ihren Gewichten in aufsteigender Reihenfolge:
Wir fügen nun schrittweise die Kanten zum MST hinzu, wobei wir darauf achten, keine Zyklen zu erzeugen:
Abschließend besteht der MST aus den Kanten:
Dies ist der Minimum Spanning Tree (MST) des gegebenen Graphen nach Kruskal's Algorithmus.
(b) Analysiere, ob das folgende 0-1-Rucksackproblem die Greedy-Eigenschaft (Optimalitätsprinzip) erfüllt:
Lösung:
Um zu analysieren, ob das 0-1-Rucksackproblem die Greedy-Eigenschaft erfüllt, betrachten wir die folgenden Details:
Ein Greedy-Algorithmus würde hier die Gegenstände nach ihrem Wert-Gewicht-Verhältnis sortieren und die Gegenstände mit dem höchsten Verhältnis auswählen, solange der Rucksack nicht überladen wird.
Berechnen wir die Wert-Gewicht-Verhältnisse:
Sortieren wir die Gegenstände nach dem Wert-Gewicht-Verhältnis in absteigender Reihenfolge:
Wir fügen nun die Gegenstände gemäß dem Greedy-Algorithmus hinzu:
Der Wert des Rucksacks mit der Greedy-Strategie beträgt: 3 (Wert von A) + 4 (Wert von B) = 7.
Nun berechnen wir die globale optimale Lösung durch vollständige Enumeration aller möglichen Kombinationen:
Die globale optimale Lösung: {A, C} mit einem Gesamtwert von 8, da dies das höchste Gewicht <= 6 ist.
Somit erkennen wir, dass der Greedy-Algorithmus beim 0-1-Rucksackproblem nicht die globale optimale Lösung liefert, da {A, C} einen höheren Gesamtwert (8) besitzt als {A, B} (7).
Begründung: Die Greedy-Eigenschaft erfüllt das 0-1-Rucksackproblem nicht, weil die lokale Entscheidung, den Gegenstand mit dem höchsten Wert-Gewicht-Verhältnis zu wählen, nicht immer zu der globalen optimalen Lösung führt. Im obigen Fall hat der Greedy-Algorithmus versagt, die Kombination mit dem maximalen Wert innerhalb des Rucksacklimits zu finden.
Du arbeitest an einem Projekt im Rahmen des 'NWERC Praktikum' an der Universität Erlangen-Nürnberg. Zu deinen Aufgaben gehört die Implementierung und Analyse von dynamischen Programmieralgorithmen. Eines der häufigsten Probleme, das du lösen musst, ist das klassische Rucksackproblem. Gegeben sind n Gegenstände mit jeweils einem Gewicht und einem Wert, sowie ein Rucksack, der ein maximal tragbares Gewicht hat. Ziel ist es, den maximalen Wert der Gegenstände im Rucksack zu finden, ohne das tragbare Gewicht zu überschreiten.
Implementiere das Rucksackproblem mit einem Bottom-Up-Ansatz. Schreibe eine Python-Funktion
def knapsack(values, weights, W), die als Eingabe eine Liste von Werten values, eine Liste von Gewichten weights und das maximale Gewicht W des Rucksacks bekommt. Die Funktion soll den maximalen Wert zurückgeben, der in den Rucksack gepackt werden kann. Nutze eine DP-Tabelle, um Zwischenresultate zu speichern. Achte auf eine effiziente Implementierung hinsichtlich Laufzeit und Speicherbedarf.
Lösung:
Um das Rucksackproblem mit einem Bottom-Up-Ansatz zu lösen, kannst Du eine dynamische Programmierung (DP) verwenden. Wir erstellen eine DP-Tabelle, die alle möglichen Kombinationen von Gegenständen und Gewichten darstellt, um den maximalen Wert zu berechnen. Hier ist ein schrittweiser Ansatz:
Hier ist die Implementierung in Python:
def knapsack(values, weights, W): n = len(values) # Anzahl der Gegenstände # Erstellen der DP-Tabelle dp = [[0 for x in range(W + 1)] for x in range(n + 1)] # Füllen der DP-Tabelle for i in range(n + 1): for w in range(W + 1): if i == 0 or w == 0: dp[i][w] = 0 elif weights[i - 1] <= w: dp[i][w] = max(values[i - 1] + dp[i - 1][w - weights[i - 1]], dp[i - 1][w]) else: dp[i][w] = dp[i - 1][w] return dp[n][W] # Beispielwerte values = [60, 100, 120] weights = [10, 20, 30] W = 50 print(knapsack(values, weights, W)) # Output sollte 220 sein
Die spiegenden Punkte des Codes sind:
dp
, die mit Nullen initialisiert ist.for
-Schleife läuft über die Gegenstände, das innere for
-Schleife über die möglichen Gewichte.w
ist), berechnet die Funktion den maximalen Wert, indem sie den Wert des aktuellen Gegenstands und den Wert der verbleibenden Kapazität vergleicht.w
ist), wird der Wert aus der vorherigen Zeile übernommen.Am Ende gibt die Funktion den maximalen Wert zurück, der in den Rucksack gepackt werden kann.
Analysiere die Komplexität der von dir implementierten Lösung. Erkläre, warum der Bottom-Up-Ansatz im Vergleich zum Top-Down-Ansatz (mit Memoisierung) sinnvoll ist. Berechne die Zeitkomplexität und Speicherkomplexität der Lösung. Begründe deine Antwort mit einer formalen Analyse und vergleiche sie mit dem Top-Down-Ansatz. Nutze dabei folgende Informationen:
Lösung:
Im Folgenden analysieren wir die Komplexität der Bottom-Up-Ansatzlösung des Rucksackproblems und vergleichen sie mit dem Top-Down-Ansatz (mit Memoisierung). Dabei betrachten wir sowohl die Zeit- als auch die Speicherkomplexität.
Der Bottom-Up-Ansatz verwendet dynamische Programmierung, um eine DP-Tabelle zu füllen, die die Werte aller Zwischenlösungen enthält. Hier sind die Details:
Beim Top-Down-Ansatz wird rekursiv gearbeitet, und Ergebnisse von Zwischenlösungen werden in einer Memoisierungstabelle gespeichert, um Redundanzen zu vermeiden.
Angenommen, Du arbeitest an einem Navigationssystem, das eine Karte als Graph darstellt. Du sollst nun Algorithmen implementieren, die dem Benutzer den kürzesten Weg von einem Startpunkt zu einem Zielpunkt berechnen. Verwende hierfür sowohl den Dijkstra-Algorithmus als auch den A*-Algorithmus. Die Graphen bestehen aus Knoten und Kanten mit nichtnegativen Gewichten.
Implementiere den Dijkstra-Algorithmus in Python, der die kürzesten Wege von einem Startknoten zu allen anderen Knoten in einem gerichteten Graphen mit nichtnegativen Kantengewichten berechnet. Verwende dabei die folgende Signatur:
def dijkstra(graph, start):Für die Darstellung des Graphen kannst Du ein Dictionary verwenden, wobei die Schlüssel die Knoten und die Werte die benachbarten Knoten und die entsprechenden Kantenlängen sind. Ein Beispiel für einen Graphen wäre:
{'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}. Der Algorithmus soll eine Dictionary zurückgeben, das die kürzesten Distanzen vom Startknoten zu jedem anderen Knoten enthält.
Lösung:
Um den Dijkstra-Algorithmus zu implementieren, folgen wir diesen Schritten:
Hier ist der Python-Code zur Implementierung des Dijkstra-Algorithmus:
import heapq
def dijkstra(graph, start): # Initialisiere die Distanzen zu allen Knoten als unendlich distances = {node: float('inf') for node in graph} # Setze die Distanz zum Startknoten auf 0 distances[start] = 0 # Initialisiere die Prioritätswarteschlange mit dem Startknoten priority_queue = [(0, start)] while priority_queue: # Wähle den Knoten mit der kürzesten Distanz current_distance, current_node = heapq.heappop(priority_queue) # Falls die aktuelle Distanz größer ist als die gespeicherte Distanz, ignoriere diesen Eintrag if current_distance > distances[current_node]: continue # Aktualisiere die Distanzen der Nachbarn for neighbor, weight in graph[current_node].items(): distance = current_distance + weight # Falls eine kürzere Distanz gefunden wird, aktualisiere sie if distance < distances[neighbor]: distances[neighbor] = distance heapq.heappush(priority_queue, (distance, neighbor)) return distances
Ein Beispiel für die Verwendung dieser Funktion mit dem gegebenen Graphen wäre:
graph = {'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}
start_node = 'A'
distances = dijkstra(graph, start_node)
print(distances)
Das wird die kürzesten Distanzen vom Startknoten 'A' zu allen anderen Knoten im Graphen ausgeben. Du wirst folgendes Ergebnis erhalten:
{'A': 0, 'B': 1, 'C': 3, 'D': 4}
Jeder Wert in dem Dictionary repräsentiert die kürzeste Entfernung von 'A' zu dem jeweiligen Knoten.
Leite die Komplexität des Dijkstra-Algorithmus mathematisch her und beschreibe, unter welchen Bedingungen der Algorithmus optimal läuft. Berücksichtige dabei die Verwendung einer Prioritätswarteschlange.
Lösung:
Der Dijkstra-Algorithmus berechnet die kürzesten Wege in einem Graphen mit nichtnegativen Kantengewichten. Die Komplexität des Algorithmus hängt maßgeblich von der verwendeten Datenstruktur zur Verwaltung der Prioritätswarteschlange ab. Hier leiten wir die Komplexität mathematisch her und erläutern die Bedingungen, unter denen der Algorithmus optimal läuft.
Mathematische Herleitung der Komplexität:
Die Gesamtlaufzeit des Dijkstra-Algorithmus ergibt sich durch die Kombination der obigen Operationen:
Gesamtlaufzeit: \[O(V \, \log V) + O(E \, \log V) = O((V + E) \, \log V)\]
Bedingungen für optimale Laufzeit:
Insgesamt beträgt die Zeitkomplexität des Dijkstra-Algorithmus also \(O((V + E) \, \log V)\), und der Algorithmus läuft optimal in sparse Graphen sowie bei Verwendung effizienter Implementierungen der Prioritätswarteschlange.
Implementiere den A*-Algorithmus in Python. Verwende dafür die folgende Signatur:
def astar(graph, start, goal, heuristic):Du kannst denselben Graphen-Darstellungsansatz wie im Dijkstra-Algorithmus verwenden. Die Heuristik-Funktion wird als zusätzlicher Parameter übergeben und gibt eine Schätzung der Kosten vom aktuellen Knoten zum Zielknoten an. Ein einfaches Beispiel für eine Heuristik-Funktion könnte die euklidische Distanz sein.
Lösung:
Der A\textsuperscript{*}-Algorithmus ist ein weiterer bekannter Algorithmus zur Berechnung der kürzesten Wege, der Heuristiken verwendet, um die Suche effizienter zu gestalten. Im Vergleich zum Dijkstra-Algorithmus, der nur den aktuellen Kostenwert berücksichtigt, verwendet der A\textsuperscript{*}-Algorithmus eine Heuristik-Funktion, um eine Schätzung der verbleibenden Kosten zum Zielknoten zu geben. Hier ist eine Python-Implementierung des A\textsuperscript{*}-Algorithmus:
Für die Implementierung benötigen wir:
Hier ist der Python-Code:
import heapq
def astar(graph, start, goal, heuristic): # Initialisiere die Prioritätswarteschlange mit dem Startknoten priority_queue = [(0 + heuristic(start, goal), 0, start, None)] # Initialisiere die Distanzen zum Startknoten auf 0 und alle anderen Knoten auf unendlich distances = {node: float('inf') for node in graph} distances[start] = 0 # Dictionary zur Rückverfolgung des Pfades came_from = {start: None} while priority_queue: # Wähle den Knoten mit den niedrigsten geschätzten Gesamtkosten (f = g + h) estimated_total_cost, current_distance, current_node, parent = heapq.heappop(priority_queue) # Falls das Ziel erreicht ist, rekonstruiere den Pfad und gib ihn zurück if current_node == goal: path = [] while current_node is not None: path.append(current_node) current_node = came_from[current_node] return path[::-1] # Umkehren des Pfades für die richtige Reihenfolge # Falls die aktuelle Distanz größer ist als die gespeicherte Distanz, ignoriere diesen Eintrag if current_distance > distances[current_node]: continue # Aktualisiere die Distanzen der Nachbarn und füge sie in die Prioritätswarteschlange ein for neighbor, weight in graph[current_node].items(): distance = current_distance + weight if distance < distances[neighbor]: distances[neighbor] = distance came_from[neighbor] = current_node estimated_total_cost = distance + heuristic(neighbor, goal) heapq.heappush(priority_queue, (estimated_total_cost, distance, neighbor, current_node)) return None # Falls kein Pfad gefunden wurde, gib None zurück
Beispiel einer Heuristik-Funktion (euklidische Distanz):
import math
def heuristic(a, b): (x1, y1) = a (x2, y2) = b return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
Beispiel zur Verwendung des A\textsuperscript{*}-Algorithmus mit einem Graphen:
graph = { 'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}
start_node = 'A'
goal_node = 'D'
def simple_heuristic(node, goal_node): heuristics = { 'A': 4, 'B': 2, 'C': 1, 'D': 0 } return heuristics[node] path = astar(graph, start_node, goal_node, simple_heuristic)
print(path)
Dies druckt den kürzesten Pfad von 'A' nach 'D' unter Verwendung der A\textsuperscript{*}-Suche aus.
Erkläre den Unterschied zwischen der Dijkstra- und der A*-Heuristik. Diskutiere in welchen Fällen der A*-Algorithmus möglicherweise schneller ist als der Dijkstra-Algorithmus und wann die Wahl der Heuristik entscheidend ist. Gibt es Situationen, in denen A* keine besseren Ergebnisse als Dijkstra liefert? Begründe Deine Antwort mit Beispielen.
Lösung:
Unterschied zwischen Dijkstra- und A*-Heuristik:
In welchen Fällen ist der A*-Algorithmus möglicherweise schneller?
Wann ist die Wahl der Heuristik entscheidend?
Gibt es Situationen, in denen A* keine besseren Ergebnisse als Dijkstra liefert?
Zusammengefasst:
Mit unserer kostenlosen Lernplattform erhältst du Zugang zu Millionen von Dokumenten, Karteikarten und Unterlagen.
Kostenloses Konto erstellenDu hast bereits ein Konto? Anmelden