Jirka Sejkora
4 years ago
1 changed files with 279 additions and 0 deletions
@ -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í |
Loading…
Reference in new issue