Структура данных — Циклический связный список
Циклический связанный список — это разновидность структуры связанного списка. Его ключевая особенность в том, что последний узел списка больше не указывает на NULL, а вместо этого указывает обратно на первый узел, так что весь связанный список логически образует кольцевую структуру.
Структурные особенности циклического связанного списка
Односвязный циклический список
Двусвязный циклический список
Обычный связный список: next в конце = NULL, условие завершения обхода — cur == NULL
Циклический связный список: next в конце = head, условие завершения обхода — cur == head (возврат к начальной точке)
Общие операции:Вставка головного узла O(n) — сначала нужно найти хвостовой узел | обход использует цикл do...while
Вставка хвостового узла O(1) (поддерживать указатель tail) | удаление аналогично
Циклические связные списки в основном делятся на два типа:
- Односвязный циклический список: каждый узел хранит только один указатель next, а next последнего узла указывает обратно на головной узел
- Двусвязный циклический список: на основе двусвязного списка next последнего узла указывает на головной узел, а prev головного узла указывает на последний узел
Главная особенность структуры циклического связного списка заключается в том, что она естественно поддерживает непрерывный обход, в котором конец соединяется обратно с началом.
Условие определения конца обхода — не "достигнут ли NULL", а "произошёл ли возврат к начальному узлу".
Основные операции
Пример
#include <stdlib.h>
struct Node {
int data;
struct Node* next;
};
/* Создать новый узел */
struct Node* createNode(int value) {
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
newNode->data = value;
newNode->next = newNode; /* Для одного узла next указывает на себя, образуя петлю */
return newNode;
}
Узел в голове
Сложность по времени: O(n) — сначала нужно найти хвостовой узел, чтобы обновить его указатель next */
struct Node* insertAtHead(struct Node* head, int value) {
struct Node* newNode = createNode(value);
if (head == NULL) {
return newNode; /* Пустой связный список, новый узел образует петлю */
}
/* Найти хвостовой узел (то есть узел, next которого указывает на head) */
struct Node* tail = head;
while (tail->next != head) {
tail = tail->next;
}
newNode->next = head; /* next нового узла указывает на исходный головной узел */
tail->next = newNode; /* next хвостового узла указывает на новый узел (новый головной узел) */
return newNode; /* Новый узел становится новым головным узлом */
}
/* Обход циклического связного списка (один цикл)
Начать с головного узла, остановиться при возврате к головному узлу снова */
void traverse(struct Node* head) {
if (head == NULL) return;
struct Node* cur = head;
printf("Циклический связный список: ");
do {
printf("%d -> ", cur->data);
cur = cur->next;
} while (cur != head); /* Остановиться при возврате к начальной точке */
printf("(назад к началу)\n");
}
int main() {
struct Node* head = NULL;
head = insertAtHead(head, 30);
head = insertAtHead(head, 20);
head = insertAtHead(head, 10);
traverse(head);
/* Вывод: Циклический связный список: 10 -> 20 -> 30 -> (назад к началу) */
return 0;
}
Ключ к обходу циклического связного списка заключается в использовании
do...whileоператора вместоwhile. Потому что при использованииwhile (cur != head), начальное состояние cur равно head, и цикл вообще не выполнится. Иdo...whileгарантирует, что тело цикла выполнится хотя бы один раз, а затем проверяет условие завершения.
Классическое применение: задача об Иосифе Флавии
Порядок выбывания и окончательный результат (n=7, m=3)
Задача об Иосифе Флавии (Josephus Problem)— это классическая математическая задача:
Предположим, n человек стоят по кругу. Счёт начинается с 1-го человека, и каждый m-й человек выбывает из круга. Затем счёт возобновляется со следующего человека, и это продолжается, пока не останется только последний человек.
Эта задача естественно соответствует структурным особенностям циклического связного списка: «соединён конец с началом, доступ осуществляется циклически».
Пример
#include <stdlib.h>
struct Node {
int data; /* Номер человека */
struct Node* next;
};
/* Решение задачи об Иосифе Флавии
Параметры: n — общее количество людей, m — каждый m-й по счёту человек выходит из круга
Используйте циклический связный список для моделирования всего процесса */
void josephus(int n, int m) {
if (n <= 0) return;
/* Шаг 1: Создать циклический связный список, содержащий n человек (пронумерованных 1~n) */
struct Node* head = (struct Node*)malloc(sizeof(struct Node));
head->data = 1;
head->next = head; /* Когда остаётся только один человек, он замыкается сам на себя */
struct Node* tail = head;
for (int i = 2; i <= n; i++) {
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
newNode->data = i;
newNode->next = head; /* У нового узла next всегда указывает на head */
tail->next = newNode; /* next предыдущего узла указывает на новый узел */
tail = newNode; /* Продвинуть tail */
}
/* Шаг 2: смоделировать процесс выбывания */
struct Node* cur = head;
struct Node* prev = tail; /* prev указывает на предшественника cur, изначально — на хвостовой узел */
printf("Кольцо Иосифа (n=%d, m=%d) порядок выбывания: ", n, m);
while (cur->next != cur) { /* Завершить, когда останется только один узел (цикл на себя) */
/* Подсчёт: отсчитать m-1 раз, одновременно продвигая prev и cur */
for (int count = 1; count < m; count++) {
prev = cur;
cur = cur->next;
}
/* Когда счётчик достигает m, cur выбывает */
printf("%d ", cur->data);
prev->next = cur->next; /* Пропустить cur */
free(cur); /* Освободить выбывший узел */
cur = prev->next; /* Перезапустить подсчёт со следующего человека */
}
printf("=> Последний выживший: %d\n", cur->data); /* Вывести последнего человека */
free(cur);
}
int main() {
josephus(7, 3);
/* Вывод: кольцо Иосифа (n=7, m=3), порядок выбывания: 3 6 2 7 5 1 => Последний выживший: 4 */
return 0;
}
Другие сценарии применения
| Сценарий | Описание |
|---|---|
| Карусельное планирование квантов времени в операционной системе | ЦП поочерёдно выделяет кванты времени каждому процессу. Когда процесс исчерпывает свой квант времени, наступает очередь следующего процесса, образуя цикл. |
| Многопользовательские игры, где игроки по очереди играют в карты | В настольных и карточных играх игроки действуют по очереди по часовой стрелке (или против часовой стрелки). |
| Кольцевой буфер | Кольцевой буфер для потоков аудио/видео данных; когда он заполнен, он возвращается к началу и перезаписывает старые данные. |