情報構造論の追試のための解答例
情報構造論の成績不振者のための追試を、8/9(木) 15:30 より 4EI 教室で実施します。
インターンシップなどで当日参加困難な場合は、別日に実施しますので、連絡してください。
テスト問題の解答および解説を、以下に示します。
追試では、問題を変更して出題しますので、決して文字の暗記で臨まないこと。
局所変数配列をreturn
情報構造論の前期期末試験で、局所変数返しの解答が多かったので、メモ。
JavaScript でプログラムを書いていると、動くネタだけど、C言語では初心者がよく間違って書いてしまう定番であり、それなりに動いたりするから、誤解が増えてしまう可能性もある。
スタック変数返し
char* foo() { char str[ 20 ] ; strcpy( str , "Hello World" ) ; return str ; } char* bar() { char baz[ 20 ] ; memset( baz , 'A' , sizeof( baz ) ) ; return NULL ; } void main() { char* ans = foo() ; printf( "%s¥n" , ans ) ; // Hello World // 一見正しく動いているように思うかも。 bar() ; // 局所変数を触るだけの処理 printf( "%s¥n" , ans ) ; // AAAAAAAAAAA // ans の中身が壊れている。 }
静的局所変数返し
上記のスタック変数を返す問題点を示すと、じゃあ静的局所変数でいいじゃん….という話がでてくる。
char* foo() { static char str[20] ; strcpy( str , "Hello World" ) ; return str ; }
この方式なら、スタック領域ではなく静的変数領域なので、関数呼び出しで破壊されることはない。
しかし、この方式は「スレッドセーフ」ではない。foo() の処理後に、スレッドを起動した場合、スレッドではメモリ空間が共有されるため、foo() を保持していて、別スレッドがそのメモリを書き換えると、他方は中身が変わってしまう。
(参考「スレッドセーフではないCライブラリ関数」)
自室サーバ、やばいかな
自室のサーバ、OS 更新をかけて部屋を離れていたら、どうも気絶をしていたようだ。
パワーリセットをかけても起動をしないので、モニタをつないで確認したら、リカバー時の fsck がうまくいっていない様子。”(initramfs)”のプロンプトの出ている状態で、fsck /dev/sda1 を入力し、いくらかエラーを吐きながらもなんとか FIX は終了し、再起動をかけたら、これまた起動せず。
boot初期段階で、”unreliable CPU thermal sensor monitoring disabled”で止まってる。色々と対策をしらべていたら、なぜか起動処理が再開され、ひとまず復帰。
unreliable CPU thermal sensor monitoring disabled
でも、これはいつ再発してもおかしくない…。自室のサーバは “PowerEdge T105” で、これのカタログを拾うと 2007年の製品。ひとまず起動したマシンで /etc のファイルの最古参の日付を確認すると、2008年。ほぼ 10 年間使っていたのか。
エラーメッセージからすれば、「CPUの温度センサーが怪しい」とか、マザーボード故障の可能性が高い。こりゃ、次のサーバを準備すべきだな。
実数と整数の変換
情報制御基礎で出題した問題で、5点の移動平均の処理の一部に、以下のコードがあった場合の間違い説明の問題。
int avg() { int s = .... ; return (1/5) * s ; }
の間違いを修正せよ….の答えだけど、return 0.2 * s ; とか return s/5 ; が答えだけど、小数点以下が切り捨てになるのか、四捨五入になるのか気になってきた。
for( double x = 0.0 ; x <= 2.0 ; x += 0.1 ) printf( "%ld %d\n" , x , (int)x ) ;
で確認してみたら、(int)実数は、小数点以下切り捨てだな。となると、四捨五入したいなら、return (int)( 0.2 * s + 0.5 ) ; とか、return (s+2)/5 ; とか書いてほしい…とか微妙な話になるな。
採点は、切り捨て・四捨五入の誤差については問わないで、0.2*s , s/5 を◯でいこう。
情報制御基礎の超優秀レポート
3年の学際科目で、5学科入り乱れての参加のある「情報制御基礎」の科目。幅広い知識を習得してもらう目的ということで、簡単なレポートでも要件を満たしていればA評価、B評価としているが、電気電子の学生さんからオリジナリティあふれる取り組みや、きちんとした考察のレポートがいくつか出てきている。この学生のレポートを100%としたら、よくわからないけど頑張ってついてきている学生さんが厳しくなっちゃうなぁ。
ひとまず嬉しいレポートもらったので、メモメモ。
澤井先生を偲ぶ
昨日、公私ともに大変お世話になった澤井先生がお亡くなりになったとの連絡が入った。私が高専に入ってプログラミングにのめり込み、就職の際に高専に誘って頂いた。
仕事でも、色々と迷惑をかけ、大変世話になっていた。
4月ころに退官する先生へのOB会の勧誘で来られている際にお会いしたけど、肺疾患の酸素ボンベを持ち歩いていて、体調も良くなかった様だった。
澤井先生は、福井高専の発足当初から電気科で教えられていて、コンピュータの発達と共に、プログラミング、計算機構成論といった授業を担当し、澤井先生にFORTRANを習い、AND-OR,JK-FFで頭をパンクさせた人も数多いことでしょう。そして、電子情報工学科設立と共に学科を移り、数多い「電子情報卒業生」を送り出してくれた先生でした…。
集合とリスト処理
リスト構造は、必要に応じてメモリを確保するデータ構造であり、データ件数に依存しないプログラム が記述できる。その応用として、集合処理を考えてみる。
2進数を用いた集合計算
リストによる集合の前に、もっと簡単な集合処理を考える。データ件数の上限が少ない場合には、「2進数の列」の各ビットを集合の各要素に対応づけし、要素の有無を0/1で表現する。この方法を用いるとC言語のビット演算命令で 和集合、積集合を計算できるので、処理が極めて簡単になる。
以下のプログラムは、0〜31の数字を2進数の各ビットに対応付けし、 ba = {1,2,3} , bb = {2,4,6} , bc= {4,6,9} を要素として持つ集合で、ba ∩ bb , bb ∩ bc の計算を行う例である。
void bit_print( unsigned int x ) { for( int i = 0 ; i < 32 ; i++ ) if ( (x & (1 << i)) != 0 ) printf( "%d " , i ) ; printf( "\n" ) ; } void main() { // 98,7654,3210 // ba = {1,2,3} = 00,0000,1110 unsigned int ba = (1<<1) | (1<<2) | (1<<3) ; // bb = {2,4,6} = 00,0101,0100 unsigned int bb = (1<<2) | (1<<4) | (1<<6) ; // bc = {4,6,9} = 10,0101,0000 unsigned int bc = (1<<4) | (1<<6) | (1<<9) ; bit_print( ba & bb ) ; // ba ∩ bb = {2} bit_print( bb & bc ) ; // bb ∩ bc = {4,6} bit_print( ba | bc ) ; // ba ∪ bc = {1,2,3,4,6,9} }
このような、2進数を用いた処理で有名なものとして、エラトステネスのふるいによる素数計算がある。このアルゴリズムでは、各bitを整数に対応付けし、素数で無いと判断した2進数の各桁に1の目印をつけていく方式である。
unsigned int prime = 0 ; void filter() { for( int i = 2 ; i < 32 ; i++ ) { if ( (prime & (1 << i)) == 0 ) { // iの倍数には、非素数の目印をつける for( int j = 2*i ; j < 32 ; j += i ) prime |= (1 << j) ; } } for( int i = 2 ; i < 32 ; i++ ) { // 目印のついていない数は素数 if ( (prime & (1 << i)) == 0 ) printf( "%d\n" , i ) ; } }
リスト処理による積集合
前述の方法は、リストに含まれる/含まれないを、2進数の0/1で表現する方式である。しかし、2進数であれば、unsigned int で 32要素、unsigned long long int で 64 要素が上限となってしまう。 (32bitコンピュータ,gccの場合)
しかし、リスト構造であれば、リストの要素として扱うことで、要素件数は自由に扱える。また、今までの授業で説明してきた cons() などを使って表現すれば、簡単なプログラムでリストの処理が記述できる。
// 先週までに説明してきたリスト構造と補助関数 struct List { int data ; struct List* next ; } ; struct List* cons( int x , struct List* n ) { struct List* ans ; ans = (struct List*)malloc( sizeof( struct List ) ) ; if ( ans != NULL ) { ans->data = x ; ans->next = n ; } return ans ; } void print( struct List* p ) { for( ; p != NULL ; p = p->next ) { printf( "%d " , p->data ) ; } printf( "\n" ) ; } int find( struct List* p , int key ) { for( ; p != NULL ; p = p->next ) if ( p->data == key ) return 1 ; return 0 ; }
例えば、積集合(a ∩ b)を求めるのであれば、リストa の各要素が、リストb の中に含まれるか find 関数でチェックし、 両方に含まれたものだけを、ans に加えていく…という考えでプログラムを作ると以下のようになる。
// 集合積の計算 struct List* set_prod( struct List* a , struct List* b ) { struct List* ans = NULL ; for( ; a != NULL ; a = a->next ) { // aの要素がbにも含まれていたら、ansに加える if ( find( b , a->data ) ) ans = cons( a->data , ans ) ; } return ans ; } void main() { struct List* a = cons( 1, cons( 2, cons( 3, NULL ) ) ) ; struct List* b = cons( 2, cons( 4, cons( 6, NULL ) ) ) ; struct List* c = cons( 4, cons( 6, cons( 9, NULL ) ) ) ; print( set_prod( a , b ) ) ; print( set_prod( b , c ) ) ; }
例題として、和集合、差集合などを考えてみよう。
リストの共有と削除の問題
リスト処理では、mallocを使うが、メモリリークをさせないためには、使用後のリストの廃棄は重要である。リストの全要素を捨てる処理であれば、以下のようになるであろう。
void list_free( struct List* p ) { while( p != NULL ) { struct List* d = p ; p = p->next ; free( d ) ; // 順序に注意 } }
一方、前説明の和集合(a ∪ b)のプログラムを以下のように作った場合、list_freeの処理は問題となる。
// 集合和 struct List* set_union( struct List*a, struct List*b ) { struct List* ans = b ; for( ; a != NULL ; a = a->next ) if ( !find( b , a->data ) ) ans = cons( a->data , ans ) ; return ans ; } void main() { struct List*a = cons( 1, cons( 2, cons( 3, NULL ) ) ) ; struct List*b = cons( 2, cons( 3, cons( 4, NULL ) ) ) ; struct List*c = set_union( a , b ) ; // a,b,cを使った処理 // 処理が終わったので、a,b,cを捨てる list_free( a ) ; list_free( b ) ; list_free( c ) ; // c = { 1 , (bのリスト) } // (b)の部分は先のlist_free(b)で解放済み }
このような、リストb,リストcで共有されている部分があると、データの廃棄処理をどのように記述すべきなのか、問題となる。
これらの解決方法としては、(1) set_union() の最初で、ans=b となっている部分を別にコピーしておく、(2) 参照カウンタ法を用いる、(3) ガベージコレクタのある言語を用いる…などがある。(2),(3)は後期授業で改めて解説を行う。
struct List* copy( struct List*p ) { struct List*ans = NULL ; for( ; p != NULL ; p = p->next ) ans = cons( p->data , ans ) ; return ans ; } struct List* set_union( struct List*a, struct List* b ) { struct List* ans = copy( b ) ; : }
理解確認
- 2進数を用いた集合処理は、どのように行うか?
- リスト構造を用いた集合処理は、どのように行うか?
- 積集合(A ∩ B)、和集合(A ∪ B)、差集合(A – B) の処理を記述せよ。
スタックと待ち行列
計算処理中に一時的なデータの保存として、stackとqueueがよく利用されるが、それを配列を使って記述したり、任意の大きさにできるリストを用いて記述する。
# 授業は、前回の演習時間が不十分だったので、前半講義、後半演習時間。
スタック
一時的な値の記憶によく利用されるスタックは、一般的にLIFO( Last In First out )と呼ばれる。配列を使って記述すると以下のようになるであろう。
#define STACK_SIZE 32 int stack[ STACK_SIZE ] ; int sp = 0 ; void push( int x ) { stack[ sp++ ] = x ; } int pop() { return stack[ --sp ] ; } void main() { push( 1 ) ; push( 2 ) ; push( 3 ) ; printf( "%d\n" , pop() ) ; // 3 printf( "%d\n" , pop() ) ; // 2 printf( "%d\n" , pop() ) ; // 1 }
しかし、この中にSTACK_SIZE以上のデータは貯えられない。同じ処理をリストを使って記述すれば、ヒープメモリを使い切るまで使うことができるだろう。
struct List* stack = NULL ; void push( int x ) { stack = cons( x , stack ) ; } int pop() { int ans = stack->data ; struct List* d = stack ; stack = stack->next ; free( d ) ; return ans ; }
キュー(QUEUE)
2つの処理の間でデータを受け渡す際に、その間に入って一時的にデータを蓄えるためには、待ち行列(キュー)がよく利用される。 FIFO(First In First Out)
配列にデータを入れる場所(wp)と取り出す場所のポインタ(rp)を使って蓄えれば良いが、配列サイズを超えることができないので、データを取り出したあとの場所を循環して用いるリングバッファは以下のようなコードで示される。
#define QUEUE_SIZE 32 int queue[ QUEUE_SIZE ] ; int wp = 0 ; int rp = 0 ; void put( int x ) { queue[ wp++ ] = x ; if ( wp >= QUEUE_SIZE ) wp = 0 ; } int get() { int ans = queue[ rp++ ] ; if ( rp >= QUEUE_SIZE ) rp = 0 ; return ans ; } void main() { put( 1 ) ; put( 2 ) ; put( 3 ) ; printf( "%d\n" , get() ) ; // 1 printf( "%d\n" , get() ) ; // 2 printf( "%d\n" , get() ) ; // 3 }
このようなデータ構造も、get() の実行が滞るようであれば、wp が rp に循環して追いついてしまう。
そこで、このプログラムもリストを使って記述すると以下のようになる。
struct List* queue = NULL ; struct List** tail = &queue ; void put( int x ) { *tail = cons( x , NULL ) ; tail = &( (*tail)->next ) ; } int get() { int ans = queue->data ; struct List* d = queue ; queue = queue->next ; free( d ) ; return ans ; }
ただし、上記のプログラムは、データ格納後にget()で全データを取り出してしまうと、tail ポインタが正しい位置になっていないため、おかしな状態になってしまう。
また、このプログラムでは、rp,wp の2つのポインタで管理することになるが、 2重管理を防ぐために、リストの先頭と末尾を1つのセルで管理する循環リストが使われることが多い。
理解確認
- 配列を用いたスタック・待ち行列は、どのような処理か?図などを用いて説明せよ。
- リスト構造を用いたスタック・待ち行列について、図などを用いて説明せよ。
- スタックや待ち行列を、配列でなくリスト構造を用いることで、どういう利点があるか?欠点があるか説明せよ。