Сортировка списка по ключу в Python (sorted и параметр key). Что такое ключ, и как это работает

Сортировка списка по ключу в Python (sorted и параметр key). Что такое ключ, и как это работает

Зачем этот пост? Стандартный параметр в стандартной функции, о котором написано сотни статей и примеров. Только понимание этого инструмента ко мне пришло после прочтения небольшого лирического отступления в теме о последовательностях в книге Р. Лучано «Python. К вершинам мастерства». Если коротко, мысль следующая: использование функции в качестве параметра key эффективно, потому что вызывается только один раз для каждого элемента последовательности. В общем-то можно на этом и закончить. Однако, я вспомнил про описание лямбда функций в книге Д. Бейдера «Чистый Python», в которой указано, что наиболее удачный и часто используемый вариант применения лямбд — это сортировка по альтернативному ключу. Хороший повод разобрать применение и ключей при сортировки и лямбд. Если нет понимания, как работает инструмент, что остается? «Копипастить» со stackoverflow без возможности гибко использовать мощь стандартного инструмента языка. Если Вам, как и мне когда-то, сложно понять, что написано, давайте нарисуем!

Предположим у нас есть список с именами изображений:

>>> list_img = ['img_0', 'img_01', 'img_2', 'img_11', 'img_111', 'img_3']

Так как они все одного типа (str), возможность отсортировать сохраняется. Применим функцию sorted:

>>> list_img = sorted(list_img)
>>> list_img
	['img_0', 'img_01', 'img_11', 'img_111', 'img_2', 'img_3']

Возможно, Вы ожидали такого вывода, если нет — почитайте о сортировке строк. Если вы хотели бы, что бы img_2 был перед img_11, а img_111 был в самом конце, то строки сортируются по первому различному символу, например список:

>>> list_img = ['img_0', 'amg_01', 'img_2', 'img_11', 'img_111', 'img_3']

будет отсортирован следующим образом:

>>> list_img = sorted(list_img)
>>> list_img
	['amg_01', 'img_0', 'img_11', 'img_111', 'img_2', 'img_3']

Допустим. А как в таком случае получить ожидаемый результат сортировки по номеру в названии изображения? Надо просто применить ключ!

Вернемся к списку:

>>> list_img = ['img_0', 'img_01', 'img_2', 'img_11', 'img_111', 'img_3']

>>> list_img = sorted(list_img, key=number_in_image_title)
>>> list_img
	['img_0', 'img_01', 'img_2', 'img_3', 'img_11', 'img_111']

Выглядит логично и ожидаемо, например, с точки зрения порядка обработки этих изображений в будущем. Что в данном случае является ключом number_in_image_title (номер в названии изображения)? Это функция, которая принимает один аргумент — название изображения, обрабатывает его и возвращает номер, который содержится в названии. Например, такая функция:

>>> def number_in_image_title(image_title):
>>>    digit_str = ''
>>>    for character in image_title:
>>>        if character.isdigit():
>>>            digit_str += character
>>>    return int(digit_str)

Воспользуемся визуализацией процессов на сайте pythontutor.

Ссылка на данный код.

Когда мы дошли до строки с сортировкой списка:

Мы имеем два объекта в глобальной области: функцию number_in_image_title и список list_img.

Далее, функция sorted, вызывает объект, который указан в качестве аргумента параметра key - number_in_image_title, а в качестве аргумента для number_in_image_title использует первый элемент итерируемого списка (в данном случае „img_0“), переданного для сортировки (list_img).

Проходя дальше по циклу (3-5 строчка кода) мы проверяем, является ли каждый символ цифрой, и если является конкатенируем с пустой строкой digit_str.


Функция number_in_image_title возвращает нам цифру 0. Следующим шагом функция sorted вызовет number_in_image_title с аргументом «img_01», digit_str будет «01», а вернет функция 1:

И так далее, пока все элементы списка не будут обработаны функцией, указанной как аргумент параметра key. Что получается? Без ключа мы имеем список:

['img_0', 'img_01', 'img_2', 'img_11', 'img_111', 'img_3']

который сортируется, согласно правилам сортировки строк. С ключом мы имеем тот же список, но сортируем не его, а фактически мы сортируем возвращаемые значения от функции key:

[0, 1, 2, 11, 111, 3]

Новый список связан с оригинальным индексами позиций элементов.

['img_0', 		'img_01', 	 'img_2', 	'img_11', 	'img_111', 		'img_3'	  ]
    |             |             |          |        	|              |
[   0,            1,            2,         11,         111,            3      ]

Теперь, если мы должны поместить цифру 3 в позицию 3-его элемента при сортировке нового списка, то «img_3», соответственно, будет помещен в позицию 3-его элемента в конечном отсортированном списке. Аналогично и с остальными элементами оригинального и нового списка.

В итоге следующий код:

def number_in_image_title(image_title):
    digit_str = ''
    for character in image_title:
        if character.isdigit():
            digit_str += character
    return int(digit_str)

list_img = ['img_0', 'img_01', 'img_2', 'img_11', 'img_111', 'img_3']
list_img = sorted(list_img, key=number_in_image_title)
print(list_img)

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

['img_0', 'img_01', 'img_2', 'img_3', 'img_11', 'img_111']

Таким образом, мы можем отсортировать итерируемый объект, с использованием любой логики сортировки, используя ключ, которым является функция, принимающая один аргумент и этот аргумент — элемент итерируемого списка.

Хорошо, но где же тут лямбда функция? В Python лямбда функции — не именованные однострочные функции. Есть ли у Вас опыт решения многоэтапных задач в одну строчку? Если Вы не понимаете о чем речь, посетите эти страницы: Powerful Python One-Liners, One-lined Python. А теперь посмотрите на код функции number_in_image_title. Понимаете ли Вы как можно решить задачу определения числа/цифры в строке (например, «img_0») в одну строчку? Если да, тогда у Вас нет с этим проблем, просто оберните эту логику в лямбда. Если нет, то вот возможное решение:

>>> int(''.join([character for character in img_0 if character.isdigit()]))
out: 0

Функция сортировки с использованием лябда будет выглядеть следующим образом:

list_img = sorted(list_img, key=lambda image_title: int(''.join([character for 		character in image_title if character.isdigit()])))

Вместо 6 строк кода и 1 функции мы использовали 0 строк кода, 0 функций, если не считать лямбда, которую мы объявили и сразу применили в качестве аргумента для параметра key! Это мощно! Но возможно трудно читаемо, особенно для новичков.

Можете быть уверены, что и результат и процесс ничем не отличается от варианта с функцией number_in_image_title. А впрочем, не стоит верить на слово, вот код для этого варианта (плюс еще и с визуализацией!), посмотрите и проверьте сами.

Логика та же, только вместо названия функции number_in_image_title у нас lambda, с тем же параметром image_title, принимающая в качестве аргумента элемент итерируемого списка list_img. join — метод строки, объединяет все цифры из конкретного элемента list_img, который передан в lambda как image_title. Цифры мы получаем как результат работы генератора списков (List Comprehension). Ну вот и всё! Практикуйтесь, читайте чужой код, не бойтесь нового и пытайтесь понять смысл выражений.

Читай:

  1. Is it possible to write obfuscated one-liners in Python?