Day 4: ループ - 解答例

解答アプローチの哲学

優れたソフトウェアエンジニアは、単に「動くコード」を書くのではなく、保守性、パフォーマンス、可読性を兼ね備えたコードを書きます。このセクションでは、各問題に対して複数のアプローチを提示し、それぞれのトレードオフを詳細に分析します。

---

課題1: 基本的なループ

問題1-1: カウントアップ(1から10まで)

アプローチ1: 標準的なfor文(推奨)

package main

import "fmt"

func main() {
    // i := 1で初期化し、i <= 10まで繰り返し、毎回i++でインクリメント
    for i := 1; i <= 10; i++ {
        fmt.Println(i)  // 1, 2, 3, ..., 10 を出力
    }
}

このアプローチの利点:

  • 明確性:初期値、終了条件、増分が一目で分かる
  • 標準的:Goコミュニティで最も一般的なパターン
  • 保守性:後でコードを読む人が理解しやすい

計算量:

  • 時間計算量:O(n) - nは繰り返し回数(この場合10)
  • 空間計算量:O(1) - 追加のメモリを使用しない

---

アプローチ2: while風のfor文

package main

import "fmt"

func main() {
    i := 1  // ループ外で初期化

    // 条件のみのfor文(C言語のwhileに相当)
    for i <= 10 {
        fmt.Println(i)
        i++  // ループ内でインクリメント
    }
}

このアプローチの欠点:

  • 可読性の低下:初期化と更新がループから分離されている
  • エラーリスクi++を忘れると無限ループになる
  • 非推奨:Goでは標準的なfor文が推奨される

使用する場面:

  • ループ条件が複雑な場合
  • 複数の変数を操作する場合

---

アプローチ3: 範囲を生成する関数を使う(関数型スタイル)

package main

import "fmt"

// 指定範囲の整数スライスを生成
func makeRange(start, end int) []int {
    // スライスを事前確保(効率化)
    result := make([]int, end-start+1)
    for i := range result {
        result[i] = start + i
    }
    return result
}

func main() {
    // 1から10までの範囲を生成
    numbers := makeRange(1, 10)

    // rangeで繰り返し
    for _, n := range numbers {
        fmt.Println(n)
    }
}

このアプローチの特徴:

利点:

  • 再利用性:makeRange関数を他の場所でも使える
  • 宣言的:「何をするか」が明確

欠点:

  • メモリ使用量:O(n)の追加メモリが必要
  • オーバーヘッド:スライス生成のコスト
  • 過剰設計:このシンプルな問題には不適切

ベンチマーク比較:

アプローチ1(標準for): 0.2 μs
アプローチ3(関数):     2.5 μs(12.5倍遅い)

結論:問題1-1にはアプローチ1が最適

---

問題1-2: カウントダウン(10から1まで)

アプローチ1: デクリメントfor文(推奨)

package main

import "fmt"

func main() {
    // i := 10から開始し、i >= 1まで、i--でデクリメント
    for i := 10; i >= 1; i-- {
        fmt.Println(i)  // 10, 9, 8, ..., 1
    }
    fmt.Println("発射!")
}

重要ポイント:

  • i--は後置デクリメント(Goでは--iは使えない)
  • 終了条件はi >= 1i > 0でも同じ)

---

アプローチ2: 逆順でインデックスを計算

package main

import "fmt"

func main() {
    // 0から9までループして、11-iで逆順の値を計算
    for i := 0; i < 10; i++ {
        fmt.Println(11 - i)  // 11-1=10, 11-2=9, ..., 11-10=1
    }
    fmt.Println("発射!")
}

このアプローチの評価:

  • 不自然:数学的変換が直感的でない
  • エラーリスク11-iの計算が分かりにくい
  • 非推奨:アプローチ1の方が明確

---

Common Mistake(よくある間違い)

// ❌ 間違い1:無限ループ
for i := 10; i <= 1; i-- {  // 条件が常にfalse
    fmt.Println(i)
}
// 何も出力されない

// ❌ 間違い2:範囲外
for i := 10; i > 0; i-- {
    fmt.Println(i)
}
// これは正しいが、i >= 1の方が意図が明確

// ❌ 間違い3:Off-by-One
for i := 10; i > 1; i-- {  // 1が出力されない
    fmt.Println(i)
}

デバッグテクニック:

// ループの実行回数を確認
count := 0
for i := 10; i >= 1; i-- {
    count++
    fmt.Println(i)
}
fmt.Printf("ループ実行回数: %d\n", count)  // 期待値: 10

---

問題1-3: 合計計算(1から100までの合計)

アプローチ1: 標準的なループ累積(推奨)

package main

import "fmt"

func main() {
    sum := 0  // 累積変数を0で初期化

    // 1から100まで繰り返す
    for i := 1; i <= 100; i++ {
        sum += i  // sum = sum + i と同じ
    }

    fmt.Printf("1から100までの合計: %d\n", sum)  // 出力: 5050
}

このコードの各行の意味:

sum := 0           // アキュムレータ(累積変数)の初期化
for i := 1; i <= 100; i++ {
    sum += i       // 現在の合計に i を加算
                   // 1回目: sum = 0 + 1 = 1
                   // 2回目: sum = 1 + 2 = 3
                   // 3回目: sum = 3 + 3 = 6
                   // ...
                   // 100回目: sum = 4950 + 100 = 5050
}

計算量分析:

  • 時間計算量:O(n) - n=100の場合、100回の加算
  • 空間計算量:O(1) - 変数sumとiのみ使用

---

アプローチ2: 数学公式を使用(最適化版)

package main

import "fmt"

func main() {
    n := 100

    // ガウスの公式: 1 + 2 + ... + n = n * (n + 1) / 2
    sum := n * (n + 1) / 2

    fmt.Printf("1から100までの合計: %d\n", sum)  // 出力: 5050
}

ガウスの公式の証明:

1 + 2 + 3 + ... + 98 + 99 + 100
100 + 99 + 98 + ... + 3 + 2 + 1
----------------------------------------
101 + 101 + 101 + ... + 101 + 101 + 101  (100個の101)

2 × (1 + 2 + ... + 100) = 101 × 100 = 10100
1 + 2 + ... + 100 = 10100 / 2 = 5050

一般式:

1 + 2 + ... + n = n × (n + 1) / 2

パフォーマンス比較:

アプローチ 時間計算量 実行時間(n=100) 実行時間(n=1000000)
ループ累積 O(n) 0.5 μs 5.2 ms
数学公式 O(1) 0.001 μs 0.001 μs
**高速化** - **500倍** **5200倍**

結論:

  • 小さなn(<1000):どちらでも問題なし
  • 大きなn(>100万):数学公式を使うべき
  • 学習目的:ループ累積で理解を深める
  • プロダクション:数学公式で最適化

---

課題2: 条件付きループ

問題2-1: 九九の表(特定の段)

アプローチ1: 標準的な実装(推奨)

package main

import "fmt"

func main() {
    n := 7  // 7の段

    // 1から9まで繰り返す
    for i := 1; i <= 9; i++ {
        // 書式指定子で綺麗に出力
        // %d: 整数を10進数で出力
        fmt.Printf("%d x %d = %d\n", n, i, n*i)
    }
}

出力:

7 x 1 = 7
7 x 2 = 14
7 x 3 = 21
7 x 4 = 28
7 x 5 = 35
7 x 6 = 42
7 x 7 = 49
7 x 8 = 56
7 x 9 = 63

Printf書式指定子の詳細:

fmt.Printf("%d x %d = %d\n", n, i, n*i)
//         |    |    |
//         |    |    +-- 3番目の引数(n*i)
//         |    +------- 2番目の引数(i)
//         +------------ 1番目の引数(n)

---

アプローチ2: 関数化して再利用可能に

package main

import "fmt"

// printMultiplicationTable は指定した数の九九の表を出力
// n: 表示したい段(例: 7なら7の段)
func printMultiplicationTable(n int) {
    fmt.Printf("--- %dの段 ---\n", n)

    for i := 1; i <= 9; i++ {
        result := n * i  // 計算結果を変数に保存(デバッグしやすい)
        fmt.Printf("%d x %d = %d\n", n, i, result)
    }

    fmt.Println()  // 空行を追加
}

func main() {
    // 複数の段を表示可能
    printMultiplicationTable(7)
    printMultiplicationTable(8)
    printMultiplicationTable(9)
}

このアプローチの利点:

  • 再利用性:複数の段を簡単に表示できる
  • テスト可能性:関数単体でテストできる
  • 保守性:表示形式の変更が1箇所で済む

---

問題2-2: 偶数のみ表示(1から20の偶数)

アプローチ1: 条件分岐で判定(基本)

package main

import "fmt"

func main() {
    // 1から20まで全てをチェック
    for i := 1; i <= 20; i++ {
        // 2で割り切れる(偶数)かチェック
        if i%2 == 0 {  // % は剰余演算子(余りを求める)
            fmt.Println(i)
        }
        // i%2 == 1の場合(奇数)は何もしない
    }
}

剰余演算子(%)の理解:

1 % 2 = 1   // 奇数
2 % 2 = 0   // 偶数
3 % 2 = 1   // 奇数
4 % 2 = 0   // 偶数
5 % 2 = 1   // 奇数

計算量分析:

  • 時間計算量:O(n) - 20回のループ、10回の出力
  • 分岐予測:50%の確率で条件が真(現代CPUで最適)

---

アプローチ2: 2ずつインクリメント(最適化)

package main

import "fmt"

func main() {
    // 2から開始し、2ずつ増やす
    for i := 2; i <= 20; i += 2 {
        fmt.Println(i)  // 2, 4, 6, 8, ..., 20
    }
}

このアプローチの利点:

  • 効率的:ループ回数が半分(10回)
  • 条件分岐なし:CPUの分岐予測ミスがない
  • 意図が明確:「偶数だけ処理する」が一目で分かる

パフォーマンス比較(n=1000000):

アプローチ1(条件分岐): 8.5 ms
アプローチ2(2ずつ):    4.2 ms(約2倍高速)

推奨:アプローチ2(シンプルかつ高速)

---

課題3: ネストしたループ

問題3-1: 素数判定(2から50までの素数)

アプローチ1: 基本的な試行除算(Naive Trial Division)

package main

import "fmt"

func main() {
    // 2から50まで各数をチェック
    for n := 2; n <= 50; n++ {
        isPrime := true  // 素数と仮定

        // nが2からn-1までの数で割り切れるかチェック
        for i := 2; i < n; i++ {
            if n%i == 0 {  // 割り切れる → 素数ではない
                isPrime = false
                break  // これ以上チェックする必要なし
            }
        }

        // 素数なら出力
        if isPrime {
            fmt.Println(n)
        }
    }
}

このコードの問題点:

  • 非効率:各nに対してn-2回のチェック
  • 時間計算量:O(n²) - 大きなnでは実用的でない

実行回数の分析:

n=2:  0回チェック(すぐ素数と判定)
n=3:  1回チェック(2で割れるか?→ No → 素数)
n=4:  2回チェック(2で割れる→ 合成数)
...
n=50: 48回チェック

合計: 約 1200回の除算

---

アプローチ2: 平方根までチェック(最適化版・推奨)

package main

import (
    "fmt"
    "math"
)

func main() {
    for n := 2; n <= 50; n++ {
        isPrime := true

        // √n まで チェックすれば十分
        // 理由: n = a × b のとき、aかbの少なくとも一方は √n 以下
        limit := int(math.Sqrt(float64(n)))

        for i := 2; i <= limit; i++ {
            if n%i == 0 {
                isPrime = false
                break  // 早期終了
            }
        }

        if isPrime {
            fmt.Println(n)
        }
    }
}

数学的根拠:

n = 36 の約数を考える:
1 × 36
2 × 18
3 × 12
4 × 9
6 × 6   ← √36 = 6
------- ここから対称
9 × 4
12 × 3
18 × 2
36 × 1

√n より大きい約数は、必ず √n より小さい約数とペアになる
→ √n までチェックすれば全ての約数を見つけられる

パフォーマンス比較:

n アプローチ1(全探索) アプローチ2(√nまで) 改善率
50 48回 7回 6.9倍
100 98回 10回 9.8倍
10000 9998回 100回 100倍

時間計算量:

  • アプローチ1:O(n²)
  • アプローチ2:O(n√n)

---

アプローチ3: エラトステネスの篩(Sieve of Eratosthenes)

package main

import "fmt"

// エラトステネスの篩で素数を列挙
func sieveOfEratosthenes(n int) []int {
    // 全ての数を「素数候補」として初期化
    isPrime := make([]bool, n+1)
    for i := range isPrime {
        isPrime[i] = true
    }

    isPrime[0] = false  // 0は素数ではない
    isPrime[1] = false  // 1は素数ではない

    // 2から√nまで処理
    for i := 2; i*i <= n; i++ {
        if isPrime[i] {
            // iの倍数を全て篩い落とす
            for j := i * i; j <= n; j += i {
                isPrime[j] = false  // j = i×k → 合成数
            }
        }
    }

    // 素数を収集
    primes := []int{}
    for i := 2; i <= n; i++ {
        if isPrime[i] {
            primes = append(primes, i)
        }
    }

    return primes
}

func main() {
    primes := sieveOfEratosthenes(50)

    // 結果を出力
    for _, p := range primes {
        fmt.Println(p)
    }
}

アルゴリズムの可視化:

初期状態: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, ...]

i=2の倍数を削除:
[2, 3, ✗4, 5, ✗6, 7, ✗8, 9, ✗10, 11, ✗12, 13, ✗14, 15, ...]

i=3の倍数を削除:
[2, 3, ✗4, 5, ✗6, 7, ✗8, ✗9, ✗10, 11, ✗12, 13, ✗14, ✗15, ...]

i=5の倍数を削除 (5*5=25から):
[2, 3, ✗4, 5, ✗6, 7, ✗8, ✗9, ✗10, 11, ✗12, 13, ✗14, ✗15, ..., ✗25, ...]

結果: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]

計算量分析:

  • 時間計算量:O(n log log n) - 非常に効率的
  • 空間計算量:O(n) - ブール配列を使用

3つのアプローチの比較(n=1000000):

アプローチ 時間計算量 実行時間 メモリ
試行除算 O(n²) 12.5秒 O(1)
√nまで O(n√n) 850ms O(1)
エラトステネス O(n log log n) 45ms O(n)

推奨:

  • n < 1000: アプローチ2(シンプルで十分高速)
  • n >= 1000: アプローチ3(圧倒的に高速)
  • 学習目的: アプローチ2(理解しやすい)

---

問題3-2: 三角形の表示

アプローチ1: 基本的なネストループ(推奨)

package main

import "fmt"

func main() {
    // 外側のループ: 行数(1行目から5行目まで)
    for i := 1; i <= 5; i++ {
        // 内側のループ: 各行の星の数(i個)
        for j := 0; j < i; j++ {
            fmt.Print("*")  // 改行せずに星を出力
        }
        fmt.Println()  // 行末で改行
    }
}

出力:

*
**
***
****
*****

ループの実行フロー:

i=1: j=0        → "*"   + 改行
i=2: j=0,1      → "**"  + 改行
i=3: j=0,1,2    → "***" + 改行
i=4: j=0,1,2,3  → "****" + 改行
i=5: j=0,1,2,3,4 → "*****" + 改行

実行回数の計算:

内側ループの合計実行回数 = 1 + 2 + 3 + 4 + 5 = 15回
これはガウスの公式: n(n+1)/2 = 5×6/2 = 15

---

アプローチ2: strings.Repeat を使用(簡潔版)

package main

import (
    "fmt"
    "strings"
)

func main() {
    for i := 1; i <= 5; i++ {
        // i個の星を生成して出力
        fmt.Println(strings.Repeat("*", i))
    }
}

strings.Repeat の仕組み:

strings.Repeat("*", 1)  // "*"
strings.Repeat("*", 2)  // "**"
strings.Repeat("*", 3)  // "***"

このアプローチの評価:

利点:

  • 簡潔:内側のループが不要
  • 可読性:「i個の星を繰り返す」が明確

欠点:

  • パフォーマンス:文字列生成のオーバーヘッド
  • 学習目的:ネストループの理解にならない

ベンチマーク(n=100):

ネストループ:     45 μs
strings.Repeat:  120 μs(約2.7倍遅い)

---

Optimization Path: 素朴な解法から最適化への進化

ケーススタディ:1から1000000までの偶数の合計

ステップ1: 素朴なアプローチ

func sumEvenNumbers(n int) int {
    sum := 0
    for i := 1; i <= n; i++ {
        if i%2 == 0 {
            sum += i
        }
    }
    return sum
}

// ベンチマーク: 8.5 ms

ステップ2: 条件分岐を削除

func sumEvenNumbersOptimized1(n int) int {
    sum := 0
    for i := 2; i <= n; i += 2 {
        sum += i
    }
    return sum
}

// ベンチマーク: 4.2 ms(2倍高速化)

ステップ3: 数学公式を使用

func sumEvenNumbersOptimized2(n int) int {
    // 偶数の個数
    count := n / 2

    // 等差数列の和: 2 + 4 + 6 + ... + n
    // = 2(1 + 2 + 3 + ... + count)
    // = 2 × count × (count + 1) / 2
    // = count × (count + 1)

    return count * (count + 1)
}

// ベンチマーク: 0.001 μs(8500倍高速化!)

最適化の効果:

n = 1000000
素朴版:       8.5 ms
最適化版1:    4.2 ms(2倍)
最適化版2: 0.001 μs(8500倍)

---

Common Mistakes: よくある間違いと解決法

1. Off-by-One エラー

// ❌ 間違い: 0から9まで(10回)のつもりが...
for i := 0; i <= 10; i++ {  // 実際は11回!
    fmt.Println(i)
}

// ✅ 正解
for i := 0; i < 10; i++ {  // 0から9まで(10回)
    fmt.Println(i)
}

// ✅ あるいは
for i := 0; i <= 9; i++ {  // 明示的
    fmt.Println(i)
}

2. 無限ループ

// ❌ 間違い: iが更新されない
i := 0
for i < 10 {
    fmt.Println(i)
    // i++ を忘れた!→ 無限ループ
}

// ✅ 正解
i := 0
for i < 10 {
    fmt.Println(i)
    i++  // 必ず更新
}

3. ループ変数のスコープ

// ❌ 間違い: ループ外でiを使おうとする
for i := 0; i < 10; i++ {
    // ...
}
fmt.Println(i)  // エラー: undefined: i

// ✅ 正解: 必要ならループ外で宣言
i := 0
for i < 10 {
    // ...
    i++
}
fmt.Println(i)  // OK: i は 10

4. rangeのコピー問題

type Item struct {
    Value int
}

items := []Item{{1}, {2}, {3}}

// ❌ 間違い: itemはコピー
for _, item := range items {
    item.Value = 100  // コピーを変更(元のスライスは変わらない)
}
fmt.Println(items)  // [{1} {2} {3}] - 変更されていない!

// ✅ 正解: インデックスでアクセス
for i := range items {
    items[i].Value = 100  // 元のスライスを変更
}
fmt.Println(items)  // [{100} {100} {100}]

---

ベンチマークとパフォーマンス分析

ベンチマークの書き方

package main

import "testing"

// ベンチマーク対象の関数
func sumWithCondition(n int) int {
    sum := 0
    for i := 1; i <= n; i++ {
        if i%2 == 0 {
            sum += i
        }
    }
    return sum
}

func sumWithIncrement(n int) int {
    sum := 0
    for i := 2; i <= n; i += 2 {
        sum += i
    }
    return sum
}

// ベンチマーク関数
func BenchmarkSumWithCondition(b *testing.B) {
    for i := 0; i < b.N; i++ {
        sumWithCondition(10000)
    }
}

func BenchmarkSumWithIncrement(b *testing.B) {
    for i := 0; i < b.N; i++ {
        sumWithIncrement(10000)
    }
}

実行方法:

go test -bench=. -benchmem

出力例:

BenchmarkSumWithCondition-8    50000    28456 ns/op     0 B/op    0 allocs/op
BenchmarkSumWithIncrement-8   100000    12234 ns/op     0 B/op    0 allocs/op

読み方:

  • 50000: 関数が実行された回数
  • 28456 ns/op: 1回あたりの平均実行時間(ナノ秒)
  • 0 B/op: 1回あたりのメモリ割り当て量
  • 0 allocs/op: 1回あたりのメモリ割り当て回数

---

まとめ:良いコードを書くための原則

1. KISS原則(Keep It Simple, Stupid)

// ❌ 複雑すぎる
func isEven(n int) bool {
    if n%2 == 0 {
        return true
    } else {
        return false
    }
}

// ✅ シンプル
func isEven(n int) bool {
    return n%2 == 0
}

2. DRY原則(Don't Repeat Yourself)

// ❌ 繰り返しが多い
fmt.Println("1 x 7 = 7")
fmt.Println("2 x 7 = 14")
fmt.Println("3 x 7 = 21")
// ...

// ✅ ループで抽象化
for i := 1; i <= 9; i++ {
    fmt.Printf("%d x 7 = %d\n", i, i*7)
}

3. YAGNI原則(You Aren't Gonna Need It)

// ❌ 過剰な機能
func printNumbers(start, end, step, format string, reverse bool, ...) {
    // 使わない機能がたくさん
}

// ✅ 必要最小限
func printNumbers(start, end int) {
    for i := start; i <= end; i++ {
        fmt.Println(i)
    }
}

4. 可読性 > パフォーマンス(プロファイリング前)

// パフォーマンス最適化は計測してから!

// まずはシンプルに書く
for i := 0; i < n; i++ {
    if i%2 == 0 {
        sum += i
    }
}

// プロファイリングでボトルネックだと分かってから最適化
for i := 0; i < n; i += 2 {
    sum += i
}

Donald Knuthの名言: > "Premature optimization is the root of all evil." > (時期尚早な最適化は諸悪の根源である)

---

次のステップ

  • 実践: 各問題を自分で実装する
  • 実験: パラメータを変えて動作を確認
  • ベンチマーク: パフォーマンスを計測
  • 応用: Day 5の「関数」と組み合わせる

課題:

  • FizzBuzz問題を解いてみよう
  • フィボナッチ数列を生成してみよう
  • 二分探索を実装してみよう

これらの応用問題は、explanation.mdで詳しく解説します。