コーディング · データ構造
連結リストとは?仕組み・コード・計算量をわかりやすく解説
結論
連結リスト(リンクリスト)は、各ノードが値と次のノードへの参照(ポインタ)を持つノードの並びです。配列と違い、ノードはメモリ上で隣り合って置かれないため、位置が分かっていればノードの挿入・削除はO(1)で行えますが、k番目の要素を探すには先頭から順にたどる必要があり、O(n)かかります。双方向連結リストは、前のノードへのポインタも持ちます。
連結リストが解決する問題
配列(Pythonのlist)は要素を連続したメモリ領域に置きます。a[i]の読み取りは一瞬ですが、先頭への挿入では他のすべての要素を1つ右にずらす必要があり、O(n)かかります。連結リストではこのずらしが不要です。各要素が独立したノードに入っていて、ノード同士は参照でつながっているため、挿入時に書き換えるのは数個の参照だけです。
連結リストは、スタック、キュー、ハッシュテーブルのバケット(チェイン法)、LRUキャッシュ、グラフの隣接リストなど、多くのデータ構造の土台になっています。
イメージで理解する
宝探しゲームを思い浮かべてください。各ヒントは次のヒントの場所を教えてくれます。7番目のヒントに直接ジャンプすることはできず、1番から6番まで順にたどる必要があります。リスト自身が覚えているのは最初のノード(先頭、head)だけで、最後のノードは何も指しません(None)。
| 種類 | 各ノードが持つもの | 主な用途 |
|---|---|---|
| 単方向連結リスト | 値、next | スタック、単純なキュー、ハッシュのバケット |
| 双方向連結リスト | 値、next、prev | デック、LRUキャッシュ、テキストエディタのバッファ |
| 循環連結リスト | 単方向または双方向と同じだが、末尾が先頭を指す | ラウンドロビン方式のスケジューリング、リングバッファ |
具体例:挿入と削除
3 -> 7 -> 9というリストから始めます。3の後ろに5を挿入し、その後7を削除します。
- 値5を持つ新しいノードを作ります。
new.nextを、3の次にあるノード(7のノード)に設定します。node3.nextを新しいノードに設定します。これでリストは3 -> 5 -> 7 -> 9になります。- 7を削除するには、先頭からたどって、その前のノード(5)を見つけます。
node5.next = node5.next.nextと設定すると、7が飛ばされます。リストは3 -> 5 -> 9になります。到達できなくなったノードは、Pythonのガベージコレクタが解放します。
単方向連結リストのPython実装
このクラスは、先頭への参照とサイズのカウンタを持ちます。先頭・末尾への追加、最初に一致した値の削除、イテレーションに対応しています。
class Node:
def __init__(self, value, next=None):
self.value = value
self.next = next
class LinkedList:
def __init__(self):
self.head = None
self.size = 0
def push_front(self, value):
self.head = Node(value, self.head)
self.size += 1
def push_back(self, value):
node = Node(value)
if self.head is None:
self.head = node
else:
cur = self.head
while cur.next:
cur = cur.next
cur.next = node
self.size += 1
def remove(self, value):
prev, cur = None, self.head
while cur and cur.value != value:
prev, cur = cur, cur.next
if cur is None:
return False # not found
if prev is None:
self.head = cur.next # removing the head
else:
prev.next = cur.next
self.size -= 1
return True
def __iter__(self):
cur = self.head
while cur:
yield cur.value
cur = cur.nextitems = LinkedList()
for x in [3, 7, 9]:
items.push_back(x)
items.push_front(1)
items.remove(7)
print(list(items), items.size) # [1, 3, 9] 3push_backはここではリスト全体をたどるため、O(n)です。追加のtail参照を持てばO(1)になり、実際のキュー実装ではそのようにしています。
代表的な操作:反転と循環検出
リストをその場で反転する問題は、連結リストの面接で最もよく出る問題です。リストを1回たどり、各nextポインタの向きを逆にします。
def reverse(head):
prev = None
cur = head
while cur:
nxt = cur.next # remember the rest
cur.next = prev # turn the pointer around
prev, cur = cur, nxt
return prev # new head
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
head = Node(1, Node(2, Node(3)))
head = reverse(head)
print(head.value, head.next.value, head.next.next.value) # 3 2 1
print(has_cycle(head)) # Falsehas_cycleはフロイドの「亀と兎」アルゴリズムです。遅いポインタは1ステップ、速いポインタは2ステップずつ進みます。ループがあれば、速いポインタはいずれ遅いポインタに追いつき、なければNoneに到達します。追加のメモリはO(1)で済みます。
時間計算量と空間計算量
| 操作 | 単方向連結リスト | Pythonのlist(動的配列) | 理由 |
|---|---|---|---|
| k番目の要素へのアクセス | O(n) | O(1) | 連結リストはkノードたどる必要がある |
| 先頭への挿入・削除 | O(1) | O(n) | 配列はすべての要素をずらす |
| 既知のノードの後ろへの挿入・削除 | O(1) | O(n) | 参照を書き換えるだけ |
| 末尾への追加 | tailポインタがあればO(1) | 償却O(1) | どちらも低コスト |
| 値の検索 | O(n) | O(n) | どちらも要素を順に走査する |
| 追加のメモリ | ノードごとに1〜2個の参照 | 余剰の容量 | ノードは別々のオブジェクト |
実際には、連結リストのほうがオーダー(ビッグO)で有利な操作でも、配列のほうが速いことがよくあります。配列の要素はメモリ上で隣り合っていてCPUキャッシュを効率よく使えるのに対し、連結リストのノードは散らばっているためです。
種類と、よくある間違い
- 空のリストを忘れる:
head.nextを読む前に、必ずhead is Noneを確認します。 nextを保存する前に上書きして、リストの残りを失う(reverseのnxtを参照)。- 最初のノードを削除したときに
headを、最後のノードを削除したときにtailを更新し忘れる。 - 非常に長いリストで再帰を使う:Pythonのデフォルトの再帰上限は約1000フレームなので、反復で書くほうが安全です。
- ダミー(番兵)の先頭ノードを置くと、特殊ケースの大半がなくなります。実データのノードがすべて前のノードを持つようになるためです。
- 双方向連結リストでは、挿入・削除のたびに4つの参照を更新する必要があります。1つでも漏れると、気づかないうちにリストが壊れます。
連結リストを使う場面と、Pythonでの選択肢
連結リストが向いているのは、すでに参照を持っている位置で、端や途中への挿入・削除を行い、ランダムアクセスをほとんど必要としない場合です。例として、LRUキャッシュ(双方向連結リストとハッシュマップ)、メモリアロケータのフリーリスト、元に戻す(undo)履歴、ハッシュテーブルのバケットがあります。
普段のPythonコードで自作することはまずありません。キューやデックには、両端でO(1)の追加・取り出しができるcollections.dequeを使います。一般的なシーケンスにはlistを使います。自作の連結リストが役に立つのは、学習や面接、そしてLRUキャッシュのようにノードをO(1)で付け替えたい場合です。
from collections import deque
q = deque([1, 2, 3])
q.appendleft(0)
q.append(4)
print(q.popleft(), q.pop(), list(q)) # 0 4 [1, 2, 3]よくある質問
Pythonのlistは連結リストですか?
いいえ。Pythonのlistは、参照を連続して格納する動的配列なので、インデックスアクセスはO(1)、先頭への挿入はO(n)です。キュー用途では、collections.dequeが組み込みの中で連結リストに最も近いものです。
連結リストでのアクセスがO(n)なのはなぜですか?
ノードは予測できるアドレスに置かれていないためです。k番目のノードに到達する唯一の方法は、先頭から始めてnext参照をk回たどることです。
双方向連結リストが単方向より優れているのはどんなときですか?
ノードへの参照だけで、そのノードを削除したい場合や、後ろ向きに進みたい場合です。prevポインタがあれば、前のノードを探さなくても削除がO(1)で行えます。代償は、ノードごとに参照が1つ増えることです。
連結リストの真ん中を1回の走査で見つけるには?
2つのポインタを使います。1つは1ステップずつ、もう1つは2ステップずつ進めます。速いポインタが末尾に着いたとき、遅いポインタが真ん中にいます。
番兵(センチネル)ノードとは?
最初の実データノードの前(場合によっては最後の後ろ)に置くダミーのノードです。データは持ちませんが、すべての実データノードが隣のノードを持つことになるため、挿入・削除のコードで空リストや先頭に対する特殊ケースの処理が減ります。