Browse Source

Popis řešení

master
Jirka Sejkora 3 years ago
parent
commit
3e1ed1b686
  1. 279
      README.md

279
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í
Loading…
Cancel
Save