Javaでのヒープソート

Javaでのヒープソート

1. 前書き

このチュートリアルでは、ヒープソートがどのように機能するかを確認し、Javaで実装します。

Heap Sort is based on the Heap data structure.ヒープソートを正しく理解するために、最初にヒープとその実装方法について詳しく説明します。

2. ヒープデータ構造

ヒープはspecialized tree-based data structureです。 したがって、ノードで構成されています。 要素をノードに割り当てます。各ノードには要素が1つだけ含まれています。

また、ノードには子を含めることができます。 If a node doesn’t have any children, we call it leaf.

ヒープが特別なものにするのは、次の2つです。

  1. すべてのノードの値はless or equal to all values stored in its childrenである必要があります

  2. complete treeです。つまり、高さが最小になります。

最初のルールのため、the least element always will be in the root of the tree

これらのルールをどのように実施するかは、実装に依存します。

ヒープは、最小(または最大)要素を抽出する非常に効率的な実装であるため、通常、優先キューの実装にヒープが使用されます。

2.1. ヒープバリアント

ヒープには多くのバリアントがあり、それらはすべて実装の詳細が異なります。

たとえば、上記で説明したのはMin-Heap, because a parent is always less than all of its childrenです。 または、Max-Heapを定義することもできます。この場合、親は常に子よりも大きくなります。 したがって、最大の要素はルートノードにあります。

多くのツリー実装から選択できます。 最も簡単なのは二分木です。 In a Binary Tree, every node can have at most two children.これらをleft childおよびright childと呼びます。

2番目のルールを強制する最も簡単な方法は、フルバイナリツリーを使用することです。 フルバイナリツリーは、いくつかの簡単なルールに従います。

  1. ノードに子が1つしかない場合、それはその左の子である必要があります

  2. 最深レベルの右端のノードのみが子を1つだけ持つことができます

  3. 葉は最も深いレベルにのみ存在できます

いくつかの例でこれらのルールを見てみましょう。

  1        2      3        4        5        6         7         8        9       10
 ()       ()     ()       ()       ()       ()        ()        ()       ()       ()
         /         \     /  \     /  \     /  \      /  \      /        /        /  \
        ()         ()   ()  ()   ()  ()   ()  ()    ()  ()    ()       ()       ()  ()
                                /          \       /  \      /  \     /        /  \
                               ()          ()     ()  ()    ()  ()   ()       ()  ()
                                                                             /
                                                                            ()

ツリー1、2、4、5、および7はルールに従います。

ツリー3と6は1番目のルールに違反し、8と9は2番目のルールに違反し、10は3番目のルールに違反しています。

このチュートリアルでは、we’ll focus on Min-Heap with a Binary Treeの実装。

2.2. 要素を挿入する

ヒープの不変量を保持する方法ですべての操作を実装する必要があります。 このようにして、build the Heap with repeated insertionsを実行できるため、単一の挿入操作に焦点を当てます。

次の手順で要素を挿入できます。

  1. 最も深いレベルで右端の使用可能なスロットである新しいリーフを作成し、そのノードにアイテムを保存します

  2. 要素が親よりも小さい場合は、それらを交換します

  3. 要素が親よりも小さくなるか、新しいルートになるまで、手順2に進みます。

ステップ2はヒープルールに違反しないことに注意してください。ノードの値をより小さい値に置き換えても、その子よりも小さいためです。

例を見てみましょう! このヒープに4を挿入します。

        2
       / \
      /   \
     3     6
    / \
   5   7

最初のステップは、4を保存する新しいリーフを作成することです。

        2
       / \
      /   \
     3     6
    / \   /
   5   7 4

4は親の6よりも小さいため、次のように交換します。

        2
       / \
      /   \
     3     4
    / \   /
   5   7 6

次に、4が親よりも小さいかどうかを確認します。 その親は2なので、停止します。 ヒープはまだ有効であり、4番を挿入しました。

1を挿入しましょう:

        2
       / \
      /   \
     3     4
    / \   / \
   5   7 6   1

1と4を交換する必要があります。

        2
       / \
      /   \
     3     1
    / \   / \
   5   7 6   4

ここで、1と2を交換する必要があります。

        1
       / \
      /   \
     3     2
    / \   / \
   5   7 6   4

1が新しいルートなので、停止します。

3. Javaでのヒープ実装

Full Binary Tree, we can implement it with an arrayを使用するため、配列内の要素はツリー内のノードになります。 次の方法で、左から右、上から下の配列インデックスですべてのノードをマークします。

        0
       / \
      /   \
     1     2
    / \   /
   3   4 5

必要なのは、ツリーに格納されている要素の数を追跡することだけです。 このように、挿入する次の要素のインデックスは配列のサイズになります。

このインデックスを使用して、親ノードと子ノードのインデックスを計算できます。

  • 親:(index – 1) / 2

  • 左の子:2 * index + 1

  • 右の子:2 * index + 2

配列の再割り当てに煩わされたくないので、実装をさらに単純化し、ArrayListを使用します。

基本的なバイナリツリーの実装は次のようになります。

class BinaryTree {

    List elements = new ArrayList<>();

    void add(E e) {
        elements.add(e);
    }

    boolean isEmpty() {
        return elements.isEmpty();
    }

    E elementAt(int index) {
        return elements.get(index);
    }

    int parentIndex(int index) {
        return (index - 1) / 2;
    }

    int leftChildIndex(int index) {
        return 2 * index + 1;
    }

    int rightChildIndex(int index) {
        return 2 * index + 2;
    }

}

上記のコードは、ツリーの最後に新しい要素を追加するだけです。 したがって、必要に応じて新しい要素を上に移動する必要があります。 次のコードでそれを行うことができます。

class Heap> {

    // ...

    void add(E e) {
        elements.add(e);
        int elementIndex = elements.size() - 1;
        while (!isRoot(elementIndex) && !isCorrectChild(elementIndex)) {
            int parentIndex = parentIndex(elementIndex);
            swap(elementIndex, parentIndex);
            elementIndex = parentIndex;
        }
    }

    boolean isRoot(int index) {
        return index == 0;
    }

    boolean isCorrectChild(int index) {
        return isCorrect(parentIndex(index), index);
    }

    boolean isCorrect(int parentIndex, int childIndex) {
        if (!isValidIndex(parentIndex) || !isValidIndex(childIndex)) {
            return true;
        }

        return elementAt(parentIndex).compareTo(elementAt(childIndex)) < 0;
    }

    boolean isValidIndex(int index) {
        return index < elements.size();
    }

    void swap(int index1, int index2) {
        E element1 = elementAt(index1);
        E element2 = elementAt(index2);
        elements.set(index1, element2);
        elements.set(index2, element1);
    }

    // ...

}

要素を比較する必要があるため、java.util.Comparableを実装する必要があることに注意してください。

4. ヒープソート

ヒープのルートには常に最小の要素the idea behind Heap Sort is pretty simple: remove the root node until the Heap becomes emptyが含まれているためです。

必要なのは、ヒープを一貫した状態に保つremove操作だけです。 BinaryTreeまたはHeapプロパティの構造に違反しないようにする必要があります。

To keep the structure, we can’t delete any element, except the rightmost leaf.したがって、ルートノードから要素を削除し、右端のリーフをルートノードに格納するという考え方です。

ただし、この操作はヒーププロパティに違反することはほぼ間違いありません。 したがって、if the new root is greater than any of its child nodes, we swap it with its least child node。 最小の子ノードは他のすべての子ノードよりも小さいため、ヒーププロパティに違反しません。

要素が葉になるまで、またはすべての子より少なくなるまで、交換を続けます。

このツリーからルートを削除しましょう:

        1
       / \
      /   \
     3     2
    / \   / \
   5   7 6   4

まず、最後のリーフをルートに配置します。

        4
       / \
      /   \
     3     2
    / \   /
   5   7 6

次に、両方の子よりも大きいため、最小の子である2と交換します。

        2
       / \
      /   \
     3     4
    / \   /
   5   7 6

4は6未満なので、停止します。

5. Javaでのヒープソートの実装

持っているすべてで、ルートの削除(ポップ)は次のようになります。

class Heap> {

    // ...

    E pop() {
        if (isEmpty()) {
            throw new IllegalStateException("You cannot pop from an empty heap");
        }

        E result = elementAt(0);

        int lasElementIndex = elements.size() - 1;
        swap(0, lasElementIndex);
        elements.remove(lasElementIndex);

        int elementIndex = 0;
        while (!isLeaf(elementIndex) && !isCorrectParent(elementIndex)) {
            int smallerChildIndex = smallerChildIndex(elementIndex);
            swap(elementIndex, smallerChildIndex);
            elementIndex = smallerChildIndex;
        }

        return result;
    }

    boolean isLeaf(int index) {
        return !isValidIndex(leftChildIndex(index));
    }

    boolean isCorrectParent(int index) {
        return isCorrect(index, leftChildIndex(index)) && isCorrect(index, rightChildIndex(index));
    }

    int smallerChildIndex(int index) {
        int leftChildIndex = leftChildIndex(index);
        int rightChildIndex = rightChildIndex(index);

        if (!isValidIndex(rightChildIndex)) {
            return leftChildIndex;
        }

        if (elementAt(leftChildIndex).compareTo(elementAt(rightChildIndex)) < 0) {
            return leftChildIndex;
        }

        return rightChildIndex;
    }

    // ...

}

前に言ったように、ソートは単にヒープを作成し、ルートを繰り返し削除するだけです:

class Heap> {

    // ...

    static > List sort(Iterable elements) {
        Heap heap = of(elements);

        List result = new ArrayList<>();

        while (!heap.isEmpty()) {
            result.add(heap.pop());
        }

        return result;
    }

    static > Heap of(Iterable elements) {
        Heap result = new Heap<>();
        for (E element : elements) {
            result.add(element);
        }
        return result;
    }

    // ...

}

次のテストで機能していることを確認できます。

@Test
void givenNotEmptyIterable_whenSortCalled_thenItShouldReturnElementsInSortedList() {
    // given
    List elements = Arrays.asList(3, 5, 1, 4, 2);

    // when
    List sortedElements = Heap.sort(elements);

    // then
    assertThat(sortedElements).isEqualTo(Arrays.asList(1, 2, 3, 4, 5));
}

we could provide an implementation, which sorts in-placeに注意してください。これは、要素を取得したのと同じ配列で結果を提供することを意味します。 さらに、この方法では、中間のメモリ割り当ては必要ありません。 ただし、その実装を理解するのは少し難しくなります。

6. 時間の複雑さ

ヒープソートは、two key stepsinserting要素、およびremovingルートノードで構成されます。 両方のステップの複雑さはO(log n)です。

両方のステップをn回繰り返すため、全体的なソートの複雑さはO(n log n)です。

配列の再割り当てのコストについては触れていませんが、O(n)であるため、全体的な複雑さには影響しません。 また、前述したように、インプレースソートを実装することも可能です。つまり、配列の再割り当ては必要ありません。

また、言及する価値があるのは、要素の50%が葉であり、要素の75%が2つの最下位レベルにあることです。 したがって、ほとんどの挿入操作は2ステップ以上かかりません。

実際のデータでは、クイックソートは通常、ヒープソートよりもパフォーマンスが高いことに注意してください。 銀色の裏打ちは、ヒープソートには常に最悪の場合のO(n log n)の時間計算量があるということです。

7. 結論

このチュートリアルでは、バイナリヒープとヒープソートの実装を見ました。

時間計算量はO(n log n)ですが、ほとんどの場合、実際のデータに最適なアルゴリズムではありません。

いつものように、例は利用可能なover on GitHubです。