Разработка

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

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

print "$_\n" for отсортированный список;

Здесь используется цикл для перебора элементов массива, однако не указано, в какую переменную по очереди отправляются элементы массива. В этом случае используется специальная переменная $_.

Запрограммируем процедуру stupidSort, ответственную за глупую сортировку. Она получает список чисел как параметр и возвращает его же, но в отсортированном виде. В процедуре предусмотрим два цикла. Внутренний нужен для поиска очередной инверсии и её устранения, а внешний повторяет эту операцию.

Мы видим, что условие выполнения внешнего цикла вычисляется по итогам работы внутреннего. Поэтому определим логическую флаговую переменную $flag и присвоим ей истинное значение перед входом во внешний цикл, сделав её условием цикла:

sub stupidSort
{
	my $flag=1;
	while($flag)
	{
		
	}
	return @_;
}

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

		for(my $i=0; $i<@_-1; $i++)
		{
			if($_[$i]>$_[$i+1])
			{
				@_[$i, $i+1]=@_[$i+1, $i];
				last;
			}
		}
		$flag=0;

Процедура shakerSort, осуществляющая шейкерную сортировку, будет отличаться от bubbleEnhancedSort наличием ещё одного внутреннего цикла для обратного прохода:

sub shakerSort
{
	my $left=0;
	my $right=$#_;

	while($left<$right)
	{
		my $lastSwap;

		for(my $i=$lastSwap=$left; $i<$right; $i++)
		{
			if($_[$i]>$_[$i+1])
			{
				@_[$i, $i+1]=@_[$i+1, $i];
				$lastSwap=$i;
			}
		}
		$right=$lastSwap;

		for(my $i=$lastSwap=$right; $i>$left; $i--)
		{
			if($_[$i]<$_[$i-1])
			{
				@_[$i, $i-1]=@_[$i-1, $i];
				$lastSwap=$i;
			}
		}
		$left=$lastSwap;
	}
	return @_;
}

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

В теле процедуры insertionSort содержится цикл, в котором в переменной $i перебираются индексы начала неотсортированной части массива. Внутри этого цикла располагается другой, предназначенный для поиска индекса места вставки $i-го элемента. Переменная $j, меняющаяся от 0 до $i-1 по завершении внутреннего цикла как раз укажет найденное место. Как только выполнится условие $_[$j]>$_[$i], следует вставить $i-й элемент на $j-е место, сдвинув вправо элементы с индексами от $j до $i-1, после чего внутренний цикл сразу прерывается. Завершается сортировочная процедура, как обычно, возвратом уже отсортированного массива @_:

sub insertionSort
{
	for my $i(0..$#_)
	{
		for my $j(0..$i-1)
		{
			if($_[$j]>$_[$i])
			{
				 вставить $i-й элемент в позицию $j
				last;
			}
		}
	}
	return @_;
}

Вставку можно запрограммировать с использованием списочного присваивания:

Perl
@_[$j..$i]=@_[$i, $j..$i-1];

Однако мы подозреваем, что этот изящный код может потребовать неоправданных затрат памяти для построения сначала списка индексов $i, $j..$i-1, а затем и списка элементов @_[$i, $j..$i-1]. Имеется другая возможность, не требующая никакой дополнительной памяти. Чтобы перенести i-й элемент на j-е место ( j < i ), нужно выполнить цепочку соседних обменов элементов с индексами i и i 1 , i 1 и i 2 , …, j + 1 и j. Для цепочки обменов потребуется ещё один цикл:

Perl
while($j<$i) { @_[$i, $i-1]=@_[$i-1, $i]; $i--; }

Тело процедуры selectionSort содержит два цикла, вложенных один в другой. Во внешнем цикле в переменной $i перебираются индексы начала неотсортированного участка, а во внутреннем в переменной $j осуществляется поиск индекса $k минимального элемента, который должен быть перемещён в начало этого участка. Как только минимальный элемент найден, он меняется местами с первым в неотсортированной части (а он, напомним, имеет индекс $i):

sub selectionSort
{
	for my $i(0..$#_-1)
	{
		my $k=$i;
		for my $j($i+1..$#_)
		{
			$k=$j if $_[$k]>$_[$j];
		}
		@_[$i, $k]=@_[$k, $i];
	}
	return @_;
}

Для реализации древесного алгоритма нам понадобится определить, помимо основной процедуры treeSort, ещё две вспомогательные процедуры: treeAddElement для добавления элементов в упорядоченное бинарное дерево и treeToArray для превращения дерева в массив.

Процедура treeSort получает список, подлежащий сортировке, как параметр, и возвращает его же после сортировки. В её теле создаётся пустое дерево в переменной $tree, которое затем заполняется элементами при многократных вызовах treeAddElement. После построения дерева уже упорядоченный список вычисляется во время вызова treeToArray, и тут же возвращается:

sub treeSort
{
	my $tree=[];
	treeAddElement($tree, $_) for @_;
	return treeToArray($tree);
}

Единственным параметром процедуры treeToArray будет ссылка на массив, моделирующий дерево. Пустое дерево, как мы договорились, представляется пустым массивом; в этом случае возвращается пустой массив. В остальных случаях возвращается массив, заполненный сначала элементами левого поддерева, корнем дерева, а затем — правого. Для получения списков элементов поддеревьев применяются рекурсивные вызовы той же процедуры:

sub treeToArray
{
	my $tree=shift;
	return () unless @$tree;
	return (treeToArray($tree->[1]), $tree->[0], treeToArray($tree->[2]));
}

Процедура treeAddElement получает два параметра — дерево и новый элемент. Дальнейшие действия зависят от того, пусто ли дерево. Для непустого дерева добавляемый элемент передаётся либо в левое, либо в правое поддерево, в зависимости от результатов сравнения с корнем. Выражение $n<$tree->[0]? 1: 2 принимает значение 1 или 2. Оно служит для выбора индекса в массиве, расположенного по ссылке $tree. Элемент добавляется в выбранное поддерево посредством рекурсивного вызова. При добавлении элемента в пустое дерево он становится корнем, и появляются поддеревья, оба пустые:

sub treeAddElement
{
	my $tree=shift;
	my $n=shift;
	if(@$tree)
	{
		treeAddElement($tree->[$n<$tree->[0]? 1: 2], $n);
	}
	else
	{
		@$tree=($n, [], []);
	}
}

Реализация рекурсивной процедуры (назовём её recursiveSort) довольно проста.

sub recursiveSort
{
	return () unless @_;
	my $key=shift;
	 сформировать локальные «левый» и «правый» массивы @left и @right
	return (recursiveSort(@left), $key, recursiveSort(@right));
}

В массивы @left и @right будут отфильтрованы те элементы массива @_, которые соответственно строго меньше и больше или равны ключевому элементу, изъятому в переменную $key.

В Perl есть удобная встроенная процедура grep, которая осуществляет фильтрацию массивов. Она отбирает из заданного массива только те элементы, которые удовлетворяют определённому условию. Список найденных элементов возвращается. Условие отбора — процедура-селектор: она должна возвратить истинное или ложное значение для заданного в переменной $_ значения. Селектор вызывается для каждого элемента фильтруемого массива (перед вызовом элемент копируется в $_), и, если он возвратит истину, элемент проходит через фильтр:

@filteredList=grep { тело селектора } @list;

Вот пример использования grep для отбора положительных элементов числового массива:

@positiveNumbers=grep { $_>0 } @numbers;

Это альтернатива более громоздкому коду:

for(@numbers)
{
	push(@positiveNumbers, $_) if $_>0;
}

Мы могли бы закодировать фрагмент следующим элегантным образом:

Perl
my @left=grep { $_<$key } @_; my @right=grep { $_>=$key } @_;

Однако воздержимся от этого элегантного решения в пользу более эффективного. Очевидно, эти два вызова grep приведут к двукратному перебору элементов массива @_. Но зачем перебирать дважды, когда можно разобрать элементы @_ в правый и левый массивы за один проход:

Perl
my @left; my @right; for(@_) { if($_<$key) { push @left, $_; } else { push @right, $_; } }

Как мы уже писали, в Perl имеется встроенная процедура sort, предназначенная для сортировки списков:

@sortedList=sort @list;

В этом случае подразумевается лексикографическая сортировка.

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

Однако возможно использование этой процедуры для сортировки, основанной на других способах сравнения элементов списка. Для этого программист должен определить процедуру, которая возвращает -1, 0 или 1 в зависимости от того, является ли первый из сравниваемых элементов меньшим, равным или большим, чем второй. Естественно, следует позаботиться, чтобы понятия «больше» и «меньше», заложенные в процедуре сравнения, были бы взаимно-обратными, а понятие «равно» — симметричным. Такая процедура называется компаратором (от англ. compare — сравнивать).

Вот как следует использовать компаратор:

@sortedList=sort { тело компаратора } @list;

Процедура sort помещает две величины в переменные $a и $b каждый раз, когда их нужно сравнить, а затем вызывает процедуру-компаратор. Из этого и нужно исходить, определяя такую процедуру. Приводим пример сортировки с арифметическим компаратором:

@sortedList=sort
	{
		if($a<$b)		{ return -1; }
		elsif($a==$b)	{ return 0; }
		else			{ return 1; }
	}
	@list;

Или чуть короче:

@sortedList=sort
	{
		return -1 if $a<$b;
		return 0 if $a==$b;
		return 1;
	}
	@list;

Или даже так:

@sortedList=sort { return $a<$b? -1: ($a==$b? 0: 1); } @list;

Или вообще вот так:

@sortedList=sort { return $a<=>$b; } @list;

Или, наконец, так:

@sortedList=sort { $a<=>$b } @list;

Встроенный оператор <=> как раз вычисляет результат арифметического сравнения двух числовых величин. В самом последнем примере return опущен, поскольку и без него процедура возвратит последнее вычисленное выражение. Точка с запятой, завершающая блок, необязательна в языке Perl, и этот тот редкий случай, когда мы разрешим себе эту маленькую вольность — опустим её.

Для сортировки в обратном порядке поменяем в компараторе переменные $a и $b местами:

@sortedList=sort { $b<=>$a } @list;

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

@sortedList=sort { (length $a)<=>(length $b) } @list;

В Perl есть оператор-компаратор и для лексикографического сравнения строк: cmp. Поэтому команды

@sortedList=sort @list;

и

@sortedList=sort { $a cmp $b } @list;

равносильны.

Для реализации инверсной сортировки сравнению в компараторе подлежат не сами строки $a и $b, а результаты их инвертирования — смены порядка символов на противоположный. Так, в результате инвертирования строки инвертирование получится строка еинаворитревни. Встроенная процедура reverse может сделать эту работу:

@sortedList=sort { (reverse $a) cmp (reverse $b) } @list;

Приводим таблицу аналогий между операторами арифметического и лексикографического сравнения:

арифметическое сравнениелексикографическое сравнениемнемоник
<ltLess Than
<=leLess or Equal
==eqEqual
>=geGreater or Equal
>gtGreater Than
<=>cmpCoMPare

Если компаратор оказывается громоздким, или если он используется многократно, есть смысл определить его как отдельную процедуру и дать этой процедуре имя. Тогда вместо тела компаратора при вызове sort следует указывать имя. Определим и используем компаратор, сравнивающий строки независимо от регистра символов (то есть не различая прописные и строчные буквы), как отдельную процедуру. Назовём её iCmp. Ясно, что нечувствительности к регистру можно добиться, если компаратор будет сравнивать не сами строки, а их «заглавные» варианты (то есть строки, получающиеся из заданных в результате их капитализации — замены всех строчных букв на прописные). За капитализацию строк отвечает встроенная процедура uc (от англ. Upper Case — верхний регистр). Определяя отдельный компаратор, следует помнить, что сравниваемые величины передаются в него уже не через переменные $a и $b, а через параметры вызова, то есть в массиве @_.

sub iCmp
{
	return (uc $_[0]) cmp (uc $_[1]);
}

@sortedList=sort iCmp @list;

И уж поскольку мы упомянули встроенную процедуру uc, нельзя не вспомнить про её компанию — процедуры lc, ucfirst и lcfirst. Первая из них меняет регистр всех букв на нижний, вторая и третья меняет регистр только у первого символа в строке (если это буква), соответственно на верхний или нижний.

[Примечание]Примечание

В последнем примере мы могли бы с равным успехом воспользоваться процедурой lc вместо uc.

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

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

(
	['КОЛЕСНИКОВ', 'АНАТОЛИЙ'],
	['ИВАНОВ', 'ПЁТР'],
	['КОЛЕСНИКОВА', 'НАТАЛЬЯ'],
	['КУЗНЕЦОВ', 'ВЛАДИМИР'],
	['КОЛЕСНИКОВ', 'ЕГОР'],
	['ГРИГОРЬЕВА', 'СВЕТЛАНА'],
)

Если бы требовалось упорядочить список людей только по фамилии или только по имени, подошли бы компараторы

{ $a->[0] cmp $b->[0] }	по фамилии
{ $a->[1] cmp $b->[1] }	по имени

Если же нужно упорядочить массив по фамилиям, а однофамильцев ещё и по имени, может возникнуть соблазн сравнивать в компараторе результат конкатенации фамилии и имени:

{ $a->[0].$a->[1] cmp $b->[0].$b->[1] }

Увы, этот подход может в некоторых случаях подвести, а именно в тех, когда фамилия одного человека служит началом фамилии другого. Именно это и произойдёт при сравнении несчастных Натальи и Анатолия. Хотя Анатолий и должен стоять раньше в алфавитном списке, чем Наталья (его фамилия «меньше»), для конкатенации фамилии и имени окажется 'КОЛЕСНИКОВАНАТОЛИЙ' gt 'КОЛЕСНИКОВАНАТАЛЬЯ'.

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

{ "$a->[0] $a->[1]" cmp "$b->[0] $b->[1]" }

Или, что равносильно, таким:

{ "@$a" cmp "@$b" }

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

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

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

(
	[3, 4],
	[2, 5],
	[3, 3],
	[1, 6],
	[5, 1],
)

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

{ $a->[0]<=>$b->[0] or $a->[1]<=>$b->[1] }

Объясним, как это работает. Если абсциссы не равны, выражение $a->[0]<=>$b->[0] отлично от нуля (оно равно плюс или минус единице), и этой информации достаточно, чтобы сравнить точки только лишь по их абсциссам. В этом случае второе выражение, $a->[1]<=>$b->[1], вообще не вычисляется, так как всё выражение в теле компаратора заведомо истинно. Если абсциссы равны, первый операнд операции or принимает ложное значение, и для вычисления значения операции необходимо вычислить второй операнд. Его значение и станет тем, что вычисляется в компараторе.

Подведём итоги. Компататор для сортировки в соответствии со многими критериями получается, если соединить при помощи операции or выражения компараторов для каждого из критериев, взятых по отдельности. Например, для сортировки списка строк сначала по длинам, а при равных длинах — по алфавиту, строится компаратор

{ length($a)<=>length($b) or $a cmp $b }

Упорядочить список людей по фамилии и по имени не то же самое, что упорядочить по имени и по фамилии. Вдумчивого читателя может удивить, как это согласуется со способом конструирования компараторов со многими критериями: кажется, что если поменять местами операнды операции or, ничего не должно измениться. Но нет, кое-что изменится. В то время как значение выражения length('AB')<=>length('B') or 'AB' cmp 'B' равно 1, значением выражения 'AB' cmp 'B' or length('AB')<=>length('B') будет уже -1. Дело в том, что операция or в Perl не коммутативна. Коммутативной она станет только если интерпретировать результат как логическое значение. В этом смысле оба числа, 1 и -1 равны, поскольку оба обозначают истину.

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

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

sub shuffle
{
	my %randoms;
	$randoms{$_}=rand for @_;
	return sort { $randoms{$a}<=>$randoms{$b} } keys %randoms;
}

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

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

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

sub shuffle
{
	my %randoms;
	for(@_)
	{
		my $rand;
		do
		{
			$rand=rand;
		}
		while(exists $randoms{$rand});
		$randoms{$rand}=$_;
	}
	return @randoms{sort { $a<=>$b } keys %randoms};
}

Вот наконец и нашлось применение циклу с постусловием! Цикл dowhile будет крутиться до тех пор, пока очередной вызов rand не вернёт случайное число, ранее не встречавшееся среди ключей в ассоциативном массиве %randoms. Скорее всего, этот цикл прокрутится лишь по одному разу для каждого элемента списка.

Заслуживает внимания значение, возвращаемое из процедуры. Выражение sort { $a<=>$b } keys %randoms возвращает список случайных чисел (ключей), упорядоченных по неубыванию, а выражение @randoms{…} — список значений из ассоциативного массива %randoms, которые этим значениям соответствуют, причём в соответствующем порядке. Обратите внимание, что если в фигурных скобках указывается список ключей, нужно писать именно @randoms{…}, а не $randoms{…}, как в случае единичного ключа.

Информатика-54© А. Н. Швец