Codeへの愛とCuriosity

CodeIQ への出題・解説解題・その周辺について、思いつくままに。

「中学入試から:正三角形?二等辺?」の、解説・解題

CodeIQ に「中学入試から:正三角形?二等辺?」という問題
https://codeiq.jp/ace/nabetani_takenori/q1097
を出した。

中学入試算数問題第3弾。

挑戦の募集はすでに締めきっている。
というわけで、解説・解題。

で。
まずは問題

問題

■問題の概要
中学入試の問題に、以下の様な問題が出ることがあります。

三角形ABCがあります。
角C=30度,BA=2cm,CA=2cm です。
この三角形の性質について正しく述べているものを下記のあ〜うからひとつ選んで下さい。
 あ)正三角形である。
 い)二等辺三角形である。(ただし、正三角形であるとはいえない。)
 う)二等辺三角形ではない。または、二等辺三角形かどうかわからない。

ABとACが等しいので、二等辺三角形。つまり、「い」が正解です。

この問題を

三角形ABCがあります。
【 A 】 です。
この三角形の性質について正しく述べているものを下記のあ〜うからひとつ選んで下さい。
 あ)正三角形である。
 い)二等辺三角形である。(ただし、正三角形であるとはいえない。)
 う)二等辺三角形ではない。または、二等辺三角形かどうかわからない。

と考え、A を入力として与えます。問題の答えを計算するプログラムを書いて下さい。

■具体的な課題
下記リンクの zip 内に data.utf8.txt および data.sjis.txt というファイルがあります。
これらのファイルの各行は
 「データID、空欄Aの内容、空欄Aの内容に対応した答え」
を TAB区切りでならべたものです。
ほとんどのデータは正しい答えになっています。
答えが間違っているデータが数件あります。それらのデータの ID を答えて下さい。

data.utf8.txt と data.sjis.txt は、文字コードが異なるだけで内容はまったく同じです。
お好きな方をお使い下さい。

■補足
・データIDがTで始まるデータは、すべて正しい答えになっています。テストにお使い下さい。
・不正な入力に対処する必要はありません。
・間違いの数は、5件より多く、20件より少なくなっています。
・空欄Aに入る内容は、長さまたは角度について記述されたものをコンマ区切りでならべたものです。
・長さについては「AB=3cm」のような形式です。長さの単位はすべて cm です。
・角度については「角A=30度」のような形式です。頭に「角」があり、単位は「度」です。


■資料
http://nabetani.sakura.ne.jp/codeiq/q1097.zip

上記リンク先の zip ファイルの中には、以下のファイル含まれています

・ data.utf8.txt
 → 調査対象およびテストデータです。文字コードUTF-8 になっています。

・ data.sjis.txt
 → 調査対象およびテストデータです。文字コードShift_JIS になっています。

■解答形式
「【解答】」の行に続いて解答をお書き下さい。
「【感想・工夫した点など】」の行に続いて、感想、引っかかった点、工夫した点、問題が曖昧だと感じた場合はそこをどう解釈したか、などをお書き下さい。
「【言語と処理系】」の行に続いて、言語名、処理系のバージョン、コマンドラインオプションなどをお書き下さい。
「【ソースコード】」の行に続いて、ソースコードをお書き下さい。ソースコード複数になるようでしたら、その旨がわかるようにお願いします。

こんな感じ。

で。
今回も若干の出題ミスのようなものがあった。
三角形が成立しないデータが含まれていた。すいません。
作図可能なものについてはきちんと調べたんだが、作図するには情報が不足しているものについてのチェックが足りていなかった。

すいません。

出題の背景と意図とか

当初は平行四辺形とか菱形にしようと思っていたんだけど、条件の量が多くなるので三角形にしてみた。
簡単だったでしょ?

実装戦略と例

出題者(私)の意図通り、完全に小学生の算数の範囲で解くことにして、入力データのバリデーションを一切しないのであれば、すごく簡単になる。

バリデーションなしの実装 ( ruby )

def solve(q)
  terms = q.split(",")
  kv=terms.each_with_object(Hash.new(0)){ |t,h|
    h[ /(\d+(?:cm|))/.match( t )[1] ] += 1
  }
  angles=kv.flat_map{ |k,v| /(\d+)/===k ? [$1.to_i]*v : [] }
  kv["#{180-angles.inject(&:+)}"]+=1 if angles.size==2 # 2角がわかっているので、残り1つもわかる。
  case kv.values.max
  when 0, 1 then ""# 重複なし
  when 2 then 0<kv["60度"] ? "" : "" # 二辺または二角が等しい
  when 3 then "" # 三辺または三角が等しい
  else raise "unexpected : #{q}"
  end
end

open( "../package/data.utf8.txt", "r:utf-8" ) do |f|
  f.flat_map{ |line|
    line.strip!
    id, q, ans = line.split(/\t+/ )
    actual = solve(q)
    ok = ans==actual
    puts [line, id, q, ans, actual ].join(" / ") unless ok
    ok ? [] : [id]
  }.join(",").tap{ |s| puts s }
end

レビューコメントで字句解析部と判定部を分けるべきだと書いているのでちょっと申し訳ないような気持ちもあるんだけど、あんまり短いのでひとつのメソッドにしてしまっている。
すいません。

実装戦略はまあ見ての通りなんだけど、一応解説しておくと。

  • 「60度」のような文字列をキーにして、それの文字列の出現回数を値にする Hash を作る。
  • Hash の値の最大値を見る。 → 3 なら 正三角形、など

というもの。

たぶんポイントは、角の名前や辺の名前を一切無視してよいという点にあると思う。

バリデーションありの実装 ( ruby )

挑戦いただいた方の中には、入力データのバリデーションをやってくださった方がいた。
それはまあ意図しないことではあったんだけど、全部バリデーションするとこれぐらいになるよ、というのも書いておく。

Equilateral="" # 正三角形
Isosceles="" # 二等辺三角形
Common="" # 普通の三角形
Impossible = "う!" # 三角形が作れない

# 内角の和で角度を補完する
def complete_by_interior_angles( a0 )
  angles = a0.dup
  missings = "ABC".chars - angles.keys
  if missings.size==1
    angles[missings[0]]=angles.values.inject( 180 ){ |acc, val| acc-val }
  end
  angles
end

# 角度の値が不正なら真を返す
def invalid_interior_angles( angles )
  return true if angles.values.any?{ |x| x<=0 || 180<=x }
  angles.size==3 && angles.values.inject(&:+)!=180
end

# 浮動小数点の誤差をごまかす等号
def almost_eq( a, b )
  (a-b).abs < 1e-5
end

# 浮動小数点の誤差をごまかしつつ uniq する
def almost_uniq(v0)
  v=v0.sort
  v.inject([]){ |acc, e|
    if acc.empty? || !almost_eq( acc.last, e )
      acc+[e]
    else
      acc
    end
  }
end

# 等しい物の数を数えるだけで判断する
def solve_simple( h )
  v = h.values
  return Impossible if v.min<=0
  m=v.max
  values = (v + [m+1, m+2] ).take(3)
  case almost_uniq(values).size
  when 1; Equilateral
  when 2; Isosceles
  when 3; Common
  else
    raise "logic error"
  end
end

# 弧度法を使った三角関数と逆三角関数
def sina(angle); Math.sin( angle*Math::PI/180 ); end
def cosa(angle); Math.cos( angle*Math::PI/180 ); end
def acosa(r); Math.acos(r)*180/Math::PI; end
def asina(r); Math.asin(r)*180/Math::PI; end

# 正弦定理を使って辺を求めて解く
def solve_by_sin( a, e )
  raise "logic error" unless a.size==3  && 0<e.size
  n=e.keys.first
  r=sina(a[n]) / e[n]
  a.keys.each do |m|
    expected = sina(a[m]) / r
    return Impossible if expected<=0
    if e[m]
      return Impossible unless almost_eq( e[m], expected )
    else
      e[m] = expected
    end
  end
  solve_simple( a )
end

# 二辺挟角なので余弦定理で辺を求めて解く
def solve_by_cos2(a, e)
  x=a.keys.first
  y,z=e.values
  expected2 = y*y+z*z-2*y*z*cosa(a.values.first)
  return Impossible unless 0<expected2
  e[x]=expected2**0.5
  solve_simple(e)
end

# 二辺と、挟角ではない角
def solve_by_sin2(a, e)
  x=a.keys.first
  y,=e.keys - [x]
  sin_y=sina(a[x]) * e[y] / e[x]
  return Impossible unless 0<sin_y && sin_y<=1
  ay0 = asina(sin_y)
  ay1 = 180-ay0
  [ay0, ay1].map{ |ay|
    solve_simple( complete_by_interior_angles(a.merge( y=>ay )) )
  }.select{ |r| r.size==1 }.max || Impossible # 文字コードがあいうの順になっていることに依存
end

# 余弦定理を使って角度を求めて解く
def solve_by_cos( a, e )
  raise "logic error" unless e.size==3
  3.times do |rot|
    names = %w(A B C).rotate(rot)
    ea, eb, ec = names.map{ |n| e[n] }
    expected = acosa( (eb*eb+ec*ec-ea*ea)/(2.0*eb*ec) )
    n=names.first
    if a[n]
      return Impossible unless almost_eq( a[n], expected )
    else
      a[n]=expected
    end
  end
  solve_simple( e )
end

# 字句解析。定義の重複についてはエラーを出す
def parse_problem( src )
  src.split(",").each_with_object( Array.new(2){{}} ) do |item,o|
    case item
    when /\A([ABC])\=(\d+)\z/
      raise "duplicate angle #{$1} in #{item}" if o[0][$1]
      o[0][$1]=$2.to_i
    when /\A([ABC]+)\=(\d+)cm\z/
      key = ( %w(A B C ) - $1.chars ).first
      raise "duplicate edge #{$1} in #{item}" if o[1][key]
      o[1][key]=$2.to_i
    else
      raise "unexpected item : #{item}"
    end
  end
end

# 三辺の長さとしてあり得るなら真
def acceptable_edges(e)
  raise "logic error" unless e.size==3
  a,b,c=e.values.sort
  return c<a+b
end

#問題を解く、主要部分
def solve_core( angles, edges )
  case [angles.size, edges.size].join
  when /[01][01]/; Common # 情報不足
  when /30/; solve_simple( angles )
  when /03/; 
    if acceptable_edges( edges )
      solve_simple( edges )
    else
      Impossible
    end
  when /3[123]/; solve_by_sin(angles, edges)
  when /[0123]3/; solve_by_cos(angles, edges)
  when /12/;
    if %w(A B C)==[angles.keys, edges.keys].flatten.sort
      solve_by_cos2(angles, edges)
    else
      solve_by_sin2(angles, edges)
    end
  when /02/;
    solve_simple( edges )
  else
    raise "logic error : #{[angles.size, edges.size]}"
  end
end

#問題を解く
def solve(src)
  angles, edges = parse_problem(src)
  angles = complete_by_interior_angles( angles )
  return Impossible if  invalid_interior_angles( angles )
  solve_core( angles, edges )
end

if __FILE__==$0
  File.open( "../../../package/data.utf8.txt", "r:utf-8" ) do |f|
    inco=[]
    imp=[]
    f.each do |line|
      id, src, expected = line.strip.split(/\t+/)
      actual = solve(src)
      inco<<id if actual[0,1] != expected
      imp<<id if 1<actual.size
    end
    puts "wrong answers : "+inco.join(",")
    puts "impossible triangles : "+imp.join(",")
  end
end

とまあ、200行弱。

バリデーションがあると、角や辺の名前を捨てる訳にはいかない。
実装戦略は

  • 既知の角/辺 の数と配置によって分岐。
  • 分岐先で、余弦定理や正弦定理を使って残りの角や辺を求めたりバリデーションしたり。
  • 最終的には、何かが 3個あったら正三角形、2個あったら二等辺、というようなところに落ちる。
  • 等値性は、浮動小数点なので適当に誤差を許して比較する。

実はこのバリデーションを一生懸命している長い実装のほうがよろしくない。
浮動小数点を使っている関係で、厳密に等しいかどうかの判定ができないからだ。

具体的には「BC=33379cm,AC=96111cm,角C=80度」のような入力で間違える。
この入力は二辺と挟まれる角なので、余弦定理で AB を求めることができて、AB=96110.999998cm ぐらいになる。
AC と ほぼ 同じなので二等辺三角形だと思い込んで「い」を返すんだけど、AB と AC は厳密に等しいわけではない。
今回の課題に含まれるデータには小さな整数しか入っていないので問題ないんだけど、三角関数で不足している辺や角を求めてしまった方は、この問題に意識的であるべきだと思う。

数学的に厳密に

実は。
「3辺が整数で、3つの角も弧度法表示で整数になる三角形は、正三角形である」
という定理があるので、数学的に厳密な実装という観点では三角関数は不要ということがわかっている。

三角形のバリデーションをするのには必要だと思うけど。

感想など

今回も、テストせずに提出するのはほぼ無理という形で出題したのが功を奏し、ほぼ全員が正解だった。
おめでとう!

いつも通り、締切後はコード公開大歓迎。
コードを公開したら、 twitter などで知らせてくれると見つけやすくて助かります。

そうそう。
解答の数字は円周率。円周率だけだと間がひろく空いてしまう場所があったので、ゾロ目も混ぜてみた。

最後に

次の問題は準備中。今回よりさらに簡単になる予定。

それと。
第27回 オフラインリアルタイムどう書く( http://yhpg.doorkeeper.jp/events/16017 ) やります。
11月7日(金) 横浜。

お暇とご興味のある方は是非。