Testen Sie eine verknüpfte Liste auf Zyklizität

Testen Sie eine verknüpfte Liste auf Zyklizität

1. Einführung

Eine einfach verknüpfte Liste ist eine Folge verbundener Knoten, die mit einernull-Referenz enden. In einigen Szenarien kann der letzte Knoten jedoch auf einen vorherigen Knoten zeigen, wodurch effektiv ein Zyklus erstellt wird.

In den meisten Fällen möchten wir in der Lage sein, diese Zyklen zu erkennen und zu kennen. Dieser Artikel konzentriert sich genau darauf - das Erkennen und potenzielle Entfernen von Zyklen.

2. Zyklus erkennen

Lassen Sie uns nun einige Algorithmen zum Erkennen von Zyklen in verknüpften Listen untersuchen.

2.1. Brute Force - O (n ^ 2) Zeitkomplexität

Mit diesem Algorithmus durchlaufen wir die Liste mit zwei verschachtelten Schleifen. In der äußeren Schleife durchlaufen wir eins nach dem anderen. In der inneren Schleife gehen wir vom Kopf aus und durchqueren so viele Knoten, wie die äußere Schleife zu diesem Zeitpunkt durchquert hat.

If a node that is visited by the outer loop is visited twice by the inner loop, then a cycle has been detected. Wenn umgekehrt die äußere Schleife das Ende der Liste erreicht, bedeutet dies, dass keine Zyklen vorhanden sind:

public static  boolean detectCycle(Node head) {
    if (head == null) {
        return false;
    }

    Node it1 = head;
    int nodesTraversedByOuter = 0;
    while (it1 != null && it1.next != null) {
        it1 = it1.next;
        nodesTraversedByOuter++;

        int x = nodesTraversedByOuter;
        Node it2 = head;
        int noOfTimesCurrentNodeVisited = 0;

        while (x > 0) {
            it2 = it2.next;

            if (it2 == it1) {
                noOfTimesCurrentNodeVisited++;
            }

            if (noOfTimesCurrentNodeVisited == 2) {
                return true;
            }

            x--;
        }
    }

    return false;
}

Der Vorteil dieses Ansatzes besteht darin, dass eine konstante Menge an Speicher benötigt wird. Der Nachteil ist, dass die Leistung sehr langsam ist, wenn große Listen als Eingabe bereitgestellt werden.

2.2. Hashing - O (n) Raumkomplexität

Mit diesem Algorithmus verwalten wir eine Reihe von bereits besuchten Knoten. Für jeden Knoten prüfen wir, ob er in der Menge vorhanden ist. Wenn nicht, fügen wir es der Menge hinzu. Das Vorhandensein eines Knotens in der Menge bedeutet, dass wir den Knoten bereits besucht haben und dass ein Zyklus in der Liste vorhanden ist.

Wenn wir auf einen Knoten stoßen, der bereits in der Menge vorhanden ist, haben wir den Beginn des Zyklus entdeckt. Nachdem wir dies entdeckt haben, können wir den Zyklus leicht unterbrechen, indem wir das Feldnext des vorherigen Knotens aufnull setzen, wie unten gezeigt:

public static  boolean detectCycle(Node head) {
    if (head == null) {
        return false;
    }

    Set> set = new HashSet<>();
    Node node = head;

    while (node != null) {
        if (set.contains(node)) {
            return true;
        }
        set.add(node);
        node = node.next;
    }

    return false;
}

In dieser Lösung haben wir jeden Knoten einmal besucht und gespeichert. Dies entspricht O (n) Zeitkomplexität und O (n) Raumkomplexität, was im Durchschnitt für große Listen nicht optimal ist.

2.3. Schnelle und langsame Zeiger

Der folgende Algorithmus zum Finden von Zyklen kann am besten inusing a metaphor erklärt werden.

Stellen Sie sich eine Rennstrecke vor, auf der zwei Personen Rennen fahren. Da die Geschwindigkeit der zweiten Person doppelt so hoch ist wie die der ersten Person, fährt die zweite Person doppelt so schnell um die Strecke und trifft die erste Person zu Beginn der Runde erneut.

Hier verwenden wir einen ähnlichen Ansatz, indem wir die Liste gleichzeitig mit einem langsamen Iterator und einem schnellen Iterator (2x Geschwindigkeit) durchlaufen. Sobald beide Iteratoren in eine Schleife eingetreten sind, werden sie sich irgendwann treffen.

Treffen sich also die beiden Iteratoren zu irgendeinem Zeitpunkt, so können wir daraus schließen, dass wir auf einen Zyklus gestoßen sind:

public static  CycleDetectionResult detectCycle(Node head) {
    if (head == null) {
        return new CycleDetectionResult<>(false, null);
    }

    Node slow = head;
    Node fast = head;

    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;

        if (slow == fast) {
            return new CycleDetectionResult<>(true, fast);
        }
    }

    return new CycleDetectionResult<>(false, null);
}

WennCycleDetectionResult eine Convenience-Klasse ist, die das Ergebnis enthält: eineboolean-Variable, die angibt, ob ein Zyklus vorhanden ist oder nicht, und falls vorhanden, enthält diese auch einen Verweis auf den Treffpunkt innerhalb des Zyklus:

public class CycleDetectionResult {
    boolean cycleExists;
    Node node;
}

Diese Methode wird auch als "The Tortoise and The Hare" -Algorithmus für "Floyd's Cycle-Finding-Algorithmus" bezeichnet.

3. Entfernen von Zyklen aus einer Liste

Schauen wir uns einige Methoden zum Entfernen von Zyklen an. All these methods assume that the ‘Flyods Cycle-Finding Algorithm' was used for cycle detection and build on top of it.

3.1. Rohe Gewalt

Sobald sich der schnelle und der langsame Iterator an einem Punkt im Zyklus treffen, nehmen wir einen weiteren Iterator (sagen wirptr) und zeigen ihn auf den Kopf der Liste. Wir beginnen die Liste mit ptr zu iterieren. Bei jedem Schritt prüfen wir, obptr vom Treffpunkt aus erreichbar ist.

Dies endet, wennptr den Anfang der Schleife erreicht, da dies der erste Punkt ist, an dem es in die Schleife eintritt und vom Treffpunkt aus erreichbar wird.

Sobald der Beginn der Schleife (bg) erkannt wurde, ist es trivial, das Ende des Zyklus zu finden (Knoten, dessen nächstes Feld aufbg zeigt). Der nächste Zeiger dieses Endknotens wird dann aufnull gesetzt, um den Zyklus zu entfernen:

public class CycleRemovalBruteForce {
    private static  void removeCycle(
      Node loopNodeParam, Node head) {
        Node it = head;

        while (it != null) {
            if (isNodeReachableFromLoopNode(it, loopNodeParam)) {
                Node loopStart = it;
                findEndNodeAndBreakCycle(loopStart);
                break;
            }
            it = it.next;
        }
    }

    private static  boolean isNodeReachableFromLoopNode(
      Node it, Node loopNodeParam) {
        Node loopNode = loopNodeParam;

        do {
            if (it == loopNode) {
                return true;
            }
            loopNode = loopNode.next;
        } while (loopNode.next != loopNodeParam);

        return false;
    }

    private static  void findEndNodeAndBreakCycle(
      Node loopStartParam) {
        Node loopStart = loopStartParam;

        while (loopStart.next != loopStartParam) {
            loopStart = loopStart.next;
        }

        loopStart.next = null;
    }
}

Leider funktioniert dieser Algorithmus auch bei großen Listen und großen Zyklen schlecht, da wir den Zyklus mehrmals durchlaufen müssen.

3.2. Optimierte Lösung - Zählen der Schleifenknoten

Definieren wir zunächst einige Variablen:

  • n = die Größe der Liste

  • k = der Abstand vom Kopf der Liste bis zum Beginn des Zyklus

  • l = die Größe des Zyklus

Wir haben die folgende Beziehung zwischen diesen Variablen:k + l = n

Wir nutzen diese Beziehung in diesem Ansatz. Insbesondere wenn ein Iterator, der am Anfang der Liste beginnt, bereitslKnoten zurückgelegt hat, muss erkmehr Knoten zurücklegen, um das Ende der Liste zu erreichen.

Hier ist die Übersicht des Algorithmus:

  1. Sobald schnelle und langsame Iteratoren zusammentreffen, ermitteln Sie die Länge des Zyklus. Dies kann erreicht werden, indem einer der Iteratoren an Ort und Stelle gehalten wird, während der andere Iterator (Iteration mit normaler Geschwindigkeit, eins nach dem anderen) fortgesetzt wird, bis er den ersten Zeiger erreicht, wobei die Anzahl der besuchten Knoten beibehalten wird. Dies zählt alsl

  2. Nehmen Sie am Anfang der Liste zwei Iteratoren (ptr1 undptr2). Verschieben Sie einen der Schritte des Iterators (ptr2)l

  3. Iterieren Sie nun beide Iteratoren, bis sie sich am Anfang der Schleife treffen. Suchen Sie anschließend das Ende des Zyklus und zeigen Sie aufnull

Dies funktioniert, weilptr1k Schritte von der Schleife entfernt ist undptr2,, das uml Schritte vorgerückt ist, auchk Schritte benötigt, um das Ende von zu erreichen die Schleife (n – l = k).

Und hier ist eine einfache, mögliche Implementierung:

public class CycleRemovalByCountingLoopNodes {
    private static  void removeCycle(
      Node loopNodeParam, Node head) {
        int cycleLength = calculateCycleLength(loopNodeParam);
        Node cycleLengthAdvancedIterator = head;
        Node it = head;

        for (int i = 0; i < cycleLength; i++) {
            cycleLengthAdvancedIterator
              = cycleLengthAdvancedIterator.next;
        }

        while (it.next != cycleLengthAdvancedIterator.next) {
            it = it.next;
            cycleLengthAdvancedIterator
              = cycleLengthAdvancedIterator.next;
        }

        cycleLengthAdvancedIterator.next = null;
    }

    private static  int calculateCycleLength(
      Node loopNodeParam) {
        Node loopNode = loopNodeParam;
        int length = 1;

        while (loopNode.next != loopNodeParam) {
            length++;
            loopNode = loopNode.next;
        }

        return length;
    }
}

Als nächstes konzentrieren wir uns auf eine Methode, mit der wir sogar den Schritt der Berechnung der Schleifenlänge eliminieren können.

3.3. Optimierte Lösung - ohne die Schleifenknoten zu zählen

Vergleichen wir die von den schnellen und langsamen Zeigern zurückgelegten Entfernungen mathematisch.

Dafür brauchen wir noch ein paar Variablen:

  • y = Abstand des Punktes, an dem sich die beiden Iteratoren treffen, vom Beginn des Zyklus an gesehen

  • z = Abstand des Punktes, an dem sich die beiden Iteratoren treffen, vom Ende des Zyklus aus gesehen (dies entspricht auchl – y)

  • m = Häufigkeit, mit der der schnelle Iterator den Zyklus abgeschlossen hat, bevor der langsame Iterator in den Zyklus eintritt

Unter Beibehaltung der anderen im vorherigen Abschnitt definierten Variablen werden die Abstandsgleichungen wie folgt definiert:

  • Mit dem langsamen Zeiger zurückgelegte Strecke =k (Entfernung des Zyklus vom Kopf) +y (Treffpunkt innerhalb des Zyklus)

  • Vom schnellen Zeiger zurückgelegte Strecke =k (Entfernung des Zyklus vom Kopf) +m (nicht oft hat der schnelle Zeiger den Zyklus abgeschlossen, bevor der langsame Zeiger eintritt) *l (Zykluslänge) +y (Treffpunkt innerhalb des Zyklus)

Wir wissen, dass die Entfernung, die der schnelle Zeiger zurücklegt, doppelt so groß ist wie die des langsamen Zeigers.

k + m * l + y = 2 * (k + y)

was auswertet zu:

y = m * l - k

Das Subtrahieren beider Seiten vonl ergibt:

l - y = l - m * l + k

oder äquivalent:

k = (m - 1) * l + z (wobei l - y wie oben definiert z ist)

Dies führt zu:

k = (m - 1) Vollschleifenläufe + Ein zusätzlicher Abstand z

Mit anderen Worten, wenn wir einen Iterator am Anfang der Liste und einen Iterator am Treffpunkt behalten und sie mit der gleichen Geschwindigkeit verschieben, schließt der zweite Iteratorm – 1 Zyklen um die Schleife ab und erfüllt die erster Zeiger zu Beginn des Zyklus. Mit dieser Einsicht können wir den Algorithmus formulieren:

  1. Verwenden Sie den Floyd's Cycle-Finding-Algorithmus, um die Schleife zu erkennen. Wenn eine Schleife vorhanden ist, endet dieser Algorithmus an einem Punkt innerhalb der Schleife (nennen Sie dies den Treffpunkt).

  2. Nehmen Sie zwei Iteratoren, einen am Anfang der Liste (it1) und einen am Treffpunkt (it2).

  3. Durchlaufen Sie beide Iteratoren mit der gleichen Geschwindigkeit

  4. Da der Abstand der Schleife vom Kopf k beträgt (wie oben definiert), würde der vom Kopf gestartete Iterator den Zyklus nachk Schritten erreichen

  5. Ink Schritten würde der Iteratorit2m – 1 Zyklen der Schleife und einen zusätzlichen Abstandz. durchlaufen, da sich dieser Zeiger bereits in einem Abstand vonz von dem befand Der Beginn des Zyklus, wenn dieser zusätzliche Abstandz zurückgelegt wird, würde ihn auch zu Beginn des Zyklus bringen

  6. Beide Iteratoren treffen sich zu Beginn des Zyklus. Anschließend können wir das Ende des Zyklus finden und aufnull zeigen

Dies kann implementiert werden:

public class CycleRemovalWithoutCountingLoopNodes {
    private static  void removeCycle(
      Node meetingPointParam, Node head) {
        Node loopNode = meetingPointParam;
        Node it = head;

        while (loopNode.next != it.next) {
            it = it.next;
            loopNode = loopNode.next;
        }

        loopNode.next = null;
    }
}

Dies ist der am besten optimierte Ansatz zum Erkennen und Entfernen von Zyklen aus einer verknüpften Liste.

4. Fazit

In diesem Artikel haben wir verschiedene Algorithmen zur Erkennung eines Zyklus in einer Liste beschrieben. Wir untersuchten Algorithmen mit unterschiedlichen Anforderungen an Rechenzeit und Speicherplatz.

Schließlich haben wir auch drei Methoden gezeigt, um einen Zyklus zu entfernen, sobald er mithilfe des Flyods Cycle-Finding-Algorithmus erkannt wurde.

Das vollständige Codebeispiel ist inover on Github verfügbar.