C++ Learning

第20回 std::list / std::forward_list ― 連結リスト

各要素が独立したノードで、前後のポインタでつながっている構造が連結リストstd::list は双方向、std::forward_list は単方向です。途中への挿入/削除が O(1)(ただし挿入位置の反復子を持っていれば)という強みがありますが、現代のマシンではキャッシュ効率の悪さで vector に負けることが多く、実戦での出番は限定的。本章では「どんな仕組みか」と「いつ使うべきか」を整理します。

このページで押さえること
✅ 最低限ここだけ覚える
  • 各要素がノードで、前後ポインタで繋がる
  • 途中挿入・削除は反復子があれば O(1)
  • lst[i] のようなランダムアクセス不可
  • 迷ったら vector。list は特定用途だけ
⭐ 余裕があれば読む
  • splice(リスト間の移動が O(1))
  • イテレータ無効化されない性質
  • std::forward_list(単方向版)
  • vector と実測で比較される理由

1. std::list の基本操作

連結リストの代表。各要素がノードで、前後のポインタで繋がっています。

list 基本C++
#include <list> std::list<int> lst = {1, 2, 3}; auto it = lst.begin(); ++it; lst.insert(it, 99); // 2 の手前に 99 を挿入 → {1, 99, 2, 3} lst.erase(it); // 2 を削除 → {1, 99, 3} // 注: 添字アクセス lst[0] はできない (ランダムアクセス不可) // 移動は反復子で (++it, --it) のみ

使えるメソッド

ランダムアクセスは不可: lst[i]lst.at(i) は書けません。i 番目の要素が欲しければ std::next(lst.begin(), i) で反復子を進める必要があり、これは O(i)。

2. 理論 vs 実測 ― vector に負ける理由

教科書には「list は中間挿入が O(1) で速い」と書かれますが、実測では vector のほうが速いことがほとんどです。理由は 2 つ。

① メモリが連続していない

各ノードはヒープ上にバラバラに配置されます。ノード間を移動するたびにキャッシュミスが起き、現代 CPU のキャッシュ階層の恩恵を受けられません。vector は連続領域なので、まとめてキャッシュに乗って高速。

② 挿入位置の「検索」が O(n)

list の「中間挿入 O(1)」は反復子を持っている前提。もし「i 番目に挿入したい」なら反復子を i 回進める必要があり、結局 O(n)。vector の insert(v.begin() + i, x) も O(n) なので、計算量だけ見ると差は無い

list で i 番目に挿入O(n)
std::list<int> lst = { ... }; auto it = lst.begin(); std::advance(it, i); // ここで O(i) lst.insert(it, x); // 挿入自体は O(1) // 合計 O(n)
vector で i 番目に挿入O(n)
std::vector<int> v = { ... }; v.insert(v.begin() + i, x); // 位置計算は O(1) // 後続要素のシフトで O(n-i) // 合計 O(n)
実測では vector が勝つ: 計算量クラスは同じでも、vector の方がキャッシュが効くうえヒープ確保が少ない(list は要素ごとに new)ため、実測では vector が 3〜10 倍速いケースも珍しくありません。

「list のほうが中間挿入が速い」はよくある誤解です。

3. list の出番 ― splice / 無効化されない反復子

それでも list を選ぶ価値がある場面は 3 つ。

splice ― 別のリストから O(1) で要素を移動

splice はリスト間でノードの所有権をポインタの付け替えだけで移せます。大量要素の移動が O(1) というのは vector では絶対に真似できない芸当。

splice の例list 独自
std::list<int> a = {1, 2, 3}; std::list<int> b = {10, 20, 30}; a.splice(a.end(), b); // a = {1, 2, 3, 10, 20, 30} // b = {} (すべて a に移動) // 要素数が百万でも O(1)!

② 反復子/ポインタが無効化されない

vector は再確保(push_back で容量超過など)が起きると全要素がコピーされ、既存の反復子/ポインタはすべて無効化されます。list は各ノードが独立しているので、別のノードをいくら追加・削除しても、既存の反復子は生き続ける

③ 要素のコピー/ムーブが非常に重い型

vector は拡張時に全要素をムーブ/コピーします。巨大な状態を持つ型(100MB のバッファを抱えたオブジェクトなど)を大量に入れる場合、list だとコピーが一切発生しない利点があります。ただし現代では大抵 std::unique_ptr を vector に入れる設計で解決します。

まとめ: 入門段階では「list は名前だけ知っておく」で十分。迷ったら vectorsplice か「反復子が無効化されては困る」の具体的な理由が出てきたときにだけ検討するコンテナです。

4. std::forward_list(単方向リスト)

std::list は双方向(前後の両方のポインタ)ですが、std::forward_list前方のポインタのみ。各ノードのメモリが少しコンパクトになる代わりに、後ろに戻ることや size() の O(1) が犠牲になります。

項目std::liststd::forward_list
ポインタ前後両方前のみ
size()O(1)提供されない(O(n) で自分で数える)
push_frontO(1)O(1)
push_backO(1)提供されない(末尾を知らない)
反復子双方向(++ / --前方のみ(++ のみ)
メモリ/ノードポインタ 2 つ分多い少しコンパクト
実務でほぼ使わない: メモリ節約が死活問題の組み込み用途などで登場することはありますが、一般的なアプリケーションでは std::list すら使う頻度が低いので、forward_list「こんなのもある」程度の認識で十分です。

確認クイズ

2 問。

Q1. std::list<int> lst について、できない(提供されない)ものは?

lst.push_back(10)
lst.push_front(10)
lst[3](インデックスアクセス)
lst.erase(it)
std::list は連結リストなのでランダムアクセスができませんlst[i]lst.at(i) もコンパイルエラー。i 番目が欲しければ std::next(lst.begin(), i) で反復子を進める必要があり、これは O(i)。

Q2. std::list を選ぶ明確な理由になるのは?

中間への要素挿入が多いから
vector より常に速いから
splice で別のリストから大量要素を O(1) で移したい
インデックスでアクセスしたいから
①「中間挿入が多いから list」は実はよくある誤解(キャッシュ効率で vector に負けやすい)。②「常に速い」は誤り。④インデックスアクセスはそもそも不可。③の splice や、反復子が無効化されないことが必須のときに限って list を選ぶのが実戦での判断基準。