4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「うちのサービス、バズったらどうなる?」計算量を味方につける、エンジニアのためのパフォーマンス入門

4
Posted at

夢の「バズ」を悪夢にしない!スケールできるか知るための、計算量入門

안녕하신게라!パナソニックコネクト株式会社クラウドソリューション部の加賀です。

「うちのサービス、バズんないかな〜」
ランチをしながら、あるいはコーヒーを片手に、同僚とそんな夢を語ったことはありませんか?

でも、エンジニアのあなたは、心のどこかでこうも思っているはずです。
「…で、本当にバズったら、うちのシステム、耐えられるんだっけ?」 と。

ユーザが殺到し、SNSで話題になり、サービスが急成長する──
最高の瞬間のはずが、サーバは高負荷、データベースは応答しなくなり、画面には無慈悲なタイムアウトエラーが表示される…

「嬉しい悲鳴」が「本当の悲鳴」に変わる瞬間です。

この記事は、そんな未来の悪夢を避けるための、最初の「計算量」入門です。
難しい数学の話はしません。
「なんとなくパフォーマンスが気になる」という状態から一歩踏み出し、サービスのスケールに耐えうるコードを書くための「考え方」を学ぶための入門ガイドです。

数学的な厳密さよりも、まずは 「このコードは危ない」「この設計なら大丈夫そう」と直感的に判断できる視点を身につけ、実践することを目指します。

で、計算量って、なに?

一言でいうと、計算量とはアルゴリズムの性能を測るモノサシです。

もう少し具体的に言うと、入力されるデータが増えたときに、処理時間がどれくらい増えるかの伸び率を示します。

「処理時間そのもの」ではない、という点がポイントです。なぜなら、処理時間なんてPCのスペックやサーバの負荷状況でいくらでも変わってしまうからです。
そういった環境要因を抜きにして、コードのアルゴリズムそのものが、入力データ量に応じて処理効率がどう変化するかを、その規模の傾向(オーダ)で評価する概念が計算量です。

O記法(オーダきほう)をもっと簡単に言うと

この伸び率の評価を表すのが$O(n)$とか$O(n^2)$といったO記法です。
これは、アルゴリズムのざっくりとした性能ランクを示すラベルのようなものだと考えてください。

そして、()中のnデータ件数です。

ざっくりと 「nにデータ件数を代入したときの計算結果の大きさが、処理時間の増加傾向のヤバさに比例する」と覚えておけばOKです!(厳密には最悪の場合の規模感となります)

  • $O(n)$
    n=1万なら、1万に比例する処理量に。なるほど?
  • $O(n^2)$
    n=1万なら、1万 × 1万1億に比例する処理量。え、ヤバそう!
  • $O(1)$
    n=1万でも1億でも、処理量は定数(1)。もしや神!?

このように、O記法を使えば、データが増えたときに処理時間が最悪どれくらい大変になるかを、ざっくり比較できるのです。

よく見る計算量の種類と、その体感速度

普段の業務で遭遇しやすい代表的な計算量を、速い順(効率の良い順) に紹介します。

🏅 第1位 定数時間

$O(1)$🚀 最強、神速。でも少し注意が必要。

データが1個だろうが100万個だろうが、処理時間が変わらないタイプです。

O(1)の例(Python)
# 配列の要素に直接アクセスする
value = data_list[10]

# ハッシュマップ(辞書)からキーで値を取得する
user_name = user_map.get("user_id_123")

ただし、この神速にはカラクリがある場合があります。
例示では、内部的に効率的なデータ構造(配列、ハッシュテーブル)を使用しているため、$O(1)$のアクセス速度を実現しています。

つまり、多くの場合はメモリ使用量と引き換えに、処理速度を手に入れているわけです。
大量のデータを扱う際は、「速いけど、メモリは大丈夫?」という視点も重要になります。

ちなみに、この「どれくらいメモリを使うか」という指標を空間計算量と呼びます。この記事では主に時間計算量が示す処理時間に焦点を当てますが、「処理時間」と「メモリ使用量」の2つのモノサシがあることを覚えておくと、より深い分析ができるようになります。

🥈 第2位 対数時間

$O(\log{n})$🚄 めっちゃ速い、優秀。

データが100万件あっても、20回程度のステップで終わるような、非常に効率的な処理です。nが大きくなっても、処理時間はほとんど増えません。

代表例は二分探索(バイナリサーチ) です。
データベースのインデックスを使った検索も、この$O(\log{n})$の効率性を実現しています。

🥉 第3位 線形時間

$O(n)$🚗 結構速い、けど乱用注意。

データ量に比例して処理時間が増えるタイプです。データが100倍になれば処理時間もだいたい100倍になります。
日々の業務で最もよく見るよく書くコードかもしれません。

O(n)の例(Python)
# 配列の全要素を1つずつチェックする
total = 0
for num in data_list:
  total += num

このオーダがボトルネックとなっている場合には、より効率の良い$O(\log{n})$などのアルゴリズムに変更できないか検討します。例えば、単純なリスト探索を、データベースのインデックスを使った探索に置き換えるケースを想像してみてください。

🙆‍♂️ 第4位 線形対数時間

$O(n\log{n})$🚴 そこそこ速い、複雑な計算の現実的な落とし所。

第3位の$O(n)$よりは少し遅いですが、まだまだ実用的な速度です。多くのプログラミング言語に組み込まれている優秀なソートアルゴリズム(マージソート等)でよく見られます。

⚠️ 第5位 二乗時間

$O(n^2)$🚶‍♂️ ちょっと遅いかも?要注意!

データが100倍になると処理時間は10000倍…と、急激に遅くなるタイプです。
nが数千を超えてくると露骨にパフォーマンスに影響が出始めます。業務中に見かけたら要注意です。ループの外側と内側のコード位置が離れていると意外と気付き難かったりします。

O(n^2)の例(Python)
# 2重ループは典型的な O(n^2)
for item1 in list1:
  for item2 in list2:
    if item1.id == item2.id:
      print( "Match found!" )

このようなループのネスト(入れ子)を見たら、「お、これは$O(n^2)$かも?」と警戒するクセをつけましょう。
いわんや、三重のループ$O(n^3)$以上は壊滅的な悪影響を与えます、要警戒です。

💣️ 第6位 階乗時間

$O(n!)$🐢 超低速、やっちゃダメ!もしくは、特殊な計算をしていると自覚しよう!

nが10になるだけでも、オーダは控えめに見積もっても絶望的に増大します。超💣️危険。
巡回セールスマン問題を解くとか特定の意図を持たせて計算するならば、他の邪魔にならないよう処理する場所を考慮した方が良いでしょう。

日々の業務でどう意識するか?

では、この知識をどうやって日々の業務に活かせばいいのでしょうか?
難しいことを考える必要はありません。まずは以下の4点を意識してみてください。

1. ループに注目する

パフォーマンスのボトルネックになりやすいのは、なんと言ってもループです。

  • ループがネスト($O(n^2)$)してないか?
    • ネストしている場合、$O(n)$にできないか?(例えば、片方のコレクションをハッシュマップ化するなど)
      特に、DBから取得した数千件のリストを二重ループで回すようなコードは危険信号です。この書き換えは非常によく使うテクニックなので、ぜひ覚えておいてください。
改善例:O(n^2)からO(n)へ(JavaScript)
// Before: O(n^2) ※itemListAとitemListBの長さをnする
for (const itemA of itemListA) { // itemListAを全件ループ
  if (itemListB.find(itemB => itemA.id === itemB.id)) { // itemListBを全件ループし、全体で二重ループに(見落としがち)
    // itemAとitemBのIDがマッチした時の処理...
  }
}

// After: O(n+n) = O(n) (オーダ記法では定数倍は無視されます)
// 1. 最初にitemListBのIDをSetに変換する【コスト: O(n)】
const lookupSet = new Set(itemListB.map(itemB => itemB.id));

// 2. listAをループし、Setを使ってO(1)で確認する【コスト: O(n)】
for (const itemA of itemListA) { // itemListAを全件ループ
  if (lookupSet.has(itemA.id)) { // Set.has()による存在チェックはほぼO(1)。一瞬で終わる!
    // itemAとitemBのIDがマッチした時の処理...
  }
}

2. DBのインデックスを意識する

計算量の考え方は、データベースのパフォーマンスチューニングにもそのまま応用できます。

  • インデックスなし
    WHERE user_id = ... のクエリは、テーブルの全件検索(フルスキャン)します。これは$O(n)$となり低速です。
  • インデックスあり
    user_id カラムにインデックスを貼ると、DBは$O(\log{n})$となり高速に目的のデータを探し当てます。

「このクエリ、なんか遅いな…」と感じたら、EXPLAINなどで実行計画を確認し、意図通りインデックスが使われているかをチェックする習慣をつけましょう。

3. ライブラリやORマッパーの裏側を想像する

便利なライブラリやフレームワーク(特にORマッパー)の裏側で、どんな処理が動いているか想像してみましょう。

  • list.includes(value) (JavaScript) / value in list (Python)
    これはリストを先頭から順に探すので$O(n)$です。
    事前にSetdictを用意して使えば$O(1)$になります。

  • ORマッパーのN+1問題
    多くのORマッパーは、関連先のデータを後から取得する遅延読み込み(Lazy Loading)がデフォルトになっていることがあります。

N+1問題のイメージ(Java)
// 1. まず全ユーザーを取得 (SQL 1回)
List<User> users = userDao.findAll();

// 2. ユーザーをループで回し、各ユーザーの投稿を取得
for (User user : users) {
    // この中でユーザー1人毎にSQLが発行されてしまう! (SQL N回)
    List<Post> posts = user.getPosts();
    System.out.println(posts);
}

このコードは一見シンプルですが、N人のユーザーがいれば、1 + N回のSQLが発行されてしまいます。ユーザーが100人いたら101回です。nが増えたとき、DBへの負荷も無視できなくなります。
事前に一括で取得(Eager Loading)する設定に切り替えることで、SQLの発行をわずか1〜2回に抑えることができ、全体でパフォーマンス向上が見込めます。が、使わないデータをも取得することにもなり、アプリケーションのメモリ消費が増大する可能性もあるため、最適な取得戦略を選択することが重要です。

「この一行は$O(1)$だろうか、$O(n)$だろうか?」
「このメソッドは中でDBアクセスが走っていないか?」
と想像するクセをつけるだけで、コードの質は格段に上がります。

4. データ量を意識する

  • そのリストには、最大で何件くらいのデータが入る想定ですか?
  • 100件ですか? 1万件ですか? 100万件ですか?

$O(n^2)$のコードでも、nが最大でも50件程度だと分かっていれば、問題になることは少ないでしょう。逆に、今は10件しかなくても、将来的に1万件になる可能性があるなら、nの増大に対してコストが大きく増えない$O(n)$や$O(n\log{n})$等のアルゴリズムに変更できるか検討すべきです。

「この処理が扱うデータの規模(件数)はどのくらいなのか」を常に意識することが、将来のパフォーマンス問題を防ぐ鍵となります。

これらの考え方は、クラウドの設計においても非常に役立ちます。

「嬉しい悲鳴」を上げる未来に向けて、まとめ

この記事では、計算量の数学的な側面は一旦横に置き、「サービスのバズ」という未来の成功に備えるための実践的な考え方を解説しました。

  • 計算量は、データが増えたときの「処理時間の伸び率」を示す性能ランク
  • その性能ランクのヤバさ序列の傾向をざっくり把握しておく
    $O(1) < O(\log{n}) < O(n) < O(n\log{n}) << O(n^2) <<< O(n!)$
  • 処理時間メモリ使用量にはトレードオフの関係があることを忘れない
  • コードに$O(n^2)$ループ、DBフルスキャン、N+1問題が潜んでいるかも?と警戒するクセをつける

「うちのサービスがバズったら…」その想像は、もはや悪夢ではありません。計算量という方位磁石があれば、どのコードがボトルネックになり、どこを改善すればトラフィックの嵐を乗り越えられるのか、その航路が見えてきます。
パフォーマンスチューニングは、炎上してから行う対症療法ではなく、サービスの成功を信じて行う未来への投資です。この方位磁石を手に、あなたのコードが持つ真のポテンシャルを解き放ちましょう。さあ、自信を持ってバズを迎え撃つ準備を始めるのです。

次のステップ

この記事で計算量に興味を持った方は、ぜひ以下のステップに進んでみてください。

  • データ構造を学ぶ
    データ構造とデータ処理するアルゴリズムを学ぶことで、時間計算量と空間計算量のトレードオフがより深く理解できます。
    • なぜハッシュマップは$O(1)$でアクセスできるのか?(ハッシュ衝突で悪化するのを知る)
    • なぜインデックスは$O(\log{n})$で検索できるのか?(データ傾向次第で低速になるのを知る)
  • アルゴリズムの定番書を読む
    図が多くて分かりやすい『アルゴリズム図鑑』(特にアプリ版[Android,iOS]はアニメーションするのでより判り易い!)などがおすすめです。
  • 競技プログラミングに触れてみる
    AtCoderなどのサイトで簡単な問題に挑戦してみると、計算量を意識したコーディングの良い練習になります。

お断り
記事内容は個人の見解であり、所属組織の立場や戦略・意見を代表するものではありません。
あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?