Try T.M Engineer Blog

多摩市で生息するエンジニアが「アウトプットする事は大事だ」と思って始めたブログ

設計技法「再帰」を学ぶ

最近はアルゴリズムを勉強している。

なかなか頭に入ってこないので、アウトプットをして整理する。

なお、今回は設計技法「再帰」の話。

一連の処理の中で、自分自身を呼び出すことを「再帰呼び出し」と言う。

再帰呼び出しを行う関数」のことを「再帰関数」と言う。

以下、1からNまでの総和を計算する再帰関数の例を書く。

package main

import (
    "fmt"
)

func recursive(N int) int {
    fmt.Printf("recursive(%d)を呼び出しました。\n", N)
    if N == 0 {
        return 0
    }
    result := N + recursive(N-1)
    fmt.Printf("%dまでの和 = %d\n", N, result)
    return result
}

func main() {
    recursive(5)
}

// recursive(5)を呼び出しました。
// recursive(4)を呼び出しました。
// recursive(3)を呼び出しました。
// recursive(2)を呼び出しました。
// recursive(1)を呼び出しました。
// recursive(0)を呼び出しました。
// 1までの和 = 1
// 2までの和 = 3
// 3までの和 = 6
// 4までの和 = 10
// 5までの和 = 15

再帰関数にはある程度テンプレートが決まっており、以下のようになる。

func 関数名 (引数) {
  if ベースケース {
    return ベースケースに対する値
  }
  // 再帰呼び出しを行う
  関数名(次の引数)
  return 答え
}

ここで、再帰関数を使ってフィボナッチ数列を求める再帰関数を考える。

  • フィボナッチ数列とは? ⇒ 「1,1,2,3,5,8,13,21,34,55…」と続く数列のことで、前2つの値を加算してできる数列のこと。

フィボナッチ数列の漸化式は以下になる。

  • F0 = 0
  • F1 = 1
  • Fn = Fn-1 + Fn-2 (n=2,3,4…)

なお、漸化式とは、ある数列に対して規則性を表す等式のことを指す。

フィボナッチ数列の場合、F0 = 0、 F1 = 1の条件式が成り立つ場合、Fn = Fn-1 + Fn-2 (n=2,3,4…)という規則性があるということ。

package main

import (
    "fmt"
)

func fibo(N int) int {
    fmt.Printf("fibo(%d)を呼び出しました。\n", N)
    if N == 0 {
        return 0
    } else if N == 1 {
        return 1
    }

    result := fibo(N-1) + fibo(N-2)
    fmt.Printf("%d 項目 = %d\n", N, result)

    return result
}

func main() {
    fibo(5)
}

// fibo(5)を呼び出しました。
// fibo(4)を呼び出しました。
// fibo(3)を呼び出しました。
// fibo(2)を呼び出しました。
// fibo(1)を呼び出しました。
// fibo(0)を呼び出しました。
// 2 項目 = 1
// fibo(1)を呼び出しました。
// 3 項目 = 2
// fibo(2)を呼び出しました。
// fibo(1)を呼び出しました。
// fibo(0)を呼び出しました。
// 2 項目 = 1
// 4 項目 = 3
// fibo(3)を呼び出しました。
// fibo(2)を呼び出しました。
// fibo(1)を呼び出しました。
// fibo(0)を呼び出しました。
// 2 項目 = 1
// fibo(1)を呼び出しました。
// 3 項目 = 2
// 5 項目 = 5

このフィボナッチ数列のコードは、作成したものの「同じ計算を何度も実行していて効率が極めて悪い」という問題がある。

上記結果を見ても、 fibo(3) , fibo(2) , fibo(1) , fibo(0) を何度か呼び出しているのがわかる。

そもそもフィボナッチ数列再帰関数を用いなければ、for文(計算量: O(N))でできる問題なので、再帰関数を用いると無駄な計算があることがわかる。

fibofor := [6]int{0, 1}
for n := 2; n < 6; n++ {
    fibofor[n] = fibofor[n-1] + fibofor[n-2]
    fmt.Printf("%d 項目 = %d\n", n, fibofor[n])
}

// 2 項目 = 1
// 3 項目 = 2
// 4 項目 = 3
// 5 項目 = 5

再帰関数で無駄な計算をさせない方法の1つがメモ化(キャッシュとも言う)。

つまり、1度計算した結果を変数に保存しておく方法。

func mfibo(n int, memo []int) int {
    fmt.Printf("func mfibo(%d) を呼びました\n", n)
    if memo[n] != -1 {
        fmt.Println("メモ化した値を返しました")
        return memo[n]
    }

    if n == 0 {
        memo[0] = 0
        return 0
    } else if n == 1 {
        memo[1] = 1
        return 1
    }

    memo[n] = mfibo(n-1, memo) + mfibo(n-2, memo)

    return memo[n]
}

func main() {
    // memo初期化
    memo := make([]int, 6)
    for i, _ := range memo {
        memo[i] = -1
    }

    mfibo(5, memo)
    fmt.Println("memo => ", memo)
}

// func mfibo(5) を呼びました
// func mfibo(4) を呼びました
// func mfibo(3) を呼びました
// func mfibo(2) を呼びました
// func mfibo(1) を呼びました
// func mfibo(0) を呼びました
// func mfibo(1) を呼びました
// メモ化した値を返しました
// func mfibo(2) を呼びました
// メモ化した値を返しました
// func mfibo(3) を呼びました
// メモ化した値を返しました
// memo =>  [0 1 1 2 3 5]

再帰関数は複雑なコードをシンプルに表現することができる手法。

しかし、無駄な計算量コストが増えてしまいがちなので、メモ化して無駄な計算コストを防ぐことが大事だということを学んだ。

アルゴリズムとは無関係の話だが、Go言語は再帰を使うと遅くなるらしい。。。

ymotongpoo.hatenablog.com

原因は、再帰関数の引数などを保管する「スタック領域」がGo言語はデフォルトが小さい、かつスタック領域の大きさを最小にするような最適化を行わないかららしい。。。

つまり、再帰呼び出しを行う度に一定量のスタック領域を食いつぶしていくイメージなのかな。。。

あと、再帰をつかったときの計算量がよくわからない。。。今後の課題である🤔

次は、「動的計画法」をやる。

設計技法「全探索」を学ぶ

最近はアルゴリズムを勉強している。

なかなか頭に入ってこないので、アウトプットをして整理する。

なお、今回は設計技法「全探索」の話。

アルゴリズムを設計するうえで重要な基礎となるのが「全探索」。

  • 「全探索」とは何か? => 解きたい問題に対して、考えられる可能性をすべて調べ上げることによって解決する手法。

高速なアルゴリズムを設計したい場合であっても、まず最初に力任せの全探索手法を考えることが有効な場合もあるらしい。

なぜなら、「全探索」をすれば遅くてもすべてを調べ上げることによって、問題を解決することができるから。

  • 線形探索法(計算量:O(N))
N個の整数 a0,a1,..., aN-1 と整数vが与えられる。ai = V となるデータが存在するかどうかを判定してください。

これも解くと以下のようになる。

func main() {
    fmt.Println("N個の整数 a0,a1,..., aN-1 と整数vが与えられる。ai = V となるデータが存在するかどうかを判定してください。")

    var N int
    fmt.Print("N:")
    _, n_err := fmt.Scan(&N)
    if n_err != nil {
        fmt.Println("input error.")
        return
    }

    a := make([]int, N, N)
    for i := 0; i < N; i++ {
        fmt.Printf("a[%d]:", i)
        _, err := fmt.Scan(&a[i])
        if err != nil {
            fmt.Println("input error.")
            return
        }
    }

    var V int
    fmt.Print("V:")
    _, v_err := fmt.Scan(&V)
    if v_err != nil {
        fmt.Println("input error.")
        return
    }

    exist := false
    for _, v := range a {
        if v == V {
            exist = true
            break
        }
    }

    fmt.Println(exist)
}

全探索は応用範囲が広く、全探索する = すべての値を参照するという特徴から以下のようなすべての値を参照しないとわからないようなものを見つけることに向いている。

  • 見つけたい値の添字の取得
  • 最小値・最大値の取得

次は応用問題で、与えられたデータの中から最適なペアを探す問題を解いてみる。

N個の整数a0,a1....aN-1とN個の整数b0,b1...bN-1が与えられる。
2組の正数列からそれぞれ1個ずつ整数を選んで和を取ります。その和として考えられる値のうち、整数K以上の範囲内で最小値を求めてください。
package main

import (
    "fmt"
    "math"
)

func main() {
    fmt.Println("N個の整数a0,a1....aN-1とN個の整数b0,b1...bN-1が与えられる。\n2組の正数列からそれぞれ1個ずつ整数を選んで和を取ります。その和として考えられる値のうち、整数K以上の範囲内で最小値を求めてください。")

    var N int
    fmt.Print("N:")
    _, n_err := fmt.Scan(&N)
    if n_err != nil {
        fmt.Println("input error.")
        return
    }

    a := make([]int, N, N)
    for i := 0; i < N; i++ {
        fmt.Printf("a[%d]:", i)
        _, a_err := fmt.Scan(&a[i])
        if a_err != nil {
            fmt.Println("input error.")
            return
        }
    }

    b := make([]int, N, N)
    for i := 0; i < N; i++ {
        fmt.Printf("b[%d]:", i)
        _, b_err := fmt.Scan(&b[i])
        if b_err != nil {
            fmt.Println("input error.")
            return
        }
    }

    var K int
    fmt.Print("K:")
    _, k_err := fmt.Scan(&K)
    if k_err != nil {
        fmt.Println("input error.")
        return
    }

    pair_min_value := math.MaxInt64
    for i := 0; i < N; i++ {
        for j := 0; j < N; j++ {
            sum := a[i] + b[j]
            if sum <= K {
                continue
            }
            if sum < pair_min_value {
                pair_min_value = sum
            }
        }
    }

    fmt.Println(pair_min_value)
}

問題文を見て、難しいことを色々考えるよりも先に全探索でできないかを最初に考えてみると良さそう。

次は、設計技法「再帰」の話。

O記法(オーダー記法)を学ぶ

最近はアルゴリズムを勉強している。

なかなか頭に入ってこないので、アウトプットをして整理する。

なお、今回はO記法(オーダー記法)の話。

  • O記法(オーダー記法)とは何か? ⇒ 計算量を大雑把に評価したものを表現できる記法のこと。

どんな記法か? ⇒ O(n)やO(n2)やO(log n)といった感じで表現する。

本当は3つだけはないのだが、私のような初心者はとりあえず3つ覚えておけば良いと思っている。

あとは、新しく出てきたときに随時覚える。

なお、計算量のO記法(オーダー記法)の考え方は、for文等の反復処理に要する計算時間を測定する。

// 計算量: O(n)
for i := 1; i <= n; i++ {
    // なにかしらの処理
}

// 計算量: O(n^2)
for i := 1; i <= n; i++ {
    for j := 1; j <= n; j++ {
        // なにかしらの処理
    }
}

// 計算量: O(log n)
func binary_search(key int) int {
    result := -1
    left := 0
    right := len(a) - 1

    for right >= left {
        mid := left + (right-left)/2
        if a[mid] == key {
            result = mid
            break
        } else if a[mid] > key {
            right = mid - 1
        } else if a[mid] < key {
            left = mid + 1
        }
    }
    return result
}

O(n) について

n に比例した回数の反復処理に要する計算時間。

  • n=2の場合: 2 ⇒ O(2) // 2回反復が必要
  • n=3の場合: 3 ⇒ O(3) // 3回反復が必要
  • n=4の場合: 4 ⇒ O(4) // 4回反復が必要

コードで表すと、以下のような for 文(反復処理)になる

for i := 1; i <= n; i++ {
    // なにかしらの処理
}

O(n2)について

n2 に比例した回数の反復処理に要する計算時間。

n2指数指数 とは、「ある数・文字の右肩に記して、それを何度掛け合わせるかを示す数字・文字」のこと。

  • n=2の場合: 22 = 4 // 4回反復が必要
  • n=3の場合: 23 = 8 // 8回反復が必要
  • n=4の場合: 24 = 16 // 16回反復が必要

指数が2なので、nが倍あるということ。O(n)と同じようにコードで表すと、以下のような for 文(反復処理)になる。

O(n2)はその倍。O(n3)はその倍の倍になる。

// 計算量: O(n^2)
for i := 1; i <= n; i++ {
    for j := 1; j <= n; j++ {
        // なにかしらの処理
    }
}

// 計算量: O(n^3)
for i := 1; i <= n; i++ {
    for j := 1; j <= n; j++ {
    for k:= 1; k <= n; k++ {
          // なにかしらの処理
    }
    }
}

O(lon n)について

log n に比例した回数の反復処理に要する計算時間。

log は、対数対数とは指数の逆の概念。

指数は、「ある数・文字の右肩に記して、それを何度掛け合わせるかを示す数字・文字」を指した。

対数は、「指数の結果に対して、何度掛け合わせればその結果になるのか」を表す。

たとえば、23 = 8 で 「2を何度回掛け合わせるれば8となるか」を表す。答えは3になる。

ここで、実は対数指数の数と同じになることに気づく。

「2を何度回掛け合わせるれば8となるか」は、逆に言えば「8を何回2で割れば1になるか」とも言える。

O(n2)のときと同じように考えてみる。(底は2と仮定する)

  • n=2の場合: log2(2) = 1 // 1回反復が必要
  • n=3の場合: log2(3) = 1.5 // 1.5回反復が必要
  • n=4の場合: log2(4) = 2 // 2回反復が必要
  • n=8の場合: log2(8) = 3 // 3回反復が必要

O(n)と比べると、反復回数がnに比例して半分になっていることがわかる。

以前書いた二分探索法を思い出す。二分探索法というのは、「真ん中で切手片方に絞っていく方法」。

つまり、反復回数がnに比例して半分する二分探索法のようなものは計算量がO(log n)ということになる。

以下、二分探索をコードで表すと以下のような for 文(反復処理)になる。

func binary_search(key int) int {
    result := -1
    left := 0
    right := len(a) - 1

    for right >= left {
        mid := left + (right-left)/2
        if a[mid] == key {
            result = mid
            break
        } else if a[mid] > key {
            right = mid - 1
        } else if a[mid] < key {
            left = mid + 1
        }
    }
    return result
}

いきなり二分探索法のコードが出てくると混乱するが、単純にO(log n)をコードで表したいだけなら、以下のような for 文(反復処理)でも良い。

for i := 1; i <= n; i = i * 2 {
    // なにかしらの処理
}

O記法(オーダー記法)のO(n)やO(n2)やO(log n)を反復処理で書くと、どのような実装になるかがわかった。

O(log n) < O(n) < O(n2)の順番に早いのだということもわかった。

一般家庭にあるPCで1秒間で処理できる計算ステップ回数は109 = 1,000,000,000回 程度と言われてるらしい。

この3つに対して、nに応じた計算ステップ回数の変化を表で表現してみると、O(log n)のアルゴリズムが大変高速であることがわかる。

n log n n2
5 2 25
100 7 10,000
1,000 10 1,000,000
10,000 13 1,000,000,000
100,000 17 -
1,000,000 20 -
10,000,000 23 -
100,000,000 27 -
1,000,000,000 30 -

ちなみに、O記法(オーダー記法)は定数倍の違いや低の違いはすべて無視する。

その理由は、O記法(オーダー記法)が、そもそも計算量を大雑把に評価したものである。というところあり、計算量というのは、コンピュータの環境やプログラミング言語コンパイラによっても変わってくるので、アルゴリズムの計算時間を議論するときには定数倍や低次の項の影響を受けないようにするために、O記法(オーダー記法)は定数倍の違いや低の違いはすべて無視するようにしている。

ここまでが、アルゴリズムの概要。次はいよいよ設計技法。

アルゴリズムを学びはじめてみた

最近はアルゴリズムを勉強している。

なかなか頭に入ってこないので、アウトプットをして整理する。

なお、言語はGoで書いている。

  • アルゴリズムとはなにか? ⇒「問題を解くための方法,手順」のことを指す。

たとえば、「FizzBuzz問題」や「自動販売機お釣り計算」を解くのにもアルゴリズムが使われるし、アルゴリズムといえば・・・?で、頻繁に出てくる「探索問題」や「グラフ問題」を解くのにもアルゴリズムが使われる。

ためしに「FizzBuzz問題」を解いてみる。

package main

import (
    "fmt"
    "strconv"
)

func FizzBuzz(i int) string {
    if i%15 == 0 {
        return "FizzBuzz"
    } else if i%5 == 0 {
        return "Buzz"
    } else if i%3 == 0 {
        return "Fizz"
    } else {
        return strconv.Itoa(i)
    }
}

func main() {
    fmt.Println("1から標準入力から入れる値(INPUT値)までの数を順に出力するプログラム。\nただし、3の倍数のときは数の代わりに「Fizz」5の倍数のときは「Buzz」3と5の両方の倍数の場合には「FizzBuzz」を出力する。")
    fmt.Print("INPUT:")

    var n int
    _, err := fmt.Scan(&n)

    if err != nil {
        fmt.Println("err: INPUT.")
        return
    }

    for i := 1; i <= n; i++ {
        fmt.Println(FizzBuzz(i))
    }
}

FizzBuzz問題」程度であれば、解き方の種類がそれほど多く存在しないが、難しい問題を解いた時、自分の書いたアルゴリズムがはたして良いアルゴリズムなのかどうかが判断できない。

その判断の材料となるのが、計算量である。

  • 計算量とは何か? ⇒ 「コンピュータ上での実行に要する時間」のことを指す。

計算量がわかれば、実装しようとしているアルゴリズムがコンピュータ上でどの程度時間を要するのか、あらかじめ大雑把に見積もる事ができる。

ある問題に対して、2種類の解き方を思いついたときに、どちらの方がより優れたアルゴリズムなのかが計算量によって判断できるので、計算量は非常に重要な考え方となる。

たとえば、以下のような「年齢当てゲーム」の場合を想像する。

Aさんの年齢を当てたいと思います。

Aさんの年齢が20歳以上36歳未満であることはわかっているものとします。

Aさんに複数回「Yes / No で答えられる質問」をすることができます。

何回の質問で、Aさんの年齢を当てることができるでしょうか?

この問題は有名で、解き方は大きく2つある。

1つは「Aさんは20歳ですか?」「Aさんは21歳ですか?」・・・と、年齢を当てるまで順番に繰り返す方法

もう1つの解き方は、「Aさんは28歳以上ですか?」と、真ん中で切手片方に絞っていく方法

前者の方法を線形探索法、後者の方法を二分探索法と呼び、以下の通り質問回数が変わってくる。

  • 線形探索法: 17回
  • 二分探索法: 5回

年齢の範囲が「20歳以上36歳未満」という前提条件があるため、質問回数の差は少ないが、前提条件の範囲が大きければ大きいほど2つの差は大きくなる。

計算量を数値で表現するのには限界がある。

先程の「年齢当てゲーム」程度の問題であれば、線形探索法を使えば17回、二分探索法を使えば5回。とか言えるが、前提条件の年齢の範囲が大きくなると、「なんか線形探索法の方が質問回数が増えそう・・・」くらいしか言えず、数値での表現に限界がでてくる。

そこで出てくるのが、O記法(オーダー記法)。

というわけで、次回はO記法(オーダー記法)について書く。

「花粉」がひどい・・・

久しぶりの投稿。アウトプットが滞った理由をこれから書きます。(つまり、言い訳である)

2月末から3月末にかけて、とても辛かったんです。

何が辛かったかというと、「花粉」。

「花粉」はダメだ。人間のありとあらゆる「やる気」を削ぐ。

毎年「花粉」で辛い思いをするのだが、今年は特に鼻よりも目が辛いと感じる日々が続いた。

耳鼻科へ行き、薬を処方してもらうことで、「花粉」の悪夢から逃れることができたものの、今度は「薬の副作用」という悪夢が現れ、毎日のように頭痛を抱えながら仕事をしていた。

最近は、花粉の種類がスギからブタクサに変わりはじめたことで、だいぶマシになった(気がする)。

(どうやら私はスギ花粉の方が症状が重いらしい。。。)

さて、そんな「花粉」に体力とやる気を奪われながらも、近況を書いてみる。

AWSのソリューションアーキテクトを再取得した後、このままプロフェッショナルも取れば良いじゃない♪ と思い、少しやる気になって勉強を始めたのだが、「花粉」に体力とやる気を奪われて、挫折してしまった。

そこで、今年はコンピュータ・サイエンスを学び直そう。もっと言えば、アルゴリズムを学ぼうと年初に決めていたので、そちらを優先することにした。

というわけで、以前に購入していた以下本2冊を現在熟読中である。

しかし、この歳で数学的な話になると、ついていけない話が多く、これは1回読むだけじゃ終わらないな。。。という気がしている。

Go言語を書きたかったので、アルゴリズムはGo言語で書いている。

4月になったので、今後はアルゴリズム関連ネタでアウトプットするとしよう。

この挑戦は長期戦だろう。(続く)