リスト構造について
データ処理において、配列は基本的データ構造だが、動的メモリ確保の説明で述べたように、基本の配列では大きさを変更することができない。これ以外にも、配列は途中にデータを挿入・削除を行う場合、の処理時間を伴う。以下にその問題点を整理し、その解決策であるリスト構造について説明する。
配列の利点と欠点
今までデータの保存には、配列を使ってきたが、配列は添字で場所を指定すれば、その場所のデータを簡単に取り出すことができる。配列には苦手な処理がある。例えば、配列の中から目的のデータを高速に探す方式として、2分探索法を用いる。
int find( int array[] , int left , int right , int key ) { // データは left から right-1までに入っているとする。 while( left < right ) { int mid = (left + right) / 2 ; // 中央の場所 if ( array[ mid ] == key ) return mid ; // 見つかった else if ( array[ mid ] > key ) right = mid ; // 左半分にある else left = mid + 1 ; // 右半分にある } return -1 ; // 見つからない }
しかし、配列の中に新たに要素を追加しようとするならば、データは昇順に並んでいる必要があることから、以下のようになるだろう。
void entry( int array[] , int* psize , int key ) { // データを入れるべき場所を探す処理 for( int i = 0 ; i < *psize ; i++ ) // O(N) の処理だけど、 if ( array[ i ] > key ) // O(log N) でも書けるけど break ; // 単純に記載する。 if ( i < *psize ) { // 要素を1つ後ろにずらす処理 for( int j = *psize ; j > i ; j-- ) // O(N)の処理 array[ j ] = array[ j - 1 ] ; array[ i ] = key ; } else { array[ *psize ] = key ; } (*psize)++ ; }
これで判るように、データを配列に追加する場合、途中にデータを入れる際にデータを後ろにずらす処理が発生する。
この例は、データを追加する場合であったが、不要となったデータを取り除く場合にも、データの場所の移動が必要である。
順序が重要なデータ列で途中へのデータ挿入削除
例えば、アパート入居者に回覧板を回すことを考える。この中で、入居者が増えたり・減ったりした場合、どうすれば良いか考える。
通常は、自分の所に回覧板が回ってきたら、次の入居者の部屋番号さえわかっていれば、回覧板を回すことができる。
101 102 103 104 105 106 [ 105 | 106 | -1 | 102 | 104 | 103 ]
このように次のデータの場所という概念を使うと、データの順序を持って扱うことができる。
struct LIST { int data ; int next ; } ; struct LIST array[] = { /*0*/ { 11 , 2 } , /*1*/ { 67 , 3 } , // 末尾にデータ34を加える /*2*/ { 23 , 4 } , // { 23 , 5 } , /*3*/ { 89 , -1 } , // 末尾データの目印 /*4*/ { 45 , 1 } , /*5*/ { 0 , 0 } , // { 34 , 4 } , } ; for( int idx = 0 ; idx >= 0 ; idx = array[ idx ].next ) { printf( "%d¥n" , array[ idx ].data ) ; }
この方法を取れば、途中にデータ入れたり、抜いたりする場合に、データの移動を伴わない。
しかし、配列をベースにしているため、配列の上限サイズを超えて格納することはできない。
簡単テストの解説
前に実施した簡単テストの答え。
キーワードの理解
型の理解
上記の問題だけでは、説明しきれないので、下図左のプログラムと、その printf() で表示するデータの型を示す。
型の意味を考えたうえで、何が表示されるか考えよう。
関数の理解
簡単テスト
情報構造論のテストにて結果は両極端な成績。苦手な人は基本理解が怪しいみたい。ということでC言語の理解の確認。
キーワードの理解
以下のプログラムの下線部 A-I の各単語を説明するのにふさわしいものを、(a)~(f)で選べ。キーワードは予約語と呼ばれることも多い。
(a) 型を表すキーワード、(b) キーワード、(c) 変数名、
(d) 関数名、(e) ファイル名、(f) それ以外
解答欄
A_____, B_____, C_____, D_____, E_____,
F_____, G_____, H_____, I_____
型の理解
以下のプログラムの下線部 A-D の型を答えよ。
(a) int , (b) int型へのポインタ, (c) char, (d) char型へのポインタ, (e) void
解答欄
A_____, B_____, C_____, D_____
関数の理解
以下のように、文字列を src から dest にコピーする(ただし最大文字数 countまで) strncpy を作った。
この関数の下線に示す仮引数部分を完成せよ。
解答欄________________________________
型の無い言語からC言語を学ぶと
情報構造論の中間試験の採点をしていて気づいたこと。
プログラムが解っていない学生の回答で、関数呼び出しで実引数に int とか実引数の型を書く学生がいる。
int foo( int x ) { return x * x ; } void main() { for( int i = 0 ; i < 10 ; i++ ) printf( "%d" , foo( int i ) ) ; } ~~~ なぜ int を書くの?
でも、最近の学生はプログラミングで最初に習うプログラム言語が、JavaScript だったり、Python だったりと、型の無い言語であることが多い。
こういう学生さんは、関数宣言と関数呼び出しでは、以下のような感じで習うことだろう。
// JavaScript function foo( x ) { return x * x ; } for( var i = 0 ; i < 10 ; i++ ) print( foo( i ) ) ; ## Python def foo( x ) return x * x for i in range( 0 , 10 ) print( foo( i ) )
んで、こういう学生さんは、関数宣言の仮引数部分を見ると、変数名しか書いてない。この後にC言語を習うと、仮引数の宣言で foo( int x ) みたいに書いてあると、そこに int と書かないとダメみたいに思う、もしくは int x で一つの引数みたいに思うんだろうな。
だから、関数呼び出しの実引数にで、foo( int i ) みたいに型名を書いてしまうと思われる。int というのは、整数型にするためのもの…みたいなイメージなんだろうか?。
C言語での関数宣言と関数呼び出しの説明
// fooという関数の宣言 int foo( int x ) // foo()の関数の答えの型は、int型。 ~~~~~~仮引数 { // 仮引数 x の型は、int 型。 return x * x ; } void main() { for( int i = 0 ; i < 10 ; i++ ) printf( "%d¥n" , foo( i ) ) ; ~~~実引数 // foo() の第一引数は、整数型を書かないといけない。 // 1番目の実引数 i の型は、int i で宣言されているから、int 型。 // foo( 1.2 ) と書くと、第一引数は double だから、 // 引数の型が合わないのでエラー。 }
関数宣言の引数は、仮引数と呼ぶ。関数を呼び出すときの引数は、実引数と呼ぶ。
C言語では、関数宣言の仮引数には「型」を明示する必要がある。(というか変数宣言では型を明示しないとダメ)
さらにC言語では関数を呼び出すときに、仮引数と実引数で「型」が一致していないとエラーとなる。
JavaScript や Python では、変数に「型」という概念が不要なので、仮引数宣言には「型」を明示する必要がない。
C言語 や Java は、静的な型付けの言語。最初から型がきまっていないとダメな言語。
これに対し、JavaScript や Python という言語は、動的な型付けの言語。プログラム言語が実行中に型を意識しながら動く言語。
mallocを使った課題
授業での malloc , free を使ったプログラミングを踏まえ、以下のレポートを作成せよ。
以下のデータのどれか1つについて、データを入力し、何らかの処理を行うこと。
課題は、原則として、(自分の出席番号%3)+1 についてチャレンジすること。
- 名前と電話番号
- 名前と年齢(もしくは生年月日)
- 名前と身長・体重
このプログラムを作成するにあたり、以下のことを考慮しmallocを適切に使うこと。
- 名前は、長い名前の人が混ざっているかもしれない。
- 保存するデータ件数は、10件かもしれない1000件かもしれない。(データ件数は、処理の最初に入力すること。)
ただし、mallocの理解に自信がない場合は、名前もしくはデータ件数のどちらか一方は固定値でも良い。
レポートには、(a)プログラムリスト, (b)プログラムの説明, (c)正しく動いたことがわかる実行例, (d)考察 を記載すること。
考察には、自分のプログラムが正しく動かない事例はどういう状況でなぜ動かないのか…などを検討したり、プログラムで良くなった点はどういう所かを説明すること。
malloc()とfree()
malloc()とfree()
malloc() は、動的(ヒープ領域)にメモリを確保する命令で、データを保存したい時に malloc() を実行し、不要になった時に free() を実行する。
malloc() では、alloca() と同じように、格納したいデータの byte 数を指定する。また、malloc() は、確保したメモリ領域の先頭を返すが、ヒープメモリが残っていない場合 NULL ポインタを返す。処理が終わってデータ領域をもう使わなくなったら、free() で解放する必要がある。
基本的には、確保したメモリ領域を使い終わった後 free() を実行しないと、再利用できないメモリ領域が残ってしまう。こういう処理を繰り返すと、次第にメモリを食いつぶし、仮想メモリ機能によりハードディスクの読み書きで性能が低下したり、最終的にOSが正しく動けなくなる可能性もある。こういった free() 忘れはメモリーリークと呼ばれ、malloc(),free()に慣れない初心者プログラマーによく見られる。
ヒープメモリは、プロセスの起動と共に確保され、プログラムの終了と同時にOSに返却される。このため、malloc()と処理のあとすぐにプロセスが終了するようなプログラムであれば、free() を忘れても問題はない。授業では、メモリーリークによる重大な問題を理解してもらうため、原則 free() は明記する。
文字列を保存する場合
#include <stdlib.h> char* names[ 10 ] ; char buff[ 1000 ] ; // 名前を10件読み込む void inputs() { for( int i = 0 ; i < 10 ; i++ ) { if ( fgets( buff , sizeof( buff ) , stdin ) != NULL ) { names[ i ] = (char*)malloc( strlen(buff)+1 ) ; if ( names[ i ] != NULL ) strcpy( names[ i ] , buff ) ; } } } // 名前を出力する void prints() { for( int i = 0 ; i < 10 ; i++ ) printf( "%s" , names[ i ] ) ; } void main() { // 文字列の入力&出力 inputs() ; prints() ; // 使い終わったら、free() で解放 for( int i = 0 ; i < 10 ; i++ ) free( names[ i ] ) ; }
文字列を保存する場合には、上記の names[i] への代入のような malloc() と strcpy() を使うことが多い。
このための専用の関数として、strdup() がある。基本的には、以下のような機能である。char* strdup( char* s ) { char* p ; if ( (p = (char*)malloc( strlen(s)+1 )) != NULL ) strcpy( p , s ) ; return p ; }また、入力した文字列をポインタで保存する場合、以下のようなプログラムを書いてしまいがちであるが、図に示すような状態になることから、別領域にコピーする必要がある。
char buff[ 1000 ] ; char* name[10] ; for( int i = 0 ; i < 10 ; i++ ) { if ( fgets( buff , sizeof(buff) , stdin ) != NULL ) name = buff ; }
配列に保存する場合
任意サイズの配列を作りたい場合には、malloc() で一括してデータの領域を作成し、その先頭アドレスを用いて配列として扱う。
#include <stdlib.h> void main() { int size ; int* array ; // 処理するデータ件数を入力 scanf( "%d" , &size ) ; // 整数配列を作る if ( (array = (int*)malloc( sizeof(int) * size )) != NULL ) { int i ; for( i = 0 ; i < size ; i++ ) array[i] = i*i ; // あんまり意味がないけど for( i = 0 ; i < size ; i++ ) printf( "%d¥n" , array[i] ) ; // mallocしたら必ずfree free( array ) ; } }
構造体の配列
同じように、任意サイズの構造体の配列を作りたいのであれば、配列サイズに「sizeof( struct Complex ) * データ件数」を指定すればいい。
#include <stdlib.h> struct Complex { double re , im ; } ; // 指定した場所にComplexを読み込む。 int input_Complex( struct Complex* p ) { return scanf( "%f %f" , &(p->re) , &(p->re) ) == 2 ; } // 指定したComplexを出力 void print_Complex( struct Complex* p ) { printf( "%f+j%f¥n" , p->re , p->im ) ; } void main() { int size ; struct Complex* array ; // 処理する件数を入力 scanf( "%d" , &size ) ; // 配列を確保して、データの入力&出力 if ( (array = (struct Complex*)malloc( sizeof(struct Complex) * size )) != NULL ) { int i ; for( i = 0 ; i < size ; i++ ) if ( !input_Complex( &array[i] ) ) break ; for( i = 0 ; i < size ; i++ ) print_Complex( &array[i] ) ; // mallocしたら必ずfree free( array ) ; } }
効率のよいメモリ使用と動的メモリ確保
次にメモリの利用効率の話について解説する。
配列宣言でサイズは定数
C言語では、配列宣言を行う時は、配列サイズに変数を使うことはできない。
最近のC(C99)では、実は下記のようなものは、裏で後述のalloca()を使って動いたりする。(^_^;
void foo( int size ) { int array[ size ] ; // エラー for( int i = 0 ; i < size ; i++ ) array[ i ] = i*i ; } void main() { foo( 3 ) ; foo( 4 ) ; }
メモリ利用の効率
配列サイズには、定数式しか使えないので、1クラスの名前のデータを覚えるなら、以下のような宣言が一般的であろう。
#define MEMBER_SIZE 50 #define NAME_LENGTH 20 char name[ MEMBER_SIZE ][ NAME_LENGTH ] ;
しかしながら、クラスに寿限無とか銀魂の「ビチグソ丸」のような名前の人がいたら、20文字では足りない。(“t-saitoh”くんは配列サイズ9byte、”寿限無”くんは配列220byte といった使い方はできない) また、クラスの人数も、巨大大学の学生全員を覚えたいとい話であれば、 10000人分を用意する必要がある。 ただし、10000人の”寿限無”ありを考慮して、5Mbyte の配列を準備したのに、与えられたデータ量が100件で終わってしまうなら、その際のメモリの利用効率は極めて低い。
このため、最も簡単な方法は、以下のように巨大な文字配列に先頭から名前を入れていき、 文字ポインタ配列に、各名前の先頭の場所を入れる方式であれば、 途中に寿限無がいたとしても、問題はない。
char array[2000] = "ayuka¥0mitsuki¥0t-saitoh¥0tomoko¥0....." ; char *name[ 50 ] = { array+0 , array+6 , array+14 , array+23 , ... } ;
この方式であれば、2000byte + 4byte(32bitポインタ)×50 のメモリがあれば、 無駄なメモリ空間も必要最低限とすることができる。
参考:
寿限無(文字数:全角103文字)
さる御方、ビチクソ丸(文字数:全角210文字)
引用Wikipedia
大きな配列を少しづつ貸し出す処理
// 巨大な配列 char str[ 10000 ] ; // 使用領域の末尾(初期値は巨大配列の先頭) char* sp = str ; // 文字列を保存する関数 char* entry( char* s ) { char* ret = sp ; strcpy( sp , s ) ; sp += strlen( s ) + 1 ; return ret ; } int main() { char* names[ 10 ] ; names[ 0 ] = entry( "saitoh" ) ; names[ 1 ] = entry( "jugemu-jugemu-gokono-surikire..." ) ; return 0 ; } // str[] s a i t o h ¥0 t o m o k o ¥0 // ↑ ↑ // names[0] names[1]
このプログラムでは、貸し出す度に、sp のポインタを後ろに移動していく。
スタック
この貸し出す度に、末尾の場所をずらす方式にスタックがある。
int stack[ 100 ] ; int* sp = stack ; void push( int x ) { *sp = x ; // 1行で書くなら sp++ ; // *sp++ = x ; } int pop() { sp-- ; return *sp ; // return *(--sp) ; } int main() { push( 1 ) ; push( 2 ) ; push( 3 ) ; printf( "%d¥n" , pop() ) ; printf( "%d¥n" , pop() ) ; printf( "%d¥n" , pop() ) ; return 0 ; }
スタックは、最後に保存したデータを最初に取り出せる(Last In First Out)から、LIFO とも呼ばれる。
このデータ管理方法は、最後に呼び出した関数が最初に終了することから、関数の戻り番地の保存や、最後に確保した局所変数が最初に不要となることから、局所変数の管理に利用されている。
alloca() 関数
局所変数と同じスタック上に、一時的にデータを保存する配列を作り、関数が終わると不要になる場合には、alloca() 関数が便利である。alloca の引数には、必要なメモリの byte 数を指定する。100個の整数データを保存するのであれば、int が 32bit の 4byte であれば 400byte を指定する。ただし、int 型は16bitコンピュータなら2byteかもしれないし、64bitコンピュータなら、8byte かもしれないので、sizeof() 演算子を使い、100 * sizeof( int ) と書くべきである。
#include <alloca.h> void foo( int size ) { int* p ; // p = (int*)alloca( sizeof( int ) * size ) ; for( int i = 0 ; i < size ; i++ ) p[ i ] = i*i ; } void main() { foo( 3 ) ; foo( 4 ) ; }
alloca() は、指定された byte 数のデータ領域の先頭ポインタを返すが、その領域を 文字を保存するために使うか、int を保存するために使うかは alloca() では解らない。alloca() の返り値は、使う用途に応じて型キャストが必要である。文字を保存するなら、(char*)alloca(…) 、 intを保存するなら (int*)alloca(…) のように使う。
ただし、関数内で alloca で確保したメモリは、その関数が終了すると、その領域は使えなくなる。このため、最後に alloca で確保したメモリが、最初に不要となる…ような使い方でしか使えない。
ポインタの加算と配列アドレス
ポインタの加算と配列アドレス
ポインタに整数値を加えることは、アクセスする場所が、指定された分だけ後ろにずれることを意味する。
// ポインタ加算の例 int a[ 5 ] = { 11 , 22 , 33 , 44 , 55 } ; void main() { int* p ; // p∇ p = &a[2] ; // a[] : 11,22,33,44,55 // -2 +0 +1 printf( "%d¥n" , *p ) ; // 33 p[0] printf( "%d¥n" , *(p+1) ) ; // 44 p[1] printf( "%d¥n" , *(p-2) ) ; // 11 p[-2] p = a ; // p∇ printf( "%d¥n" , *p ) ; // a[] : 11,22,33,44,55 p++ ; // → p∇ printf( "%d¥n" , *p ) ; // a[] : 11,22,33,44,55 p += 2 ; // → → p∇ printf( "%d¥n" , *p ) ; // a[] : 11,22,33,44,55 }
ここで、注意すべき点は、ポインタの加算した場所の参照と、配列の参照は同じ意味となる。
*(p + 整数式) と p[ 整数式 ] は同じ意味
特に配列 a[] の a だけを記述すると、配列の先頭を意味することに注意。
構造体とポインタ
構造体を関数に渡して処理を行う例を示す。
struct Person { char name[ 10 ] ; int age ; } ; struct Person table[3] = { { "t-saitoh" , 55 } , { "tomoko" , 44 } , { "mitsuki" , 19 } , } ; void print_Person( struct Person* p ) { printf( "%s %d\n" , (*p).name , // * と . では . の方が優先順位が高い // p->name と簡単に書ける。 p->age ) ; // (*p).age の簡単な書き方 } void main() { for( int i = 0 ; i < 3 ; i++ ) { print_Person( &(table[i]) ) ; // print_Person( table + i ) ; でも良い } }
構造体へのポインタの中の要素を参照する時には、アロー演算子 -> を使う。
ソートアルゴリズム
前回の授業のハノイの塔は、単純な再帰方程式で処理時間のオーダーが巨大となる一例として示した。そこで、プログラムの中でよく利用されるデータの並び替え(ソート)で処理時間の分析を行ってみる。
様々なソートアルゴリズム
データの有名な並び替えアルゴリズムとその処理時間のオーダーを示す。
- バブルソート O(N2)
- 選択法 O(N2/2)
- クイックソート O( N log N )
- マージソート O( N log N )
マージソートの分析
マージソートは、与えられたデータを2分割し、 その2つの山をそれぞれマージソートを行う。 この結果の2つの山の頂上から、大きい方を取り出す…という処理を繰り返すことで、 ソートを行う。
このことから、再帰方程式は、以下のようになる。
この再帰方程式を、N=1,2,4,8…と代入を繰り返していくと、 最終的に処理時間のオーダが、 となる。
:
よって、
選択法とクイックソートの処理時間の比較
データ数 N = 20 件でソート処理の時間を計測したら、選択法で 10msec 、クイックソートで 20msec であった。
- データ件数 N = 100 件では、選択法,クイックソートは、それぞれどの程度の時間がかかるか答えよ。
- データ件数何件以上なら、クイックソートの方が高速になるか答えよ。
設問2 は、通常の関数電卓では求まらないので、数値的に方程式を解く機能を持った電卓が必要。
ポインタを使った処理
この後の授業で、ポインタを使ったプログラムが増えるので、ポインタの理解の確認
ポインタと引数
値渡し
// 値渡しのプログラム void foo( int x ) { // x は局所変数(仮引数は呼出時に // 対応する実引数で初期化される。 x++ ; printf( "%d¥n" , x ) ; } void main() { int a = 123 ; foo( a ) ; // 124 // 処理後も main::a は 123 のまま。 foo( a ) ; // 124 }
このプログラムでは、aの値は変化せずに、124,124 が表示される。
でも、プログラムによっては、124,125 と変化して欲しい場合もある。
どのように記述すべきだろうか?
// 大域変数を使う場合 int x ; void foo() { x++ ; printf( "%d¥n" , x ) ; } void main() { x = 123 ; foo() ; // 124 foo() ; // 125 }
しかし、このプログラムは大域変数を使うために、間違いを引き起こしやすい。
// 大域変数が原因で予想外の挙動をしめす簡単な例 int i ; void foo() { for( i = 0 ; i < 2 ; i++ ) printf( "A" ) ; } void main() { for( i = 0 ; i < 3 ; i++ ) // このプログラムでは、AA AA AA と foo() ; // 表示されない。 }
静的局所変数
大域変数を使わない方法としては、静的局所変数( static )を使う方法もある。
// 静的局所変数を使う場合 void foo() { static int x = 123 ; // x は foo の内部でのみ使用可 // x はプログラム起動時に作られ 123 で初期化される。 x++ ; printf( "%d¥n" , x ) ; } void main() { foo() ; // 124 foo() ; // 125 // ここで x = 321 といった代入はできない }
静的局所変数は、変数の寿命が大域変数と同じように、プログラム起動から終了までの間だが、参照できるスコープは所属するブロック内に限られる。
ポインタ渡し
C言語で引数を通して、呼び出し側の値を変化して欲しい場合は、変更して欲しい変数のアドレスを渡し、関数側では、ポインタ変数を使って受け取った変数のアドレスの示す場所の値を操作する。
// ポインタ渡しのプログラム void foo( int* p ) { // p はポインタ (*p)++ ; printf( "%d¥n" , *p ) ; } void main() { int a = 123 ; foo( &a ) ; // 124 // 処理後 main::a は 124 に増えている。 foo( &a ) ; // 124 } // さらに125と増える。
ポインタを利用して引数に副作用を与える方法は、ポインタを正しく理解していないプログラマーでは、危険な操作となる。C++では、ポインタ渡しを使わないようにするために、参照渡しを利用する。
参照渡し
// ポインタ渡しのプログラム void foo( int& x ) { // xは参照 x++ ; printf( "%d¥n" , x ) ; } void main() { int a = 123 ; foo( a ) ; // 124 // 処理後 main::a は 124 に増えている。 foo( a ) ; // 124 } // さらに125と増える。
ポインタ渡しを使う処理
// ポインタ渡しのプログラム void swap( int* pa , int* pb ) { int t = *pa ; *pa = *pb ; *pb = t ; } void main() { int a = 123 ; int b = 111 ; swap( &a , &b ) ; printf( "%d %d¥n" , a , b ) ; }