はじめに
この記事は、以下の記事で紹介した初期研修を終えて現場に配属された新人たちに課している練習問題の出題意図や回答例を紹介するものです。
練習問題は特定の言語に依存しないよう作成したつもりですが、私が主にC#の開発に携わっているので以下の回答例はC#のみ作成しています。
全体的な出題意図
主に初期研修(プログラミング等の集合研修)を終えた新人に対して、プログラミング言語への理解度の確認を行う為に出題しました。
集合研修ではあまり提示された仕様を満たすプログラムを作成する練習をしていないようでしたので、提示された仕様から自由にプログラムを作成する練習としても活用できるよう意図しています。
問題文に提示した仕様は、複数の解釈が残るように敢えて細かい指定を省略しています。
回答者は明示された仕様を確実に満たしつつ、明示されていない事項を自由に定めて良いとすることで能動的なプログラム作成行動を促します。
また、以下の問題は単に解かせるだけではなく、問題への取り組みを通して先輩と新人がコミュニケーションを交わせることを目的の一つとしています。
これを通して、お互いのキャラクター(性格やクセ等)を理解し、今後の指導に役立てる準備となることを期待します。
補足
以下の回答例は、正解ではなく、あくまで例となります。
これが唯一の正しいプログラムということも無く、むしろ仕様を満たすことを目的とした場合には不必要な記述さえあります(例えば、回答例ではクラスを多く設けていますが、その全てがなくても仕様を満たすプログラムが作成できます)。
状況によっても求められるプログラムは変わりますので、「このような書き方も出来る」程度に読み取ってもらえると幸いです。
また、回答例は .NET Framework 4.7.2 で動作確認しています。
第3問を除いて、using句は新規クラス作成時に自動作成されるもので十分ですが、.NET Core の場合はusing句が省略されます。
以下のusing句が必要になる場合があるのでご確認ください。
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO; // 第3問のみ
問題ごとの出題意図・回答例
1. Fizz Buzz 問題
問題
問題文はこちら(再掲)
(1) 可能な限り、少ない行数となるように工夫すること。
(2) 可能な限り、操作ごとにメソッドを分割し、適切な名称を付与すること。
※ Fizz Buzz 問題 : 以下の通り
- 1 から順に番号を出力する
- ただし、3 の倍数 の場合は "Fizz"、5 の倍数 の場合は "Buzz"、両者の公倍数の場合は "Fizz Buzz" を数字の代わりに出力する
出題意図
Fizz Buzz 問題 は有名ですが、これを解くには様々なアプローチがあるので、回答者から出題者まで楽しめると思い、採用しました。
今回は2通りの方針で作成されることで、同じ問題を複数の切り口で考えさせることを意図しました。
行数を減らす為には3項演算子が有用ですので、これに気付いてもらうことが重要です。
メソッドを分割する為には、仕様をできるだけ細かく処理に落とし込む必要があります。また、適切なメソッド名を付与する為には、処理内容には一言で表せる具体性が必要になります。
これらに気付き、工夫してもらうことを目指しました。
回答例
回答例はこちら
public static void Main(string[] args)
{
Enumerable.Range(1, 100).ToList().ForEach(i => Console.WriteLine(string.Format("{0} {1}", i % 3 == 0 ? "Fizz" : "", i % 5 == 0 ? "Buzz" : i % 3 == 0 ? "" : i.ToString()).Trim()));
}
public static void Main(string[] args)
{
// 1 ~ 100 の FizzBuzz配列 を作成
var list = CreateFizzBuzzArray(1, 100);
// FizzBuzz問題の解を順に出力する
foreach (var fz in list)
{
Console.WriteLine(fz);
}
}
/// <summary>
/// 指定した 開始値 から 終了値 までの整数値を元に FizzBuzzインスタンス を作成し、配列で返す。
/// </summary>
/// <param name="min">開始値</param>
/// <param name="max">終了値</param>
/// <returns>FizzBuzzインスタンス配列</returns>
private static FizzBuzz[] CreateFizzBuzzArray(int min, int max)
{
// min ~ max の連番Sequenceを作成し、新規FizzBuzzインスタンスに射影して配列で返す
return Enumerable.Range(min, max - min + 1).Select(i => new FizzBuzz(i)).ToArray();
}
/// <summary>
/// FizzBuzzクラス
/// </summary>
private class FizzBuzz
{
/// <summary>
/// 対象とする数値
/// </summary>
private readonly int i;
/// <summary>
/// Fizzを出力する条件を満たす
/// </summary>
public bool IsFizz => this.i % 3 == 0;
/// <summary>
/// Buzzを出力する条件を満たす
/// </summary>
public bool IsBuzz => this.i % 5 == 0;
/// <summary>
/// 数値を出力する条件を満たす
/// </summary>
public bool IsNumeric => !(this.IsFizz || this.IsBuzz);
/// <summary>
/// Fizzを出力する条件を満たす場合 "Fizz"、そうでない場合は空文字列
/// </summary>
public string Fizz => this.IsFizz ? "Fizz" : string.Empty;
/// <summary>
/// Buzzを出力する条件を満たす場合 "Buzz"、そうでない場合は空文字列
/// </summary>
public string Buzz => this.IsBuzz ? "Buzz" : string.Empty;
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="i">FizzBuzz問題の対象とする数値</param>
public FizzBuzz(int i)
{
this.i = i;
}
/// <summary>
/// このインスタンスに設定された数値を、それと等価な"FizzBuzz"文字列形式に変換します。
/// </summary>
/// <returns>FizzBuzz問題の解</returns>
public override string ToString()
{
if (this.IsNumeric)
{
return this.i.ToString();
}
return $"{this.Fizz} {this.Buzz}".Trim();
}
}
2. 選択問題
(1) 素数
問題
問題文はこちら(再掲)
※ 素数 : 1 より大きく、正の約数が 1 と 自身 のみである 自然数
出題意図
特定の条件を満たす数値だけを抽出させる処理です。
素数をプログラミング的にどのように判断するかを考える練習となります。
また、判定方法は効率性等でいくつもありますので、複数ある解決策からいずれかを選ぶ練習にもなるかと思います。
回答例
回答例はこちら
public static void Main(string[] args)
{
// 100までの素数を順次出力する
foreach (var prime in PrimeNumbers(100))
{
Console.WriteLine(prime);
}
}
/// <summary>
/// 指定した整数値以下の素数を列挙する。
/// </summary>
/// <param name="max">取得する素数の最大値</param>
/// <returns>素数</returns>
private static IEnumerable<int> PrimeNumbers(int max)
{
// 2未満の場合は空(素数なし)
if (max < 2)
{
yield break;
}
// 素数リスト(判定済)
var primeList = new List<int>();
// 指定数値が素数であるか判定する関数
bool isPrime(int v)
{
// 素数で割り切れるか判断
foreach (var prime in primeList)
{
// 対象数値の平方根未満の素数で割り切れるものが無ければ、素数
if (prime * prime > v)
{
break;
}
// 対象数値未満の素数で割り切れたら、素数ではない
if (v % prime == 0)
{
return false;
}
}
// 素数
primeList.Add(v);
return true;
}
// 2 ~ 最大値 までの数値について素数のみ返す
foreach (var prime in Enumerable.Range(2, max - 1).Where(isPrime))
{
yield return prime;
}
}
(2) うるう年判定
問題
問題文はこちら(再掲)
※ うるう年 : 以下の通り判定すること
- 西暦年 が 4 で割り切れる場合、うるう年とする
- ただし、西暦年が 100 で割り切れる場合、うるう年では無い
- ただし、西暦年が 400 で割り切れる場合、うるう年とする
出題意図
コマンドライン引数に応じた判定を行う処理です。
コマンドライン引数は必ずしもプログラマの意図通りに設定されるとは限らないので、バリデーション処理が必要になります。
適切なバリデーションを実装する練習として提示しました。
また、うるう年の判定は提示した文章はそのままプログラム言語に置き換えることが出来ません("ただし"という文言がある為)。
これをどのようにプログラムに落とし込むかも見どころです。
回答例
回答例はこちら
public static void Main(string[] args)
{
// 引数チェック
if (!Validate(args, out var year))
{
return;
}
// 指定された年がうるう年か判定、結果を出力
if (IsLeapYear(year))
{
Console.WriteLine($"{year}年はうるう年です。");
}
else
{
Console.WriteLine($"{year}年はうるう年ではありません。");
}
}
/// <summary>
/// 実行引数のバリデーションを行う
/// </summary>
/// <param name="args">実行引数</param>
/// <param name="year">年(引数から取得)</param>
/// <returns>問題が無い場合、True</returns>
private static bool Validate(string[] args, out int year)
{
// 初期化
year = 0;
// 引数なし
if (args.Length == 0)
{
Console.WriteLine("引数に 年 を指定してください。");
return false;
}
// 第1引数チェック(年)
if (int.TryParse(args[0], out year))
{
if (year <= 0)
{
Console.WriteLine("年 は 正の整数 で入力してください。");
return false;
}
}
else
{
Console.WriteLine("年 は 整数 で入力してください。");
return false;
}
// 問題なし
return true;
}
/// <summary>
/// 指定した年がうるう年か判定する
/// </summary>
/// <param name="year">年</param>
/// <returns>うるう年の場合、True</returns>
private static bool IsLeapYear(int year)
{
if (year % 4 > 0)
{
return false;
}
if (year % 100 == 0)
{
if (year % 400 == 0)
{
return true;
}
return false;
}
return true;
}
(3) 乱数
問題
問題文はこちら(再掲)
- 01 ~ 43 までの 43個の数字 から 異なる6個 を選択し、出力する
出題意図
乱数を用いた「実行するたびに結果が変わる」典型的な処理となります。
また、異なる数字を複数選択しなくてはならないので、既に選択された数字を何らかの方法で判別しなくてはなりません。
この問題も解決方法がいくつもあるので、そのいずれかを選んでプログラムにする必要があります。
回答例
回答例はこちら
public static void Main(string[] args)
{
// 初期化
var numbers = new Numbers();
// Numbersインスタンスからランダムで6個の数値を取得(昇順ソート)
var balls = numbers.GetBalls(6).OrderBy(i => i).ToArray();
// 選択された数値をスペース区切りで出力する
Console.WriteLine(string.Join(" ", balls.Select(i => i.ToString("00"))));
}
/// <summary>
/// 出力数値管理クラス
/// </summary>
private class Numbers
{
/// <summary>
/// 管理対象の数値リスト
/// </summary>
private readonly List<int> balls;
/// <summary>
/// 乱数ジェネレータ
/// </summary>
private readonly Random rands;
/// <summary>
/// コンストラクタ
/// </summary>
public Numbers()
{
// リストを初期化し 1 ~ 43 を格納する
this.balls = Enumerable.Range(1, 43).ToList();
// 乱数ジェネレータの初期化
this.rands = new Random((int)DateTime.Now.Ticks);
}
/// <summary>
/// 管理されている数値の1つをランダムで取り出す
/// </summary>
public int Ball => this.GetBall();
/// <summary>
/// 管理されている数値の1つをランダムで取り出す
/// </summary>
/// <returns>数値</returns>
public int GetBall()
{
// 数値を取り出す(リストから除外)
int takeout(int i)
{
var value = this.balls[i];
this.balls.RemoveAt(i);
return value;
}
// 取得するリスト要素のインデックス値を乱数で作成
var index = this.rands.Next(this.balls.Count);
// リストから数値を取り出して返す
return takeout(index);
}
/// <summary>
/// 管理されている数値をランダムで指定した個数だけ取り出す
/// </summary>
/// <returns>数値</returns>
public IEnumerable<int> GetBalls(int count)
{
foreach (var _ in Enumerable.Range(0, count))
{
yield return this.Ball;
}
}
}
(4) 小数を含む計算
問題
問題文はこちら(再掲)
- 1年ごとに 元金 に 年利率 をかけた金額を 利息 として算出し、元金 と 利息 の合計を 1円単位に四捨五入した金額 を 翌年の元金 とする(複利法)
- 最終的な元金と利息の合計を 年数 × 12ヶ月 で割り、1円単位に四捨五入した金額 を 月ごとの支払額 とする
- 月ごとの支払額 を 年数 × 12ヶ月 でかけた金額を 支払総額 とする
- 全ての計算は10進数で行い、不要な丸め誤差が発生しないようにすること
出題意図
小数を含む演算において、2進数の浮動小数点数値型では小数部の計算が正確に行えないことを認識してもらうことが目的です。
また、この問題では最終的な計算結果は整数とする為、丸め処理(四捨五入)を適切な時点で実施する必要があります。
これを仕様から正確に読み取り、正しくプログラムに落とし込めることを望みます。
なお、支払による元金の減少を考慮しない仕様としている為、このプログラムが出力する月ごとの支払額は現実よりもかなり高くなります。
直観に反する実行結果に対して、「仕様がこうであるからこの結果は正しい」と判断できるかが重要になります。
回答例
回答例はこちら
public static void Main(string[] args)
{
// 引数チェック
if (!Validate(args))
{
return;
}
// 引数をパラメータ化
var param = ToParam(args);
// 月ごとの支払額
var monthlyPayment = param.MonthlyPayment;
// 支払総額
var totalPayment = monthlyPayment * param.Periods * 12;
Console.WriteLine($"支払総額 : {totalPayment,13:#,0}");
Console.WriteLine($"月ごとの支払額 : {monthlyPayment,13:#,0}");
}
/// <summary>
/// 実行引数のバリデーションを行う
/// </summary>
/// <param name="args">実行引数</param>
/// <returns>問題が無い場合、True</returns>
private static bool Validate(string[] args)
{
// 引数が 3個 でない
if (args.Length != 3)
{
Console.WriteLine("引数に 元金 年数 年利率(%) を指定してください。");
return false;
}
// 元金 が 整数に変換できない または 負数
if (!int.TryParse(args[0], out var principal) || principal <= 0)
{
Console.WriteLine("元金 は 正の整数 で入力してください。");
return false;
}
// 年数 が 整数に変換できない または 負数
if (!int.TryParse(args[1], out var periods) || periods <= 0)
{
Console.WriteLine("年数 は 正の整数 で入力してください。");
return false;
}
// 年利率 が 数値に変換できない または 負数
if (!decimal.TryParse(args[2], out var rate) || rate <= decimal.Zero)
{
Console.WriteLine("年利率 は 正の数値 で入力してください。");
return false;
}
return true;
}
/// <summary>
/// 実行引数をパラメータに変換する
/// </summary>
/// <param name="args">実行引数</param>
/// <returns>パラメータ</returns>
private static Param ToParam(string[] args)
{
return new Param()
{
Principal = Math.Floor(decimal.Parse(args[0])),
Periods = int.Parse(args[1]),
Rate = Math.Round(decimal.Parse(args[2]), 2, MidpointRounding.ToEven)
};
}
/// <summary>
/// パラメータ管理クラス
/// </summary>
private class Param
{
/// <summary>
/// 元金
/// </summary>
public decimal Principal { get; set; }
/// <summary>
/// 年数
/// </summary>
public int Periods { get; set; }
/// <summary>
/// 年利率
/// </summary>
public decimal Rate { get; set; }
/// <summary>
/// 月ごとの支払額
/// </summary>
public decimal MonthlyPayment => this.CalcMonthlyPayment();
/// <summary>
/// 元金、年数、年利率 を元に 月ごとの支払額 を複利で計算する
/// </summary>
/// <returns>月ごとの支払額</returns>
private decimal CalcMonthlyPayment()
{
// 支払額
var payment = this.Principal;
// 年数分だけ 支払額 に 年利 を加算
foreach (var _ in Enumerable.Range(0, this.Periods))
{
payment += Math.Round(payment * this.Rate / 100m, 0, MidpointRounding.ToEven);
}
// 月ごとの支払額 を算出
return Math.Round(payment / this.Periods / 12m, 0, MidpointRounding.ToEven);
}
}
3. 一行掲示板
問題
問題文はこちら(再掲)
- コマンドラインアプリとする。
- 実行時に引数を与えなかった場合、投稿された書き込みを全てコンソールに出力する。
- 実行時に引数を 1つ 与えた場合、その内容を 投稿文 として登録する。
登録後、投稿された書き込みを全て出力する。 - 実行時に引数を 2つ以上 与えた場合、エラーとする。
- 投稿された書き込みは、以下の書式でコンソールに出力する("△" は半角スペース)。
投稿日時("YYYY/MM/DD HH:MM:SS"形式)△投稿文
- 投稿された書き込みは、投稿日時の降順でコンソールに出力する(新しい書き込みが上にくるようにする)。
- 投稿された書き込みは、投稿日時 と 投稿文 をCSVファイルとして保存する。
- 投稿された書き込みは、投稿の都度CSVファイルの末尾に追記する。
- プログラムは適宜クラス、メソッドの分割を行い、それぞれに適切な名称を付与すること。
出題意図
比較的規模が大きめの練習問題として、また、ファイルの入出力に触れることを意図しました。
今回の「一行掲示板」は大きく分けて2つの機能(投稿の書き込み と 投稿の一覧表示)を持っています。
羅列された仕様を適切に2つの機能に分割して考えることが出来るかを確認します。
また、この2つの機能は同じファイルを共有する為、ファイルのインタフェースは共通のものにすることが望ましくなります。
このことに気付けるか、気付けた場合にプログラムとして表現できるかが肝要です。
回答例
回答例はこちら
using System.IO; // 冒頭に左記を追加する必要がある
public static void Main(string[] args)
{
try
{
// 実行クラスを取得し、実行
GetExecutable(args).Exec();
}
catch (ArgumentOutOfRangeException)
{
Console.WriteLine("引数が多すぎます。");
return;
}
}
/// <summary>
/// 実行クラスを取得する
/// </summary>
/// <param name="args">実行引数</param>
/// <returns>実行クラス</returns>
private static IExecutable GetExecutable(string[] args)
{
switch (args.Length)
{
case 0:
// 引数が指定されない場合、投稿内容を表示
return new ReadBoard();
case 1:
// 引数が1個指定された場合、引数の内容を投稿する
return new WriteBoard(args[0]);
default:
break;
}
throw new ArgumentOutOfRangeException();
}
/// <summary>
/// 実行可能であることを示すインタフェース
/// </summary>
interface IExecutable
{
/// <summary>
/// 実行メソッド
/// </summary>
void Exec();
}
/// <summary>
/// 掲示板の内容を取得する実行クラス
/// </summary>
class ReadBoard : IExecutable
{
/// <summary>
/// 実行メソッド
/// </summary>
public void Exec()
{
Board.Read();
}
}
/// <summary>
/// 掲示板に投稿を行う実行クラス
/// </summary>
class WriteBoard : IExecutable
{
/// <summary>
/// 投稿メッセージ
/// </summary>
private readonly string message;
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="message">投稿メッセージ</param>
public WriteBoard(string message)
{
this.message = message;
}
/// <summary>
/// 実行メソッド
/// </summary>
public void Exec()
{
Board.Write(this.message);
Board.Read();
}
}
/// <summary>
/// 一行掲示板アプリ
/// </summary>
public static class Board
{
/// <summary>
/// 投稿内容の記録用ファイル名
/// </summary>
private const string LogFileName = @"board.txt";
/// <summary>
/// 一行掲示板の投稿内容を新規順に出力する
/// </summary>
public static void Read()
{
// 投稿内容リスト
var contentList = ReadFile();
// 投稿内容を投稿日時の降順でソートして出力する
foreach (var contents in contentList.OrderByDescending(c => c.Date))
{
Console.WriteLine($"{contents.Date:yyyy/MM/dd HH:mm:ss} {contents.Message}");
}
}
/// <summary>
/// 記録用ファイルを読み込み、投稿内容を列挙する
/// </summary>
/// <returns>投稿内容</returns>
private static IEnumerable<Contents> ReadFile()
{
if (!File.Exists(LogFileName))
{
// ファイルが未作成の場合:NOP
yield break;
}
using (var reader = new StreamReader(LogFileName))
{
// 投稿リストを1行ずつ処理
string line;
while ((line = reader.ReadLine()) != null)
{
// Contents インスタンスに変換して返す
yield return Contents.Parse(line);
}
}
}
/// <summary>
/// 一行掲示板にメッセージを投稿する
/// </summary>
/// <param name="message">投稿メッセージ</param>
public static void Write(string message)
{
using (var writer = new StreamWriter(LogFileName, true))
{
// 投稿内容インスタンスを作成し、ファイル末尾に出力
writer.WriteLine(new Contents(message));
}
}
}
/// <summary>
/// 投稿内容管理クラス
/// </summary>
public class Contents
{
/// <summary>
/// 投稿日時
/// </summary>
public DateTime Date { get; private set; }
/// <summary>
/// 投稿メッセージ
/// </summary>
public string Message { get; private set; }
/// <summary>
/// デフォルトコンストラクタ
/// </summary>
public Contents() { }
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="message">投稿メッセージ</param>
public Contents(string message)
{
this.Date = DateTime.Now;
this.Message = message;
}
/// <summary>
/// 記録用ファイルの内容をContentsインスタンスに変換する
/// </summary>
/// <param name="line">記録用ファイルの内容</param>
/// <returns>Contentsインスタンス</returns>
public static Contents Parse(string line)
{
try
{
var texts = line.Split(new char[] { ',' }, 2);
return new Contents
{
Date = DateTime.Parse(texts[0]),
Message = texts[1]
};
}
catch
{
throw new FormatException();
}
}
/// <summary>
/// Contentsインスタンスの内容を記録用の文字列表現に置き換える
/// </summary>
/// <returns>記録用の文字列表現</returns>
public override string ToString()
{
return string.Join(",",
this.Date.ToString(@"yyyy/MM/dd HH:mm:ss"),
this.Message
);
}
}