$ sudo teach IT

Зачем нужны сортировка и поиск

Список результатов теста хочется показать по убыванию баллов. Каталог товаров — от дешёвых к дорогим. В списке заказов нужно быстро найти первый просроченный. Всё это одна и та же задача с двух сторон: расставить элементы коллекции в нужном порядке или найти среди них конкретный элемент, не перебирая всё вручную циклом. У массивов и словарей для этого уже есть готовые методы — их и разберём.

sorted() и sort()

Метод sorted() возвращает новый массив с элементами по возрастанию, а исходный оставляет как есть.

let numbers = [5, 3, 8, 1]
let ascending = numbers.sorted()
print(ascending)
print(numbers)

ascending — это [1, 3, 5, 8], а numbers по-прежнему [5, 3, 8, 1]: sorted() ничего не меняет в исходном массиве, а строит рядом новый. Именно поэтому numbers можно объявить через let — метод не пытается его изменить.

У sorted() есть парный метод sort() — без приставки ed. Он не возвращает ничего нового, а переставляет элементы прямо в существующем массиве. Раз массив меняется, он обязан быть объявлен через var:

var scores = [70, 40, 95]
scores.sort()
print(scores)

После этой строки scores — уже [40, 70, 95], отдельной переменной под результат заводить не пришлось. Если бы scores был объявлен через let, компилятор отказался бы собирать программу: sort() меняет получателя, а константы менять нельзя.

Comparable простыми словами

Чтобы sorted() вообще мог расставить элементы по порядку, тип элементов должен уметь отвечать на вопрос «кто из двух меньше». Это умение называется протоколом Comparable: если тип ему соответствует, для его значений работают <, > и подобные операторы, а значит и сортировка без уточнений. У Int, Double и String это уже есть «из коробки» — поэтому [3, 1, 2].sorted() и ["б", "а"].sorted() работают без вопросов. Для своих типов такое умение придётся объявлять самостоятельно, но это отдельная тема, до которой мы ещё дойдём.

Своё правило сортировки: sorted(by:)

Если стандартного порядка мало — например, нужно по убыванию, а не по возрастанию, — на помощь приходит sorted(by:). Он принимает замыкание с двумя элементами и должен вернуть true, если первый должен идти раньше второго:

let prices = [250, 40, 999, 15]
let descending = prices.sorted(by: { first, second in first > second })
print(descending)

first > second означает «первый должен встать раньше, если он больше второго» — то есть по убыванию. Результат — [999, 250, 40, 15]. Такое правило пишут и короче, через сокращённую запись параметров замыкания:

let descending2 = prices.sorted(by: { $0 > $1 })
print(descending2)

Здесь $0 — первый из сравниваемых элементов, $1 — второй, а сама запись читается точно так же: «первый больше второго — значит, он раньше». То же самое работает и у sort(by:) — только результат окажется в исходном (обязательно var) массиве, а не в новом.

Сортировка строк и ловушка с регистром

Строки сравниваются посимвольно по числовому коду символов, а не «по алфавиту, как в словаре». У латиницы заглавные буквы стоят в таблице символов раньше строчных, поэтому обычный sorted() без уточнений расставит заглавную букву перед любой строчной, даже если по алфавиту она должна идти позже:

let names = ["banana", "Apple", "cherry"]
print(names.sorted())

Результат — ["Apple", "banana", "cherry"]: "Apple" оказалась первой не потому, что она раньше по алфавиту, а потому, что заглавная A «меньше» строчной b как символ. Если регистр не должен влиять на порядок, элементы приводят к одному регистру прямо в правиле сравнения:

let sortedIgnoringCase = names.sorted { $0.lowercased() < $1.lowercased() }
print(sortedIgnoringCase)

Теперь сравниваются уже полностью строчные копии строк, и результат — привычный алфавитный порядок: ["Apple", "banana", "cherry"] с учётом того, что "apple" действительно раньше остальных по алфавиту.

Сортировка по нескольким критериям

Иногда одного правила мало: например, отсортировать имена сначала по длине, а среди одинаковых по длине — по алфавиту. Тогда в замыкании проверяют первый критерий и, только если он не различает элементы, переходят ко второму:

let words = ["fig", "date", "kiwi", "banana"]
let byLengthThenAlpha = words.sorted { a, b in
    if a.count != b.count {
        return a.count < b.count
    }
    return a < b
}
print(byLengthThenAlpha)

Если длины различаются, порядок решает именно длина. Если длины совпали (как у "date" и "kiwi", по четыре символа), в дело идёт вторая проверка — обычное сравнение строк. Результат — ["fig", "date", "kiwi", "banana"].

Поиск первого и последнего подходящего элемента

first(where:) возвращает первый элемент, для которого замыкание вернуло true, а last(where:) — последний. Раз подходящего элемента может и не найтись, оба возвращают Optional:

let temperatures = [18, 22, 15, 30, 12]
let firstHot = temperatures.first(where: { $0 > 25 })
print(firstHot)

firstHot — это Optional(30): единственное число больше 25. Если бы такого числа не было, результатом стал бы nil, и его, как любой опционал, нужно разворачивать — например, через ??, чтобы подставить значение по умолчанию, если ничего не нашлось.

Поиск позиции: firstIndex

firstIndex(of:) ищет позицию конкретного значения, а firstIndex(where:) — позицию первого элемента, подходящего под условие. Оба тоже возвращают Optional, потому что элемента может не быть вовсе:

let letters = ["a", "b", "c", "d"]
let position = letters.firstIndex(of: "c")
print(position)

position — Optional(2), потому что "c" стоит третьим по счёту, а индексы начинаются с нуля.

contains, allSatisfy и границы коллекции

contains проверяет, есть ли в коллекции конкретное значение, а contains(where:) — есть ли хотя бы один элемент, подходящий под условие; оба возвращают простой Bool, без опционала — ответ либо есть, либо нет, независимого от найденного значения:

let numbers2 = [4, 8, 15, 16]
print(numbers2.contains(15))
print(numbers2.contains(where: { $0 > 100 }))

Первая строка выведет true — 15 есть в массиве. Вторая — false: чисел больше ста здесь нет. У allSatisfy обратная логика — он проверяет, что условию подходят вообще все элементы: numbers2.allSatisfy { $0 > 0 } вернёт true, потому что все числа положительные.

min(), max() и свои правила min(by:), max(by:)

min() и max() возвращают наименьший и наибольший элемент — тоже как Optional, потому что у пустой коллекции их не существует:

let values = [7, 2, 9, 4]
print(values.min())
print(values.max())

Здесь min() даст Optional(2), а max() — Optional(9). Когда нужно сравнивать не «в лоб», а по своему правилу — например, по длине строки, — используют min(by:) и max(by:) с тем же замыканием из двух аргументов, что и у sorted(by:):

let animals = ["cat", "elephant", "dog"]
let longest = animals.max(by: { $0.count < $1.count })
print(longest)

max(by:) с правилом «первый короче второго» находит элемент, который ни для одного другого не оказался короче — то есть самый длинный. Результат — Optional("elephant").

Ловушка со словарём: sorted(by:) возвращает не словарь

У словаря нет понятия «порядок» — пары ключ-значение хранятся так, как удобно самой структуре, а не так, как их добавили. Когда вы вызываете sorted(by:) у словаря, результатом становится не отсортированный словарь (такого в принципе не существует), а обычный массив кортежей (key, value), уже расставленных по нужному правилу:

let scores = ["Anna": 90, "Boris": 95, "Karim": 80]
let sortedPairs = scores.sorted { $0.value > $1.value }
print(sortedPairs)

sortedPairs имеет тип [(key: String, value: Int)] — это массив, а не словарь, и обращаться к его элементам нужно как к парам: sortedPairs[0].key, sortedPairs[0].value. Если из результата нужны только имена, их дополнительно достают через map: sortedPairs.map { $0.key }.

Частые ошибки

  • Вызывать sort() на массиве, объявленном через let — компилятор откажется собирать код, потому что sort() меняет получателя на месте.
  • Ожидать, что sorted() без параметров расставит строки «по алфавиту, как в словаре» — на деле заглавные буквы окажутся раньше строчных.
  • Забывать, что first(where:), firstIndex(of:), min() и max() возвращают Optional — и пытаться использовать результат так, будто он точно есть.
  • Думать, что sorted(by:) у словаря вернёт словарь — на самом деле это всегда массив кортежей (key, value).
  • Путать sorted() и sort() в описании кода: первый ничего не меняет и возвращает новое, второй меняет исходную коллекцию и не возвращает ничего полезного.

Резюме

  • sorted() возвращает новый отсортированный массив, исходный не трогает; работает для типов, соответствующих Comparable — у Int, Double, String это уже есть.
  • sort() сортирует массив на месте и требует var.
  • sorted(by:) и sort(by:) принимают своё правило сравнения — замыкание вида { $0 > $1 }; несколько критериев проверяют по очереди внутри одного замыкания.
  • Сортировка строк по умолчанию учитывает регистр символов — заглавные буквы идут раньше строчных.
  • first(where:), last(where:), firstIndex(of:), firstIndex(where:), min(), max() возвращают Optional, потому что подходящего элемента может не найтись.
  • contains, contains(where:) и allSatisfy возвращают обычный Bool.
  • min(by:) и max(by:) ищут крайний элемент по своему правилу, как sorted(by:).
  • sorted(by:) у словаря возвращает массив кортежей (key, value), а не словарь.

Проверьте себя

3 вопроса

Сортировка по длине и алфавиту

Напишите функцию sortByLengthThenAlphabetically, которая принимает массив строк и возвращает новый массив, отсортированный по длине строки по возрастанию. Если у двух строк длина одинаковая, они должны идти в обычном алфавитном порядке между собой.

Используйте sorted(by:) со своим правилом сравнения: сначала сравните длины, а если они равны — сами строки.

Первое длинное слово

Напишите функцию firstWordLongerThan, которая принимает массив строк words и число length, а возвращает первое слово, длина которого строго больше length. Если такого слова нет, функция должна вернуть строку "not found".

Используйте first(where:) — он вернёт первое подходящее слово или nil, если такого нет, а значение по умолчанию для случая nil удобно подставить оператором ??.

Лучшие результаты

Напишите функцию topScorers, которая принимает словарь scores вида [String: Int] (имя — результат) и число n, а возвращает массив из n имён с наибольшими результатами, отсортированный по убыванию результата. Если два участника набрали поровну, между собой они должны идти в алфавитном порядке имён. Если n больше числа участников, верните имена всех участников. Если n меньше или равно нулю, верните пустой массив.

Вспомните, что sorted(by:) у словаря возвращает массив кортежей (key, value), а не словарь — из него можно взять нужное число элементов через prefix и достать имена через map.