Название: Алгоритмическое мышление при решении задач (Шамшев А. Б.)

Жанр: Информационные системы и технологии

Просмотров: 1389


Задача 17. работа с односвязанным списком

Для начала дадим определение односвязанного списка.

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

Исходя  из  этого  определения,  можно  понять,  что  в  элементе односвязанного списка можно объединить данное и указатель на следующую

вершину. При этом следующая вершина имеет такой же тип, что и текущая. Поэтому первый шаг в объявлении узла односвязанного списка будет следующий:

public class ListNode { public int data; public ListNode next;

}

Однако  по  логике  элемент  данных  не  может  не  содержать  данных.

Поэтому следующий шаг заключается во введении конструктора (в  список его параметров для удобства сразу добавим ссылку на следующий элемент).

public class ListNode { public int data; public ListNode next;

 

public ListNode(int _data, ListNode _next) {

data = _data;

next = _next;

}

}

Если ссылка next равна null, то это означает, что следующего элемента за

текущим элементом нет.

Для конкретики изобразим пример односвязанного списка.

 

При этом условимся, что             означает ссылку на null. Следует отметить, что формально этой связи нет (с точки зрения определения из теории графов), она изображается для удобства.

Из этого рисунка вытекает важное следствие: в последнем элементе односвязанного списка ссылка на next равна null.

Как правило, односвязанный список характеризуют через голову (head).

Отличительной особенностью головы  является то,  что  в  нее  нет  входящих связей. Поэтому детализируем рисунок.

 

Элемент односвязанного списка используется в односвязанном списке. Следует отметить, что в .NET существует класс List, поэтому наш класс, который реализует односвязанный список,  мы  назовем  SimpleList. Как  уже было сказано, односвязанный список характеризуется головой (следует отметить, что для односвязанного циклического списка это не так, но сейчас идет речь об ациклическом списке).

class SimpleList {

public ListNode head = null;

}

Рассмотрим процесс добавления элементов в список.

Вначале список пуст. Т. е. в нем нет данных. А раз нет данных, то и нет

элементов, в которых хранятся данные. head – один из элементов. А значит head

равно null (т. е. его нет).

Добавим элемент в список. Очевидно, что список примет вид:

 

Т. е. новый элемент станет головой. При этом ссылка на next в нем будет равна null. Это если список был пуст. А он был пуст, если head равен null. Тогда первый шаг в добавлении элемента списка будет:

class SimpleList {

public ListNode head = null;

 

public bool isEmpty() {

return (head == null);

}

 

public void add(int newData) {

if (isEmpty()) {

head = new ListNode(newData, null);

} else {

//логика добавления  в  не   пустой список

}

}

}

Теперь  добавим  число  4  в  список.  Следует  отметить,  что  его  можно добавить как минимум 2 способами:

1.  В голову

 

2.  В хвост

 

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

1.  Создается новый элемент

 

2.  У нового элемента ссылка на next ставится на голову

3.  Голова переносится на новый элемент

 

4.  После этого переменная temp нам не нужна

 

Следует отметить, что 1 и 2 шаг объединяются в кодах (т. к. при создании элемента списка указывается следующий элемент). И они будут материализованы в виде кода следующим образом:

ListNode temp = new ListNode(newData, head); После этого голова переставляется на новый элемент head = temp;

Таким  образом,  в  виде  кодов  реализация добавления в  голову  примет

следующий вид:

public void addToHead(int newData) {

if (isEmpty()) {

head = new ListNode(newData, null);

} else {

ListNode temp = new ListNode(newData, head);

head = temp;

}

}

Очевидно, что в  ветви else temp можно не использовать, а записывать значение сразу в head.

public void addToHead(int newData) {

if (isEmpty()) {

head = new ListNode(newData, null);

} else {

head = new ListNode(newData, head);

}

}

Так же очевидна верность следующей замены, потому что если список пуст, то head равно null, а значит null можно заменить на head:

public void addToHead(int newData) {

if (isEmpty()) {

head = new ListNode(newData, head);

} else {

head = new ListNode(newData, head);

}

}

И тогда очевидно, что в head записывается одинаковое значение в обоих ветвях if. Поэтому можно сократить объем исходного кода

public void addToHead(int newData) {

head = new ListNode(newData, head);

}

 

Теперь рассмотрим добавление в хвост. Для того, что бы лучше его понять,

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

И пусть односвязанный список имеет следующий вид:

 

И мы добавляем в хвост новый элемент со значением 4. После добавления этого значения список примет следующий вид:

 

Возникает  вопрос,  как  это  реализовать?  Для  того,  что  бы  добавить четверку после тройки (а вернее элемента со значением три), мы должны найти тройку. При этом head не должна меняться (и до и после добавления она указывает на единицу). А значит надо ввести новую переменную, для примера curNode.

Предположим,  что  мы  нашли  curNode  каким-то  образом.  Возникает вопрос, как добавить четверку после curNode. Для ответа на этот вопрос изобразим промежуточные рисунки:

1 шаг

 

2 шаг

 

Как видно на первом шаге создается новый узел temp, ссылка которого на

next равна null. Вторым шагом в curNode ссылка на next ставится на temp.

Поэтому в виде кодов реализация будет иметь вид:

public void addToTail(int newData) {

if (isEmpty()) {

head = new ListNode(newData, null);

} else {

ListNode curNode;

//логика  по   нахождению  curNode.

ListNode temp = new ListNode(newData, null);

curNode.next = temp;

}

}

Теперь возникает вопрос, как найти curNode. Как уже было сказано, до добавления curNode был последним элементом, а значит, ссылка на next в нем равна null.

Так же логично предположить, что поиск элемента curNode начинается с головы списка.

Сейчас  curNode  указывает  на  head.  И  поле  next  в  нем  не  равно  null. Поэтому надо перейти на следующий элемент, который содержится в curNode, т. е. curNode = curNode.next.

 

Рассуждения при рассмотрении текущего узла аналогичны предыдущим.

 

Рассуждения опять аналогичны.

 

Сейчас поле next в curNode равно null. А значит, это последний элемент.

Поэтому  в  виде  псевдокода  реализация  алгоритма  поиска  последнего элемента будет следующей:

 

Объявить переменную для текущего узла. Инициализировать переменную головой списка Пока поле next в текущей переменной не равно null НачалоЦикла

В переменную для текущего узла записать следующий узел за текущим

ОкончаниеЦикла

 

В виде кодов материализация алгоритма будет следующей:

public void addToTail(int newData) {

if (isEmpty()) {

head = new ListNode(newData, null);

} else {

ListNode curNode = head;

while (curNode.next != null) {

curNode = curNode.next;

}

ListNode temp = new ListNode(newData, null);

curNode.next = temp;

}

}

Цикл while можно заменить на цикл for c пустой инициализацией

public void addToTail(int newData) {

if (isEmpty()) {

head = new ListNode(newData, null);

} else {

ListNode curNode = head;

for (; curNode.next != null; curNode = curNode.next) ;

ListNode temp = new ListNode(newData, null);

curNode.next = temp;

}

}

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

будет следующим:

public void addToTail(int newData) {

if (isEmpty()) {

head = new ListNode(newData, null);

} else {

ListNode curNode = head;

for (; curNode.next != null; curNode = curNode.next);

curNode.next = new ListNode(newData, null);

}

}

При решении задачи добавления в хвост автоматом «решилась» задача просмотра всего списка. Мы его почти просмотрели, когда искали последний

элемент. Следует отметить, что во время просмотра списка он не должен меняться, и, как следствие, не должна меняться голова списка. Поэтому надо объявить переменную.

Если нам надо вывести все данные, которые находятся в списке, псевдокод алгоритма будет следующий:

 

Объявить переменную для просмотра списка (текущий узел)

Инициализировать текущий узел головой списка

Пока текущий элемент существует

НачалоЦикла

Вывести данное в текущем узле

Перейти на следующий элемент за текущим элементом.

ОкончаниеЦикла

В виде кодов материализация алгоритма будет следующая:

public void print() { ListNode curNode = head; while (curNode != null) {

Console.Write(curNode.data + " ");

curNode = curNode.next;

} Console.WriteLine();

}

Но ее можно сократить, используя особенности цикла for

public void print() {

for (ListNode curNode = head; curNode != null; Console.Write(curNode.data + " "), curNode =

curNode.next) ; Console.WriteLine();

}

 

Мы  рассмотрели  операции  добавления,  просмотра.  Из  «классических»

операций осталось только удаление элемента.

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

1.  Если  список  характеризуется  головой  списка,  то  удаление  можно рассматривать как  удаление  из  головы  списка  или  удаление  не  из

головы списка.

2.  Если удаление происходит из головы списка, то список меняется.

3.  При «переходе» на следующий узел в односвязанном списке нельзя

«вернуться назад» к предыдущему узлу.

Теперь рассмотрим само удаление. Пусть у нас есть список и происходит удаление единицы.

 

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

 

Т. е. голова просто переместилась на следующий элемент. Тогда первый шаг в решении будет

public void delFromHead(int delValue) {

if (head.data == delValue) {

head = head.next;

}

}

Однако мы написали head.data, значит мы должны гарантировать, что head

не равен null. Если голова равна null, то это означает, что список пуст. А значит удаление ни к чему не приведет и можно просто выйти из функции. Поэтому верное решение удаления из головы будет

public void delFromHead(int delValue) {

if (head == null) {

return;

}

if (head.data == delValue) {

head = head.next;

}

}

Теперь рассмотрим удаление не из головы. Для примера у нас есть список и мы удаляем значение 7.

 

 

 

вид:

Логично предположить, что после удаления список примет следующий

 

 

Из этого рисунка видно, что поле next у элемента списка со значением 2 изменилось. Для того, что бы лучше понять, как оно изменилось, рассмотрим промежуточные рисунки:

1 шаг

2 шаг

 

3 шаг

 

4 шаг

 

5 шаг

 

Возникает вопрос, как это реализовать? Что бы изменить поле next у элемента со значением 2, надо этот элемент найти. Но двойка – ни как не привязана  к  удаляемому  значению  в  общем  виде.  Поэтому  дадим  другое условие поиска – мы ищем элемент, который находится перед элементом, в котором содержится удаляемое значение.

Пусть мы нашли наш элемент (который находится перед удаляемым). Назовем его prevNode. Тогда очевидно, что в элементе, который после него (prevNode.next), находится удаляемое данное (prevNode.next.data == delValue). Как уже говорилось, это удаление не из головы. Поэтому prevNode всегда существует. Но  и  precNode.next должен существовать, что  бы prevNode.next.data имело смысл. Поэтому в виде псевдокода алгоритм поиска будет следующий:

 

Объявить переменную для предыдущего узла (prevNode)

Инициализировать ее головой.

Пока следующий элемент за prevNode существует

НачалоЦикла

Если в след. элементе после prevNode находится удаляемое данное, то

НачалоЕсли

Переставить next в prevNode на следующий узел после удаляемого.

Прервать цикл ОкончаниеЕсли Иначе НачалоИначе

Перейти на следующий элемент.

ОкончаниеИначе

ОкончаниеЦикла

 

Первый шаг реализации будет иметь следующий вид:

public void delNotFromHead(int delValue) { ListNode prevNode = head;

while (prevNode.next != null) {

if (prevNode.next.data == delValue) { ListNode delNode = prevNode.next; prevNode.next = delNode.next; return;

} else {

prevNode = prevNode.next;

}

}

}

Но его можно оптимизировать по сложности кода, инвертировав условие.

public void delNotFromHead(int delValue) { ListNode prevNode = head;

while (prevNode.next != null) {

if (prevNode.next.data != delValue) {

prevNode = prevNode.next;

continue;

}

ListNode delNode = prevNode.next;

prevNode.next = delNode.next;

return;

}

}

Следующим шагом будет отказ от использования delNode и добавление проверки на пустоту списка (на всякий случай) в начале функции.

public void delNotFromHead(int delValue) {

if (head == null) {

return;

}

ListNode prevNode = head;

while (prevNode.next != null) {

if (prevNode.next.data != delValue) {

prevNode = prevNode.next;

continue;

}

prevNode.next = prevNode.next.next;

return;

}

}

Удаление и из головы, и не из головы мы реализовали.

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

Для примера, пусть в списке три двойки и мы удаляем двойку. Очевидно,

что в начале список был

 

После этого он пройдет следующие шаги:

 

И после этого он станет пустым списком.

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

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

public void delByValue(int delValue) {

while (head != null) {

if (head.data == delValue) {

head = head.next;

}

}

}

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

public void delByValue(int delValue) {

while (head != null)

if (head.data == delValue) {

head = head.next;

} else break;

}

Если после удаления список стал пустым, то делать больше ничего не

надо.

public void delByValue(int delValue) {

while (head != null) {

if (head.data == delValue) {

head = head.next;

} else {

break;

}

}

if (head == null) {

return;

}

//логика удаления  не   из  головы списка

}

Логику удаления не из головы списка мы уже разобрали. Единственное, что надо помнить – что удаление элемента ведет к «приближению» остальных элементов. Поэтому, как и в массивах, в случае удаления элемента не надо перемещаться на следующий элемент. Поэтому в виде кода материализация будет следующая:

public void delByValue(int delValue) {

while (head != null) {

if (head.data == delValue) {

head = head.next;

} else break;

}

if (head == null) return; ListNode prevNode = head;

while (prevNode.next != null) {

if (prevNode.next.data != delValue) {

prevNode = prevNode.next;

continue;

}

prevNode.next = prevNode.next.next;

}

}