Tester une liste chaînée pour la cyclicité

Tester une liste chaînée pour la cyclicité

1. introduction

Une liste à liaison unique est une séquence de nœuds connectés se terminant par une référencenull. Toutefois, dans certains scénarios, le dernier nœud peut pointer sur un nœud précédent, ce qui crée effectivement un cycle.

Dans la plupart des cas, nous voulons pouvoir détecter et connaître ces cycles; Cet article se concentrera sur exactement cela - détecter et potentiellement supprimer des cycles.

2. Détection d'un cycle

Explorons maintenant quelques algorithmes pour détecter les cycles dans les listes liées.

2.1. Force brute - Complexité temporelle O (n ^ 2)

Avec cet algorithme, nous parcourons la liste en utilisant deux boucles imbriquées. Dans la boucle externe, nous traversons un par un. Dans la boucle interne, nous partons de la tête et traversons autant de nœuds que la boucle externe l'a traversée à ce moment-là.

If a node that is visited by the outer loop is visited twice by the inner loop, then a cycle has been detected. A l'inverse, si la boucle externe atteint la fin de la liste, cela implique une absence de cycles:

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;
}

L'avantage de cette approche est qu'elle nécessite une quantité de mémoire constante. L'inconvénient est que les performances sont très lentes lorsque de grandes listes sont fournies en entrée.

2.2. Hashing - Complexité de l'espace O (n)

Avec cet algorithme, nous maintenons un ensemble de nœuds déjà visités. Pour chaque nœud, nous vérifions s'il existe dans l'ensemble. Sinon, nous l'ajoutons à l'ensemble. L'existence d'un nœud dans l'ensemble signifie que nous avons déjà visité le nœud et fait apparaître la présence d'un cycle dans la liste.

Lorsque nous rencontrons un nœud qui existe déjà dans l’ensemble, nous avons découvert le début du cycle. Après avoir découvert cela, nous pouvons facilement interrompre le cycle en définissant le champnext du nœud précédent surnull, comme illustré ci-dessous:

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;
}

Dans cette solution, nous avons visité et stocké chaque noeud une fois. Cela équivaut à une complexité temporelle en O (n) et à une complexité en espace O (n), qui, en moyenne, n'est pas optimale pour les grandes listes.

2.3. Pointeurs rapides et lents

L'algorithme suivant pour trouver des cycles peut être mieux expliquéusing a metaphor.

Considérons une piste de course où deux personnes courent. Étant donné que la vitesse de la deuxième personne est le double de celle de la première personne, la deuxième personne fera le tour de la piste deux fois plus vite que la première et rencontrera à nouveau la première personne au début du tour.

Ici, nous utilisons une approche similaire en parcourant la liste simultanément avec un itérateur lent et un itérateur rapide (vitesse 2x). Une fois que les deux itérateurs sont entrés dans une boucle, ils se rencontreront éventuellement en un point.

Par conséquent, si les deux itérateurs se rencontrent à un moment quelconque, nous pouvons alors conclure que nous sommes tombés sur un cycle:

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);
}

CycleDetectionResult est une classe de commodité pour contenir le résultat: une variableboolean qui dit si le cycle existe ou non et s'il existe, alors cela contient également une référence au point de rencontre à l'intérieur du cycle:

public class CycleDetectionResult {
    boolean cycleExists;
    Node node;
}

Cette méthode est également connue sous le nom d’algorithme «The Tortoise and The Hare Algorithm» pour «Algorithme de recherche de cycle de Floyd».

3. Suppression de cycles d'une liste

Jetons un œil à quelques méthodes pour supprimer les cycles. All these methods assume that the ‘Flyods Cycle-Finding Algorithm' was used for cycle detection and build on top of it.

3.1. Force brute

Une fois que les itérateurs rapides et lents se rencontrent à un moment donné du cycle, nous prenons un itérateur supplémentaire (disonsptr) et le pointons vers la tête de la liste. Nous commençons à itérer la liste avec ptr. A chaque étape, nous vérifions siptr est joignable depuis le point de rencontre.

Cela se termine lorsqueptr atteint le début de la boucle car c'est le premier point quand il entre dans la boucle et devient accessible à partir du point de rencontre.

Une fois le début de la boucle (bg) découvert, alors il est trivial de trouver la fin du cycle (nœud dont le champ suivant pointe versbg). Le pointeur suivant de ce nœud d'extrémité est alors mis ànull pour supprimer le cycle:

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;
    }
}

Malheureusement, cet algorithme fonctionne également mal en cas de grandes listes et de grands cycles, car nous devons parcourir le cycle plusieurs fois.

3.2. Solution optimisée - Comptage des nœuds de boucle

Définissons d'abord quelques variables:

  • n = la taille de la liste

  • k = la distance entre la tête de liste et le début du cycle

  • l = la taille du cycle

Nous avons la relation suivante entre ces variables:k + l = n

Nous utilisons cette relation dans cette approche. Plus particulièrement, lorsqu'un itérateur qui commence depuis le début de la liste, a déjà parcourul nœuds, alors il doit parcourirk autres nœuds pour atteindre la fin de la liste.

Voici le plan de l'algorithme:

  1. Une fois que les itérateurs lents se sont rencontrés rapidement, trouvez la durée du cycle. Cela peut être fait en gardant l'un des itérateurs en place tout en continuant avec l'autre itérateur (itérant à une vitesse normale, un par un) jusqu'à atteindre le premier pointeur, en conservant le nombre de nœuds visités. Cela compte pourl

  2. Prenez deux itérateurs (ptr1 etptr2) au début de la liste. Déplacer l’une des étapes de l’itérateur (ptr2)l

  3. Maintenant, itérez les deux itérateurs jusqu'à ce qu'ils se rencontrent au début de la boucle, puis recherchez la fin du cycle et pointez-le surnull

Cela fonctionne carptr1 est àk pas de la boucle, etptr2, qui est avancé del étapes, a également besoin dek étapes pour atteindre la fin de la boucle (n – l = k).

Et voici une implémentation simple et potentielle:

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;
    }
}

Ensuite, concentrons-nous sur une méthode dans laquelle nous pouvons même éliminer l'étape de calcul de la longueur de la boucle.

3.3. Solution optimisée - sans compter les nœuds de boucle

Comparons mathématiquement les distances parcourues par les pointeurs rapides et lents.

Pour cela, nous avons besoin de quelques variables supplémentaires:

  • y = distance du point de rencontre des deux itérateurs, vue depuis le début du cycle

  • z = distance du point de rencontre des deux itérateurs, vue de la fin du cycle (elle est également égale àl – y)

  • m = nombre de fois que l'itérateur rapide a terminé le cycle avant que l'itérateur lent entre dans le cycle

En gardant les autres variables identiques à celles définies dans la section précédente, les équations de distance seront définies comme suit:

  • Distance parcourue par le pointeur lent =k (distance du cycle à la tête) +y (point de rencontre à l'intérieur du cycle)

  • Distance parcourue par le pointeur rapide =k (distance du cycle par rapport à la tête) +m (nombre de fois que le pointeur rapide a terminé le cycle avant que le pointeur lent entre) *l (durée du cycle) +y (point de rencontre à l'intérieur du cycle)

Nous savons que la distance parcourue par le pointeur rapide est le double de celle du pointeur lent, d'où:

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

qui évalue à:

y = m * l - k

La soustraction des deux côtés del donne:

l - y = l - m * l + k

ou équivalent:

k = (m - 1) * l + z (où, l - y est z comme défini ci-dessus)

Cela mène à:

k = (m - 1) Boucle complète + Une distance supplémentaire z

En d'autres termes, si nous gardons un itérateur en tête de la liste et un itérateur au point de rencontre, et les déplaçons à la même vitesse, alors, le deuxième itérateur effectueram – 1 cycles autour de la boucle et rencontrera le premier pointeur au début du cycle. En utilisant cette idée, nous pouvons formuler l'algorithme:

  1. Utilisez l’algorithme de recherche de cycle de Floyd pour détecter la boucle. Si la boucle existe, cet algorithme se terminerait en un point de la boucle (appelez ceci le point de rencontre)

  2. Prenez deux itérateurs, un en tête de liste (it1) et un au point de rencontre (it2)

  3. Traverser les deux itérateurs à la même vitesse

  4. Puisque la distance de la boucle à la tête est k (comme défini ci-dessus), l'itérateur démarré à partir de la tête atteindrait le cycle aprèsk pas

  5. Dans les pas dek, l'itérateurit2 traverseraitm – 1 cycles de la boucle et une distance supplémentairez. puisque ce pointeur était déjà à une distance dez du début du cycle, en parcourant cette distance supplémentairez, l'amènerait également au début du cycle

  6. Les deux itérateurs se rencontrent au début du cycle, par la suite, nous pouvons trouver la fin du cycle et la pointer versnull

Cela peut être mis en œuvre:

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;
    }
}

C'est l'approche la plus optimisée pour la détection et la suppression des cycles d'une liste chaînée.

4. Conclusion

Dans cet article, nous avons décrit divers algorithmes permettant de détecter un cycle dans une liste. Nous avons étudié des algorithmes avec différents besoins en temps de calcul et en espace mémoire.

Enfin, nous avons également montré trois méthodes pour supprimer un cycle, une fois qu’il est détecté à l’aide de l’algorithme de recherche de cycle Flyods.

L'exemple de code complet est disponibleover on Github.