C ++研究ノート(3つ)-ツリー

1.ツリー

大量の入力データの場合、リンクリストの線形アクセス時間は長すぎて使用できません。このセクションでは、ほとんどの操作の実行時間が平均でO(logN)である単純なデータ構造を紹介します。

私たちが関わっているデータ構造は、バイナリ検索ツリーと呼ばれます。バイナリ検索ツリーは、多くのアプリケーションで使用される2つのライブラリコレクションクラスセットとマップの実装基盤です。ツリーは、コンピューターサイエンスで非常に役立つ抽象的な概念です。

1.1予備知識

ツリーはいくつかの方法で定義できます。ツリーを定義する自然な方法は、再帰的な方法です。ツリーはノードのコレクションです。このセットは空のセットにすることができます。空のセットでない場合、ツリーはルートと呼ばれるノードrと、それぞれにルートを持つ0個以上の空でない(サブ)ツリーT_ {1}、T_ {2}、\ cdots、T_ {k}で構成されます。ルートrからの有向エッジによって接続されています。

各サブツリーのルートはルートrの子と呼ばれ、rは各サブツリーのルートの親です。次の図は、再帰によって定義される典型的なツリーを示しています。

再帰的な定義から、ツリーはN個のノードとN-1個のエッジのコレクションであり、ノードの1つはルートと呼ばれることがわかります。N-1個のエッジがあるという結論は、各エッジがノードをその親に接続し、ルートノードを除くすべてのノードに親があるという事実から導き出されます(下の図を参照) )。

上の図のツリーでは、ノードAがルートです。ノードFには、父親Aと息子K、L、およびMがいます。各ノードには、任意の数の息子を含めることも、息子を持たないこともできます。息子のいないノードはリーフノードと呼ばれます。上図のリーフノード(リーフ)は、B、C、H、I、P、Q、K、L、M、およびNです。同じ父を持つノードは兄弟ノードであるため、K、L、およびMはすべて兄弟です。同様の方法を使用して、祖父母と孫の間の関係を定義できます。

ノードn_ {1}からノードn_ {k}のパスはノードn_ {1}、n_ {2}、\ cdots、n_ {k}のシーケンスとして定義されるため1 \ leq i <k、の場合、ノードn_ {i}n_ {i + 1}親になります。パスの長さは、パス上のエッジの数、つまりk-1です。各ノードからそれ自体への長さゼロのパスがあります。ルートからツリー内の各ノードへのパスは1つだけであることに注意してください。

どのノードn_ {i}でもn_ {i}、深さはルートからのn_ {i}一意のパスの長さです。したがって、ルートの深さはゼロです。n_ {i}高さはn_ {i}、葉からまでの最長の経路の長さです。したがって、すべての葉の高さは0です。木の高さはその根の高さと同じです。上の図の木の場合、深さEは1、高さは2、深さFは1、高さも1、木の高さは3です。木の深さはその最も深い葉の深さに等しく、深さは常に木の高さに等しくなります。

そこから場合n_ {1}n_ {2}パスは、n_ {1}あるn_ {2}先祖(祖先)およびn_ {2}あるn_ {1}子孫(子孫)。の場合n_ {1} \ neq n_ {2}、 n_ {1}それはn_ {2}1つの真の祖先(適切な祖先)でn_ {2}ありn_ {1}、真の子孫(適切な子孫)です。

1.1.1ツリーの実装

ツリーを実装する1つの方法は、各ノードのデータに加えて、ノードの各子を指すチェーンをいくつか持つことです。ただし、各ノードの息子の数は大きく異なる可能性があり、事前にわからないため、無駄なスペースが多すぎるため、データ構造内の各息子ノードへの直接リンクを確立することはできません。実際、解決策は非常に単純で、各ノードのすべての息子をツリーノードのリンクリストに入れます。次のコードは非常に典型的なステートメントです。

struct TreeNode
{
    Object  element;
    TreeNode  *firstChild;
    TreeNode  *nextSibling;
}

上記のコードは、この実装メソッドによってツリーがどのように表されるかを示しています。図の下向きの矢印は、firstChildを指すチェーンです。左から右への矢印は、aingnextSiblingを指すチェーンです。空のチェーンが多すぎるため、描画されません。

次の図に示すツリーでは、ノードEには兄弟(F)を指すチェーンと、息子(I)を指すチェーンがあり、一部のノードには両方のチェーンがありません。

1.1.2ツリートラバーサルとアプリケーション

木の用途はたくさんあります。一般的な使用法の1つは、UNIXやDOSを含む多くの一般的なオペレーティングシステムのディレクトリ構造です。次の図は、UNIXファイルシステムの一般的なディレクトリです。

このディレクトリのルートは/ usrです(名前の後のアスタリスクは、/ usr自体がディレクトリであることを示します)。/ usrには、mark、alex、billの3つの息子があり、これらはすべてそれ自体がディレクトリです。したがって、/ usrには3つのディレクトリが含まれ、通常のファイルは含まれません。ファイル名/usr/mark/book/ch1.rは、左端の息子ノードから3回連続して取得されます。最初の「/」の後の各「/」はエッジを表し、結果はフルパス(パス名)になります。この階層ファイルシステムは、ユーザーがデータを論理的に整理できるため、非常に人気があります。それだけでなく、異なるディレクトリにある2つのファイルも同じ名前を持つことができます。これは、ルートからのパスが異なる必要があるため、パス名が異なるためです。UNIXファイルシステムのディレクトリは、そのすべての息子を含むファイルであるため、これらのディレクトリは、上記の型宣言に従ってほぼ正確に作成されます。実際、UNIXの一部のバージョンでは、ファイルを印刷する標準コマンドがディレクトリに適用されている場合、ディレクトリ内のファイル名が(他の非ASCII情報とともに)出力に表示されます。

ディレクトリ内のすべてのファイルの名前を一覧表示するとします。出力形式は次のとおりです。深さ1のd_ {i}ファイルd_ {i}はタブインデントされ、その名前が出力されます。アルゴリズムは、次の疑似コードで提供されます。

void FileSystem::listAll(int depth = 0) const
{
printName( depth );  //Print the name of the object
if( isDirectory() )
     for each file c in this directory (for each child)
        c.listAll( depth + 1 );

}

インデントなしでルートを表示するには、再帰関数listAllを深さ0から開始する必要があります。ここでの深さは内部のブックキーピング変数であり、呼び出し元のルーチンが知ることを期待できる種類のパラメーターではありません。したがって、深さにはデフォルト値の0を指定する必要があります。

アルゴリズムのロジックはシンプルで理解しやすいです。ファイルオブジェクトの名前は、適切な数のタブで出力されます。ディレクトリの場合は、すべての息子を1つずつ再帰的に処理します。これらの息子は同じ深さにいるので、追加のスペースをインデントする必要があります。全体の出力は次のとおりです。

/usr
   mark
      book
         ch1.r
         ch2.r
         ch3.r
      course 
         cop3530
             fall05
                 sy1.r
             spr06
                 sy1.r
             sum06
                 sy1.r
      junk
   alex
      junk
   bill 
      work
      course
          cop3212
              fall05
                  grades
                  prog1.r
                  prog2.r
              fall06
                  prog2.r
                  prog1.r
                  grades

このトラバーサル戦略は、プレオーダートラバーサル(プレオーダートラバーサル)と呼ばれます。プレオーダートラバーサルでは、ノードの処理は、その息子ノードが処理される前に実行されます。プログラムの実行中は、各名前が1回だけ出力されるため、明らかに最初の行はノードごとに1回だけ実行されます。最初の行はノードごとに最大1回実行されるため、2番目の行もノードごとに1回実行する必要があります。それだけでなく、各ノードの各子ノードの4行目は、最大で1回しか実行できません。ただし、息子の数はノードの数よりも正確に1つ少なくなります。その後、4行目が実行されるたびに、forループが1回繰り返され、ループが終了するたびに追加されます。したがって、各ノードの合計ワークロードは一定です。出力するファイル名がN個ある場合、実行時間はO(N)です。

もう1つの一般的な方法は、ツリーのポストオーダーをトラバースすることです(ポストオーダートラバーサル)。ポストオーダートラバーサルでは、ノードの作業は、その息子ノードが計算された後に実行されます。たとえば、次の図は、前と同じディレクトリ構造を示しています。括弧内の数字は、各ファイルが占めるディスクブロックの数を表しています。

ディレクトリはそれ自体がファイルであるため、サイズもあります。ツリー内のすべてのファイルが占めるディスクブロックの総数を計算するとします。最も一般的なアプローチは、サブディレクトリ/ usr / mark(30)、/ usr / alex(9)、および/ usr / bill(32)に含まれるブロックの数を見つけることです。したがって、ディスクブロックの総数は、サブディレクトリ(71)内のブロックの総数に、/ usrが使用する1つのブロックを加えたものであり、合計72ブロックになります。次の疑似コードメソッドサイズは、このトラバーサル戦略を実装します。

int FileSystem::size ( ) const
{
    int totalSize = sizeOfThisFile( );
    
    if( isDirectory( ) )
       for each file c in this directory (for each child)
           totalSize += c.size( )

    return totalSize;
}

現在のオブジェクトがディレクトリでない場合、sizeはそれが占めるブロックの数のみを返します。それ以外の場合、ディレクトリが占有するブロックの数は、そのすべての子ノードが検出するブロックの数に(再帰的に)追加されます。注文後のトラバーサル戦略と注文前のトラバーサル戦略を区別するために、次のコードは、このアルゴリズムによって各ディレクトリまたはファイルのサイズがどのように生成されるかを示しています。

             ch1.r
             ch2.r
             ch3.r
          book
                     sy1.r
                  fall05
                     sy1.r
                  spr06
                     sy1.r
                  sum06
             cop3530
          course
          junk
       mark
          junk
       alex
          work
                     grades
                     prog1.r
                     prog2.r
                  fall05
                     prog2.r
                     prog1.r
                     grades
                  fall06
              cop3212
         course
       bill
/usr

1.2標準ライブラリで設定およびマップ

第3章では、STLのベクターコンテナとリストコンテナについて説明しましたが、どちらも検索には不十分です。これに対応して、STLは、基本操作(挿入、削除、検索など)の対数時間オーバーヘッドを保証する2つの追加コンテナーsetとmapを提供します。

1.2.1セット

セットはソートされたコンテナであり、重複は許可されません。ベクターやリスト内のアイテムにアクセスするための多くのルーチンは、セットにも適用されます。特に、iteratorタイプとconst_iteratorタイプはセットにネストされているため、セットをトラバースできます。vectorとlistのいくつかのメソッドは、begin、end、size、emptyなど、セット内でまったく同じ名前を持っています。

セット固有の操作は、効率的な挿入、削除、および基本的な検索です。

挿入ルーチンは、適切にin​​sertという名前が付けられています。ただし、setは繰り返しを許可しないため、挿入が失敗する可能性があります。したがって、戻り型をこの状況を示すことができるブール変数にする必要があります。ただし、insertはboolタイプよりもはるかに複雑なタイプを返します。これは、insertが戻るときに、insertがxの位置を与えるイテレーターも返すためです。このイテレーターは、新しく挿入されたアイテムを指すか、挿入が失敗する原因となった既存のアイテムを指します。このイテレーターは、アイテムの場所がわかっている場合はアイテムをすばやく削除できるため、非常に便利です。アイテムを含むノードを直接取得できるため、検索操作を回避できます。

STLは、pairという名前のクラステンプレートを定義します。このテンプレートには、ペアの2つのアイテムにアクセスするために、structよりも最初と2番目に2つのメンバーがあります。次に、2つの異なる挿入ルーチンを示します。

pair<iterator,bool>insert( const Object & x);
pair<iterator,bool>insert( iterator hint, const Object & x);

単一パラメーター挿入の実行は上に示されています。2つのパラメーターを挿入すると、xが挿入される場所の手がかりを説明できます。手がかりが正確である場合、挿入は高速で、通常はO(1)です。正確でない場合は、従来の挿入アルゴリズムを使用して完了する必要があります。この時点での実行は、単一パラメーターの挿入と同じです。たとえば、次のコードで2パラメータ挿入を使用すると、1パラメータ挿入を使用するよりもはるかに高速になります。

set<int>s;
for ( int i=0; i<1000000; i++)
    s.insert(s.end(),i);

消去にはいくつかのバージョンがあります。

int erase( const Object & x);
iterator erase( iterator itr);
iterator erase( iteratorstart, iteartor end);

最初の単一パラメーターeraseはxを削除し(見つかった場合)、削除された要素の数を返します。明らかに、戻り値は0または1のいずれかです。2番目の単一パラメーター消去の実行は、vectorおよびlistの場合とまったく同じです。イテレーターで指定された位置にあるオブジェクトを削除します。返されたイテレーターは、eraseを呼び出す直前に、itrの次の位置にある要素を指し、この時点でitrが使用できなくなったため、itrを無効にします。2パラメータ消去の実行は、ベクトルまたはリストの場合と同じです。最初から最後まですべてのアイテムを削除します(最後を除く)。

検索の場合、setは、変数を返すcontainsルーチンよりも優れたfindルーチンを提供します。このルーチンは、アイテムの位置を指す(検索が失敗した場合は終了識別子を指す)イテレーターを返します。これにより、実行時間を費やすことなく、かなりの量のより多くの情報が提供されます。検索の形式は次のとおりです。

iterator find( const Object & x ) const;

デフォルトでは、ソート操作はless <Object>関数オブジェクトを使用して実装され、関数オブジェクトはオブジェクトの演算子を呼び出すことによって実装されます。別の代替ソートスキームは、関数オブジェクトタイプのセットテンプレートによって例示できます。たとえば、CaseInsensitiveCompare関数オブジェクトを使用して、文字列オブジェクトを格納するセットを生成し、文字の大文字小文字を無視することができます。以下のコードでは、セットsのサイズは1です。

set<string,CaseInsensitiveCompare> s;
s.insert( "hello" );s.insert("HeLLo");
cout<< "The size is: " << s.size() <<endl;

1.2.2マップ

マップは、キーと値で構成されるアイテムのソートされたコレクションを格納するために使用されます。キーは一意である必要がありますが、複数のキーが同じ値に対応する場合があります。したがって、値は一意である必要はありません。マップ内のキーは、論理的にソートされた順序を維持します。

マップの実行は、ペアで例示されているセットに似ています。比較機能にはキーのみが含まれます。したがって、mapはbegin、end、size、およびenmtyをサポートしますが、基本的なイテレーターはキーと値のペアです。つまり、イテレーターitrの場合、* itrのタイプはpair <KeyType、ValueType>です。mapは、挿入、検索、および消去もサポートしています。挿入するには、pair <KeyType、ValueType>オブジェクトを指定する必要があります。findに必要なキーは1つだけですが、返されたイテレーターはペアを指します。通常、これらの操作を使用する価値はありません。これは、構文の負担が大きくなるためです。

幸い、mapには、単純な構文を取得するための重要な追加操作があります。以下は、mapの配列インデックス演算子のオーバーロードです。

ValueType & operator[] ( const KeyType & key );

operator []の構文は次のとおりです。マップにキーがある場合、対応する値への参照が返されます。マップにキーがない場合は、マップにデフォルト値を挿入してから、挿入されたデフォルト値への参照を返します。このデフォルト値は、引数がゼロのコンストラクターを適用することによって取得されます。基本タイプの場合は0です。これらの構文では、operator []の関数バージョンを変更できないため、operator []を定数マップに使用することはできません。たとえば、マップがルーチン内の定数参照によって渡される場合、operator []は使用できません。

次の図のコードスニペットは、マップアイテムにアクセスするための2つの手法を示しています。最初に3行目を観察します。左側でoperator []が呼び出されるので、「Pat」と値0のdoubleをマップに挿入します。また、このdoubleへの参照を返します。次に、マップのdoubleを75000に割り当てます。ライン4は75000を出力します。残念ながら、5行目は「Jan」と給与「0.0」をマップに挿入して印刷します。アプリケーションによっては、正しい結果が得られる場合と得られない場合があります。マップ内のアイテムとマップ内にないアイテムを区別することが重要な場合、またはそれらがマップに挿入されていない場合(変更できないため)、7〜12行目に示されている別の方法を使用できます。見つけるための呼び出しがあります。キーが見つからない場合、イテレーターは終了マーカーであり、テストできます。キーが見つからない場合は、ペアのイテレーターによって参照される2番目のアイテムにアクセスできます。これは、キーに対応する値です。itrがconst_iteratorではなくiteratorの場合は、itr-> secondを割り当てることができます。

map<string,double>salaries;

salaries[ "Pat" ] = 75000.00;
cout << salsries[ "Pat" ] << endl;
cout << salsries[ "Jan" ] << endl;

map<string,double>::const_iterator itr;
itr = salaries.find( "Chris" );
if( itr == salaries.end( ) )
   cout << "Not an employee of this company!" << endl;
else
   cout << itr->second << endl;

 

おすすめ

転載: blog.csdn.net/weixin_38452841/article/details/109093176