JavaScriptでデータ構造を深く理解する - 二重連結リスト
二重連結リストとは何か?
二重連結リストは、連結リストデータ構造の変形です。それは一般的な連結リストの全ての特徴を持っていますが、ノードが前と次への2つのポインタを持っているという追加的な機能があります。この点が単方向連結リストと異なります、単方向連結リストには次のノードを指す一つのポインタしかありません。
この記事では、一部のセクションで単方向連結リストについて参照しているので、あなたが連結リストデータ構造に馴染みがあると想定しています。もし違う場合や連結リストについて速習が必要な場合は、以下のリンクから連結リストに関する記事から始め、後で戻ってここを続けてください。
二重連結リストの構造
二重連結リストは一連の接続されたノードによって構成されます。各ノードには3つのプロパティがあります:
Prev(ポインタ): 前のノードへの参照(ポインタ)です。
Value: ノードの値やデータを保持します。
Next(ポインタ): 次のノードへの参照(ポインタ)です。
単方向連結リストのように、ここでも最初のノードを**「HEAD」、最後のノードを「TAIL」**と言います。ですが、おそらくヘッドノードについて少し違いに気付くでしょう - 一部はビジュアルでnullを指しています。これは、ヘッドが常に最初のノードであるため、他の前のノードがリストに存在しないためです。そのため、ヘッドノードの前のポインタは常にnullを指します。
いつ二重連結リストを使用すべきか、また使用しないべきか
あなたが特に二重連結リストの使用を検討している場面に出くわした場合、あなたは既に連結リストを使用すると決めており、単方向連結リストか二重連結リストのいずれかを使うか比較しているでしょう。次のセクションでこれら2つを比較していきますが、まず二重連結リストにおける一般的な操作のビッグOをぱっと見てみましょう。
二重連結リスト vs 単方向連結リスト
別のデータ構造やその異なる実装を扱う際、何を選ぶべきかの答えはいつも同じです:"コンテキストに依存します"。より良いアイデアを得るために、それぞれの長所と短所を見ていきましょう。
単方向連結リスト
長所:
- 実装が二重連結リストに比べてシンプルで直接的です。
- 各ノードにシングルポインタを持っているため、メモリが少なくてすみます。
- 各ノードで扱うポインタが一つだけなので、メソッド内の操作が少なくてすみます。
- メソッドでの操作が少ないため、二重連結リストよりもわずかに動作が速いです。
短所:
- ポインタが次のノードをターゲットにしているので、逆方向にはたどれません。
- ヘッドノードが正しく維持されていなかったり、何らかの理由で失われた場合、メモリ上のリストの残りの部分も失われます。
単方向連結リストを使用する場合:
- メモリが少なく高価な場合。
- 高速な挿入と削除が主な目標で、頻繁にトラバーサルを行う必要がない場合。
二重連結リスト
長所:
- トラバーサル能力が向上し、両方向(前方または後方)にたどることができます。
- deleteTail() メソッドが高速です。単方向連結リストでは、テールを削除するにはリスト全体をたどる必要があり、この操作には O(n) の線形時間がかかります。二重連結リストでは、テールノードの前のポインタを単純に使用できるので、O(1) の定数時間がかかります。
短所:
- メソッド内で2つのポインタを扱う必要があるため、単方向連結リストに比べて実装がやや複雑です。
- 2つのポインタを保持しているため、より多くのメモリスペースを必要とします。
- 各メソッド内でポインタに対する操作が多いため、単方向連結リストよりもわずかに遅いです。
二重連結リストを使用する場合:
- メモリの問題がない場合。
- リスト内の要素をたどったり検索したい場合、後方にたどれる能力がトラバーサルパフォーマンスの最適化に優位を与えます。
JavaScriptでの二重連結リストの実装
単方向連結リストの実装と同様に、ここでもES6のクラスを使用してこのデータ構造を構築します。お好きなコードエディタを開いて、私たちが手順を追うときに一緒に進めてみてください。
手順1 - 二重連結リストノードのクラスを作成する
まずはノード要素のクラスを特定しましょう。これは新しいノードを作る必要がある時に使います。
class Node {
constructor(value) {
this.value = value;
this.next = null;
this.prev = null;
}
}
// 新しいノードを作成します:
const newNode = new Node(10);
console.log(newNode);
/* newNode の出力:
Node {
value: 10,
next: null,
prev: null,
}
*/
手順2 - 二重連結リストのクラスを作成する
さらに進んで二重連結リストのクラスを作成しましょう。headとtailプロパティが必要だとわかっています。使いやすさのために、リストの長さを追跡するためのlengthプロパティを追加することもできます。
また、空で始めるか一つのスターター値で始めるかを選択できるようにコンストラクタを持っていると良いでしょう。次の手順ではappendメソッドを扱います。
class DoublyLinkedList {
constructor(value) {
this.head = null;
this.tail = null;
this.length = 0;
// スターター値があるかどうかで二重連結リストを作成するかどうかを選択する
if (value) {
this.append(value);
}
}
}
const doublyLinkedList = new DoublyLinkedList();
console.log(doublyLinkedList);
/* doublyLinkedList の初期化時の出力(空のスターター):
DoublyLinkedList {
head: null,
tail: null,
length: 0
}
*/
この時点で、基本構造であるNode
クラスとDoublyLinkedList
クラスが完成しました。これで、DoublyLinkedList
クラスを一般的なメソッドで拡張し続けることができます。これらのメソッドを理解しやすくするために、特定の場所にコードコメントを置いています。
実装するメソッドのリストは次のとおりです:
append(value)
- 最後に追加prepend(value)
- 最初に追加toArray()
- デバッグを簡単にするために二重連結リストの要素を配列で返すtraverseToIndex(index)
- トラバーサルヘルパーinsert(index, value)
- 中間に追加deleteHead()
- 最初から削除deleteTail()
- 最後から削除delete(index)
- 中間から削除reverse()
- 項目の順序を逆にする
手順3 - 二重連結リストのappendメソッド
リストの最後に追加する方法です。
append(value) {
// 受け取った値で新しいノードを初期化します
const newNode = new Node(value);
// まず二重連結リストが空かどうかを確認しましょう。
if (!this.head) {
// ヘッドがない場合(要素なし)、それは空です。その場合、newNodeをヘッドとして設定します。
// この時点で唯一のノードですし、テールもありませんので、
// テールは同じ値を持ち(ヘッドとテールはこれから同じメモリ位置を指します):
this.head = newNode;
this.tail = newNode;
} else {
// newNodeが新しいテールになるので、変更を適用する前に現在のテールのprev値を設定します。タイミングが重要です!
newNode.prev = this.tail;
// 最初の入力時に this.tail = this.head が設定されます
// 最初に this.tail.next に newNode を入れると、両方が同じオブジェクトを参照しているため、この段階でヘッドとテールが等しく見えます:
this.tail.next = newNode;
// この段階で、新しいノードをテールとして設定することで、テールをクリーンアップします。言い換えれば、テールを使ってヘッドを拡張し、その後新しいノードを使ってテールをクリーンアップしました。
this.tail = newNode;
}
this.length++;
return this;
}
手順4 - 二重連結リストのprependメソッド
リストの始めに追加する方法です。
prepend(value) {
// まず二重連結リストが空かどうか確認しましょう。
// もしそうなら、代わりにappendメソッドを使ってここでリターンします。
if (!this.head) {
return this.append(value);
}
// 受け取った値で新しいノードを初期化します
const newNode = new Node(value);
// newNode.next プロパティに参照を適用します。最初に追加するとき、自然に前に追加されたノードのnext値は this.head を指すべきです。
newNode.next = this.head;
// newNodeが新しいヘッドの前になるので、ヘッドのprev値をnewNodeに設定します。このポインタのthis.headをnewNodeに変更する前に行います。タイミングが重要です!
this.head.prev = newNode;
// newNodeにthis.headがnextとして、そしてprevとしてnewNodeがあるので、直接this.headをnewNodeに設定できます。
this.head = newNode;
this.length++;
return this;
}
手順5 - 二重連結リストのtoArrayメソッド(オプション)
リスト内の進行状況を簡単にデバッグするため(または二重連結リストを配列として出力するオプションが必要な場合)toArrayメソッドが必要です。
toArray() {
const array = [];
let currentNode = this.head;
while (currentNode !== null) {
array.push(currentNode.value);
currentNode = currentNode.next;
}
return array;
}
手順6 - 二重連結リストのtraverseToIndexメソッド(ヘルパー)
挿入や削除に関連するメソッドは特定のインデックスにたどり着くためのトラバーサルを扱わなければならないので、そのためのヘルパーを実装するのが賢明です。
traverseToIndex(index) {
// 受け取ったindexパラメータを検証します:
if (!index) return 'Index is missing';
if (typeof index !== 'number') return 'Index should be a number';
let counter = 0;
let currentNode = this.head;
while (counter !== index) {
currentNode = currentNode.next;
counter++;
}
return currentNode;
}
手順7 - 二重連結リストのinsertメソッド
特定のインデックスに挿入する方法です。
insert(index, value) {
// 受け取ったindexパラメータを検証します:
if (!index) return 'Index is missing';
if (typeof index !== 'number') return 'Index should be a number';
// もしインデックスが長すぎる場合、またはヘッドがない場合は、単に末尾に追加します(最後に追加)。
if (index >= this.length || !this.head) {
return this.append(value);
}
// もしインデックスが0なら、ただ先頭に追加します(最初に追加)。
if (index === 0) {
return this.prepend(value);
}
// 受け取った値で新しいノードを初期化します
const newNode = new Node(value);
/*
解決策の流れ:
1 - ターゲットidxの前のインデックスノードを選びます。
2 - preIdx.nextポインタを使ってターゲットidxノードを選びます。
3 - 新しいノードに指すように前<br><br>こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。<br>[https://dev.to/humblecoder00/deep-dive-into-data-structures-using-javascript-doubly-linked-list-2ddi](https://dev.to/humblecoder00/deep-dive-into-data-structures-using-javascript-doubly-linked-list-2ddi)