Ř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.
Jirka Sejkora 86a199af97 Popis použitých řešení pro kombinace 7 months ago
src Přidáno varování pro použití tokenu 7 months ago
Cargo.lock Add time output to the bot 8 months ago
Cargo.toml Add time output to the bot 8 months ago
README.md Popis použitých řešení pro kombinace 7 months ago
layouts.sqlite Add the initial database 7 months ago

README.md

33-3-4 Obsazování území

Tento repozitář obsahuje kód a popis řešení KSP úlohy Obsazování území, 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]

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

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

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

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ů, Kód obdélníku

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ý, Kód multi obdélníku

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, RightLine). 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)

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)

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ě:

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

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, Kód transpozice měst

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, Kód použití

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

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. Tohle je věc, které bych si nevšiml, kdybych nepoužil flamegraph-rs 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ů, Kód konstrukce párů, Kód cyklu přes páry

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í

Celou iterovanou část pouštíme na (typicky) 1500 nejlepších řešeních. I 3500 nejlepších řešení - 12 250 000 párů doběhlo v rozumném čase.

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í

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 - 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í