リンクリストの周期性をテストする
1. 前書き
単一リンクリストは、null参照で終わる接続ノードのシーケンスです。 ただし、一部のシナリオでは、最後のノードが前のノードを指している可能性があり、事実上サイクルを作成しています。
ほとんどの場合、これらのサイクルを検出して認識できるようにしたいと考えています。この記事では、まさにそのことに焦点を当てます–サイクルを検出し、潜在的に削除します。
2. サイクルの検出
次に、リンクリストのサイクルを検出するためのいくつかのアルゴリズムを調べてみましょう。
2.1. ブルートフォース– O(n ^ 2)時間計算量
このアルゴリズムでは、2つのネストされたループを使用してリストを走査します。 外側のループでは、1つずつ走査します。 内側のループでは、先頭から開始し、その時点までに外側のループが通過したノードと同じ数だけノードを走査します。
If a node that is visited by the outer loop is visited twice by the inner loop, then a cycle has been detected.逆に、外側のループがリストの最後に達した場合、これはサイクルがないことを意味します。
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;
}
このアプローチの利点は、一定量のメモリを必要とすることです。 欠点は、大きなリストが入力として提供される場合、パフォーマンスが非常に遅くなることです。
2.2. ハッシュ– O(n)スペースの複雑さ
このアルゴリズムを使用して、すでにアクセスしたノードのセットを維持します。 各ノードについて、セットに存在するかどうかを確認します。 そうでない場合は、セットに追加します。 セット内にノードが存在するということは、そのノードに既にアクセスしており、リスト内にサイクルが存在することを示しています。
セットにすでに存在するノードに遭遇したとき、サイクルの始まりを発見しました。 これを発見した後、以下に示すように、前のノードのnextフィールドをnullに設定することで、サイクルを簡単に中断できます。
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;
}
このソリューションでは、各ノードを一度訪れて保存しました。 これは、O(n)時間の複雑さとO(n)スペースの複雑さになり、平均して、大きなリストには最適ではありません。
2.3. 高速および低速ポインタ
サイクルを見つけるための次のアルゴリズムは、using a metaphorで最もよく説明できます。
2人がレースをしているレーストラックを考えてみましょう。 2人目の速度が1人目の速度の2倍であることを考えると、2人目は最初の人の2倍の速さでトラックを回り、ラップの開始時に再び1人目に会います。
ここでは、遅いイテレータと速いイテレータ(2倍速)を同時にリスト内で繰り返すことにより、同様のアプローチを使用します。 両方の反復子がループに入ると、最終的にはある時点で会います。
したがって、2つのイテレーターが任意の時点で一致する場合、サイクルにつまずいたと結論付けることができます。
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は、結果を保持するための便利なクラスです。サイクルが存在するかどうかを示すboolean変数であり、存在する場合は、サイクル内のミーティングポイントへの参照も含まれます。
public class CycleDetectionResult {
boolean cycleExists;
Node node;
}
この方法は、「Floyd's Cycle-Finding Algorithm」の「The Tortoise and The Hare Algorithm」としても知られています。
3. リストからのサイクルの削除
サイクルを削除するためのいくつかの方法を見てみましょう。 All these methods assume that the ‘Flyods Cycle-Finding Algorithm' was used for cycle detection and build on top of it.
3.1. 強引な
サイクルのある時点で高速イテレータと低速イテレータが出会ったら、もう1つのイテレータ(たとえばptr)を取得して、リストの先頭にポイントします。 ptrでリストの反復を開始します。 各ステップで、ミーティングポイントからptrに到達できるかどうかを確認します。
これは、ptrがループの先頭に到達すると終了します。これは、ptrがループに入り、ミーティングポイントから到達可能になる最初のポイントであるためです。
ループの開始(bg)が検出されると、サイクルの終了(次のフィールドがbgを指すノード)を見つけるのは簡単です。 次に、このエンドノードの次のポインタをnullに設定して、サイクルを削除します。
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;
}
}
残念ながら、このアルゴリズムは、大きなリストや大きなサイクルの場合にもパフォーマンスが低下します。これは、サイクルを複数回トラバースする必要があるためです。
3.2. 最適化されたソリューション–ループノードのカウント
最初にいくつかの変数を定義しましょう。
-
n =リストのサイズ
-
k =リストの先頭からサイクルの開始までの距離
-
l =サイクルのサイズ
これらの変数の間には次の関係があります:k + l = n
このアプローチでは、この関係を利用します。 より具体的には、リストの先頭から開始するイテレータがすでにlノードを移動した場合、リストの最後に到達するには、k多くのノードを移動する必要があります。
アルゴリズムの概要は次のとおりです。
-
速いイテレータと遅いイテレータが出会ったら、サイクルの長さを見つけます。 これは、最初のポインタに到達するまで他のイテレータを(通常の速度で1つずつ)続けながら、一方のイテレータを所定の位置に保持し、アクセスしたノードの数を保持することで実行できます。 これはlとしてカウントされます
-
リストの先頭にある2つのイテレータ(ptr1とptr2)を使用します。 イテレータの1つを移動します(ptr2)lステップ
-
次に、ループの開始時に出会うまで両方のイテレータを反復し、その後、サイクルの終了を見つけて、nullをポイントします。
これが機能するのは、ptr1がループからkステップ離れており、lステップ進んだptr2,も、最後に到達するためにkステップが必要だからです。ループ(n – l = k)。
そして、これが単純で潜在的な実装です。
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;
}
}
次に、ループ長を計算するステップを排除することさえできる方法に焦点を当てましょう。
3.3. 最適化されたソリューション–ループノードをカウントせずに
高速ポインタと低速ポインタが移動した距離を数学的に比較してみましょう。
そのためには、さらにいくつかの変数が必要です。
-
y =サイクルの最初から見た、2つのイテレータが出会うポイントの距離
-
z =サイクルの終わりから見た、2つのイテレータが出会うポイントの距離(これもl – yに等しい)
-
m =低速イテレータがサイクルに入る前に、高速イテレータがサイクルを完了した回数
他の変数を前のセクションで定義したものと同じに保つと、距離方程式は次のように定義されます。
-
スローポインターの移動距離=k(頭からのサイクルの距離)+y(サイクル内の合流点)
-
高速ポインターが移動した距離=k(ヘッドからのサイクルの距離)+m(低速ポインターが入る前に高速ポインターがサイクルを完了した回数)*l(サイクル長)+y(サイクル内のミーティングポイント)
高速ポインターの移動距離は低速ポインターの2倍であることがわかっています。したがって、
k + m * l + y = 2 *(k + y)
次のように評価されます。
y = m * l – k
lから両側を引くと、次のようになります。
l – y = l – m * l + k
または同等に:
k =(m – 1)* l + z(ここで、l – yは上記で定義されたzです)
これはにつながります:
k =(m – 1)完全なループ実行+追加の距離z
つまり、リストの先頭に1つのイテレータを保持し、ミーティングポイントに1つのイテレータを保持し、それらを同じ速度で移動すると、2番目のイテレータはループの周りでm – 1サイクルを完了し、サイクル開始時の最初のポインター。 この洞察を使用して、アルゴリズムを定式化できます。
-
「Floyd's Cycle-Finding Algorithm」を使用してループを検出します。 ループが存在する場合、このアルゴリズムはループ内のポイントで終了します(これをミーティングポイントと呼びます)
-
リストの先頭(it1)とミーティングポイント(it2)の2つのイテレータを使用します。
-
両方のイテレーターを同じ速度でトラバースします
-
ヘッドからのループの距離はk(上記で定義)であるため、ヘッドから開始されたイテレータは、kステップ後にサイクルに到達します。
-
kステップでは、イテレータit2はループのm – 1サイクルと余分な距離z.をトラバースします。このポインタはすでにzの距離にあるためです。サイクルの開始時に、この余分な距離zを移動すると、サイクルの開始時にも移動します。
-
両方のイテレータはサイクルの開始時に会合し、その後、サイクルの終了を見つけてnullを指すことができます。
これは実装できます:
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;
}
}
これは、リンクリストからサイクルを検出および削除するための最も最適化されたアプローチです。
4. 結論
この記事では、リスト内のサイクルを検出するためのさまざまなアルゴリズムについて説明しました。 計算時間とメモリスペースの要件が異なるアルゴリズムを検討しました。
最後に、「Flyods Cycle-Finding Algorithm」を使用して検出されたサイクルを削除する3つの方法も示しました。
完全なコード例はover on Githubで入手できます。