Huffman-kódolás

Használható segédanyagok: Haskell könyvtárainak dokumentációja, (lokális!) Hoogle, a tárgy honlapja és a BE-AD rendszerbe feltöltött beadandók. Ha bármilyen kérdés, észrevétel felmerül, azt a felügyelőknek kell jelezni, nem a diáktársaknak!

A feladatok egymásra épülnek ezért érdemes ezeket a megadásuk sorrendjében megoldani, de legalább megérteni az aktuális feladatot megelőző feladatokat! A függvények definíciójában lehet, sőt javasolt is alkalmazni a korábban definiált függvényeket (függetlenül attól, hogy sikerült-e azokat megadni).

Tekintve, hogy a tesztesetek, bár odafigyelés mellett íródnak, nem fedik le minden esetben a függvény teljes működését, határozottan javasolt még külön próbálgatni a megoldásokat beadás előtt, vagy megkérdezni a felügyelőket!

Részpontszámokat csak az elégséges szint elérése után lehet kapni!

A feladat összefoglaló leírása

A Huffman-kódolás karakterek (jelek, betűk, számjegyek) olyan kódolását jelenti, ahol az egyes karakterekhez rendelt kódok nem azonos hosszúságúak (különböző számú bitből állnak), így a belőlük alkotott szöveg hosszában rövidíthetővé válik. Ez a karakterek gyakoriságának figyelembe vételével történik. Maga a kódolás egy mohó stratégián alapszik, és az adattömörítésben igen hatékonyan használható. A kapcsolódó algoritmust David A. Huffman (1925–1999) írta le először egy mesteri vizsgadolgozatban, és 1952-ben publikálta.

A kódolás során egy speciális adatszerkezetet (lényegében egy bináris fát, amely most egyszerűen csak listaként ábrázolunk) építünk fel lépésről-lépésre, a következő módon:

  1. Kiválasztjuk a lista két legkisebb gyakoriságú elemét, amely egy háromcsúcsú bináris fa két levele (olyan csúcs, amelynek nincs gyereke) lesz (amelyeket a gyakorisággal címkézzük meg), majd ezekhez hozzárendelünk egy gyökeret, amelyet a két gyakoriság összegével címkézünk meg.

  2. Ezután a két vizsgált elemet kitöröljük a listából, és azok összegét beszúrjuk az érték szerinti megfelelő helyre, hogy a lista rendezettsége megmaradjon.

  3. Ezután folytatjuk a műveletet az 1. lépésnél mindaddig, amíg van elem a listában.

Az így felépített adatszerkezetben a levelek az eredeti karaktereknek (illetve azok gyakoriságának) felelnek meg.

Az eredményül kapott fában, minden csúcs esetében címkézzük meg 0-val a belőle kiinduló bal oldali élt, 1-gyel pedig a jobb oldalit.

A gyökértől egy adott levélig egyetlen út halad. Ezen út éleihez rendelt 0 és 1 címkéket sorrendben összeolvasva, megkapjuk a levélhez rendelt karakter kódját. Látható, hogy a gyakoribb karakterek kódja rövidebb, míg a kevésbé gyakoribbaké hosszabb lesz.

Megjegyzés: Attól függően, hogy a bináris fa felépítésében egy adott lépésben melyik elem kerül balra és melyik jobbra, különböző eredményt kaphatunk, de ez nem befolyásolja a kapott kód hatékonyságát, illetve a megoldás helyességét.

Példa (“almafa” szöveg kódfája)

Huffmanf8c294b4ffa0f724de86686f908d3a66.png

Huffmanba31b9adb003f33bb2dfaa5ca39701d8.png

Huffman411db2d564f3f14e0ac6e0d753b15623.png

Huffmanf700bbbfc2e87fd088c927e63302337d.png

Betűgyakoriságok (1 pont)

A gyakoriságok ábrázolásához használjuk a következő típusokat:

type Frequency = Int
type LetterSum = (String, Frequency)

Számoljuk meg a paraméterként kapott szövegben az egyes betűk előfordulásainak a számát! Az eredmény listában az egyes karaktereket mint egy hosszúságú sztringeket ábrázoljuk a későbbi, egyszerűbb felhasználhatóság érdekében.

Megjegyzés: Az eredményben a rendezett párok az első elemük szerint rendezettek.

countLetters :: String -> [LetterSum]

Test>
[("a", 1), ("b", 1), ("c", 1), ("d", 1)] :: [LetterSum]
Test>
[("a", 4), ("f", 1), ("l", 2), ("m", 1), ("t", 1)] :: [LetterSum]

Kezdőállapot (2 pont)

A bevezetőben szerepelt gráf ábrázolásához az alábbi típusokat használjuk fel:

type Id    = Int
type Node  = (Id, LetterSum)
type Label = String
type Edge  = (Id, Id, Label)

A gráf csúcsait (Node) rendezett párokkal adjuk meg. A rendezett pár első komponense a csúcs azonosítója (Id), a második pedig a betű és annak előfordulási gyakorisága (LetterSum). A gráf élei irányítottak (Edge), az éleket ábrázoló hármas első két komponense a kiinduló és a célcsúcs azonosítója (Id), a harmadik komponens pedig az él címkéje.

Állítsunk elő az algoritmus kezdőállapotát (State), melyet az alábbi rendezett hármasként tudunk felírni:

type State = ([Node], [Edge], [Node])

A rendezett hármas balról jobbra a következő komponensekből épül fel:

  1. [Node] a feldolgozandó csúcsok listája: a csúcsokat 1-től indexeljük, ahol a sorrend fontos, ez gyakoriság alapján növekvő (egyenlő gyakoriság esetén a karakterek alapján rendezzük, szintén növekvő),

  2. [Edge] az éllista, mely kezdetben üres,

  3. [Node] a már feldolgozott csúcsok listája, mely kezdetben szintén üres.

A startState a betű-gyakoriság párokat egy rendezetlen listában kapja meg. Ezt rendezni kell a gyakoriság alapján a sorszámozás előtt.

startState :: [LetterSum] -> State

Test>
([(1, ("f", 1)), (2, ("l", 1)), (3, ("m", 1)), (4, ("a", 3))], [], []) :: State
Test>
([(1, ("a", 1)), (2, ("b", 1)), (3, ("c", 1)), (4, ("d", 1))], [], []) :: State
Test>
([(1, ("f", 1)), (2, ("l", 1)), (3, ("m", 1)), (4, ("a", 3)), (5, ("b", 3))], [], []) :: State

Következő azonosító előállítása (1 pont)

A gráf csúcsait 1-től kezdve sorszámoztuk a kezdőállapot elkészítésekor. A későbbiekben, ha újabb csúcsot készítünk, akkor ennek szüksége lesz egy olyan azonosítóra, amely még nem szerepel a State-ben. A nextId függvény keresse meg a használt azonosítók közül a legnagyobbat és adjon vissza ennél eggyel nagyobb értéket!

Megjegyzés: Feltehetjük, hogy az állapotban mindig van legalább egy csúcs.

nextId :: State -> Id

Test>
5 :: Id
Test>
3 :: Id
Test>
6 :: Id
Test>
4 :: Id

Új csúcs létrehozása (1 pont)

Készítsük el a createNode függvényt, mely létrehoz egy új csúcsot! A függvény az új csúcs azonosítóját és a gráfbeli gyerekeit, x és y, várja paraméterül. Az új csúcs gyakorisága az x és y csúcsok gyakoriságának összege, a címkéje pedig az x és y csúcsok címkéje egymás után fűzve.

createNode :: Id -> Node -> Node -> Node

Test>
(3, ("ab", 6)) :: Node
Test>
(2, ("eb", 5)) :: Node

Új csúcs beszúrása (2 pont)

Illesszünk be egy új csúcsot a kapott Node listába úgy, hogy a lista (Frequency értéke alapján vett) rendezettsége megmaradjon! Azonos Frequency esetén az első lehetséges helyre történjen a beszúrás. Fontos, hogy a sorrendet megőrizzük!

Megjegyzés: A feladatban az azonosítók (Id) esetleges ütközésére, illetve azok folytonosságára nem kell figyelni!

insertNode :: Node -> [Node] -> [Node]

Test>
[(1, ("f", 1)), (2, ("m", 1)), (3, ("a", 3)), (4, ("x", 12))] :: [Node]
Test>
[(4, ("x", 1)), (1, ("f", 1)), (2, ("m", 1)), (3, ("a", 3))] :: [Node]
Test>
[(1, ("f", 1)), (2, ("m", 1)), (4, ("x", 2)), (3, ("a", 3))] :: [Node]

Az algoritmus egy lépése (3 pont)

Az algoritmus egy lépése:

Megjegyzés: Üres csúcslistára váltsunk ki hibát az error függvénnyel!

step :: State -> State

Test>
([(3, ("m", 1)), (5, ("fl", 2)), (4, ("a", 3))], [(1, 5, "0"), (2, 5, "1")], [(1, ("f", 1)), (2, ("l", 1))]) :: State
Test>
([(6, ("mfl", 3)), (4, ("a", 3))], [(3, 6, "0"), (5, 6, "1"), (1, 5, "0"), (2, 5, "1")], [(3, ("m", 1)), (5, ("fl", 2)), (1, ("f", 1)), (2, ("l", 1))]) :: State
Test>
([], [(6, 7, "0"), (5, 7, "1"), (3, 6, "0"), (4, 6, "1"), (1, 5, "0"), (2, 5, "1")], [(7, ("cdab", 4)), (6, ("cd", 2)), (5, ("ab", 2)), (3, ("c", 1)), (4, ("d", 1)), (1, ("a", 1)), (2, ("b", 1))]) :: State
Test>
⊥₁ :: State
⊥₁: step: empty node list
CallStack (from HasCallStack):
  error, called at ./Huffman.lhs:304:19 in main:Huffman

Teljes feldolgozás (2 pont)

Ismételjük az előző függvényben megvalósított csúcsösszevonást egészen addig, amíg a State-ben a feldolgozásra váró csúcsok listája ki nem ürül! Az eredmény típusa legyen az alábbi:

type ProcessedState = ([Edge], [Node])

Ha feldolgoztuk az összes csúcsot, akkor adjuk vissza az élek listáját, illetve a feldolgozott csúcsok listáját egy párként.

steps :: State -> ProcessedState

Test>
([(6, 7, "0"), (4, 7, "1"), (3, 6, "0"), (5, 6, "1"), (1, 5, "0"), (2, 5, "1")], [(7, ("mfla", 6)), (6, ("mfl", 3)), (4, ("a", 3)), (3, ("m", 1)), (5, ("fl", 2)), (1, ("f", 1)), (2, ("l", 1))]) :: ProcessedState
Test>
([(6, 11, "0"), (10, 11, "1"), (8, 10, "0"), (9, 10, "1"), (4, 9, "0"), (5, 9, "1"), (7, 8, "0"), (3, 8, "1"), (1, 7, "0"), (2, 7, "1")], [(11, ("afmlt ", 15)), (6, ("a", 6)), (10, ("fmlt ", 9)), (8, ("fml", 4)), (9, ("t ", 5)), (4, ("t", 2)), (5, (" ", 3)), (7, ("fm", 2)), (3, ("l", 2)), (1, ("f", 1)), (2, ("m", 1))]) :: ProcessedState

Van-e szülő? (1 pont)

Egy csúcs azonosítója és az éllista alapján döntsük el, hogy a csúcsnak van-e szülője, vagyis vezet-e ki irányított él a kapott Id-vel azonosított csúcsból!

hasParent :: Id -> [Edge] -> Bool

Test>
False :: Bool
Test>
True :: Bool

Szülőhöz vezető él? (1 pont)

Egy csúcs azonosítója és az éllista alapján adjuk vissza a szülő felé vezető irányított élet! Ha nincs ilyen, váltsunk ki hibát az error függvénnyel!

Minden csúcsról tudhatjuk, hogy ha van szülője felé vezető éle, akkor csak egyetlen ilyen éle lehet.

findEdgeToParent :: Id -> [Edge] -> Edge

Test>
(4, 7, "1") :: Edge
Test>
(3, 6, "0") :: Edge
Test>
⊥₁ :: Edge
⊥₁: findEdgeToParent: no parent found: 7
CallStack (from HasCallStack):
  error, called at ./Huffman.lhs:368:20 in main:Huffman

Egy karakter kódja (2 pont)

Gyűjtsük össze a felépített adatszerkezetből egy csúcs kódját! Ehhez csak annyit kell tennünk, hogy a kapott csúcsból (pontosabban a kapott Id-hez tartozó csúcsból) elindulunk és követjük az irányított éleket. Ezek mindig a szülő csúcs felé vezetnek, amíg van ilyen. Menet közben pedig összegyűjtjük egy listában az összes érintett él Label értékét. A kódot megkapjuk az összegyűjtött címkéket fordított sorrendben összeolvasva.

getCodeForOne :: ProcessedState -> Id -> String

Test>
"010" :: String
Test>
"00" :: String
Test>
"111" :: String

Teljes kódtábla (1 pont)

type CodingTable = [(Char,String)]

A felépített adatszerkezetből állítsuk elő a teljes kódtáblát, melyben karakterekhez rendeljük a kódjukat! Szűrjük ki a kész állapot feldolgozott csúcsai közül azokat, melyek 1 karakterből álló szöveget tartalmaznak. Minden ilyen csúcs azonosítóját felhasználva a csúcsban tárolt egyetlen karakterhez tartozó kódot állítsuk elő a getCodeForOne függvénnyel!

getCodingTable :: ProcessedState -> CodingTable

Test>
[('a', "1"), ('m', "00"), ('f', "010"), ('l', "011")] :: CodingTable
Test>
[('a', "0"), ('t', "110"), (' ', "111"), ('l', "101"), ('f', "1000"), ('m', "1001")] :: CodingTable

Keresés a kódtáblában (1 pont)

Írjuk meg a findCode függvényt, mely ki tud keresni a kódtáblában egy adott karakterhez tartozó kódot! Feltesszük, hogy a kódtábla nem üres és mindig megtalálható benne a keresett karakter.

findCode :: Char -> CodingTable -> String

Test>
"011" :: String
Test>
"00" :: String

Kódolás (2 pont)

Kódoljunk le egy szöveget! Az egyes lépések az előző lépés eredményét használják. A lépések a következők:

  1. Készítsünk el a karakter-előfordulási statisztikát (countLetters).

  2. Állítsuk elő a kezdőállapotot (startState).

  3. Alkalmazzuk a csúcsösszevonó algoritmust (steps).

  4. Készítsük el a teljes kódtáblát (getCodingTable).

  5. Végül a kódtáblában kikeresve az egyes karaktereket készítsük el a kódolt szöveget, az egyes kódokat egymás után fűzve.

encode :: String -> String

Test>
"10110010101" :: String
Test>
"01011001011101111000011101010110110" :: String

Keresés a kódtáblában (folytatás) (1 pont)

Írjuk meg a findChar függvényt, mely kikeresi a kódtáblában egy megadott kódhoz tartozó karaktert! Feltesszük, hogy a kódtábla nem üres és mindig megtalálható benne a keresett kód.

findChar :: String -> CodingTable -> Char

Test>
'm' :: Char
Test>
'l' :: Char

Dekódolás (2 pont)

Dekódoljunk egy szöveget a megadott kódtábla alapján! Az eredeti szöveget karakterről-karakterre fejtjük vissza. Ehhez az alábbi lépések szükségesek:

  1. Keressük meg a kódtáblában azt a kódot, amelyik prefixe a paraméterül kapott kódsorozatnak. Ehhez a prefixhez tartozó karakter lesz a dekódolt szöveg első karaktere, melyet a findChar függvénnyel deríthetünk ki.

  2. Fejtsük vissza rekurzívan a maradék kódot. Az így visszafejtett szöveg elé fűzzük az előző pontban visszafejtett karaktert.

Megjegyzés: A dekódolás mindig egyértelmű, mert az előállított kódok nem prefixei egymásnak, így a dekódolandó szöveg mindig egyértelműen (mohó módon) felbontható a kódtábla alapján.

decode :: String -> CodingTable -> String

Test>
"almafa" :: String
Test>
"almafa" :: String
Test>
"alma a fa alatt" :: String

Pontozás