В качестве введения. Для чего эта статья?

Эта статья призвана резюмировать приобретенные знание полученные в процессе обучения программированию. Так вышло, что я попал на обучение на программиста, скажем так, "по-взрослому". Поэтому первым языком стал Си. Основы языка дались легко благодаря опыту работы с языками программирования PHP, JavaScript, C# и Python. Но позднее процесс обучения вышел на новый уровень, связанный со структурами данных. И начались мои страдания.

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

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

Условия реализации следующие. Все программы будут реализовываться для работы в ОС Ubuntu. Возможен их запуск в Unix-подобных системах. Другие специфические особенности будут мной указаны непосредственно в самой статье по конкретному решению.

Ну а теперь к делу. Реализация простого варианта списка:

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

В самом простом, элементарном варианте, наш список состоит из двух вещей: Первое - общая структура списка, и второе - структура элемента списка. Попробуем реализовать эту радость.

Создадим заголовочный файл: database.h:

#include <stdio.h> // стандартная библиотека Си
#include <string.h> // для работы со строками
#include <stdlib.h> // для работы с памятью

// структура элемента списка
typedef struct list_item {
    void *data; // по этому указателю мы храним какие-то данные
    struct list_item *next; // это у нас ссылка на следующий указатель
    struct list_item *prev; // это у нас ссылка на предыдущий указатель
} list_item;

// Общая структура списка
typedef struct list {
    int count; // информация о размере списка
    list_item *head; // это ссылка на головной элемент
    list_item *tail; // это у нас ссылка на последний элемент (хвост списка)
} list;

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

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

Попробуем реализовать все эти функции. В том же самом заголовочном файле "database.h" ниже под структурами обозначим прототипы функций.

list * db_create(); // создает список. возвращает список.

Перейдем теперь в файл "main.c" - это наш главный файл программы, который мы будем компилировать. Оформляем заготовок программы.

#include "database.h" // не забудем подключить наш заголовочный файл

int main(int argc, const char** argv) {
		// code
}

Поскольку прототип функции у нас уже есть в нашем заголовочном файле, ее реализацию мы можем написать ниже под функцией main.

list * db_create() {
  	// Создадим указатель на переменную структуры списка и выделим немного памяти для нее
    list *lst = (list*)malloc(sizeof(list));
    
    // задаем первоначальные значения
    lst->count = 0; // наш список пуст
    lst->head = NULL; // первого элемента у нас нет
    lst->tail = NULL; // и последнего тоже
    
    return lst;
}

Возвращаемся в функцию main, вызываем наш список.

int main(int argc, const char** argv) {
		list *database = create(); // список мы создали
}

Теперь мы немного с этим списком поработаем. А чтобы было с чем работать мы туда, для начала, попробуем добавить первые данные. Добавлять мы будем следующим образом, мы укажем некий индекс, который будет сигнализировать куда добавлять элемент в начало или конец, ну и сами данные в виде строки. Ну и также в нашу функцию мы будем передавать указатель на список, чтобы она понимала, куда ей это счастье наше добавлять.
В нашем заголовочном файле "database.h" ниже пишем новый прототип:

void db_insert(list *lst, int index, char *data);

В файле "main.c":

void db_insert(list *lst, int index, char *data) {
		// создадим указатель переменной элемента списка, 
		// и присвоим ему значение указателя на первый элемент списка
  	list_item *base = lst->head;
  
  	// создадим указатель переменной на новый элемент и выделим под него память
		list_item *new_item = (list_item*)malloc(sizeof(list_item));
  
  	// выделим память внутри самого элемента структуры куда принимаем данные,
  	// и получим указатель на него,
  	// strlen() нужен, чтобы выделенная память была равна длинне полученной строки.
  	new_item->data = malloc(sizeof(char) * strlen(data)); 
  	strcpy(new_item->data, data); // копируем туда данные
  	
  	// Пришла пора решить куда мы определим элемент,
  	// т.к. у нас еще нет элементов, lst->head вернет нам NULL.
  	// Следовательно нужно условие, при создании первого элемента списка.
  	if (base == NULL) {
      	// Этот элемент единственный, а значит его указатели будут NULL.
      	new_item->next = NULL;
        new_item->previous = NULL;
				
      	// При этом, он сам будет первым и последним в списке.
        lst->first = new_item;
        lst->last = new_item;
        lst->count++; // Увеличем кол-во на единицу
        return;
    }
  
  	// Если индекс, который пришел будет меньше нуля, то будем вставлять в конец
  	if (index < 0) {
    		// голова теперь будет ссылаться на новый элм. впереди себя
      	base->prev = new_item; 
        new_item->previous = NULL; 
        new_item->next = base; // а ссылка на след. элм. у нового будет на голову

        lst->head = new_item; // назначаем новый элемент головой
    } else { // тут все в обратном порядке
    		base = lst->tail; // перейдем в хвост списка
      	
      	// пусть он теперь ссылаеться на новый элемент
      	base->next = new_item;
      	new_item->next = NULL; // Новый не будет иметь ссылки на следующий
      	new_prev->prev = base; // А предыдущий у него будет хвост списка
      
      	lst->tail = new_item; // Назначаем новый элемент хвостом списка
    }
  lst->count++; // увеличим размер на единицу
}

На этом функция вставки готова, теперь вставим что-нибудь в наш список. В функции main пишем.

int main(int argc, const char** argv) {
		list *database = create(); // список мы создали
  	
  	insert(database, 0, "One");
  	insert(database, 1, "Two");
  	insert(database, -1, "Three");
}

Теперь мы хотим получить какой-нибудь элемент, допустим мы знаем его индекс в списке. Возвращать мы будем его значение.

char * db_read(list *lst, int index) {
  	list_item *base;	
  	int middle = lst->count / 2; // Вычисляем середину списка
  
    if (index > middle) { // если индекс больше середины
    		base = lst->tail;
      	for (int i = lst->count; i > index; i--)
        		base = base->prev;
    } else { // если индекс меньше середины
    		base = lst->head;
      	for (int i = 0; i < index; i++)
        		base = base->next;
    }
    
		// Если элемента нет
    if (base == NULL) {
        printf("\033[3;31mError! The list item was not found...\n\033[0m");
        return NULL;
    }
  	
    char *value = malloc(sizeof(char) * strlen(base->data)); // Выделяем память под строку
    strcpy(value, base->data); // копируем данные

    return value; // возвращаем полученное значение
}

Таким образом мы получим значение строки, которая храниться по определенному индексу. А что если мы не знаем индекс элемента, но знаем с каким значением он там записан. Надо выполнить его поиск, и получить значение его индекса в списке.

int db_search(list *lst, char *data) {
    int i = 0; // организуем счетчик
    list_item *base = lst->first; // перейдем к первому элементу
  	// воспользуемся функцией strcmp, чтобы сравнить перебираемые строки
    while (strcmp(base->data, data) != 0) {
        // пока строки не совпадут с тем что бы ищем, будем перебирать элементы
      	base = base->next; 
        i++;
    }
    return i; // получив совпадение просто вернем полученный индекс
}

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

list_item * get_element(list *lst, int index) {
  	list_item *base;	
  	int middle = lst->count / 2; // Вычисляем середину списка
  
    if (index > middle) { // если индекс больше середины
    		base = lst->tail;
      	for (int i = lst->count; i > index; i--)
        		base = base->prev;
    } else { // если индекс меньше середины
    		base = lst->head;
      	for (int i = 0; i < index; i++)
        		base = base->next;
    }
    
		// Если элемента нет
    if (base == NULL) {
        printf("\033[3;31mError! The list item was not found...\n\033[0m");
        return NULL;
    }
  	return base; // возвращаем элемент
}

Перепишем нашу функцию read:

char * db_read(list *lst, int index) {
  	list_item *base = get_element(lst, index);
  	if (base == NULL)
      	return NULL;
    
  	char *value = malloc(sizeof(char) * strlen(base->data)); // Выделяем память под строку
    strcpy(value, base->data); // копируем данные

    return value; // возвращаем полученное значение
}

То же самое с функцией delete:

void db_delete(list *lst, int index) {
    list_item *base = get_element(lst, index);
    list_item *prev, *next;
  
  	if (base == NULL)
      	return;
    
    prev = base->previous; // получение предыдущего элемента
    next = base->next; // мы получаем следующий элемент
  	
  	// переопределение указателя для предыдущего элемента на следующий
    if (prev != NULL)
        prev->next = base->next;
    
  	// И тоже самое для предыдущего элемента
    if (next != NULL)
        next->previous = base->previous; 

    free(base); // Освобождаем память 
    lst->count--; // уменьшаем длинну списка на единицу
}

Попробуем воспользоваться полученными функциями. Для этого перейдем к функции main

int main(int argc, const char** argv) {
		list *database = create(); // список мы создали
  	
  	insert(database, 0, "One"); // добавим несколько элементов
  	insert(database, 1, "Two");
  	insert(database, -1, "Three");
  
  	char *value = read(database, 1); // Получим One
  	printf("Value %s", value);
  	
  	int index = search(database, "One"); // получим 1
  	printf("Index: %d", index);
  
  	db_delete(database, 1); // удалит элемент One
  
  	// Пришла пора посмотреть, что там в нашем списке
  	db_print(database);
}

Нам теперь нужна функция, которая поможет нам вывести содержимое списка на экран. Делается это следующим образом:

void db_print(list *lst) {
    list_item *base = lst->first; // переходим к началу списка
    puts("\033[43m***Printing a list***\033[0m");
     
    if (lst->count == 0) { // если список пустой, так и говорим
        printf("The list is empty\n");
        return;
    }
  	
		int i = 0; // организуем счетчик
    while (base != NULL) { // Пока все элементы не кончаться мы будем их перебирать
        printf("ID: %d || Data: %s\n", i, (char*)base->data); // выводя на экран
        base = base->next;
        i++;
    }
  	// В конце покажем какой размер у нашего списка
    printf("Base size: %d\n", lst->count);
}

Такой вот простой способ организации двусвязного списка. Работает как положено, но у него есть один существенный минус. Что если элементов в списке будет не 3, ни 10, ни даже 1000, а будет их 1млн? Как показало испытание, генерация списка размером в 1млн заняло порядка 20 секунд. 10млн - 15 минут. И это только генерация, а поиск и чтение по такому огромному списку тоже не сильно быстрее. Пришлось несколько подумать над решением этой проблемы, и в следующей статье я объясню как ее решил.

Следующая статья: Динамические структуры данных на Си: Список - Продвинутая версия

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


  1. RekGRpth
    04.05.2022 18:11
    +1

    есть же стандартные списки на макросах https://man7.org/linux/man-pages/man7/queue.7.html


    1. dio4
      05.05.2022 09:11

      Я вам больше скажу что давно есть glib где есть все ))


  1. serg_meus
    04.05.2022 18:36
    +6

    Что? Вставка и удаление в список с поиском по элементам каждый раз? Вообще-то списки полезны тем, что можно всавлять и удалять элементы за константное время, а также перебрасывать элементы из одного списка в другой за константное время без аллокаций. У вас получился гибрид с корнями помидора и ботвой картофеля.


    1. HADGEHOGs
      05.05.2022 12:59

      Ну а как ты место вставки найдешь? У автора - вставка по индексу, что в принципе, бессмысленно. Автор как бэ задумывает упорядоченный индексный список, что бред само по себе.


      1. serg_meus
        05.05.2022 14:00

        Место вставки можно найти по указателю на элемент, после или перед которым нужно вставить. Авторский способ тоже имеет право на жизнь, он более безопасный (указатель может указывать на не пойми что, а индекс легко проверитъ), зато даёт доступ за O(N) вместо O(1).


        1. HADGEHOGs
          05.05.2022 15:04

          " Место вставки можно найти по указателю на элемент "

          Как найти указатель на элемент?


          1. serg_meus
            05.05.2022 16:04

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


  1. motoroller95
    04.05.2022 19:39

    Похоже на лабораторную студента, который не разобрался. В самом начале хорошая затравка на

    решение будет ориентированно и оптимизировано на работу с большим объемом данных

    а потом, как уже отметили выше, вставка/удаление за O(N), а так же в db_read вместо того чтобы просто отдать указатель на элемент его предварительно копируют в новый участок памяти. А что если мне изменить данные надо? read + delete + insert? окей, есть еще get_element но выглядит она как приватная. однако толку от нее 0, потому что все равно все работает через индексы. db_search ну отдайте вы указатель если нашли, но нет, сначала достаем индекс а потом то что уже выше писал.

    Следующая статья: Динамические структуры данных на Си: Список - Продвинутая версия

    вот бы базовую версию увидеть


    1. cyrox007 Автор
      04.05.2022 20:59
      -1

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


  1. HADGEHOGs
    04.05.2022 20:03

    Это уныло.

    Генерация списка - ну сгенерируйте 1000 элементов махом, выделив память под 1000*sizeof(TElement), потом пользуйтесь свободными. Ну и не генерят такие списки в продакшене, динамическая загрузка с диска.

    Поиск - ну создавайте отсортированный список вставками в нужное место. Нужное место ищите бисекцией (кластерный индекс, короче). Или создайте B+ дерево поиска в дополнении к списку, в листах которого будут указатели на элементы списка.

    Но все это уже есть.

    Есть, есть, есть.

    TList

    TMap


    1. alex103
      05.05.2022 06:14
      +1

      А что не уныло?? (очередной чатбот для телеграмы?)

      Ждём ваш список ( хм.. опять список???).. ))


      1. HADGEHOGs
        05.05.2022 13:34

        Ну, не уныло реализовать концепцию кластерного индекса, некластерного на b+ дереве, да еще со страницами данных на диски и их динамической загрузки и выгрузки.

        Потом hash и merge join -ы.

        Потом накопление статистики для выбора hash, nested или merge.

        Вот это годно...


  1. JordanCpp
    04.05.2022 23:40
    +1

    Пропущены функции append и prepend, Сложность O(N), это сама суть использования списка. Что бы бороться с фрагментацией и кеш мисом, выделять память блоком, после чего из данного блока конструировать елементы списка. Вы как раз юзаете двухсвязный список. Тема конечно не раскрыта. Как то коцано подано. Списки реализованы в glibc, там и сортировка есть. На примере можно было прокомментировать код. Как раз из таких маленьких кирпичиков, и состоят программы. Списки, хеш таблицы и деревья.