データ構造: 配列とリンクされたリスト

目次

1 アレイ

1.1 配列に対する一般的な操作

1. 配列を初期化する

2. 要素にアクセスする

3. 要素を挿入する

4. 要素を削除する

5. 配列を走査する

6. 要素を検索する

7. アレイを展開します

1.2 アレイの利点と制限

1.3 配列の一般的な用途

2 リンクリスト

2.1 リンクリストに対する一般的な操作

1. リンクリストの初期化

2. ノードの挿入

3. ノードの削除

4. アクセスノード

5. ノードを見つける

2.2 配列 VS リンクリスト

2.3 一般的なリンク リストのタイプ

2.4 リンクリストの典型的な用途

3 リスト

3.1 リストに対する一般的な操作

1. 初期化リスト

2. 要素にアクセスする

3. 要素の挿入と削除

4. リストをたどる

5. スプライシングリスト

6. ソートされたリスト

3.2 リストの実装

4 まとめ

1. 主要なレビュー

2. Q&A


1 アレイ

「配列」は、同じ種類の要素を連続したメモリ空間に格納する線形データ構造です。配列内の要素の位置を要素の「インデックス」と呼びます。図 4-1 は、配列の主な用語と概念を示しています。

配列の定義と保存

図 4-1 配列の定義と格納方法

1.1 配列に対する一般的な操作

1. 配列を初期化する

必要に応じて、配列に対して 2 つの初期化方法 (初期値なしと初期値を指定) を選択できます。ほとんどのプログラミング言語は、初期値が指定されていない場合、配列要素を 0 に初期化します。

int arr[5] = { 0 }; // { 0, 0, 0, 0, 0 }
int nums[5] = { 1, 3, 2, 5, 4 };

2. 要素にアクセスする

配列要素は連続したメモリ空間に格納されるため、配列要素のメモリ アドレスの計算が非常に簡単になります。配列のメモリ アドレス (つまり、最初の要素のメモリ アドレス) と要素のインデックスが与えられると、図 4-2 に示す式を使用して要素のメモリ アドレスを計算でき、それによって要素のメモリ アドレスに直接アクセスできます。要素。

配列要素のメモリアドレス計算

図 4-2 配列要素のメモリアドレス計算

図 4-2 を見ると、配列の最初の要素のインデックスが 0 であることがわかります。これは、1 から数え始める方が自然であるため、直感に反するように思えます。しかし、アドレス計算式の観点から見ると、インデックスの意味は本質的にメモリ アドレスのオフセットです最初の要素のアドレス オフセットは 0 なので、そのインデックスが 0 であることは当然です。

配列内の要素へのアクセスは非常に効率的で、O(1) で配列内の任意の要素にランダムにアクセスできます。

/* 随机访问元素 */
int randomAccess(int *nums, int size) {
    // 在区间 [0, size) 中随机抽取一个数字
    int randomIndex = rand() % size;
    // 获取并返回随机元素
    int randomNum = nums[randomIndex];
    return randomNum;
}

3. 要素を挿入する

配列要素はメモリ内で「隣り合って」おり、それらの間にデータを格納するためのスペースはありません。図 4-3 に示すように、配列の途中に要素を挿入する場合は、その要素の後のすべての要素を 1 つ後ろに移動してから、その要素をインデックスに割り当てる必要があります。

配列に要素を挿入する例

図 4-3 配列への要素の挿入例

配列の長さは固定されているため、要素を挿入すると必ず配列の末尾要素が「失われる」ことになることに注意してください。この問題の解決策はリストの章に譲ります。

/* 在数组的索引 index 处插入元素 num */
void insert(int *nums, int size, int num, int index) {
    // 把索引 index 以及之后的所有元素向后移动一位
    for (int i = size - 1; i > index; i--) {
        nums[i] = nums[i - 1];
    }
    // 将 num 赋给 index 处元素
    nums[index] = num;
}

4. 要素を削除する

同様に、図 4-4 に示すように、インデックス i の要素を削除する場合は、インデックス i 以降の要素を 1 つ前に移動する必要があります。

配列削除要素の例

図 4-4 配列から要素を削除する例

要素を削除すると、最後にある元の要素は「意味がなくなる」ため、特に変更する必要はありません。

/* 删除索引 index 处元素 */
// 注意:stdio.h 占用了 remove 关键词
void removeItem(int *nums, int size, int index) {
    // 把索引 index 之后的所有元素向前移动一位
    for (int i = index; i < size - 1; i++) {
        nums[i] = nums[i + 1];
    }
}

一般に、配列の挿入および削除操作には次のような欠点があります。

  • 高い時間計算量: 配列の挿入と削除の平均時間計算量は O(n) です。ここで、n は配列の長さです。
  • 要素の欠落: 配列の長さは不変であるため、要素を挿入した後、配列の長さを超える要素は失われます。
  • メモリの無駄: 比較的長い配列を初期化して先頭部分のみを使用できるため、データを挿入するときに失われた最後の要素は「意味がありません」が、これによりメモリ空間の一部が無駄になります。

5. 配列を走査する

ほとんどのプログラミング言語では、インデックスによって配列を走査することも、直接走査して配列内の各要素を取得することもできます。

/* 遍历数组 */
void traverse(int *nums, int size) {
    int count = 0;
    // 通过索引遍历数组
    for (int i = 0; i < size; i++) {
        count++;
    }
}

6. 要素を検索する

配列内の指定された要素を見つけるには配列を走査する必要があり、各ラウンドで要素の値が一致するかどうかが判断され、一致する場合は対応するインデックスが出力されます。

配列は線形データ構造であるため、上記の検索操作は「線形検索」と呼ばれます。

/* 在数组中查找指定元素 */
int find(int *nums, int size, int target) {
    for (int i = 0; i < size; i++) {
        if (nums[i] == target)
            return i;
    }
    return -1;
}

7. アレイを展開します

複雑なシステム環境では、プログラムが配列後のメモリ空間を確保することが難しく、安全に配列容量を拡張することができません。したがって、ほとんどのプログラミング言語では、配列の長さは不変です

配列を拡張したい場合は、より大きな配列を再作成し、元の配列の要素を新しい配列に順番にコピーする必要があります。これは O(n) 操作であり、配列が大きい場合には非常に時間がかかります。

/* 扩展数组长度 */
int *extend(int *nums, int size, int enlarge) {
    // 初始化一个扩展长度后的数组
    int *res = (int *)malloc(sizeof(int) * (size + enlarge));
    // 将原数组中的所有元素复制到新数组
    for (int i = 0; i < size; i++) {
        res[i] = nums[i];
    }
    // 初始化扩展后的空间
    for (int i = size; i < size + enlarge; i++) {
        res[i] = 0;
    }
    // 返回扩展后的新数组
    return res;
}

1.2 アレイの利点と制限

配列は連続したメモリ空間に格納され、同じ要素タイプを持ちます。このアプローチには、システムがデータ構造の運用効率を最適化するために使用できる豊富な事前情報が含まれています。

  • 高いスペース効率: アレイは追加の構造的オーバーヘッドを発生させることなく、連続したメモリ ブロックをデータに割り当てます。
  • ランダム アクセスのサポート: 配列では、O(1) 時間で任意の要素にアクセスできます。
  • キャッシュの局所性: 配列要素にアクセスすると、コンピューターはその要素をロードするだけでなく、その周囲の他のデータもキャッシュし、それによってキャッシュを使用して後続の操作を高速化します。

連続スペースストレージは諸刃の剣であり、次のようなデメリットがあります。

  • 挿入と削除は非効率的です。配列内に多数の要素がある場合、挿入と削除の操作では多数の要素を移動する必要があります。
  • 不変の長さ: 配列の長さは初期化後に固定されます。配列を拡張するには、すべてのデータを新しい配列にコピーする必要があり、非常にコストがかかります。
  • スペースの無駄: 割り当てられた配列のサイズが実際に必要なサイズよりも大きい場合、余分なスペースが無駄になります。

1.3 配列の一般的な用途

配列は基本的で一般的なデータ構造であり、さまざまなアルゴリズムで頻繁に使用され、さまざまな複雑なデータ構造の実装にも使用できます。

  • ランダム アクセス: いくつかのサンプルをランダムに選択したい場合、それらを配列に保存し、ランダム シーケンスを生成し、インデックスに基づいてサンプルをランダムに選択できます。
  • 並べ替えと検索: 配列は、並べ替えおよび検索アルゴリズムに最も一般的に使用されるデータ構造です。クイックソート、マージソート、二分探索などはすべて主に配列に対して実行されます。
  • ルックアップ テーブル: 要素をすばやく見つけたり、要素間の対応関係を見つけたりする必要がある場合、配列をルックアップ テーブルとして使用できます。文字を ASCII コードにマッピングする場合は、文字の ASCII コード値をインデックスとして使用でき、対応する要素が配列内の対応する位置に格納されます。
  • 機械学習: ニューラル ネットワークは、ベクトル、行列、テンソル間の線形代数演算を広範囲に利用しており、これらのデータはすべて配列の形式で構築されます。配列は、ニューラル ネットワーク プログラミングで最も一般的に使用されるデータ構造です。
  • データ構造の実装: 配列を使用して、スタック、キュー、ハッシュ テーブル、ヒープ、グラフなどのデータ構造を実装できます。たとえば、グラフの隣接行列表現は実際には 2 次元配列です。

2 リンクリスト

メモリ領域はすべてのプログラムに共通のリソースですが、複雑なシステム動作環境では、空きメモリ領域がメモリ全体に分散している場合があります。配列を格納するためのメモリ空間は連続的でなければならないことはわかっていますが、配列が非常に大きい場合、メモリはそのような大きな連続空間を提供できない可能性があります。このとき、リンクリストの柔軟性の利点が反映されます。

「リンクリスト」は、各要素がノードオブジェクトとなり、各ノードが「参照」を介して接続された線形のデータ構造です。参照には、現在のノードから次のノードにアクセスできる次のノードのメモリ アドレスが記録されます。

リンク リストの設計により、各ノードをメモリ全体に分散して格納できるため、メモリ アドレスが連続している必要がありません。

リンクリストの定義と保存方法

図4-5 リンクリストの定義と保存方法

図 4-5 に注目してください。リンク リストの単位は「ノード ノード」オブジェクトです。各ノードには、ノードの「値」と次のノードへの「参照」という 2 つのデータが含まれています。

  • リンクされたリストの最初のノードは「ヘッド ノード」と呼ばれ、最後のノードは「テール ノード」と呼ばれます。
  • 末尾ノードは「null」を指しており、Java、C++、Python ではそれぞれ null、nullptr、None として記録されます。
  • C、C++、Go、Rust などのポインタをサポートする言語では、上記の「参照」を「ポインタ」に置き換える必要があります。

次のコードに示すように、リンク リスト ノード ListNode には、値を含めるだけでなく、追加の参照 (ポインター) も保存する必要があります。したがって、同じ量のデータの場合、リンク リストは配列よりも多くのメモリ スペースを占有します

/* 链表节点结构体 */
struct ListNode {
    int val;               // 节点值
    struct ListNode *next; // 指向下一节点的指针
};

typedef struct ListNode ListNode;

/* 构造函数 */
ListNode *newListNode(int val) {
    ListNode *node, *next;
    node = (ListNode *) malloc(sizeof(ListNode));
    node->val = val;
    node->next = NULL;
    return node;
}

2.1 リンクリストに対する一般的な操作

1. リンクリストの初期化

リンク リストを確立するには 2 つの手順があり、最初の手順は各ノード オブジェクトの初期化であり、2 番目の手順は参照指示関係を構築することです。初期化が完了すると、リンク リストのヘッド ノードから開始して、 next 参照ポイントを介してすべてのノードに順番にアクセスできます。

/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
ListNode* n0 = newListNode(1);
ListNode* n1 = newListNode(3);
ListNode* n2 = newListNode(2);
ListNode* n3 = newListNode(5);
ListNode* n4 = newListNode(4);
// 构建引用指向
n0->next = n1;
n1->next = n2;
n2->next = n3;
n3->next = n4;

配列全体は変数です。たとえば、配列には nums 要素 nums[0] など が含まれますnums[1] が、リンク リストは複数の独立したノード オブジェクトで構成されます。通常、ヘッド ノードはリンク リストの代表名として使用されます。たとえば、上記コードのリンク リストはリンク リストとして記録できます n0 。

2. ノードの挿入

リンク リストにノードを挿入するのは非常に簡単です。図 4-6 に示すように、 2 つの隣接するノード n0 と n1 の間に新しいノードを挿入する と仮定するとP 、2 つのノード参照 (ポインタ) を変更するだけでよく、時間計算量は O(1) です。

対照的に、要素を配列に挿入する時間計算量は O(n) であり、大量のデータを扱う場合は効率が低くなります。

リンクリストにノードを挿入する例

図 4-6 リンクリストへのノードの挿入例

/* 在链表的节点 n0 之后插入节点 P */
void insert(ListNode *n0, ListNode *P) {
    ListNode *n1 = n0->next;
    P->next = n1;
    n0->next = P;
}

3. ノードの削除

図 4-7 に示すように、リンク リスト内のノードを削除する場合も、ノードの参照 (ポインタ) を変更するだけで非常に便利です

削除操作が完了した後も ノードはP まだポイントしています がn1 、実際にはリンク リストを走査してアクセスできなくなること に注意してください。P これは、 P そのノードがリンク リストに属さなくなったことを意味します。

リンクリストからノードを削除

図 4-7 リンクリストからノードを削除

/* 删除链表的节点 n0 之后的首个节点 */
// 注意:stdio.h 占用了 remove 关键词
void removeNode(ListNode *n0) {
    if (!n0->next)
        return;
    // n0 -> P -> n1
    ListNode *P = n0->next;
    ListNode *n1 = P->next;
    n0->next = n1;
    // 释放内存
    free(P);
}

4. アクセスノード

リンク リスト内のノードへのアクセスは非効率的です前のセクションで述べたように、配列内の任意の要素に O(1) 時間でアクセスできます。リンク リストの場合はそうではなく、プログラムはヘッド ノードから開始し、ターゲット ノードが見つかるまで 1 つずつ逆方向にたどる必要があります。つまり、リンク リストの i 番目のノードにアクセスするには、i-1 回のループが必要となり、時間計算量は O(n) になります。

/* 访问链表中索引为 index 的节点 */
ListNode *access(ListNode *head, int index) {
    while (head && head->next && index) {
        head = head->next;
        index--;
    }
    return head;
}

5. ノードを見つける

リンク リストを走査し、 target リンク リスト内の値を持つノードを見つけて、リンク リスト内のノードのインデックスを出力します。このプロセスも線形探索です。

/* 在链表中查找值为 target 的首个节点 */
int find(ListNode *head, int target) {
    int index = 0;
    while (head) {
        if (head->val == target)
            return index;
        head = head->next;
        index++;
    }
    return -1;
}

2.2 配列 VS リンクリスト

表 4-1 は、配列とリンク リストの特性と演算効率をまとめて比較したものです。これらは 2 つの相反するストレージ戦略を採用しているため、さまざまな特性と動作効率も相反する特性を示します。

表 4-1 配列とリンク リストの効率比較

配列 リンクされたリスト
保存方法 連続したメモリ空間 メモリ空間を広げる
キャッシュの局所性 フレンドリー 不親切な
容量拡張 不変の長さ 柔軟に拡張可能
メモリ効率 メモリをほとんど占有せず、スペースを無駄にします 多くのメモリを消費する
アクセス要素 ○(1) の上)
要素の追加 の上) ○(1)
要素の削除 の上) ○(1)

2.3 一般的なリンク リストのタイプ

図 4-8 に示すように、リンク リストには 3 つの一般的なタイプがあります。

  • 一方向リンクリスト: 上で紹介した通常のリンクリスト。一方向リンク リストのノードには、値と次のノードへの参照という 2 つのデータが含まれます。最初のノードをヘッド ノード、最後のノードをテール ノードと呼び、テール ノードは None を指します。
  • 循環リンク リスト: 一方向リンク リストの末尾ノードが先頭ノードを指すようにすると (つまり、端から端まで接続された)、循環リンク リストが得られます。循環リンク リストでは、任意のノードをヘッド ノードとみなすことができます。
  • 二重リンク リスト: 単一リンク リストと比較して、二重リンク リストは両方向の参照を記録します。二重リンク リストのノード定義には、後続ノード (次のノード) と先行ノード (前のノード) の両方への参照 (ポインター) が含まれます。一方向リンク リストと比較して、二重リンク リストは柔軟性が高く、リンク リストを両方向に走査できますが、より多くのメモリ スペースも必要とします。

/* 双向链表节点结构体 */
struct ListNode {
    int val;               // 节点值
    struct ListNode *next; // 指向后继节点的指针
    struct ListNode *prev; // 指向前驱节点的指针
};

typedef struct ListNode ListNode;

/* 构造函数 */
ListNode *newListNode(int val) {
    ListNode *node, *next;
    node = (ListNode *) malloc(sizeof(ListNode));
    node->val = val;
    node->next = NULL;
    node->prev = NULL;
    return node;
}

一般的なタイプのリンク リスト

図 4-8 一般的なタイプのリンク リスト

2.4 リンクリストの典型的な用途

一方向リンク リストは、スタック、キュー、ハッシュ テーブル、グラフなどのデータ構造を実装するためによく使用されます。

  • スタックとキュー: 挿入操作と削除操作がリンク リストの一方の端で実行される場合、スタックに対応する先入れ後出しの特性を示します。挿入操作がリンク リストの一方の端で実行される場合は、 、削除操作がリンク リストの他端で実行されると、先入れ先出し機能がキューに対応します。
  • ハッシュ テーブル: チェーン アドレス法は、ハッシュの競合を解決するための主流の解決策の 1 つであり、この解決策では、競合するすべての要素がリンク リストに配置されます。
  • グラフ: 隣接リストはグラフを表す一般的な方法で、グラフの各頂点がリンク リストに関連付けられ、リンク リストの各要素がその頂点に接続されている他の頂点を表します。

二重リンク リストは、前後の要素をすぐに見つける必要があるシナリオでよく使用されます。

  • 高度なデータ構造: たとえば、赤黒ツリーや B ツリーでは、ノードの親ノードにアクセスする必要があります。これは、二重リンク リストと同様に、ノード内の親ノードへの参照を保存することで実現できます。 。
  • ブラウザ履歴: Web ブラウザで、ユーザーが「進む」ボタンまたは「戻る」ボタンをクリックしたとき、ブラウザはユーザーが訪問した前後の Web ページを認識する必要があります。二重リンクリストの特性により、この操作は簡単になります。
  • LRU アルゴリズム: キャッシュエビクション アルゴリズム (LRU) では、最も最近使用されていないデータを迅速に見つけて、ノードの迅速な追加と削除をサポートする必要があります。現時点では、二重リンクリストを使用することが非常に適切です。

循環リンク リストは、オペレーティング システムでのリソース スケジューリングなど、定期的な操作が必要なシナリオでよく使用されます。

  • タイム スライス ラウンド ロビン スケジューリング アルゴリズム: オペレーティング システムでは、タイム スライス ラウンド ロビン スケジューリング アルゴリズムは一般的な CPU スケジューリング アルゴリズムであり、プロセスのグループを循環する必要があります。各プロセスにはタイム スライスが与えられ、タイム スライスがなくなると、CPU は次のプロセスに切り替えます。この循環操作は、循環リンク リストを通じて実現できます。
  • データ バッファ: データ バッファの実装によっては、循環リンク リストも使用される場合があります。たとえば、オーディオおよびビデオ プレーヤーでは、シームレスな再生を実現するために、データ ストリームが複数のバッファ ブロックに分割され、循環リンク リストに入れられる場合があります。

3 リスト

配列の長さが不変であると実用性が低下します実際には、どのくらいの量のデータを保存する必要があるかを事前に決定できない場合があり、そのため配列の長さの選択が難しくなります。長さが小さすぎると、連続的にデータを追加するときに配列を頻繁に拡張する必要があり、長すぎるとメモリ空間が無駄に消費されます。

この問題を解決するために、「ダイナミック配列」と呼ばれるデータ構造が登場しました。これは、「リスト」とも呼ばれる可変長の配列です。リストは配列に基づいて実装され、配列の利点を継承し、プログラムの実行中に動的に拡張できます。容量制限を超えることを心配することなく、リストに要素を自由に追加できます。

3.1 リストに対する一般的な操作

1. 初期化リスト

通常、「初期値なし」と「初期値あり」の 2 つの初期化方法を使用します。

// C 未提供内置动态数组

2. 要素にアクセスする

リストは本質的に配列であるため、要素へのアクセスと更新は O(1) 時間で実行でき、非常に効率的です。

// C 未提供内置动态数组

3. 要素の挿入と削除

配列と比較して、リストは要素を自由に追加および削除できます。リストの末尾に要素を追加する場合の時間計算量は �(1) ですが、要素の挿入および削除の効率は配列の場合と同じであり、時間計算量は O(n) です。

// C 未提供内置动态数组

4. リストをたどる

配列と同様に、リストはインデックスに基づいて走査することも、個々の要素を直接走査することもできます。

// C 未提供内置动态数组

5. スプライシングリスト

新しい list を指定すると list1 、そのリストを元のリストの末尾に連結できます。

// C 未提供内置动态数组

6. ソートされたリスト

リストをソートした後、配列アルゴリズムの問​​題でよく検討される「二分探索」アルゴリズムと「ダブル ポインター」アルゴリズムを使用できます。

// C 未提供内置动态数组

3.2 リストの実装

Java、C++、Python など、多くのプログラミング言語では組み込みリストが提供されています。実装は比較的複雑で、初期容量や拡張倍数など、各パラメータの設定も非常に複雑です。興味のある読者はソースコードをチェックして学ぶことができます。

リストがどのように機能するかについての理解を深めるために、次の 3 つの主要な設計を含む、リストの簡略化されたバージョンの実装を試みます。

  • 初期容量: アレイの適切な初期容量を選択します。この例では、初期容量として 10 を選択します。
  • 数量レコードsize: リスト内の現在の要素数を記録する変数を宣言し 、要素が挿入および削除されるとリアルタイムで更新します。この変数に基づいて、リストの終わりを特定し、拡張が必要かどうかを判断できます。
  • 展開機構:要素挿入時にリスト容量がいっぱいの場合は展開が必要です。まず、拡張倍数に基づいてより大きな配列を作成し、次に現在の配列のすべての要素を新しい配列に順番に移動します。この例では、配列が毎回前のサイズの 2 倍に拡張されることを規定しています。

/* 列表类简易实现 */
struct myList {
    int *nums;       // 数组(存储列表元素)
    int capacity;    // 列表容量
    int size;        // 列表大小
    int extendRatio; // 列表每次扩容的倍数
};

typedef struct myList myList;

/* 构造函数 */
myList *newMyList() {
    myList *list = malloc(sizeof(myList));
    list->capacity = 10;
    list->nums = malloc(sizeof(int) * list->capacity);
    list->size = 0;
    list->extendRatio = 2;
    return list;
}

/* 析构函数 */
void delMyList(myList *list) {
    free(list->nums);
    free(list);
}

/* 获取列表长度 */
int size(myList *list) {
    return list->size;
}

/* 获取列表容量 */
int capacity(myList *list) {
    return list->capacity;
}

/* 访问元素 */
int get(myList *list, int index) {
    assert(index >= 0 && index < list->size);
    return list->nums[index];
}

/* 更新元素 */
void set(myList *list, int index, int num) {
    assert(index >= 0 && index < list->size);
    list->nums[index] = num;
}

/* 尾部添加元素 */
void add(myList *list, int num) {
    if (size(list) == capacity(list)) {
        extendCapacity(list); // 扩容
    }
    list->nums[size(list)] = num;
    list->size++;
}

/* 中间插入元素 */
void insert(myList *list, int index, int num) {
    assert(index >= 0 && index < size(list));
    // 元素数量超出容量时,触发扩容机制
    if (size(list) == capacity(list)) {
        extendCapacity(list); // 扩容
    }
    for (int i = size(list); i > index; --i) {
        list->nums[i] = list->nums[i - 1];
    }
    list->nums[index] = num;
    list->size++;
}

/* 删除元素 */
// 注意:stdio.h 占用了 remove 关键词
int removeNum(myList *list, int index) {
    assert(index >= 0 && index < size(list));
    int num = list->nums[index];
    for (int i = index; i < size(list) - 1; i++) {
        list->nums[i] = list->nums[i + 1];
    }
    list->size--;
    return num;
}

/* 列表扩容 */
void extendCapacity(myList *list) {
    // 先分配空间
    int newCapacity = capacity(list) * list->extendRatio;
    int *extend = (int *)malloc(sizeof(int) * newCapacity);
    int *temp = list->nums;

    // 拷贝旧数据到新数据
    for (int i = 0; i < size(list); i++)
        extend[i] = list->nums[i];

    // 释放旧数据
    free(temp);

    // 更新新数据
    list->nums = extend;
    list->capacity = newCapacity;
}

/* 将列表转换为 Array 用于打印 */
int *toArray(myList *list) {
    return list->nums;
}

4 まとめ

1. 主要なレビュー

  • 配列とリンク リストは 2 つの基本的なデータ構造であり、それぞれコンピューター メモリにデータを格納する 2 つの方法、つまり連続空間ストレージと分散空間ストレージを表します。両者の特性は相補的な特性を示します。
  • 配列はランダム アクセスをサポートしており、占有メモリは少なくなりますが、要素の挿入と削除は非効率であり、初期化後に長さは不変です。
  • リンクリストは、参照(ポインタ)を変更することでノードの挿入・削除を効率よく行え、長さも柔軟に調整できますが、ノードのアクセス効率が低く、多くのメモリを消費します。一般的なタイプのリンク リストには、単一リンク リスト、循環リンク リスト、二重リンク リストなどがあります。
  • リストとも呼ばれる動的配列は、配列の実装に基づいたデータ構造です。アレイの利点を維持しながら、長さを柔軟に調整できます。リストの出現により配列の使いやすさは大幅に向上しましたが、メモリ空間の一部が無駄になる可能性があります。

2. Q&A

スタック上の配列ストレージとヒープ上のストレージは、時間効率とスペース効率に影響を与えますか?

スタック上に格納される配列とヒープ上に格納される配列は、連続したメモリ空間に格納されるため、データの操作効率は基本的に同じです。ただし、スタックとヒープにはそれぞれの特徴があり、次のような違いがあります。

  1. 割り当てと解放の効率: スタックは小さなメモリであり、割り当てはコンパイラによって自動的に完了しますが、ヒープ メモリは比較的大きく、コード内で動的に割り当てることができ、断片化が容易です。したがって、ヒープ上での割り当ておよび割り当て解除の操作は、一般にスタック上での操作よりも遅くなります。
  2. サイズ制限: スタック メモリは比較的小さく、ヒープのサイズは通常、利用可能なメモリによって制限されます。したがって、ヒープは大きな配列を格納するのにより適しています。
  3. 柔軟性: スタック上の配列のサイズはコンパイル時に決定する必要がありますが、ヒープ上の配列のサイズは実行時に動的に決定できます。

配列には同じ型の要素が必要であるのに、リンクされたリストでは同じ型が強調されないのはなぜですか?

リンク リストは、参照 (ポインター) を介して接続されたノードで構成され、各ノードには、int、double、string、object などのさまざまな種類のデータを格納できます。

対照的に、配列要素は同じ型である必要があるため、オフセットを計算することで対応する要素の位置を取得できます。たとえば、配列に int 型と Long 型の両方が含まれており、1 つの要素がそれぞれ 4 バイトと 8 バイトを占める場合、配列には 2 つの長さの要素が含まれているため、現時点では次の式を使用してオフセットを計算することはできません。

# 元素内存地址 = 数组内存地址 + 元素长度 * 元素索引

ノードを削除した後、それを P.next 「なし」に設定する必要がありますか?

改造しなくても大丈夫です P.next 。リンクされたリストの観点からは、開始ノードから終了ノードまでのトラバースは発生していません P 。これは、ノードが P リンク リストから削除されたことを意味しますが、この時点では、 P ノードがどこを指していても、リンク リストには影響しません。

ガベージ コレクションの観点から見ると、Java、Python、Go などの自動ガベージ コレクションを備えた言語では、 ノードがリサイクルされるかどうかは、 その値P ではなく、そのノードを指す参照がまだ存在するかどうかによって決まります 。P.nextC や C++ などの言語では、ノード メモリを手動で解放する必要があります。

リンク リストでの挿入および削除操作の時間計算量は O(1) です。しかし、追加または削除する前に要素を見つけるのに O(n) かかるのに、時間計算量が O(n) ではないのはなぜでしょうか。

最初に要素を検索してから要素を削除すると、実際には O(n) になります。ただし、リンク リストの O(1) 追加および削除の利点は、他のアプリケーションにも反映できます。たとえば、双方向キューはリンク リストを使用して実装するのに適しています。常に先頭ノードと末尾ノードを指すポインタ変数を維持します。各挿入および削除操作は O(1) です。

「リンク リストの定義と格納方法」の図で、水色のストレージ ノード ポインタはメモリ アドレスを占有していますか? それともノード値と半々にするべきでしょうか?

記事内の模式図は定性的な表現にすぎず、定量的な表現は具体的な状況に応じて分析する必要があります。

  • int、long、double、instance オブジェクトなど、さまざまなタイプのノード値が異なるスペースを占有します。
  • ポインタ変数が占めるメモリ空間のサイズは、使用するオペレーティング システムとコンパイル環境によって異なりますが、通常は 8 バイトまたは 4 バイトです。

リストの末尾に要素を追加すると常に O(1) になりますか?

要素を追加するときにリストの長さを超える場合は、追加する前にリストを展開する必要があります。システムは新しいメモリを申請し、元のリストのすべての要素をそこに移動します。このときの時間計算量は O(n) になります。

「リストの出現により、配列の実用性は大幅に向上しましたが、副作用として、一部のメモリ空間が無駄になることです。」 ここでの空間の無駄とは、容量、長さ、拡張倍数などの追加の変数によって占有されるメモリのことを指しますか? ?

ここでのスペースの無駄には主に 2 つの意味があります。1 つは、リストによって初期の長さが設定されるため、必ずしもそれほど多くのスペースを使用する必要がないということです。一方、頻繁な拡張を防ぐため、拡張には1.5倍などの係数を掛けるのが一般的です。また、多くの空きスポットが残り、完全に埋めることができないことがよくあります。

Python での初期化 後n = [1, 2, 3] 、これら 3 つの要素のアドレスは接続されますが、初期化では、 m = [2, 1, 3] 各要素の ID が連続的ではなく、  n in と同じであることがわかります。これらの要素のアドレスは連続していないので、 m やはり配列なのでしょうか?

リスト要素がリンクされたリスト ノードに置き換えられる場合 n = [n1, n2, n3, n4, n5] 、通常、これら 5 つのノード オブジェクトもメモリ全体に分散して格納されます。ただし、リスト インデックスを指定すると、ノード メモリ アドレスを取得し、O(1) 時間で対応するノードにアクセスできます。これは、配列にはノード自体ではなくノードへの参照が格納されるためです。

多くの言語とは異なり、Python の数値もオブジェクトとしてラップされ、リストに格納されるのは数値そのものではなく、数値への参照です。したがって、2 つの配列内の同じ番号は同じ ID を持ち、これらの番号のメモリ アドレスは連続している必要がないことがわかります。

C++ STL の std::list では二重リンクリストが実装されていますが、アルゴリズムの本によってはこれを直接使用していないものもあるようですが、何か制限はあるのでしょうか?

一方で、主に 2 つの理由から、アルゴリズムの実装には配列を使用し、必要な場合にのみリンク リストを使用することを好む傾向があります。

  • スペースのオーバーヘッド: 各要素には 2 つの追加ポインター (前の要素に 1 つと次の要素に 1 つ) が必要なため、 std::list 通常は std::vector より多くのスペースを占有します。
  • キャッシュに不向き: データが継続的に保存されないため、std::list キャッシュの使用率は低くなります。一般に、std::vector パフォーマンスは向上します。

一方、リンク リストが必要となる主なケースは、バイナリ ツリーとグラフです。スタックとキューは、  リンク リストではなく、プログラミング言語によって提供されるstack 合計 を使用する傾向があります。queue

おすすめ

転載: blog.csdn.net/timberman666/article/details/133499921