Это часть 5 Kotlin for Interviews, серии, в которой я рассматриваю функции Kotlin и фрагменты кода, которые часто возникали во время подготовки к собеседованию с Android. Я также составил шпаргалку по всем 5 частям этой серии, которую вы можете найти здесь.
Вы можете найти Часть 1: Общие типы данных здесь, Часть 2: Функции сбора здесь, Часть 3: Числа и математику здесь и Часть 4: Итерацию здесь.
Эта часть охватывает:
- Создание графов в виде списка смежности
- Поиск в ширину
- Поиск в глубину
- Обход дерева
- Динамическое программирование / мемоизация
Мы рассмотрим блоки кода, которые я часто использую для решения множества различных задач. Например, многие задачи собеседования сводятся к поиску в глубину, и для их решения я использовал варианты базового фрагмента кода поиска в глубину.
Создание графиков в форме списка смежности
Для многих задач с графами вам будет предоставлен список пар узлов, где второй узел зависит от первого узла (или наоборот, в зависимости от вашего интервьюера). Например, пара, которая выглядит как [0, 1], означает, что для посещения узла 1 вы должны сначала посетить 0. Однако для большинства алгоритмов графа требуется представление списка смежности, поэтому вот алгоритм, который принимает список пар узлов и преобразует его в список смежности.
Учитывая этот пример ввода:
[[1, 2], [1, 3], [1, 4], [2, 4], [2, 5], [3, 6], [4, 3], [4,6], [4, 7], [5, 4], [5, 7], [7, 6]]
Что представляет собой этот график:

Мы хотим создать следующий список смежности:
[[1: [2, 4, 3]], [2: [4, 5]], [3: [6]], [4: [6, 7, 3]], [5: [4, 7]], [7: [6]]]

fun createAdjacencyList(pairs: Array<IntArray>) {
val graph: HashMap<Int, MutableList<Int>> = hashMapOf()
pairs.forEach { pair ->
if (!graph.containsKey(pair[0])) {
// If the current node isn't in the adjacency list yet,
// add it and create its dependency list starting with
// pair[1]
graph[pair[0]] = mutableListOf(pair[1])
} else {
// Otherwise, append pair[1] to its existing dependency
// list.
val dependencies = graph[pair[0]]
dependencies.add(pair[1])
graph[pair[0]] = dependencies
}
}
}
Обратите внимание, что этот алгоритм предназначен для ориентированных графов. Если вам сказали, что граф неориентирован - это означает, что пара [0, 1] просто означает, что 0 и 1 имеют ребро между ними - просто повторите код в цикле forEach() с заменой pair[0] и pair[1], чтобы MutableLists в графе представляют все смежные узлы, а не только направленные зависимости.
Поиск в ширину
Многие задачи собеседования требуют обхода графов - от поиска узла до проверки циклов и определения длины пути между двумя узлами. Один из способов сделать это - поиск в ширину. Алгоритм начинается с некоторого узла графа и с помощью очереди исследует все соседние узлы на текущей глубине, прежде чем перейти к узлам на следующем уровне глубины.
Вот базовая версия, которая проходит через все узлы, доступные с первого. Вы можете изменить его в зависимости от решаемой задачи с графиком.
fun bfs(nodes: List<List<Int>>) {
val visited = BooleanArray(nodes.size) { false }
// Create a queue and add 0 to represent the index of the
// first node
val queue: MutableList<Int> = mutableListOf(0)
while (queue.isNotEmpty()) {
// Dequeue a node from queue
val node = queue.removeAt(0)
// Add all of the node's unvisited neighbors to the queue
if (!visited[node]) {
nodes[node].forEach {
queue.add(it)
}
// Mark the dequeued node as visited
visited[node] = true
}
}
}
Поиск в глубину
Поиск в глубину также может использоваться для задач обхода графа. Алгоритм использует стек вместо очереди и исследует текущую ветвь узла, насколько это возможно, прежде чем будет вынужден вернуться и развернуться на другие узлы.
Вот рекурсивная версия, которая полагается на стек вызовов функции, а не на явную переменную стека. Вы также можете написать итеративную версию алгоритма, используя переменную стека.
fun dfs(nodes: List<List<Int>>) {
val visited = BooleanArray(nodes.size) { false }
helper(nodes, 0, visited)
}
fun helper(nodes: List<List<Int>>, node: Int, visited: BooleanArray){
visited[node] = true
nodes[node].forEach {
if (!visited[it]) {
helper(nodes, it, visited)
}
}
}
Обход дерева
Проблемы с деревьями очень часто встречаются при собеседовании. Некоторые примеры включают поиск наименьшего общего предка двух узлов, суммирование значений всех узлов в дереве и т. Д.
Двоичное дерево
Двоичные деревья - это наиболее распространенное дерево, с которым вы столкнетесь на собеседовании. Узел двоичного дерева будет выглядеть примерно так:
class Node(
var key: Int,
var left: Node? = null,
var right: Node? = null
)
Обратите внимание, что не все бинарные деревья являются бинарными деревьями поиска, и вы не должны предполагать, что вам дали BST, если ваш интервьюер не подтвердит это. Бинарное дерево является BST только в том случае, если оно также удовлетворяет следующим критериям:
- Левое поддерево узла содержит только узлы с ключами меньше ключа узла.
- Правое поддерево узла содержит только узлы с ключами больше ключа узла.
- Левое и правое поддерево также должны быть двоичным деревом поиска.
Давайте возьмем это дерево (которое не является BST!) В качестве примера:

Вы можете построить его, используя следующий код:
val node4 = Node(4) val node7 = Node(7) val node6 = Node(6, node4, node7) val node11 = Node(11) val node9 = Node(9, node6, node11) val node2 = Node(2) val node5 = Node(5) val node12 = Node(12, node2, node5) val node3 = Node(3, node12) val node1 = Node(1, node9, node3)
Вот как будет выглядеть обход предварительного заказа:
fun preOrder(n: Node?) {
n?.let { node ->
print(node.key)
preOrder(node.left)
preOrder(node.right)
}
}
preOrder(node1) // prints 1 9 6 4 7 11 3 12 2 5
Вот как будет выглядеть обход по порядку:
fun inOrder(n: Node?) {
n?.let { node ->
inOrder(node.left)
print(node.key)
inOrder(node.right)
}
}
inOrder(node1) // prints 4 6 7 9 11 1 2 12 5 3
Вот как будет выглядеть обход пост-заказа:
fun postOrder(n: Node?) {
n?.let { node ->
postOrder(node.left)
postOrder(node.right)
print(node.key)
}
}
postOrder(node1) // prints 4 7 6 11 9 2 5 12 3 1
Дерево с несколькими дочерними элементами
Вы также можете встретить деревья, у которых есть массив дочерних элементов, а не левый и правый узлы. Примером этой структуры данных может быть иерархия представлений Android, где каждое представление может иметь несколько дочерних элементов.
class Node(var value: Int) {
val children: List<Node>
}
В этом случае вам придется рекурсивно вызывать свою функцию для всех дочерних элементов, и код будет выглядеть примерно так:
fun traverse(node: Node) {
print(node.key)
node.children.forEach {
traverse(it)
}
}
Динамическое программирование / мемоизация
Всякий раз, когда вы получаете рекурсивный алгоритм, который имеет повторяющиеся вызовы с одними и теми же входными данными, вы, вероятно, можете оптимизировать его с помощью динамического программирования. Идея состоит в том, чтобы сохранить результаты подзадач в таблице, чтобы нам не приходилось повторно вычислять их, когда это понадобится позже. Это снижает временную сложность от экспоненциальной до полиномиальной. Его можно реализовать с помощью итерации или рекурсии.
Итерация
Мы начинаем с самого маленького i и заполняем оттуда таблицу результатов. Каждая подзадача, которая нам нужна для текущей итерации, уже должна быть решена. На последней итерации мы решаем i=n и возвращаем этот результат.
fun fibonacci(n: Int): Int {
// Initialize an array to keep track of results of subproblems
// We'll use 0 as the placeholder initial value
val results = Array(n + 1) { 0 }
// Set the base cases
results[1] = 1
results[2] = 1
for (i in 3..n) {
results[i] = results[i-1] + results[i-2]
}
return results[n]
}
Рекурсия
Начнем с i = n. Если результаты подзадач, которые нам нужны для текущей итерации, уже существуют в таблице результатов, мы можем их использовать. В противном случае мы вызовем функцию рекурсивно, чтобы решить их и сохранить результаты.
fun fibonacci(n: Int): Int {
// Initialize an array to keep track of results of subproblems
// We'll use 0 as the placeholder initial value
val results = Array(n + 1) { 0 }
// Set the base cases
results[1] = 1
results[2] = 1
return helper(n, results)
}
// Write a helper function that takes in the results array as an
// argument
fun helper(n: Int, results: Array<Int>): Int {
// Check for the result of the subproblem you need in the
// results table first
val nMinusOne: Int = if (results[n-1] != 0) {
results[n-1]
} else {
// Only make the recursive call to the subproblem if it's
// not in the results table yet
helper(n-1, results)
}
val nMinusTwo: Int = if (results[n-2] != 0) {
results[n-2]
} else {
helper(n-2, results)
}
// Fill in the results table with the current results
results[n] = nMinusOne + nMinusTwo
return nMinusOne + nMinusTwo
}
И это конец серии "Котлин для интервью". Вот ссылка на шпаргалку, снова охватывающую все 5 частей. Удачи на собеседовании!
Щелкните 👏, чтобы сказать «спасибо!» и помогите другим найти эту статью.
Чтобы быть в курсе отличных новостей о Kt. Academy, подписывайтесь на рассылку новостей, следите за Twitter и следите за нами на Medium.
Если вам нужна мастерская Kotlin, узнайте, чем мы можем вам помочь: kt.academy.
