Ukážeme si, jak s pomocí strukturovaných typů implementovat jednoduchou datovou strukturu - jednosměrný lineární seznam. Data můžeme za chodu programu přidávat, vyhledávat i mazat, aniž bychom předem specifikovali maximální počet prvků, ten je omezen jen celkovým množstvím dostupné paměti.
6.7.2005 07:00 | Jan Němec | czytane 51213×
RELATED ARTICLES
KOMENTARZE
Jednosměrný seznam
Programátor občas potřebuje dynamickou datovou strukturu. Chlípný důstojník
z minulého dílu si ukládá data o lidech, kteří pro něj mají nějaký význam.
Pokud zná jednoho člověka, použije jednu proměnnou, na dva lidi proměnné
dvě a na deset lidí nejspíš statické pole. Pokud se počet dozví po
spuštění programu a nikoliv již v době překladu, problém vyřeší dynamickou
alokací paměti. Skutečná aplikace však bude zřejmě ještě o něco komplikovanější,
neboť důstojník bude lidi zadávat, vyhledávat a mazat, aniž by programu po
spuštění sdělil, kolik nejvýš záznamů hodlá držet v paměti.
Jednoduchou datovou strukturou, která podobné operace zvládne je seznam.
Ukládat do něj budeme čísla typu int, ale úplně stejně bychom postupovali
v případě strukturovaných typů z příkladu s chlípným důstojníkem.
typedef struct seznam {
int data;
struct seznam *dalsi;
} SEZNAM;
Struktura seznam a jí odpovídající typ SEZNAM reprezentují jeden datový prvek
v dynamické datové struktuře. Vlastní data jsou ve stejnojmenné proměnné typu
int, položka dalsi je pouze režie datové struktury. Jedná se o ukazatel opět na
strukturu typu seznam. Prázdnému seznamu odpovídá proměnná p, ukazatel na
SEZNAM s hodnotou NULL, pokud obsahuje jedno číslo bude nenulový, ale
v p->dalsi bude NULL. V případě dvou a víceprvkového seznamu ukazuje
p->dalsi na druhý prvek, p->dalsi->dalsi na třetí atd. Celý řetízek
je zakončen hodnotou NULL.
Všimněte si definice prvku dalsi ve struktuře. Hodil by se ukazatel na SEZNAM,
to je ale dosud nedefinovaný typ, takže zde musíme použít strukturu seznam.
Je to jediný případ, kdy ve vlastním kódu v C musíme použít jméno struktury
a nevystačíme si se jménem typu.
Nejjednodušší operací je výpis celého seznamu.
void vypis(const SEZNAM *ps) {
if (!ps) return; /* Prázdný seznam. */
printf("%i\n", ps->data); /* Vypiš aktuální prvek */
vypis(ps->dalsi); /* i celý zbytek seznamu */
}
Rekurzivní implementace funkce je asi nejjednodušší, ale efektivnější je se
rekurzi vyhnout a vše vyřešit while cyklem.
void vypis(const SEZNAM *ps) {
while (ps != NULL) { /* Dokud nejsme na konci, */
printf("%i\n", ps->data); /* vypiš aktuální prvek */
ps = ps->dalsi; /* a posuň se na další. */
}
}
Aby bylo co vypisovat, musíme nejprve prvky do seznamu přidat.
void pridej(SEZNAM **pps, int prvek) {
SEZNAM *ps;
ps = (SEZNAM *) malloc(sizeof(SEZNAM));
if (!ps) {
/* Tady bychom nějak ošetřili chybu alokace. */
return;
}
ps->data = prvek;
ps->dalsi = *pps;
*pps = ps;
}
Přidání prvku si zaslouží komentář. Především při uvedené implementaci je
seznam netříděný a umožňuje přidání duplicit. To nám umožní prostě naalokovat
novou hlavu (1. prvek) seznamu a jako druhý prvek poslouží hlava původní.
Celý seznam je reprezentován ukazatelem na první prvek a ten se přidáním
změní, vstupně výstupní parametr pps proto musí být ukazatel na ukazatel
na SEZNAM. Kdybychom (podobně jako v případě výpisu) zvolili jen ukazatel
na SEZNAM, přiřazení v posledním řádku by ovlivnilo jen parametr funkce pps,
ale nikoli původní proměnnou. Z kódu budeme přidání volat asi takhle:
SEZNAM *s;
s = NULL; /* Důležité, v opačném případě by nebyl seznam ukončen. */
pridej(&s, 1);
pridej(&s, 2);
pridej(&s, 3);
vypis(s);
Mírně komplikovanější je přidání do setříděného seznamu bez duplicit. Při běžné
implementaci procházíme seznam while cyklem a postupně porovnáváme zadávaný
prvek s daty ze seznamu. Dojde-li k rovnosti, operace skončí s chybou,
v opačném případě seznam na příslušném místě rozpojíme a vložíme přidávaný
prvek.
Operace mazání se přidání do setříděného seznamu podobá. V našem případě
smažeme první prvek s požadovanou hodnotou.
void mazej(SEZNAM **pps, int prvek) {
SEZNAM *ps, *ps2;
ps = *pps;
if (!ps) {
/* Prázdný seznam. */
return;
}
if (ps->data == prvek) {
/* Mazání hlavy je speciální. */
*pps = ps->dalsi;
free(ps);
return;
}
ps2 = ps->dalsi;
while (ps2 != NULL) {
if (ps2->data == prvek) {
/* Mazání prvku. */
ps->dalsi = ps2->dalsi;
free(ps2);
return;
}
/* Posun */
ps = ps2;
ps2 = ps->dalsi;
}
}
Je vidět, že mazání je nejobtížnější operací. I v tak jednoduché struktuře,
jakou je lineární seznam je třeba trocha obezřetnosti, musíme pamatovat také
na zvláštní případy, jakými jsou pokus o mazání v prázdném seznamu a mazání
hlavy.
Po ukončení práce se seznamem je třeba jej smazat a uvolnit paměť. Mohli bychom
volat funkci mazej, dokud je seznam neprázdný, ale to by bylo neefektivní.
Lepší je napsat speciální funkci.
void mazejvse(SEZNAM **pps) {
SEZNAM *ps;
ps = *pps;
while (ps) {
*pps = ps->dalsi;
free(ps);
ps = *pps;
}
}
I v tomto případě jsme potřebovali jednu pomocnou proměnnou. Zjednodušené
řešení
while (ps) {
free(ps);
ps = ps->dalsi; /* Přístup do uvolněné paměti. Chyba. */
}
není správné, neboť jsme přistupovali do dealokované paměti. Chyba je to
nebezpečná, neboť se na konkrétních platformách nemusí projevit, případně se
projeví jen někdy v závislosti na nepředvídatelných okolnostech.
Funkční zdrojový kód seznamu z dnešního dílu si můžete stáhnou zabalený
jako c23.tar.gz.
Lze vymyslet ještě celou řadu operací: vyhledávání prvku, třídění, odstranění
duplicit a podobně. V praktických případech často neukládáme do seznamu jen
čísla nebo jen nějaký uživatelský typ, ale obojí. Jeden záznam v seznamu pak
kromě ukazatele na dalších prvek a vlastních dat obsahuje jednoznačný
identifikátor (klíč) záznamu. Může se jednat třeba deskriptor otevřeného
souboru, rodné číslo osoby a podobně. V seznamu pak většinou vyhledáváme
právě podle tohoto klíče.
Seznamy jsou pro větší objemy dat neefektivní. Složitost mazání i vyhledávání
je v průměrném i nejhorším případě lineární, při n záznamech v seznamu musíme
projít n/2 v průměrném a n záznamů v nejhorším případě pro každou operaci.
Ve skutečných aplikacích, které intenzivně pracující s větším množstvím dat,
se proto používají jiné datové struktury. Jednou z možností jsou binární
stromy. Zde má každý prvek hned dva následníky (místo jednoho ukazatele dalsi
v případě našeho seznamu), levý a pravý, hlavě se v případě stromů říká kořen.
Klíč v levém následníku je vždy menší a v pravém větší než rodičovském záznamu.
Vyhledávání, přidávání i mazání má potom jen logaritmickou časovou složitost,
ovšem algoritmy jednotlivých operací jsou složitější než v případě seznamu,
zájemce proto odkazuji například na wikipedii.
Pokračování příště
V příštím dílu probereme operace se soubory.