線形リスト

アドバンスドプログラミングのTOPページに戻る

線形リストとは

線形リスト(あるいは単に,リスト)というデータ構造も,1次元配列と同様に,データの列を扱うためのものである.

配列が特定の型の要素をメモリ上に整然と並べたものであるのに対して,線形リストはデータとポインタとが入ったノードと呼ばれる要素をポインタでつないだものである. また、そのノードはメモリ上に整然と並んでいるとは限らない。

ノード

次の図は,3個のノードで構成された線形リストである.

リスト

ノードはメモリ上に順番に存在する必要はないが,ノードのポインタは次の要素をきちんと指している必要がある.リスト最後尾の要素にあるポインタの値は,何も指さないポインタ(NULL ポインタ)となっている.

線形リストの各ノードにアクセスするには,リストの先頭を指すポインタから入って,ノードを順にたどって行くことで,行われる.

利点と欠点

 線形リストと配列とを比べたときの,線形リストの利点と欠点について述べる

NULLポインタとは

NULL は,何も指していないことを意味するポインタ定数として,stdlib.h, stddef.h, stdio.h, string.h, time.h でマクロ定義されている。 NULLを用いるときには,これらのヘッダファイルのどれか一つ以上をインクルードすればよい。

このページ(線形リスト)の先頭に戻る

ヒープの利用法

線形リストは,通常,ヒープを利用して実現されるので,その利用法を知る必要がある.

ヒープとは

ヒープ(heap)は,プログラムの実行途中にプログラムから随時利用可能なメモリ領域である。 heap は、積み上げた山、堆積物などの意味の英語である。

ヒープを扱うための標準ライブラリ関数

 ヒープの管理は,malloc, calloc, realloc, free などの関数によってなされている.これらは,ヘッダファイル stdlib.h に宣言された標準ライブラリ関数であるので,使用する際には, それに先だって, 次のようにヘッダファイルを読み込むことが必要である.

#include <stdlib.h>
メモリ割付

関数 void * malloc(size_t size) を用いることにより, size バイトの連続したメモリ領域が,ヒープ領域内に割り当てられる.割り当てられた領域の先頭を指すポインタが,この関数の戻り値となる.何らかの理由で割り当てに失敗した場合には, NULL ポインタが戻り値となる.関数名 malloc は memory allocate (メモリ割付)の略である.

malloc
    int *p;
    p = malloc(sizeof (int));

このように実行すると,これ以降,*p を整数型変数として使用することできる.

    *p = 3;
メモリ解放

malloc によって割り当てられたメモリ領域は,その先頭を指すポインタを関数 void free(void *ptr) の引数に渡すことにより,解放される.解放されたメモリ領域は,その後に malloc が呼ばれたときに(可能であれば)再利用される.

free
    free(p);

これ以降,*p を変数として使うことはできない。使用するようなプログラムを書いた場合,コンパイルエラーにはならないが,そのプログラムは正常に作動しない。

初期化割付,再割付

関数 calloc(初期化区画割付), realloc(再割付) については,C言語参考書等を参考にして欲しい.

このページ(線形リスト)の先頭に戻る

線形リストの構築

線形リストのノードとなる構造体を定義し,線形リストを操作する関数群を用意して,線形リストを構築してみる.

ノード構造体の定義

線形リストを構成する,一つひとつのノードは,データと次のノードを指すポインタとでできている.このような,複数個のものが一つにまとまったものを構成するには,構造体を用いると良い.

また,ここでは,ノードが含んでいるデータの型名として, data_t という名前を用いる.これは,ここで作った構造体や関数群を書き直して,他の用途に使うときに,変更が容易であるようにするための配慮である.

typedef int data_t;

typedef struct nodetag {
    data_t data;
    struct nodetag *next;
} node_t;

この構造体定義について,一つずつ解説しよう.

まず全体が,次のような形になっている。

typedef 
    .....
 node_t;

これは,..... というものを node_t という新しい名前の型として定義する,という意味である.そして,..... は次のようになっている.

struct nodetag {
    data_t data;
    struct nodetag *next;
}

この struct nodetag は, nodetag というタグ名の構造体の定義であることを表している.そして,それに続く { } の中が構造体の本体である.

data_t data はデータを入れるためのメンバである.

struct nodetag *next は,次のノードにつなげるためのポインタである.ポインタ next が指すものの型は,いま定義している構造体, struct nodetag である.

もっと詳しい解説

簡単な線形リストの例

では,このノード構造体を使って線形リストを作ってみよう. 3個のノードだけでできている線形リストである.

main()
{
    node_t nd1, nd2, nd3;
    node_t *p;
    
    nd1.data = 1;
    nd1.next = &nd2;
    nd2.data = 2;
    nd2.next = &nd3;
    nd3.data = 3;
    nd3.next = NULL;
    
    p = &nd1;
    printf("%d\n", p -> data);     /*p は nd1 を指している */

    p = p -> next;
    printf("%d\n", p -> data);    /* p は nd2 を指している */
    
    p = p -> next;
    printf("%d\n", p -> data);    /* p は nd3 を指している */
}
1
2
3

次の図は,プログラムの途中,p = &nd1 を実行した時点での状態であり, p は nd1 を指している.

3個のノードをもつリスト

ここで p = p -> next を実行すると, p の値は p -> next すなわち nd1.next すなわち &nd2 となるので, p は nd2 を指すことになる.

3個のノードをもつリスト

もう一度 p = p -> next を実行すると, p の値は p -> next すなわち nd2.next すなわち &nd3 となるので, p は nd3 を指すことになる.

3個のノードをもつリスト

ノードのメモリ割り当て

これからは,線形リストの各ノードは,次で定義する nodeNew 関数によって,ヒープ領域にメモリ割り当てすることとする。 したがって,ノードのためのメモリの確保は,

    node_t nd; 

のように,直接, node_t 型変数として宣言されるのではなく, 次のように,ノード型ポインタとして宣言された変数に,nodeNew によって割り当てたメモリを指すポインタ値を代入することにより成される.

    node_t *ndptr;
    ndptr = nodeNew(...);

もちろん,さきほどのプログラム例のように, node_t nd; と宣言しても,間違いではないが,これでは線形リストの柔軟な性質が生きてこない.

関数 node_t *nodeNew(data_t dt, node_t *nxt) の仕様
新しいノード構造体のためのメモリをヒープ領域に割り当てるための関数である.ヒープメモリをノード構造体に割り当てたのち,そのメンバ data と next とを,二つの引数 dt と nxt とで初期化する.割り当てに成功した場合,メモリを割り当てて確保したノード構造体変数へのポインタを返す.失敗した場合は, NULL を返す.
node_t *nodeNew(data_t dt, node_t *nxt)
{
    node_t * ndPtr;

    ndPtr = malloc(sizeof (node_t));
    if (ndPtr == NULL) {
        return NULL;      /* 割り当て失敗 */
    } else {
        ndPtr -> data = dt;
        ndPtr -> next = nxt;
        return ndPtr;
    }
}

sizeof (node_t) は,ノード構造体のメモリサイズである.その容量のメモリを malloc で割り当てて,それを指すポインタを ndPtr に代入している.その値が NULL ならば割り当て失敗であるので, NULL を返す.そうでなければ,構造体の各メンバを初期化して,ndPtr の値を返している.

使用例

    node_t *ndPtr;
    ndPtr = nodeNew(10, NULL);

この例は,10 をデータとして持ち,次に繋がるノードがないノードを作って,そのアドレスを ndPtr に入れている.

コンパイル時に,
warning: assignment makes pointer from integer without a cast
という警告が出るときは,プログラムの先頭に #include <stdlib.h> を書き忘れている. malloc はこのヘッダファイルで宣言されている.

線形リスト型

線形リスト自体を表す型について説明する.

線形リスト自体は,線形リストを構成するノードの中で最初のものを指すポインタである.したがって,線形リスト型という型を特に定義する必要はない.線形リストを表す list という名前の変数を宣言するには,次のようにすればよい。

    node_t *list;

以下,線形リストを表す変数はこのように宣言する.しかし,線形リスト型というものに名前を付けたければ,次のような型名を定義しておくのも一つの方法である.

    typedef node_t *list_t;

このようにしておけば,list_t を node_t * の代わりに使用して, 次のように宣言することができる。

    list_t list;

C言語の一つのコーディングスタイルとして,プログラマが定義した型名には _t を名前の最後につける,という書き方がある.その利点には次のことがある.

線形リスト操作関数

ノードのためのメモリ割り当て,リスト内のデータの表示,ノードの追加,挿入,削除などの,線形リスト操作のための関数群を定義する.

 以下で定義する関数のうち,データ内容の表示を行う listPrint 以外の関数は,リストを変形する関数である.それらの関数の場合,引数の値に与えられたリスト,すなわち node_t * 型の変数を,関数の方で書き換える必要が生じることがある.したがって,これらの関数の引数は,リストを表す node_t * ではなく,リストへのポインタ node_t ** としなくてはならない.

たとえば,リストの先頭に新しいノードを付け加える関数 nodePrepend を例にとって解説しよう.

node_t * 型変数,すなわちノード型ポインタ変数 list が,線形リストの最初のノードを指しているとする.この線形リストの先頭に新しいノードを加えて,その先頭を指すように list の値を変えたいとする.次図では,緑色が操作前のlist の値を,青色が新しいノードと操作後の list の値とを表している.

最先頭へのノード追加

この操作を nodePrepend という関数にやらせようと思うとき, nodePrepend には, list の値(緑の矢印)を渡したのではどうしようもない.list という変数の値を変更するためには, list の値ではなく, list という変数の場所,すなわち list を指すポインタの矢印)が必要なのである.list が node_t * すなわちノード型ポインタという型を持っているのであれば,list を指すポインタは node_t ** すなわちノード型ポインタ型ポインタという型になる(これが図の上方から list を指している ndPtrPtr である)。

引数名あるいは変数名に使われている, ndPtr は node pointer, ndPtrPtr は node pointer pointer という意味のつもりである.

最初に,関数値の戻り値をマクロ定数として定義しておく.

#define SUCCESS     1       /* 成功 */
#define FAILURE     0       /* 失敗 */

データ内容の表示

関数 void listPrint(ndPtr) の仕様。
ノード構造体型ポインタ ndPtr を引数にとり,そのポインタが指す所から始まる線形リストのデータ内容を順次表示する.
void listPrint(node_t *ndPtr)
{
    printf("{ ");
    while (ndPtr != NULL) {
        printf("%d ", ndPtr->data);
        ndPtr = ndPtr -> next;
    }
   printf("}\n");
}

ndPtr が NULL になるまで,ndPtr が指すノードの next の値で ndPtr の値を置き換えながら,data を表示している.

この関数は線形リストの内容を表示するだけなので,実引数であるリストの先頭を指すポインタ変数の値を変更することはない.従って,引数に渡されるのは,リストの先頭を指すポインタの値であればよい.

データ型 data_t を変更するときには,この関数内の "%d" の部分を実際の型に対応するように書き換える必要がある.

練習問題1: リスト中のノード数を返す関数 int nodeCount(node_t * ndPtr) を作れ.
解答解説

リスト先頭へのノード追加

これ以降は,リストを変形するための関数である.リストの始まりを示すポインタ変数 (node_t * 型)の値を,関数内で変更する必要があるので,リストの始まりを示す引数は,さらにそのポインタ変数を指すポインタ node_t ** 型となっている.

まずは,リストの先頭に新しいノードを作って追加をする関数である.

関数 int nodePrepend(node_t ** ndPtrPtr, data_t dt) の仕様
ndPtrPtr が指すノード構造体型ポインタ変数 *ndPtrPtr が指すノードから始まる線形リストの先頭に,dt をデータ値とする新しいノードを付け加え,*ndPtrPtr の値をその新しいノードへのポインタに変更し, SUCCESS を戻り値として返す.ただし,新しいノードのメモリ割り当てに失敗したときには,*ndPtrPtr の値は変更せず,戻り値として FAILURE を返す.
int nodePrepend(node_t **ndPtrPtr, data_t dt)
{
    node_t * ndPtr;

    ndPtr = nodeNew(dt, *ndPtrPtr);
    if (ndPtr == NULL) return FAILURE;
    *ndPtrPtr = ndPtr;
    return SUCCESS;
}

node_t *list;
と宣言されたノード型ポインタ変数 list が線形リストの先頭ノードを指しているとき, この関数
nodePrepend(dt, &list);
を実行すると,新しいノードがどのように付け加わるかを,次の図式で表した.緑色は操作前のつながり方を表し,青色は操作後の新しいノードとつながり方とを表す.関数の仮引数 ndPtrPtr には, list のアドレスが引き渡されるので, ndPtrPtr は変数 list を指すポインタになる.

最後尾へのノード追加

新しくできた青いノードの next の値は,以前に先頭であったノードを指す.その値は *ndPtrPtr の値と同じであるので, nodeNew(dt, *ndPtrPtr) として,新しいノードを作ればよい.その後で,list すなわち *ndPtrPtr の値を新しいノードへのポインタ値に変更する.

リスト最後尾へのノード追加

リストの最後尾に,新しいノードを作って追加する関数である.

関数 int nodeAppend(node_t **ndPtrPtr, data_t dt) の仕様
ndPtrPtr が指すノード構造体型ポインタ変数 *ndPtrPtr が指すノードから始まる線形リストの最後尾に,dt をデータ値とする新しいノードを付け加えて, SUCCESS を戻り値として返す.ただし,新しいノードのメモリ割り当てに失敗したときには,ノードの追加は行わずに,戻り値として FAILURE を返す.

int nodeAppend(node_t **ndPtrPtr, data_t dt)
{
    node_t * ndPtr;

    ndPtr = nodeNew(dt, NULL);
    if (ndPtr == NULL) return FAILURE;
    while (*ndPtrPtr != NULL) {
        ndPtrPtr = &((*ndPtrPtr)->next);
    }
    *ndPtrPtr = ndPtr;
    return SUCCESS;
}

この関数には,さきほどの nodePrepend にはない,次の部分がある。

    while (*ndPtrPtr != NULL) {
        ndPtrPtr = &((*ndPtrPtr)->next);
    }

ここでは,リストの最後尾を見つけるために,*ndPtrPtr の値が NULL になるまで,next を次々とたどっている.

        *ndPtrPtr = (*ndPtrPtr)->next;

として,*ndPtrPtr の値を変更しているのではなく,

        ndPtrPtr = &((*ndPtrPtr)->next);

として,ndPtrPtr 自身の値を変更していることに注意しよう.

日本語でこの部分を説明すると,「 ndPtrPtr が指していたポインタが指す構造体の nextメンバを指すように,ndPtrPtr の値を変更する」となる.

リストの先頭を指すノード型ポインタ変数 list に対して, nodeAppend(dt, &list) を実行したときの動きを,図示する. ここで, ndPtrPtr が指しているのは,ノードではなくて,ノード内の next メンバであることに注意せよ。

最後尾へのノード追加

もしも,list が NULL ポインタ,すなわちリストを構成するノードがない場合は, list 自身が書き換わる。

任意の場所へのノード挿入

リスト中の指定された場所に,新しいノードを作って挿入する関数を作る。

関数 int nodeInsert(node_t **ndPtrPtr, int n, data_t dt) の仕様
*ndPtrPtr が指すノードから始まる線形リストの n 番目のノードの場所に dt をデータとする新しいノードを挿入し, SUCCESS を戻り値として返す.(新しいノードが n 番目のノードとなる.また,番号は0 番から始まる.すなわち,0番目に挿入するということは,先頭に挿入するということになる.) ただし,挿入前のリスト長が n 未満のとき,または,新しいノードのメモリ割り当てに失敗したときには,ノードの追加は行わずに,戻り値として FAILURE を返す.
int nodeInsert(node_t **ndPtrPtr, int n, data_t dt)
{
    int i;
    node_t * ndPtr;

    if (n < 0) return FAILURE;
    for (i = 0; i < n && *ndPtrPtr != NULL; i++) {
        ndPtrPtr = &((*ndPtrPtr)->next);
    }
    if (i < n) return FAILURE;
    ndPtr = nodeNew(dt, *ndPtrPtr);
    if (ndPtr == NULL) return FAILURE;
    *ndPtrPtr = ndPtr;
    return SUCCESS;
}

  for 文は,n 回ループするか,*ndPtrPtr が NULL になれば終了する. for 文の終了時点で i < n が成立しているならば,それは for 文のループ回数が n 回に満たないことを意味し, それは挿入前のリスト長が n 未満であることになり, FAILURE を返している. 新しいノードの next に *ndPtrPtr の値を入れて,*ndPtrPtr に新しいノードのアドレスを入れれば,挿入が達成される.

指定位置へのノード挿入

ノードの削除

リスト中の指定されたノードを削除する関数を作る.

関数 int nodeDelete(node_t ** ndPtrPtr, int n) の仕様
*ndPtrPtr が指すリストの n 番目のノード(ノード番号は 0 番から始まる)を削除する.削除したノードに割り当てられていたメモリは解放される.ただし,リスト中のノード数が n 以下で,n番目のノードがない場合には,何もしない.
戻り値は,n番目のノードが削除できたときには SUCCESS,n番目のノードが存在せず,削除できなかったときには FAILURE を返す.
int nodeDelete(node_t **ndPtrPtr, int n)
{
    node_t * ndPtr;
    while (n > 0 && *ndPtrPtr != NULL) {
        ndPtrPtr = &((*ndPtrPtr)->next);
        n--;
    }
    if (*ndPtrPtr != NULL) {
        ndPtr = (*ndPtrPtr)->next;
        free(*ndPtrPtr);
        *ndPtrPtr = ndPtr;
        return SUCCESS;
    } else {
        return FAILURE;
    }
}

while ループが終了したときに,*ndPtrPtr は, n 番目のノードまたは NULL を指すノード型ポインタ変数となっている.*ndPtrPtr が NULL でなければ,それは n 番目のノードを指すポインタであるから,そのポインタ *ndPtrPtr の値を,そのポインタが指すノードの next メンバの値で置き換える.同時に,そのノードに割り当てられたメモリを解放する.

ノード型ポインタ変数 list で指されたリストに対して,nodeDelete(&list, n) を施したときの動きを図示する.緑色が操作前のつながりで,青色が操作後のつながりである.□はそのn+1番目のノードあるいはNULLポインタ値を表す.

指定位置のノード削除

線形リスト操作関数使用例

以上の関数の具体的な使用例を載せておく.

main()
{
    node_t *list = NULL;
	
    listPrint(list);
    nodeAppend(&list, 1);     /* { 1 } */
    listPrint(list);
    nodeAppend(&list, 2);     /* { 1 2 }*/
    listPrint(list);
    nodePrepend(&list, 3);    /* { 3 1 2 } */
    listPrint(list);
    nodeInsert(&list, 2, 4);  /* { 3 1 4 2 } */
    listPrint(list);
    nodeInsert(&list, 0, 5);  /* { 5 3 1 4 2 } */
    listPrint(list);
    nodeInsert(&list, 5, 6);  /* { 5 3 1 4 2 6 } */
    listPrint(list);
    nodeDelete(&list, 3);     /* { 5 3 1 2 6 } */
    listPrint(list);
    nodeDelete(&list, 0);     /* { 3 1 2 6 } */
    listPrint(list);
}
{  }
{ 1 }
{ 1 2 }
{ 3 1 2 }
{ 3 1 4 2 }
{ 5 3 1 4 2 }
{ 5 3 1 4 2 6 }
{ 5 3 1 2 6 }
{ 3 1 2 6 }

練習問題2: *ndPtrPtr が指すリストの最後に ndPtr が指すリストを結合して一つのリストにする関数 int listConcaten(node_t ** ndPtrPtr, node_t * ndPtr) を作れ.
解答解説

レポート問題

関数仕様が書かれていないときは,それを考えることも問題です。


List-1 (レベルA)

ndPtr から始まる線形リスト中のノードで,データ値が dt であるものを探してそのノードへのポインタを返す関数
node_t * nodeSearch(node_t * ndPtr, data_t dt)
を作れ.
ただし,データ値が dt であるノードが複数個ある場合には,そのうちの先頭に近いノードへポインタを返し,データ値が dt であるノードがない場合には NULL を返すものとする.

解答解説

List-2 (レベルB)

線形リストを複写する関数 int listCopy(node_t * src, node_t ** dest) を作れ. 戻り値は,複写に成功すれば SUCCESS,失敗すれば FAILURE とする.

解答解説

List-3 (レベルB)

線形リスト全体を削除する関数 void listDelete(node_t ** list) を作れ.ノードをすべて削除した後, *list の値を NULLにする.(ノードに割り当てられたメモリを解放することを忘れないように)

解答解説

List-4 (レベルB+)

リストのノードの順序を,逆順にする関数を作れ.(データを入れ替えるのではなく,next の値を変更することにより,逆順にする)

解答解説

List-5 (レベルB+)

本文中で作った nodeInsert を改良して,指定された挿入場所のインデックス n が負のときには, 新しく挿入されたノードがリストの末尾から数えて -n 番目になるようにせよ.
(たとえば,n == -1 のときには,末尾に挿入される)

解答解説

このページ(線形リスト)の先頭に戻る

アドバンスドプログラミングのTOPページに戻る