diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a17e7c --- /dev/null +++ b/README.md @@ -0,0 +1,279 @@ +# 33-3-4 Obsazování území + +Tento repozitář obsahuje kód a popis řešení KSP úlohy [Obsazování území](https://ksp.mff.cuni.cz/h/ulohy/33/zadani3.html#task-33-3-4), které našlo sadu domů s cenou +532293 (nejlepší ve výsledkové listině). + +Kód je psaný v Rustu a některé části se ovládaly upravováním zdrojového +kódu místo nějakého pěkného načítání parametrů z argumentů. Více detailů níže. + +## Architektura řešení + +Postup hledání řešení se skládá z několika různých kroků, které se v pozdějších fázích ručně míchaly podle uvážení (generování pomocí DB, kombinace pomocí řezů a optimalizace *podměst*). + +Obecná myšlenka je, že začneme s náhodnými validními řešeními a ty postupně vylepšujeme a kombinujeme. Nikdy neporušíme +invariant, že řešení je validní - tedy nikdy nevyrobíme řešení, které nemá pokrytý nějaký dům. + +Protože je mapa velká a všechny optimalizace poběží delší dobu, tak toto děláme jenom jednou a udržujeme si globální +databázi (používáme SQLite). Nekomplikujeme to synchronizací mezi více běžícími procesy najednou. [[db.rs](src/db.rs)] + +### Generování počátečních řešení + +Počáteční řešení generujeme kompletně náhodně: +- vybereme náhodnou pozici +- pokud je nepokrytá a je tam dům, koupíme +- opakujeme dokud není vše pokryto + +Udržujeme počet pokrytých domů a porovnáváme s celkovým počtem pro rychlé zjištění jestli už je pokryto. + +[Kód](src/population.rs#L11) + +### Generování řešení za pomoci databáze + +Jakmile už máme nějaká řešení, tak můžeme využít naši databázi pro generování řešení podobných těm, co už máme. + +Zvolíme si rozsah výsledných cen (např. 530000-560000) a každému domu z řešení s cenou v tomto rozsahu přidáme váhu +odpovídající tomu, jak je řešení dobré (lineární poměr). Díky tomu budou často použité domy z dobrých řešení mít větší pravděpodobnost na +zvolení. + +Taky přidáme malou šanci, že vybereme naprosto náhodný dům, abychom mohli najít i něco nového. + +[Kód](src/population.rs#L47) + +### Zlepšování řešení + +Jakmile máme nagenerované řešení, tak ho ještě před přidáním do databáze hladově zlepšíme. Máme dva postupy: +- posuny jednotlivých domů +- spojování dvojic domů v jeden (merge) + +Tyto kroky opakujeme dokud se už nic nezlepší. [Kód](src/optimization.rs#L11) + +#### Posuny jednotlivých domů +Každý dům *D* posuneme na nejlevnější pozici v jeho okolí, která zachová validitu řešení, nebo ho kompletně zrušíme, pokud +je zbytečný. + +Toto je potřeba dělat efektivně. Proto si vždy nejdřív spočítáme, kam je možné posunout dům aniž by se porušila +validita. Pro toto udržujeme pro celou mapu počty, kolikrát je které políčko pokryté. + +V okolí pokrytém domem *D* mohou být *kritické* domy, co mají tento počet 1. Kritické domy jsou domy, co nutně potřebují dům *D* aby byla +zachovaná validita. Najdeme nejsevernější, nejjižnější, nejvýchodnější a nejzápadnější kritický dům a podíváme se na +jejich vzdálenosti od okraje oblasti domu *D*. Tyto vzdálenosti nám říkají jak daleko můžeme dům *D* posunout v opačném +směru aniž bychom rozbili validitu. Celkově tak zjistíme obdélník, ve kterém se může *D* posouvat v lineárním čase. + +Z obdélníku už jenom vybereme nejlevnější dům a je hotovo. + +Posuny děláme v náhodném pořadí, ale vyzkoušíme všechny. Pokud se něco změnilo, musíme znovu zkusit všechny domy. Toto +je vcelku rychlé, v rámci sekund. + +[Kód posunů](src/optimization.rs#L165), [Kód obdélníku](src/optimization.rs#L117) + +#### Spojování domů +Pro každou dvojici domů *D1*, *D2*, které jsou dostatečně blízko (jejich oblasti se alespoň dotýkají) +zkusíme tyto dva domy zrušit a nahradit jedním. + +Použijeme stejnou techniku jako u posunů abychon nalezli obdélník, kam lze umístit dům, který nahradí oba domy. Musíme +ale změnit, které domy jsou kritické. Počet pokrytí tady totiž záleží na tom, jestli je dům pokrytý jen domem *D1* či +*D2* nebo oběma. Pokud je pokrytý jen jedním domem, tak je kritický pokud je číslo 1, pokud oběma, tak 2. + +Může nám tady vyjít obdélník se zápornou velikostí, v tom případě prostě nelze domy spojit protože by výsledný dům +nedosáhl na kritické domy. + +Tato optimalizace běží desítky sekund. + +[Kód spojování - ošklivý](src/optimization.rs#L229), [Kód multi obdélníku](src/optimization.rs#L57) + +### Kombinace řešení + +To, co jsme zatím popsali stačí na rychlé nalezení řešení kolem 650000 (za pár minut). Pojďme to vzít ještě dál! + +Řešení, co máme v databázi totiž můžeme kombinovat. Ukážeme si oba postupy, co jsme použili: +- spojování pomocí rovných řezů +- vyřezávání "podměst" + +#### Spojování pomocí řezů + +Představme si, že bychom vzali dvě řešení *L* a *R*, vybrali si nějaké *x* z rozsahu [0, 16384), kde uděláme svislou čáru a pak vzali +domy z řešení *L* vlevo od *x* a domy z řešení *R* vpravo od *x*. + +Z toho samozřejmě nemusí vzniknout validní řešení. Naivní ověření validity celého řešení trvá jednotky sekund a +spotřebuje hodně paměti, což znamená, že tohle nemůžeme dělat moc často pokud to neuděláme chytře. +Abychom to zvládli dělat efektivně, tak budeme potřebovat hned několik optimalizací. + +Budeme zametat přímkou zleva doprava. Začneme na x = 0 a postupně ho budeme zvětšovat po jedné, až do x = 16383. + +##### Levá a pravá linie +Budeme si průběžně udržovat domy nalevo a domy napravo od řezu. Zároveň si budeme počítat jak daleko doprava +sahá pokrytí levé strany a jak daleko doleva sahá pokrytí pravé strany pro každé *y*. + +Toto zabalíme do datových struktur, kterým budeme říkat levá linie a pravá linie ([LeftLine](src/combine.rs#L348), [RightLine](src/combine.rs#L380)). +Do levé linie se domy jen přidávají a z pravé se jen domy odebírají. + +Udržování dosahu pokrytí pro každé *y* svádí k použití nějaké stromové struktury na intervaly, protože se vždy bude +měnit souvislý úsek 1001 hodnot (či méně pokud jsme u okraje). My to neděláme, prostě máme pole velikosti 16384, které +aktualizujeme. Výkonnostně to stačí a pro tak malou velikost to možná bude i rychlejší pokud se budeme hodně dotazovat. + +Přidání domu *(x, y)* do levé linie znamená akorát zvětšení dosahu v rozsahu [*y*-500, *y*+500] na *x+500*. ([Kód](src/combine.rs#L356)) + +Odebrání domu *(x, y)* z pravé linie implementujeme vyresetováním dosahů v [*y*-500, *y*+500], spočítáním vertikálního +průniku pokrytí s tímto rozsahem pro každý zbývající dům a doplnění nových hodnot. ([Kód](src/combine.rs#L399)) + +##### Pseudokód jednoduchých řezů +- *x* = 0 +- dokud *x* < 16384 + - přidej domy na *x* do levé linie + - odeber domy na *x* z pravé linie + - ověř kompatibilitu liníí (viz níže) - pokud je kompatibilní, máme nové řešení + - *x* += 1 + + +##### Kompatibilita stran + +Abychom ověřili, že můžeme levou a pravou linii spojit do validního řešení, tak stačí pro každé y ověřit, že mezi +dosahem levé linie a dosahem pravé linie neleží žádný dům - ten by totiž byl nepokrytý. + +To se dá snadno naprogramovat dvěma cykly v sobě: +```rust +for y in 0..city.height() { + let max_left_covered_x = left.get_max_covered_x(y); + let min_right_covered_x = right.get_min_covered_x(y); + + for x in (max_left_covered_x + 1)..min_right_covered_x { + if city.is_house_xy(x, y) { + return false; + } + } +} +``` + +Vnitřní cyklus přes *x* se dá ale nahradit něčím lepším. Jde o dotaz, jestli existuje mezi dvěma domy na řádku další dům. To se +dá snadno vyřešit předpočítáním buď prefixových počtů domů na každém řádku nebo předpočítáním nejbližšího pravého domu. +Pak lze celý vnitřní cyklus nahradit porovnáním jednoho čísla. + +*Téhle optimalizace jsem si velmi dlouho nevšiml, a zrychlilo to běh verze popsané níže z několika hodin na několik +minut. A to jsem se neobtěžoval otočit směr průchodu, teď je to velmi ošklivé k procesorovým keším, protože procházíme +2D pole po sloupcích.* + +[Kód kontroly kompatibility](src/combine.rs#L317) +##### Více liníí najednou + +Můžeme rovnou udržovat několik levých linií a pravých linií *(používal jsem 1500 na každé straně, což je 2 250 000 párů)*. +To nám umožní udělat hned několik porovnání kompatibility na stejné *x* a neduplikujeme práci s aktualizacemi linií. + +Taky můžeme snadno udržovat pro každou linii cenu domů, které v ní jsou, a tedy můžeme jednoduchým součtem předem spočítat +jaká je výsledná cena, pokud tyto dvě linie zkombinujeme. Díky tomu můžeme začít zkoušet kompatibilitu od nejlepších +párů linií. + +Nový pseudokód pak vypadá takto: + +- *x* = 0 +- dokud *x* < 16384 + - přidej domy na *x* do levých linií + - odeber domy na *x* z pravých linií + - vyrob seznam párů (levá linie, pravá linie), spočítej ceny, jdi od nejlevnějšího páru: + - pokud je cena páru horší než nejlepší řešení, co máme, skonči cyklus a pokračuj na dalším *x* + - ověř kompatibilitu liníí - pokud je kompatibilní, máme nové nejlepší řešení, přidáme do DB + - *x* += 1 + +Tady stojí za explicitní zmínku, že nijak neukládáme řešení, co není nové nejlepší. To nám trochu zmenšuje počet nových +řešení do databáze, ale šetří to čas. + +##### A co horizontální řezy? + +Zatím jsme popisovali vertikální řezy podle *x*. Vše jsme naprogramovali pro levou a pravou stranu atd. Teď bychom mohli +to samé udělat pro *y*, nadefinovat si horní a dolní stranu a zduplikovat všechen kód. + +Místo toho ale máme mnohem snažší řešení: prostě provedeme transpozici celého města a vstupních řešení a pouštíme opět +vertikální řezy. Nalezená řešení potom zase transponujeme zpět. + +[Kód iterované části, co transponuje](src/combine.rs#L77-L91), [Kód transpozice měst](src/combine.rs#L97) +##### Keše + +Pořád existují další vylepšení, kterými jsme zrychlili běh kódu. Prvním z nich je přidání keše, která nám umožní +přeskočit výpočty kompatibility pokud se linie od posledně nezměnily. Proto si pro linie pamatujeme na kterém x se +naposledy změnily (`left_update_index` a `right_update_index`). Keš nám kešuje následující funkci: +``` +is_compatible(left_layout, right_layout, left_update_index, right_update_index, y_axis) +``` +*Keš je v kódu globální - proto je tam potřeba `y_axis`, ale hned další popsaná optimalizace víceméně odstranila možnost, že se použije mezi různými +spuštěními řezů. Taky měla mnohem větší význam když jsem ještě neměl optimalizaci popsanou v Kompatibilitě stran.* + +[Kód struktury](src/combine.rs#L20-L38), [Kód použití](src/combine.rs#L257) +##### Spodní meze + +Spodní meze (`MergeLowerBound`) jsou kritická optimalizace našich kombinací pomocí řezů. Říkají nám, co je nejmenší +cena, kterou umíme získat kombinací dvou řešení na jakémkoliv *x* (či *y*). Jsou to hodnoty, které si také ukládáme do +globální databáze, a zajištují nám, že nikdy nezkoušíme kombinovat stejnou dvojici řešení po stejné ose vícekrát (alespoň za +předpokladu, že se naše nejlepší řešení nikdy nezhorší). + +Díky tomu zkoušíme kombinovat jenom nová řešení, a ostatní ignorujeme. + +[Kód struktury](src/db.rs#L16) + +Za zmínku tady stojí, že ušetřilo asi 20% času, že se neptáme hashovací tabulky uvnitř cyklu přes *x*, ale předem +vyrobíme tabulku přeskočených kombinací která je prosté pole. [Kód tabulky](src/combine.rs#L194). +*Tohle je věc, které bych si nevšiml, kdybych nepoužil [flamegraph-rs](https://github.com/flamegraph-rs/flamegraph) na +zjištění, kde kód tráví nejvíc času.* + +##### Finální verze řezů +- vyrob tabulku přeskočených párů řešení (stejné nebo vždy moc drahé - spodní meze) +- *x* = 0 +- dokud *x* < 16384 + - přidej domy na *x* do levých linií + - odeber domy na *x* z pravých linií + - vyrob seznam párů (levá linie, pravá linie), odstraň přeskočené, spočítej ceny, jdi od nejlevnějšího páru: + - pokud je cena páru horší než nejlepší řešení, co máme, skonči cyklus a pokračuj na dalším *x* + - ověř kompatibilitu liníí (zkus keš) - pokud je kompatibilní, máme nové nejlepší řešení, přidáme do DB + - *x* += 1 +- nastav všem párům spodní mez na cenu nejlepšího řešení + +[Kód řezů](src/combine.rs#L119), [Kód konstrukce párů](src/combine.rs#L242-L245), [Kód cyklu přes páry](src/combine.rs#L251-L297) + +Tyto řezy začneme řezáním po ose x, pak po ose y, opakujeme dokud dvakrát po sobě stejná osa nic nenašla. Opakovat po +sobě stejnou osu má smysl, nové řešení může jít znova řezat po stejné ose. [Kód iterování](src/combine.rs#L52-L95) + +#### Vyřezávání podměst +Druhou kombinací je vyřezávání "podměst". + +Myšlenka je jednoduchá: vezmeme si nějaké řešení (typicky nejlepší), vybereme si oblast, kterou chceme samostatně +optimalizovat a zafixujeme všechny domy, co jsou mimo oblast. Zafixování provedeme tak, že všechno, co je pokryté +zafixovanými domy odstraníme. [Kód vyříznutí](src/subcity.rs#L39) + +Pak můžeme město oříznout a najednou řešíme stejnou úlohu, ale na menším městě - *podměstě* *(subcity)*. + +*Tohle je pěkný příklad případu, kdy se vymstí nejdřív všude použít globální konstantu 16384, protože najednou pracujeme +s městy s jinými rozměry (a dokonce různou výškou a šířkou).* + +##### Použití optimalizace podměst + +Protože podměsta jsou menší, tak je postup zlepšování automatizovaný, narozdíl od celého města, kdy jsme vybírali další +optimalizace ručně. Na podměsta používáme samostatné databáze. + +1. Nagenerujeme kompletně náhodná řešení (typicky 200) +2. V cyklu + - Zkoušíme kombinovat několik (typicky 500) nejlepších řešení pomocí řezů + - Nagenerujeme několik (typicky 100) vážených měst za pomoci DB +3. Když nás to přestane bavit + - spojíme nejlepší řešení podměsta s řešením, z kterého jsme ho vyříznuli + - provedeme iterované vylepšení (často nachází dvojice domů, které lze spojit v jeden) + - přidáme si do hlavní globální DB i suboptimální řešení protože se často vyplatí kombinovat pomocí řezů + - poté pustíme kombinace řezy na globální DB + +[Kód](src/optimize-subcity.rs) - tohle se ovládalo ze zdrojáku a není to moc pěkné. + +## Zdrojové kódy + +- `layouts.sqlite` - prázdná databáze s nadefinovanými tabulkami + +### Sdílené moduly +- `src/city.rs` - obecné věci ohledně města, domů, jejich dosahů, kontroly validity, načítání vstupu +- `src/db.rs` - databáze na řešení a spodní odhady (SQLite nebo jenom v paměti) +- `src/population.rs` - vyrábění nových řešení (kompletně náhodně nebo s DB) +- `src/optimization.rs` - vylepšování existujících řešení +- `src/combine.rs` - kombinování pomocí řezů +- `src/subcity.rs` - podměsta + +### Spustitelné binárky +- `src/main.rs` - primárně generování nových řešení pomocí DB +- `src/combine-layouts.rs` - kombinování pomocí řezů +- `src/optimize-subcity.rs` - vylepšování podměst +- `src/import-logs.rs` - import řešení z logů, použito jenom při založení hlavní DB +- `src/upload-bot.rs` - bot, který používá KSP API na automatické přehazování nejlepšího o 1 + zhoršovač řešení \ No newline at end of file