Реализация бинарного дерева в Котлине

Реализация бинарного дерева в Котлине

1. обзор

В этом руководстве мы реализуем основные операции для двоичного дерева с помощью языка программирования Kotlin.

Не стесняйтесь проверить нашу версию Java того жеtutorial.

2. Определение

В программировании abinary tree is a tree where every node has no more than two child nodes. Каждый узел содержит некоторые данные, которые мы называем ключом.

Без потери общности ограничимся рассмотрением случая, когда ключи представляют собой просто целые числа.

Итак, мы можем определить рекурсивный тип данных:

class Node(
    var key: Int,
    var left: Node? = null,
    var right: Node? = null)

Он содержит значение (целочисленное полеkey) и дополнительные ссылки на левый и правый дочерние элементы того же типа, что и их родительский элемент.

Мы видим, что из-за связанной природы все двоичное дерево может быть описано только одним узлом, который мы будем называть корневым узлом.

Все становится более интересным, если мы применяем некоторые ограничения на древовидную структуру. In this tutorial, we suppose that the tree is an ordered binary tree (также известное как двоичное дерево поиска). Это означает, что узлы расположены в некотором порядке.

Мы предполагаем, что все следующие условия являются частьюinvariant нашего дерева:

  1. дерево не содержит дубликатов ключей

  2. для каждого узла его ключ больше, чем ключи его левых узлов поддерева

  3. для каждого узла его ключ меньше, чем ключи его правых узлов поддерева

3. Основные операции

Некоторые из наиболее распространенных операций включают в себя:

  • Поиск узла с заданным значением

  • Вставка нового значения

  • Удаление существующего значения

  • И поиск узлов в определенном порядке

3.1. Уважать

When the tree is ordered, the lookup process becomes very efficient: если значение для поиска равно значению текущего узла, то поиск окончен; если значение для поиска больше, чем значение текущего узла, то мы можем отбросить левое поддерево и рассмотреть только правое:

fun find(value: Int): Node? = when {
    this.value > value -> left?.findByValue(value)
    this.value < value -> right?.findByValue(value)
    else -> this
}

Обратите внимание, что значение может отсутствовать среди ключей дерева, и, следовательно, результат поиска может вернуть значениеnull.

Обратите внимание, как мы использовалиKotlin keyword when, который является аналогом оператораswitch-case в Java, но гораздо более мощным и гибким.

3.2. вставка

Поскольку дерево не допускает дублирования ключей, довольно просто вставить новое значение:

  1. если значение уже присутствует, никаких действий не требуется

  2. если значение отсутствует, оно должно быть вставлено в узел, который имеет свободную левую или правую «щель»

Таким образом, мы можем рекурсивно анализировать дерево в поисках поддерева, которое должно соответствовать значению. Когда значение меньше ключа текущего узла, выберите его левое поддерево, если оно присутствует. Если его нет, это означает, что место для вставки значения найдено: это левый потомок текущего узла.

Аналогично, в случае, когда значение больше ключа текущего узла. Единственная оставшаяся возможность - это когда значение равно текущему ключу узла: это означает, что значение уже присутствует в дереве, и мы ничего не делаем:

fun insert(value: Int) {
    if (value > this.key) {
        if (this.right == null) {
            this.right = Node(value)
        } else {
            this.right.insert(value)
        }
    } else if (value < this.key) {
        if (this.left == null) {
            this.left = Node(value)
        } else {
            this.left.insert(value)
        }
    }

3.3. Удаление

Во-первых, мы должны определить узел, который содержит данное значение. Подобно процессу поиска, мы сканируем дерево в поисках узла и поддерживаем ссылку на родителя искомого узла:

fun delete(value: Int) {
    when {
        value > key -> scan(value, this.right, this)
        value < key -> scan(value, this.left, this)
        else -> removeNode(this, null)
    }
}

Есть три различных случая, с которыми мы можем столкнуться при удалении узла из двоичного дерева. Мы классифицируем их по количеству ненулевых дочерних узлов.

Both child nodes are null Этот случай довольно прост в обращении, и это единственный случай, в котором мы можем не удалить узел: если узел является корневым, мы не можем его устранить. В противном случае достаточно установитьnull соответствующего потомка родителя.

image

Реализация этого подхода может выглядеть так:

private fun removeNoChildNode(node: Node, parent: Node?) {
    if (parent == null) {
        throw IllegalStateException("Can not remove the root node without child nodes")
    }
    if (node == parent.left) {
        parent.left = null
    } else if (node == parent.right) {
        parent.right = null
    }
}

One child is null, the other is not null В этом случае мы всегда должны добиваться успеха, поскольку достаточно «переместить» единственный дочерний узел в узел, который мы удаляем:

image

Мы можем реализовать этот случай прямо:

private fun removeSingleChildNode(parent: Node, child: Node) {
    parent.key = child.key
    parent.left = child.left
    parent.right = child.right
}

Both child nodes are not null Этот случай более сложный, поскольку мы должны найти узел, который должен заменить узел, который мы хотим удалить. Один из способов найти этот «замещающий» узел - это выбрать узел в левом поддереве с наибольшим ключом (он наверняка существует). Другой способ - симметричный: мы должны выбрать узел в правом поддереве с наименьшим ключом (он также существует). Здесь мы выбираем первый:

image

Как только найден замещающий узел, мы должны «сбросить» ссылку на него из его родителя. Это означает, что при поиске замещающего узла мы также должны возвращать его родителя:

private fun removeTwoChildNode(node: Node) {
    val leftChild = node.left!!
    leftChild.right?.let {
        val maxParent = findParentOfMaxChild(leftChild)
        maxParent.right?.let {
            node.key = it.key
            maxParent.right = null
        } ?: throw IllegalStateException("Node with max child must have the right child!")
    } ?: run {
        node.key = leftChild.key
        node.left = leftChild.left
    }
}

3.4. пересечение

Существуют различные способы посещения узлов. Most common are depth-first, breadth-first, and level-first search. Here, we consider only depth-first search, который может быть одного из следующих типов:

  1. предварительный заказ (посещение родительского узла, затем левого потомка, затем правого потомка)

  2. по порядку (посетите левого потомка, затем родительский узел, затем правый потомок)

  3. пост-заказ (посещение левого потомка, затем правого потомка, затем родительского узла)

В Kotlin все эти виды обхода могут быть выполнены довольно простым способом. Например, для прохождения предварительного заказа мы имеем:

fun visit(): Array {
    val a = left?.visit() ?: emptyArray()
    val b = right?.visit() ?: emptyArray()
    return a + arrayOf(key) + b
}

Обратите внимание, как Kotlin позволяет нам объединять массивы с помощью оператора «+». Эта реализация далеко не эффективна: она не является хвостовой рекурсией, и для более глубокого дерева мы можем столкнуться с исключением переполнения стека.

4. Заключение

В этом уроке мы рассмотрели, как создавать и реализовывать основные операции для двоичного дерева поиска с использованием языка Kotlin. Мы продемонстрировали некоторые конструкции Kotlin, которых нет в Java и которые мы могли бы найти полезными.

Как всегда доступна полная реализация описанных выше алгоритмовover on Github.