集合とリスト処理
リスト構造は、必要に応じてメモリを確保するデータ構造であり、データ件数に依存しないプログラム が記述できる。その応用として、集合処理を考えてみる。集合処理の記述には、2進数を使った方式やリストを用いた方法が一般的である。以下にその処理について示す。
bit演算子
2進数を用いた集合処理を説明する前に、2進数を使った計算に必要なbit演算子について復習してみる。
bit演算子は、その数値を2進数表記とした時の各ビットをそれぞれAND,OR,EXOR,NOTなどの計算を行う。
bit演算子 | 計算の意味 | 関連知識 |
---|---|---|
& bit AND | 3 & 5 0011)2 & 0101)2= 0001)2 |
論理積演算子 if ( a == 1 && b == 2 ) … |
| bit OR | 3 | 5 0011)2 | 0101)2= 0111)2 |
論理和演算子 if ( a == 1 || b == 2 ) … |
~ bit NOT | ~5 ~ 00..00,0101)2= 11..11,1010)2 |
論理否定演算子 if ( !a == 1 ) … |
^ bit EXOR | 3 ^ 5 0011)2 ^ 0101)2= 0110)2 |
|
<< bit 左シフト | 3 << 2 0011)2 << 2 = 001100)2 |
x << y は と同じ |
>> bit 右シフト | 12 >> 2 1100)2 >> 2 = 11)2 |
x >> y は と同じ |
import java.util.*; public class Main { public static void main(String[] args) throws Exception { System.out.println( 12 & 5 ) ; // 1100 & 0101 = 0100 = 4 System.out.println( 12 | 5 ) ; // 1100 | 0101 = 1101 = 13 System.out.println( ~12 & 0xF ) ; // ~1100 & 1111 = 0011 = 3 System.out.println( 3 << 2 ) ; // 0011 << 2 = 1100 System.out.println( 12 >> 2 ) ; // 1100 >> 2 = 0011 System.out.println( ~12 + 1 ) ; // ~0..00001100 + 1 = 1..11110011 + 1 = 1..11110100 = -12 } }
論理演算子とbit演算子の違い
論理積,論理和という点では、論理演算子&&,|| と bit演算子&,| は複数桁の2進数で計算する違いと思うかもしれないが、論理演算子&&,|| は若干挙動が違う。論理積&&演算子は、左辺の結果が false だと(結果がfalse確定なので) 右辺の計算式や呼び出されない。同じように論理和||演算子は、左辺の結果が true だと(結果がtrue確定なので) 右辺の計算式は呼び出されない。
import java.util.*; public class Main { static boolean boolean_print( boolean yn ) { System.out.print( yn + " " ) ; return yn ; } static int int_print( int yn ) { System.out.print( yn + " " ) ; return yn ; } public static void main(String[] args) throws Exception { boolean ans ; int x ; ans = boolean_print( true ) && boolean_print( true ) ; System.out.println() ; ans = boolean_print( false ) && boolean_print( true ) ; System.out.println() ; ans = boolean_print( true ) || boolean_print( true ) ; System.out.println() ; ans = boolean_print( false ) || boolean_print( true ) ; System.out.println() ; x = int_print( 0 ) & int_print( 1 ) ; System.out.println() ; x = int_print( 1 ) | int_print( 0 ) ; System.out.println() ; } }
- 論理演算子とbit演算子の違い(Paiza.io)
2進数とビットフィールド
例えば、誕生日の年月日の情報を扱う際、20230726で、2023年7月26日を表現することも多い。
しかしこの方法は、この年月日の情報から年(4桁)、月(2桁)、日(2桁)を取り出す処理では、乗算除算が必要となる。通常のCPUであれば、簡単な乗除算は速度的にも問題はないが、組込み系では処理速度の低下も懸念される。
int ymd = 20230726 ; int y , m , d ; y = ymd / 10000 ; m = ymd / 100 % 100 ; d = ymd % 100 ; y = 1965 ; m = 2 ; d = 7 ; ymd = y * 10000 + m * 100 + d ;
こういった処理を扱う際には、2進数の考え方を使って扱う方法がある。
例えば、年は 0..2047 の範囲と考えれば 11 bit で表現でき、月は1..12の範囲であり 4bit で表現可能であり、日は1..31 で 5bit で表現できる。これを踏まえて、年月日を 11+4+5 = 20bit で表す(YYYY,YYYY,YYYM,MMMD,DDDD)なら、以下のプログラムのように書ける。
int ymd = (2024 << 9) + (7 << 5) + 26 ; // YYYY,YYYY,YYYM,MMMD,DDDD int y , m , d ; // 1111,1101,0000,1111,1010 y = ymd >> 9 ; // YYYYYYYYYYY m = (ymd >> 5) & 0xF ; // YYYYYYYYYYYMMMM & 000000000001111 d = (ymd & 0x1F) ; // YYYYYYYYYYYMMMMDDDDD & 00000000000000011111 y = 1965 ; m = 2 ; d = 7 ; ymd = (y << 9) + (m << 5) + d ;
C言語でのビットフィールド
しかし、上記のプログラムでは、いちいち2進数bit演算をイメージする必要があって、プログラムが分かりづらい。C言語では、こういった際にに使うのが ビットフィールドである。
// C言語の場合 (Javaではビットフィールドの構文がない) struct YMD { unsigned int year : 11 ; // ビットフィールドでは、 unsigned int month : 4 ; // 構造体の要素を何ビットで保存するのか unsigned int day : 5 ; // 指定することができる。 } ; struct YMD ymd = { 2023 , 7 , 26 } ; int y , m , d ; y = ymd.year ; m = ymd.month ; d = ymd.day ; ymd.year = 1965 ; ymd.month = 2 ; ymd.day = 7 ;
2進数を用いた集合計算
リストによる集合の前に、もっと簡単な集合処理を考える。
最も簡単な方法は、要素に含まれる=true か 含まれない=false を boolean型の配列に覚える方法であろう。数字Nが集合に含まれる場合は、配列[N]に true を覚えるものとする。この方法で積集合などを記述した例を以下に示す。
import java.util.*; public class Main { public static void boolarray_print( boolean[] a ) { for( int i = 0 ; i < a.length ; i++ ) System.out.print( a[i] ? "T" : "F" ) ; System.out.println() ; } public static void boolarray_and( boolean[] ans , boolean[] a , boolean[] b ) { for( int i = 0 ; i < a.length ; i++ ) ans[i] = a[i] && b[i] ; } public static void boolarray_or( boolean[] ans , boolean[] a , boolean[] b ) { for( int i = 0 ; i < a.length ; i++ ) ans[i] = a[i] || b[i] ; } public static void main(String[] args) throws Exception { // 0 1 2 3 4 5 6 7 8 9 boolean[] ba = { false, true, true, true, false, false, false, false, false, false } ; // {1,2,3} boolean[] bb = { false, false, true, false, true, false, true, false, false, false } ; // {2,4,6} boolean[] bc = { false, false, false, false, true, false, true, false, false, true } ; // {4,6,9} boolean[] ans = new boolean[ 10 ] ; boolarray_print( ba ) ; boolarray_print( bb ) ; boolarray_and( ans , ba , bb ) ; boolarray_print( ans ) ; boolarray_print( bb ) ; boolarray_print( bc ) ; boolarray_or( ans , bb , bc ) ; boolarray_print( ans ) ; } } FTTTFFFFFF // ba FFTFTFTFFF // bb FFTFFFFFFF // ba & bb FFTFTFTFFF // bb FFFFTFTFFT // bc FFTFTFTFFT // bb | bc
しかし、上述のプログラムでは、要素に含まれる/含まれないという1bitの情報をboolean型で保存しているが、実体は整数型で保存しているためメモリの無駄となる。
データ件数の上限が少ない場合には、「2進数の列」の各ビットを集合の各要素に対応づけし、要素の有無を0/1で表現する。この方法を用いるとビット演算命令で 和集合、積集合を計算できるので、処理が極めて簡単になる。
2進数を用いた集合計算
扱うデータ件数が少ない場合には、「2進数の列」の各ビットを集合の各要素に対応づけし、要素の有無を0/1で表現する。この方法を用いるとC言語のビット演算命令で 和集合、積集合を計算できるので、処理が極めて簡単になる。
以下のプログラムは、0〜31の数字を2進数の各ビットに対応付けし、 ba = {1,2,3} , bb = {2,4,6} , bc= {4,6,9} を要素として持つ集合で、ba ∩ bb , bb ∪ bc の計算を行う例である。
import java.util.*; public class Main { static void bitfield_print( int x ) { for( int i = 0 ; i < 10 ; i++ ) System.out.print( ((x & (1 << i)) != 0) ? "T" : "F" ) ; System.out.println() ; } public static void main(String[] args) throws Exception { int ba = (1 << 1) | (1 << 2) | (1 << 3) ; // {1,2,3} int bb = (1 << 2) | (1 << 4) | (1 << 6) ; // {2,4,6} int bc = (1 << 4) | (1 << 6) | (1 << 9) ; // {4,6,9} bitfield_print( ba ) ; bitfield_print( bb ) ; bitfield_print( ba & bb ) ; bitfield_print( bb ) ; bitfield_print( bc ) ; bitfield_print( bb | bc ) ; } }
有名なものとして、エラトステネスのふるいによる素数計算を2進数を用いて記述してみる。このアルゴリズムでは、各bitを整数に対応付けし、素数で無いと判断した2進数の各桁に1の目印をつけていく方式である。
import java.util.*; public class Main { static final int INT_BITS = 31 ; static int prime = 0 ; public static void main(String[] args) throws Exception { // 倍数に非素数の目印をつける for( int i = 2 ; i <= INT_BITS ; i++ ) { if ( (prime & (1 << i)) == 0 ) { for( int j = 2 * i ; j <= INT_BITS ; j += i ) prime |= (1 << j) ; } } // 非素数の目印の無い値を出力 for( int i = 2 ; i <= INT_BITS ; i++ ) { // 目印のついていない値は素数 if ( (prime & (1 << i)) == 0 ) System.out.println( i ) ; } } }
リスト処理による積集合
前述の方法は、リストに含まれる/含まれないを、2進数の0/1で表現する方式である。しかし、2進数であれば、int で 31要素、long int で 63 要素が上限となってしまう。
しかし、リスト構造であれば、リストの要素として扱うことで、要素件数は自由に扱える。また、今までの授業で説明してきた cons() などを使って表現すれば、簡単なプログラムでリストの処理が記述できる。
例えば、積集合(a ∩ b)を求めるのであれば、リストa の各要素が、リストb の中に含まれるか find 関数でチェックし、 両方に含まれたものだけを、ans に加えていく…という考えでプログラムを作ると以下のようになる。
import java.util.*; class ListNode { int data ; ListNode next ; ListNode( int d , ListNode n ) { this.data = d ; this.next = n ; } static void print( ListNode p ) { for( ; p != null ; p = p.next ) System.out.print( p.data + " " ) ; System.out.println() ; } static boolean find( ListNode p , int key ) { for( ; p != null ; p = p.next ) if ( p.data == key ) return true ; return false ; } static ListNode set_prod( ListNode a , ListNode b ) { ListNode ans = null ; for( ; a != null ; a = a.next ) { if ( find( b , a.data ) ) ans = new ListNode( a.data , ans ) ; } return ans ; } } ; public class Main { public static void main(String[] args) throws Exception { ListNode b = new ListNode( 2 , new ListNode( 4 , new ListNode( 6 , null ) ) ) ; ListNode c = new ListNode( 4 , new ListNode( 6 , new ListNode( 9 , null ) ) ) ; ListNode b_and_c = ListNode.set_prod( b , c ) ; ListNode.print( b_and_c ) ; } }
例題として、和集合、差集合などを考えてみよう。
理解確認
- 2進数を用いた集合処理は、どのように行うか?
- リスト構造を用いた集合処理は、どのように行うか?
- 積集合(A ∩ B)、和集合(A ∪ B)、差集合(A – B) の処理を記述せよ。
前期期末前の課題レポート
プログラムは書いて・動かして・間違って・直す が重要ということで、以下に前期期末試験前までに取り組むレポート課題をしめす。
レポート課題(プログラム例)
Java を用いて、後に示すデータ処理をするためのリスト構造を定義し、与えられたデータを追加していく処理を作成せよ。
課題の説明用に、複素数のリスト構造を定義し、指定した絶対値以下の複素数を抜き出す関数をつくった例を示す。
import java.util.*; class ComplexListNode { double re ; double im ; ComplexListNode next ; ComplexListNode( double r , double i , ComplexListNode n ) { this.re = r ; this.im = i ; this.next = n ; } } ; public class Main { static ComplexListNode top = null ; static void print( ComplexListNode p ) { for( ; p != null ; p = p.next ) { System.out.println( "(" + p.re + ")+j(" + p.im + ")" ) ; } } static void add( double r , double i ) { top = new ComplexListNode( r , i , top ) ; } static ComplexListNode filter_lessthan( ComplexListNode p , double v_abs ) { ComplexListNode ans = null ; for( ; p != null ; p = p.next ) { if ( Math.sqrt( p.re * p.re + p.im * p.im ) <= v_abs ) ans = new ComplexListNode( p.re , p.im , ans ) ; } return ans ; } public static void main(String[] args) throws Exception { add( 1.0 , 2.0 ) ; add( -1.0 , -1.0 ) ; add( 2.0 , -1.0 ) ; add( 1.0 , 0 ) ; print( top ) ; ComplexListNode less_than_2 = filter_lessthan( top , 2 ) ; System.out.println( "less than 2" ) ; print( less_than_2 ) ; } } ((( 実行結果の例 ))) (1.0)+j(0.0) (2.0)+j(-1.0) (-1.0)+j(-1.0) (1.0)+j(2.0) less than 2 (-1.0)+j(-1.0) (1.0)+j(0.0)
レポート内容
上記のプログラムをまねて、以下のレポート課題を作成すること。テーマは ((出席番号-1)%3+1) を選択すること。
- 年号のデータが、年号の名称と年号の始まりの年月日がYYYYMMDD形式で、”Meiji”,18681023 / ”Taisho”,19120730 / “Showa”,19261225 / “Heisei”,19890108 / “Reiwa”,20190501 の様に与えられる。このデータ構造を覚えるリスト構造を作成せよ。また ListNode のデータで、西暦の日付のリストが seireki_list = new ListNode( 19650207, new ListNode( 20030903 , null ) ) ; のように与えられたら、そのデータを和暦で表示するプログラムを作成せよ。 (参考2023年前期期末)
- 市町村名,月,日,最高気温,最低気温のデータが、”fukui”,8月,4日,27.6℃,22.3℃ / “fukui”,8月,5日,31.5℃,23.3℃ / “fukui”,8月,7日,34.7℃,25.9℃ / “obama”,8月,6日,34.2℃,23.9℃ の様に与えられる。このデータ構造で覚えるリスト構造を作成せよ。また、この中から真夏日(最高気温が30℃以上)でかつ熱帯夜(最低気温が25℃)の日のリストを抽出し表示するプログラムを作成せよ。(参考2022年前期期末)
- ホスト名と、IPアドレス(0~255までの8bitの値✕4個で与えるものとする)のデータ構造で、”www.fukui-nct.ac.jp”,104,215,54,205 / “perrine.tsaitoh.net”,192,168,11,2 / “dns.fukui-nct.ac.jp”,10,10,21,51 / “dns.google.com”,8,8,8,8 の様に与えられる。このデータ構造をリスト構造で覚えるプログラムを作成せよ。また、この中からプライベートアドレスのリストを抽出し表示するプログラムを作成せよ。プライベートアドレスは 10.x.x.x, 172.16~31.x.x,192.168.x.x とする。(参考2019年前期期末)
プログラムを作るにあたり、リスト構造には add( 与えられたデータ… ) のように呼び出してリストに追加すること。この時、生成されるリストが、登録の逆順になるか、登録順になるかは、自分の理解度に応じて選択すること。抽出する処理を書く場合も登録順序どおりにするかは自分の理解度に応じて選べばよい。
また、理解度に自信がある人は、add() などの処理を「オブジェクト指向」のように記述する方法を検討すること。
あくまで、リスト構造の理解を目的とするため、ArrayList<型> , List<型> のようなクラスは使わないこと。(ただし考察にて記述性の対比の対象として使うのはOK)
スタックと待ち行列
前回の授業では、リストの先頭にデータを挿入する処理と、末尾に追加する処理について説明したが、この応用について説明する。
計算処理中に一時的なデータの保存として、スタック(stack)と待ち行列・キュー(queue)がよく利用される。それを配列を使って記述したり、任意の大きさにできるリストを用いて記述することを示す。
スタック
配列を用いたスタック
一時的な値の記憶によく利用されるスタック(stack)は、データの覚え方の特徴からLIFO( Last In First out )とも呼ばれる。配列を使って記述すると以下のようになるであろう。
import java.util.*; public class Main { static final int STACK_SIZE = 10 ; static int[] stack = new int[ STACK_SIZE ] ; static int sp = 0 ; static void push( int x ) { stack[ sp++ ] = x ; } static int pop() { return stack[ --sp ] ; } public static void main(String[] args) throws Exception { push( 11 ) ; push( 22 ) ; push( 33 ) ; System.out.println( pop() ) ; System.out.println( pop() ) ; System.out.println( pop() ) ; } }
配列を使った Stack をオブジェクト指向で記述するなら、以下のように書ける。
import java.util.*; class Stack { static final int STACK_SIZE = 10 ; int[] array ; int sp ; Stack() { this.array = new int[ STACK_SIZE ] ; this.sp = 0 ; } void push( int x ) { array[ sp++ ] = x ; } int pop() { return array[ --sp ] ; } } ; public class Main { public static void main(String[] args) throws Exception { Stack stack = new Stack() ; stack.push( 11 ) ; stack.push( 22 ) ; stack.push( 33 ) ; System.out.println( stack.pop() ) ; System.out.println( stack.pop() ) ; System.out.println( stack.pop() ) ; } }
C言語で書いた場合
#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 }
++,–の前置型と後置型の違い
// 後置インクリメント演算子 int i = 100 ; printf( "%d" , i++ ) ; // これは、 printf( "%d" , i ) ; i++ ; // と同じ。100が表示された後、101になる。 // 前置インクリメント演算子 int i = 100 ; printf( "%d" , ++i ) ; // これは、 i++ ; printf( "%d" , i ) ; // と同じ。101になった後、101を表示。
リスト構造を用いたスタック
しかし、この中にSTACK_SIZE以上のデータは貯えられない。同じ処理をリストを使って記述すれば、配列サイズの上限を気にすることなく使うことができるだろう。では、リスト構造を使ってスタックの処理を記述してみる。
import java.util.*; class ListNode { int data ; ListNode next ; ListNode( int d , ListNode n ) { this.data = d ; this.next = n ; } } public class Main { static ListNode stack = null ; static void push( int x ) { stack = new ListNode( x , stack ) ; } static int pop() { int ans = stack.data ; stack = stack.next ; return ans ; } public static void main(String[] args) throws Exception { push( 1 ) ; push( 2 ) ; push( 3 ) ; System.out.println( pop() ) ; System.out.println( pop() ) ; System.out.println( pop() ) ; } }
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 ; // データ 0 件で pop() した場合のエラー対策は省略 free( d ) ; return ans ; }
オブジェクト指向っぽく書くならば、下記のようになるだろう。初期状態で stack = null にしておくと、stack.push() ができないので、stack の先頭には、ダミーデータを入れるようにプログラムを書くと以下のようになるだろう。
import java.util.*; class ListNode { int data ; ListNode next ; ListNode( int d , ListNode n ) { this.data = d ; this.next = n ; } ListNode() { // stack初期化用のコンストラクタ this.data = -1 ; this.next = null ; } void push( int x ) { this.next = new ListNode( x , this.next ) ; } int pop() { int ans = this.next.data ; this.next = this.next.next ; return ans ; } } ; public class Main { public static void main(String[] args) throws Exception { ListNode stack = new ListNode() ; // stack初期化用のコンストラクタを使う stack.push( 1 ) ; stack.push( 2 ) ; System.out.println( stack.pop() ) ; System.out.println( stack.pop() ) ; } }
キュー(QUEUE)
2つの処理の間でデータを受け渡す際に、その間に入って一時的にデータを蓄えるためには、待ち行列(キュー:queue)がよく利用される。 データの覚え方の特徴からFIFO(First In First Out)とも呼ばれる。
配列を用いたQUEUE / リングバッファ
配列にデータを入れる場所(wp)と取り出す場所のポインタ(rp)を使って蓄えれば良いが、配列サイズを超えることができないので、データを取り出したあとの場所を循環して用いるリングバッファは以下のようなコードで示される。
import java.util.*; public class Main { static final int QUEUE_SIZE = 32 ; static int[] queue = new int[ QUEUE_SIZE ] ; static int wp = 0 ; static int rp = 0 ; static void put( int x ) { queue[ wp++ ] = x ; if ( wp >= QUEUE_SIZE ) // wp = wp % QUEUE_SIZE ; or wp = wp & (QUEUE_SIZE - 1) ; wp = 0 ; } static int get() { int ans = queue[ rp++ ] ; if ( rp >= QUEUE_SIZE ) // rp = rp % QUEUE_SIZE ; or rp = rp & (QUEUE_SIZE - 1) ; rp = 0 ; return ans ; } public static void main(String[] args) throws Exception { // Your code here! put( 1 ) ; put( 2 ) ; put( 3 ) ; System.out.println( get() ) ; System.out.println( get() ) ; System.out.println( get() ) ; } }
#define QUEUE_SIZE 32 int queue[ QUEUE_SIZE ] ; int wp = 0 ; // write pointer(書き込み用) int rp = 0 ; // read pointer(読み出し用) 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 に循環して追いついてしまう。このため、上記コードはまだエラー対策としては不十分である。どのようにすべきか?
リスト構造を用いたQUEUE
前述のリングバッファもget()しないまま、配列上限を越えてput()を続けることはできない。
この配列サイズの上限問題を解決したいのであれば、リスト構造を使って解決することもできる。この場合のプログラムは、以下のようになるだろう。
import java.util.*; class ListNode { int data ; ListNode next ; ListNode( int d , ListNode n ) { this.data = d ; this.next = n ; } } ; public class Main { static ListNode top = new ListNode( -1 , null ) ; static ListNode tail = top ; static void put( int x ) { tail.next = new ListNode( x , null ) ; tail = tail.next ; } static int get() { int ans = top.next.data ; top.next = top.next.next ; return ans ; } public static void main(String[] args) throws Exception { put( 1 ) ; put( 2 ) ; put( 3 ) ; System.out.println( get() ) ; System.out.println( get() ) ; System.out.println( get() ) ; } }
Javaで書かれた ListNode を用いた待ち行列のイメージ図は下記のように示される。
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つのセルで管理する循環リストが使われることが多い。
理解確認
- 配列を用いたスタック・待ち行列は、どのような処理か?図などを用いて説明せよ。
- リスト構造を用いたスタック・待ち行列について、図などを用いて説明せよ。
- スタックや待ち行列を、配列でなくリスト構造を用いることで、どういう利点があるか?欠点があるか説明せよ。
- 配列を用いたリングバッファが用いられている身近な例にはどのようなものがあるか?
- 配列を用いたリングバッファを実装する場合配列サイズには 2n 個を用いることが多いのはなぜだろうか?
Javaでリスト構造
6/24(月)の大雨による休講で7/1(月)に説明
テスト前のリスト導入の復習
前回のリスト構造の導入では、配列のデータに次のデータの入っている番号を添えることで途中にデータを挿入できるデータ構造の説明をした。
リスト構造 ListNode
前述の data と next で次々とデータを続けて保存する方法を、next の部分を次のデータへの参照を用いるように、リスト構造(連結リスト)を定義する。
import java.util.*; class ListNode { int data ; // データ部分 ListNode next ; // 次のデータへの参照 // コンストラクタ ListNode( int d , ListNode nx ) { this.data = d ; this.next = nx ; } } ; public class Main { public static void main(String[] args) throws Exception { ListNode top = new ListNode( 11 , new ListNode( 22 , new ListNode( 33 , null ) ) ) ; for( ListNode p = top ; p != null ; p = p.next ) System.out.println( p.data ) ; // 途中にデータを入れる top.next = new ListNode( 15 , top.next ) ; for( ListNode p = top ; p != null ; p = p.next ) System.out.println( p.data ) ; } }
リスト操作
リスト構造に慣れるために簡単な練習をしてみよう。リスト構造のデータに対するメソッドをいくつか作ってみよう。print() や sum() を参考に、データ数を求める count() , 最大値を求める max() , データを検索する find() を完成させてみよう。
class ListNode { (略) } ; public class Main { static void print( ListNode p ) { // リストを表示 for( ; p != null ; p = p.next ) System.out.print( p.data + " " ) ; System.out.println() ; } static int sum( ListNode p ) { // リストの合計を求める int s = 0 ; for( ; p != null ; p = p.next ) s += p.data ; return s ; } static int count( ListNode p ) { // データ件数を数える } static int max( ListNode p ) { // データの最大値を求める } static boolean find( ListNode p , int key ) { // データ列の中から特定のデータを探す // 見つかったら true , 見つからなければ false } public static void main(String[] args) throws Exception { ListNode top = new ListNode( 11 , new ListNode( 22 , new ListNode( 33 , null ) ) ) ; print( top ) ; System.out.println( "合計:" + sum( top ) ) ; System.out.println( "件数:" + count( top ) ) ; System.out.println( "最大:" + max( top ) ) ; System.out.println( "検索:" + (find( top , 22 ) ? "みつかった" : "みつからない" ) ) ; } }
オブジェクト指向っぽく書いてみる
前述のプログラムでは、print( top ) のように使う static な関数としてプログラムを書いていた。しかし、オブジェクト指向であれば、オブジェクトに対するメソッドだと top.print() のように書きたい。この場合だと、以下のように書くかもしれない。
import java.util.*; class ListNode { int data ; ListNode next ; ListNode( int d , ListNode n ) { this.data = d ; this.next = n ; } void print() { // リストの全データを表示 for( ListNode p = this ; p != null ; p = p.next ) System.out.print( p.data + " " ) ; System.out.println() ; } int sum() { // リストの合計を求める int s = 0 ; for( ListNode p = this ; p != null ; p = p.next ) s += p.data ; return s ; } } ; public class Main { public static void main(String[] args) throws Exception { ListNode top = new ListNode( 11 , new ListNode( 22 , new ListNode( 33 , null ) ) ) ; top.print() ; System.out.println( "合計: " + top.sum() ) ; ListNode list_empty = null ; list_empty.print() ; // 実行時エラー java.lang.NullPointerException ぬるぽ! } }
しかし、データ件数 0件 に対してメソッドを呼び出せない。
ListNode と List というクラスで書いてみる
ひとつの方法として、リストの先頭だけのデータ構造を宣言する方法もある。
class ListNode { int data ; ListNode next ; ListNode( int d , ListNode n ) { this.data = d ; this.next = n ; } } ; class List { ListNode top ; List( ListNode p ) { this.top = p ; } void print() { for( ListNode p = top ; p != null ; p = p.next ) System.out.print( p.data + " " ) ; System.out.println() ; } } ; public class Main { public static void main(String[] args) throws Exception { List list = new List( new ListNode( 11 , new ListNode( 22 , new ListNode( 33 , null ) ) ) ) ; list.print() ; List list_empty = new List( null ) ; list_empty.print() ; } }
しかし、List と ListNode の2つのデータの型でプログラムを書くのは面倒くさい。
授業ではシンプルに説明したいので、今後はこの方法は極力避けていく。
先頭にダミーデータを入れる
複数のクラス宣言するぐらいなら、リストデータの先頭は必ずダミーにしておく方法もあるだろう。
import java.util.*; class ListNode { int data ; ListNode next ; ListNode( int d , ListNode n ) { this.data = d ; this.next = n ; } void print() { // リストの全データを表示 for( ListNode p = this.next ; p != null ; p = p.next ) System.out.print( p.data + " " ) ; System.out.println() ; } } ; public class Main { public static void main(String[] args) throws Exception { ListNode list = new ListNode( -1 , null ) ; list.next = new ListNode( 11 , new ListNode( 22 , new ListNode( 33 , null ) ) ) ; top.print() ; System.out.println( "合計: " + top.sum() ) ; ListNode list_empty = new ListNode( -1 , null ) ; list_empty.print() ; } }
以降、必要に応じて、先頭にダミーを入れる手法も取り混ぜながらプログラムを書くこととする。
入力データをリストに追加
入力しながらデータをリストに格納する処理を考えてみる。
リストでデータを追加保存するのであれば、一番簡単なプログラムは、以下のように先頭にデータを入れていく方法だろう。
class ListNode { (略) void print() { for( ListNode p = this ; p != null ; p = p.next ) System.out.print( p.data ) ; System.out.println() ; } } ; public class Main { public static void main(String[] args) throws Exception { int[] inputs = { 11 , 22 , 33 } ; ListNode top = null ; for( int datum : inputs ) top = new ListNode( datum , top ) ; top.print() ; } }
でもこの方法だと、先頭にデータを入れていくため、保存されたデータは逆順になってしまう。
末尾にデータを入れる
逆順になるのを避けるのであれば、データを末尾に追加する方法があるだろう。ただし初期状態でデータが0件だと処理が書きづらいので、先頭にダミーを入れておく方法で書いてみる。
public class Main { public static void main(String[] args) throws Exception { int[] test_data = { 11 , 22 , 33 } ; ListNode top = new ListNode( -1 , null ) ; // ダミー ListNode tail = top ; for( int x : test_data ) { tail.next = new ListNode( x , null ) ; tail = tail.next ; } top.print() ; // -1 11 22 33 } // ダミー }
Javaのオブジェクト指向入門
今日は、テスト返しの残り時間で、4年の情報構造論で、リスト構造などの内容を進める前に、3年プログラミング応用でクラスなどに自信がない人向けの簡単レクチャ。
クラスは、データ構造と手続き
例えば、名前と年齢のデータをクラスで扱うのであれば、以下のようなコードが基本となるだろう。
import java.util.*; class NameAge { String name ; // インスタンス変数 int age ; // インスタンス変数 static int count = 0 ; // クラス変数 // コンストラクタ NameAge( String s , int a ) { this.name = s ; this.age = a ; count++ ; } // メソッド void print() { System.out.println( this.name + "," + this.age ) ; System.out.println( "member = " + count ) ; } } ; public class Main { public static void main(String[] args) throws Exception { NameAge tsaitoh = new NameAge( "tsaitoh" , 59 ) ; tsaitoh.print() ; System.out.println( "age = " + tsaitoh.age ) ; NameAge tomoko = new NameAge( "tomoko" , 48 ) ; tomoko.print() ; } } 実行結果 tsaitoh,59 member = 1 age = 59 tomoko,48 member = 2
クラスとは、データ構造(オブジェクト)とそのデータ構造を扱うための関数(メソッド)をまとめて扱う。
クラス NameAge の中で宣言されている、NameAge() の関数は、オブジェクトを初期化するための関数(メソッド)であり、特にコンストラクタと呼ばれる。
実際にデータを保存するための tsaitoh や tomoko とよばれる変数に NameAge オブジェクトの実体を作る時には 「new クラス名」 とやることで、初期化ができる。
イメージでは、下図のようなデータ構造ができあがる。
でも、年齢の覚え方は、将来的に誕生日を覚えるように変化するかもしれない。この際に、Main 関数の中で age を使うと後で混乱の元になるかもしれない。こういう時は、NameAge クラス以外では中身を勝手に使わせないために、インスタンス変数などに public / private といったアクセス制限を加える。
import java.util.*; class NameAge { private String name ; // インスタンス変数 private int age ; // インスタンス変数 public static int count = 0 ; // クラス変数 // コンストラクタ public NameAge( String s , int a ) { this.name = s ; this.age = a ; count++ ; } // メソッド public void print() { System.out.println( this.name + "," + this.age ) ; System.out.println( "member = " + count ) ; } } ; public class Main { public static void main(String[] args) throws Exception { NameAge tsaitoh = new NameAge( "tsaitoh" , 59 ) ; tsaitoh.print() ; System.out.println( "age = " + tsaitoh.age ) ; ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ここがエラーになる。NameAge::age は private NameAge tomoko = new NameAge( "tomoko" , 48 ) ; tomoko.print() ; } }
クラス自体も、public class NameAge … のように宣言することもあるが、public なクラスは 1つ の *.java ファイルの中に1つしか書けないというルールがあるので要注意。
中間試験問題の回答
import java.util.*; import java.util.* ; class Item { int id ; String name ; int price ; Item( int i , String n , int p ) { this.id = i ; this.name = n ; this.price = p ; } } ; class Buy { int id ; int count ; Buy( int i , int n ) { this.id = i ; this.count = n ; } } ; public class Main { static Item[] item_list = { new Item( 1010 , "orange" , 50 ) , new Item( 1020 , "apple" , 100 ) , new Item( 1022 , "pineapple" , 1000 ) , } ; static Buy[] buy_list = { new Buy( 1010 , 5 ) , new Buy( 1020 , 3 ) , new Buy( 1022 , 1 ) , } ; public static void main( String[] args ) { System.out.println( total_price( item_list , buy_list ) ) ; } static int total_price( Item[] i_list , Buy[] b_list ) { int sum = 0 ; for( Item item : i_list ) for( Buy buy : b_list ) if ( item.id == buy.id ) sum += item.price * buy.count ; return sum ; } // static int total_price( Item[] i_list , Buy[] b_list ) { // int sum = 0 ; // for( int i = 0 ; i < i_list.length ; i++ ) // for( int j = 0 ; j < b_list.length ; j++ ) // if ( i_list[ i ].id == b_list[ j ].id ) // sum += i_list[ i ].price * b_list[ j ].count ; // return sum ; // } }
練習問題
科目(Subject)と学生(Student)の情報があり、科目を受講した成績(Result)で成績を管理している。
このデータを管理するためのクラスを宣言し、下に示すような出力が得られるプログラムを作成せよ。
今回の中間テストで成績が悪かった人は、テスト前に示したレポート課題ではなく下記の課題で提出してよいこととする。
科目: Subject id name teacher // Subject[] subject_table = { 10010 情報構造論 t-saitoh // new Subject( 10010 , "情報構造論" , "t-saitoh" ) , 10020 電気磁気学 takaku // new Subject( .... 10030 電気回路 komatsu // } ; 成績: Result s_id id point // Result[] result_table = { 16213 10020 83 // new Result( 16213 , 10020 , 83 ) , 18348 10010 95 // new Result( ... 17101 10030 64 // } ; 16213 10010 89 学生: Student s_id name age // Student[] student_table = { 16213 斉藤太郎 18 // new Student( 16213 , "斉藤太郎" , 18 ) , 17101 山田次郎 19 // new Student( ... 18348 渡辺花子 18 // } ; 以下のようなデータが出力されること 斉藤太郎 電気磁気学 83 渡辺花子 情報構造論 95 山田次郎 電気回路 64 斉藤太郎 情報構造論 89
配列に要素を追加
データが登録済みかどうかを判定する処理を作るために、登録された値を配列に次々と値を追加保存する場合、どのようにプログラムを記述するだろうか?
配列にデータを追加
次々と与えられた値を保存していくのであれば、Java であれば下記のようなコードが一般的であろう。
でも、ArrayList とはどのようにデータを覚えているのだろうか? なぜ 宣言は ArrayList<Integer> array であって ArrayList<int> array で宣言するとエラーが出るのであろうか?
import java.util.*; public class Main { public static void main(String[] args) throws Exception { // ArrayList は連続アドレス空間に保存してくれる可変長配列 // ランダムアクセスをする場合に向いている ArrayList<Integer> array = new ArrayList<Integer>() ; array.add( 11 ) ; array.add( 2 ) ; array.add( 333 ) ; for( Integer i : array ) { System.out.println( i ) ; } } }
このような ArrayList のようなデータ構造の仕組みを考えるために、最も単純な配列でプログラムを作ってみる。
末尾に追加
import java.util.*; public class Main { static int[] array = new int[ 10 ] ; static int size = 0 ; public static void add( int x ) { array[ size ] = x ; size++ ; } public static void main(String[] args) throws Exception { add( 11 ) ; add( 2 ) ; add( 333 ) ; for( int i = 0 ; i < size ; i++ ) System.out.println( array[i] ) ; } }
同じ処理をC言語で書いてみる。
#include <stdio.h> int array[ 10 ] ; int size = 0 ; void add( int x ) { // if ( size < array.length ) ... の判定が必要かも array[ size ] = x ; size++ ; } int main() { add( 11 ) ; add( 2 ) ; add( 333 ) ; for( int i = 0 ; i < size ; i++ ) printf( "%d\n" , array[ i ] ) ; return 0 ; }
しかし、このプログラムでは、最初に宣言した要素数10個を越えてデータを保存できないし、配列溢れさせないためには要素数の上限チェックも必要となるだろう。
昇順に並べながら途中に要素を追加
前述のプログラムでは、配列の末尾の場所を size で覚えておき、末尾にデータを追加していた。でも、配列に保存されている値の中から目的の値が含まれているか検索したいのであれば、配列に要素を昇順に保存しておいて2分探索法を使うのが一般的であろう。では、前述のプログラムを昇順で保存するにはどうすべきか?
最も簡単な方法で書くのであれば、下記のようなコードになるかもしれない。
public static void add( int x ) { int i ; for( i = 0 ; i < size ; i++ ) { // ここは2分探索で書けば O( log N ) にできるかも if ( array[ i ] > x ) break ; } // for( int j = i ; j < size ; j++ ) // 途中に挿入は、コレじゃダメ? // array[ j + 1 ] = array[ j ] ; for( int j = size - 1 ; j >= i ; j-- ) // 途中にデータを入れるために要素を1つ後ろに移動 array[ j + 1 ] = array[ j ] ; array[ i ] = x ; size++ ; }
void add( int x ) { int i ; for( i = 0 ; i < size ; i++ ) { if ( array[ i ] > x ) break ; } // for( int j = i ; j < size ; j++ ) // array[ j + 1 ] = array[ j ] ; for( int j = size - 1 ; j >= i ; j-- ) array[ j + 1 ] = array[ j ] ; array[ i ] = x ; size++ ; }
このプログラムでは、for( i … ) の処理でデータを挿入すべき場所を見つけ、for( int j … ) の繰り返しでデータを1つ後ろにずらしてから要素を加えている。
for( i … ) の処理は、このプログラムでは O( N ) となっているが、2分探索法を用いれば O( log N ) に改善ができるかもしれない。しかし、for( int j… ) の処理は、データを1つ後ろにずらす必要があるため O( N ) の処理が必要となる。
ここで、途中にデータを追加する処理の効率を改善することを考える。
リスト構造の導入
以下のデータ構造では、配列にデータと次のデータの場所を覚えることで、一見デタラメな順序に保存されているようにみえるが、next[] に次の値の保存されている場所が入っている。
import java.util.*; public class Main { // 0 1 2 3 4 5 static int[] data = new int[] { 11 , 55 , 22 , 44 , 33 , 0 , 0 , 0 , 0 , 0 } ; static int[] next = new int[] { 2 , -1 , 4 , 1 , 3 , 0 , 0 , 0 , 0 , 0 } ; static int size = 5 ; static int top = 0 ; static void insert( int n , int x ) { data[ size ] = x ; next[ size ] = next[ n ] ; next[ n ] = size ; size++ ; } public static void main(String[] args) throws Exception { for( int idx = top ; idx >= 0 ; idx = next[ idx ] ) System.out.println( data[ idx ] ) ; insert( 2 , 25 ) ; for( int idx = top ; idx >= 0 ; idx = next[ idx ] ) System.out.println( data[ idx ] ) ; } }
#include <stdio.h> int data[ 10 ] = { 11 , 55 , 22 , 44 , 33 , 0 , 0 , 0 , 0 , 0 } ; int next[ 10 ] = { 2 , -1 , 4 , 1 , 3 , 0 , 0 , 0 , 0 , 0 } ; int size = 5 ; int top = 0 ; void insert( int n , int x ) { data[ size ] = x ; next[ size ] = next[ n ] ; next[ n ] = size ; size++ ; } int main() { for( int idx = top ; idx >= 0 ; idx = next[ idx ] ) printf( "%d\n" , data[ idx ] ) ; insert( 2 , 25 ) ; for( int idx = top ; idx >= 0 ; idx = next[ idx ] ) printf( "%d\n" , data[ idx ] ) ; return 0 ; }
このようなデータ構造であれば、データ自体は末尾に保存しているが、次の値が入っている場所を修正することで途中にデータを挿入することができる。この方法であれば、途中にデータを入れる場合でもデータを後ろにずらすような処理が不要であり、O(1)で途中にデータを挿入できる。
このプログラムでは、配列の当初の長さを超えてデータを格納することはできない。
リスト構造 ListNode
前述の data と next で次々とデータを続けて保存するために、リスト構造(連結リスト)を定義する。
import java.util.*; class ListNode { int data ; ListNode next ; ListNode( int d , ListNode nx ) { this.data = d ; this.next = nx ; } } ; public class Main { public static void main(String[] args) throws Exception { ListNode top = new ListNode( 11 , new ListNode( 22 , new ListNode( 33 , null ) ) ) ; for( ListNode p = top ; p != null ; p = p.next ) System.out.println( p.data ) ; top.next = new ListNode( 15 , top.next ) ; for( ListNode p = top ; p != null ; p = p.next ) System.out.println( p.data ) ; } }
#include <stdio.h> #include <stdlib.h> struct ListNode { int data ; ListNode* next ; } ; ListNode* newListNode( int d , ListNode* nx ) { ListNode* _this = new ListNode() ; if ( _this != NULL ) { _this->data = d ; _this->next = nx ; } return _this ; } int main() { ListNode* top = newListNode( 11 , newListNode( 22 , newListNode( 33 , NULL ) ) ) ; for( ListNode* p = top ; p != NULL ; p = p->next ) printf( "%d\n" , p->data ) ; top->next = newListNode( 15 , top->next ) ; for( ListNode* p = top ; p != NULL ; p = p->next ) printf( "%d\n" , p->data ) ; return 0 ; }
Javaのジェネリクス
Javaのジェネリクス(C++のテンプレート)を使って書いてみた。ジェネリクスは、クラスやメソッドにおいて、特定の型を指定することなく動作するコードを記述することができる機能。これにより、型安全性を保ちながら、コードの再利用性と柔軟性を向上させることがでる。
import java.util.*; class ListNode<T> { T data ; ListNode<T> next ; ListNode( T d , ListNode<T> n ) { this.data = d ; this.next = n ; } } ; public class Main { public static void main(String[] args) throws Exception { // var 宣言は型推論で、右辺のデータ型を自動的に選択してくれる。 // itop は整数型のリスト var itop = new ListNode<Integer>( 11 , new ListNode<Integer>( 22 , new ListNode<Integer>( 33 , null ) ) ) ; // new List<int>( 11 , ... ) と書くと、<>の中は reference しか使えないと言われる。 for( var p = itop ; p != null ; p = p.next ) System.out.println( p.data ) ; // stop は文字列型のリスト var stop = new ListNode<String>( "aa" , new ListNode<String>( "bb" , new ListNode<String>( "cc" , null ) ) ) ; for( var p = stop ; p != null ; p = p.next ) System.out.println( p.data ) ; } }前述のプログラムをJavaのジェネリッククラスで記述
import java.util.*; public class Main { public static void main(String[] args) throws Exception { // LinkedList は上記のリスト構造で保存される。 // 途中に要素の追加削除を行ったり、シーケンシャルアクセスに向いたデータ構造 var top = new LinkedList<Integer>() ; top.add( 11 ) ; top.add( 22 ) ; top.add( 33 ) ; for( int i : top ) // 11 22 33 System.out.println( i ) ; top.add( 1 , 15 ) ; for( int i : top ) // 11 15 22 33 System.out.println( i ) ; } }
クラスの宣言とコンストラクタ・メソッド
import java.util.*; // クラス宣言 class Person { // データ構造 String name ; int age ; // コンストラクタ(データ構造を初期化する関数) Person( String n , int x ) { this.name = n ; // this は対象となるデータそのものを指す this.age = x ; // 対象が明言されていれば、this は省略可能 } // データを扱うメソッド void print() { // データを表示 System.out.println( this.name + "," + this.age ) ; } boolean sameAge( Person x ) { // 同じ年齢か判断するメソッド return this.age == x.age ; } } ; public class Main { public static void main(String[] args) throws Exception { Person tsaitoh = new Person( "Tohru Saitoh" , 59 ) ; Person tomoko = new Person( "Tomoko Saitoh" , 48 ) ; tsaitoh.print() ; // Tohru Saitoh, 59 tomoko.print() ; // Tomoko Saitoh,48 if ( tsaitoh.sameAge( tomoko ) ) { // sameAge( Person x ) では、 // this = tsaitoh , x = tomoko となって呼び出される System.out.println( "同じ年齢ですよ" ) ; } Person[] family = new Person[ 2 ] ; family[0] = tsaitoh ; family[1] = tomoko ; for( int i = 0 ; i < 2 ; i++ ) family[ i ].print() ; } }こ
このプログラムのデータ構造は下記のような状態。
情報構造論のレポート課題
情報構造論の前期中間までのレポートとして、自分の理解力に応じて下記課題の1つを選んで回答せよ。ポインタや文字列操作の練習を目的とするため、言語はC言語,C++にて行うこと。
- 入力の中の特定文字列ABCを、別の文字列DEFGに変換して出力したい。ABCやDEFGの文字列は最初に与える。
最初の2行で、変換元ABCと変換後DEFGで与えられ、その後に複数行の入力が続くものとする。- 変換元,変換後の文字列は、空白を含まない50文字以内の文字。複数行の入力は10文字以内、1行は200文字以内とする。
- 入力例と変換例
orange (変換元) apple (変換後) I like an orange. He likes a pineapple.
⇒ I like an apple. He likes a pineapple.
- URLが複数行入力として与えられる。最初にすべての入力行を配列に格納した後、URLの中のドメイン名部分は大文字小文字の区別がないので、ドメイン名部分だけ小文字に修正し、その結果を表示せよ。
- URLは10行以内、URLの1行は200文字以内とする。
- 変換例
- http://HOGE.jp/FUGA.html → http://hoge.jp/FUGA.html
- https://www.Google.co.jp/search?q=FOO+BAR
→ https://www.google.co.jp/search?q=FOO+BAR
- プログラムのソースコードが入力として与えられる。最初にすべての入力行を配列に格納した後、プログラム中のキーワード(int, char, if, while, など)だけを大文字に変換して出力するプログラムを作成せよ。(難易度高いので注意)
- プログラムは10行以内。1行は200文字以内とする。
- 変換例
- int a = 123 ; → INT a = 123 ;
- for( int form = 0 ; form < 10 ; form++ ) printf( “int = %d\n” , form ) ; // if
→ FOR( INT form = 0 ; form < 10 ; form++ ) printf( “int = %d\n” , form ) ; // if- formはキーワードではない。
- “int…”は、C言語の文字列内なのでキーワードではない。(オプション)
- /*…*/ , // のコメント内の if はキーワードではない。(オプション)
レポートには、下記の点を記載してあること。
- プログラムリスト
- 説明(コメントや解説)
- 動作検証とその結果
- 考察(自分のプログラムの問題点)
C言語での文字列処理に便利な標準関数<string.h>
- strlen( str ) : 文字列の長さを数える。文字列末尾文字NUL ‘\0’ までの文字数
- strcpy( dest , src ) : 文字列をコピー。
- strcmp( s1 , s2 ) : 文字列を比較(辞書順で s1<s2 なら負の値, s1=s2 なら0, s1>s2 なら正の値を返す)
- strncmp( s1 , s2 , n ) : 文字列を指定した長さ n までで比較。
文字判定に便利な標準関数<ctype.h>
- isalpha( c ) : 文字 c が英字(A-Z or a-z)、isdigit( c ) : 文字 c が数字(0-9)
ポインタと文字列処理
C言語でのポインター
#include <stdio.h> int main() { int x = 123 ; // px [ 〇 ] int* px ; // px はポインタ ↓ px = &x ; // x の変数の番地を px に代入 x [ 123 ] *px = 321 ; // px の指し示す場所に 321 を代入 printf( "%d\n" , x ) ; // 321 を出力 return 0 ; }
値渡し(pass by value)
// 値渡しのプログラム void foo( int x ) { // x は局所変数(仮引数は呼出時に // 対応する実引数で初期化される。 x++ ; printf( "%d¥n" , x ) ; } int main() { int a = 123 ; foo( a ) ; // 124 // 処理後も main::a は 123 のまま。 foo( a ) ; // 124 return 0 ; }
このプログラムでは、aの値は変化せずに、124,124 が表示される。
でも、プログラムによっては、124,125 と変化して欲しい場合もある。
どのように記述すべきだろうか?
// 大域変数を使う場合 int x ; void foo() { x++ ; printf( "%d¥n" , x ) ; } int main() { x = 123 ; foo() ; // 124 foo() ; // 125 return 0 ; }
しかし、このプログラムは大域変数を使うために、間違いを引き起こしやすい。
// 大域変数が原因で予想外の挙動をしめす簡単な例 int i ; void foo() { for( i = 0 ; i < 2 ; i++ ) printf( "A" ) ; } int main() { for( i = 0 ; i < 3 ; i++ ) // このプログラムでは、AA AA AA と foo() ; // 表示されない。 return 0 ; }
ポインタ渡し(pass by pointer)
C言語で引数を通して、呼び出し側の値を変化して欲しい場合は、変更して欲しい変数のアドレスを渡し、関数側では、ポインタ変数を使って受け取った変数のアドレスの示す場所の値を操作する。
// ポインタ渡しのプログラム void foo( int* p ) { // p はポインタ (*p)++ ; printf( "%d¥n" , *p ) ; } int main() { int a = 123 ; foo( &a ) ; // 124 // 処理後 main::a は 124 に増えている。 foo( &a ) ; // 124 return 0 ; // さらに125と増える }
ポインタを利用して引数に副作用を与える方法は、ポインタを正しく理解していないプログラマーでは、危険な操作となる。C++では、ポインタ渡しを極力使わないようにするために、参照渡しを利用する。ただし、ポインタ渡しも参照渡しも、機械語レベルでは同じ処理にすぎない。
参照渡し(pass by reference)
// ポインタ渡しのプログラム void foo( int& x ) { // xは参照 x++ ; printf( "%d¥n" , x ) ; } int main() { int a = 123 ; foo( a ) ; // 124 // 処理後 main::a は 124 に増えている。 foo( a ) ; // 124 return 0 ; // さらに125と増える。 }
ポインタの加算と配列アドレス
ポインタに整数値を加えることは、アクセスする場所が、指定された分だけ後ろにずれることを意味する。
// ポインタ加算の例 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 だけを記述すると、配列の先頭を意味することに注意。
ポインタと文字列処理
#include <stdio.h> void my_tolower( char d[] , char s[] ) { int i ; for( i = 0 ; s[i] != '#include <stdio.h> void my_tolower( char d[] , char s[] ) { int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) d[i] = s[i] - 'A' + 'a' ; else d[i] = s[i] ; d[i] = '\0' ; } int main(void){ char str[ 20 ] ; my_tolower( str , "AaBcDeF Hoge" ) ; printf( "%s\n" , str ) ; return 0 ; }' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) d[i] = s[i] - 'A' + 'a' ; else d[i] = s[i] ; d[i] = '#include <stdio.h> void my_tolower( char d[] , char s[] ) { int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) d[i] = s[i] - 'A' + 'a' ; else d[i] = s[i] ; d[i] = '\0' ; } int main(void){ char str[ 20 ] ; my_tolower( str , "AaBcDeF Hoge" ) ; printf( "%s\n" , str ) ; return 0 ; }' ; } int main(void){ char str[ 20 ] ; my_tolower( str , "AaBcDeF Hoge" ) ; printf( "%s\n" , str ) ; return 0 ; }
間違ったプログラム
C言語の面倒な点は、データがどのように格納されるのかを考えないと正しく動かない所であろう。
下記のプログラムの問題点がわかるだろうか?
#include <stdio.h> // 前述の my_tolower と同じ void my_tolower( char d[] , char s[] ) { int i ; for( i = 0 ; s[i] != '#include <stdio.h> // 前述の my_tolower と同じ void my_tolower( char d[] , char s[] ) { int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) d[i] = s[i] - 'A' + 'a' ; else d[i] = s[i] ; d[i] = '\0' ; } // 引数に副作用のある my_tolower char* my_tolower_1( char s[] ) { for( int i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) s[i] = s[i] - 'A' + 'a' ; return s ; } // 局所変数のメモリを帰してはダメ char* my_tolower_2( char s[] ) { char str[ 20 ] ; int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) str[i] = s[i] - 'A' + 'a' ; else str[i] = s[i] ; str[i] = '\0' ; // printf( "in my_tolower_2 : %s\n" , str ) ; return str ; } int main(void) { char str[ 20 ] = "Hoge" ; ; // case-1 char* ptr ; my_tolower( ptr , "Piyo" ) ; // Illegal instruction (core dumped) // case-2 printf( "%s\n" , my_tolower_1( str ) ) ; printf( "%s\n" , my_tolower_1( "Fuga" ) ) ; // 小文字にならない // csse-3 printf( "%s\n" , my_tolower_2( "foo" ) ) ; // ゴミが表示される return 0 ; }' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) d[i] = s[i] - 'A' + 'a' ; else d[i] = s[i] ; d[i] = '#include <stdio.h> // 前述の my_tolower と同じ void my_tolower( char d[] , char s[] ) { int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) d[i] = s[i] - 'A' + 'a' ; else d[i] = s[i] ; d[i] = '\0' ; } // 引数に副作用のある my_tolower char* my_tolower_1( char s[] ) { for( int i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) s[i] = s[i] - 'A' + 'a' ; return s ; } // 局所変数のメモリを帰してはダメ char* my_tolower_2( char s[] ) { char str[ 20 ] ; int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) str[i] = s[i] - 'A' + 'a' ; else str[i] = s[i] ; str[i] = '\0' ; // printf( "in my_tolower_2 : %s\n" , str ) ; return str ; } int main(void) { char str[ 20 ] = "Hoge" ; ; // case-1 char* ptr ; my_tolower( ptr , "Piyo" ) ; // Illegal instruction (core dumped) // case-2 printf( "%s\n" , my_tolower_1( str ) ) ; printf( "%s\n" , my_tolower_1( "Fuga" ) ) ; // 小文字にならない // csse-3 printf( "%s\n" , my_tolower_2( "foo" ) ) ; // ゴミが表示される return 0 ; }' ; } // 引数に副作用のある my_tolower char* my_tolower_1( char s[] ) { for( int i = 0 ; s[i] != '#include <stdio.h> // 前述の my_tolower と同じ void my_tolower( char d[] , char s[] ) { int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) d[i] = s[i] - 'A' + 'a' ; else d[i] = s[i] ; d[i] = '\0' ; } // 引数に副作用のある my_tolower char* my_tolower_1( char s[] ) { for( int i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) s[i] = s[i] - 'A' + 'a' ; return s ; } // 局所変数のメモリを帰してはダメ char* my_tolower_2( char s[] ) { char str[ 20 ] ; int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) str[i] = s[i] - 'A' + 'a' ; else str[i] = s[i] ; str[i] = '\0' ; // printf( "in my_tolower_2 : %s\n" , str ) ; return str ; } int main(void) { char str[ 20 ] = "Hoge" ; ; // case-1 char* ptr ; my_tolower( ptr , "Piyo" ) ; // Illegal instruction (core dumped) // case-2 printf( "%s\n" , my_tolower_1( str ) ) ; printf( "%s\n" , my_tolower_1( "Fuga" ) ) ; // 小文字にならない // csse-3 printf( "%s\n" , my_tolower_2( "foo" ) ) ; // ゴミが表示される return 0 ; }' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) s[i] = s[i] - 'A' + 'a' ; return s ; } // 局所変数のメモリを帰してはダメ char* my_tolower_2( char s[] ) { char str[ 20 ] ; int i ; for( i = 0 ; s[i] != '#include <stdio.h> // 前述の my_tolower と同じ void my_tolower( char d[] , char s[] ) { int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) d[i] = s[i] - 'A' + 'a' ; else d[i] = s[i] ; d[i] = '\0' ; } // 引数に副作用のある my_tolower char* my_tolower_1( char s[] ) { for( int i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) s[i] = s[i] - 'A' + 'a' ; return s ; } // 局所変数のメモリを帰してはダメ char* my_tolower_2( char s[] ) { char str[ 20 ] ; int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) str[i] = s[i] - 'A' + 'a' ; else str[i] = s[i] ; str[i] = '\0' ; // printf( "in my_tolower_2 : %s\n" , str ) ; return str ; } int main(void) { char str[ 20 ] = "Hoge" ; ; // case-1 char* ptr ; my_tolower( ptr , "Piyo" ) ; // Illegal instruction (core dumped) // case-2 printf( "%s\n" , my_tolower_1( str ) ) ; printf( "%s\n" , my_tolower_1( "Fuga" ) ) ; // 小文字にならない // csse-3 printf( "%s\n" , my_tolower_2( "foo" ) ) ; // ゴミが表示される return 0 ; }' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) str[i] = s[i] - 'A' + 'a' ; else str[i] = s[i] ; str[i] = '#include <stdio.h> // 前述の my_tolower と同じ void my_tolower( char d[] , char s[] ) { int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) d[i] = s[i] - 'A' + 'a' ; else d[i] = s[i] ; d[i] = '\0' ; } // 引数に副作用のある my_tolower char* my_tolower_1( char s[] ) { for( int i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) s[i] = s[i] - 'A' + 'a' ; return s ; } // 局所変数のメモリを帰してはダメ char* my_tolower_2( char s[] ) { char str[ 20 ] ; int i ; for( i = 0 ; s[i] != '\0' ; i++ ) if ( 'A' <= s[i] && s[i] <= 'Z' ) str[i] = s[i] - 'A' + 'a' ; else str[i] = s[i] ; str[i] = '\0' ; // printf( "in my_tolower_2 : %s\n" , str ) ; return str ; } int main(void) { char str[ 20 ] = "Hoge" ; ; // case-1 char* ptr ; my_tolower( ptr , "Piyo" ) ; // Illegal instruction (core dumped) // case-2 printf( "%s\n" , my_tolower_1( str ) ) ; printf( "%s\n" , my_tolower_1( "Fuga" ) ) ; // 小文字にならない // csse-3 printf( "%s\n" , my_tolower_2( "foo" ) ) ; // ゴミが表示される return 0 ; }' ; // printf( "in my_tolower_2 : %s\n" , str ) ; return str ; } int main(void) { char str[ 20 ] = "Hoge" ; ; // case-1 char* ptr ; my_tolower( ptr , "Piyo" ) ; // Illegal instruction (core dumped) // case-2 printf( "%s\n" , my_tolower_1( str ) ) ; printf( "%s\n" , my_tolower_1( "Fuga" ) ) ; // 小文字にならない // csse-3 printf( "%s\n" , my_tolower_2( "foo" ) ) ; // ゴミが表示される return 0 ; }
ポインタインクリメントと式
C言語では、ポインタを動かしながら処理を行う場合に以下のようなプログラムもよくでてくる。
// string copy 配列のイメージで記載 void strcpy( char d[] , char s[] ) { int i ; for( i = 0 ; s[ i ] != '// string copy 配列のイメージで記載 void strcpy( char d[] , char s[] ) { int i ; for( i = 0 ; s[ i ] != '\0' ; i++ ) d[ i ] = s[ i ] ; d[ i ] = '\0' ; } int main() { char a[] = "abcde" ; char b[ 10 ] ; strcpy( b , a ) ; printf( "%s\n" , b ) ; return 0 ; }' ; i++ ) d[ i ] = s[ i ] ; d[ i ] = '// string copy 配列のイメージで記載 void strcpy( char d[] , char s[] ) { int i ; for( i = 0 ; s[ i ] != '\0' ; i++ ) d[ i ] = s[ i ] ; d[ i ] = '\0' ; } int main() { char a[] = "abcde" ; char b[ 10 ] ; strcpy( b , a ) ; printf( "%s\n" , b ) ; return 0 ; }' ; } int main() { char a[] = "abcde" ; char b[ 10 ] ; strcpy( b , a ) ; printf( "%s\n" , b ) ; return 0 ; }
しかし、この strcpy は、ポインタを使って書くと以下のように書ける。
// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '\0' ) { *p = *q ; p++ ; q++ ; } *p = '\0' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '\0' ) *p++ = *q++ ; // *(p++) = *(q++) *p = '\0' ; } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '\0' ) // while( *p++ = *q++ ) ; でも良い ; }' ) { *p = *q ; p++ ; q++ ; } *p = '// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '\0' ) { *p = *q ; p++ ; q++ ; } *p = '\0' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '\0' ) *p++ = *q++ ; // *(p++) = *(q++) *p = '\0' ; } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '\0' ) // while( *p++ = *q++ ) ; でも良い ; }' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '\0' ) { *p = *q ; p++ ; q++ ; } *p = '\0' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '\0' ) *p++ = *q++ ; // *(p++) = *(q++) *p = '\0' ; } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '\0' ) // while( *p++ = *q++ ) ; でも良い ; }' ) *p++ = *q++ ; // *(p++) = *(q++) *p = '// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '\0' ) { *p = *q ; p++ ; q++ ; } *p = '\0' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '\0' ) *p++ = *q++ ; // *(p++) = *(q++) *p = '\0' ; } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '\0' ) // while( *p++ = *q++ ) ; でも良い ; }' ; } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '\0' ) { *p = *q ; p++ ; q++ ; } *p = '\0' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '\0' ) *p++ = *q++ ; // *(p++) = *(q++) *p = '\0' ; } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '\0' ) // while( *p++ = *q++ ) ; でも良い ; }' ) // while( *p++ = *q++ ) ; でも良い ; }
値渡しと参照渡しとポインター
Javaでの引数に対する副作用
Javaでのプログラムにおいて、下記のように関数に引数でデータが渡された場合、呼び出し元の変数が変化する/変化しないの違いが分かるであろうか?
import java.util.*; class A { private int a ; public A( int x ) { a = x ; } public void set( int x ) { a = x ; } public int get() { return a ; } } public class Main { public static void foo( int x , Integer y , String s , int z[] , A a ) { x = 12345 ; // プリミティブな引数の書き換え y = 23456 ; // イミュータブルな引数の書き換え s = "hoge" ; z[0] = 34567 ; // 参照で渡されたオブジェクトの書き換え a.set( 45678 ) ; } public static void main(String[] args) throws Exception { int mx = 11111 ; // プリミティブなデータ Integer my = 22222 ; // イミュータブルなオブジェクト String ms = "aaa" ; int mz[] = { 33333 } ; // それ以外のオブジェクト A ma = new A( 44444 ) ; foo( mx , my , ms , mz , ma ) ; System.out.println( "mx="+mx+",my="+my+",ms="+ms+",mz[0]="+mz[0]+",ma="+ma.get() ); } }
上記のプログラムでは、foo() の第1引数 mx は、プリミティブ型なので関数の引数に渡される際には、コピーが生成されて渡されるため、呼び出し元の変数 mx の値は変化していない。
Javaでは、プリミティブ型以外のデータは、ヒープ領域に実体が保存され、そのデータの場所(ポインタ)によって管理される。
しかし、Integer型のオブジェクト my や、String型のオブジェクト ms は、参照(データの場所)が渡されるが、イミュータブルな(変更できない)オブジェクトなので、値の代入が発生すると新しいオブジェクトが生成され、そのアドレスが参照を保存している変数(ポインタ)に代入される。このため、呼び出し元の my や ms は値が変化しない。
これに対し、配列 mz や クラスオブジェクト ma は、オブジェクトの中身を関数 foo で値を変更すると、呼び出し元の変数の内容が変更される。こういった関数やメソッドの呼び出しによって、呼び出し元の値が変化することは「副作用」と呼ばれる。
こういった参照のメカニズムは、データの管理の仕方を正しく理解する必要があることから、もっと原始的な C 言語にて理解を目指す。
C言語の基礎
#include <stdio.h> int main() { int n ; scanf( "%d" , &n ) ; // 標準入力から整数をnに保存 int m = 1 ; for( int i = 1 ; i <= n ; i++ ) m *= i ; printf( "%d! = %d\n" , n , m ) ; // return 0 ; }
printf の最初の引数は、表示する際のフォーマットであり、%d の部分には対応する引数の値に置き換えて表示される。
型 | 基数 | 型 | 表示方式 long int %ld | 10進数 %d | double %lf | 固定小数点表示 %f 12.34 int %d | 16進数 %x | float %f | 指数小数点表示 %e 1.234e+1 short int %hd | 8進数 %o | | 固定/指数自動 %g char %c | | printf( "%5.2f" , 1.2345 ) ; □1.23 char[], char* %s | |
// Compile by C++ #include <stdio.h> int main(void) { long int x = 123456789L ; int y = 1234567 ; short int z = 32767 ; printf( "%ld %d %hd\n" , x , y , z ) ; // 123456789 1234567 32767 printf( "%d %x %o\n" , 0x1000 , 32767 , 32767 ) ; // 4096 7fff 77777 double p = 123.45678L ; float q = 12.345 ; printf( "%lf %f\n" , p , q ) ; // 123.456780 12.345000 printf( "(%lf) (%8.3lf) (%le)\n" , p , p , p ) ; // (123.456780) ( 123.457) (1.234568e+02) char c = 0x41 ; char s[] = "ABCDE" ; char t[] = { 0x41 , 0x42 , 0x43 , 0x0 } ; // C言語の文字列の末尾には'// Compile by C++ #include <stdio.h> int main(void) { long int x = 123456789L ; int y = 1234567 ; short int z = 32767 ; printf( "%ld %d %hd\n" , x , y , z ) ; // 123456789 1234567 32767 printf( "%d %x %o\n" , 0x1000 , 32767 , 32767 ) ; // 4096 7fff 77777 double p = 123.45678L ; float q = 12.345 ; printf( "%lf %f\n" , p , q ) ; // 123.456780 12.345000 printf( "(%lf) (%8.3lf) (%le)\n" , p , p , p ) ; // (123.456780) ( 123.457) (1.234568e+02) char c = 0x41 ; char s[] = "ABCDE" ; char t[] = { 0x41 , 0x42 , 0x43 , 0x0 } ; // C言語の文字列の末尾には'\0'が必要 printf( "(%c) (%s) (%s)\n" , c , s , t ) ; // (A) (ABCDE) (ABC) return 0 ; }'が必要 printf( "(%c) (%s) (%s)\n" , c , s , t ) ; // (A) (ABCDE) (ABC) return 0 ; }
C言語でのポインター
#include <stdio.h> int main() { int x = 123 ; // px [ 〇 ] int* px ; // px はポインタ ↓ px = &x ; // x の変数の番地を px に代入 x [ 123 ] *px = 321 ; // px の指し示す場所に 321 を代入 printf( "%d\n" , x ) ; // 321 を出力 return 0 ; }
ポインタの加算と配列アドレス
ポインタに整数値を加えることは、アクセスする場所が、指定された分だけ後ろにずれることを意味する。
// ポインタ加算の例 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 だけを記述すると、配列の先頭を意味することに注意。
ポインタインクリメントと式
C言語では、ポインタを動かしながら処理を行う場合に以下のようなプログラムもよくでてくる。
// string copy 配列のイメージで記載 void strcpy( char d[] , char s[] ) { int i ; for( i = 0 ; s[ i ] != '// string copy 配列のイメージで記載 void strcpy( char d[] , char s[] ) { int i ; for( i = 0 ; s[ i ] != '\0' ; i++ ) d[ i ] = s[ i ] ; d[ i ] = '¥0' ; } int main() { char a[] = "abcde" ; char b[ 10 ] ; strcpy( b , a ) ; printf( "%s\n" , b ) ; return 0 ; }' ; i++ ) d[ i ] = s[ i ] ; d[ i ] = '¥0' ; } int main() { char a[] = "abcde" ; char b[ 10 ] ; strcpy( b , a ) ; printf( "%s\n" , b ) ; return 0 ; }
しかし、この strcpy は、ポインタを使って書くと以下のように書ける。
// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '\0' ) { *p = *q ; p++ ; q++ ; } *p = '\0' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '\0' ) *p++ = *q++ ; // *(p++) = *(q++) } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '\0' ) // while( *p++ = *q++ ) ; でも良い ; }' ) { *p = *q ; p++ ; q++ ; } *p = '// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '\0' ) { *p = *q ; p++ ; q++ ; } *p = '\0' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '\0' ) *p++ = *q++ ; // *(p++) = *(q++) } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '\0' ) // while( *p++ = *q++ ) ; でも良い ; }' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '\0' ) { *p = *q ; p++ ; q++ ; } *p = '\0' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '\0' ) *p++ = *q++ ; // *(p++) = *(q++) } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '\0' ) // while( *p++ = *q++ ) ; でも良い ; }' ) *p++ = *q++ ; // *(p++) = *(q++) } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '// string copy ポインタのイメージで記載 void strcpy( char* p , char* q ) { while( *q != '\0' ) { *p = *q ; p++ ; q++ ; } *p = '\0' ; } // ポインタ加算と代入を一度に書く void strcpy( char* p , char* q ) { while( *q != '\0' ) *p++ = *q++ ; // *(p++) = *(q++) } // ポインタ加算と代入と'¥0'判定を一度に書く void strcpy( char* p , char* q ) { while( (*p++ = *q++) != '\0' ) // while( *p++ = *q++ ) ; でも良い ; }' ) // while( *p++ = *q++ ) ; でも良い ; }
再帰呼び出しと処理時間の見積もり
前回の講義で説明できなかった、オーダーの問題の解説
練習問題
- の処理時間を要するアルゴリズム(データ件数が変わっても処理時間は一定)を、オーダー記法で書くとどうなるか?また、このような処理時間となるアルゴリズムの例を答えよ。
- ある処理のデータ数Nに対する処理時間が、であった場合、オーダー記法で書くとどうなるか?
- の処理時間を要するアルゴリズムを、オーダー記法で書くとどうなるか?
(ヒント: ロピタルの定理)
- 1は、O(1)。
- 誤答の例:O(0)と書いちゃうと、T(N)=Tα×0=0になってしまう。
- 事例は、電話番号を、巨大配列の”電話番号”番目の場所に記憶するといった方法。(これはハッシュ法で改めて講義予定)
- 2は、N→∞において、N2 ≪ 2Nなので、O(2N) 。厳密に回答するなら、練習問題3と同様の証明が必要。
- 3の解説
再帰呼び出しの基本
次に、再帰呼び出しを含むような処理の処理時間見積もりについて解説をおこなう。そのまえに、再帰呼出しと簡単な処理の例を説明する。
再帰関数は、自分自身の処理の中に「問題を小さくした」自分自身の呼び出しを含む関数。プログラムには問題が最小となった時の処理があることで、再帰の繰り返しが止まる。
// 階乗 (末尾再帰) int fact( int x ) { if ( x <= 1 ) return 1 ; else return x * fact( x-1 ) ; } // ピラミッド体積 (末尾再帰) int pyra( int x ) { if ( x <= 1 ) return 1 ; else return x*x + pyra( x-1 ) ; } // フィボナッチ数列 (非末尾再帰) int fib( int x ) { if ( x <= 2 ) return 1 ; else return fib( x-1 ) + fib( x-2 ) ; }
階乗 fact(N) を求める処理は、以下の様に再帰が進む。(N=5の場合)
また、フィボナッチ数列 fib(N) を求める処理は以下の様に再帰が進む。(N=5の場合)
再帰呼び出しの処理時間
次に、この再帰処理の処理時間を説明する。 最初のfact(),pyra()については、 x=1の時は、関数呼び出し,x<=1,return といった一定の処理時間を要し、T(1)=Ta で表せる。 x>1の時は、関数呼び出し,x<=1,*,x-1,returnの処理(Tb)に加え、x-1の値で再帰を実行する処理時間T(N-1)がかかる。 このことから、 T(N)=Tb=T(N-1)で表せる。
} 再帰方程式
このような、式の定義自体を再帰を使って表した式は再帰方程式(漸化式)と呼ばれる。これを以下のような代入の繰り返しによって解けば、一般式 が得られる。
T(1)=Ta
T(2)=Tb+T(1)=Tb+Ta
T(3)=Tb+T(2)=2×Tb+Ta
:
T(N)=Tb+T(N-1)=Tb + (N-2)×Tb+Ta
一般的に、再帰呼び出しプログラムは(考え方に慣れれば)分かりやすくプログラムが書けるが、プログラムを実行する時には、局所変数や関数の戻り先を覚える必要があり、深い再帰ではメモリ使用量が多くなる。
ただし、fact() や pyra() のような関数は、プログラムの末端で再帰が行われている。(fib()は、再帰の一方が末尾ではない)
このような再帰は、末尾再帰(tail recursion) と呼ばれ、関数呼び出しの return を、再帰処理の先頭への goto 文に書き換えるといった最適化が可能である。言い換えるならば、末尾再帰の処理は繰り返し処理に書き換えが可能である。このため、末尾再帰の処理をループにすれば再帰のメモリ使用量の問題を克服できる。
再帰を含む一般的なプログラム例
ここまでのfact()やpyra()のような処理の再帰方程式は、再帰の度にNの値が1減るものばかりであった。もう少し一般的な再帰呼び出しのプログラムを、再帰方程式で表現し、処理時間を分析してみよう。
以下のプログラムを実行したらどんな値になるであろうか?それを踏まえ、処理時間はどのように表現できるであろうか?
// 分割統治法による配列合計 #include <stdio.h> int sum( int a[] , int L , int R ) { // 非末尾再帰 // L : 左端のデータ // R : 右端のデータが入っているの場所+1 if ( R - L == 1 ) { return a[ L ] ; } else { int M = (L + R) / 2 ; return sum( a , L , M ) + sum( a , M , R ) ; } } int main() { int array[ 8 ] = { // L=0 1 2 3 4 5 6 7 R=8 3 , 6 , 9 , 1 , 8 , 2 , 4 , 5 , } ; printf( "%d¥n" , sum( array , 0 , 8 ) ) ; return 0 ; }
// 分割統治法による配列合計 import java.util.*; public class Main { static int sum( int a[] , int L , int R ) { // 非末尾再帰 // L : 左端のデータ // R : 右端のデータが入っているの場所+1 if ( R - L == 1 ) { return a[ L ] ; } else { int M = (L + R) / 2 ; return sum( a , L , M ) + sum( a , M , R ) ; } } public static void main(String[] args) throws Exception { int array[] = { // L=0 1 2 3 4 5 6 7 R=8 3 , 6 , 9 , 1 , 8 , 2 , 4 , 5 , } ; System.out.println( sum( array , 0 , array.length ) ); } }
このプログラムでは、配列の合計を計算しているが、引数の L,R は、合計範囲の 左端(左端のデータのある場所)・右端(右端のデータのある場所+1)を表している。そして、再帰のたびに2つに分割して解いている。
このような、処理を(この例では半分に)分割し、分割したそれぞれを再帰で計算し、その処理結果を組み合わせて最終的な結果を求めるような処理方法を、分割統治法と呼ぶ。
このプログラムでは、対象となるデータ件数(R-L)をNとおいた場合、実行される命令からsum()の処理時間Ts(N)は次の再帰方程式で表せる。
← Tβ + (L〜M)の処理時間 + (M〜R)の処理時間
これを代入の繰り返しで解いていくと、
ということで、このプログラムの処理時間は、 で表せる。
ハノイの塔
ここまでは、簡単な再帰呼び出しのプログラムを例にして再帰方程式などの説明を行った。次に「ハノイの塔」の処理時間を例題に、プログラムの処理時間について分析を行う。
ハノイの塔は、3本の塔にN枚のディスクを積み、(1)1回の移動ではディスクを1枚しか動かせない、(2)ディスクの上により大きいディスクを積まない…という条件で、山積みのディスクを目的の山に移動させるパズル。
一般解の予想
ハノイの塔の移動回数を とした場合、 少ない枚数での回数の考察から、 以下の一般式で表せることが予想できる。
… ①
この予想が常に正しいことを証明するために、ハノイの塔の処理を、 最も下のディスク1枚への操作と、その上の(N-1)枚のディスクへの操作に分けて考える。
再帰方程式
上記右の図より、N枚の移動をするためには、上に重なるN-1枚を移動させる必要があるので、
… ②
… ③
ということが言える。(これがハノイの塔の移動回数の再帰方程式)
ディスクが枚の時、予想①が正しいのは明らか①,②。
ディスクが 枚で、予想が正しいと仮定すると、 枚では、
… ③より
… ①を代入
… ①のの場合
となり、 枚でも、予想が正しいことが証明された。 よって数学的帰納法により、1枚以上で予想が常に成り立つことが証明できた。
また、ハノイの塔の処理時間は、で表せる。