Řešení KSP úlohy 33-3-4 Obsazování území
https://ksp.mff.cuni.cz/h/ulohy/33/zadani3.html#task-33-3-4
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
279 lines
16 KiB
279 lines
16 KiB
4 years ago
|
# 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í
|