Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyで学ぶリンクリストの基礎と実装:配列との違いからコード例まで徹底解説

本記事は「Practical Computer Science in Ruby」シリーズの第3回です。今回はリンクリスト(連結リスト)について詳しく解説します。

リンクリストとは何か?

名前の通り、リンクリストとはデータをリスト形式で格納するためのデータ構造です。

「リンク(連結)」という言葉が示すように、データはノードと呼ばれる単位に保存され、これらのノードが順番に互いに連結される仕組みになっています。

Rubyで学ぶリンクリストの基礎と実装:配列との違いからコード例まで徹底解説

リンクリストと配列の違い

リンクリストは配列とは異なるパフォーマンス特性を持っています。それが、用途に応じてどちらかを選ぶ理由の一つです。つまり、特定のタスクにおいては、リンクリストの方が配列より効率的な場合があるということです。

リンクリストの弱点:ランダムアクセスができない

リンクリストにはインデックス(添字)が存在せず、ランダムアクセスもできません。つまり、リストの中間にある要素に直接アクセスすることはできません

目的のノードを見つけるには、リストの「先頭(head)」から出発し、リンクをたどって目的のノードを見つけるまで、あるいはリストの末尾まで順番に進む必要があります。

リンクリストの強み:中間への挿入・削除が高速

一方で、リンクリストの中間から要素を削除(または追加)するのは非常に高速です。必要な作業は、あるノードの「next(次)」ポインタを変更するだけだからです。

対照的に、配列の中間から要素を削除すると隙間(ギャップ)が生じます。その隙間を埋めるには、削除した要素より右側にあるすべての要素を移動させなければなりません。

Rubyで学ぶリンクリストの基礎と実装:配列との違いからコード例まで徹底解説

この操作を頻繁に行う必要がある場合、あまり効率的とは言えませんね。同様に、配列の中間に要素を挿入する場合も、空きスペースを作るために後続のすべての要素をずらす必要があります。

配列への挿入のコード例

a = [1,2,3,4,5,6,7,8]

def insert(arr, item, pos)
  tmp      = arr[pos]
  arr[pos] = item

  arr.replace(arr[0..pos] + [tmp] + arr[pos+1..-1])
end

insert(a, 99, 3)
p a

補足:Rubyには組み込みの Array#insert メソッドも存在しますが、ここではその内部動作を理解してもらうためにあえて自前で実装しています。

ベンチマークで見るパフォーマンス差

リンクリストの中間に要素を挿入すると、パフォーマンスにはどのような影響があるのでしょうか?以下がベンチマーク結果です。

Comparison:
   LinkedList:  1815407.9 i/s
   Array:       18090.3 i/s - 100.35x  slower

大きな差が出ていますね。ただし、挿入前にノードを検索する必要があるため、この結果は LinkedList のサイズにも大きく依存します。

データ構造を比較するもう一つの方法として、計算量(時間複雑度)の表を見てみましょう(挿入・削除時にノード検索が不要な場合を想定)。

データ構造 アクセス 検索 挿入 削除
配列 O(1) O(n) O(n) O(n)
リンクリスト O(n) O(n) O(1) O(1)

実際の活用例

現実世界での応用例をお探しですか?Aaron Patterson氏によるプルリクエストでは、リンクリストを使ってRubyGemsを高速化しています。

https://github.com/rubygems/rubygems/pull/1188

Rubyでのリンクリスト実装

Rubyには組み込みの LinkedList クラスが用意されていないため、自分で作成する必要があります。

以下の操作が使えるようにしましょう。

  • append(リストの末尾に追加)
  • append_after(指定ノードの後に追加)
  • delete(指定値のノードを削除)
  • find(指定値のノードを検索)

LinkedListクラスの実装例

class LinkedList
  def initialize
    @head = nil
  end

  def append(value)
    if @head
      find_tail.next = Node.new(value)
    else
      @head = Node.new(value)
    end
  end

  def find_tail
    node = @head

    return node if !node.next
    return node if !node.next while (node = node.next)
  end

  def append_after(target, value)
    node           = find(target)

    return unless node

    old_next       = node.next
    node.next      = Node.new(value)
    node.next.next = old_next
  end

  def find(value)
    node = @head

    return false if !node.next
    return node  if node.value == value

    while (node = node.next)
      return node if node.value == value
    end
  end

  def delete(value)
    if @head.value == value
      @head = @head.next
      return
    end

    node      = find_before(value)
    node.next = node.next.next
  end

  def find_before(value)
    node = @head

    return false if !node.next
    return node  if node.next.value == value

    while (node = node.next)
      return node if node.next && node.next.value == value
    end
  end

  def print
    node = @head
    puts node

    while (node = node.next)
      puts node
    end
  end
end

この実装では末尾(tail)を保持していないため、新しい要素を追加するたびに末尾を探しに行きます。そのため、append操作は線形時間 O(n) となります。下記の動画では、先頭に要素を追加する別の実装方法をご紹介しています。

Nodeクラスの実装

続いて、ノードを表すクラスは以下のようになります。

class Node
  attr_accessor :next
  attr_reader   :value

  def initialize(value)
    @value = value
    @next  = nil
  end

  def to_s
    "Node with value: #{@value}"
  end
end

実際の使い方

これらのクラスは次のように使用できます。

list = LinkedList.new

list.append(10)
list.append(20)
list.append(30)

list.append_after(10, 15)
list.append_after(20, 25)

list.print

これは基本的な「片方向リンクリスト(Singly-Linked List)」の実装です。

その他のリンクリストの種類

リンクリストには他にも以下のような種類があります。

  • 双方向リンクリスト(Doubly-Linked List)
  • 循環リンクリスト(Circular Linked List)

双方向リンクリストでは、各ノードが2つのポインタを持ちます。1つは次のノードへのポインタ、もう1つは前のノードへのポインタです。

これにより、リストの検索時により柔軟な操作が可能になりますが、リストを変更する際には追加の手間が必要になります。

循環リンクリストは双方向リンクリストと似ていますが、最後のノードが先頭のノードにつながっている点が異なります。

動画解説

まとめ

今回はリンクリストについて学びました。配列の中間で頻繁に要素の追加・削除を行うような場面では、このデータ構造を検討する価値があります。

また、コーディング面接でもリンクリストは頻出テーマの一つです。それだけのためでも学んでおく価値は十分にあるでしょう。

最後に、下のボタンからぜひこの記事をシェアしてください。より多くの人々がこの知識の恩恵を受けられます 🙂

  1. C++でマルチレベル連結リストをフラット化する方法を解説

    この記事では、マルチレベル連結リスト(Multilevel Linked List)をフラット化するプログラムをC++で作成する方法について解説します。フラット化とは、第1レベルのノードをすべて先に並べ、その後に第2レベルのノードが続くように、階層構造を持つリストを1本の直線的な連結リストへ変換する操作のことです。マルチレベル連結リストとはマルチレベル連結リストとは、多次元的なデータ構造の一種です。各ノードは2つのポインタを持ちます。1つは次のノードを指す「next」ポインタ、もう1つは1つ以上のノードからなる子リストを指す「child」ポインタです。この子ポインタは、他のリストのノードを指す

  2. Rubyで学ぶ実践グラフ理論:基礎からアルゴリズム活用まで

    本記事は「実践コンピュータサイエンス」シリーズの続編です。古典的なコンピュータサイエンスの概念を、Rubyを使って実際の問題解決に応用する方法を学んでいきます。 今回のテーマはグラフ理論です。 二分木(バイナリツリー)という言葉を耳にしたことがある方も多いでしょう。二分木は次のような構造をしています。 実は、二分木とはグラフの特殊な一種にすぎません。このことからも、グラフがいかに広く普及したデータ構造であるかがわかります。 まずはグラフ理論の基礎を概観し、その後、実用的な活用例とRubyでの実装方法を見ていきましょう。 グラフの基本 グラフは次の2つの要素で構成されます。 ノード(頂点とも