[C++] STL - ハッシュ テーブルを使用して unordered_map と unordered_set をカプセル化する

ハッシュ テーブル (バケット) を使用して unordered_map と unordered_set をカプセル化する

ここに画像の説明を挿入

1.ハッシュテーブルのソースコード

unordered_map と unordered_set の以前の調査によると、基になるレイヤーがハッシュ テーブルの助けを借りて実装されていることがわかっています. 次に、前のブログで実装されたオープン ハッシュ ハッシュ テーブルを使用して、unordered_map と unordered_set の実装をシミュレートします。ハッシュ テーブル コード リンクのソース:

Hash/Hash/HashBucket.h · wei/cplusplus - コード クラウド - オープン ソース中国 (gitee.com)

次に、unordered_map と unordered_set を適切にカプセル化できるように、ハッシュ テーブルを変更します。


2. ハッシュ関数テンプレート パラメータの制御

unordered_map は KV モデルであり、unordered_set は K モデルであり、以前に実装されたハッシュ テーブル (バケット) はキーと値のペアの KV モデルであることは誰もが知っています.明らかに、unordered_set の K モデルは適用されないため、ジェネリックを実装します。これは、ハッシュ テーブルのテンプレート パラメータを制御する必要があります。

ハッシュ ノードのテンプレート パラメータを変更します。

  • 元のハッシュ テーブルのテンプレート パラメータと区別するために、ここではハッシュ テーブルの 2 番目のテンプレート パラメータを T に設定します。ペアのキーと値のペア KV モデル、次に KV モデル

画像-20230414200938920

template<class T>
struct HashNode
{
     
     
	T _data;
	HashNode<T>* _next;
	//构造函数
	HashNode(const T& data)
		:_data(data)
		, _next(nullptr)
	{
     
     }
};

ハッシュ テーブルのテンプレート パラメーターを変更します。

  • ここでは、2 番目のテンプレート パラメーターを T に設定します。これは、後で渡されるデータ型を識別するのに便利です。
//unordered_map -> HashTable<K, pair<K, V>> _ht;
//unordered_set -> HashTable<K, K> _ht;
template<class K, class T, class Hash = HashFunc<K>>

unordered_set のパラメーター制御:

  • 上位層が unordered_set コンテナを使用する場合、ハッシュ テーブルのパラメータは K、K タイプに対応します。
template<class K>
class unordered_set
{
     
     
private:
	HashBucket_realize::HashBucket<K, K, Hash, SetKeyOfT> _hb;
};

unordered_map のパラメーター制御:

  • 上位層が unordered_map コンテナーを使用する場合、ハッシュ テーブルのパラメーターは K, pair<K, V> 型に対応します。
template<class K, class V>
class unordered_map
{
     
     
private:
	HashBucket_realize::HashBucket<K, pair<const K, V>, Hash, MapKeyOfT> _hb;
};

3. 上位コンテナのファンクターを構築して、後続のマッピングを容易にします

ハッシュ マッピングのプロセスでは、要素のキー値を取得し、対応するハッシュ関数を介してマッピングのアドレスを計算する必要があります. 前のステップで、unordered_set と unordered_map に適応するために、テンプレートパラメータ. ハッシュノード 保存されたデータ型は T. T はキーと値のペアかキーと値のペアかもしれません. 基礎となるハッシュは入ってくるデータの型を知らないので, ファンクタの層を設定する必要があります.上部のコンテナで、基礎となるハッシュを伝えます。

unordered_set のファンクター:

  • unordered_set などの K タイプのコンテナーの場合、キーを直接返すことができます。
template<class K>
class unordered_set
{
     
     
	//仿函数
	struct SetKeyOfT
	{
     
     
		const K& operator()(const K& key)
		{
     
     
			return key;
		}
	};
private:
	HashBucket_realize::HashBucket<K, K, Hash, SetKeyOfT> _hb;
};

知らせ:

unordered_set コンテナーがハッシュ テーブルに渡す T はキー値ですが、下層のハッシュ テーブルは上位コンテナーの型を認識していないため、下層のハッシュ テーブルにファンクターを提供する必要もあります。

unordered_map のファンクター:

  • unordered_map のデータ型はペアのキーと値のペアです。キーと値のペアの最初のデータ キーを取り出して返すだけで済みます。
template<class K, class V>
class unordered_map
{
     
     
	//仿函数
	struct MapKeyOfT
	{
     
     
		const K& operator()(const pair<K, V>& kv)
		{
     
     
			return kv.first;
		}
	};
private:
	HashBucket_realize::HashBucket<K, pair<const K, V>, Hash, MapKeyOfT> _hb;
};

知らせ:

ハッシュ ノードに格納するデータ型は T であるため、この T はキー値である場合もあれば、キーと値のペアである場合もありますが、基になるハッシュ テーブルについては、ハッシュ ノードのデータ型はわかりません。どのタイプのデータが格納されているかということです。そのため、上位コンテナーは、T タイプ データのキー値を取得するためのファンクターを提供する必要があります。

基になるハッシュのテンプレート パラメーターを変更します。

  • 上位コンテナーのファンクターが設定されました。上位コンテナーのデータ型を受け取るには、下位レベルのハッシュのテンプレート パラメーターを修正する必要があります。
template<class K, class T, class Hash, class KeyOfT>
class HashBucket
  • 最初のパラメータ K: キーのタイプは K です。検索機能はキーで検索するのでKが必要です。

  • 2 番目のパラメーター T: ハッシュ テーブル ノードによって格納されるデータ型。int、double、pair、string など。

  • 3 番目のパラメーター KeyOfT: タイプ T (ノードのデータ型) のキーを取得します。

  • 4 番目のパラメーター Hash: 使用されるハッシュ関数を示します


4. 文字列型をモジュロにすることはできません

  • 文字列をモジュロにすることはできません。これは、ハッシュの問題で最も一般的な問題です。

上記の分析の後、ハッシュ テーブルにテンプレート パラメーターを追加します.このとき、上位コンテナーが unordered_set であるか unordered_map であるかに関係なく、上位コンテナーが提供するファンクターを介して要素のキー値を取得できます。

しかし、私たちが毎日書いているコードでは、キーと値のキーとして文字列を使用することが非常に一般的です. たとえば、 unordered_map コンテナを使用して果物の出現回数をカウントする場合、各果物の名前を次のように使用する必要があります.キー値。

文字列は整数ではないため、ハッシュ アドレスを計算するために文字列を直接使用することはできません. ハッシュ関数に代入してハッシュ アドレスを計算する前に、何らかの方法で文字列を整数に変換する必要があります.

しかし残念ながら、文字列と整数の間で 1 対 1 の変換を実現する方法を見つけることはできません。これは、コンピューターでは、符号なし整数で格納できる最大数が 4294967295 であるなど、整数のサイズが制限されているためです。しかし、多くの文字で構成できる文字列の種類は無限です。

これを考慮すると、文字列を整数に変換するためにどの方法を使用しても、ハッシュの衝突が発生しますが、衝突の確率は異なります。

前任者の実験の後、実際の効果とコーディングの実装の両方で、BKDRHash アルゴリズムの効果が最も顕著であることがわかりました。このアルゴリズムは、Brian Kernighan と Dennis Ritchie の著書「The C Programming Language」で示された名前にちなんで名付けられた、単純で高速なハッシュ アルゴリズムであり、現在 Java で使用されている文字列のハッシュ アルゴリズムでもあります。

したがって、キー値キーを対応する整数に変換するために、ハッシュ テーブルのテンプレート パラメーターに別のファンクターを追加する必要があります。

template<class K, class T, class KeyOfT, class Hash = HashFunc<K>>
class HashBucket

上位層がファンクターを渡さない場合は、キー値キーを直接返すことができるデフォルトのファンクターを使用しますが、キー値キーとして文字列を使用する方が一般的であるため、文字列型の特殊化を記述できますこのとき、キー値が文字列型の場合、ファンクタは BKDRHash アルゴリズムに従って対応する整数を返します。

template<class K>
struct Hash
{
     
     
	size_t operator()(const K& key) //返回键值key
	{
     
     
		return key;
	}
};
//string类型的特化
template<>
struct Hash<string>
{
     
     
	size_t operator()(const string& s) //BKDRHash算法
	{
     
     
		size_t value = 0;
		for (auto ch : s)
		{
     
     
			value = value * 131 + ch;
		}
		return value;
	}
};

5、ハッシュ テーブルの既定のメンバー関数の実装

1. コンストラクター

オブジェクトをインスタンス化するとき、ハッシュ テーブルには 2 つのメンバー変数があります。

  • _table は、 vector のデフォルトのコンストラクターを自動的に呼び出して初期化します。
  • _n は、指定したデフォルト値に従って 0 に設定されます。
vector<Node*> _table; //哈希表
size_t _n = 0; //哈希表中的有效元素个数

コンストラクターを作成し、対応する空間を素数テーブルの最初のデータで初期化します。

//构造函数
//HashBucket() = default; //显示指定生成默认构造函数
HashBucket()
	:_n(0)
{
     
     
	//_tables.resize(10);
	_tables.resize(__stl_next_prime(0));
}

知らせ:

スペースを初期化しない場合、コンストラクターを記述する必要はありません。デフォルトで生成されたコンストラクターを使用するだけで十分ですが、後でコピー コンストラクターを記述する必要があるため、コピー コンストラクターを記述した後、デフォルト コンストラクターがこの時点で、 default キーワードを使用して、生成されたデフォルト コンストラクターを表示および指定する必要があります。


2. コンストラクターのコピー

コピーするときは、ハッシュ テーブルを深くコピーする必要があります。そうしないと、コピーされたハッシュ テーブルと元のハッシュ テーブルに同じノードのバッチが格納されます。

ハッシュ テーブルのコピー コンストラクターの実装ロジックは次のとおりです。

  1. ハッシュ テーブルのサイズを ht._table のサイズに変更します。
  2. ht._table の各バケット内のノードを独自のハッシュ テーブルに 1 つずつコピーします。
  3. ハッシュテーブルの有効データ数を変更してください。
//拷贝构造函数
HashBucket(const HashBucket& hb)
{
     
     
	//1、将哈希表的大小调整为hb._tables的大小
	_tables.resize(hb._tables.size());
	//2、将hb._tables每个桶当中的结点一个个拷贝到自己的哈希表中(深拷贝)
	for (size_t i = 0; i < hb._tables.size(); i++)
	{
     
     
		if (ht._tables[i]) //桶不为空
		{
     
     
			Node* cur = hb._tables[i];
			while (cur) //将该桶的结点取完为止
			{
     
     
				Node* copy = new Node(cur->_data); //创建拷贝结点
				//将拷贝结点头插到当前桶
				copy->_next = _tables[i];
				_tables[i] = copy;
				cur = cur->_next; //取下一个待拷贝结点
			}
		}
	}
	//3、更改哈希表当中的有效数据个数
	_n = hb._n;
}

3. 代入演算子オーバーロード機能

代入演算子のオーバーロード関数を実装する場合、パラメーターを使用して間接的にコピー コンストラクターを呼び出し、コピーによって構築されたハッシュ テーブルと現在のハッシュ テーブルの 2 つのメンバー変数をそれぞれ交換することができます。コピーによって構築されたハッシュテーブルは範囲外のため自動的に破棄され、元のハッシュテーブルより前のデータは途中で解放されます。

//赋值运算符重载函数
HashBucket& operator=(HashBucket hb)
{
     
     
	//交换哈希表中两个成员变量的数据
	_table.swap(hb._table);
	swap(_n, hb._n);

	return *this; //支持连续赋值
}

4.デストラクタ

ハッシュ テーブルに格納されているノードはすべて新しいため、ハッシュ テーブルが破棄されたときにノードを解放する必要があります。ハッシュ テーブルを破棄するときは、空でないハッシュ バケットを 1 つずつ取り出し、ハッシュ バケット内のノードをトラバースして解放するだけです。

//析构函数
~HashBucket()
{
     
     
	//将哈希表当中的结点一个个释放
	for (size_t i = 0; i < _tables.size(); i++)
	{
     
     
		Node* cur = _tables[i];
		while (cur) //将该桶的结点取完为止
		{
     
     
			Node* next = cur->_next; //记录下一个结点
			delete cur; //释放结点
			cur = next;
		}
		_tables[i] = nullptr; //将该哈希桶置空
	}
}

6. ハッシュ テーブルの基礎となる反復子の実装

1. イテレータの基本フレームワーク

ハッシュ テーブルのフォワード イテレータは、実際にはハッシュ ノード ポインタをカプセル化しますが、++ 演算子のオーバーロードを実装する場合、ハッシュ テーブルで次の空でないハッシュ バケットを見つける必要があるため、各ノード ポインタをフォワードに格納することに加えて、 iterator には、ハッシュ テーブルのアドレスも格納する必要があります。最後に、_node と _hb を初期化するコンストラクターを記述します。

ハッシュテーブルがイテレータで使用され、イテレータがハッシュテーブルで使用されている、つまり、2 つのクラスが相互に参照しているという現象が見られます。

  • 反復子がハッシュ テーブルの前に記述されている場合、コンパイラはコンパイル時にハッシュ テーブルが未定義であることを検出します (コンパイラは前方/上方の識別子のみを検索します)。

  • ハッシュ テーブルが反復子の前に記述されている場合、コンパイラは、コンパイル時に反復子が未定義であることを検出します。

ここで、イテレータの位置はハッシュ テーブル (HashBucket) の上に配置され、イテレータ内で HashBucket を使用します。これは、コンパイラが上を向いているためです。この位置によれば、HashBucket に対するイテレータのクラスには見つからないため、 iterator の前に HashBucket クラスを宣言する必要があります

// 哈希表前置声明
template<class K, class T, class Hash, class KeyOfT>
class HashBucket;
//正向迭代器
template<class K, class T, class Hash, class KeyOfT>
struct __HTIterator
{
     
     
	typedef HashNode<T> Node; //哈希节点的类型
	typedef __HTIterator<K, T, Hash, KeyOfT> Self; //正向迭代器的类型
	typedef HashBucket<K, T, Hash, KeyOfT> HB; // 哈希表

	Node* _node; // 节点指针
	HB* _hb; // 哈希表地址
	// 构造函数
	__HTIterator(Node* node, HB* hb);
	// *运算符重载
	T& operator*();
	// ->运算符重载
	T* operator->();
	//==运算符重载
	bool operator==(const Self& s) const;
	//!=运算符重载
	bool operator!=(const Self& s) const;
	//++运算符重载
	Self& operator++();
    
    Self& operator++(int);
};

2.++ 演算子のオーバーロード

このときのハッシュテーブルの構造が次の図のようになっているとします。

画像-20230412001629135

これはハッシュ バケット構造であり、各バケットは単一リンク リストの文字列であることに注意してください. 単一バケットでは、ヘッド ノード ポインターを取得し、リンク リストを ++it で 1 つずつトラバースできますが、これは1 つのバケットが終了した後、次のバケットに移動するようにする必要があるため、++ 演算子のオーバーロードを設定するには、次の規則に従う必要があります。

  • 現在のノードが現在のハッシュ バケットの最後のノードでない場合、++ の後に現在のハッシュ バケットの次のノードに移動します。
  • 現在のノードが現在のハッシュ バケットの最後のノードである場合、++ は次の空でないハッシュ バケットの最初のノードに移動します。
//++运算符重载
Self& operator++()
{
     
     
	if (_node->_next)
	{
     
     
		_node = _node->_next;
	}
	else//当前桶已经走完,需要到下一个不为空的桶
	{
     
     
		KeyOfT kot;//取出key数据
		Hash hash;//转换成整型
		size_t hashi = hash(kot(_node->_data)) % _hb->_tables.size();
        ++hashi;
		while(hashi < _hb->_tables.size())
        {
     
     
			if (_hb->_tables[hashi])//更新节点指针到非空的桶
			{
     
     
				_node = _hb->_tables[hashi];
				break;
			}
            else
            {
     
     
                hashi++;
            }
		}
		//没有找到不为空的桶,用nullptr去做end标识
		if (hashi == _hb->_tables.size())
		{
     
     
			_node = nullptr;
		}
	}
	return *this;
}

// 后置++
Self& operator++(int) 
{
     
     
	Self tmp = *this;
	operator++();
	return tmp;
	}

フォワード反復子の ++ 演算子オーバーロード関数は、次のノードを探すときにハッシュ テーブルのメンバー変数 _tables にアクセスし、_tables メンバー変数はハッシュ テーブルのプライベート メンバーであるため、フォワードする必要があるため、反復子クラスハッシュテーブル クラスのフレンドとして宣言されます。

template<class K, class T, class KeyOfT, class HashFunc>
class HashTable
{
     
     
	//把迭代器设为HashTable的友元
	template<class K, class T, class KeyOfT, class HashFunc>
	friend class __HTIterator;

	typedef HashNode<T> Node;//哈希结点类型
public:
	//……
}

注:ハッシュ テーブルの反復子は一方向の反復子であり、- 演算子のオーバーロードはありません。


3. == および != 演算子のオーバーロード

2 つの反復子が等しいかどうかを比較するには、2 つの反復子によってカプセル化されたノードが同じかどうかを判断するだけで済みます。

//!=运算符重载
bool operator!=(const Self& s) const
{
     
     
	return _node != s._node;
}
//==运算符重载
bool operator==(const Self& s) const
{
     
     
	return _node == s._node;
}

4. * and -> 演算子のオーバーロード

  • * 演算子は、ハッシュ ノード データへの参照を返します。
  • -> 演算子は、ハッシュ ノード データのアドレスを返します。
//*运算符重载
T& operator*()
{
     
     
	return _node->_data;//返回哈希节点中数据的引用
}
//->运算符重载
T* operator->()
{
     
     
	return &(_node->_data);//返回哈希节点中数据的地址
}

セブン、ハッシュテーブルの始まりと終わり

ここで前方イテレータ型をtypedefする必要があるのですが、typedefの後に前方イテレータ型のイテレータを外部から使えるようにするためには、公開領域でtypedefする必要があることに注意してください。typedef の後、begin() と end() の関数を実現できます。

template<class K, class T, class KeyOfT, class Hash>
class HashBucket
{
     
     
 //把迭代器设为HashTable的友元
	template<class K, class T, class KeyOfT, class Hash>
	friend class __HTIterator;

	typedef HashNode<T> Node;//哈希结点类型
public:
	typedef __HTIterator<K, T, KeyOfT, Hash> iterator;//正向迭代器的类型
}

始める():

  1. ハッシュ テーブルをトラバースし、最初の空でないバケットの最初のノードの反復子の位置を返し、前方反復子のコンストラクターを完成させ、this ポインター (ハッシュ テーブルのポインター) を渡します。
  2. トラバーサルの最後に見つからない場合は、空のハッシュ テーブルがないことを意味し、end() の最後の反復子の位置が直接返されます。
//begin
iterator begin()
{
     
     
	for (size_t i = 0; i < _tables.size(); i++)
	{
     
     
		Node* cur = _tables[i];
		//找到第一个不为空的桶的节点位置
		if (cur)
		{
     
     
			return iterator(cur, this);
		}
	}
	return end();
}

終わり():

  • ハッシュ テーブルの end() は、イテレータのコンストラクタを直接返すことができます (ノード ポインタは空で、ハッシュ テーブル ポインタは this です)。
//end()
iterator end()
{
     
     
	return iterator(nullptr, this);
}

8. ハッシュ表(素数表)の最適化

剰余法を削除するときは、素数をモジュロするのが最善です。これにより、モジュロが完了した後にハッシュ競合が発生しにくくなり、それを解決する素数テーブルを作成できます。

inline unsigned long __stl_next_prime(unsigned long n)
{
     
     
    //素数序列
	static const int __stl_num_primes = 28;
	static const unsigned long __stl_prime_list[__stl_num_primes] =
	{
     
     
		53ul, 97ul, 193ul, 389ul, 769ul,
		1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
		49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
		1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
		50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
		1610612741ul, 3221225473ul, 4294967291ul
	};
    
	// 获取比prime大那一个素数
	for (int i = 0; i < __stl_num_primes; ++i)
	{
     
     
		if (__stl_prime_list[i] > n)
		{
     
     
			return __stl_prime_list[i];
		}
	}

	return __stl_prime_list[__stl_num_primes - 1];
}

9、挿入操作と [] 演算子のオーバーロード

  • unordered_map のデータ型は、unordered_set とは異なり、ペアのキーと値のペアを挿入する KV モデルであり、実装方法も異なります。

画像-20230412135747114

unordered_set はキー値を挿入します。ここで、挿入の戻り値を変更する必要があります。

画像-20230412141428667

画像-20230412141713301

unordered_set には [] 演算子のオーバーロードがないため、この関数を提供する必要はありません。unordered_map でのみこの関数を提供します。

  1. 最初に挿入関数を呼び出してキーと値のペアを挿入し、反復子 ret を返します
  2. 返された反復子 ret を介して要素値 value を呼び出します

:キーと値のペアの最初のパラメーターは、ユーザーによって渡されたキー値であり、2 番目のパラメーターは、ユーザーによって宣言された 2 番目のテンプレート パラメーターの既定のコンストラクターです

//[]运算符重载
V& operator[](const K& key)
{
     
     
	pair<iterator, bool> ret = insert(make_pair(key, V()));
	return ret.first->second;
}

次に、ハッシュ テーブルの Insert の戻り値を、unordered_map のペア データ型と一致するように変更する必要があります。変更点は以下の2点です。

画像-20230412142818310


10.ハッシュテーブル(修正版)のソースコードリンク

変更されたハッシュ テーブル ソース コード リンク:
HashBucket.h wei/cplusplus - Code Cloud - Open Source China (gitee.com)


11、unordered_set、unordered_map シミュレーション実装コード

1. unordered_set のコード

#pragma once
#include "HashBucket.h"

namespace unordered_set_realize
{
     
     
	template<class K, class Hash = HashFunc<K>>
	class unordered_set
	{
     
     
		struct SetKeyOfT
		{
     
     
			const K& operator()(const K& key)
			{
     
     
				return key;
			}
		};

	public:
		typedef typename HashBucket_realize::HashBucket<K, K, Hash, SetKeyOfT>::iterator iterator;

		iterator begin()
		{
     
     
			return _hb.begin();
		}

		iterator end()
		{
     
     
			return _hb.end();
		}

		pair<iterator, bool> insert(const K& key)
		{
     
     
			return _hb.Insert(key);
		}

	private:
		HashBucket_realize::HashBucket<K, K, Hash, SetKeyOfT> _hb;
	};

	void test_unordered_set()
	{
     
     
		unordered_set<int> us;
		us.insert(13);
		us.insert(3);
		us.insert(23);
		us.insert(5);
		us.insert(5);
		us.insert(6);
		us.insert(15);
		us.insert(223342);
		us.insert(22);

		unordered_set<int>::iterator it = us.begin();
		while (it != us.end())
		{
     
     
			cout << *it << " ";
			++it;
		}
		cout << endl;

		for (auto e : us)
		{
     
     
			cout << e << " ";
		}
		cout << endl;
	}
}

2. unordered_map のコード

#pragma once
#include "HashBucket.h"

namespace unordered_map_realize
{
     
     
	template<class K, class V, class Hash = HashFunc<K>>
	class unordered_map
	{
     
     
		struct MapKeyOfT
		{
     
     
			const K& operator()(const pair<const K, V>& kv)
			{
     
     
				return kv.first;
			}
		};

	public:
		typedef typename HashBucket_realize::HashBucket< K, pair<const K, V>, Hash, MapKeyOfT>::iterator iterator;
	
		iterator begin()
		{
     
     
			return _hb.begin();
		}

		iterator end()
		{
     
     
			return _hb.end();
		}

		pair<iterator, bool> insert(const pair<K, V>& data)
		{
     
     
			return _hb.Insert(data);
		}

		V& operator[](const K& key)
		{
     
     
			pair<iterator, bool> ret = _hb.Insert(make_pair(key, V()));
			return ret.first->second;
		}

	private:
		HashBucket_realize::HashBucket<K, pair<const K, V>, Hash, MapKeyOfT> _hb;
	};

	void test_unordered_map()
	{
     
     
		string arr[] = {
     
      "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜",
			"苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };

		unordered_map<string, int> countMap;
		for (auto& e : arr)
		{
     
     
			countMap[e]++;
		}

		for (const auto& kv : countMap)
		{
     
     
			cout << kv.first << ":" << kv.second << endl;
		}
	}
}

参考ブログ:

1. STL の詳細な説明 (13) - ハッシュ テーブルを使用して unordered_map と unordered_set_2021 ドラゴンを同時にカプセル化する

おすすめ

転載: blog.csdn.net/m0_64224788/article/details/130186863