И снова здравствуйте!

На днях угораздило меня писать про алгоритмы. И писал я про них всяко и всякое. И то, что вертел я их на местах причинных, и то, что без них жизни своей программистской не представляю в случаях критических, моментах важных, выборах трудных. Ну и раз взял на себя оный труд, то ничто так не мотивирует на его продолжение, как положительные отзывы.

По этому решил я эту тему продолжать. Хотя, правды ради и прохождения полиграфа для, я бы всё равно не выдержал и написал бы что-нибудь про это ещё.

Сего дня я решил, что напишу про алгоритмы структур данных. Про те, которые смогу вспомнить. И, как говорит наш дорогой шеф: «Погнали!»

Мы так часто видим вместе слова «Алгоритмы» и «Структуры данных», что давно привыкли их «одноитожести» (изобретаю слова). И вот кажется нам, что одно без другого ну вот прям никак. Но мне кажется, что и по данному поводу надо бы высказать своё мнение, облечь тезисы в словеса, ибо мнение моё может быть тут «разойдётся с политикой партии».

Итак, откуда же такое отождествление одного с другим? Прям как партия и Ленин, ибо когда говорят об алгоритмах, то сразу же подразумевают структуры данных, а когда говорят о структурах данных, то тут же возникают алгоритмы.

И кажется мне, что эти понятия запутались между собой, как квантовые состояния (или что там путается вечно?). Но я предлагаю разобраться и вспомнить добрым словом и то, и другое (и без хлеба).

Нет, когда нас спрашивают отдельно о структурах данных, то мы, полагаю, можем как-то воспринимать их отделёнными от алгоритмов. Вот у нас массив, структура, список, словарь, таблица, хеш-таблица, дерево, что-то там ещё. И мы отделяем их от алгоритмов. Но в общем-то алгоритмы без структур данных сами по себе редко что дают. Но сами структуры данных часто под своим капотом имеют те самые алгоритмы, о которых я читал в книжке «Паскаль в иллюстрациях», упомянутой в предыдущей публикации. Так давайте же отделим алгоритмы вообще от алгоритмов внутри структур данных и пробежимся по этим структурам с точки зрения этих алгоритмов.

Структуры данных

Программисты навыдумывали за свою недолгую эру столько разнообразных структур, что наверное про все я тут не расскажу. Но постараюсь пройти по самым полезным. Предыдущая статья говорила о том, что сами по себе алгоритмы — это такое, не особо кому нужное. Да, хорошо, если кто разбирается или думать ещё не разучился, но и без этого наивным способом перебора элементов можно решить большинство задач за некое O(N). Но содержимое каких структур программа должна перебирать — программист знать ну хоть мало-мальски, но должен. Надеюсь, что против такого тезиса никто не возразит. Ну а если возразит — на то он и Хабр, чтобы возражать.

Ну а начнём давайте с эффективности алгоритмов вообще, чтобы потом указать эту эффективность для каждой из рассмотренных структур. Ну чтобы на одном языке говорит.

Я тут выше уже привёл некое O(N). Так вот «O» здесь — это «эффективность», а N — это количество итераций для вычисления результата. Эффективность распространяется так же и на количество используемой памяти, поэтому алгоритм может быть очень хорош в плане итераций, но очень плох в плане памяти — все это мы тоже рассмотрим.

МАССИВЫ

Итак, начнём с массивов. Примем за массив последовательный список элементов. В общем и целом массив — это последовательная область памяти, содержащая в себе или однородные значения, или их адреса. Доступ к элементу массива осуществляется по индексу, и эффективность этого доступа равна O(1). Т.е. программа в ходе обращения к элементу массива просто вычисляет его положение относительно адреса начала, умножая размер элемента (или его адреса) на индекс. В массив, если память за ним свободна, можно легко добавить элемент — это тоже занимает одну итерацию O(1). А вот вставка элемента в середину вынуждает сдвигать все остальные элементы вверх, поэтому эффективность такой вставки от условного O(2) — предпоследний элемент, до O(N) — если мы вдруг захотели вставить в самое начало. Время поиска значения среди значений массива уже стремится к O(N), особенно если такого значения там нет. В массиве легко менять элементы местами, поэтому его несложно отсортировать огромным количеством разнообразных алгоритмов — от детского собирания пирамидки и до всех этих «сортировок слиянием», которые, предположу, реализованы в разнообразных библиотеках для разнообразных языков. В 1с, например, нет метода сортировки массива, поэтому бравые 1С-неги загружают массив в список значений, который можно упорядочить, а потом выгружают назад. Никому в голову не приходят все эти мысли о том, чтобы написать свою сортировку. И ведь правильно же, да?

В общем, массив — это просто, как дважды два. Поэтому я его применяю весьма нечасто. А как у вас обстоит с этим дела? Расскажите в комментах.

СТРУКТУРЫ

После массива у меня в порядке увеличения сложности идёт структура. Они есть во многих языках. По сути, структура — это совокупность именованных элементов. Структуру можно представить, как строку базы данных, в каждой колонке которой содержатся свои типы (строки, числа, даты, ну и все прочее). Структура — это в общем-то «закрытая» штука, но в той же 1С в неё можно повставлять ключи со значениями и даже без них. Здесь структура превращается в этакое хранилище «Ключ»:«Значение». И если в низкоуровневых языках обращение к полям структуры скомпилировано с эффективность O(1), то в высокоуровневых языках, предполагающих изменение набора данных, все не так однозначно. В итоге структура там реализуется скорее всего как связанный список, о котором мы и поговорим.

СПИСКИ

Список — это, в моем понимании, некий связанный набор данных, где каждый элемент указывает на то, где находится следующий. И если в массиве к элементу можно обратиться по индексу, то в структуре нельзя — можно обратиться только к следующему элементу, который будет содержать адрес ещё более следующего, ну и так далее. Часто элементы списка имеют не только ссылку на следующий элемент, но и на предыдущий. Помимо прочего, списки могут всегда находиться в упорядоченном состоянии — это достигается за счёт того, что вставка между любыми двумя его элементами весьма эффективна. Фактически создаётся новый элемент списка, в нем указываются адреса следующего и предыдущего элемента, а у самих этих элементов меняется адрес следующего или предыдущего элемента. При том вставка в обычный список в принципе имеет эффективность O(1), т. к. этот элемент привязывается к последнему (ну или первому — разницы нет). А вставка в упорядоченный список имеет эффективность O(Log2N). Подобная эффективность объясняется тем самым алгоритмом двоичного поиска места вставки, о котором я вещал в прошлой статье.

Кстати, сишная std::map — это такой вот связанный список. Можете сами прикинуть эффективность доступа к N-ному элементу или эффективность поиска значения в такой структуре данных.

ХЕШ-ТАБЛИЦЫ

Хеш-таблицы — тоже очень интересная структура данных, рассказ о которой может быть поможет вам взглянуть на эту штуку по новому. В Си для такой структуры данных есть std::unordered_map, в 1С такой структурой несомненно является Соответствие. Есть подобное и в питоне — словарь. Про другие языки особо не интересовался, но в годы моей юности ни в паскале, ни в бейсике, ни тем более в ассемблере ничего подобного не было.

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

Состоит такая таблица из записей, которые расположены в памяти практически так, как и массивы — последовательно. А вот индекс массива получается с помощью вычисления хеш-функции от ключа. И чем больше бит возвращает хеш-функция, тем больше памяти кушает хеш-таблица. Чувствуете погружение в детали? Так вот, если мы захотим использовать в качестве хеш-функции какой-нибудь MD5, то нам нужно будет где-то разместить 2^128 адресов даже для одного элемента, зато доступ к этому элементу будет иметь эффективность условно O(1). И понятно, что языки программирования режут хеш-функцию до приемлемого размера, создавая 2^8/12/16/24 элементов, меняя разрядность в ходе роста количества коллизий. Кстати, давайте о них (коллизиях) тоже поговорим.

КОЛЛИЗИИ В ХЕШ-ТАБЛИЦАХ

Коллизии — это когда хеш-функция для двух разных значений вернула один и тот же индекс. Знаете, как оценить предельный размер коллизий? Там все просто. Если у нас есть хеш-функция, результат которой занимает N бит, то количество коллизий для ключей, длина которых M бит, равна 2^(M-N). Для того, чтобы оживить пример, возьмём MD5, которая возвращает 128-битный результат. 128 бит — это 16 байт. Допустим, у нас есть много файлов длиной в 20 байт. И если взять все на свете варианты этих 20-ти байт, то на каждую хеш-сумму найдётся 2^(160-128=32) коллизий. Т.е. 4 миллиарда. Предположу, что вам не нужны все варианты 20-байтных файлов — у нас и своих файлов хватает разной длины. Но имейте ввиду, что среди файлов на вашем компьютере вполне могут обнаружится такие, у которых MD5 одинаковый.

Итак, когда у нас хеш-функция порождает коллизию, т. е. оказывается, что в ячейке с таким хешем уже что-то лежит, то по адресу лежащего данные могут быть преобразованы в массив, список и в чего угодно ещё. При третьей коллизии в этот массив или список просто будет добавлена ещё одна запись. Ну и понятно, что ключи тоже где-то должны храниться, чтобы при поиске нужного ключа было с чем сравнить.

Ну а после того, как хеш-функция будет признана вырожденной (после преодоления какого-нибудь порога коллизий), должно произойти перестроение хеш-таблицы с новой хеш-функцией большей разрядности. Поэтому вставка в такую таблицу далеко не всегда происходит с эффективностью O(1), а при стремящейся к вырождению хеш-функции и эффективность чтения из этой структуры данных уходит от O(1). Но если хеш-функция подобрана хорошо, обеспечивая при обрабатываемом наборе данных минимальное количество коллизий, эффективность работы данной структуры великолепна.

ЗАКЛЮЧЕНИЕ

Да, я опять не привёл никакого кода ни на каких ассемблерах, сях, питонах и даже 1С-ах. Но что поделать — я существо ленивое, как и все живое. Может быть ещё накопятся ягоды в ягодицах, и я разрожусь более технической статьёй. Но надеюсь, что и словесно-цифровое описание структур данных прольёт чуть-чуть света на то, как ими пользоваться, немного понимая в алгоритмах, которые внутри них вертятся. Надеюсь, что это все я писал не зря.

Подписывайтесь на мой канал, ставьте лайк (с)

Комментарии (18)


  1. Dolios
    20.12.2023 21:49

    «Алгоритмы» vs «Структуры данных»

    Молоток vs как забивать гвоздь ))

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

    Что такое "список" и что значит "последовательный"?

    Так вот «O» здесь — это «эффективность», а N — это количество итераций для вычисления результата.

    Эээ, нет.

    По сути, структура — это совокупность именованных элементов.

    Файловая система, это совокупность именованных элементов, потому что файл, это именованная область данных на диске. Файловая система, это структура? Кстати, что значит "именнованных"? Словарь, это совокупность именнованных элементов или нет?

    И если в массиве к элементу можно обратиться по индексу, то в структуре нельзя

    Конечно, ведь структуру вы описали выше, а тут про списки )

    А вставка в упорядоченный список имеет эффективность O(Log2N). Подобная эффективность объясняется тем самым алгоритмом двоичного поиска места вставки

    С этого места поподробнее. 2 вопроса:

    1. Зачем вы указываете основание логарифма, какое значение оно имеет в теории сложности?

    2. Как вы на односвязном списке (вы про них всю дорогу пишите, вроде) сделаете вставку за логарифм?


    1. Alexandroppolus
      20.12.2023 21:49

      Как вы на односвязном списке (вы про них всю дорогу пишите, вроде) сделаете вставку за логарифм?

      В авторской классификации там, неожиданно для всех, оказался std::map


      1. starik-2005 Автор
        20.12.2023 21:49

        Даже интересно стало, что такое по-вашему std::map...


        1. dopusteam
          20.12.2023 21:49

          А вы расскажите, пожалуйста, как за логарифм вставку в односвязном списке сделать


          1. starik-2005 Автор
            20.12.2023 21:49

            Так я ведь спрашиваю потому, что не совсем, видимо, понял, а не потому, что считаю Вас неправым.


        1. Alexandroppolus
          20.12.2023 21:49

          КЧ-дерево. То есть "связное", но не "список". Там даже есть куча дополнительного кода, чтобы оно не было списком.


      1. idkisl
        20.12.2023 21:49

        Без ухода в конкретику: это бинарное дерево поиска.

        Понятное дело, что хорошее дерево.


  1. nronnie
    20.12.2023 21:49

    Сего дня я решил, что напишу про алгоритмы структур данных.

    Лучше бы вы этого не делали.


    1. ViktorAbba
      20.12.2023 21:49

      Кровь из глаз, когда вижу орфографические ошибки и описки( Это неуважение к читателям. неужели трудно проверить статью на правописание перед публикацией?(


  1. letchik
    20.12.2023 21:49

    Спасибо, вспомнились студенческие годы. Но, пожалуйста, если вы посягнули на краткое изложение Кнута, то хотя бы не путайтесь в терминологии.

    • Big O - это не эффективность алгоритма, а лишь оценка эффективности. Пусть это и небольшое изменение, но значительно меняет смысл. Она не бывает О(2); может быть лишь O(1), O(N). O(Log2N) и т. д.

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

    • Списки - они не связанные, с связные; их не связали, а в них есть связи. И std::map, судя по документации использует в реализации красно-черные деревья, а по сути это ассоциативный массив, но никак не список. Вот std::list - это список.

    • Не нужно для Хэш-таблицы создавать все ключи сразу и бессмысленно резервировать память. Это всё делается по мере необходимости.


    1. starik-2005 Автор
      20.12.2023 21:49

      А расскажите чем список от набора отличается? Даже вот интересно прям стало.


      1. letchik
        20.12.2023 21:49

        Вы говорите, что массив это последовательный список. Список это самостоятельная структура данных. Если в статье о структурах данных кто-то говорит о списке, подразумевается связный список, а не некая абстрактная кучка значений. Это устоявшаяся терминология и в данном контексте вы используете её не верно. Вместо слова "набор" можете использовать любое понравившееся вам слово, хоть кучка, хоть пачка, но не стоит употреблять слова, которые могут быть ассоциированы с другими структурами данных.
        Если эти элементарные вещи вам не понятны, то, не стоит писать статьи на такие темы.


  1. kipar
    20.12.2023 21:49

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

    • смешались в кучу списки и деревья. В упорядоченный список нельзя вставить за логарифм и бинарный поиск не поможет (ведь нельзя проскочить сразу полсписка, придется проходить все элементы по одному), но можно в дерево. std::map - дерево. А у деревьев есть не совсем тривиальные алгоритмы балансировки, чтобы дерево не превратилось ну собственно в список. Да и в целом деревья - свой мир с кучей вариаций для разных задач. Среди моих любимых https://ru.wikipedia.org/wiki/Куча_(структура_данных) и https://ru.wikipedia.org/wiki/Система_непересекающихся_множеств

    • описаны, причем не совсем правильно, только хеш-таблицы с так называемой "закрытой адресацией". А есть еще вариант (и он, возможно, даже более распостранен из-за дружелюбности к кешу) хеш-таблиц с "открытой адресацией" - там когда ячейка уже занята мы не создаем список, а ищем другую свободную ячейку (в простейшем случае - пихаем в ближайшую пустую из соседних справа, но есть и более хитрые алгоритмы).

    В качестве утешения могу сказать что в этом вопросе многое зависит от места работы. Кто-то может всю жизнь делать формочки и никогда ни про какие списки не узнать. Да что там кто-то - я в целом интересуюсь такими штуками да и на работе задачи разнообразные, но, скажем, в программировании микроконтроллеров мне даже сортировка пригодилась ровно один раз в жизни (сортировать интервалы для pwm чтоб светить несколькими диодами с помощью одного таймера). Про что-то более сложное и говорить смысла нет.

    Ну и да, если хотите все-таки на практике познакомиться с алгоритмами@структурами данных, то рекомендую https://adventofcode.com/ (он сейчас как раз идет, но прошлые годы тоже проходить можно). Там есть некоторые перекосы, но в целом мне нравится то как там проверяется правильность решения. Никто не придирается к коду и не спрашивает вопросы про О большое, просто объем входных данных подобран так чтобы решение "в лоб" считалось часы или дни (в лучшем случае), а решение с хорошим алгоритмом работало за секунды даже на медленном скриптовом языке. Соответственно запускаешь наивный подход, тесты вроде проходит но на основных данных видишь что результата не дождаться, начинаешь думать.


    1. nronnie
      20.12.2023 21:49

      описаны, причем не совсем правильно, только хеш-таблицы с так называемой "закрытой адресацией". А есть еще вариант (и он, возможно, даже более распространён из-за дружелюбности к кешу) хеш-таблиц с "открытой адресацией" - там когда ячейка уже занята мы не создаем список, а ищем другую свободную ячейку (в простейшем случае - пихаем в ближайшую пустую из соседних справа, но есть и более хитрые алгоритмы).

      Всё правильно, только вы в терминологии перепутали местами.

      Ахо, Хопкрофт, Ульман 'Структуры данных и алгоритмы'

      При закрытом хешировании в таблице сегментов хранятся непосредственно элементы словаря, а не заголовки списков. Поэтому в каждом сегменте может храниться только один элемент словаря. При закрытом хешировании применяется методика повторного хеширования. Если мы попытаемся поместить элемент х в сегмент с номером h(x), который уже занят другим элементом (такая ситуация называется коллизией), то в соответствии с методикой повторного хеширования выбирается последовательность других номеров сегментов h1(x), h2(x), ..., куда можно поместить элемент х. Каждое из этих местоположений последовательно проверяется, пока не будет найдено свободное.

      Если уж товарищ решил копнуть тему, то следовало бы для начала дать человеческое определение O(n) а про структуры данных начинать с "абстрактных типов данных" (АТД / ADT).


      1. kipar
        20.12.2023 21:49

        Я уж подумал что ошибиться в таком комментарии было бы сиволично но нет, не перепутал.

        https://en.wikipedia.org/wiki/Open_addressing

        Open addressing, or closed hashing

        адресация открытая, а хеширование закрытое.

        А насчет определений и ADT - я так понимаю автор статьи практик. Все эти понятия ему не особо важны, он (и не только он) и без ADT много лет программирует. И хотя подходы могут быть разные и не все одинаково эффективны, но такой подход от практики (когда ты пишешь себе спокойно на условном 1С условные отчеты, а потом тебе не говорят что надо для начала прочесть все тома кнута, а наглядно показывают как на твоей задаче можно сделать эффективнее. Дальше ты если заинтересуешься то и до определений дойдешь) тоже имеет право на существование.


        1. nronnie
          20.12.2023 21:49

          А, понятно. У Ахо, просто, термин "открытая адресация" для этой структуры не используется, и это ввело меня в заблуждение.

          Что такого rocket-science в ADT? Если бы автор понимал, что, например, "массив" или "список" определяются в первую очередь не тем как они в памяти или коде представлены, а тем какие операции с ними можно выполнять (что, собственно, и есть ADT), то facepalm-а типа "некий связанный набор данных, где каждый элемент указывает на то, где находится следующий" в статье было бы по крайней мере меньше :)


          1. SwetlanaF
            20.12.2023 21:49

            Здравствуйте. "Паскаль в иллюстрациях" - моя первая книга по программированию. Чудесная книга! Потом у меня ее какой-то студент из Африки зачитал. Спасибо, что напомнили о ней.

            Теперь хотелось бы раз'яснить связь между алгоритмами и структурами данных. От выбора структуры данных зависит все: и выбор модели, и, конечно, выбор и эффективность алгоритма. Например, т.н. "транспортные задачи". В матричном представлении решаются линейным программированием, в сетевом - потоковыми алгоритмами.

            P.S О(f(n)) <= C*f(n), где C - положительная константа.


  1. zaiats_2k
    20.12.2023 21:49

    А вставка в упорядоченный список имеет эффективность O(Log2N). Подобная эффективность объясняется тем самым алгоритмом двоичного поиска места вставки, о котором я вещал в прошлой статье.

    Как осуществить двоичный поиск без доступа к произвольному элементу?