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 >= 1(i > 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で詳しく解説します。