Dieses Dokument erklärt Schritt für Schritt einen Algorithmus. Der Algorithmus findet alle Rechtecke in einer Liste von Punkten.
Ich habe dieses Projekt auch mit Golang (Go) implementiert und anschließend die Performance beider Projekte gemessen und miteinander verglichen. Für weitere Informationen kannst du diesen Link ansehen:
go-rectangles: https://github.com/jahanalem/go-rectangles
Wir verwenden diese 19 Punkte als Beispiel für alle Schritte.
public static List<Point> POINTS = new List<Point>
{
new Point(1, 1), new Point(1, 2), new Point(2, 0), new Point(2, 1),
new Point(2, 4), new Point(2, 5), new Point(2, 6), new Point(2, 7),
new Point(3, 1), new Point(3, 2), new Point(4, 8), new Point(5, 0),
new Point(5, 4), new Point(6, 2), new Point(6, 3), new Point(7, 2),
new Point(7, 3), new Point(9, 1), new Point(9, 7)
};
Zuerst räumen wir die Daten auf. Wir entfernen alle doppelten Punkte. Das ist wichtig, damit der Algorithmus schnell und korrekt arbeitet.
Das machen wir am Anfang mit der Methode Distinct()
.
// Aus der Datei: Program.cs
var allDistinctPoints = allPoints.Distinct().ToList();
DataProcessor.ProcessData(allDistinctPoints);
Ein Rechteck hat horizontale Seiten. Die Punkte auf diesen Seiten haben denselben Y-Wert. Deshalb gruppieren wir alle Punkte nach ihrem Y-Wert. So finden wir schnell alle Punkte, die auf einer horizontalen Ebene liegen.
Die Methode GroupPointsByY
erledigt das. Sie erstellt Gruppen von Punkten. Jede Gruppe enthält Punkte mit dem gleichen Y-Wert.
// Aus der Datei: RectangleProcessorParallel.cs
public Dictionary<int, List<Point>> GroupPointsByY(List<Point> points)
{
var pointsGroupedByY = new Dictionary<int, List<Point>>();
foreach (var point in points)
{
if (!pointsGroupedByY.TryGetValue(point.Y, out var group))
{
group = new List<Point>();
pointsGroupedByY[point.Y] = group;
}
group.Add(point);
}
return pointsGroupedByY;
}
Mit unseren Daten entstehen diese Gruppen:
- Y = 0:
(2, 0), (5, 0)
- Y = 1:
(1, 1), (2, 1), (3, 1), (9, 1)
- Y = 2:
(1, 2), (3, 2), (6, 2), (7, 2)
- Y = 3:
(6, 3), (7, 3)
- Y = 4:
(2, 4), (5, 4)
- Y = 7:
(2, 7), (9, 7)
Jetzt erstellen wir aus den Gruppen horizontale Linien. Eine Linie braucht mindestens zwei Punkte. Diese Linien sind die möglichen Ober- und Unterseiten unserer Rechtecke. Um das schnell zu machen, läuft dieser Prozess parallel.
Die Methode CreateLines
nimmt die Gruppen und erstellt Linien. Parallel.ForEach
beschleunigt den Prozess.
// Aus der Datei: RectangleProcessorParallel.cs
public List<Line> CreateLines(Dictionary<int, List<Point>> pointsGroupedByY)
{
var lines = new ConcurrentBag<Line>();
Parallel.ForEach(pointsGroupedByY.Values, group =>
{
if (group.Count < 2) return; // Man braucht mindestens 2 Punkte
for (int i = 0; i < group.Count; i++)
{
for (int j = i + 1; j < group.Count; j++)
{
lines.Add(new Line(group[i], group[j]));
}
}
});
return lines.ToList();
}
Das ist der Kern des Algorithmus. Wir nehmen jede Linie als "Basis-Linie". Dann suchen wir eine zweite, passende Linie.
Eine passende Linie muss zwei Regeln erfüllen:
- Sie muss einen anderen Y-Wert haben.
- Sie muss die gleichen X-Werte haben wie die Basis-Linie.
Wenn wir so eine Linie finden, haben wir ein Rechteck gefunden.
Die Methode FindPotentialRectangles
setzt diese Logik um.
// Aus der Datei: RectangleProcessorParallel.cs
public List<IRectangle> FindPotentialRectangles(List<Line> lines)
{
var potentialRectangles = new ConcurrentDictionary<IRectangle, bool>();
// ... Gruppierung der Linien nach Y ...
Parallel.ForEach(linesGroupedByY, baseGroup =>
{
foreach (var baseLine in baseGroup.Value)
{
foreach (var comparisonGroup in linesGroupedByY)
{
if (comparisonGroup.Key <= baseGroup.Key) continue;
foreach (var comparisonLine in comparisonGroup.Value)
{
// Prüfen, ob die X-Werte gleich sind
if ((baseLine.Point1.X == comparisonLine.Point1.X && baseLine.Point2.X == comparisonLine.Point2.X) ||
(baseLine.Point1.X == comparisonLine.Point2.X && baseLine.Point2.X == comparisonLine.Point1.X))
{
var newRectangle = new Models.Rectangle(baseLine, comparisonLine);
potentialRectangles.TryAdd(newRectangle, true);
}
}
}
}
});
return potentialRectangles.Keys.ToList();
}
- Der Algorithmus wählt die Basis-Linie von
(2,0)
bis(5,0)
. Die X-Werte sind 2 und 5. - Er sucht in anderen Y-Ebenen eine Linie mit den X-Werten 2 und 5.
- Er findet die Linie von
(2,4)
bis(5,4)
. - Ergebnis: Ein Rechteck wurde gefunden!
Ein Rechteck kann mehrfach gefunden werden. Wir müssen das verhindern. Dafür benutzen wir ein ConcurrentDictionary
. Es speichert jedes gefundene Rechteck. Wenn ein Rechteck schon existiert, wird es nicht nochmal hinzugefügt. Das funktioniert, weil wir eine spezielle Equals
-Methode für unsere Rechtecke geschrieben haben.
Diese Equals
-Methode in der Rectangle
-Klasse ist sehr wichtig. Sie prüft, ob zwei Rechtecke dieselben vier Eckpunkte haben.
// Aus der Datei: Rectangle.cs
public bool Equals(IRectangle? other)
{
if (other == null) return false;
// Zwei Rechtecke sind gleich, wenn ihre 4 Eckpunkte gleich sind.
return GetOrderedPoints().SequenceEqual(other.GetOrderedPoints());
}
Am Ende gibt der Algorithmus eine saubere Liste zurück. In dieser Liste ist jedes gefundene Rechteck nur einmal enthalten.
Der Algorithmus findet diese 4 einzigartigen Rechtecke:
- Rechteck 1: Ecken bei (1,1), (3,1), (1,2), (3,2)
- Rechteck 2: Ecken bei (2,0), (5,0), (2,4), (5,4)
- Rechteck 3: Ecken bei (6,2), (7,2), (6,3), (7,3)
- Rechteck 4: Ecken bei (2,1), (9,1), (2,7), (9,7)
In diesem Teil schauen wir uns den C#-Code genauer an. Wir erklären wichtige Methoden und Befehle so einfach wie möglich. Das hilft jedem zu verstehen, wie der Code funktioniert, auch ohne tiefe C#-Kenntnisse.
Diese Methode ist der erste Schritt im Algorithmus. Sie nimmt eine Liste von Punkten und sortiert sie in Gruppen. Jeder Punkt kommt in eine Gruppe, die zu seinem Y-Wert gehört.
Der Code:
public Dictionary<int, List<Point>> GroupPointsByY(List<Point> points)
{
var pointsGroupedByY = new Dictionary<int, List<Point>>();
foreach (var point in points)
{
if (!pointsGroupedByY.TryGetValue(point.Y, out var group))
{
group = new List<Point>();
pointsGroupedByY[point.Y] = group;
}
group.Add(point);
}
return pointsGroupedByY;
}
1. Dictionary<int, List<Point>>
- Ein
Dictionary
ist eine Art Sammlung zum Speichern von Datenpaaren. Es ist wie ein Telefonbuch. - Jedes Paar besteht aus einem Schlüssel (Key) und einem Wert (Value). Der Schlüssel muss einzigartig sein.
- In unserem Code ist der Schlüssel eine
int
(Ganzzahl). Das ist der Y-Wert. - Der Wert ist eine
List<Point>
(eine Liste von Punkten). Das sind alle Punkte mit diesem Y-Wert. var pointsGroupedByY = new Dictionary...
erstellt ein neues, leeres Dictionary.
2. foreach (var point in points)
- Das ist eine Schleife. Sie geht die Liste
points
durch, Punkt für Punkt. - Bei jeder Wiederholung der Schleife enthält die Variable
point
den aktuellen Punkt.
3. pointsGroupedByY.TryGetValue(point.Y, out var group)
- Das ist der cleverste Teil dieser Methode. Lassen Sie es uns langsam erklären.
TryGetValue
bedeutet: "Versuche, den Wert zu bekommen".- Der Befehl versucht, den Schlüssel
point.Y
(den Y-Wert des aktuellen Punktes) im Dictionary zu finden.
Es gibt zwei mögliche Ergebnisse:
-
Fall A: Der Schlüssel existiert bereits.
- Das bedeutet, wir hatten schon einen Punkt mit diesem Y-Wert.
TryGetValue
findet die passende Liste von Punkten und speichert sie in der Variablegroup
.- Der Befehl gibt
true
(wahr) zurück. - Wegen dem
!
(Nicht-Operator) wird!true
zufalse
. Dieif
-Bedingung ist also nicht erfüllt und der Code imif
-Block wird übersprungen.
-
Fall B: Der Schlüssel existiert noch nicht.
- Das bedeutet, dies ist der erste Punkt, den wir auf dieser Y-Ebene sehen.
TryGetValue
findet nichts. Die Variablegroup
bleibt leer.- Der Befehl gibt
false
(falsch) zurück. - Wegen dem
!
(Nicht-Operator) wird!false
zutrue
. Dieif
-Bedingung ist erfüllt und der Code imif
-Block wird ausgeführt.
4. Der if
-Block
- Dieser Code wird nur ausgeführt, wenn eine Y-Ebene zum ersten Mal auftaucht.
group = new List<Point>();
erstellt eine neue, leere Liste für die Punkte.pointsGroupedByY[point.Y] = group;
fügt das neue Paar zum Dictionary hinzu. Der Y-Wert ist der Schlüssel und die neue, leere Liste ist der Wert.
5. group.Add(point)
- Dieser Befehl steht außerhalb des
if
-Blocks. Er wird also immer ausgeführt. - Er fügt den aktuellen
point
zur richtigen Gruppe (group
) hinzu. - Wenn die Gruppe neu war, ist es jetzt der erste Punkt in dieser Liste.
- Wenn die Gruppe schon existierte, wird der Punkt einfach zur bestehenden Liste hinzugefügt.
Am Ende gibt die Methode das fertige Dictionary zurück, in dem alle Punkte ordentlich nach ihrem Y-Wert gruppiert sind.
Nachdem wir die Punkte gruppiert haben, ist der nächste Schritt, aus ihnen Linien zu machen. Diese Methode nimmt die Gruppen von Punkten und erstellt alle möglichen horizontalen Linien innerhalb jeder Gruppe.
Der Code:
public List<Line> CreateLines(Dictionary<int, List<Point>> pointsGroupedByY)
{
var lines = new ConcurrentBag<Line>();
Parallel.ForEach(pointsGroupedByY.Values, group =>
{
// Eine Gruppe muss mindestens zwei Punkte haben, um eine Linie zu bilden.
if (group.Count < 2) return;
for (int i = 0; i < group.Count; i++)
{
for (int j = i + 1; j < group.Count; j++)
{
var newLine = new Line(group[i], group[j]);
lines.Add(newLine);
}
}
});
return lines.ToList();
}
1. ConcurrentBag<Line>
- Stellen Sie sich einen normalen "Beutel" (
Bag
) vor, in den Sie Dinge hineinwerfen können. In C# ist eine normale Liste (List
) wie ein ordentlicher Stapel. EinBag
ist eher wie ein Sack, die Reihenfolge ist nicht so wichtig. - Das Wort
Concurrent
bedeutet "gleichzeitig". - Ein
ConcurrentBag
ist also ein spezieller "Beutel", der sicher von mehreren Arbeitern gleichzeitig benutzt werden kann. In der Programmierung nennen wir diese Arbeiter Threads. - Wenn mehrere Threads gleichzeitig versuchen, etwas in eine normale Liste zu legen, kann es zu Fehlern oder Datenverlust kommen. Ein
ConcurrentBag
ist dafür gebaut, das zu verhindern. Er ist thread-sicher. var lines = new ConcurrentBag<Line>();
erstellt einen neuen, leeren und thread-sicheren Beutel, in den wir unsereLine
-Objekte legen werden.
2. Parallel.ForEach
- Ein normales
foreach
ist wie ein einzelner Arbeiter, der eine Liste von Aufgaben eine nach der anderen abarbeitet. Parallel.ForEach
ist wie ein Team von Arbeitern. Es teilt die Aufgabenliste unter den Arbeitern auf, und alle arbeiten gleichzeitig an ihren Aufgaben.- In einem modernen Computer hat der Prozessor (CPU) mehrere Kerne (
Cores
). Jeder Kern kann wie ein eigener Arbeiter sein.Parallel.ForEach
nutzt diese Kerne, um die Arbeit viel schneller zu erledigen.
Wie funktioniert es hier?
Parallel.ForEach(pointsGroupedByY.Values, ...)
bekommt die Liste aller unserer Punkt-Gruppen.- Anstatt die Gruppen nacheinander zu verarbeiten, teilt es die Gruppen auf die verfügbaren CPU-Kerne auf.
- Zum Beispiel:
- Kern 1 bekommt die Gruppe
Y = 1
und erstellt alle Linien daraus. - Kern 2 bekommt die Gruppe
Y = 2
und erstellt gleichzeitig alle Linien daraus. - Kern 3 bekommt die Gruppe
Y = 4
und arbeitet auch gleichzeitig.
- Kern 1 bekommt die Gruppe
- Der Code innerhalb der Schleife (
for (int i...
) ist der gleiche wie bei einer normalen Schleife. Er findet alle Paare von Punkten und erstellt eineLine
. lines.Add(newLine);
wirft die neu erstellte Linie in denConcurrentBag
. Da der Beutel thread-sicher ist, gibt es kein Problem, dass mehrere Kerne gleichzeitig Linien hinzufügen.
Am Ende, wenn alle Teams ihre Arbeit erledigt haben, wandelt lines.ToList()
den Beutel wieder in eine normale Liste um und gibt sie zurück.
Zusammenfassend: Diese Methode nutzt moderne Hardware voll aus, indem sie die Arbeit des Linien-Erstellens auf mehrere Schultern verteilt und so den gesamten Prozess erheblich beschleunigt. 🚀
Das ist der wichtigste Teil des Programms. Hier passiert die eigentliche "Magie". Die Methode nimmt die Liste aller horizontalen Linien und findet heraus, welche davon Rechtecke bilden.
Der Code:
public List<IRectangle> FindPotentialRectangles(List<Line> lines)
{
var potentialRectangles = new ConcurrentDictionary<IRectangle, bool>();
var linesGroupedByY = lines.GroupBy(line => line.Point1.Y)
.ToDictionary(g => g.Key, g => g.ToList());
Parallel.ForEach(linesGroupedByY, baseGroup =>
{
foreach (var baseLine in baseGroup.Value)
{
foreach (var comparisonGroup in linesGroupedByY)
{
// **Bedingung 1**
if (comparisonGroup.Key <= baseGroup.Key)
{
continue;
}
foreach (var comparisonLine in comparisonGroup.Value)
{
// **Bedingung 2**
if ((baseLine.Point1.X == comparisonLine.Point1.X && baseLine.Point2.X == comparisonLine.Point2.X) ||
(baseLine.Point1.X == comparisonLine.Point2.X && baseLine.Point2.X == comparisonLine.Point1.X))
{
var newRectangle = new Models.Rectangle(baseLine, comparisonLine);
potentialRectangles.TryAdd(newRectangle, true);
}
}
}
}
});
return potentialRectangles.Keys.ToList();
}
- Wie wir schon wissen, ist
Concurrent
für die Arbeit im Team (parallel). - Ein
Dictionary
speichert Datenpaare. Hier ist der Schlüssel (Key
) dasIRectangle
(unser Rechteck) und der Wert (Value
) ein einfacherbool
-Wert (true
oderfalse
). - Wir benutzen es hier wie einen Türsteher. Es lässt jedes Rechteck nur einmal hinein. Wenn ein Rechteck schon drin ist, wird es ignoriert. Das verhindert Duplikate.
- Der
bool
-Wert selbst ist hier nicht wichtig. Wir nutzen nur die Eigenschaft des Dictionarys, dass jeder Schlüssel einzigartig sein muss.
Ganz am Anfang der Methode sehen wir diese Codezeile:
var linesGroupedByY = lines.GroupBy(line => line.Point1.Y)
.ToDictionary(group => group.Key, group => group.ToList());
Was passiert hier? Schritt für Schritt:
-
lines.GroupBy(line => line.Point1.Y)
:- Problem: Wir haben eine lange Liste mit allen möglichen Linien. Diese ist unsortiert.
- Befehl:
GroupBy
ist ein sehr nützlicher Befehl in C#. Er funktioniert wie eine Sortiermaschine. Er nimmt die lange Liste und steckt jede Linie in einen "Eimer". - Regel: Die Regel zum Sortieren ist
line => line.Point1.Y
. Das bedeutet: "Nimm den Y-Wert des ersten Punktes einer Linie als Etikett für den Eimer." - Ergebnis: Wir haben jetzt mehrere Eimer. Jeder Eimer hat ein Y-Etikett (z.B.
Y=1
,Y=2
) und enthält alle Linien, die auf dieser Y-Ebene liegen.
-
.ToDictionary(...)
:- Diese "Eimer" von
GroupBy
sind nur temporär. Der Befehl.ToDictionary()
macht daraus eine permanente, feste Struktur. - Er erstellt ein
Dictionary
, das wir schon kennen. group => group.Key
sagt: "Nimm das Etikett des Eimers (den Y-Wert) als Schlüssel."group => group.ToList()
sagt: "Nimm alle Linien im Eimer und mache daraus eine Liste. Das ist der Wert."
- Diese "Eimer" von
Beispiel:
- Vorher:
[Line(1,1)-(2,1), Line(6,3)-(7,3), Line(1,1)-(3,1), ...]
(eine lange, unsortierte Liste) - Nachher: Ein
Dictionary
das so aussieht:- Key: 1 -> Value:
[Line(1,1)-(2,1), Line(1,1)-(3,1), ...]
(alle Linien von Ebene Y=1) - Key: 2 -> Value:
[Line(1,2)-(3,2), Line(6,2)-(7,2), ...]
(alle Linien von Ebene Y=2) - Key: 3 -> Value:
[Line(6,3)-(7,3)]
(alle Linien von Ebene Y=3) - ... und so weiter.
- Key: 1 -> Value:
Jetzt ist alles sauber organisiert für die große Suche.
Das ist die entscheidende Prüfung, um ein Rechteck zu bestätigen.
if ((baseLineX1 == comparisonLineX1 && baseLineX2 == comparisonLineX2) ||
(baseLineX1 == comparisonLineX2 && baseLineX2 == comparisonLineX1))
Warum ist diese Bedingung so aufgebaut?
- Das Problem: Ein
Line
-Objekt hatPoint1
undPoint2
. Aber wir wissen nicht, welcher Punkt links und welcher rechts ist. Eine Linie vonA
nachB
ist dieselbe Linie wie vonB
nachA
. Unsere Prüfung muss also beide Möglichkeiten abdecken. - Die Bedingung besteht aus zwei Teilen, die mit einem
||
(ODER) verbunden sind. Wenn einer der beiden Teilewahr
ist, ist die ganze Bedingungwahr
.
Beispiel Schritt für Schritt:
Nehmen wir an, wir suchen ein Rechteck und haben diese beiden Linien:
baseLine
(auf Y=1): Die Punkte sind(2,1)
und(9,1)
.baseLineX1
= 2baseLineX2
= 9
comparisonLine
(auf Y=7): Die Punkte sind(9,7)
und(2,7)
.comparisonLineX1
= 9comparisonLineX2
= 2
Jetzt prüfen wir die Bedingung:
-
Erster Teil:
(baseLineX1 == comparisonLineX1 && baseLineX2 == comparisonLineX2)
(2 == 9 && 9 == 2)
false && false
ergibtfalse
.
-
Zweiter Teil:
(baseLineX1 == comparisonLineX2 && baseLineX2 == comparisonLineX1)
(2 == 2 && 9 == 9)
true && true
ergibttrue
.
-
Gesamtergebnis:
false || true
ergibttrue
.- Perfekt! Die Linien sind vertikal perfekt ausgerichtet. Ein Rechteck wird erstellt. 🎉
Diese doppelte Prüfung stellt sicher, dass wir kein Rechteck verpassen, nur weil die Punkte in einer Linie "falsch herum" gespeichert sind.
Diese Methode ist einer der intelligentesten Tricks im gesamten Algorithmus. Sie wird sowohl vom sequentiellen als auch vom parallelen Prozessor aufgerufen, um die Suche nach Rechtecken drastisch zu beschleunigen. Man kann sie sich als einen sehr effizienten Vor-Filter vorstellen.
Stellen Sie sich vor, Sie haben eine Basis-Linie und müssen eine passende Partner-Linie auf einer anderen Y-Ebene finden.
- Der langsame Weg: Alle anderen Linien im gesamten Datensatz eine nach der anderen zu überprüfen.
- Der schnelle Weg (mit dieser Methode): Zuerst zu fragen: "In welchen Y-Gruppen gibt es überhaupt eine Linie, die als Partner in Frage kommt?"
Diese Methode beantwortet genau diese Frage. Sie gibt eine kleine Liste von vielversprechenden Gruppen zurück, sodass wir unsere Suche auf diese wenigen Gruppen beschränken können.
public List<KeyValuePair<int, List<Line>>> GetMatchingXGroups(
Dictionary<int, List<Line>> linesGroupedByY, int baseLineX1, int baseLineX2)
{
// Eine leere Liste für die vielversprechenden Gruppen
var matchingXGroups = new List<KeyValuePair<int, List<Line>>>();
// Gehe jede Y-Gruppe durch
foreach (var yGroup in linesGroupedByY)
{
// Gehe jede Linie innerhalb dieser Gruppe durch
foreach (var line in yGroup.Value)
{
// Prüfe, ob die X-Werte dieser Linie mit denen der Basis-Linie übereinstimmen
if ((line.Point1.X == baseLineX1 && line.Point2.X == baseLineX2) ||
(line.Point1.X == baseLineX2 && line.Point2.X == baseLineX1))
{
// Treffer! Füge die gesamte Gruppe zur Ergebnisliste hinzu
matchingXGroups.Add(yGroup);
// Extrem wichtige Optimierung: Beende die innere Schleife sofort!
break;
}
}
}
return matchingXGroups; // Gib die Liste der relevanten Gruppen zurück
}
Nehmen wir an, der Haupt-Algorithmus hat gerade diese Basis-Linie ausgewählt:
baseLine
ist die Linie von(6,2)
bis(7,2)
.- Die gesuchten X-Werte sind also
baseLineX1 = 6
undbaseLineX2 = 7
.
Jetzt ruft der Algorithmus GetMatchingXGroups(..., 6, 7)
auf.
- Schleife startet: Die Methode nimmt die erste Gruppe, z.B. von der Ebene
Y=0
. - Prüfung: Sie schaut sich alle Linien in der Gruppe
Y=0
an. Gibt es hier eine Linie mit den X-Werten 6 und 7? Nein. Es passiert nichts. - Nächste Gruppe: Die Methode geht weiter zur Gruppe
Y=1
. Gibt es hier eine passende Linie? Nein. - Der Treffer: Die Methode erreicht die Gruppe der Ebene
Y=3
.- Die innere Schleife startet und findet die Linie von
(6,3)
bis(7,3)
. - Die
if
-Bedingung prüft: Hat diese Linie die X-Werte 6 und 7? Ja! - Aktion 1:
matchingXGroups.Add(yGroup);
-> Die gesamte Gruppe vonY=3
wird zur ErgebnislistematchingXGroups
hinzugefügt. - Aktion 2:
break;
-> Dieser Befehl ist der Schlüssel! Er sagt: "Wir haben einen Treffer in dieser Gruppe gefunden. Es ist nicht nötig, die restlichen Linien in derY=3
Gruppe zu prüfen. Wir wissen bereits, dass diese Gruppe wichtig ist." Die innere Schleife wird sofort beendet, was enorm viel Zeit spart.
- Die innere Schleife startet und findet die Linie von
Das Endergebnis der Methode:
Die Methode gibt eine Liste zurück, die nur die relevanten Gruppen enthält. In unserem Beispiel wäre das eine Liste, die nur das KeyValuePair
für die Ebene Y=3
enthält.
Der Haupt-Algorithmus in FindPotentialRectangles
muss jetzt nicht mehr alle 5 oder 10 Y-Gruppen durchsuchen. Er durchsucht nur noch die eine Gruppe, die ihm diese Methode als vielversprechend geliefert hat. Dies reduziert die Anzahl der Vergleiche dramatisch und ist der Hauptgrund für die hohe Geschwindigkeit des Programms.
Diese Klasse ist mehr als nur ein einfacher Behälter für zwei Linien. Sie ist eine intelligente Struktur, die sehr schnell und zuverlässig arbeiten kann. Sie weiß, wie man sich selbst mit anderen Rechtecken vergleicht und wie man sich für schnelle Suchen vorbereitet.
Ganz oben in der Klasse finden wir diese zwei Zeilen:
private const int HashSeed = 19;
private const int HashFactor = 31;
- Was sind das? Das sind zwei feste Zahlen, die wir für eine spezielle Berechnung benutzen: den
GetHashCode
. Man kann sie sich als "Start-Zutat" (HashSeed
) und "Misch-Faktor" (HashFactor
) vorstellen. - Warum die Zahlen 19 und 31? Das sind Primzahlen. Programmierer benutzen oft Primzahlen für Hash-Berechnungen. Der Grund ist mathematisch, aber die einfache Erklärung ist: Primzahlen helfen dabei, die Ergebnisse besser zu "mischen". Das reduziert die Wahrscheinlichkeit, dass zwei verschiedene Rechtecke zufällig denselben Hash-Code bekommen. Das macht unser
Dictionary
undHashSet
schneller und zuverlässiger. Die genauen Zahlen sind nicht heilig, aber kleine Primzahlen sind eine gute und bewährte Wahl.
Diese Klasse muss oft die gleichen, teuren Berechnungen durchführen. Um Zeit zu sparen, hat sie ein "Gedächtnis" (Cache).
private Point[]? _cachedOrderedPoints;
private int? _cachedHashCode;
private string? _cachedString;
- Was ist das? Das sind private Felder, die ein Ergebnis speichern, nachdem es einmal berechnet wurde.
- Wie funktioniert es? Wenn eine Methode wie
GetHashCode()
aufgerufen wird, prüft sie zuerst: "Habe ich dieses Ergebnis schon im Gedächtnis?"- Ja: Super, ich gebe einfach den gespeicherten Wert zurück. (Sehr schnell!)
- Nein: Okay, ich berechne das Ergebnis, speichere es in meinem Gedächtnis für das nächste Mal und gebe es dann zurück. (Dauert beim ersten Mal länger.)
- Das ist eine sehr starke Optimierungstechnik! 🚀
public IEnumerable<Point> GetOrderedPoints()
{
if (_cachedOrderedPoints != null)
return _cachedOrderedPoints;
// ...
}
- Was sie tut: Diese Methode gibt die vier Eckpunkte des Rechtecks zurück, aber immer in einer festen, sortierten Reihenfolge.
- Warum ist das wichtig? Ein Rechteck kann auf verschiedene Weisen erstellt werden (Linie A + B oder Linie B + A). Aber am Ende ist es dasselbe Rechteck. Durch das Sortieren der Punkte (zuerst nach X, dann nach Y) stellen wir sicher, dass jedes Rechteck eine eindeutige Identität hat. Das ist die Grundlage für zuverlässige Vergleiche.
- Wie sie Caching nutzt: Sie prüft zuerst, ob die sortierten Punkte schon im
_cachedOrderedPoints
-Gedächtnis liegen. Wenn ja, gibt sie diese sofort zurück.
public bool Equals(IRectangle? other)
{
// ...
var thisPoints = GetOrderedPoints();
var otherPoints = other.GetOrderedPoints();
return thisPoints.SequenceEqual(otherPoints);
}
- Was sie tut: Sie vergleicht, ob dieses Rechteck mit einem anderen (
other
) identisch ist. - Wie sie funktioniert: Sie ist sehr schlau. Sie sagt nicht "Sind Linie1 und Linie2 gleich?". Stattdessen fragt sie:
- Gib mir die sortierten Punkte von mir selbst.
- Gib mir die sortierten Punkte des anderen Rechtecks.
- Sind diese beiden sortierten Listen exakt gleich? (
SequenceEqual
prüft das.)
- Dank
GetOrderedPoints
ist dieser Vergleich 100% zuverlässig, egal wie die Rechtecke ursprünglich erstellt wurden.
Ein HashCode
ist wie ein digitaler Fingerabdruck für ein Objekt. Jedes Objekt bekommt eine fast einzigartige Nummer. Datenstrukturen wie HashSet
oder Dictionary
benutzen diesen Fingerabdruck, um Objekte blitzschnell zu finden. Diese Methode stellt sicher, dass unser Fingerabdruck für das Rectangle
-Objekt korrekt, konsistent und schnell erstellt wird.
Der Code:
public override int GetHashCode()
{
// Schritt 1: Prüfung des Gedächtnisses (Cache)
if (_cachedHashCode.HasValue)
return _cachedHashCode.Value;
// Schritt 2: Der sichere Rechenbereich
unchecked
{
// Schritt 3: Die Start-Zutat
int hash = HashSeed;
// Schritt 4: Das Vermischen der Zutaten
foreach (var point in GetOrderedPoints())
{
hash = hash * HashFactor + point.GetHashCode();
}
// Schritt 5: Das Ergebnis im Gedächtnis speichern
_cachedHashCode = hash;
// Schritt 6: Das Ergebnis zurückgeben
return hash;
}
}
if (_cachedHashCode.HasValue)
return _cachedHashCode.Value;
- Was passiert hier? Bevor die Methode irgendeine Arbeit macht, schaut sie in ihr "Gedächtnis" (
_cachedHashCode
).HasValue
prüft, ob dort schon ein Wert gespeichert ist. - Warum? Die Berechnung eines Hash-Codes kann (ein bisschen) teuer sein, besonders wenn sie oft aufgerufen wird. Wenn wir den Wert schon einmal berechnet haben, gibt es keinen Grund, es nochmal zu tun.
- Ergebnis: Wenn schon ein Wert da ist, wird er sofort zurückgegeben. Das ist extrem schnell. Die Methode endet hier.
unchecked
{
// ... Berechnungen ...
}
- Was ist das? In C# wird standardmäßig geprüft, ob eine mathematische Operation zu einer zu großen Zahl führt (einem sogenannten "Overflow"). Wenn das passiert, gibt es einen Fehler und das Programm stürzt ab.
- Warum benutzen wir
unchecked
? Bei der Berechnung von Hash-Codes wollen wir, dass die Zahlen überlaufen. Es ist ein Teil des "Misch"-Prozesses. Wenn die Zahl zu groß für eineint
wird, soll sie einfach wieder am Anfang des Zahlenbereichs (im negativen Bereich) weitermachen.unchecked
sagt dem Programm: "Schalte diese Sicherheitsprüfung für den folgenden Code-Block aus. Ich weiß, was ich tue. Kein Absturz bei einem Overflow."
int hash = HashSeed;
- Was passiert hier? Wir deklarieren eine neue Variable namens
hash
und geben ihr einen Startwert. Dieser Startwert ist unsere KonstanteHashSeed
(die Zahl 19). - Warum? Wir brauchen einen Anfangspunkt für unsere Berechnung. Wir können nicht mit Null anfangen, weil das zu schlechteren, weniger einzigartigen Hash-Codes führen würde, besonders wenn einer der Punkte den Hash-Code Null hat. Eine Primzahl wie 19 ist ein viel besserer, "zufälligerer" Startpunkt.
foreach (var point in GetOrderedPoints())
{
hash = hash * HashFactor + point.GetHashCode();
}
- Das ist die Koch-Anleitung für unseren Fingerabdruck.
foreach (var point in GetOrderedPoints())
: Die Schleife geht durch die vier Eckpunkte des Rechtecks. Wichtig ist, dassGetOrderedPoints()
die Punkte immer in derselben, sortierten Reihenfolge zurückgibt. Das garantiert, dass gleiche Rechtecke immer den gleichen Hash-Code bekommen.hash = hash * HashFactor + point.GetHashCode();
: Das ist die magische Formel. Bei jeder Wiederholung passiert Folgendes:- Nimm den aktuellen
hash
-Wert. - Multipliziere ihn mit unserem
HashFactor
(der Primzahl 31). Das "streckt" und verteilt die Bits der Zahl. - Hole den eigenen Fingerabdruck (
GetHashCode()
) des aktuellenpoint
. - Addiere diesen Punkt-Fingerabdruck zum Ergebnis.
- Nimm den aktuellen
- Beispiel-Ablauf:
- Start:
hash = 19
- Punkt 1:
hash = (19 * 31) + hash_von_punkt1
- Punkt 2:
hash = (aktueller_hash * 31) + hash_von_punkt2
- Punkt 3:
hash = (aktueller_hash * 31) + hash_von_punkt3
- Punkt 4:
hash = (aktueller_hash * 31) + hash_von_punkt4
- Start:
- Am Ende dieser Schleife haben wir eine finale Zahl, die auf einzigartige Weise von allen vier Punkten und ihrer Reihenfolge beeinflusst wurde.
_cachedHashCode = hash;
return hash;
_cachedHashCode = hash;
: Bevor wir das Ergebnis zurückgeben, speichern wir es in unserem Gedächtnis. Beim nächsten Aufruf dieser Methode wird Schritt 1 diesen Wert finden und sofort zurückgeben.return hash;
: Wir geben den finalen, berechneten Fingerabdruck zurück.
Diese Klasse repräsentiert eine einfache Linie zwischen zwei Punkten. Aber sie hat eine sehr wichtige Eigenschaft: Die Reihenfolge der Punkte spielt keine Rolle. Eine Linie von Punkt A nach B ist dieselbe wie eine Linie von B nach A. Der Code in dieser Klasse sorgt dafür, dass das Programm das auch so versteht.
Diese Methode prüft, ob zwei Linien identisch sind. Es gibt zwei Equals
-Methoden, schauen wir sie uns an.
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || (obj is Line otherLine && Equals(otherLine));
}
- Diese Methode ist der allgemeine Einstiegspunkt für Vergleiche.
ReferenceEquals(this, obj)
: Das ist der schnellste Check. Er fragt: "Zeigen diese beiden Variablen (this
undobj
) auf exakt dasselbe Objekt im Speicher des Computers?"- Wenn ja, dann sind sie zu 100% gleich. Die Methode gibt sofort
true
zurück und ist fertig. Das spart Zeit.
- Wenn ja, dann sind sie zu 100% gleich. Die Methode gibt sofort
||
(ODER): WennReferenceEquals
false
ist, geht es hier weiter.obj is Line otherLine && Equals(otherLine)
: Das prüft zwei Dinge:obj is Line otherLine
: Ist das andere Objekt (obj
) überhaupt eineLine
? Wenn nicht, können sie nicht gleich sein. Wenn es eineLine
ist, wird es in der neuen VariableotherLine
gespeichert.&& Equals(otherLine)
: Wenn es eineLine
ist, wird die andere, spezifischereEquals
-Methode (die wir als nächstes besprechen) aufgerufen, um den eigentlichen Wertvergleich zu machen.
public bool Equals(Line? other)
{
if (other is null)
{
return false;
}
return Point1.Equals(other.Point1) && Point2.Equals(other.Point2) ||
Point1.Equals(other.Point2) && Point2.Equals(other.Point1);
}
- Das ist die Kernlogik des Vergleichs.
- Die Bedingung prüft beide möglichen Anordnungen der Punkte:
- Teil 1:
Point1.Equals(other.Point1) && Point2.Equals(other.Point2)
- Ist mein erster Punkt gleich dem ersten Punkt der anderen Linie UND mein zweiter Punkt gleich dem zweiten?
||
(ODER)- Teil 2:
Point1.Equals(other.Point2) && Point2.Equals(other.Point1)
- Ist mein erster Punkt gleich dem zweiten Punkt der anderen Linie UND mein zweiter Punkt gleich dem ersten? (Der "über Kreuz"-Vergleich)
- Teil 1:
- Wenn einer dieser beiden Teile
wahr
ist, sind die Linien gleich. Damit stellen wir sicher, dassLine(A, B)
undLine(B, A)
als identisch angesehen werden.
public override int GetHashCode()
{
int hashPoint1 = Point1.GetHashCode();
int hashPoint2 = Point2.GetHashCode();
return hashPoint1 ^ hashPoint2;
}
- Diese Methode muss eine Regel befolgen: Wenn zwei Objekte laut
Equals
gleich sind, müssen sie auch denselben Hash-Code haben. - Wie erreichen wir das, wenn die Reihenfolge der Punkte egal ist? Mit einem cleveren mathematischen Trick: dem XOR-Operator
^
.
^
steht für "Exklusives ODER". Es ist eine bitweise Operation. Die einfache Erklärung ist: Es ist eine Art von Addition, bei der die Reihenfolge keine Rolle spielt.- Es hat die Eigenschaft:
A ^ B
ist immer dasselbe Ergebnis wieB ^ A
.
Beispiel:
-
Nehmen wir an,
Point1.GetHashCode()
gibt die Zahl 10 zurück. -
Und
Point2.GetHashCode()
gibt die Zahl 25 zurück. -
Fall A:
Line(Point1, Point2)
10 ^ 25
ergibt einen bestimmten Wert (z.B. 19).
-
Fall B:
Line(Point2, Point1)
25 ^ 10
ergibt exakt denselben Wert (19).
Dank des ^
Operators produzieren Line(A, B)
und Line(B, A)
denselben Hash-Code. Damit ist die Regel erfüllt und unser HashSet
oder Dictionary
funktioniert perfekt und zuverlässig mit unseren Line
-Objekten. ✨