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