Popis řešení
This commit is contained in:
parent
e095eabb7d
commit
3e1ed1b686
1 changed files with 279 additions and 0 deletions
279
README.md
Normal file
279
README.md
Normal file
|
@ -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 a new issue