Программирование алгоритмов, требующих больших объемов оперативной памяти
Что мы можем описать?
Увы! Это всегда лишь то, что начинает увядать и портиться.
Ницше. По ту сторону добра и зла
Идея написания предлагаемой вашему вниманию статьи навеяна многократным штудированием книги Д.Кнута «Искусство программирования для ЭВМ» [1] и личным опытом автора, приобретенным при разработке и программировании алгоритмов, требующих большого объема оперативной памяти. Описанные в указанной книге методы выделения «наиболее подходящего», «первого подходящего» и «системы близнецов», а также методы освобождения памяти в настоящее время относятся к задачам, возлагаемым скорее на операционные системы, чем на средства разработки приложений, что отмечено Д. Кнутом в конце главы 2.5. Причина этого заключается в том, что сейчас большинство операционных систем снабжены подсистемой управления памятью, которая эффективно предоставляет оперативную память как один из ресурсов. В то же время у автора были проблемы с использованием 256 Мбайт оперативной памяти до появления операционной системы Windows 95 (в эпоху DOS и Windows 3.11), а также при организации эффективного использования в одной из задач 1 Гбайт памяти уже во времена Windows 95/98/NT/2000. Это и послужило причиной написания данной статьи, посвященной программированию работы с оперативной памятью.
Размер доступной для программы оперативной памяти зависит от разрядности вычислительного устройства, разрядности поддерживаемой операционной системы, а также от средства разработки приложений, с помощью которого производится компиляция или интерпретация алгоритма. Обычно сначала появляются процессоры с большей разрядностью, а затем для них создаются операционные системы и средства разработки приложений. В середине 90-х годов все они были 16- и 32-разрядными. Новое тысячелетие, по-видимому, будет эрой 64-разрядных вычислительных средств.
Если x0, x1,…xn-1, xn (где xiО{0,1}) — адрес идентификатора в приложении, то максимально возможный адрес, доступный данному приложению, — 2n+1. Для 16-разрядных приложений (n = 15) это число составляет 65 536, для 32-разрядных — 4 294 967 296 (» 4 Гбайт), для 64-разрядных — 1,844674407371*10+19. Обычно на эти ограничения в адресации накладываются дополнительные ограничения, присущие операционой системе и средству разработки. Например, в Windows 95/98/NT/2000 реальный размер максимальной адресации меньше теоретически возможного, так как часть доступной памяти забирает для своих нужд сама операционная система. К тому же средства разработки позволяют создавать приложения, как правило, с фиксированной разрядностью в смысле адресации.
Управление памятью всегда было актуальной задачей, поскольку это один из самых дорогостоящих ресурсов, предоставляемых вычислительными устройствами. В табл. 1 показаны цены на модули оперативной памяти для двух фирм-производителей на август 2000 года.
Таблица 1
Название фирмы |
Серийный номер |
Объем ОП, Мбайт |
Цена, долл. |
---|---|---|---|
Kingston Technology |
KTH6521/64 |
32 |
168 |
KTH6521/64 |
64 |
143 |
|
KTH6521/128 |
128 |
269 |
|
KTH6521/256 |
256 |
521 |
|
KTH6742/512 |
512 |
1220 |
|
HP |
D6522A |
64 |
126 |
D6523A |
128 |
239 |
|
D6743A |
256 |
479 |
|
D6742A |
512 |
2643 |
Ясно, что закономерность здесь более или менее очевидна.
Во времена, когда 640 Кбайт conventional memory (обычная память, которую нередко также называют основной памятью — ввиду двоякой возможности перевода слова conventional) были пределом доступной для программистов ОП, приходилось бороться за десятки «лишних» килобайт, придумывая драйверы, позволяющие выйти за пределы возможностей 16-разрядных приложений и ограничений операционной системы. Например, были популярны стандарты EMS (expanded memory), которая продавалась на отдельной плате со своим процессором, и XMS (extended memory), которая была организована так же, как и основная память. В настоящий момент интерес к таким драйверам снизился вследствие появления процессоров, операционных систем и средств разработки с большей разрядностью, но для иллюстрации идей и методов представляется интересным рассмотреть и этот случай управления памятью. В операционной системе MS-DOS оперативная память распределялась следующим образом:
- 0-640 Kбайт — обычная (conventional) память;
- 640-1024 Kбайт — так называемая старшая (upper) память (UMB);
- 1024-1088 Kбайт — так называемая верхняя (high) память (HMS);
- 1088 Кбайт и выше — дополнительная (extended) память (XMS);
- на отдельной плате — расширенная (expanded) память (EMS).
Предположим, на вашем компьютере имеется 2 Мбайт оперативной памяти. К 640 Кбайт можно адресоваться непосредственно из программы, причем размер одного блока, то есть максимальная длина части оперативной памяти для переменной (или идентификатора массива) не должна превышать 65 519. Операционная система для своих нужд также использовала память из тех же 640 Кбайт. Первоначально (при создании DOS) предполагалось, что максимальный объем требуемой оперативной памяти для любой задачи не может превысить 1 Мбайт. Память от 640 Кбайт до 1 Мбайт (upper memory) была занята чем попало — видеобуфером экрана, областями специально для компьютера PS/2 и т.д. В дальнейшем функциональное назначение ее расширилось — туда стали записывать резидентные программы в целях экономии основной памяти. Теперь представим, что перед нами стоит задача написать систему управления базой данных, размер записи которой — 1 Кбайт, а количество записей может составить несколько тысяч. При этом для быстроты работы приложения все или большая часть записей должны находиться в памяти. Понятно, что без дополнительных ухищрений в DOS такая задача решена быть не может, поскольку здесь нет необходимого для этого ресурса — памяти размером в несколько мегабайт с прямой адресацией. Однако физически такая память есть — надо только обойти ограничение, заложенное в операционной системе, чтобы заставить приложение работать быстрее при выполнении операций чтения и записи в память. Обычно это делается при помощи организации связи между небольшим «окном» оперативной памяти с прямой адресацией, куда можно обратиться с помощью быстрых команд операционной системы, и большим «окном» «верхней» памяти, куда можно обратиться с помощью медленных команд операционной системы, которые все же более быстрые, чем команды чтения/записи на долговременные носители данных. Можно долго рассказывать, как это сделать на уровне команд DOS, а можно воспользоваться библиотекой, реализующей подобные функции (например, Object Professional компании TurboPower Software, http://www.turbopower.com), и обсудить проблему на более высоком уровне. Именно это мы и сделаем.
Для иллюстрации идей, рассматриваемых в данной статье, будут использоваться Borland Pascal 7.0 или Borland Delphi. Надеюсь, что реализация рассматриваемых алгоритмов на других языках программирования не будет вызывать затруднений.
В библиотеке Object Professional (используемой совместно с Borland Pascal 7.0) имеется класс OpArray, наследуемый от класса AbstractArray и позволяющий управлять памятью при помощи больших двухмерных массивов:
OpArray = object(AbstractArray) constructor Init(Rows, Cols : Word; ElementSize : Word; FileName : String; HeapToUse : LongInt; ArrayOptions: Byte; var Priority : AutoPriority); …. end;
Объект поддерживает размещение данных в памяти с прямой адресацией, размещение данных в «верхней» памяти и временных наборах данных на долговременных носителях. Для унификации методов доступа к данным и обработки ошибок возможно создание дополнительного объекта.
PAsciizStringArray = ^TAsciizStringArray; TAsciizStringArray = object(TObject) … constructor Init(Rows, Cols, Element : Word; FileName: String; HeapToUse: LongInt); procedure InsertQ(var Item: Asciiz); … end; var OA: OpArray; … constructor TAsciizStringArray.Init(Rows, Cols, Element : Word; FileName: String; HeapToUse: LongInt); const ArrayOptions : Word = LDeleteFile + LRangeCheck; MyDefaultPriority:AutoPriority=(LXmsArray,LRamArray,LEMSArray, LVirtualArray); {массив может располагаться в ОП с прямой адресацией (LRamArray), в “верхней” ОП (LXMSArray и LEMSArray) и во временных наборах данных (LVirtualArray)} … var E: Word; begin XmsAvailable := TRUE; OA.Init(Rows, Cols, Element, FileName, HeapToUse, ArrayOptions, MyDefaultPriority); Count := 0; E := OA.ErrorA; if E <> 0 then ErrorM(E, Count); {обработка ошибок} OA.ClearA(ItemDefault, ExactInit); {предварительная очистка массива} E := OA.ErrorA; if E <> 0 then ErrorM(E, Count); {обработка ошибок} … end; procedure TAsciizStringArray.InsertQ(var Item: Asciiz); var E: Word; begin OA.SetA(count, 0, Item); {метод вставки, наследуемый от AbstractArray} E := OA.ErrorA; if E <> 0 then ErrorM(E, count); Inc(Count); end; …
Для использования объекта надо описать его и запросить для него ресурс в памяти:
… PS: PAsciizStringArray; AS: Asciiz; … PS := New(PAsciizStringArray, Init(MaxSize, 1, SizeOf(S), 'kadr.wrk', MaxAvail div 2 )); … PS^.InsertQ(AS); {вставка новой строки в динамический массив} …
Попутно отметим, что, кроме того, с помощью класса TAsciizStringArray могут быть решены вопросы отсева дубликатов, поиска, сортировки, а также другие проблемы, выходящие за рамки тематики статьи. Также обратите внимание на размер запрашиваемой памяти с прямой адресацией — параметр HeapToUse, который равен половине размера доступной памяти и, в свою очередь, может быть получен с помощью функции MaxAvail. Во избежание коллизий необходимо предусмотреть, чтобы вашим приложением запрашивалась не вся доступная память, так как явные или неявные запросы на свободную память возможны из других мест вашего приложения либо из приложений, работающих параллельно с вашим. Приложение, фрагменты исходного текста которого приведены выше, будет работать в MS-DOS или в DOS-окне Windows.
Актуальность задачи распределения и перераспределения памяти сохранилась и после появления 32-разрядных операционных систем и средств разработки, позволяющих адресоваться из одного массива данных к памяти размером до 4 Гбайт. Дело в том, что алгоритмы, обходящиеся одним массивом, встречаются редко — как правило, их бывает несколько. В случае если в алгоритме используется память полиномиального размера [2], перед разработчиком встает вопрос о способах организации данных. Прежде чем подавать исходные данные на вход алгоритма, надо договориться о том, как они представляются в «понятном для компьютера виде». До появления в Delphi 4 (выпущенном в 1998 году) динамических массивов, позволяющих резервировать место в ОП по мере необходимости, при программировании на Object Pascal обходились линейными списками. При программировании на Delphi для организации списков можно воспользоваться классом TList из модуля classes. Здесь возможны два варианта:
- создавать наследника класса Tlist:
TDataListI = class (TList) // (1*) protected procedure Put(Index: Integer; Item: TData); function Get (Index: Integer): TData; public procedure Add (Obj: TData); property Items[Index: Integer]: TData read Get write Put; default; end;
- вставлять класс TList в класс-контейнер:
TDataListW = class(TObject) // (2*) private FList: TList; function Get(Index: Integer): TData; procedure Put(Index: Integer; Item: TData); function GetCount: Integer; public constructor Create; destructor Destroy; override; function Add(Item: TData): Integer; function Equals(List: TDataListW): Boolean; property Count: Integer read GetCount; property Items[Index: Integer]: TData read Get write Put; default; end;
Тип элемента TData, как правило, является классом, но в общем случае может быть и любым другим типом данных. Если тип элемента — не класс, то память освободить сложнее, поскольку операции освобождения памяти ложатся не на функцию Destroy, принадлежащую классу, а на дополнительные модули или операторы внутри ваших модулей. Как видно из описания классов, унификация внутри модуля относительно типа элементов (записи, классы и т.п.), из которых состоят списки, существует только для классов, так как только классы «знают», как освобождать память, занятую объектом данного класса. Для реализации корректного освобождения памяти нужно переписать класс TDataListW, например следующим образом:
TDataListС = class // (3*) private LType: TClass; FList: TList; function Get (Index: Integer): TObject; procedure Put (Index: Integer; Item: TObject); function GetCount: Integer; public constructor Create (CType: TClass); destructor Destroy; override; function Add (Item: TObject): Integer; function Equals(List: TDataListС): Boolean; property Count: Integer read GetCount; property Items [Index: Integer]: TObject read Get write Put; default; end;
Тексты самих методов здесь не приводятся из-за их тривиальности. В итоге, применяя методы Create и Add, можно создавать списки и добавлять в них новые элементы. Обращаться к элементам списков можно при помощи идентификатора Items или как к обычному элементу массива, поскольку свойство Items определено как default. Кроме того, в Delphi определены классы TClassList и TObjectList (из модуля contnrs), наследуемые от класса TLlist и похожие на класс TDataListС, а также классы TStack, TObjectStack, TQueue, TObjectQueue, наследуемые от TOrderedList и реализующие различные виды линейных списков; класс TCollection (из модуля classes), наследуемый от TPersistent, в котором реализована возможность синхронизации доступа к элементам благодаря методам BeginUpdate, EndUpdate; класс TStrings (из модуля classes), наследуемый от Tpersistent, — абстрактный базовый класс для манипуляции со строками; TStringList (из модуля classes), наследуемый от TStrings и управляющий списками строк и присоединенных к ним объектов с замещенными абстрактными методами из TStrings. Как видите, классы, реализующие списки, довольно многообразны.
Однако при работе со списками могут быть и проблемы. У автора при использовании класса типа TDataListW не освобождалась оперативная память — немного, всего 16 байт, но при интенсивной работе со списками это приводило к нехватке памяти. Поэтому иногда бывает оправдан подход, когда разработчик не использует чужих классов, а все пишет сам. Это несложно. Сначала создается запись, которая будет являться элементом списка.
PItem_Tree = ^TItem_Tree; TItem_Tree = record {Рабочая часть записи} … {-------------------------------------------------} {Часть записи для организации списка} Next: PItem_Tree; end;Затем пишется класс, реализующий список:
TRecList = class // (4*) private Head, Last: PItem_Tree; BeforeSet: PItem_Tree; IndexBeforeSet: integer; BeforeGet: PItem_Tree; IndexBeforeGet: integer; FCount: integer; RetCode: byte; function GetItem(Index: integer): TItem_Tree; procedure SetItem(Index: integer; Rec: TItem_Tree); function Empty: boolean; function GetNodeSet(i:integer): PItem_Tree; function GetNodeGet(i:integer): PItem_Tree; protected function GetCount: integer; public constructor Create; destructor Destroy; override; procedure Clear; function Add(Rec: TItem_Tree): integer; virtual; function AddBegin(Rec: TItem_Tree): integer; virtual; procedure Assign(var Output: TRecList); procedure Insert(Index: integer; const Rec: TItem_Tree); virtual; procedure DeleteFromListCheckedFlagItog; procedure CopyRec(const Input: TItem_Tree; var Output: TItem_Tree); property Items[i: integer]:TItem_Tree read GetItem write SetItem;default; property Count: integer read GetCount; end; function TRecList.Empty:boolean; begin if Head<>nil then begin RetCode:=Succes; if Head^.Next=nil then Empty:=TRUE else Empty:=FALSE end else begin RetCode:=NotFill; Empty:=TRUE; end; end; procedureTRecList.CopyRec(const Input:TItem_Tree; var Output:TItem_Tree); begin with OUTPUT do begin {Присвоение рабочей части записи} … {Часть записи для организации списка} Next:=nil; end; end; constructor TRecList.Create; begin inherited Create; Head:=nil; Last:=Head; Before:=Head; IndexBefore:=0; BeforeSet:=Head; IndexBeforeSet:=0; BeforeGet:=Head; IndexBeforeGet:=0; FCount:=0; end; destructor TRecList.Destroy; begin Clear; inherited Destroy; end; procedure TRecList.Clear; var P,P1:PItem_Tree; begin if Head<>nil then begin if Empty and (RetCode=Succes) then begin Dispose(Head);Head:=nil; RetCode:=Succes; Exit; end; P:=Head; while P<>nil do begin P1:=P^.Next; Dispose(P); P:=P1; end; RetCode:=Succes; Head :=nil; Last :=nil; Before :=nil; IndexBefore:=0; BeforeSet :=nil; IndexBeforeSet:=0; BeforeGet :=nil; IndexBeforeGet:=0; FCount:=0; end else RetCode:=NotFill; end; function TRecList.GetNodeSet(i:integer): PItem_Tree; var j: integer; P: PItem_Tree; begin RetCode:=Succes; if (i-1=IndexBeforeSet) and (BeforeSet <> nil) then begin P:=BeforeSet^.Next; BeforeSet:=P; IndexBeforeSet:=i; GetNodeSet:=P; end else begin P:=Head; j:=0; while P<>nil do begin if i=j then break; P:=P^.Next; Inc(j); end; BeforeSet:=P; IndexBeforeSet:=i; GetNodeSet:=P; end; end; function TRecList.GetNodeGet(i: integer): PItem_Tree; var j: integer; P: PItem_Tree; begin RetCode:=Succes; if (i-1=IndexBeforeGet) and (BeforeGet <> nil) then begin P:=BeforeGet^.Next; BeforeGet:=P; IndexBeforeGet:=i; GetNodeGet:=P; end else begin P:=Head; j:=0; while P<>nil do begin if i=j then break; P:=P^.Next; Inc(j); end; BeforeGet:=P; IndexBeforeGet:=i; GetNodeGet:=P; end; end; procedure TRecList.SetItem(Index: integer; Rec: TItem_Tree); var P, P1: PItem_Tree; begin if Index>FCount then begin RetCode:=ErrIndex;Exit;end; P:=GetNodeSet(Index); if RetCode=Succes then begin P1:=P^.Next; CopyRec(Rec, P^); P^.Next:=P1; end; end; function TRecList.GetItem(Index: integer): TItem_Tree; var P:PItem_Tree; begin if Index>FCount then begin RetCode:=ErrIndex;Exit;end; P:=GetNodeGet(Index); if RetCode=Succes then if P<>nil then CopyRec(P^, Result); end; function TRecList.Add(Rec: TItem_Tree): integer; begin if Head=nil then begin New(Head); if Head<>nil then begin CopyRec(Rec, Head^); Last:=Head; FCount:=1; Result:=1; end else Result:=-1; end else begin New(Last^.Next); Last:=Last^.Next; CopyRec(Rec, Last^); Inc(FCount); Result:=FCount; end; end; function TRecList.AddBegin(Rec: TItem_Tree): integer; var P: PItem_Tree; begin if Head=nil then begin New(Head); if Head<>nil then begin CopyRec(Rec, Head^); Last:=Head; FCount:=1; Result:=1; end else Result:=-1; end else begin New(P); P^.Next:=Head; Head:=P; P:=P^.Next; BeforeSet:=Head; IndexBeforeSet:=0; BeforeGet:=Head; IndexBeforeGet:=0; CopyRec(Rec, Head^); Head^.Next:=P; Inc(FCount); Result:=FCount; end; end; procedure TRecList.Assign(var Output: TRecList); begin output.Clear; output.Head:=Head; output.Last:=Last; output.BeforeSet:=BeforeSet; output.IndexBeforeSet:=IndexBeforeSet; output.BeforeGet:=BeforeGet; output.IndexBeforeGet:=IndexBeforeGet; output.FCount:=FCount; inherited Destroy; end; procedure TRecList.Insert(Index: integer; const Rec: TItem_Tree); var P,P1,P2:PItem_Tree; i: integer; begin New(P); Inc(FCount); CopyRec(Rec, P^); if Head=nil then Head:=P else {Если список не пуст} begin P1:=Head; P2:=Head; i:=0; while (P2<>nil) and (i<Index) do begin P1:=P2; P2:=P2^.Next; Inc(i) end; {Пройден весь список-элемент в конец} if P2=nil then P1^.Next:=P else begin P^.Next:=P2; {В начало списка} if P2=Head then Head:=P else {Внутрь списка} P1^.Next:=P end; end; end; function TRecList.GetCount: integer; begin GetCount:=FCount; end; procedure TRecList.DeleteFromListCheckedFlagItog; var P, P1: PItem_Tree; begin P:=Head; while P<>nil do if not P^.FlagItog then begin {Удаление из начала списка} if P=Head then begin Head:=Head^.Next; Dispose(P); Dec(FCount); P:=Head; end else begin {Удаление из середины списка} P1^.Next:=P^.Next; Dispose(P); Dec(FCount); P:=P1^.Next; end; end else begin {Переход на следующий элемент списка} P1:=P; P:=P^.Next; end; end;
Метод Empty предназначен для проверки списка на наличие элементов и используется в других методах класса. CopyRec используется для заполнения элементов списка. Методы Create и Destroy применяются для создания и уничтожения списка соответственно. Метод Clear удаляет все элементы из списка. Методы GetNodeSet и GetNodeGet совместно с полями BeforeSet, IndexBeforeSet, BeforeGet, IndexBeforeGet используются как внутренние и обеспечивают простенькую оптимизацию без дополнительных связей, основанную на том, что при чтении и записи элементов подряд достаточно хранить индекс предыдущего элемента для проверки и ссылку на предыдущий элемент для выполнения действий. Этот способ оптимизации для однонаправленного списка оказывается эффективным, естественно, только для больших списков (сотни тысяч элементов). Методы SetItem и GetItem обслуживают доступ к элементам с помощью свойства Items. Добавление элементов в конец, начало и в указанное место списка обслуживается методами Add, AddBegin и Insert. Метод Assign при помощи полей Head (указатель на первый элемент) и Last (указатель на последний элемент) поддерживает копирование из списка в список. DeleteFromListCheckedFlagItog – тоже хитрый метод. Если вы, работая с большим списком, попытаетесь удалять из него элементы по одному, это займет много времени. Однако можно вместо удаления просто пометить какое-то поле в элементе, а затем, просмотрев список, за один раз удалить все помеченные элементы.
Попутно отметим, что в объектах, аналогичных приведенным выше, возможно выполнять сжатие данных (хранить в элементах списка типы данных меньшего размера, чем данные, с которыми производятся какие-то действия). При программировании на Delphi для сжатия узла списка можно воспользоваться классом (объектом) TBits из модуля classes. В случае если поля узла списка имеют тип массива байт, слов и т.п., можно при сохранении структуры в узле списка производить операции сжатия данных, как, например, в функции, приведенной ниже:
function ByteTo2Bit(B: array of Byte; var Bit: TBits): boolean; var i, j: integer; begin ByteTo2Bit:=True; Bit:=TBits.Create; Bit.Size:=Length(B)*2; for i:=Low(B) to High(B) do begin j:=(i-Low(B))*2; if B[i]=2 then Bit.Bits[j]:=True else if B[i]=1 then Bit.Bits[j+1]:=True; end; end;
А при извлечении данных из узла списка для работы можно использовать функцию Bit2ToByte:
function Bit2ToByte(Bit: TBits; var B: array of Byte): Boolean; var i, j: integer; begin Bit2ToByte:=True; if Length(B)*2<Bit.Size then begin Bit2ToByte:=False; Exit; end; i:=0; while i<Bit.Size do begin j:=(i div 2)+Low(B); if Bit.Bits[i] then B[j]:=2 else if Bit.Bits[i+1] then B[j]:=1 else B[j]:=0; inc(i,2); end; end;
В табл. 2 представлено время обработки списков различного размера — без сжатия и со сжатием — для классов типа TDataListW. Размер записи до сжатия составлял 3688 байт, после сжатия — 68 байт.
Таблица 2
Тип объекта |
Размер списка |
Время счета, мин:с |
Затраченная ОП, Кбайт |
---|---|---|---|
Без сжатия |
50 000 |
2 |
171 000 |
100 000 |
4 |
343 000 |
|
150 000 |
12 |
470 000 |
|
200 000 |
25 |
472 884 |
|
Со сжатием |
10 000 |
21 |
13 560 |
50 000 |
1:48 |
62 404 |
|
100 000 |
3:37 |
123 472 |
|
150 000 |
5:47 |
184 528 |
|
500 000 |
18:41 |
481 372 |
Результаты замеров, приведенные в таблице, показывают, что при «навешивании» операций сжатия на класс динамических списков память экономится в разы, а время обработки данных растет на порядки. Из этого следует, что сжимать данные в списках нужно только в том случае, если нет другого выхода.
Динамические массивы удобнее в использовании и во многом заменяют списки. Однако не обрабатывается ситуация, когда заранее неизвестно, сколько памяти надо выделять под объект, которым является динамический массив. В данном случае опять требуется написание дополнительного кода. Одна из возможных стратегий — периодическая проверка индекса массива и перераспределение памяти в случае выхода его значения за границу установленного диапазона. При этом существует несколько способов перераспределения: увеличивать динамический массив поэлементно; увеличивать его на фиксированное количество элементов; увеличивать его на переменное, в зависимости от ситуации, количество элементов. Недостатком данного подхода является необходимость дополнительных операций проверки выхода индекса за границу диапазона и перераспределение (выделение и освобождение) памяти. Особенно это будет ощущаться в случае, когда трудно сделать априорные предположения о возможных границах массивов. Тактика программирования для реализации идей может быть аналогична тактике построения динамических списков, рассмотренных выше.
Здесь необходимо отметить, что динамический массив не является классом в традиционном смысле. И другие типы данных, например integer, не являются классами в Pascal-подобных языках. Как представляется, это существенно облегчило бы программирование, к которому мы сейчас и переходим. Представим вариант класса, расширяющего возможности динамических массивов:
TS = array of TObject; // Массив объектов TGigaArray = class // (5*) protected FGArray: TS; FSizeBlockTS:TCount; FMaxArray: TCount; procedure SetElGigaArray(Index: integer; A: TObject); function GetElGigaArray(Index: integer): TObject; function ReplaceArray(var C1: TCount): boolean; virtual; destructor Destroy; virtual; public constructor Create(ExtentSizeArray: integer); propertyElement[Index: integer]:Tobject read GetElGigaArray write SetElGigaArray;default; end;
Методы класса реализуют возможность увеличения количества элементов массива произвольных объектов (классов) на фиксированную величину, заданную в конструкторе:
constructor TGigaArray.Create(ExtentSizeArray: integer); begin FSizeBlockTS:= ExtentSizeArray; FMaxArray := ExtentSizeArray; SetLength(FGArray, FMaxArray); end; destructor TGigaArray.Destroy; begin FGArray := NIL; inherited Destroy; end; procedure TGigaArray.SetElGigaArray(Index: integer; A: TObject); var C: TCount; begin C := Index; ReplaceArray(C); FGArray[Index] := A; end; function TGigaArray.GetElGigaArray(Index: integer): TObject; var i: integer; C1: TCount; begin if Index <= FMaxArray then Result := FGArray[Index] else begin C1 := Index; ReplaceArray(C1); for i:=FmaxArray-FSizeBlockTS to FMaxArray do FGArray[i] := nil; Result := FGArray[Index]; end; end; function TGigaArray.ReplaceArray(var C1: TCount): boolean; var WGArray: TS; i: integer; begin Result := True; if C1 > FMaxArray then begin FMaxArray := 0; Dec(C1); SetLength(WGArray, C1); for i:=0 to C1-1 do begin WGArray[i] := FGArray[i]; Inc(FMaxArray); end; FGArray := NIL; C1:= 0; Inc(FMaxArray, FSizeBlockTS); SetLength(FGArray, FMaxArray); Dec(FMaxArray, FSizeBlockTS); for i:=0 to FMaxArray-1 do begin FGArray[i] := WGArray[i]; Inc(C1); end; Inc(FMaxArray, FSizeBlockTS); Inc(C1); WGArray := nil; Result := False; end; end;
В данном классе не производится проверка типов, как в классе TDataListС. Метод ReplaceArray, реализующий расширение массива, вызывается и при чтении (GetElGigaArray), и при записи (SetElGigaArray) в массив. Возможно, это не всегда нужно, поэтому в этом случае данные методы следует сделать виртуальными. Применение класса TGigaArray требует некоторых «накладных расходов» по сравнению с чистым динамическим массивом. Для создания объекта класса надо применять метод Create, а не функцию SetLength. Для освобождения памяти необходимо применять метод Free, а не присваивать nil. Самое неприятное — элемент массива представляет собой только объект, что требует определенной техники программирования, например такой:
… TS = class // описание типа элемента динамического массива I: integer; end; … M: TGigaArray; // описание экземпляра класса ДМ (объекта) S: TS; // описание экземпляра элемента ДМ … M:=TGigaArray.Create(”первичное число элементов”); // создание ДМ … S:=TS.Create; // создание текущего элемента ДМ S.I:=”какое-то значение”; // присвоение ему значения M[i]:=S; // запись объекта в ДМ … write(TS(M[i]).I); // обращение к массиву с приведением типов … M.Free; // освобождение ОП, занятой ДМ
Можно избавиться от необходимости приведения типов и создания экземпляров элементов динамического массива, отказавшись от типа TObject в качестве типа элемента, и создавать классы по аналогии с TGigaArray, подставляя нужные типы элементов в описание класса, например TGigaArrayInteger, TGigaArrayWord или TGigaArrayReal.
В публикации [2] для динамических таблиц, которыми могут быть стек, куча, хэш-таблица или просто массив, рекомендуется вдвое увеличивать таблицу в момент переполнения (авторы указанной работы ссылаются на свой собственный опыт). Такой способ управления памятью предполагает наличие процедуры для вставки нового элемента в динамический массив. Сложнее производить перераспределение памяти при удалении ненужных элементов. Там же [2] предлагается, как только число элементов падает ниже некоторого предела, выделять память под новый динамический массив меньшего размера, копировать в меньший динамический массив все элементы из большего динамического массива, после чего высвободить память, занятую старым динамическим массивом. Высвобождение памяти рекомендуется предпринимать, когда коэффициент заполнения динамического массива (отношение числа заполненных элементов к размеру массива) падает ниже 1/4. Стратегия удаления также нуждается в дополнительной процедуре, которая (в случае с динамическим массивом) не только проверяет коэффициент заполнения, но и сдвигает элементы в случае удаления элемента из середины массива. Все вышесказанное можно проиллюстрировать следующим кодом:
TS = array of integer; // Массив целых чисел TGiga2Array = class // (6*) protected FGArray: TS; FCount: integer; // количество элементов FSize: integer; // размер массива procedure Put(Index: integer; A: integer); function Get(Index: integer): integer; function GetCount: integer; destructor Destroy; virtual; public constructor Create; procedure Add(X: integer); procedure Delete(I: integer); property Element[Index: integer]: integer read Get write Put; default; property Count: integer read GetCount; end; constructor TGiga2Array.Create; begin FSize := 1; FCount := 0; SetLength(FGArray, FSize); end; destructor TGiga2Array.Destroy; begin FGArray := NIL; inherited Destroy; end; procedure TGiga2Array.Put(Index: integer; A: integer); begin FGArray[Index] := A; end; function TGiga2Array.Get(Index: integer): integer; begin if Index <= FSize then Result := FGArray[Index] else Result := -1; // ошибочная ситуация end; function TGiga2Array.GetCount: integer; begin Result:= Length(FGArray); end; procedure TGiga2Array.Add(X: integer); var NFGArray: TS; i: integer; begin if FCount = FSize then begin SetLength(NFGArray, 2*FSize); // ОП вдвое больше для временного массива for i:=Low(FGArray) to High(FGArray) do NFGArray[i]:=FGArray[i]; // копируем старые значения FGArray := nil; // освободить старую ОП SetLength(FGArray, 2*FSize); // ОП вдвое больше for i:=Low(NFGArray) to High(NFGArray) do FGArray[i]:=NFGArray[i]; //копируем в новый массив NFGArray := nil; // освободить временный массив FSize := 2*FSize; end; FGArray[FCount]:= X; // добавить в конец массива FCount := FCount + 1; end; procedure TGiga2Array.Delete(I: integer); var NFGArray: TS; j: integer; begin if FSize < (4*FCount) then begin SetLength(NFGArray, FSize div 2); // ОП вдвое меньше для временного массива for i:=Low(FGArray) to I-1 do // копируем старые значения NFGArray[i]:=FGArray[i]; for j:=I+1 to High(FGArray) do // пропуская удаленный NFGArray[j]:=FGArray[j]; FGArray := nil; // освободить старую ОП SetLength(FGArray, FSize div 2); // ОП вдвое меньше for j:=Low(NFGArray) to High(NFGArray) do FGArray[j]:=NFGArray[j]; // копируем в новый массив NFGArray := nil; // освободить временный массив FSize := FSize div 2; end; FCount := FCount - 1; end;
Предлагаемый код наверняка является не самым оптимальным с точки зрения поставленной задачи. Можно определить в классе два поля типа FGArray и писать/читать данные попеременно — то в одно, то в другое поле, что даст возможность в методах Add и Delete избавиться от двойного копирования данных. Возможно, метод Delete для классов такого типа вообще не имеет смысла. Не проводилось также никаких исследований по подбору пороговых коэффициентов и т.п. Однако сама идея управления памятью для таких задач вполне применима.
Руководствуясь опытом, приобретенным при создании рассмотренных программ, попытаемся сформулировать требования к представлению данных:
- типы представления данных должны накладывать как можно меньше ограничений на возможную размерность данных и предусматривать простые способы первоначального выделения памяти для данных;
- описание типа должно быть как можно проще и удобнее в использовании;
- описание типа должно вмещать в себя как можно больше полезных функций (быстрый поиск, сортировка и т. п.);
- описание не должно вызывать ошибок и неточностей в работе алгоритма.
Рассмотренные структуры данных позволяют размещать в памяти данные заранее неизвестного объема. Динамические массивы, по сравнению с классами списков, делают это более изящным способом. Но если заранее не известен размер массива, удобнее использовать классы, реализующие функциональность линейных списков. В алгоритмах, где быстрота работы с линейным списком и занимаемая им память имеют первостепенное значение, предпочтительнее не использовать встроенные в средства разработки классы, которые создаются в основном для простых визуальных задач. Более того, с развитием вычислительной техники (увеличением размеров доступной памяти, широким внедрением многопроцессорных вычислительных устройств и т.д.), разработка классов, позволяющих манипулировать сложными структурами данных, приобретает первостепенное значение для решения широкого круга задач.
Литература
- Кнут Д. Искусство программирования для ЭВМ. Том II — М.: Мир, 1976.
- Кормен Т., Лейзерсон Ч., Ривест Р. Алгоритмы: построение и анализ. М.: МЦНМО, 1999.
С автором можно связаться по адресу y_zaches@mail.ru или по тел. (095)971-36-23.
КомпьютерПресс 12'2000