diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 6ceb46d..0000000 --- a/.gitmodules +++ /dev/null @@ -1,12 +0,0 @@ -[submodule "src/en"] - path = src/en - url = https://github.com/algorithmica-org/en - branch = master -[submodule "src/ru"] - path = src/ru - url = https://github.com/algorithmica-org/ru - branch = master -[submodule "src/tg"] - path = src/tg - url = https://github.com/algorithmica-org/tg - branch = master diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2271285..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -dist: xenial - -# before_script: -# - sudo apt-get install pandoc -script: -- git submodule update --recursive --remote -- wget https://github.com/jgm/pandoc/releases/download/2.7.2/pandoc-2.7.2-1-amd64.deb -- sudo dpkg -i pandoc-2.7.2-1-amd64.deb -- ./build.sh - -deploy: - provider: pages - skip_cleanup: true - github_token: $GITHUB_TOKEN - keep_history: true - local_dir: web - fqdn: algorithmica.org - on: - branch: master diff --git a/web/404.html b/404.html similarity index 100% rename from web/404.html rename to 404.html diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..036ff89 --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +algorithmica.org diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 2e72ca1..0000000 --- a/LICENSE +++ /dev/null @@ -1,427 +0,0 @@ -Attribution-ShareAlike 4.0 International - -======================================================================= - -Creative Commons Corporation ("Creative Commons") is not a law firm and -does not provide legal services or legal advice. Distribution of -Creative Commons public licenses does not create a lawyer-client or -other relationship. Creative Commons makes its licenses and related -information available on an "as-is" basis. Creative Commons gives no -warranties regarding its licenses, any material licensed under their -terms and conditions, or any related information. Creative Commons -disclaims all liability for damages resulting from their use to the -fullest extent possible. - -Using Creative Commons Public Licenses - -Creative Commons public licenses provide a standard set of terms and -conditions that creators and other rights holders may use to share -original works of authorship and other material subject to copyright -and certain other rights specified in the public license below. The -following considerations are for informational purposes only, are not -exhaustive, and do not form part of our licenses. - - Considerations for licensors: Our public licenses are - intended for use by those authorized to give the public - permission to use material in ways otherwise restricted by - copyright and certain other rights. Our licenses are - irrevocable. Licensors should read and understand the terms - and conditions of the license they choose before applying it. - Licensors should also secure all rights necessary before - applying our licenses so that the public can reuse the - material as expected. Licensors should clearly mark any - material not subject to the license. This includes other CC- - licensed material, or material used under an exception or - limitation to copyright. More considerations for licensors: - wiki.creativecommons.org/Considerations_for_licensors - - Considerations for the public: By using one of our public - licenses, a licensor grants the public permission to use the - licensed material under specified terms and conditions. If - the licensor's permission is not necessary for any reason--for - example, because of any applicable exception or limitation to - copyright--then that use is not regulated by the license. Our - licenses grant only permissions under copyright and certain - other rights that a licensor has authority to grant. Use of - the licensed material may still be restricted for other - reasons, including because others have copyright or other - rights in the material. A licensor may make special requests, - such as asking that all changes be marked or described. - Although not required by our licenses, you are encouraged to - respect those requests where reasonable. More_considerations - for the public: - wiki.creativecommons.org/Considerations_for_licensees - -======================================================================= - -Creative Commons Attribution-ShareAlike 4.0 International Public -License - -By exercising the Licensed Rights (defined below), You accept and agree -to be bound by the terms and conditions of this Creative Commons -Attribution-ShareAlike 4.0 International Public License ("Public -License"). To the extent this Public License may be interpreted as a -contract, You are granted the Licensed Rights in consideration of Your -acceptance of these terms and conditions, and the Licensor grants You -such rights in consideration of benefits the Licensor receives from -making the Licensed Material available under these terms and -conditions. - - -Section 1 -- Definitions. - - a. Adapted Material means material subject to Copyright and Similar - Rights that is derived from or based upon the Licensed Material - and in which the Licensed Material is translated, altered, - arranged, transformed, or otherwise modified in a manner requiring - permission under the Copyright and Similar Rights held by the - Licensor. For purposes of this Public License, where the Licensed - Material is a musical work, performance, or sound recording, - Adapted Material is always produced where the Licensed Material is - synched in timed relation with a moving image. - - b. Adapter's License means the license You apply to Your Copyright - and Similar Rights in Your contributions to Adapted Material in - accordance with the terms and conditions of this Public License. - - c. BY-SA Compatible License means a license listed at - creativecommons.org/compatiblelicenses, approved by Creative - Commons as essentially the equivalent of this Public License. - - d. Copyright and Similar Rights means copyright and/or similar rights - closely related to copyright including, without limitation, - performance, broadcast, sound recording, and Sui Generis Database - Rights, without regard to how the rights are labeled or - categorized. For purposes of this Public License, the rights - specified in Section 2(b)(1)-(2) are not Copyright and Similar - Rights. - - e. Effective Technological Measures means those measures that, in the - absence of proper authority, may not be circumvented under laws - fulfilling obligations under Article 11 of the WIPO Copyright - Treaty adopted on December 20, 1996, and/or similar international - agreements. - - f. Exceptions and Limitations means fair use, fair dealing, and/or - any other exception or limitation to Copyright and Similar Rights - that applies to Your use of the Licensed Material. - - g. License Elements means the license attributes listed in the name - of a Creative Commons Public License. The License Elements of this - Public License are Attribution and ShareAlike. - - h. Licensed Material means the artistic or literary work, database, - or other material to which the Licensor applied this Public - License. - - i. Licensed Rights means the rights granted to You subject to the - terms and conditions of this Public License, which are limited to - all Copyright and Similar Rights that apply to Your use of the - Licensed Material and that the Licensor has authority to license. - - j. Licensor means the individual(s) or entity(ies) granting rights - under this Public License. - - k. Share means to provide material to the public by any means or - process that requires permission under the Licensed Rights, such - as reproduction, public display, public performance, distribution, - dissemination, communication, or importation, and to make material - available to the public including in ways that members of the - public may access the material from a place and at a time - individually chosen by them. - - l. Sui Generis Database Rights means rights other than copyright - resulting from Directive 96/9/EC of the European Parliament and of - the Council of 11 March 1996 on the legal protection of databases, - as amended and/or succeeded, as well as other essentially - equivalent rights anywhere in the world. - - m. You means the individual or entity exercising the Licensed Rights - under this Public License. Your has a corresponding meaning. - - -Section 2 -- Scope. - - a. License grant. - - 1. Subject to the terms and conditions of this Public License, - the Licensor hereby grants You a worldwide, royalty-free, - non-sublicensable, non-exclusive, irrevocable license to - exercise the Licensed Rights in the Licensed Material to: - - a. reproduce and Share the Licensed Material, in whole or - in part; and - - b. produce, reproduce, and Share Adapted Material. - - 2. Exceptions and Limitations. For the avoidance of doubt, where - Exceptions and Limitations apply to Your use, this Public - License does not apply, and You do not need to comply with - its terms and conditions. - - 3. Term. The term of this Public License is specified in Section - 6(a). - - 4. Media and formats; technical modifications allowed. The - Licensor authorizes You to exercise the Licensed Rights in - all media and formats whether now known or hereafter created, - and to make technical modifications necessary to do so. The - Licensor waives and/or agrees not to assert any right or - authority to forbid You from making technical modifications - necessary to exercise the Licensed Rights, including - technical modifications necessary to circumvent Effective - Technological Measures. For purposes of this Public License, - simply making modifications authorized by this Section 2(a) - (4) never produces Adapted Material. - - 5. Downstream recipients. - - a. Offer from the Licensor -- Licensed Material. Every - recipient of the Licensed Material automatically - receives an offer from the Licensor to exercise the - Licensed Rights under the terms and conditions of this - Public License. - - b. Additional offer from the Licensor -- Adapted Material. - Every recipient of Adapted Material from You - automatically receives an offer from the Licensor to - exercise the Licensed Rights in the Adapted Material - under the conditions of the Adapter's License You apply. - - c. No downstream restrictions. You may not offer or impose - any additional or different terms or conditions on, or - apply any Effective Technological Measures to, the - Licensed Material if doing so restricts exercise of the - Licensed Rights by any recipient of the Licensed - Material. - - 6. No endorsement. Nothing in this Public License constitutes or - may be construed as permission to assert or imply that You - are, or that Your use of the Licensed Material is, connected - with, or sponsored, endorsed, or granted official status by, - the Licensor or others designated to receive attribution as - provided in Section 3(a)(1)(A)(i). - - b. Other rights. - - 1. Moral rights, such as the right of integrity, are not - licensed under this Public License, nor are publicity, - privacy, and/or other similar personality rights; however, to - the extent possible, the Licensor waives and/or agrees not to - assert any such rights held by the Licensor to the limited - extent necessary to allow You to exercise the Licensed - Rights, but not otherwise. - - 2. Patent and trademark rights are not licensed under this - Public License. - - 3. To the extent possible, the Licensor waives any right to - collect royalties from You for the exercise of the Licensed - Rights, whether directly or through a collecting society - under any voluntary or waivable statutory or compulsory - licensing scheme. In all other cases the Licensor expressly - reserves any right to collect such royalties. - - -Section 3 -- License Conditions. - -Your exercise of the Licensed Rights is expressly made subject to the -following conditions. - - a. Attribution. - - 1. If You Share the Licensed Material (including in modified - form), You must: - - a. retain the following if it is supplied by the Licensor - with the Licensed Material: - - i. identification of the creator(s) of the Licensed - Material and any others designated to receive - attribution, in any reasonable manner requested by - the Licensor (including by pseudonym if - designated); - - ii. a copyright notice; - - iii. a notice that refers to this Public License; - - iv. a notice that refers to the disclaimer of - warranties; - - v. a URI or hyperlink to the Licensed Material to the - extent reasonably practicable; - - b. indicate if You modified the Licensed Material and - retain an indication of any previous modifications; and - - c. indicate the Licensed Material is licensed under this - Public License, and include the text of, or the URI or - hyperlink to, this Public License. - - 2. You may satisfy the conditions in Section 3(a)(1) in any - reasonable manner based on the medium, means, and context in - which You Share the Licensed Material. For example, it may be - reasonable to satisfy the conditions by providing a URI or - hyperlink to a resource that includes the required - information. - - 3. If requested by the Licensor, You must remove any of the - information required by Section 3(a)(1)(A) to the extent - reasonably practicable. - - b. ShareAlike. - - In addition to the conditions in Section 3(a), if You Share - Adapted Material You produce, the following conditions also apply. - - 1. The Adapter's License You apply must be a Creative Commons - license with the same License Elements, this version or - later, or a BY-SA Compatible License. - - 2. You must include the text of, or the URI or hyperlink to, the - Adapter's License You apply. You may satisfy this condition - in any reasonable manner based on the medium, means, and - context in which You Share Adapted Material. - - 3. You may not offer or impose any additional or different terms - or conditions on, or apply any Effective Technological - Measures to, Adapted Material that restrict exercise of the - rights granted under the Adapter's License You apply. - - -Section 4 -- Sui Generis Database Rights. - -Where the Licensed Rights include Sui Generis Database Rights that -apply to Your use of the Licensed Material: - - a. for the avoidance of doubt, Section 2(a)(1) grants You the right - to extract, reuse, reproduce, and Share all or a substantial - portion of the contents of the database; - - b. if You include all or a substantial portion of the database - contents in a database in which You have Sui Generis Database - Rights, then the database in which You have Sui Generis Database - Rights (but not its individual contents) is Adapted Material, - - including for purposes of Section 3(b); and - c. You must comply with the conditions in Section 3(a) if You Share - all or a substantial portion of the contents of the database. - -For the avoidance of doubt, this Section 4 supplements and does not -replace Your obligations under this Public License where the Licensed -Rights include other Copyright and Similar Rights. - - -Section 5 -- Disclaimer of Warranties and Limitation of Liability. - - a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE - EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS - AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF - ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, - IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, - WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR - PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, - ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT - KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT - ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. - - b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE - TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, - NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, - INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, - COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR - USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN - ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR - DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR - IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. - - c. The disclaimer of warranties and limitation of liability provided - above shall be interpreted in a manner that, to the extent - possible, most closely approximates an absolute disclaimer and - waiver of all liability. - - -Section 6 -- Term and Termination. - - a. This Public License applies for the term of the Copyright and - Similar Rights licensed here. However, if You fail to comply with - this Public License, then Your rights under this Public License - terminate automatically. - - b. Where Your right to use the Licensed Material has terminated under - Section 6(a), it reinstates: - - 1. automatically as of the date the violation is cured, provided - it is cured within 30 days of Your discovery of the - violation; or - - 2. upon express reinstatement by the Licensor. - - For the avoidance of doubt, this Section 6(b) does not affect any - right the Licensor may have to seek remedies for Your violations - of this Public License. - - c. For the avoidance of doubt, the Licensor may also offer the - Licensed Material under separate terms or conditions or stop - distributing the Licensed Material at any time; however, doing so - will not terminate this Public License. - - d. Sections 1, 5, 6, 7, and 8 survive termination of this Public - License. - - -Section 7 -- Other Terms and Conditions. - - a. The Licensor shall not be bound by any additional or different - terms or conditions communicated by You unless expressly agreed. - - b. Any arrangements, understandings, or agreements regarding the - Licensed Material not stated herein are separate from and - independent of the terms and conditions of this Public License. - - -Section 8 -- Interpretation. - - a. For the avoidance of doubt, this Public License does not, and - shall not be interpreted to, reduce, limit, restrict, or impose - conditions on any use of the Licensed Material that could lawfully - be made without permission under this Public License. - - b. To the extent possible, if any provision of this Public License is - deemed unenforceable, it shall be automatically reformed to the - minimum extent necessary to make it enforceable. If the provision - cannot be reformed, it shall be severed from this Public License - without affecting the enforceability of the remaining terms and - conditions. - - c. No term or condition of this Public License will be waived and no - failure to comply consented to unless expressly agreed to by the - Licensor. - - d. Nothing in this Public License constitutes or may be interpreted - as a limitation upon, or waiver of, any privileges and immunities - that apply to the Licensor or You, including from the legal - processes of any jurisdiction or authority. - - -======================================================================= - -Creative Commons is not a party to its public -licenses. Notwithstanding, Creative Commons may elect to apply one of -its public licenses to material it publishes and in those instances -will be considered the “Licensor.” The text of the Creative Commons -public licenses is dedicated to the public domain under the CC0 Public -Domain Dedication. Except for the limited purpose of indicating that -material is shared under a Creative Commons public license or as -otherwise permitted by the Creative Commons policies published at -creativecommons.org/policies, Creative Commons does not authorize the -use of the trademark "Creative Commons" or any other trademark or logo -of Creative Commons without its prior written consent including, -without limitation, in connection with any unauthorized modifications -to any of its public licenses or any other arrangements, -understandings, or agreements concerning use of licensed material. For -the avoidance of doubt, this paragraph does not form part of the -public licenses. - -Creative Commons may be contacted at creativecommons.org. diff --git a/README.md b/README.md deleted file mode 100644 index 21b1863..0000000 --- a/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Algorithmica.org — Contribution FAQ - -**Q: I found a small bug/typo and I want to fix it. How can I do it?** - -Login to GitHub, go to `src//.md`, click "Edit", correct the error, press "commit" and create a tiny pull request. Someone will soon review and approve it. - -**Q: How to contribute an article?** - -If you want to contribute something more than just a bugfix, we advise cloning the repository and editing it locally. - -You just need to add a new markdown file in `src` directory. We recommend using specialized editors like [MarkText](https://marktext.app/), but you can use whatever you want as long as it complies with standard markdown syntax. - -It would be really great if you also checked how it will actually be displayed. To build the entire website, install [Pandoc](https://pandoc.org/), run `build.sh` and wait for ~10 seconds. The articles will be compiled into `web` directory, just find `.html` there. - -Once your pull request is merged into master, it will be automatically deployed to the web. - -**Q: May I use these articles somewhere else?** - -Sure, everything is published under Creative Commons. You can use it wherever you want, even commercially, but please provide a link to original post. - -*Note that some articles include diagrams borrowed from external resources. Since they aren't ours, we can't give you permission to use them. -Other than that you can use any articles.* diff --git a/build.sh b/build.sh deleted file mode 100755 index a5b7a38..0000000 --- a/build.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -mkdir -p web/{en,ru,tg} -for sec in {en,ru,tg} -do - cp -r src/${sec}/img web/${sec}/img -done -cd src -for file in `find {en,ru,tg} -iname "*.md" -type f` -do - section=${file:0:2} - edit="Редактировать" - view_history="История" - migration="Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там." - if [ "$section" = "ru" ]; then - sitename="Алгоритмика" - fi - if [ "$section" = "en" ]; then - sitename="Algorithmica" - edit="Edit this page" - view_history="View history" - migration="I'm writing a book. It's free and available online. You can find an updated version of this and many more of my old articles there.
— Sergey Slotin" - fi - if [ "$section" = "tg" ]; then - sitename="Tinkoff Generation" - fi - basename=`basename ${file%.md}` - index="FALSE" # TODO: swap true and false - title=`grep -m1 -Po "^# *\K(.*)$" ${file}` - echo "Building ${file}..." - if [ "$basename" != "index" ]; then - title="$title - $sitename" - index="TRUE" - fi - pandoc "${file}" \ - -o "../web/${file%.md}.html" \ - -H ../web/head.html \ - --css ../pandoc.css \ - --template ../web/template.html \ - --metadata title="$title" \ - --variable filename="$basename" \ - --variable section="$section" \ - --variable sitename="$sitename" \ - --variable edit="$edit" \ - --variable view_history="$view_history" \ - --variable migration="$migration" \ - --metadata index="$index" \ - --standalone --mathjax -done diff --git a/en/b-tree.html b/en/b-tree.html new file mode 100644 index 0000000..8f6fdf1 --- /dev/null +++ b/en/b-tree.html @@ -0,0 +1,288 @@ + + + + + + + Implicit Static B-trees - Algorithmica + + + + + + + + + + + + + + + + +
I'm writing a book. It's free and available online. You can find an updated version of this and many more of my old articles there.
— Sergey Slotin
+

Implicit Static B-trees

+

This is a follow up on a previous article about using Eytzinger memory layout to speed up binary search. Here we use implicit (pointerless) B-trees accelerated with SIMD operations to perform search efficiently while using less memory bandwidth.

+

It performs slightly worse on array sizes that fit lower layers of cache, but in low-bandwidth environments it can be up to 3x faster (or 7x faster than std::lower_bound).

+

B-tree layout

+

B-trees generalize the concept of binary search trees by allowing nodes to have more than two children.

+

Instead of single key, a B-tree node contains up to \(B\) sorted keys may have up to \((B+1)\) children, thus reducing the tree height in \(\frac{\log_2 n}{\log_B n} = \frac{\log B}{\log 2} = \log_2 B\) times.

+

They were primarily developed for the purpose of managing on-disk databases, as their random access times are almost the same as reading 1MB of data sequentially, which makes the trade-off between number of comparisons and tree height beneficial. In our implementation, we will make each the size of each block equal to the cache line size, which in case of int is 16 elements.

+

Normally, a B-tree node also stores \((B+1)\) pointers to its children, but we will only store keys and rely on pointer arithmetic, similar to the one used in Eytzinger array:

+
    +
  • The root node is numbered \(0\).

  • +
  • Node \(k\) has \((B+1)\) child nodes numbered \(\{k \cdot (B+1) + i\}\) for \(i \in [1, B]\).

  • +
+

Keys are stored in a 2d array in non-decreasing order. If the length of the initial array is not a multiple of \(B\), the last block is padded with the largest value if its data type.

+
const int nblocks = (n + B - 1) / B;
+alignas(64) int btree[nblocks][B];
+
+int go(int k, int i) {
+    return k * (B + 1) + i + 1;
+}
+

In the code, we use zero-indexation for child nodes.

+

Construction

+

We can construct B-tree similarly by traversing the search tree.

+
void build(int k = 0) {
+    static int t = 0;
+    if (k < nblocks) {
+        for (int i = 0; i < B; i++) {
+            build(go(k, i));
+            btree[k][i] = (t < n ? a[t++] : INF);
+        }
+        build(go(k, B));
+    }
+}
+

It is correct, because each value of initial array will be copied to a unique position in the resulting array, and the tree height is \(\Theta(\log_{B+1} n)\), because \(k\) is multiplied by \((B + 1)\) each time a child node is created.

+

Note that this approach causes a slight imbalance: “lefter” children may have larger respective ranges.

+ +

Here is a short but rather inefficient implementation that we will improve later:

+
int search(int x) {
+    int k = 0, res = INF;
+    start: // the only justified usage of the goto statement
+           // as doing otherwise would add extra inefficiency and more code
+    while (k < nblocks) {
+        for (int i = 0; i < B; i++) {
+            if (btree[k][i] >= x) {
+                res = btree[k][i];
+                k = go(k, i);
+                goto start;
+            }
+        }
+        k = go(k, B);
+    }
+    return res;
+}
+

The issue here is that it runs a linear search on the whole array, and also that it has lots of conditionals that costs much more than just comparing integers.

+

Here are some ideas to counter this:

+
    +
  • We could unroll the loop so that it performs \(B\) comparisons unconditionally and computes index of the right child node.

  • +
  • We could run a tiny binary search to get the right index, but there is considerable overhead to this.

  • +
  • We could code all the binary search comparisons by hand, or force compiler to do it so that there is no overhead.

  • +
+

But we’ll pick another path. We will honestly do all the comparisons, but in a very efficient way.

+

SIMD

+

Back in the 90s, computer engineers discovered that you can get more bang for a buck by adding circuits that do more useful work per cycle than just trying to increase CPU clock rate which can’t continue forever.

+

This worked particularly well for parallelizable workloads like video game graphics where just you need to perform the same operation over some array of data. This this is how the concept of SIMD became a thing, which stands for single instruction, multiple data.

+

Modern hardware can do lots of stuff under this paradigm, leveraging data-level parallelism. For example, the simplest thing you can do on modern Intel CPUs is to:

+
    +
  1. load 256-bit block of ints (which is \(\frac{256}{32} = 8\) ints),

  2. +
  3. load another 256-bit block of ints,

  4. +
  5. add them together,

  6. +
  7. write the result somewhere else

  8. +
+

…and this whole transaction costs the same as loading and adding just two ints—which means we can do 8 times more work. Magic!

+

So, as we promised before, we will perform all \(16\) comparisons to compute the index of the right child node, but we leverage SIMD instructions to do it efficiently. Just to clarify—we want to do something like this:

+
int mask = (1 << B);
+for (int i = 0; i < B; i++)
+    mask |= (btree[k][i] >= x) << i;
+int i = __builtin_ffs(mask) - 1;
+// now i is the number of the correct child node
+

…but ~8 times faster.

+

Actually, compiler quite often produces very optimized code that leverages these instructions for certain types of loops. This is called auto-vectorization, and this is the reason why a loop that sums up an array of shorts is faster (theoretically by a factor of two) than the same loop for ints: you can fit more elements on the same 256-bit block. Sadly, this is not our case, as we have loop-carried dependencies.

+

The algorithm we will implement:

+
    +
  1. Somewhere before the main loop, convert \(x\) to a vector of \(8\) copies of \(x\).

  2. +
  3. Load the keys stored in node into another 256-bit vector.

  4. +
  5. Compare these two vectors. This returns a 256-bit mask in which pairs that compared “greater than” are marked with ones.

  6. +
  7. Create a 8-bit mask out of that and return it. Then you can feed it to __builtin_ffs.

  8. +
+

This is how it looks using C++ intrinsics, which are basically built-in wrappers for raw assembly instructions:

+
// SIMD vector type names are weird and tedious to type, so we define an alias
+typedef __m256i reg;
+
+// somewhere in the beginning of search loop:
+reg x_vec = _mm256_set1_epi32(x);
+
+int cmp(reg x_vec, int* y_ptr) {
+    reg y_vec = _mm256_load_si256((reg*) y_ptr);
+    reg mask = _mm256_cmpgt_epi32(x_vec, y_vec);
+    return _mm256_movemask_ps((__m256) mask);
+}
+

After that, we call this function two times (because our node size / cache line happens to be 512 bits, which is twice as big) and blend these masks together with bitwise operations.

+

Complete implementation

+
#pragma GCC optimize("O3")
+#pragma GCC target("avx2")
+
+#include <x86intrin.h>
+#include <bits/stdc++.h>
+
+using namespace std;
+
+typedef __m256i reg;
+
+const int n = (1<<20), B = 16;
+const int nblocks = (n + B - 1) / B;
+const int INF = numeric_limits<int>::max();
+
+alignas(64) int btree[nblocks][B];
+
+int go(int k, int i) { return k * (B + 1) + i + 1; }
+
+void build(int k = 0) {
+    static int t = 0;
+    if (k < nblocks) {
+        for (int i = 0; i < B; i++) {
+            build(go(k, i));
+            btree[k][i] = (t < n ? a[t++] : INF);
+        }
+        build(go(k, B));
+    }
+}
+
+int cmp(reg x_vec, int* y_ptr) {
+    reg y_vec = _mm256_load_si256((reg*) y_ptr);
+    reg mask = _mm256_cmpgt_epi32(x_vec, y_vec);
+    return _mm256_movemask_ps((__m256) mask);
+}
+
+int search(int x) {
+    int k = 0, res = INF;
+    reg x_vec = _mm256_set1_epi32(x);
+    while (k < nblocks) {
+        int mask = ~(
+            cmp(x_vec, &btree[k][0]) +
+            (cmp(x_vec, &btree[k][8]) << 8)
+        );
+        int i = __builtin_ffs(mask) - 1;
+        if (i < B)
+            res = btree[k][i];
+        k = go(k, i);
+    }
+    return res;
+}
+

That’s it. This implementation should outperform even the state-of-the-art indexes used in high-performance databases, though it’s mostly due to the fact that data structures used in real databases have to support fast updates while we don’t.

+

Note that this implementation is very specific to the architecture. Older CPUs and CPUs on mobile devices don’t have 256-bit wide registers and will crash (but they likely have 128-bit SIMD so the loop can still be split in 4 parts instead of 2), non-Intel CPUs have their own instruction sets for SIMD, and some computers even have different cache line size.

+

Legacy version

+

If you consider using SIMD a form of cheating (not sure if this qualifies as “comparison-based binary search” anymore), or running it on older hardware, or need to use it with a custom data type and don’t have too much time, try replacing search with this simpler version:

+
int search(int x) {
+    int k = 0, res = INF;
+    while (k < nblocks) {
+        int mask = (1 << B);
+        for (int i = 0; i < B; i++)
+             mask |= (btree[k][i] >= x) << i;
+        int i = __builtin_ffs(mask) - 1;
+        if (i < B)
+            res = btree[k][i];
+        k = go(k, i);
+    }
+    return res;
+}
+

It is ~30% slower (which is still quite good, because std::lower_bound is ~600% slower), but it saves you from rewriting the comparator with SIMD by hand, which in most cases is totally doable, but requires some effort.

+ + diff --git a/en/cuda.html b/en/cuda.html new file mode 100644 index 0000000..91d4f6d --- /dev/null +++ b/en/cuda.html @@ -0,0 +1,406 @@ + + + + + + + Supercomputing for the Masses - Algorithmica + + + + + + + + + + + + + + + + +
I'm writing a book. It's free and available online. You can find an updated version of this and many more of my old articles there.
— Sergey Slotin
+

Supercomputing for the Masses

+

This is an html-rendered Jupyter notebook. If you want to do the exercises right here, open it in Colab or download and edit it locally. In former case, you need to complete a little quest and install CUDA and PyCuda, its Python binding. On a Debian-based machine, this will probably be enough: * apt-get install nvidia-cuda-dev nvidia-cuda-toolkit * pip install pycuda

+

Prerequisites: basic knowledge of Python and C, basic algorithms, and generally how computers work.

+

Subtlties of the Moore’s law

+

Here is a graph that roughly represents what is happening in the CPU world:

+

+

Moore’s law is the observation that the number of transistors in a microprocessor doubles about every two years. This roughly means that the performance doubles too.

+

You can see that around 2005 there became a shift in design .

+

The cores are more or less independent.

+

Modern GPUs appeared in early 2000s. They exploit the specific area they operate.

+

There are physical limitations to the speed of a core.

+

One solid one: the speed of light. You at least need the time for electromagnetic wave (this is light too) to pass from one side of the motherboard to another.

+

Some of them have

+

The default free GPUs available on Google Colab are rather powerfull. Author has no idea why Google does this, but this is awesome.

+

Why multiprocessing?

+

Clock frequencies — for example, Intel Core i7 can have. This gives an upper bound

+

There are two types of

+

General-purpose GPU

+

There was a period in time when hedge funds hired computer graphics guys from game companies because of their computing skills.

+

There are several.

+

This is like with Windows and Linux.

+

We will stick with CUDA, because it more spread, especially in fields where noone cares, like deep learning.

+

Heterogineous computing

+

CUDA programming involves running code on two different platforms concurrently: a host system with one or more CPUs and one or more GPUs.

+

Differences from CPUs

+

Threads

+

Threads on a CPU are generally heavyweight entities. The operating system must swap threads on and off CPU execution channels to provide multithreading capability. Context switches are therefore slow and expensive.

+

By comparison, threads on GPUs are extremely lightweight. In a typical system, thousands of threads are queued up for work — in warps of 32 threads each. If the GPU must wait on one warp of threads, it simply begins executing work on another. Because separate registers are allocated to all active threads, no swapping of registers or other state need occur when switching among GPU threads. Resources stay allocated to each thread until it completes its execution.

+

In short, CPU cores are designed to minimize latency for one or two threads at a time each, whereas GPUs are designed to handle a large number of concurrent, lightweight threads in order to maximize throughput.

+

Memory

+

The host system and the device each have their own distinct attached physical memories. As the host and device memories are separated by the PCI Express (PCIe) bus, items in the host memory must occasionally be communicated across the bus to the device memory or vice versa as described in What Runs on a CUDA-Enabled Device?

+

You can easily dump 98% of performance of you think this way.

+

Installing PyCUDA

+

CUDA is available for many languages.

+

Nice documentation can be found here: https://documen.tician.de/pycuda/index.html

+

If you are on Colab, go to Runtime -> Change runtime type -> Hardware accelerator and set it to “GPU”.

+
# you may want to clear the output of this cell after installation
+from IPython.display import clear_output
+ 
+# this might take a while
+!pip install pycuda
+
+clear_output()
+
import numpy as np
+
+from pycuda.compiler import SourceModule
+import pycuda.driver as drv
+import pycuda.autoinit
+

The basics

+

Let’s start with a simple example and then dive deeper.

+

Kernels

+

Just like C or C++, except that you use some custom built-in functions and specifiers.

+

CUDA is pretty much like normal C, except that you can specify some functions to be run as. Depending on implementation, the workflow goes as follows:

+

You need to think of your computer as a heterogenious machine: there is host data and device data.

+
    +
  • You move input data to device memory.
  • +
  • You run some computation on device.
  • +
  • You retrieve back this data.
  • +
+

In fact, kernel runs are concurrent — you program does not block until kernel run is complete. Newer devices can even run multiple kernels concurrently this way and wait for their results.

+

The famous \(A + B\) problem

+

For testing and coordination with host, we will use NumPy package. If you don’t have it, install it: pip install numpy.

+

NumPy is a package for linear algebra and array manupulation in Python. It is written in C and is very efficient, but runs solely on CPU, so we will benchmark against it.

+
# lets generate our test data: two float arrays filled with something random
+a = numpy.random.randn(100).astype('float32')
+b = numpy.random.randn(100).astype('float32')
+# the type needs to be specified in this case, because randn's default type is float64, but CUDA knows nothing about it
+
+# we need to create space where kernel should write its answers to
+dest = numpy.zeros_like(a)
+
+# this is the kernel itself
+mod = SourceModule("""
+    __global__ void add(float *dest, float *a, float *b) {
+        const int i = threadIdx.x;
+        dest[i] = a[i] + b[i];
+    }
+""")
+
+# you need to specify the source code, and PyCUDA will compile it
+add_kernel = mod.get_function("add")
+
+add_kernel(
+    drv.Out(dest),  # specifies that this memory should be accessible for writing
+    drv.In(a),  # specifies this should be accessible for reading
+    drv.In(b),
+    block=(100,1,1)  # we'll talk about it in a minute
+)
+
+assert np.allclose(dest, a + b), 'WA'  # checks that these are equal
+print('OK')
+
  File "<ipython-input-27-afc857479fe4>", line 19
+    %%time
+    ^
+SyntaxError: invalid syntax
+

Memory management

+

In CUDA C API, you need to allocate memory explicitly. So this is actually really nice.

+

There is also drv.InOut function, which makes it available for both reading and writing, but we won’t use it in this tutorial because we need to test our code too.

+

Most of the operations here are memory operations, so measuring performance here is useless. Don’t worry, we will get to more complex examples soon enough.

+

GPUs have very specific operations. However, in case of NVIDIA GPUs managing it is quite simple: the cards have compute capabilities (1.0, 1.1, 1.2, 1.3, 2.0, etc.) and all features added at capability \(x\) is also available at later versions. These can be checked at run-time or compile-time.

+

You can check differences in this Wikipedia article: https://en.wikipedia.org/wiki/CUDA#Version_features_and_specifications

+

Synchronization

+

Reduction is any array-wise operation.

+

Assume the following problem:

+

Dynamic programming

+

Consider the following recurrence:

+
## Problem: dynamic programming
+

Work vs. Latency

+

We actually think about both work and step complexity now.

+

Some tasks, especially in cryptography, cannot be parallelized. But some can.

+

Summing arrays in \(O(\log n)\) time

+

Assume we want to perform some associative (i. e. \(A*(B*C) = (A*B)*C\)) operation on an array of \(n\) elements. Say, sum it up.

+

Normally, we would do that with a simple loop:

+
float s = 0;
+for (int i = 0; i < n; i++) {
+     s += a[i]; 
+}
+

Its computation graphs looks like this:

+

+

This is optimal in terms of work complexity, but not in terms of step complexity: it’s \(O(n)\). We may want something that is a bit worse in terms of work complexity, but can be parallelized.

+

Let’s try this divide-and-conquer approach:

+

+

Now it’s still \(O(n)\) work complexity (you actually need exactly the same number of additions), but this is \(O(\log n)\) step complexity.

+

When you unroll the recursion from top to bottom, you will see that to get each required value,

+

+

Reducing small arrays

+
a = numpy.random.randn(2048).astype('float32')
+
+mod = SourceModule("""
+    __global__ void sum(float *dest, float *a, float *b) {
+        const int i = threadIdx.x;
+        // for l from 0 to logn:
+        //   __sync_threads()
+        //   if the thread is active
+        //     sum two elements into where they belong
+        // a[0] should containt the needed sum
+    }
+""")
+
+sum_kernel = mod.get_function("sum")
+
+add_kernel(
+    drv.InOut(a),
+    block=(1024,1,1)
+)
+
+assert np.allclose(dest, a + b), 'WA'  # checks that these are equal
+print('OK')
+

Warps and thread blocks

+

Threads are bandled in groups of 32. All threads in a group must be either waiting or performing the same operation. This is caused by architectural difficulties.

+

+

You can actually do the same stuff with 2d and 3d indexing — weird, right?

+

Atomics

+

Reducing big arrays

+

Reducing very big arrays

+

Now, things get harder. It’s time to tell how exactly GPU parallelism works.

+
+

Dense Matrix multiplication

+

Let’s get to our first example where using GPUs actually makes sense: matrix multiplication.

+

Sorting

+

Our last (and hardest task) is to implement sorting.

+

You might notice that we advocated divide-and-conquer approaches most of the time.

+

It’s true. They work. But we can’t get an algorithm that works already.

+
# we'll use a deep learning library for benchmarking because I'm not familiar with anything else 
+import torch
+
+a = torch.randn(10**8)
+b = a.cuda()
+
# this should run for ~15 secs
+%time c = torch.sort(a)
+%time c = torch.sort(b)
+
CPU times: user 15.2 s, sys: 177 µs, total: 15.2 s
+Wall time: 15.2 s
+CPU times: user 274 ms, sys: 237 ms, total: 511 ms
+Wall time: 511 ms
+

So, 30 times speedup. So, we now what we need to compete against.

+
b.sort()
+
(tensor([-5.4567, -5.3551, -5.3288,  ...,  5.3529,  5.4484,  5.4486],
+        device='cuda:0'),
+ tensor([55083205,  8383169, 73705953,  ..., 79814161, 50474932, 27805828],
+        device='cuda:0'))
+

There are two types of sorting algorithms: data-driven.

+

The second can be represented and analuzed with sorting networks. Here is the one that we’ll use, it’s called bitonic sort.

+

+

It has \(O(\log n)\) stages, in total they have \(1 + 2 + 3 + \ldots + \log n = O(\log^2 n\) blocks of comparisons that can’t be parallelized and invonve every element of the array. So, in total it has \(O(n \log^ n)\) work complexity, but \(O(\log^2 n)\) step complexity, which is pretty sweet.

+

It is actually not that hard to implement. To make it clear, here is a slow recursive Python implementation:

+
def bitonic_sort(a, up=False):
+    if len(a) <= 1:
+        return a
+    else: 
+        l = bitonic_sort(x[:len(a) // 2], True)
+        r = bitonic_sort(x[len(a) // 2:], False)
+        return bitonic_merge(first + second, up)
+
+def bitonic_merge(a, up): 
+    # assume input a is bitonic, and sorted list is returned 
+    if len(a) == 1:
+        return a
+    else:
+        bitonic_compare(a, up)
+        l = bitonic_merge(a[:len(a) // 2], up)
+        r = bitonic_merge(a[len(a) // 2:], up)
+        return l + r
+
+def bitonic_compare(a, up):
+    dist = len(a) // 2
+    for i in range(dist):  
+        if (a[i] > a[i + dist]) == up:
+            a[i], a[i + dist] = a[i + dist], x[i]  # this is how swap is done in Python
+
bitonic_sort([57, 179, 42, 17, 300, 111])
+
[300, 179, 111, 57, 42, 17]
+
a = np.random.randn(10**8).astype('float32')
+
---------------------------------------------------------------------------
+
+NameError                                 Traceback (most recent call last)
+
+<ipython-input-27-58a927c14aae> in <module>()
+----> 1 a = np.random.randn(10**8).astype('float32')
+
+
+NameError: name 'np' is not defined
+

Why CUDA

+

Most of it still applicable.

+

Again, GPU programming is very specific.

+

SSE and tensor cores.

+

Kernels

+

Just like C or C++, except that you use some custom built-in functions and specifiers.

+

CUDA is pretty much like normal C, except that you can specify some functions to be run as. Depending on implementation, the workflow goes as follows:

+

You need to think of your computer as a heterogenious machine: there is host data and device data.

+
    +
  • You move input data to device memory.
  • +
  • You run some computation on device.
  • +
  • You retrieve back this data.
  • +
+

In fact, kernel runs are concurrent — you program does not block until kernel run is complete. Newer devices can even run multiple kernels concurrently this way and wait for their results.

+

What you need to understand about GPUs is that they are extremely specialised for their applications.

+

Intrinsics for that.

+

Now, a lot of value comes from cryptocurrency and deep learning. The latter relies on two specific operations: matrix multiplications for linear layers and convolutions for convolutional layers used in computer vision.

+

First, they introduced “multiply-accumulate” operation (e. g. x += y * z) per 1 GPU clock cycle.

+

Google uses Tensor Processing Units. Nobody really knows how they work (proprietary hardware that they rent, not sell).

+

Each tensor core perform operations on small matrices with size 4x4. Each tensor core can perform 1 matrix multiply-accumulate operation per 1 GPU clock. It multiplies two fp16 matrices 4x4 and adds the multiplication product fp32 matrix (size: 4x4) to accumulator (that is also fp32 4x4 matrix).

+

This is a lot of work per

+

Well, you don’t really need anything more precise than that for deep learning anyway.

+

It is called mixed precision because input matrices are fp16 but multiplication result and accumulator are fp32 matrices.

+

Probably, the proper name would be “4x4 matrix cores”, however NVIDIA marketing team decided to use “tensor cores”.

+

So, see, this is not exactly fair comparison.

+

+* +
+You need to extend this graph just a little bit: last November, NVIDIA’s stock dropped 30% following Bitcoin crash, so I wouldn’t be so hopefull +
+
    +
  • +
+

down to int4 (16-valued, you heard correct)

+

You need to know a lot of this specialised stuff to write efficient code. So this is a bad idea to write libraries from scratch.

+

Anyway, for pedagogical and recreational reasons, today we will reinvent the wheel and do a matrix multiply.

+

Reducing an array

+

It seems to be simple: you just need to .

+

What actually happens when you do s += x? This is not a single operation. Actually, four things happen:

+
    +
  1. Read \(x\) into register
  2. +
  3. Read \(s\) into register
  4. +
  5. Calculate \(s + x\)
  6. +
  7. Write it back to wherever \(s\) was initially
  8. +
+

Two threads may execute it in an interleaved fashion. Say thread A could get \(s\), but a nanosecond later thread B will be writing here, but thread A doesn’t know about it and will re-write unchanged value.

+

Note: Atomics to do that

+

for small data types they are implemented on the hardware level and much more faster than that.

+

std::atomic is introduced to handle atomic operations in multi-thread context. In multi-thread environment, when two threads operating on the same variable, you must be extra careful to avoid race conditions.

+

Memory types

+

If all the various types of device memory were to race, here’s how the race would turn out:

+

Register size (= machine word width) is 32 bits, but they also contain 64bit capability (otherwise having more than 4gb of memory would not be possible).

+
    +
  • 1st place: Register memory
    This is the data visible only to the thread that wrote it. It lasts only for the lifetime of that thread.
  • +
  • 2nd place: Shared Memory
    Shared to all threads within thread block. Lasts only for the lifetime of that block. This type of memory allows communication (data sharing) between threads. This is why you should
  • +
  • 3rd place: Constant Memory
  • +
  • 4th: Texture Memory
  • +
  • Tie for last place: Local Memory and Global Memory
  • +
+

What you need to care for now is register

+

For now, you need to care about differe

+

Accessing global memory takes hundreds.

+

Problem: dense matrix multiplication

+

A lot of these are actually sparse. You can do stuff with social network graphs or web graphs.

+

Cool. But let’s disapploint us for a bit:

+ + diff --git a/en/eytzinger.html b/en/eytzinger.html new file mode 100644 index 0000000..c721be7 --- /dev/null +++ b/en/eytzinger.html @@ -0,0 +1,311 @@ + + + + + + + Eytzinger Binary Search - Algorithmica + + + + + + + + + + + + + + + + +
I'm writing a book. It's free and available online. You can find an updated version of this and many more of my old articles there.
— Sergey Slotin
+

Eytzinger Binary Search

+

This tutorial is loosely based on a 46-page paper by Paul-Virak Khuong and Pat Morin “Array layouts for comparison-based searching” and describes one particular way of performing efficient binary search by rearranging elements of a sorted array in a cache-friendly way.

+

We briefly review relevant concepts in processor architecture; if you want to get deeper, we recommend reading the original 2015 paper, as well as these articles:

+ +

Our minimalistic implementation is only ~15 lines of code while offering 4-5x speedup over std::lower_bound. The exact speedup depends a lot on available memory bandwidth (see notes below).

+

If you are writing a contest right now, stuck on a problem where binary search is a bottleneck, and suddenly remembered about this article, jump straight to “complete implementation”, it’s compilable and copy-pastable.

+

Why is binary search slow?

+

Here is a standard way of searching for the first element not less than \(x\) in a sorted array:

+
int lower_bound(int x) {
+    int l = 0, r = n - 1;
+    while (l < r) {
+        int t = (l + r) / 2;
+        if (a[t] >= x)
+            r = t;
+        else
+            l = t + 1;
+    }
+    return a[l];
+}
+

The running time of this (or any) algorithm is not just the “cost” of all its arithmetic operations, but rather this cost plus the time spent waiting for data to be fetched from memory. Thus, depending on the algorithm and problem limitations, it can be CPU-bound or memory-bound, meaning that the running time is dominated by one of its components.

+

If array is large enough—usually around the point where it stops fitting in cache and fetches become significantly slower—the running time of binary search becomes dominated by memory fetches.

+

To give an idea, the following code is only ~5% slower for \(n \approx 10^6\):

+
int slightly_slower_lower_bound(int x) {
+    int l = 0, r = n - 1;
+    while (l < r) {
+        volatile int s = 0; // volatile to prevent compiler from cutting this code out
+        for (int i = 0; i < 10; i++)
+            s += i;
+        int t = (l + r) / 2;
+        if (a[t] >= x)
+            r = t;
+        else
+            l = t + 1;
+    }
+    return a[l];
+}
+

To be more precise about it, we briefly explain how memory fetching works.

+

How caching works

+

Here is a famous quote about caching:

+

+

The reality is much more complicated, e. g. main memory has pagination and HDD is actually a rotating physical thing with weird access patterns, but we stick to this analogy and introduce some important entities to it:

+
    +
  • Cache hierarchy is a memory architecture which uses a hierarchy of memory stores based on varying access speeds to cache data. Adjacent cache layers usually differ in size by a factor of 8 to 10 and in latency by a factor of 3 to 5. Most modern CPUs have 3 layers of cache (called L1, L2 and L3 from fastest / smallest to slowest / largest) with largest being a few megabytes large.

  • +
  • Cache line is the unit of data transfer between CPU and main memory. The cache line of your PC is most likely 64 bytes, meaning that the main memory is divided into blocks of 64 bytes, and whenever you request a byte, you are also fetching its cache line neighbours regardless whether your want it or not. Fetching a cache line is like grabbing a 6-pack.

  • +
  • Eviction policy is the method for deciding which data to retain in the cache. In CPUs, it is controlled by hardware, not software. For simplicity, programmer can assume that least recently used (LRU) policy is used, which just evicts the item that hasn’t been used for the longest amount of time. This is like preferring beer with later expiration dates.

  • +
  • Bandwidth is the rate at which data can be read or stored. For the purpose of designing algorithms, a more important characteristic is the bandwidth-latency product which basically tells how many cache lines you can request while waiting for the first one without queueing up. It is around 5 or more on most systems. This is like having friends whom you can send for beers asynchronously.

  • +
  • Temporal locality is an access pattern where if at one point a particular item is requested, it is likely that this same location will be requested again in the near future. This is like fetching the same type of beer over and over again.

  • +
  • Spacial locality is an access pattern where if a memory location is requested, it is likely that a nearby memory locations will be requested again in the near future. This is like storing the kinds of beer that you like on the same shelf.

  • +
+

The main problem with binary search over a sorted array is that its memory accesses pattern is neither temporary nor spacially local. For example, element \(\lfloor \frac n 2 \rfloor\) is accessed very often (every search) and element \(\lfloor \frac n 2 \rfloor + 1\) is not, while they are probably occupying the same cache line. In general, only the first 3-5 reads are temporary local and only the last 3-4 reads are spacially local, and the rest are just random memory accesses.

+

We can overcome this by enumerating and permuting array elements in a more cache-friendly way. The numeration we will use is actually half a millenium old, and chances are you already know it.

+

The Eytzinger layout

+

Michaël Eytzinger is a 16th century Austrian nobleman known for his work on genealogy, particularily for a system for numbering ancestors called ahnentafel (German for “ancestor table”).

+

Ancestry mattered a lot back then, but writing down that data was expensive. Ahnentafel allows displaying a person’s genealogy compactly, without wasting extra space by drawing diagrams.

+

It lists a person’s direct ancestors in a fixed sequence of ascent. First, the person theirself is listed as number 1, and then, recursively, for each person numbered \(k\), their father is listed as \(2k\) and their mother as \((2k+1)\).

+

Here is the example for Paul I, the great-grandson of Peter I, the Great:

+
    +
  1. Paul I

  2. +
  3. Peter III (Paul’s father)

  4. +
  5. Catherine II (Paul’s mother)

  6. +
  7. Charles Frederick (Peter’s father, Paul’s paternal grandfather)

  8. +
  9. Anna Petrovna (Peter’s mother, Paul’s paternal grandmother)

  10. +
  11. Christian August (Catherine’s father, Paul’s maternal grandfather)

  12. +
  13. Johanna Elisabeth (Catherine’s mother, Paul’s maternal grandmother)

  14. +
+

Apart from being compact, it has some nice properties, like that all even-numbered persons are male and all odd-numbered (possibly apart from 1) are female.

+

One can also find the number of a particular ancestor only knowing the genders of their descendants. For example, Peter the Great’s bloodline is Paul I → Peter III → Anna Petrovna → Peter the Great, so his number should be \(((1 \times 2) \times 2 + 1) \times 2 = 10\).

+

In computer science, this enumeration has been widely used for implicit (i. e. pointer-free) implementation of heaps, segment trees, and other binary tree structures, where instead of names it stores underlying array items.

+

This is how this layout will look when applied to binary search:

+

+

You can immediately see how its temporal locality is better (in fact, theoretically optimal) as the elements closer to the root are closer to the beginning of the array, and thus are more likely to be fetched from cache.

+

Construction

+

Here is a function that constructs Eytzinger array by traversing the original search tree.

+

It takes two indexes \(i\) and \(k\)—one in the original array and one in constructed—and recursively goes to two branches until a leaf node is reached, which could simply be checked by asserting \(k \leq n\) as Eytzinger array should have same number of items.

+
const int n = 1e5;
+int a[n], b[n+1];
+
+int eytzinger(int i = 0, int k = 1) {
+    if (k <= n) {
+        i = eytzinger(i, 2 * k);
+        b[k] = a[i++];
+        i = eytzinger(i, 2 * k + 1);
+    }
+    return i;
+}
+

Despite being recursive, this is actually a really fast implementation as all memory reads are sequential.

+

Note that the first element is left unfilled and the whole array is essencially 1-shifted. This will actually turn out to be a huge performance booster.

+

Binary search implementation

+

We can now descend this array using only indices: we just start with \(k=1\) and execute \(k := 2k\) if we need to go left and \(k := 2k + 1\) if we need to go right. We don’t even need to store and recalculate binary search boundaries anymore.

+

The only problem arises when we need to restore the index of the resulting element, as \(k\) may end up not pointing to a leaf node. Here is an example of how that can happen:

+
    array:  1 2 3 4 5 6 7 8
+eytzinger:  4 2 5 1 6 3 7 8
+1st range:  ---------------  k := 1
+2nd range:  -------          k := 2*k      (=2)
+3rd range:      ---          k := 2*k + 1  (=5)
+4th range:        -          k := 2*k + 1  (=11)
+

Here we query array of \([1, …, 8]\) for the lower bound of \(x=4\). We compare it against \(4\), \(2\) and \(5\), and go left-right-right and end up with \(k = 11\), which isn’t even a valid array index.

+

Note that, unless the answer is the last element of the array, we compare \(x\) against it at some point, and after we learn that it is not less than \(x\), we start comparing \(x\) against elements to the left, and all these comparisons will evaluate true (i. e. leading to the right). Hence, the solution to restoring the resulting element is to cancel some number of right turns.

+

This can be done in an elegant way by observing that the right turns are recorded in the binary notation of \(k\) as 1-bits, and so we just need to find the number of trailing ones in the binary notation and right-shift \(k\) by exactly that amount.

+

To do this we can invert the number (~x) and call “find first set” instruction available on most systems. In GCC, the corresponding builtin is __builtin_ffs.

+
int search(int x) {
+    int k = 1;
+    while (k <= n) {
+        if (b[k] >= x)
+            k = 2 * k;
+        else
+            k = 2 * k + 1;
+    }
+    k >>= __builtin_ffs(~k);
+    return b[k];
+}
+

Note that \(k\) will be zero if binary search returned no result (i. e. all elements are less than \(x\) and all turns were right-turns that got canceled). In that case, you can put a special flag in the first element of b.

+

This is already 2-3 times faster than std::lower_bound, but we are not going to stop there and apply a series of small incremental improvements.

+

Branch-free

+

Compiled program instructions are stored and loaded from main memory too, just as normal data. They are fetched during execution by similar mechanisms, and they have a separate instruction cache. In fact, in large applications you can sometimes remove blocks of literally unused code, and the program may run faster because of better instruction cache hit rate, but this is a topic for another article.

+

To avoid performance hits caused by memory latency here, CPU loads 20-ish instructions ahead of time, but to do this it needs to know ahead of time which instructions to fetch. If a program has conditional execution (if-s, while-s, for-s) there is no option other than to take a guess.

+

Branch misprediction (guessing “wrong” branch of “if”) costs around 10-20 cycles. To partially negate this penalty, hardware branch predictors were developed. These are complex ad-hoc systems that use statistical methods—some even use simple neural networks—to make a more accurate guess.

+

In case of binary search, if all of our data is random, branch prediction doesn’t help at all, just because it can’t: all comparisons are 50-50. This is why we need to get rid of if-s and rewrite our main loop the following way:

+
while (k <= n)
+    k = 2 * k + (b[k] < x);
+

It also directly saves us from executing a few unnecessary arithmetic instructions.

+

Prefetching

+

Compiler doesn’t like when CPU is sitting idle while waiting for memory fetches. Sometimes it can take a guess about which cache line is going to be needed soon and fetch it ahead of time (recall that bandwidth-latency product is usually much larger than 1).

+

This works well for simple access patterns, like iterating over array in increasing or decreasing order, but for something complex like what we have here it’s not going to perform well.

+

As we know a bit more about our problem than the compiler does, we can explicitly tell it to prefetch a cache line we need. This is done by __builtin_prefetch in GCC:

+
while (k <= n) {
+    __builtin_prefetch(b + k * block_size);
+    k = 2 * k + (b[k] < x);
+}
+

Here, block_size equals 16, which is precisely how many ints are needed to cover a cache line. When we reference cache line at b + k * block_size, we are referencing \(k\)’s grand-grandson (block_size = \(2 \times 2 \times 2 \times 2\), or 4 left turns) and possibly some of his neighbours in his layer (recall that indexes at the same level are just consecutive numbers).

+

The whole point of doing this is that there is a good chance that we will prefetch an element that we will use later on \((i+4)\)-th iteration. What chance, exactly? Well, it turns out that it is constant for each iteration.

+

Memory allignment

+

Note that for each layer in the tree, except for the first 4 and possibly the last one, the number of nodes in that layer is divisible by 16, the block size. This means that the fraction of covered nodes on each iteration depends only on the position of the first offset of the array in respect to its cache line. But what is more important is that it can be made that all of \(k\)’s grand-grandchildren are covered by the same cache line.

+

The way to achieve this is to place the first element of the array to the 1st position (0-indexed) of a cache line, or placing the array itself on the beginning of a cache line, since its first (i. e. b[0]) element is blank by design. This way the next \(1 + 2 + 4 + 8 = 15\) elements of first 4 layers will occupy the rest of the cache line, and the rest of the array is alligned in nice 16-element blocks of nodes that share a grandpa.

+

We just need to ask memory manager to allocate our array on the beginning of a cache line (by default it allocates your arrays wherever it wants), and that’s it. To do this, we can use alignas specifier:

+
alignas(64) int b[n+1];
+

This is it. Now our algorithm is constantly prefetching 4 layers / cache lines ahead of time, which is covered by the bandwidth of our RAM. This way the effective latency is reduced by a factor of 4, and we’re basically trading off bandwidth for latency.

+

Complete implementation

+
#pragma GCC optimize("O3")
+#include <bits/stdc++.h>
+
+using namespace std;
+
+const int n = (1<<20);
+const int block_size = 16; // = 64 / 4 = cache_line_size / sizeof(int)
+alignas(64) int a[n], b[n+1];
+
+int eytzinger(int i = 0, int k = 1) {
+    if (k <= n) {
+        i = eytzinger(i, 2 * k);
+        b[k] = a[i++];
+        i = eytzinger(i, 2 * k + 1);
+    }
+    return i;
+}
+
+int search(int x) {
+    int k = 1;
+    while (k <= n) {
+        __builtin_prefetch(b + k * block_size);
+        k = 2 * k + (b[k] < x);
+    }
+    k >>= __builtin_ffs(~k);
+    return k;
+}
+

Few more things to note:

+
    +
  • It works best when \(n\) is a power of 2 or close to it, because otherwise the branch predictor will have a hard time figuring out whether or not to unroll the \((\log n)\)-th cycle.

  • +
  • Its performance varies by cache size and array length, but stays >3x even on smaller arrays (<1MB).

  • +
  • Preprocessing isn’t costly. It is around 1% of the cost of firing the same number of queries as the array size.

  • +
  • Modern hardware won’t penalize you for prefetching cache lines that aren’t yours, though this maybe be an issue for older CPUs, which can be solved by a simple if statement.

  • +
  • For some reason, basic binary search implementation (the very first code block in this article) is already ~20% faster than std::sort.

  • +
+

What about B-trees?

+

B-trees are basically \((k+1)\)-ary trees, meaning that they store \(k\) elements in each node and choose between \((k+1)\) possible branches instead of 2.

+

They are widely used for indexing in databases, especially those that operate on-disk, because if \(k\) is big, this allows large sequential memory accesses while reducing the height of the tree.

+

To perform static binary searches, one can implement a B-tree in an implicit way, i. e. without actually storing any pointers and spending only \(O(1)\) additional memory, and \(k\) could be made equal to the cache line size so that each node request fetches exactly one cache line.

+

+

Turns out, they have the same rate of growth but sligtly larger compute-tied constant. While the latter is explainable (our while loop only has like 5 instructions; can’t outpace that), the former is surprising.

+

Let’s assume that arithmetic costs nothing and do simple cache block analysis:

+
    +
  • The Eytzinger binary search is supposed to be \(4\) times faster if compute didn’t matter, as it requests them ~4 times faster on average.

  • +
  • The B-tree makes \(\frac{\log_{17} n}{\log_2 n} = \frac{\log n}{\log 17} \frac{\log 2}{\log n} = \frac{\log 2}{\log 17} \approx 0.245\) memory access per each request of binary search, i. e. it requests ~4 times less cache lines to fetch

  • +
+

This explains why they have roughly the same slope.

+

Note that this method, while being great for single-threaded world, is unlikely to make its way into database and heavy multi-threaded applications, because it sacrifices bandwidth to achieve low latency.

+

Part 2 explores efficient implementation of implicit static B-trees in bandwidth-constrained environment.

+ + diff --git a/en/img/btree.png b/en/img/btree.png new file mode 100644 index 0000000..ab28903 Binary files /dev/null and b/en/img/btree.png differ diff --git a/en/img/cache_and_beer.png b/en/img/cache_and_beer.png new file mode 100644 index 0000000..27f3e63 Binary files /dev/null and b/en/img/cache_and_beer.png differ diff --git a/en/img/eytzinger.png b/en/img/eytzinger.png new file mode 100644 index 0000000..97237c7 Binary files /dev/null and b/en/img/eytzinger.png differ diff --git a/en/img/img/btree.png b/en/img/img/btree.png new file mode 100644 index 0000000..ab28903 Binary files /dev/null and b/en/img/img/btree.png differ diff --git a/en/img/img/cache_and_beer.png b/en/img/img/cache_and_beer.png new file mode 100644 index 0000000..27f3e63 Binary files /dev/null and b/en/img/img/cache_and_beer.png differ diff --git a/en/img/img/eytzinger.png b/en/img/img/eytzinger.png new file mode 100644 index 0000000..97237c7 Binary files /dev/null and b/en/img/img/eytzinger.png differ diff --git a/en/index.html b/en/index.html new file mode 100644 index 0000000..2994b08 --- /dev/null +++ b/en/index.html @@ -0,0 +1,50 @@ + + + + + + + Algorithmica + + + + + + + + + + + + + +

Algorithmica

+
+The website has moved. +
+ + diff --git a/web/favicon.ico b/favicon.ico similarity index 100% rename from web/favicon.ico rename to favicon.ico diff --git a/web/fonts/cmu.woff2 b/fonts/cmu.woff2 similarity index 100% rename from web/fonts/cmu.woff2 rename to fonts/cmu.woff2 diff --git a/web/fonts/garamond.woff2 b/fonts/garamond.woff2 similarity index 100% rename from web/fonts/garamond.woff2 rename to fonts/garamond.woff2 diff --git a/web/fonts/inconsolata.woff2 b/fonts/inconsolata.woff2 similarity index 100% rename from web/fonts/inconsolata.woff2 rename to fonts/inconsolata.woff2 diff --git a/web/fonts/opensans.woff2 b/fonts/opensans.woff2 similarity index 100% rename from web/fonts/opensans.woff2 rename to fonts/opensans.woff2 diff --git a/web/fonts/original/cmu.ttf b/fonts/original/cmu.ttf similarity index 100% rename from web/fonts/original/cmu.ttf rename to fonts/original/cmu.ttf diff --git a/web/fonts/original/garamond.ttf b/fonts/original/garamond.ttf similarity index 100% rename from web/fonts/original/garamond.ttf rename to fonts/original/garamond.ttf diff --git a/web/fonts/original/inconsolata.ttf b/fonts/original/inconsolata.ttf similarity index 100% rename from web/fonts/original/inconsolata.ttf rename to fonts/original/inconsolata.ttf diff --git a/web/fonts/original/opensans.ttf b/fonts/original/opensans.ttf similarity index 100% rename from web/fonts/original/opensans.ttf rename to fonts/original/opensans.ttf diff --git a/guidelines.md b/guidelines.md deleted file mode 100644 index fec6773..0000000 --- a/guidelines.md +++ /dev/null @@ -1,229 +0,0 @@ -# Гайдлайны Алгоритмики - -Протестировать, как всё будет отображаться: `./build.sh`. - -Рекомендуется использовать редактор MarkText. - -### Возможности markdown - -Какие-нибудь *новые термины* нужно набирать так: `*новые термины*`. - -Что-нибудь **важное** нужно набирать так: `**важное**`. - -* Unordered списки - -* мы набираем - -* через `*`. -1. Нумерованные списки - -2. мы набираем - -3. просто через цифры с точками. - -> Через `>` рекомендуется набирать цитаты или условия задач. - -Что-либо относящееся к коду лучше держать в `обратных кавычках`. - -### Заголовки - -Используйте `#` только для названия статьи. - -Всё, что по смыслу меньше `##` или `###`, лучше выделять так: - -**Топик**. Какие-то предложения. - -Но какие-то большие разделы при этом лучше явно разделять заголовком. - -Теоремы с доказательствами рекомендуется оформлять так: - -**Лемма.** Ко-ко-ко. А именно: - -$$ -ko + ko = ko -$$ - -**Доказательство.** Очевидно. - -### Пунктуация - -Используйте правильную пунктуацию, то есть —, «, » и так далее. - -https://www.artlebedev.ru/kovodstvo/sections/97/ -https://www.artlebedev.ru/kovodstvo/sections/104/ - -В линуксе есть удобный способ набирать спецсимволы — называется compose key. Под убунтой можно скачать Gnome tweak tools (скорее всего он уже есть) и поставить его на какой-нибудь бесполезный правый альт. - -* `« = alt + < <` - -* `» = alt + > >` - -* `— = alt + - - -` - -* `½ = alt + 1 2` - -### Названия переменных - -Рекомендуется использовать одну строчную букву для математических переменных. - -Хорошо: $n$ вершин, $m$ рёбер. - -Не очень хорошо: $N$ мальчиков, $M$ девочек. - -В коде желательно переменные называть так же, как в самой статье. Если для какой-то переменной при объявлении не очевидно, для чего она будет использоваться, то лучше оставить комментарий про этой. - -### Языки - -Мы используем современный C++. Официальным компилятором считаем GCC, но стараемся обходить стороной compiler-specific фичи, если это возможно. - -Для каких-нибудь специальных случаев (например, скрипт и генерация тестов для стресс-тестирования) можно использовать Python 3. - -### Картинки - -Картинки можно вставлять так: `![](http://example.com/path/img.png)`. Или так, предварительно загрузив картинку в директорию `img`: `![](../img/img.png)`. - -### Полуинтервалы - -Предпочитайте полуинтервалы. - -### Переиспользование теорем - -Ссылайтесь на какую-то известную теорему вместо её доказательства. - -Парадокс дней рождений был доказан раза три — это нужно исправить. - -### Грамматика - -* стек - -* кэш - -* хэш - ---- - -## C++ codestyle - -Некоторая часть кода на данный момент не соответствует кодстайлу. - -В приоритете понятность реализациии, а не скорость её работы: программа, в которой в два раза меньше строк кода, лучше программы, которая работает в два раза быстрее. Можно сразу после неё привести другую реализацию, которая быстрее, но уродливее. - -Желательно дублировать и оставлять готовую версию без комментариев в репозитории `algorithmica-org/implementations`. - -### Отступы - -Используйте 4 пробела вместо табов. - -Для скобочек используем «K&R». - -Хорошо: - -```c++ -struct Node { -    // ... -} - -if (...) { -    // ... -} - -if (...) -    // ... - -if (...) -    // ... -else if (...) -    // ... -else { -    // ... -} -``` - -Плохо: - -``` -if (...) // ... - -if (...) -{ - // .. -} -``` - -### `struct` vs `class` - -Старайтесь использовать структуры вместо массивов каких-то элементов, разве что если это сильно медленнее. - -Также забудьте про `private` поля и прочий ООП. - -Плохо: - -```c++ -vector>> t; -``` - -Хорошо: - -```c++ -struct Something { - int x, y, z; -} - -vector t; -``` - -### Шаблоны - -Старайтесь без необходимости не использовать шаблоны или что-либо сложное, особенно в статьях для начинающих. - -### Современный C++ - -Если это какая-то понятная конструкция языка из новых стандартов (не move-семантика или constexpr-ы), и она сокращает код, то нужно её использовать. - -Хорошо: - -```c++ -auto t = f(x); - -for (int x : g[v]) { - // ... -} -``` - -Плохо: - -```c++ -set>:iterator t = f(x); - -for (size_t i = 0; i < g[v].size; i++) { -    // ... -} -``` - -### Инкрементирование - -Используем `i++` вместо `++i` , если это просто `int`-овый счётчик. - -Компилятор всё равно это соптимизирует. - -### Лямбды - -Мы любим лямбды и разное функциональное программирование там, где оно имеет смысл. - -```c++ -sort(a.begin(), a.end(), [](int x, int y) { - return x < y; -}); - -auto hf = hash(); -h = hf(s); -``` - -### Неймспейс - -Предполагайте, что вы работаете в `std` и уже подключили все необходимые библиотеки. То есть что эти две строчки уже есть: - -``` -#include -using namespace std; -``` diff --git a/web/head.html b/head.html similarity index 100% rename from web/head.html rename to head.html diff --git a/web/index.html b/index.html similarity index 93% rename from web/index.html rename to index.html index 6b15388..c092799 100644 --- a/web/index.html +++ b/index.html @@ -26,8 +26,7 @@ window.location.href = 'https://ru.algorithmica.org/'; break; default: - window.location.href = 'https://ru.algorithmica.org/'; - //window.location.href = 'en/'; + window.location.href = 'https://en.algorithmica.org/'; break; } diff --git a/web/pandoc.css b/pandoc.css similarity index 100% rename from web/pandoc.css rename to pandoc.css diff --git a/ru/aho-corasick.html b/ru/aho-corasick.html new file mode 100644 index 0000000..70ef359 --- /dev/null +++ b/ru/aho-corasick.html @@ -0,0 +1,238 @@ + + + + + + + Алгоритм Ахо-Корасик - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Алгоритм Ахо-Корасик

+

Пусть дан набор строк в алфавите размера \(k\) суммарной длины \(n\). Алгоритм Ахо-Корасик за \(O(nk)\) времени и памяти строит префиксное дерево для этого набора строк, а затем по этому дереву строит автомат, который может использоваться в различных строковых задачах — например, для нахождения всех вхождений каждой строки из данного набора в произвольный текст за линейное время.

+

Алгоритм открыт в 1975-м году и получил широкое распространение в системных программах для потоковой обработки текстов, например, в утилите grep.

+

Автору этой статьи понадобилось по работе реализовать алгоритм Ахо-Корасик целых два раза, что на два раза больше, чем все остальные «олимпиадные» алгоритмы.

+

Префиксное дерево

+

Префиксное дерево или бор (англ. trie) — это структура данных для компактного хранения строк. Она устроена в виде дерева, где на рёбрах между вершинами написана символы, а некоторые вершины помечены терминальными.

+

+

Говорят, что бор принимает строку \(s\), если существует такая вершина \(v\), что, если выписать подряд все буквы на путях от корня до \(v\), то получится строка \(s\).

+

Бор сам по себе можно использовать для разных задач:

+
    +
  • Хранение строк — бор может занимать гораздо меньше места, чем массив или set строк.

  • +
  • Сортировка строк — по бору можно пройтись dfs-ом и вывести все строки в лексикографическом порядке.

  • +
  • Просто множество строк — как мы увидим, в него легко добавлять и удалять слова, а также проверять, есть ли слово в боре.

  • +
+

Реализация

+

Бор состоит из ссылающихся друг на друга вершин. В вершине обычно хранится следующая информация:

+
    +
  • терминальная ли вершина,
  • +
  • ссылки на детей,
  • +
  • возможно, какая-нибудь дополнительная зависящая от задачи информация о слове. Например, количество таких слов, заканчивающихся в вершине — так можно реализовать мультисет.
  • +
+
const int k = 26;
+
+struct Vertex {
+    Vertex* to[k] = {0};
+    bool terminal = 0;
+};
+
+Vertex *root = new Vertex();
+

Чтобы добавить слово в бор, нужно пройтись от корня по символам слова. Если перехода по для очередного символа нет — создать его, иначе пройти по уже существующему переходу. Последнюю вершину нужно пометить терминальной.

+
void add_string(string &s) {
+    v = root;
+    for (char c : s) {
+        c -= 'a';
+        if (!v->to[c]) 
+            v->to[c] = new Vertex();
+        v = v->to[c];
+    }
+    v->terminal = true;
+}
+

Чтобы проверить, есть ли слово в боре, нужно так же пройти от корня по символам слова. Если в конце оказались в терминальной вершине — то слово есть. Если оказались в нетерминальной вершине или когда-нибудь потребовалось пройтись по несуществущей ссылке — то нет.

+

Удалить слово можно «лениво»: просто дойти до него и убрать флаг терминальности.

+

Как хранить ссылки

+

Хранить ссылки на детей не обязательно в массиве. Возможно, наш алфавит большой — тогда нам просто не хватит памяти инициализировать столько массивов, большинство из которых будут пустыми.

+

В этом случае можно придумать какой-нибудь другой способ хранить отображение из символа в ссылку. Например, хранить существующие ссылки в бинарном дереве (map), хэш-таблице (unordered_map) или просто в векторе. Они будут работать дольше, но зато потребление памяти в них будет линейным.

+

Суффиксные ссылки

+

Вернёмся к основной теме статьи.

+

Пусть дан набор строк \(s_1, s_2, \ldots, s_n\), называемый cловарём и большой текст \(t\). Необходимо найти все позиции, где строки словаря входят в текст. Для простоты дополнительно предположим, что строки из словаря не являются подстроками друг друга (позже мы увидим, что это требование избыточно).

+

Решать эту задачу будем следующим образом. Будем обрабатывать символы текста по одному и поддерживать наибольшую строку, являющуюся префиксом строки из словаря, и при этом также суффиксом считанного на данный момент текста. Если эта строка совпадает с каким-то \(s_i\), то отметим текущий символ — в нём заканчивается какая-то строка из словаря.

+

Для этой задачи нам нужно как-то эффективно хранить и работать со всеми префиксами слов из словаря — для этого нам и понадобится префиксное дерево.

+

Добавим все слова в префиксное дерево и пометим соответствующие им вершины как терминальные. Теперь наша задача состоит в том, чтобы при добавлении очередного символа быстро находить вершину в префиксном дереве, которая соответcтвует наидленнейшему входящему в бор суффиксу нового выписанного префикса. Для этого нам понадобятся несколько вспомогательных понятий.

+

Анти-формализм. Чтобы не писать везде громоздкое «строка \(s\), которая соответствуют вершине \(v\)», условимся дальше отождествлять вершину и соответствующую ей строку в боре.

+

Определение. Суффиксная ссылка \(l(v)\) ведёт в вершину \(u \neq v\), которая соответствует наидлиннейшему принемаемому бором суффиксу \(v\).

+

Определение. Автоматный переход \(\delta(v, c)\) ведёт в вершину, соответствующую минимальному принемаемому бором суффиксу строки \(v + c\). Если переход и так существует в боре, то автоматный переход будет вести туда же.

+

Автоматные переходы — это именно то, что нам и надо в задаче: они ведут ровно в те вершины, которые соответствуют самому длинному «сматченному» суффиксу.

+

Заметим следующую связь суффиксных ссылок и автоматных переходов:

+
    +
  • \(l(s_{:n}) = \delta(l(s_{:n-1}), s_n)\).

  • +
  • Если прямого перехода \(v \to_c u\) не существует, то \(\delta(v, c) = \delta(l(v), c)\).

  • +
+

Мы только что выразили \(l\) и \(\delta\) от строки длины \(n\) через \(l\) и \(\delta\) от строк размера \((n-1)\). Значит, суффиксные ссылки и автоматные переходы можно найти динамическим программированием.

+

Реализация

+

По сравнению с префиксным деревом, нам помимо массива переходов в боре to нужно будет хранить некоторую дополнительную информацию:

+
    +
  • Сам массив автоматных переходов размера go.

  • +
  • Суффиксную ссылку link.

  • +
  • «Родительский» (последний) символ pch, который используется в формуле для суффиксной ссылки.

  • +
+
const int k = 26;
+
+struct Vertex {
+    Vertex *to[k] = {0}, *go[k] = {0};
+    Vertex *link = 0, *p;
+    int pch;
+    Vertex (int _pch, Vertex *_p) { pch = _pch, p = _p; }
+};
+
+Vertex *root = new Vertex(-1, 0);
+

Добавление строки почти такое же:

+
void add_string(string &s) {
+    Vertex *v = root;
+    for (char _c : s) {
+        int c -= int(_c - 'a');
+        if (!v->to[c])
+            v->to[c] = new Vertex(c, v);
+        v = v->to[c];
+    }
+}
+

Подсчитывать динамики go и link будем «лениво» — введем для них две функции, которые будут мемоизировать свой результат выполнения.

+
// нам нужно объявить две функции, ссылающиеся друг на друга
+// в C++ для этого нужно одну из них объявить только с сигнатурой перед другой
+Vertex* go(Vertex *v, int c);
+
+Vertex* link(Vertex *v) {
+    if (!v->link) {
+        // для строк длины меньше двух суффиксная ссылка это корень
+        if (v == root || v->p == root)
+            v->link = root;
+        else // для остальных случаев формула работает
+            v->link = go(link(v->p), v->pch);
+    }
+    return v->link;
+}
+
+Vertex* go(Vertex *v, int c) {
+    if (!v->go[c]) {
+        // если переход есть, то автоматный должен вести туда же
+        if (v->to[c])
+            v->go[c] = v->to[c];
+        // если перехода нет из корня, то нужно сделать петлю
+        else if (v == root)
+            v->go[c] = root;
+        else // для остальных случаев формула работает
+            v->go[c] = go(link(v), c);
+    }
+    return v->go[c];
+}
+

На самом деле, эффективнее и «чище» реализовывать построение автомата через bfs, но так получается немного сложнее для понимания.

+ + diff --git a/ru/annealing.html b/ru/annealing.html new file mode 100644 index 0000000..a455bd5 --- /dev/null +++ b/ru/annealing.html @@ -0,0 +1,207 @@ + + + + + + + Метод отжига - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Метод отжига

+

Алгоритм имитации отжига (англ. simulated annealing) — эвристический алгоритм глобальной оптимизации, особенно эффективный при решении дискретных и комбинаторных задач.

+

Алгоритм вдохновлён процессом отжига в металлургии — техники, заключающейся в нагревании и контролируемом охлаждении металла, чтобы увеличить его кристаллизованность и уменьшить дефекты. Симулированние отжига в переборных задачах может быть использовано для приближённого нахождения глобального минимума функций с большим количеством свободных переменных.

+

Алгоритм вероятностный и не даёт почти никаких гарантий сходимости, однако хорошо работает на практике при решении NP-полных задач. Иногда на контестах им удаётся сдать сложные комбинаторные задачи, у которых есть нормальное решение: Ильдар Гайнуллин сдает отжигом div2E на динамику по подмножествам.

+

+

Описание алгоритма

+

Для примера будем рассматривать задачу коммивояжёра:

+
+

Eсть \(n\) городов, соединённых между собой дорогами. Необходимо проложить между ними кратчайший замкнутый маршрут, проходящий через каждый город только один раз.

+
+

Пусть имеется некоторая функция \(f(x)\) от состояния \(x\), которую мы хотим минимизировать. В данном случае \(x\) это перестановка вершин (городов) в том порядке, в котором мы будем их посещать, а \(f(x)\) это длина соответствующего пути.

+

Возьмём в качестве базового решения какое-то состояние \(x_0\) (например, случайную перестановку) и будем пытаться его улучшать.

+

Введём температуру \(t\) — какое-то действительное число (изначально равное единице), которое будет изменяться в течение оптимизации и влиять на вероятность перейти в соседнее состояние.

+

Пока не придём к оптимальному решению или пока не закончится время, будем повторять следующие шаги:

+
    +
  1. Уменьшим температуру \(t_{k} = T(t_{k-1})\).

  2. +
  3. Выберем случайного соседа \(x\) — то есть какое-то состояние \(y\), которое может быть получено из \(x\) каким-то минимальным изменением.

  4. +
  5. С вероятностью \(p(f(x), f(y), t_k)\) сделаем присвоение \(x \leftarrow y\).

  6. +
+

В каждом шаге есть много свободы при реализации. Основные эвристические соображения следующие:

+
    +
  1. В начале оптимизации наше решение и так плохое, и мы можем позволить себе высокую температуру и риск перейти в состояние хуже. В конце наоборот — наше решение почти оптимальное, и мы не хотим терять прогресс. Температура должна быть высокой в начале и медленно уменьшаться к концу.

  2. +
  3. Алгоритм будет работать лучше, если функция \(f(x)\) «гладкая» относительно этого изменения, то есть изменяется не сильно.

  4. +
  5. Вероятность должна быть меньше, если новое состояние хуже, чем старое. Также вероятность должна быть больше при высокой температуре.

  6. +
+

Например, можно действовать так:

+
    +
  1. \(t_k = \gamma \cdot t_{k-1}\), где \(\gamma\) это какое-то число, близкое к единице (например, \(0.99\)). Оно должно зависить от планируемого количества итераций: оптимизация при низкой температуре почти ничего не будет менять.

  2. +
  3. В случае с перестановками этим минимальным изменением может быть, например, своп двух случайных элементов.

  4. +
  5. Если \(y\) не хуже, то есть \(f(y) \leq f(x)\), то переходим в него в любом случае. Иначе делаем переход в \(y\), с вероятностью \(p = e^\frac{f(x)-f(y)}{t_k}\) — это экспонента отрицательного числа, и она даст вероятность в промежутке \((0, 1)\).

  6. +
+

Вообще, в выборе конкретных эвристик не существует «золотого правила». Все компоненты алгоритма сильно зависят друг от друга и от задачи.

+

+

Реализация

+

На практике применим алгоритм к другой задаче:

+
+

Дана шахматная доска \(n \times n\) и \(n\) ферзей. Нужно расставить их так, чтобы они не били друг друга.

+
+

Будем кодировать состояние так же перестановкой чисел от \(0\) до \((n-1)\): ферзь номер \(i\) будет стоять на пересечении \(i\)-той строки и \(p_i\)-того столбца.

+

Такое представление кодирует не все возможные расстановки, но это даже хорошо: точно не учтутся те расстановки, где ферзи бьют друг друга по вертикали или горизонтали.

+

Выберем функцию \(f(p)\), равную числу успешно расставленных ферзей.

+

Примерно эквивалентный код на C++:

+
const int n = 100;  // размер доски
+const int k = 1000; // количество итераций алгоритма
+
+int f(vector<int> &p) {
+    int s = 0;
+    for (int i = 0; i < n; i++) {
+        int d = 1;
+        for (int j = 0; j < i; j++)
+            if (abs(i - j) == abs(p[i] - p[j]))
+                d = 0;
+        s += d;
+    }
+    return s;
+}
+
+// генерирует действительное число от 0 до 1
+double rnd() { return double(rand()) / RAND_MAX; }
+
+int main() {
+    // генерируем начальную перестановку
+    vector<int> v(n);
+    iota(v.begin(), v.end(), 0);
+    shuffle(v.begin(), v.end());
+    int ans = f(v); // текущий лучший ответ 
+
+    double t = 1;
+    for (int i = 0; i < k && ans < n; i++) {
+        t *= 0.99;
+        vector<int> u = v;
+        swap(u[rand() % n], u[rand() % n]);
+        int val = f(u);
+        if (val > ans || rnd() < exp((val - ans) / t)) {
+            v = u;
+            ans = val;
+        }
+    }
+
+    for (int x : v)
+        cout << x + 1 << " ";
+
+    return 0;
+}
+

Здесь подсчёт количества свободных диагоналей работает за \(O(n^2)\). Его можно соптимизировать до \(O(n)\) и делать в \(O(n)\) итераций больше: можно создать булев массив, в котором для каждой диагонали хранить, была ли она занята. С этой оптимизацией уже должна быть сдаваема эта задача на информатиксе.

+

Упражнение. Соптимизируйте пересчёт до \(O(1)\).

+

Примечание. На самом деле, задача о ферзях решается конструктивно.

+ + diff --git a/ru/bayans.html b/ru/bayans.html new file mode 100644 index 0000000..96d8fdb --- /dev/null +++ b/ru/bayans.html @@ -0,0 +1,276 @@ + + + + + + + Красивые идейные задачи - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Красивые идейные задачи

+

Везде, где не указано — время работы \(O(n)\), а если есть конкретные числа, то TL 1 секунда.

+

Задачи идут в порядке вспоминания, то есть в весьма рандомном.

+

Попугаи

+

Вам нужно передать 64 байт некоторой информации. Для этого у вас есть 320 специально обученных попугаев, каждый из которых может запомнить число от 0 до 255. Все попугаи рано или поздно долетают до точки назначения и сообщают свой байт, но, возможно, не в том порядке, в котором были выпущены. Попугаи внешне неразличимы.

+

Минимум и максимум

+

Даны 68 камней разного веса. Требуется за 100 операций взвешивания определить самый лёгкий и самый тяжелый.

+

Перестановка

+

Есть перестановка из 64 элементов. Вы можете спрашивать, какие элементы находятся на заданном множестве позиций (вам сообщается список элементов в произвольном порядке). Восстановите перестановку за 6 запросов.

+

Выпуклая оболочка

+

Требуется отвечать на 2 типа запросов:

+
    +
  1. Добавить точку в выпуклую оболочку.
  2. +
  3. Проверить, лежит ли точка внутри выпуклой оболочки.
  4. +
+

Обе операции онлайн за \(O(\log n)\).

+

Геометрическая прогрессия

+

Найдите способ посчитать \(\frac{1-a^n}{1-a}\) по произвольному модулю за \(O(\log n)\).

+

Покемоны

+

В турнире участвуют 1024 видов покемонов. Вы, будучи экспертом по покемонам, знаете, кто кого может победить. Иначе говоря, вам полностью известен граф-турнир из 1024 вершин и \(1023 \times 1022 : 2\) рёбер, в котором каждые две вершины соединены ровно одним ориентированным ребром, определяющим победителя в возможном поединке. Обратите внимание, что из \(a \to b\) и \(b \to c\) не следует, что \(a \to c\).

+

У вас есть 10 покеболов. Составьте команду из 10 покемонов такую, что для каждого из 1024 типов покемонов в вашей команде найдется покемон, побеждающий его. Считайте, что в битве одинаковых покемонов побеждает ваш.

+

Сортировка

+

Можно ли отсортировать * 5 камней за 8 взвешиваний? * 5 камней за 7 взвешиваний? * 20 камней за 60 взвешиваний?

+

Точки в круге

+

Даны \(n\) точек, равномерно распределенных в единичном круге с центром в начале координат. Предложите алгоритм, который за \(O(n)\) в среднем сортирует их по удаленности от начала координат.

+

Замкнутые ломаные

+

Даны две замкнутые несамопересекающиеся ломаные. Определите, можно ли перевести их друг в друга с помощью параллельного переноса, поворотов и гомотетии?

+

Неубывающий массив

+

Дан массив из \(n\) целых чисел. Требуется за \(2n\) операций «прибавить к одному элементу любой другой» сделать его неубывающим.

+

Чётный цикл

+

Дан неориентированный граф. Определите, есть ли в нём простой цикл чётной длины.

+

\(k\)-ая порядковая статистика

+

Дан массив из \(n\) целых чисел. Найдите его \(k\)-й наименьший элемент за \(O(n)\).

+

Доминирующий элемент

+

Дан массив из \(n\) элементов. Требуется ответить на \(m\) запросов, есть ли на отрезке \([l, r]\) доминирующий элемент — тот, который встречается на нём хотя бы \(\frac{r-l}{2}\) раз. Время работы \(O((n+m) \log n)\).

+

Разрушение дерева

+

Дано корневое дерево. Каждую итерацию выбирается вершина (равновероятно из всех оставшихся), и удаляется всё поддерево, соответствующее этой вершине. Найти, сколько ходов в среднем будет продолжаться этот процесс, то есть матожидание номера итерации, на которой будет удалён корень дерева.

+

\(k\)-ый элемент на отрезке

+

Дан массив из \(n\) целых чисел. Требуется ответить на \(m\) запросов \(k\)-ой порядковой статистики на произвольном отрезке. Время работы \(O((n+m) \log n)\).

+

Различные числа на отрезке

+

Дан массив из \(n\) целых чисел. Требуется ответить на \(m\) запросов количества различных элементов на произвольном отрезке. Время работы \(O(m\sqrt{n})\).

+

Физкультура

+

Зачёт по физкультуре в одном институте ставится по количеству посещений, поэтому важно знать, сколько учебных дней осталось до конца семестра (особенно когда напропускал пары). Семестр длится \(m\) дней. Деканат последовательно издает \(n\) приказов двух типов:

+
    +
  1. Объявить все дни с \(l\) по \(r\) выходными (физру закрывать нельзя)
  2. +
  3. Объявить все дни с \(l\) по \(r\) учебными (физру закрывать можно)
  4. +
+

При этом приказ может частично отменить действие предыдущих приказов.

+

После каждого приказа нужно посчитать суммарное число учебных дней в семестре. Асимптотика \(O(n \log n)\).

+

Нулевая сумма

+

Дано мультимножество из \(n\) целых чисел. Найдите любое его подмножество, сумма чисел которого делится на \(n\).

+

Мета-задача

+

В задаче дана произвольная строка, по которой известным только авторам способом генерируется ответ yes/no. В задаче 100 тестов. У вас есть 20 попыток. В качестве фидбэка вам доступны вердикты на каждом тесте. Вердикта всего два: OK (ответ совпал) и WA. Попытки поделить на ноль, выделить терабайт памяти и подобное тоже считаются как WA. «Решите» задачу.

+

Ниточка

+

В плоскую доску вбили \(n\) гвоздей радиуса \(r\), причём так, что соответствующие точки на плоскости образуют вершины выпуклого многоугольника. На эти гвозди натянули ниточку, причём ниточка «огибает» по кругу гвозди. Найдите длину ниточки, то есть периметр этого многоугольника с учётом закругления.

+

Пельмени

+

Компания друзей захотела сделать заготовки для пельменей из огромного прямоугольного куска теста. Для этого в ход пошли всевозможные предметы округлой формы: стаканы, кружки, кострюли… В итоге тесто было разделено \(n\) возможно пересекающимися окружностями произвольных радиусов и центров. Нам интересно посчитать, сколько получилось заготовок, то есть на сколько кусков распалось тесто. Асимптотика \(O(n^2 \log n)\).

+

От нуля до единицы

+

Дан следующий код:

+
x = 0
+while x < 1:
+    x += random()
+

Требуется посчитать матожидание x.

+

(random в питоне возвращает случайное действительное число от 0 до 1.)

+

Площадь

+

Дан единичный квадрат и 100 кругов. Нужно посчитать площадь квадрата, которую покрывают эти круги, с ошибкой менее 1%.

+

Окружности

+

Имеется окружность радиуса \(R\), назовём её внешней. Внутри неё лежит окружность радиуса \(r < R\) и соприкасается с ней. Дальше строятся бесконечное число окружностей по следующему правилу: \(k\)-я окружность должна

+
    +
  • соприкасаться с внешней,
  • +
  • соприкасаться с предыдущей ((\(k-1\))-ой),
  • +
  • иметь при этом максимальный радиус.
  • +
+

Найдите (выведите формулу за \(O(1)\)) радиус \(k\)-й такой окружности.

+

Блеф

+

Катя и Серёжа играют в игру. У Кати есть \(n\) карт, у Серёжи — \(m\). Одна дополнительная карта лежит на столе рубашкой вверх. Назовём её особой. Цель игроков — её отгадать. Все \(n+m+1\) карт различны, игроки изначально знают только свои карты. Игроки ходят по очереди, начинает Катя. Игрок в свой ход может:

+
    +
  • Попытаться угадать особую карту. Если получилось угадать, то игрок победил, иначе проиграл. В любом случае, игра на этом заканчивается.
  • +
  • Назвать любую карту из колоды. Если у соперника есть такая карта — он обязан показать ее и вывести из игры. Если же у него нет такой карты — он сообщает об этом.
  • +
+

С какой вероятностью выиграет Катя при оптимальной игре обоих игроков? Асимптотика \(O(nm)\).

+

Достижимость

+

Дан ориентированный граф без кратных рёбер. Для всех пар вершин \(u\) и \(v\) определите, можно ли дойти из \(u\) в \(v\). Вершин меньше 2000.

+

Нумизмат

+

Есть \(n\) жадных коллекционеров монет, которые согласны меняться, только если взамен монеты, которых у них более одной, они получают монету, которых у них нет вовсе. Всего есть \(k\) типов монет. Известно количество монет каждого типа у каждого коллекционера и у коллекционера Серёжи. Серёжа хочет максимизировать количество различных монет у него путем обмена с жадными коллекционерами. Считайте, что жадные коллекционеры не меняются между собой.

+

Придумайте любой полиномиальный алгоритм.

+

Принцесса

+

В некотором королевстве жила принцесса. Она была настольно прекрасна, что каждый юноша в королевстве хотел жениться на ней. Принцесса была привередлива, поэтому твердо решила выйти замуж только за самого красивого юношу в государстве.

+

Она составила список из \(n\) самых красивых юношей и вызывает их каждый день в случайном порядке. После того, как к ней приходит очередной юноша, она принимает решение: либо выйти за него замуж, либо нет. Если она выходит за него замуж, то она все равно просматривает всех остальных, чтобы убедиться, что он действительно самый красивый. Если это так, то они живут долго и счастливо. Если нет (если она вышла замуж не за самого красивого, либо не вышла замуж вообще), то принцесса покончит жизнь самоубийством.

+

У принцессы очень хорошая память, поэтому она может сравнивать красоту очередного юноши со всеми предыдущими, а также у неё тонкий вкус, поэтому никакие двое юношей не являются для неё одинаково красивыми.

+

Помогите принцессе разработать стратегую, которая максимизирует вероятность того, что она выйдет замуж за наиболее красивого юношу.

+

Асимптотика \(O(n^2)\).

+

Спираль

+

Определим спираль \((2n+1) \times (2n+1)\) как матрицу следующего вида:

+

\[ +\begin{matrix} +21 & 22 & 23 & 24 & 25 \\ +20 & 7 & 8 & 9 & 10 \\ +19 & 6 & 1 & 2 & 11 \\ +18 & 5 & 4 & 3 & 12 \\ +17 & 16 & 15 & 14 & 13 \\ +\end{matrix} +\]

+

Ваша задача — рассчитать ответы на \(q\) запросов суммы чисел в произвольной прямоугольной области (по модулю \(10^9+7\)).

+

\(q \leq 100\), \(n \leq 10^9\).

+

Польский лабиринт

+

Группа из \(n\) туристов гуляет в бесконечном лабиринте. Лабиринт имеет форму треугольника Серпинского: клетка \((x, y)\) свободна, только если x & y == 0.

+

+

Туристы устали блуждать по лабиринту и хотят встретиться в какой-нибудь свободной клетке, сумма расстояний от всех туристов до которой наименьшая. Туристы за один ход могут передвигаться вверх, вниз, влево и вправо по свободным клеткам.

+

\(n \leq 10^5\), изначальные координаты туристов до \(10^9\).

+

Нимные подмножества

+

Есть множество \(A\), состоящее из \(n\) чисел от 0 до \(2^{32}-1\). Требуется выбрать его подмножество \(B \subseteq A\) максимальной суммы такое, что нельзя выбрать его подмножество \(C \subseteq B\) такое, что ним на кучках соответствующего размера — проигрышный. Асимптотика \(O(n \log n)\).

+

Баланс степеней

+

Дан неориентированный граф. Требуется ориентировать каждое ребро так, чтобы максимизировать число вершин, у которых степень исхода равна степени захода.

+

Два пути

+

Дан ориентированный граф. Найдите два непересекающихся по рёбрам пути из \(s\) в \(t\).

+

Пьяница

+

Пьяница стоит на числовой прямой в точке 0. Каждую секунду он делает единичный шаг вправо (в сторону увеличения координат) с вероятностью \(p\) и влево с вероятностью \(1-p\). С какой вероятностью он когда-либо окажется в точке с отрицательной координатой?

+

Ксоровый рюкзак

+

Дан массив из \(10^5\) целых чисел от \(0\) до \((2^{30}-1)\). Найти количество различных подпоследовательностей этого массива, xor-сумма которых равна заданному числу \(x\).

+

Иван Сусанин

+

Польская армия хочет добраться из поселения \(s\) в поселение \(t\). Ей руководят два гетмана — Камиль и Матеуш.

+
    +
  • Камиль руководит армией днём и водит армию по дорогам.
  • +
  • Матеуш руководит армией ночью и совершает маневры по секретным тропам.
  • +
+

Каждый гетман перед маршем спрашивает дорогу у Ивана Сусанина. Иван хочет задержать наступление польских войск.

+

Карта дорог известна Камилю. Аналогично, карта секретных троп известна Матеушу. Поэтому Ивану не удастся их так просто обмануть — он должен каждый раз выбрать переход так, что минимальное расстояние между поселением \(t\) и войском по соответствующей карте строго уменьшилось.

+

Вы знаете карту дорог и троп вместе с их длинами. Помогите Ивану как можно дольше (желательно, бесконечно) вести армию из \(s\) в \(t\).

+

Варенье

+

В ряд стоят \(n\) пустых банок из-под варенья. Вместительность \(i\)-й банки равна \(v_i\) грамм.

+

Карлсон наполняет эти банки вареньем в \(m\) этапов. На каждом этапе он выбирает числа \(l\), \(r\), \(x\) и \(y\), а затем пролетает над банками с \(l\) по \(r\), выполняя следующие операции: в банку номер \(l\) он добавляет \(x\) грамм варенья, в банку номер \((l + 1)\)\((x + y)\) грамм варенья, в банку номер \((l + 2)\)\((x + 2y)\), и так далее до \(r\)-той банки, в которую он положит \(x + y(r - l)\) грамм варенья.

+

Малышу хочется определить для каждой банки наименьший номер операции, после которой она станет полной.

+

\(n, m \leq 10^5\)

+

Лабиринт

+

Серёжа потерялся в лабиринте \(n \times m\). Каждая клетка либо свободна, либо стена. Все крайние клетки — стены. Вам известно, где находится выход из лабиринта, но не известно, где находится Серёжа. Составьте для него последовательность направлений (вверх, вниз, влево, вправо) такую, после которой он окажется в клетке с выходом, вне зависимости от его стартовой позиции. Если вы прикажете ему идти в стену, то просто ничего не произойдёт.

+

Придумайте любой полиномиальный алгоритм.

+

Обезьяна

+

Дана строка из \(10^5\) символов латинского алфавита. Обезьяна нажимает случайные клавиши на клавиатуре (одну из 26 букв), пока не наберёт её целиком, то есть пока исходная подстрока не станет подстрокой набранной строки. Какое ожидание числа нажатых клавиш перед тем, как это произойдёт?

+

Ожидание минимума

+

Даны \(n\) случайных величин, равномерно распределенных на отрезках \([l_i, r_i]\) — у каждой величины свой отрезок. Найдите математическое ожидание минимума этих случайных величин.

+

Придумайте любой точный полиномиальный алгоритм.

+

Шумный ксор

+

Загадано некое число \(x\). Вы можете делать запросы следующего типа: назвать число \(y\) и получить в ответ число единичных битов в ксор-сумме \(x\), \(y\) и \(m\), где \(m\) это случайно сгенерированная маска, в которой каждый бит имеет вероятность \(p = \frac15\) быть единичным, то есть каждый бит \(x \oplus y\) заменяется на противоположный с вероятностью \(y\), и вам возвращается количество единичных битов. Для ясности:

+
x = # ...
+
+def mask(p=0.2):
+    r = 0
+    for i in range(32):
+        if random.random() < p:
+            r += 2**i
+    return r
+
+def query(y):
+    return bin(x ^ y ^ mask()).count('1')
+

Ваша задача — отгадать число, используя не более 10000 попыток.

+ + diff --git a/ru/binary-search.html b/ru/binary-search.html new file mode 100644 index 0000000..e679bda --- /dev/null +++ b/ru/binary-search.html @@ -0,0 +1,257 @@ + + + + + + + Бинарный поиск - Алгоритмика + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Бинарный поиск

+

Пусть дан отсортированный массив \(a\), и требуется определить, есть ли в нём элемент \(x\).

+

Чтобы это сделать, не обязательно даже тратить \(O(n)\) операций на просмотр всех элементов массива. Можно воспользоваться следующим методом.

+

Пример. Пусть автор загадал число \(x\) от \(1\) до \(100\), которое вам нужно отгадать какой-нибудь «данеткой». Вам разрешено спрашивать, больше

+

Примечание. На самом деле, быстрее \(O(\log n)\), а точнее \(\lfloor \log n \rfloor\).

+

Однако иногда найти число X в массиве можно и быстрее! Для этого надо добавить условие на то, что массив отсортирован. Но давайте начнем не с этого.

+

Задание

+

Я загадал число X от 1 до 100. Вы можете спрашивать, больше ли мое число чем число T, я отвечаю “да” или “нет”. За сколько вопросов в худшем случае вы сможете найти число X? Как нужно действовать?

+

Решение и состоит в идее бинарного (двоичного) поиска - нужно первым вопросом спросить “число X больше, чем 50?”. После этого, если ответ “нет”, надо спросить “число X больше, чем 25”? И так далее, нужно уменьшать отрезок возможных значений в два раза каждый раз.

+

Почему нужно делить обязательно пополам? Почему бы не спросить “число X больше, чем 80?” первым же вопросом? Но если вдруг ответ “нет”, то мы останемся с 80 вариантами вместо 100. То есть деление отрезка ровно пополам гарантирует, что в худшем случае мы останемся не более чем с половиной вариантов.

+

Чтобы понять, как быстро это работает, введём новую математическую функцию. Логарифмом по основанию \(a\) от \(b\) будем называть число \(c\), такое что \(a ^ c = b\). Обозначается как \(\log_a b = c\). Чаще всего мы будем работать с двоичным логарифмом, то есть в какую степень \(c\) нужно возвести двойку, чтобы получить \(b\). Поэтому договоримся, что запись \(\log n\) означает двочный логарифм \(n\).

+

Теперь вернёмся к нашей задаче. Можно понять, что такой алгоритм работает как раз за \(O(\log n)\) вопросов (если число 100 на заменить абстрактную переменную \(n\)). Несложно убедиться, что именно логарифм раз нужно поделить число на два, чтобы получилось 1.

+

Общий принцип

+

А теперь представьте такую задачу: у вас есть массив, состоящий из некоторого количества подряд идущих нулей, за которыми следует какое-то количество подряд идущих единиц.

+
a = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]
+n = len(a)
+n
+
14
+

Вам дан массив, и вам нужно найти позицию первой единицы, то есть найти такое место, где заканчиваются нули, и начинаются единицы. Это можно сделать с помощью линейного поиска за один проход по массиву, но хочется сделать это быстрее.

+

Давайте обратимся к идее бинарного поиска. Посмотрим на элемент посередине массива. Если это нуль, то первую единицу стоит искать в правой половину массива, а если единица - то в левой.

+

Есть много способов писать бинарный поиск, и в его написании люди очень часто путаются. Очень удобно в данном случае воспользоваться инвариантом (это слово значит “постоянное свойство”):

+

Пусть переменная left всегда указывает на \(0\) в массиве, а переменная right всегда указывает на \(1\) в массиве.

+

Дальше мы будем переменные left и right постепенно сдвигать друг к другу, и в какой-то момент они станут соседними числами. Это и будет означать, что мы нашли место, где заканчиваются нули и начинаются единицы.

+

Чему равны left и right изначально, когда мы ничего про массив не знаем? Первая приходящая в голову идея - поставить их на \(0\) и \(n-1\) соответственно. Увы, в общем случае это может быть неверно, потому что a[0] может быть единицей, а a[n-1] может быть нулём. Правильнее сделать вот так:

+
left = -1
+right = n
+

То есть изначально left и right указывают на несуществующие индексы. Но это нормально - например в массиве [1, 1, 1, 1] в конце алгоритма как раз должно быть left == -1, right == 0.

+

Осталось нам написать цикл while:

+
while right - left > 1:
+    middle = (left + right) // 2 # именно такая формула для среднего индекса между left и right
+    if a[middle] == 1:
+        right = middle # right всегда должна указывать на 1
+    else:
+        left = middle # left всегда должна указывать на 0
+print left, right
+print a[left], a[right]
+
8 9
+0 1
+

Мы решили задачу для ноликов и единичек, но это легко обобщается на абсолютно любую задачу, где есть какое-то свойство, которое в начале массива не выполняется, а потом выполняется.

+

Например, если мы хотим найти, есть ли число \(X\) в отсортированном массиве, то мы просто представим, что \(0\) - это числа, меньшие \(X\), а \(1\) - это числа, большие или равные \(X\). Тогда достаточно найти первую “единицу” и проверить, равно ли это число \(X\).

+
a = [1, 3, 4, 10, 10, 10, 11, 80, 80, 81] # отсортированный массив
+def bin_search(a, x):
+    n = len(a)
+    left = -1
+    right = n
+    while right - left > 1:
+        middle = (left + right) // 2
+        if a[middle] >= x: # практически единственная строка, которая меняется от задачи к задаче
+            right = middle
+        else:
+            left = middle
+    if right != n and a[right] == x: # ответ лежит в right
+        return True
+    else:
+        return False
+
+print (bin_search(a, 1))
+print (bin_search(a, 10))
+print (bin_search(a, 20))
+print (bin_search(a, 79))
+print (bin_search(a, 80))
+print (bin_search(a, 81)
+
True
+True
+False
+False
+True
+True
+

Задание

+

Придумайте, как с помощью бинарного поиска решить такие задачи:

+
    +
  • Найти первое число, равное X в отсортированном массиве, или вывести, что таких чисел нет
  • +
  • Найти последнее число, равное X в отсортированном массиве, или вывести, что таких чисел нет
  • +
  • Посчитать, сколько раз встречается число X в отсортированном массиве (в решении помогают два предыдущих пункта)
  • +
  • Дан массив чисел, первая часть состоит из нечетных чисел, а вторая - из четных. Найти индекс, начиная с которого все числа четные.
  • +
+

Все эти задачи решаются бинарным поиском за \(O(\log{n})\). Правда нужно понимать, что в чистом виде такую задачу решать двоичным поиском бессмысленно - ведь чтобы создать массив размера \(n\), уже необходимо потратить \(O(n)\) операций.

+

Поэтому зачастую такие задачи сформулированы таким образом:

+

Дан отсортированный массив размера \(n\). Нужно ответить на \(m\) запросов вида “встречается ли число \(x_i\) в массиве n”?

+

Задание

+

Найдите время работы, за которое решается эта задача?

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

Решение: Такая задача решается за \(O(n + m\log{n})\) - нужно создать массив за \(O(n)\) и \(m\) раз запустить бинарный поиск.

+

Задание

+

Решите 3 первые задачи в этом контесте:

+

https://informatics.msk.ru/moodle/mod/statements/view.php?id=33216

+

Бинарный поиск с вещественными числами

+

У нас все еще есть функция f(x), которая сначала равна 0, а потом равна 1, и мы хотим найти это место, где она меняет значение. Но теперь аргумент функции - вещественное число. Например:

+
    +
  • \(f(x) = 1\), если \(x^2 > 2\)
  • +
  • \(f(x) = 0\), если \(x^2 \leq 2\)
  • +
+

Понятно, что при \(x = \sqrt 2\) \(f(x) = 0\), а при любом даже немного большем значении \(f(x) = 1\). Если мы научимся решать такую задачу, то мы научимся находить корень из двух!

+

Увы, возникает проблема: действительные числа хранятся в компьютере неточно

+
# известный пример
+0.1 + 0.1 + 0.1
+
0.30000000000000004
+

Тем более не сможем найти точное значение \(\sqrt 2\), потому что это бесконечная непериодическая дробь. Так что давайте снова воспользуемся бинарным поиском, причем всегда \(f(left) = 0\), \(f(right) = 1\), и мы остановимся тогда, когда left и right будет очень-очень близко.

+

И тут снова возникает проблема. Помимо того, что бесконечную дробь в принципе невозможно точно хранить в компьютере, ещё и арифметические операции понижают эту точность. Поэтому, чтобы явно не использовать разность между правым и левым указателем, можно задать фиксированное число шагов, которое будет выполняться.

+

Так как мы знаем, что двоичный поиск работает за двоичный логарифм, можно сказать, что на угадывание десятичного разряда числа потребуется примерно три шага бинпоиска (т. к. $ $). Значит, например, если нам нужно посчитать значение функции до шести знаков после запятой, то нам нужно ещё примерно 18 шагов уже после того, как расстояние между left и right достигло одного.

+

Чтобы каждый раз об этом не думать, можно считать, что ста шагов бинпоиска хватит для почти любых разумных целей.

+
left = 0.0 # 0^2 < 2, а значит f(0) = 0
+right = 10.0 # 10^2 > 2, а значит f(10) = 1
+for i in range(100):
+    middle = (left + right) / 2 # теперь деление не нацело, а вещественное
+    if middle ** 2 > 2:
+        right = middle # right всегда должна указывать на 1
+    else:
+        left = middle # left всегда должна указывать на 0
+print left, right
+print left ** 2, right ** 2
+
1.41421356237 1.41421356237
+2.0 2.0
+

Вот мы и нашли корень из 2 с достаточно высокой точностью.

+

На самом деле, так можно искать ноль любой непрерывной функции (мы сейчас искали ноль функции \(x^2 - 2\)), у которой вы знаете значение меньше нуля и значение больше нуля.

+

Задание

+

Придумайте, как с помощью вещественного бинпоиска найти

+
    +
  • \(\sqrt[\leftroot{-2}\uproot{2}17]{1000}\)
  • +
  • какой-нибудь корень уравнения \(x^4 + 3x = 5\)
  • +
+

Поиск максимума выпуклой функции: тернарный поиск, бинарный поиск

+

Так мы только что научились находить корень непрерывной функции, у которой мы знаем значение меньше и больше 0. Но можно ли найти с помощью бинарного поиска локальный максимум функции? Можно!

+

Как известно, локальный максимум функции \(f\) - это просто такое \(x_0\), что для всех близких к нему \(x\) значения \(f(x) < f(x_0)\). Для непрерывных функций выполняется более крутая вещь: слева от максимума функция возрастает, а справа от максимума функция убывает. Так это как раз отличное условие для нашего вещественного бинарного поиска!

+

Если вы знаете \(x_1\) такое, что в его окрестности f(x) возрастает, и \(x_2\) такое, что в его окрестности f(x) убывает, то можно запустить между ними бинпоиск и найти точку \(x_0\) такую, что слева от нее возрастает значение функции, а справа - убывает. Это и есть локальный максимум.

+

А если функция выпуклая, то она вообще выглядит красиво: сначала возрастает, потом максимум, потом убывает.

+

Проблема только в одном: как по точке понять, в ее окрестности значение функции убывает или возрастает? Достаточно тыкнуть две точки очень-очень рядом с ней и сравнить их значения!

+

Задание

+

Придумайте, как с помощью вещественного бинпоиска найти

+
    +
  • максимум функции \(x - e^x\) (она выпуклая, и максимум ровно один)
  • +
  • какой-нибудь локальный максимум функции \(31x+x^3-x^4\)
  • +
+

Тернарный поиск

+

Другой способ искать максимум - это тернарный поиск. Пусть известно, что максимум находится между left и right. Поделим отрезок на три равные части:

+
    +
  • middle_left = (2 * left + right) / 3
  • +
  • middle_right = (left + 2 * right) / 3
  • +
+

Тогда если f(middle_left) < f(middle_right), то можно спокойно заменить left на middle_left (максимум точно не левее middle_left), а если f(middle_left) > f(middle_right), то можно спокойно заменить right на middle_right. Он будет работать не за двоичный логарифм, а за логарифм по основанию полтора, что больше (но асимптотически то же самое, так как отличается в константу раз).

+

Оба способа работают быстро, и обобщаются на дискретный случай (то, что было в начале - когда дан массив, значения в котором сначала возрастают, а потом убывают). Но проблема есть в том, что если функция нестрого возрастает и нестрого убывает, а именно если там есть отрезки постоянства, то алгоритм не работает. В случае, когда значения функции равны, никак нельзя понять, с какой стороны искать максимум - он может быть с любой стороны.

+

Задание

+

Решите 4 и 5 задачи в этом контесте:

+

https://informatics.msk.ru/moodle/mod/statements/view.php?id=33216

+

Бинарный поиск по неотсортированному массиву

+

Заметьте, что в первоначальной задаче условие на то, что сначала идут нули, а потом идут единицы несущественно. Главное, чтобы мы знали индекс, который показывает на 0, и индекс, который показывает на 1. После этого бинарным поиском мы таким же способом найдем пару соседних нуля и единицы в массиве.

+

Поэтому бинарный поиск работает и не для возрастающих массивов / функций, если наша задача состоит именно в поиске двух соседних индексов, в которых условие выполняется и не выполняется.

+

Например, если мы знаем, что \(f(x_0) < 0\) и \(f(x_1) > 0\), и функция непрерывная, то бинарным поиском можно найти ноль этой функции между \(x_0\) и \(x_1\), даже если функция не монотонная!

+

Или, например, если нужно в массиве найти соседние четное и нечетное числа, и известно положение какого-то четного числа и какого-то нечетного числа, то это тоже можно легко сделать с помощью бинарного поиска.

+

Полезно иметь это в виду, это применяется в нескольких задачах контестов.

+

Бинарный поиск по ответу

+

Рассмотрим такую задачу:

+

Пример: “Корова в стойла”

+

Условие: На прямой расположены N стойл (даны их координаты на прямой), в которые необходимо расставить K коров так, чтобы минимальное расcтояние между коровами было как можно больше. Гарантируется, что \(1 < K < N\).

+

Решение:

+

Если решать задачу в лоб, то вообще неясно что делать. Нужно решать обратную задачу: давайте предположим, что мы знаем это расстояние X, ближе которого коров ставить нельзя. Тогда сможем ли мы расставить самих коров?

+

Ответ - да, можно ставить их довольно просто: самую первую ставим в самое левое стойло, это всегда выгодно. Следующие несколько стойл надо оставить пустыми, если они на расстоянии меньше X. В самое левое стойло из оставшихся надо поставить вторую корову и так далее. Даже ясно как это писать: надо идти слева направо по отсортированному массиву стойл, хранить координату последней коровы, и либо пропускать стойло, либо ставить в него новую корову.

+

То есть если мы знаем расстояние X, то мы можем за O(n) проверить, можно ли расставить K коров на таком расстоянии. Ну так давайте запустим бинпоиск по X, ведь при слишком маленьком X коров точно можно расставить, а при слишком большом - нельзя, и как раз эту границу и просят найти в задаче (“как можно больше”).

+

Осталось точно определить границы, то есть изначальные значения left и right. Нам точно хватит расстояния 0, так как гарантируется, что коров меньше, чем стойл. И точно не хватит расстояния max_coord - min_coord + 1, так как по условию есть хотя бы 2 коровы.

+
coords = [2, 5, 7, 11, 15, 20] # координаты стойл
+k = 3 # число коров
+
+def is_correct(x): # проверяем, можно ли поставить K коров в стойла, если между коровами расстояние хотя бы x
+    cows = 1
+    last_cow = coords[0]
+    for c in coords:
+        if c - last_cow >= x:
+            cows += 1
+            last_cow = c
+    return cows >= k
+
+left = 0 # расставить коров на расстоянии хотя бы 0 можно всегда
+right = max(coords) - min(coords) + 1 # при таком расстоянии даже 2 коровы поставить нельзя
+while right - left != 1:
+    middle = (left + right) // 2
+    if is_correct(middle): # проверяем, можно ли поставить K коров в стойла, если между коровами расстояние хотя бы middle
+        left = middle # left всегда должна указывать на ситуацию, когда можно поставить коров
+    else:
+        right = middle # right всегда должна указывать на ситуацию, когда нельзя поставить коров
+print left # left - максимальное расстояние, на котором можно расставить коров в стойла
+
9
+

Общий принцип

+

Такой метод и называется бинпоиск по ответу. Он очень важный и очень распространен на олимпиадах, очень рекомендую решать на него задачи.

+

По сути мы просто взяли задачу “найдите максимальное X, такое что какое-то свойство от X выполняется” и решили её бинпоиском. Самое сложное - увидеть такую формулировку в задаче. Поэтому рассмотрим еще один пример.

+

Пример: “Очень Легкая Задача”

+

Условие: есть два принтера, один печатает лист раз в \(x\) минут, другой раз в \(y\) минут. За сколько минут они напечатают \(N\) листов? \(N > 0\)

+

Решение: Здесь, в отличие от предыдущей задачи, кажется, существует прямое решение с формулой. Но вместо того, чтобы о нем думать, можно просто свести задачу к обратной. Давайте подумаем, как по числу минут \(T\) (ответу) понять, сколько листов напечатается за это время? Очень легко: \[\lfloor\frac{T}{x}\rfloor + \lfloor\frac{T}{y}\rfloor\]

+

Ясно, что за \(0\) минут \(N\) листов распечатать нельзя, а за \(xN\) минут один только первый принтер успеет напечатать \(N\) листов. Поэтому \(0\) и \(xN\) - это подходящие первые границы для бинарного поиска.

+

Примечание: заметьте, что задача в контесте немного отличается! Прочитайте внимательно условие.

+

Задание

+

Решите как можно больше задач в практическом контесте:

+

https://informatics.msk.ru/mod/statements/view.php?id=34097

+

Там могут встречаться задачи как на бинпоиск по ответу, так и на тернарный поиск по ответу.

+ + diff --git a/ru/bitset.html b/ru/bitset.html new file mode 100644 index 0000000..9cee7e6 --- /dev/null +++ b/ru/bitset.html @@ -0,0 +1,215 @@ + + + + + + + Битовое сжатие - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Битовое сжатие

+

Процессор устроен так, что работает не с индивидуальными битами, а сразу с блоками по 32 или 64 бита — эта величина называется машинным словом. Как следствие, операции, затрагивающие лишь один бит, на самом деле «стоят» столько же, сколько и операции над целым int-ом или long-ом.

+

Когда требуется делать какие-то теоретико-множественные операции, их часто можно свести к большому количеству одинаковых операций над элементами булевого массива. Например:

+
    +
  • Объединение множеств — побитовое ИЛИ.

  • +
  • Пересечение множеств — побитовое И.

  • +
  • Симметрическая разность — побитовый XOR.

  • +
+

Здесь появляется следующая идея оптимизации: сгруппировать элементы массива в блоки размера 64 и каждый такой блок считать двоичным числом. Тогда можно применить эту битовую операцию сразу к 64 элементам и потратить на это один такт вместо 64-х.

+

Это всё несложно кодить и вручную, но в STL это уже сделали до нас, написав структуру, ведущую себя как большое двоичное число со всеми стандартными битовыми операциями: bitset.

+

Работать с ним нужно вот так:

+
const int lim = 1000;
+bitset<lim> b; // создать битсет размера lim (должно быть константой)
+b.set();       // заполнить единицами
+b.reset();     // заполнить нулями
+b.flip();      // заменить единички на нули и наоборот
+b.count();     // посчитать число единичек
+cout << b;     // вывести битовую строку
+

Также для битсетов работает вся битовая арифметика: &, |, ^, ~, <<, >> и их варианты с [operator]=.

+

Примечание. Часто «асимптотику» использующих bitset решений пишут как \(O(f / 64)\). Автору не нравится такая нотация, потому что численную константу формально можно сократить, и, вообще, на разных архитектурах будут разные константы. Вместо этого мы будем везде писать \(O(f / w)\), где \(w\) означает размер машинного слова.

+

Задача о рюкзаке

+
+

Даны \(n\) предметов с положительными целыми весами \(a_i\) и рюкзак размера \(lim\). Требуется выбрать подмножество предметов с максимальной суммой, не превышающий размер рюкзака.

+
+

Обычно его решают так:

+
bool dp[lim] = {}; // так можно его заполнить нулями
+dp[0] = 1;
+for (int i = 0; i < n; i++)
+    for (int x = lim - a[i]; x >= 0; x--)
+        dp[x + a[i]] |= dp[x];
+

…а с битсетом оно разгоняется так:

+
bitset<lim> b;
+b[0] = 1;
+for (int i = 0; i < n; i++)
+    b |= b << a[i];
+

Цикл длины 3

+

Пусть нам нужно узнать, есть ли цикл длины 3 в ориентированном графе из \(n\) вершин, заданном своей матрицей смежности. Обернем эту матрицу в битсет, и тогда задача решается за \(O(n^3 / w)\) следующим образом:

+
bitset<maxn> g[maxn]; // матрица смежности
+for (int a = 0; a < n; a++) {
+    for (int b = 0; b < n; b++) {
+        if (g[a][b] && (~g[a] & g[b]).any()) {
+            // цикл найден
+        }
+    }
+}
+

Бенчмарк: на серверах CodeForces этот код при \(n = 5000\) работает за 7 секунд.

+

Перемножение матриц

+

Основное применение в олимпиадах: матрица смежности графа, возведенная в степень \(n\), имеет комбинаторный смысл — на пересечении \(a\)-ой строки и \(b\)-того столбца матрицы \(G^n\) будет записано количество способов дойти из вершины \(a\) в вершину \(b\), используя ровно \(n\) переходов.

+

В некоторых задачах нам не нужно знать именно число способов — нам хватит знания, можно ли вообще через \(n\) ходов там оказаться. Тогда вместо числового умножения нам хватит битового умножения, то есть &:

+
typedef bitset<maxn> t;
+typedef array<t, maxn> matrix;
+
+matrix matmul(matrix a, matrix b) {
+    matrix c;
+    for (int i = 0; i < n; i++)
+        for (int j = 0; j < n; j++)
+            if (a[i][j])
+                c[i] |= b[j];
+    return c;
+}
+

Метод Гаусса

+

Иногда встречаются задачи, требующие решения системы линейных уравнений. Большую часть из них на самом деле можно решить над полем \(\mathbb{Z}_2\) — то есть взять все числа по модулю 2.

+

Пример: есть \(n\) переключателей лампочек, каждый активированный переключатель меняет состояние (включает или выключает) какого-то подмножества из \(n\) лампочек. Известно текущее состояние всех лампочек, нужно восстановить по нему состояние переключаетелей.

+

Нас по сути просят решить следующую систему:

+

\[ +\begin{cases} +a_{11} x_1 + a_{12} x_2 + \ldots + a_{1n} x_n \equiv b_1 \pmod 2\\ +a_{21} x_1 + a_{22} x_2 + \ldots + a_{2n} x_n \equiv b_2 \pmod 2\\ +\ldots \\ +a_{n1} x_1 + a_{n2} x_2 + \ldots + a_{nn} x_n \equiv b_n \pmod 2 +\end{cases} +\]

+

Здесь \(x\) — состояния переключателей, \(b\) — состояния лампочек, а матрица \(A\) содержит информацию о том, влияет ли переключатель на лампочку.

+

В таком случае можно значительно ускорить и упростить обычный метод Гаусса:

+
t gauss(matrix a) {
+    for (int i = 0; i < n; i++) {
+        int nonzero = i;
+        for (int j = i+1; j < n; j++)
+            if (a[j][i])
+                nonzero = j;
+        swap(a[nonzero], a[i]);
+        for (int j = 0; j < n; j++)
+            if (j != i && a[j][i])
+                a[j] ^= a[i];
+    }
+    t x;
+    for (int i = 0; i < n; i++)
+        x[i] = a[i][n] ^ a[i][i];
+    return x;
+}
+

Код находит вектор \(x\) из уравнения \(Ax = b\) при условии, что решение существует и единственно. Для простоты кода предполагается, что вектор \(b\) приписан справа к матрице \(A\).

+

Самописный bitset

+

На самом деле, реализация из STL очень медленная. Простой for, в котором and-ят int-ы, будет работать в несколько раз быстрее аналогичной операции битсета. Ускорение получается за счёт векторизации — компилятор перестраивает цикл, используя специальные инструкции, работающие с блоками по 128 / 256 бит, а не 64.

+

Нужно просто написать цикл с достаточно простым телом, и компилятор сделает всё сам. Трудности возникают только при реализации битовых сдвигов и .count(). Последнюю проще всего реализовать через встроенную в GCC быструю функцию __builtin_popcount, но вообще в STL для этого используется предпосчитанный массив размера 256 с числами от 0 до 8 — количеста единичных бит в записи каждого возможного значения байта.

+

Автор предполагает, что битсет из STL реализовывали во времена, когда векторизация и отдельная инструкция popcnt ещё не поддерживались процессорами, поэтому всё устроено так, как устроено.

+ + diff --git a/ru/centroid.html b/ru/centroid.html new file mode 100644 index 0000000..ca8f2b1 --- /dev/null +++ b/ru/centroid.html @@ -0,0 +1,237 @@ + + + + + + + Центроидная декомпозиция - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Центроидная декомпозиция

+

Центроидная декомпозиция — обобщение метода «разделяй-и-властвуй» на деревья. Обычно её используют для решения двух типов задач: «сколько есть путей с такими-то свойствами» и «есть ли такое-то свойство у этого пути».

+

Иногда вместо неё можно написать heavy-light декомпозицию, что чуть сложнее, или метод переливаний, что чуть проще.

+

+

Определение. Центром или центроидом (англ. centroid) дерева будем называть вершину, при удалении которой размеры оставшихся компонент будут не более \(\frac{n}{2}\).

+

Центроид всегда существует — это следует из алгоритма его поиска:

+
int s[maxn];
+
+int sizes (int v) {
+    s[v] = 1;
+    for (int u : g[v])
+        // для простоты считаем, что дерево корневое
+        s[v] += sizes(u);
+    return s[v];
+}
+
+// второй параметр -- размер дерева
+int centroid (int v, int n) {
+    for (int u : g[v])
+        if (s[u] > n/2)
+            return centroid(u, n);
+    return v;
+}
+

Утверждение. centroid действительно находит цетроид.

+

Доказательство:

+
    +
  • centroid вернет вершину, у которой размеры всех детей не больше \(\frac{n}{2}\) (это явно проверяется в if-е).
  • +
  • Мы пришли в эту вершину, когда её размер был больше \(\frac{n}{2}\), а это значит, что в «обратном» направлении есть не более \(n - (\frac{n}{2}+1) = \frac{n}{2}-1\) вершин.
  • +
  • Значит, размеры всех соседей не больше половины \(n\), и алгоритм корректен.
  • +
+

Иногда центроидов два (пример: 1-2-3-4), и тогда алгоритм вернёт «нижний» центроид.

+

Определение. Центроидной декомпозицией будем называть рекурсивный процесс «выделить центроид, удалить, запуститься от компонент».

+

Определение. Компонентой центроида будем называть множество вершин, достижимых из центроида непосредственно перед его удалением.

+

Заметим, что в конце всё дерево будет удалено, а значит каждая вершина ровно один раз побывала центроидом своей компоненты.

+

Теперь поймём, зачем мы всё это делали.

+

Утверждение. Каждая вершина входит в \(O(\log n)\) компонент.

+

Доказательство. Центроид разбивает дерево на компоненты в хотя бы два раза меньшего размера. Значит, никакая вершина не может «прожить» более \(\lceil \log_2 n \rceil\) разделений.

+

Следствие. Центроидная декомпозиция (см. определение выше) работает за \(O(n \log n)\).

+

Утверждение. Для любого пути \(a \leadsto b\) есть единственный центроид \(c\), в чьей компоненте были и \(a\), и \(b\).

+

Доказательство. Каждая вершина когда-то была центроидом. Какая-то из вершин на пути была центроидом первой, и она навсегда разъединила \(a\) и \(b\).

+

Это очень важные выводы. Получается, что в процессе центроидной декомпозиции и для каждого простого пути будет ровно одна ситуация, когда этот путь содержится в какой-то компоненте и проходит через центроид. Ровно в этот момент мы можем обработать какой-то запрос, пользуясь техникой, похожей на метод переливаний.

+

Подсчет путей с заданным свойством

+

Рассмотрим конкретный пример: подсчёт путей заданной длины.

+

Вмешаемся в процесс центроиднодной декомпозиции: для каждого центроида перед его удалением будем прибавлять к ответу число интересующих нас путей, которые проходят через этот центроид.

+

Количество таких путей можно посчитать за размер текущей компоненты: заведём массив d, в котором будем хранить количество вершин на каждом расстоянии от 0 до размера компоненты. Подвесим наше дерево-компоненту за центроид и будем запускать от его непосредственных детей dfs, который будет возвращать временный массив t — список глубин вершин в этом поддереве. Мы можем пройтись по каждому значению x в нём и добавить к ответу d[l-x], а затем добавить все значения из t в d. Можно убедиться, что таким образом каждый интересующий нас путь будет учтён ровно один раз.

+
int l = 179; // нужная нам длина
+int ans = 0;
+
+// нам лень явно удалять вершины: заведем массив used -- была ли вершина удалена
+bool used[maxn];
+int s[maxn]; // размеры поддеревьев
+
+void sizes (int v, int p) {
+    s[v] = 1;
+    for (int u : g[v])
+        if (u != p && !used[u])
+            sizes(u, v), s[v] += s[u];
+}
+
+int centroid (int v, int p, int n) {
+    for (int u : g[v])
+        if (u != p && !used[u] && s[u] > n/2)
+            return centroid(u, v, n);
+    return v;
+}
+
+// записывает в t[] глубины вершин
+void dfs (int v, int p, int d, vector<int> &t) {
+    t.push_back(d);
+    for (int u : g[v])
+        if (u != p && !used[u])
+            dfs(u, v, d + 1, t);
+} 
+
+void solve (int v) {
+    /* <единственный зависящий от конкретной задачи код> */
+    sizes(v);
+    vector<int> d(s[v], 0);
+    d[0] = 1;
+    for (int u : g[v]) {
+        if (!used[u]) {
+            vector<int> t;
+            dfs(u, v, 1, t);
+            for (int x : t)
+                if (x <= l)
+                    ans += d[l-x];
+            for (int x : t)
+                d[x]++;
+        }
+    }
+    /* </единственный зависящий от конкретной задачи код> */
+
+    used[v] = 1;
+    for (int u : g[v])
+        if (!used[u])
+            solve(centroid(u, v, s[u]));
+}
+

Асимптотика \(O(n \log n)\), потому что на каждую из \(O(n)\) вершин мы потратим \(O(1)\) операций на каждом из \(O(\log n)\) «уровней» центроидной декомпозиции.

+

Запросы на путях — offline

+

Мы находим всё, что нам надо, сразу после того, как получили новую компоненту. Если у нас есть список запросов «посчитать что-то на пути», то мы часто можем обработать их в offline.

+

А именно, мы можем хранить вместе с вершиной список относящихся к ней ещё не отвеченных запросов. Этот список мы будем просматривать во время обработки очередной компоненты, и если центроид лежит на этом запросе, то мы ответим на этот запрос и удалим его из списка, а в обратном случае просто проигнорируем.

+

Например, при запросах суммы на пути, мы можем насчитать во внутреннем dfs для каждой вершины сумму на пути от её текущего центроида, и если при обработке текущей компоненты встретим вторую вершину запроса, в какой-то другой ветке, то просто возьмем её сумму и уже посчитанной суммой на пути до первой вершины.

+

Таким образом, каждый запрос будет просмотрен \(O(\log n)\) раз, пока не будет удален, и асимптотика составит \(O(q \log n + n \log n)\).

+

Запросы на путях — online

+

Альтернативно, можно сначала «построить» центроидную декомпозицию, а потом отвечать на запросы.

+

Пусть мы хотим посчитать отвечать на запросы минимума на путях и не знаем, как решать LCA двоичными подъемами. Создадим двумерный массив centroid[][] размера \(n \times \log n\), в котором для каждой вершины будем хранить \(O(\log n)\) центроидов, в чьи компоненты она когда-то входила. Помимо этого, в таком же массиве будем хранить информацию, которая нам будем нужна для ответа на запросы — в данном случае для каждой пары вершина-центроид будем хранить минимум на соответствующем пути.

+

Тогда, при ответе на запрос, мы за \(O(\log n)\) или даже \(O(\log \log n)\) операций находим центроид на нужном нам пути (первые сколько-то значений centroid[v] и centroid[u] будут совпадать — нас интересует последнее) и берём в качестве ответа минимум от минимумов на двух путях до центроида.

+

Асимптотика при более долгих пересчётах

+

Представим, что мы написали «мердж» (ту часть, которая отвечает за ответ на запросы или подсчёт путей, идущих через центроид) не за линейное время, а за \(O(n \log n)\), например, где-то использовав set. Сильно ли это хуже по времени?

+

В худшем случае (в бамбуке) каждый раз компонента будет разбиваться на две равные части. Просуммируем общее количество операций в дереве рекурсии (см. мастер-теорему):

+

\[ +\sum_{k=0}^{\log n} n \log \frac{n}{2^k} += \sum_{k=0}^{\log n} n (\log n - k) += n \sum_{k=0}^{\log n} k += \Theta (n \log^2 n) +\]

+

Мораль: используйте хэш-таблицы.

+ + diff --git a/ru/combinatorics.html b/ru/combinatorics.html new file mode 100644 index 0000000..7461898 --- /dev/null +++ b/ru/combinatorics.html @@ -0,0 +1,265 @@ + + + + + + + Ликбез по комбинаторике - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Ликбез по комбинаторике

+

Статья не затрагивает теоирию чисел и линейную алгебру.

+

Воможно, поможет почитать про нахождение обратного.

+

[Конкретная математика](http://sereja.me/f/discrete_math_knuth.pdf Грэхэма, Кнута и Паташика.

+

Рекуррентные задачи

+

Ханойские башни

+

123

+

+

Задача Иосифа

+

Иосиф Флавий (лат. Flavius Josephus) — знаменитый историк первого векf. По легенде, он бы не дожил до известности если бы не его математические способности.

+

Во время еврейсDuring the Jewish-Roman war, he was among a band of 41 Jewish rebels trapped in a cave by the Romans. Preferring suicide to capture, the rebels decided to form a circle and, proceeding around it, to kill every third remaining person until no one was left. But Josephus, along with an unindicted co-conspirator, wanted none of this suicide nonsense; so he quickly calculated where he and his friend should stand in the vicious circle.

+

Принцип Дирихле. Если кролики рассажены в клетки, причём число кроликов больше числа клеток, то хотя бы в одной из клеток находится более одного кролика.

+

В англоязычном мире называется Pigeonhole principle и говорит о голубях, но суть не меняется.

+

+

Принцип кажется очевидным, однако его применение очевидно не всегда. Рассмотрим несколько примеров.

+

Задача. Дан квадрат \(2 \times 2\) и \(5\) точек внутри него. Доказать, что среди них найдется две такие, что расстояние между ними \(\leq \sqrt 2\).

+

Решение. Рассмотрим \(4\) квадратика \(1 \times 1\). По принципу Дирихле найдётся квадратик, внутри или на границе которого 2 точки. Эти точки — искомые.

+

Примеры посложнее:

+
    +
  • Вы пытаетесь замостить шахматную доску доминошками. Если убрать два противоположных угловых поля, это станет невозможно.

  • +
  • В группе из шести человек, всегда найдутся трое, которые являются либо взаимными друзьями, либо незнакомцами.

  • +
  • Пример из реального мира: любой алгоритм сжатия без потери данных не может гарантировать сжатие для всех возможных входных файлов.

  • +
+

Задача про покемонов.

+

Бином Ньютона

+

Поли=много, Би=два. nomen = название.

+

\[ +(a + b)^n +\]

+

Коэффициенты при разложении имеют огромную роль в комбинаторике.

+

В России их обозначают как , во всем остальном мире — как .

+

Сильно раздражает, что нет даже единого мнения, где \(n\), а где \(k\).

+

Биномиальные коэффициенты

+

Алгоритм Евклида

+

Решето

+

Треугольник Паскаля

+

Можно заметить

+

+

Сочетания

+

Упорядоченный набор из \(k\) элементов данного множества, среди которых могут быть повторяющиеся, называется размещением из \(n\) элементов по \(k\) с повторением.

+

Соответственно, если элементы могут повторяться, то набор называется размещением без повторений, или просто размещением.

+

Сочетанием из \(n\) элементов по \(k\) называется набор \(k\) элементов этого множества, при том что наборы, отличающиеся только порядком следования элементов, считаются одинаковыми (этим сочетание отличается от размещения).

+

Аналогично, сочетания бывают с повторениями и без.

+

Читается как «цэ из \(n\) по \(k\)».

+

Как это считать

+
int t[maxn];
+
+int bp (int a, int n) {
+    int ans = 1;
+    while (n) {
+        if (n&1) ans = ans*a % mod;
+        a = a*a % mod;
+        n >>= 1;
+    }
+    return ans;
+}
+
+int inv (int x) {
+    return bp(x, mod-2);
+}
+
+int c (int n, int k) {
+    return t[n]*inv(t[k]) % mod * inv(t[n-k]) % mod;
+}
+
+t[0] = 1;
+for (int i = 1; i < maxn; i++)
+    t[i] = i*t[i-1] % mod;
+

Разложения

+

Шары и перегородки.

+

Автор не запоминает эту формулу и не пытается проделать алгебру в уме, а просто определяет вспомогательную функцию:

+
int d(int n, int k) {
+    return c(n + k - 1, k - 1);
+}
+

Известные равенства

+

\[ +\sum_k \binom{r}{m+k}\binom{s}{n-k} = \binom{r+s}{m+n} +\]

+

\[ +\sum_k \binom{l}{m+k} \binom{s}{n+k} = \binom{l+s}{l-m+n} +\]

+

\[ +\sum_k \binom{l}{m+k}\binom{s+k}{n} (-1)^k = (-1)^{l+m} \binom{s-m}{n-l} +\]

+

\[ +\sum_{k \leq l} \binom{l-k}{m}\binom{s}{k-n} (-1)^k = (-1)^{l+m} \binom{s-m-l}{l-m-n} +\]

+

\[ +\sum_{0 \leq k \leq l} \binom{l-k}{m}\binom{q+k}{n} = \binom{l+q+1}{m+n+1} +\]

+

Hockey stick identity

+

«Равенство хоккейной клюшки».

+

+

Правильные скобочные последовательности

+

Диаграмма Юнга

+

Числа Каталана

+

Числа Каталана — числовая последовательность, встречающаяся в удивительном числе комбинаторных задач. Например, \(n\)-ное число Каталана равно числу:

+
    +
  • …корректных скобочных последовательностей, состоящих из \(n\) открывающих и \(n\) закрывающих скобок.

  • +
  • …корневых бинарных деревьев с n+1 листьями (если вершины не пронумерованы).

  • +
  • …разбиений выпуклого многоугольника непересекающимися диагоналями на треугольники \((n+2)\)-угольника (это называется триангуляцией).

  • +
  • …способов соединить \(2n\) точек на окружности \(n\) непересекающимися хордами.

  • +
  • …разбиений отрезка из \(n\) элементов на непрерывные блоки.

  • +
  • …путей из точки \((0,0)\) в точку \((n,n)\) в квадратной решётке размером \(n \times n\), не поднимающихся над главной диагональю.

  • +
+

Первые несколько чисел Каталана, начиная с нулевого:

+

\[ +1, 1, 2, 5, 14, 42, 132, 429, 1430, … +\]

+

Числа Каталана можно считать квадратичной динамикой. Пусть \(k\)-тое число Каталана, \(C_k\), равно количеству правильных скобочных последовательностей длины \(2k\), то есть из \(k\) пар скобок. Последовательность начинается с открывающей скобки, и этой скобке соответствует какая-то закрывающая. Можно перебрать её место (все нечетные при нумерации с нуля позиции), и для каждого варианта последовательность разделится на две правильные скобочные последовательности — до закрывающей скобки (из \(k\) пар скобок) и после (из \((n-1-k)\) пар).

+

Получается следующая рекуррентная формула:

+

\[ +C_n = \sum_{k = 0}^{n-1} C_k C_{n-1-k} +\]

+

Аналитическая формула

+

Но не нужно, потому что есть аналитическая формула, которую мы сейчас докажем:

+

\[ +C_n = \dfrac{1}{n+1} \dbinom {2n} {n} +\]

+

Эту формулу проще всего вывести из задачи о монотонных путях —. Общее количество монотонных путей в решётке размером n n равно C_{2n}^{n}. Теперь посчитаем количество монотонных путей, пересекающих диагональ. Рассмотрим какой-либо из таких путей, и найдём первое ребро, которое стоит выше диагонали. Отразим относительно диагонали весь путь, идущий после этого ребра. В результате получим монотонный путь в решётке (n-1) (n+1). Но, с другой стороны, любой монотонный путь в решётке (n-1) (n+1) обязательно пересекает диагональ, следовательно, он получен как раз таким способом из какого-либо (причём единственного) монотонного пути, пересекающего диагональ, в решётке n n. Монотонных путей в решётке (n-1) (n+1) имеется C_{2n}^{n-1}. В результате получаем формулу:

+

Все \(C_4 = 14\) путей на квадрате \(4 \times 4\):

+

+

\[ +C_n = C_{2n}^{n} - C_{2n}^{n-1} = \frac{1}{n+1} C[...] +\]

+

Перестановки

+

Перестановки как граф

+

Граф, у которого

+

Наибольшая общая подпоследовательность двух перестановок.

+

Ничего не мешает нам переименовать объекты так, что в первой они будут идти по порядку.

+

Генерация комбинаторных объектов

+

Обратная задача

+

Задача «Попугаи». Вам нужно передать 64 байт некоторой информации. Для этого у вас есть 320 специально обученных попугаев, каждый из которых может запомнить число от 0 до 255. Все попугаи рано или поздно долетают до точки назначения и сообщают свой байт, но, возможно, не в том порядке, в котором были выпущены. Попугаи внешне неразличимы.

+

Принцип включений-исключений

+

Это бывает полезно при подсчете площадей многомерных фигур через префиксные суммы.

+

Куда дальше, или полезные незатронутые темы

+

Если вам зачем-то понадобилось.

+

Лемма бернсайда.

+

Очень трудно осознать без теории групп, но позволяет считать очень нетривиальные штуки, например количество валидных кубиков Рубика.

+

Линейные рекурренты.

+

Теория чисел.

+

Частично упорядоченные множества и теорема Дилуорса.

+

Производящие функции. Очень интересный алгебраический трюк, позволяющий находить явные формулы для многих комбинаторных величин.

+

Разные отдельные факты на границе комбинаторики и теории графов.

+

Комбинаторика, которая находится на границе с теорией вероятностей.

+ + diff --git a/ru/convex-hulls.html b/ru/convex-hulls.html new file mode 100644 index 0000000..671c79e --- /dev/null +++ b/ru/convex-hulls.html @@ -0,0 +1,277 @@ + + + + + + + Выпуклые оболочки - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Выпуклые оболочки

+

Выпуклое множество — такое множество точек, что, для любых двух точек множества, все точки на отрезке между ними тоже принадлежат этому множеству.

+

Выпуклая оболочка множества точек — такое выпуклое множество точек, что все точки фигуры также лежат в нем.

+

Минимальная выпуклая оболочка множества точек — это минимальная по площади выпуклая оболочка.

+

Для экономии времени дальше минимальные выпуклые оболочки мы будем называть просто выпуклыми оболочками.

+

+

Для практических целей выпуклые оболочки полезны тем, что они компактно хранят всю необходимую информацию о множестве точек, что позволяет быстро отвечать на разнообразные запросы на этом множестве.

+

Выпуклые оболочки можно рассматривать в любом пространстве, но в этой статье мы ограничимся двумерным и научимся их эффективно строить по какому-то множеству из \(n\) точек на плоскости.

+

Алгоритм Джарвиса

+

Выберем какую-нибудь точку \(p_0\), которая гарантированно попадёт в выпуклую оболочку. Например, нижнюю, а если таких несколько, то самую левую из них.

+

Дальше будем действовать так: найдём самую «правую» точку от последней добавленной (то есть точку с минимальным полярным углом) и добавим её в оболочку. Будем так итеративно добавлять точки, пока не «замкнёмся», то есть пока самой правой точкой не станет \(p_0\).

+

+

Корректность алгоритма легко доказывается по индукции:

+
    +
  • На первом шаге мы выбрали точку, точно лежащую в МВО.

  • +
  • На \(i\)-м шаге мы взяли такую точку, что все остальные лежат по «правильную» сторону отрезка \((p_i, p_{i+1})\).

  • +
+

Алгоритм Джарвиса также называют алгоритмом заворачивания подарка: мы каждый раз находим самый близкий «угол».

+

Для каждой точки выпуклой оболочки (обозначим их количество за \(h\)) мы из всех оставшихся \(O(n)\) точек будем искать оптимальную, что суммарно будет работать за \(O(n h)\).

+

В реализации будем пользоваться тем же классом для точки, что и в статье про вычислительную геометрию. Для краткости, будем считать, что все точки различны, имеют целочисленные координаты, а также что нет трёх точек на одной прямой.

+
vector<r> jarvis_convex_hull(vector<r> points) {
+    r p0 = points[0];
+    for (r p : points)
+        if (p.x < p0.x || (p.x == p0.x && p.y < p0.y))
+        p0 = p;
+    vector<r> hull = {p0};
+    while (true) {
+        r t = p0; // кандидат на следующую точку
+        for (r p : points)
+            // лучше никакие полярные углы не считать
+            if ((p - p0) ^ (t - p0) > 0)
+                t = p;
+        if (t == p0)
+            continue;
+        else {
+            p0 = t;
+            hull.push_back(t);
+        }
+    }
+    return hull;
+}
+

Важно помнить, что асимптотика именно \(O(nh)\), а не \(O(n^2)\): существуют задачи, где оболочка маленькая, и это существенно.

+

Алгоритм Грэхема

+

Алгоритм Грэхема — это оптимизация алгоритма Джарвиса, основанная на следующем наблюдении: если отсортировать все точки по полярному углу относительно точки \(p_0\), то выпуклая оболочка будет какой-то подпоследовательностью такого отсортированного массива точек.

+

Алгоритм последовательно строит выпуклые оболочки для каждого префикса этого отсортированного масива. При добавлении \(i\)-й точки в оболочку нужно удалить сколько-то последних добавленных точек, которые не будут входить в новую оболочку. Чтобы это делать эффективно, мы можем хранить выпуклую оболочку в стеке и в цикле while смотреть на три последние точки и проверять, образуют ли они правый поворот. Если это так, то среднюю следует удалить — мы нашли треугольник \((p_0, p_i, p_{i-2})\), который содержит \(p_{i-1}\), значит её можно удалить.

+
+alt text +
+

Каждая точка будет добавлена один раз удалена не более одного раза, что занимает константное количество операций. Соответственно, время работы будет упираться во время работы сортировки, то есть \(O(n \log n)\).

+
struct r {
+    int x, y;
+    r operator-(r a) {return {x - a.x; y - a.y};}
+    // в векторном произведении возможно переполнение int
+    int operator^(r a) {return x * a.y - y * a.x;}
+};
+vector<r> graham_convex_hull(vector<r> points) {
+    // находим p0, как и раньше
+    r p0 = points[0];
+    for (r p : points)
+        if (p.x < p0.x || (p.x == p0.x && p.y < p0.y))
+            p0 = p;
+
+    // TODO: удалить p0
+    // сделаем p0 началом координат (для сокращения кода компаратора)
+    for (r &x : points) {
+        x.x -= p0.x;
+        x.y -= p0.y;
+    }
+    // сортируем точки по полярному углу
+    sort(points.begin(), points.end(), [&](r a, r b){
+        return a ^ b > 0 || a ^ b == 0 && a.x * a.x + a.y * a.y < b.x * b.x + b.y * b.y;
+    }); // если векторное произведение == 0 то сначала будем рассматривать точку, которая ближе к p0
+
+    vector<r> hull;
+    for (r p : points) {
+        // удаляем последнюю точку МВО пока она образует невыпуклость (оператор "-" у точек должен быть перегружен, как и "^"
+        /*
+        Пусть т. A - последняя в оболочке, т. B - предпоследняя, т. C - точка, которую мы хотим добавить
+        Тогда невыпуклость(или вырожденность) образуется, когда (C - A) ^ (B - A) <= 0
+        */
+        while (hull.size() >= 2 && ((p - hull.back()) ^ (hull[hull.size() - 2] - hull.back()) <= 0) {
+             hull.pop_back();
+        }
+        hull.push_back(p);
+    }
+    // переместим начало координат обратно
+    for (r &x : hull) {
+        x.x += p0.x;
+        x.y += p0.y;
+    }
+    return hull;
+}
+

Верхние и нижние огибающие

+

Для весьма большого количества применений нам на самом деле нужны не выпуклые оболочки, а только их половины (правые и левые или верхние и нижние), которые называют огибающими (англ. envelope).

+

Огибающие строить немного проще: можно отсортировать точки по \(x\) и пройтись по ним в таком порядке, поддерживая на стеке верхнюю огибающую для текущего префикса. При добавлении очередной точки нам нужно аналогичным образом проверить и удалить сколько-то верхних точек на стеке:

+
vector<r> upper_envelope(vector<r> points) {
+    sort(points.begin(), points.end(), [](r a, r b){
+        return a.x < b.x;
+    });
+
+    vector<r> s = {p0};
+    for (r p : points) {
+        while (...) {
+            s[s.size()-2] = s[s.size()-1];
+            s.pop_back();
+        }
+        s.push_back(p);
+    }
+
+    return s;
+}
+

С огибающими работать легче, чем с целыми оболочками: их легко пересекать, объединять и делать бинпоиск (например, чтобы находить касательные). Кстати, объединение можно производить за линейное время (пройдясь двумя указателями по обеим оболочкам одновременно), что позволяет альтернативно строить огибающую методом «разделяй и властвуй», тоже за \(O(n \log n)\).

+

Иногда имеет смысл разбить оболочку на две огибающие и работать с ними. Основном минус такого подхода: нужно либо писать в два раза больше кода, либо писать его так, чтобы внутренние процедуры не отличали выше от ниже — тогда точки нижней огибающей можно передовать в те же рутины, что и для верхней, заменив \(y_i\) на \(-y_i\).

+

Алгоритм Эндрю

+

Для построения выпуклой оболочки можно поступить следующим образом: построить верхнюю огибающую, построить нижнюю огибающую, а затем просто объединить их списки.

+
vector<r> andrew_convex_hull(vector<r> points) {
+    vector<r> lower = // ...
+    vector<r> upper = // ...
+    vector<r> hull = // TODO     
+}
+

Динамические выпуклые оболочки

+

Оперируя с оболочкой как с двумя огибающими, можно относительно просто обрабатывать запросы добавления точек, обернув огибающие, например, в std::set так, что они оказываются отсортированными по \(x\). При добавлении точки нужно просто найти её позицию в дереве и проверить и, возможно, удалить сколько-то её левых и правых соседей.

+

Обрабатывать удаление точки из множества сложнее. Если запросы известны заранее, то проще воспользоваться идеями корневой эвристики или dynamic connectivity problem и поддерживать только те точки, которые существуют на всем текущем блоке, и сводить удаление к добавлению.

+

Если же запросы удаления требуется обрабатывать онлайн (fully dynamic convex hull), то для этого есть очень неприятный алгоритм на 250-300 строк кода, заключающийся в поддержании огибающих для всех поддеревьев в этом дереве поиска и быстром объединении огибающих при перестроении дерева. Алгоритм работает за \(O(\log^2 n)\) на запрос: при слиянии огибающих при объединении вершин используется бинпоиск с разбором кучи случаев.

+

Алгоритм Чана

+

Как уже упоминалось ранее, иногда существенную роль играет то, что алгоритм Джарвиса работает за \(O(nh)\), а не \(O(n^2)\), то есть когда точек немного, он лучше алгоритмов Грэхэма и Эрдрю.

+

Алгоритм Чана пытается получить лучшее из двух и объединенить алгоритмы Джарвиса и Грэхэма (или Эндрю), чтобы получить асимптотику \(O(n \log h)\).

+

Алгоритм. Разделим все точки на группы по \(m\) точек. В каждой группе построим выпуклую оболочку за \(O(m \log m)\) алгоритмом Грэхэма. Точки никак не упорядочены, и эти оболочки могут пересекаться — это нормально. Суммарно для всех групп понадобится \(O(n \log m)\) операций.

+

Затем, начиная с самой левой нижей точки, мы будем строить общую выпуклую обочку аналогично алгоритму Джарвиса, но теперь «самую правую точку» можно находить каждый раз не за \(O(n)\), а за \(O(\frac{n}{m} \log m)\), если делать бинарный поиск в каждой из \(\frac{n}{m}\) оболочек.

+

Получается, что такое решение будет работать за \(O(h \frac{n}{m} \log m + n \log m)\). Если заранее приблизительно знать \(h\), то можно положить \(m = h\), и тогда асимптотика составит \(O(n \log h)\).

+

Понятно, что ни при каких применимых на практике ограничениях, даже если оболочка состоит из трёх точек, этот алгоритм не будет быстрее обычного алгоритма Грэхэма, так что автор не будет приводить его реализацию. Этот способ просто интересен с теоретической точки зрения.

+

Упражнение. Придумайте, как при неизвестном \(h\) подбирать \(m\) так, чтобы асимптотика оставалась \(O(n \log h)\).

+

Пересечение полуплоскостей

+

Очень похожей задачей является пересечение полуплоскостей (англ. half-plane intersection). Требуется найти множество, удовлетворяющее набору неравенств:

+

\[ +a_i x + b_i y + c \geq 0 +\]

+

Например, через сведение к пересечению полуплоскостей можно (не самым оптимальным образом) строить подобные клёвые картинки:

+

+

Это диаграмма Вороного — для каждой точки выделено множество точек плоскости, которые являются к ней ближайшими. Чтобы построить это множество для данной точки \(p_i\), можно перебрать все осталньные точки \(p_j\), посчитать коэффициенты прямой, которая делит плоскость между ними пополам, и пересечь все ориентированные в нужную сторону полуплоскости, соответствующие этим прямым.

+

Результатом пересечения множества полуплоскостей тоже будет выпуклая оболочка — полуплоскость это выпуклое множество, а пересечение любых выпуклых множеств тоже выпуклое. Возможно, эта оболочка будет бесконечной по каким-то направлениям. Чтобы не обрабатывать это отдельно, часто добавляют bounding box — «коробку» из четырёх полуплоскостей на большом удалении.

+

Один из способов построения — построить отдельно верхнюю и нижнюю огибающую и пересечь. Для этого нужно разделить все полуплоскости на «смотрящие вверх» и «смотрящие вниз», для обеих групп отсортировать их по углу вектора нормали и пройтись по ним со стеком, удаляя «ненужные» полуплоскости — те, которые полностью покрываются новой и предпоследней полуплоскостью в стеке.

+
// TODO: я не хочу это кодить
+

Алгоритм почти совпадает с алгоритмом алгоритмом Грэхэма и тоже имеет асимптотику \(O(n \log n)\). На самом деле, разделять полуплоскости на верхние и нижние и потом объединять не требуется — можно пройтись таким же образом по сортированному набору полуплоскостей два раза, и какой-то подотрезок получившегося стека будет нужным ответом. Автор не в состоянии внятно объяснять, почему.

+

Нахождение касательной

+

В некоторых задачах нам на самом деле не нужно явно находить пересечение, а достаточно лишь определить, пустое оно или нет. В блоге Петра Митричева описан простой алгоритм, позволяющий это делать за линейное время.

+
    +
  • Если это так, то она останется той же, и ничего дальше считать не надо.

  • +
  • Если это не так, то новая точка должна лежать на пересечении новой полуплоскости и какой-то из старых. Важный факт здесь в том, что она лежит на границе новой полуплоскости. Чтобы её найти, пересечём все старые полуплоскости с этой границей и решим одномерную задачу пересечения множества интервалов, бесконечных в одну из сторон. Если получившийся интервал пуст, то и пересечение полуплоскостей пустое, а в противном случае один из его концов будет искомой точкой, если его положить обратно на границу новой полуплоскости.

  • +
+

Теперь интересная часть — асимптотика. Казалось бы, такой алгоритм в худшем случае может работать за квадратичное время — мы могли каждый раз на \(k\)-том шаге пересекать все имеющиеся полуплоскости с новой за \(O(k)\).

+

Выясняется, что за счёт рандомизированности нам нужно выполнять это пересечение не так уж и часто. В случайном множестве из \(k\) полуплоскостей только две из них дают в пересечении оптимальную точку. Из этого следует, что вероятность того, что \(k\)-тая полуплоскость станет новой граничной (и понадобится всё пересчитывать) равна всего \(\frac{2}{k}\).

+

Таким образом, в среднем алгоритм совершит линейное количество операций:

+

\[ +\frac{2}{2} \cdot 2 + \frac{2}{3} \cdot 3 + \frac{2}{4} \cdot 4 + \ldots + \frac{2}{n} \cdot n = \sum_k \frac{2}{k} \cdot k = O(n) +\]

+

Подобная техника иногда используется и для других геометрических задач — например, для нахождения пары ближайших точек на плоскости.

+ + diff --git a/ru/dfs.html b/ru/dfs.html new file mode 100644 index 0000000..7313796 --- /dev/null +++ b/ru/dfs.html @@ -0,0 +1,370 @@ + + + + + + + Обходы графов - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Обходы графов

+

В этой статье рассмотрены основные применения обхода в глубину: топологическая сортировка, нахождение компонент сильной связности, решение задачи 2-SAT, нахождение мостов и точек сочленения, а также построение эйлерова пути и цикла в графе.

+

Поиск в глубину

+

Поиском в глубину (англ. depth-first search, DFS) называется рекурсивный алгоритм обхода дерева или графа, начинающий в корневой вершине (в случае графа её может быть выбрана произвольная вершина) и рекурсивно обходящий весь граф, посещая каждую вершину ровно один раз.

+
const int maxn = 1e5;
+bool used[maxn]; // тут будем отмечать посещенные вершины
+
+void dfs(int v) {
+    used[v] = true;
+    for (int u : g[v])
+        if (!used[u])
+            dfs(v);
+}
+

Немного его модифицируем, а именно будем сохранять для каждой вершины, в какой момент мы в неё вошли и в какой вышли — соответствующие массивы будем называть \(tin\) и \(tout\).

+

Как их заполнить: заведем таймер, отвечающий за «время» на текущем состоянии обхода, и будем инкрементировать его каждый раз, когда заходим в новую вершину:

+
int tin[maxn], tout[maxn];
+int t = 0;
+
+void dfs(int v) {
+    tin[v] = t++;
+    for (int u : g[v])
+        if (!used[u])
+            dfs(u);
+    tout[v] = t; // иногда счетчик тут тоже увеличивают
+}
+

У этих массивов много полезных свойств:

+
    +
  • Вершина \(u\) является предком \(v\) \(\iff tin_v \in [tin_u, tout_u)\). Эту проверку можно делать за константу.
  • +
  • Два полуинтервала — \([tin_v, tout_v)\) и \([tin_u, tout_u)\) — либо не пересекаются, либо один вложен в другой.
  • +
  • В массиве \(tin\) есть все числа из промежутка от 0 до \(n-1\), причём у каждой вершины свой номер.
  • +
  • Размер поддерева вершины \(v\) (включая саму вершину) равен \(tout_v - tin_v\).
  • +
  • Если ввести нумерацию вершин, соответствующую \(tin\)-ам, то индексы любого поддерева всегда будут каким-то промежутком в этой нумерации.
  • +
+

Эти свойства часто бывают полезными в задачах на деревья.

+

Мосты и точки сочленения

+

Определение. Мостом называется ребро, при удалении которого связный неориентированный граф становится несвязным.

+

Определение. Точкой сочленения называется врешина, при удалении которой связный неориентированный граф становится несвязным.

+

Пример задачи, где их интересно искать: дана топология сети (компьютеры и физические соединения между ними) и требуется установить все единые точки отказа — узлы и связи, без которых будут существовать два узла, между которыми не будет пути.

+

Наивный алгоритм поочередного удаления каждого ребра \((u, v)\) и проверки наличия пути \(u \leadsto v\) потребует \(O(m^2)\) операций. Чтобы научиться находить мосты быстрее, сначала сформулируем несколько утверждений, связанных с обходом в глубину.

+

Запустим DFS из произвольной вершины. Введем новые виды рёбер:

+
    +
  • Прямые рёбра — те, по которым были переходы в dfs.

  • +
  • Обратные рёбра — то, по которым не было переходов в dfs.

  • +
+

Заметим, что никакое обратное ребро \((u, v)\) не может являться мостом: если его удалить, то всё равно будет существовать какой-то путь от \(u\) до \(v\), потому что подграф из прямых рёбер является связным деревом.

+

Значит, остается только проверить все прямые рёбра. Это уже немного лучше — такой алгоритм будет работать за \(O(n m)\).

+

Сооптимизировать его до линейного времени (до одного прохода dfs) поможет замечание о том, что обратные рёбра могут вести только «вверх» — к какому-то предку в дереве обхода графа, но не в другие «ветки» — иначе бы dfs увидел это ребро раньше, и оно было бы прямым, а не обратным.

+

+

Тогда, чтобы определить, является ли прямое ребро \(v \to u\) мостом, мы можем воспользоваться следующим критерием: глубина \(h_v\) вершины \(v\) меньше, чем минимальная глубина всех вершин, соединенных обратным ребром с какой-либо вершиной из поддерева \(u\).

+

Для ясности, обозначим эту величину как \(d_u\), которую можно считать во время обхода по следующей формуле:

+

\[ +d_v = \min \begin{cases} +h_v, &\\ +d_u, &\text{ребро } (v \to u) \text{ прямое} \\ +h_u, &\text{ребро } (v \to u) \text{ обратное} +\end{cases} +\]

+

Если это условие (\(h_v < d_u\)) не выполняется, то существует какой-то путь из \(u\) в какого-то предка \(v\) или саму \(v\), не использующий ребро \((v, u)\), а в противном случае — наоборот.

+
const int maxn = 1e5;
+
+bool used[maxn];
+int h[maxn], d[maxn];
+
+void dfs(int v, int p = -1) {
+    used[u] = true;
+    d[v] = h[v] = (p == -1 ? 0 : h[p] + 1);
+    for (int u : g[v]) {
+        if (u != p) {
+            if (used[u]) // если рябро обратное
+                d[v] = min(d[v], h[u]);
+            else { // если рябро прямое
+                dfs(u, v);
+                d[v] = min(d[v], d[u]);
+                if (h[v] < d[v]) {
+                    // ребро (v, u) -- мост
+                }
+            }
+        }
+    }
+}
+

Примечание. Более известен алгоритм, вместо глубин вершин использующий их \(tin\), но автор считает его чуть более сложным для понимания.

+

Точки сочленения

+

Задача поиска точек сочленения не сильно отличается от задачи поиска мостов.

+

Вершина \(v\) является точкой сочленения, когда из какого-то её ребёнка \(u\) нельзя дойти до её предка, не используя ребро \((v, u)\). Для конкретного прямого ребра \(v \to u\) этот факт можно проверить так: \(h_v \leq d_u\) (теперь нам нам достаточно нестрогого неравенства, так как если из вершины можно добраться до нее самой, то она все равно будет точкой сочленения).

+

Используя этот факт, можно оставить алгоритм практически прежним — нужно проверить этот критерий для всех прямых рёбер \(v \to u\):

+
void dfs(int v, int p = -1) {
+    used[u] = 1;
+    d[v] = h[v] = (p == -1 ? 0 : h[p] + 1);
+    int children = 0; // случай с корнем обработаем отдельно
+    for (int u : g[u]) {
+        if (u != p) {
+            if (used[u])
+                d[v] = min(d[v], h[u]);
+            else {
+                dfs(u, v);
+                d[v] = min(d[v], d[u]);
+                if (dp[v] >= tin[u] && p != -1) {
+                    // u -- точка сочленения
+                }
+                children++;
+            }
+        }
+    }
+    if (p == -1 && children > 1) {
+        // v -- корень и точка сочленения
+    }
+}
+

Единственный крайний случай — это корень, так как в него мы по определению войдём раньше других вершин. Но фикс здесь очень простой — достаточно посмотреть, было ли у него более одной ветви в обходе (если корень удалить, то эти поддеревья станут несвязными между собой).

+

Топологическая сортировка

+

Задача топологической сортировки графа звучит так: дан ориентированный граф, нужно упорядочить его вершины в массиве так, чтобы все графа рёбра вели из более ранней вершины в более позднюю.

+

Это может быть полезно, например, при планировании выполнения связанных задач: вам нужно одеться, в правильном порядке надев шорты (1), штаны (2), ботинки (3), подвернуть штаны (4) — как хипстеры — и завязать шнурки (5).

+

+

Во-первых, сразу заметим, что граф с циклом топологически отсортировать не получится — как ни располагай цикл в массиве, все время идти вправо по ребрам цикла не получится.

+

Во-вторых, верно обратное. Если цикла нет, то его обязательно можно топологически отсортировать — сейчас покажем, как.

+

Заметим, что вершину, из которой не ведет ни одно ребро, можно всегда поставить последней, а такая вершина в ациклическом графе всегда есть (иначе можно было бы идти по обратным рёбрам бесконечно). Из этого сразу следует конструктивное доказательство: будем итеративно класть в массив вершину, из которой ничего не ведет, и убирать ее из графа. После этого процесса массив надо будет развернуть.

+

Этот алгоритм проще реализовать, обратив внимание на времена выхода вершин в dfs. Вершина, из которой мы выйдем первой — та, у которой нет новых исходящих ребер. Дальше мы будем выходить только из тех вершин, которые если имеют исходящие ребра, то только в те вершины, из которых мы уже вышли.

+

Следовательно, достаточно просто выписать вершины в порядке выхода из dfs, а затем полученный список развернуть, и мы получим какую-то из корректных топологических сортировок.

+

Компоненты сильной связности

+

Мы только что научились топологически сортировать ациклические графы. А что же делать с циклическими графами? В них тоже иногда требуется найти какую-то структуру.

+

Для этого можно ввести понятие сильной связности.

+

Определение. Две вершины ориентированного графа связаны сильно (англ. strongly connected), если существует путь из одной в другую и наоборот. Иными словами, они обе лежат в каком-то цикле.

+

Понятно, что такое отношение транзитивно: если \(a\) и \(b\) сильно связны, и \(b\) и \(c\) сильно связны, то \(a\) и \(c\) тоже сильно связны. Поэтому все вершины распадаются на компоненты сильной связности — такое разбиение вершин, что внутри одной компоненты все вершины сильно связаны, а между вершинами разных компонент сильной связности нет.

+

+

Самый простой пример сильно-связной компоненты — это цикл. Но это может быть и полный граф, или сложное пересечение нескольких циклов.

+

Часто рассматривают граф, составленный из самих компонент сильной связности, а не индивидуальных вершин. Очевидно, такой граф уже будет ациклическим, и с ним проще работать. Задачу о сжатии каждой компоненты сильной связности в одну вершину называют конденсацией графа, и её решение мы сейчас опишем.

+

Если мы знаем, какие вершины лежат в каждой компоненте сильной связности, то построить граф конденсации несложно: дальше нужно лишь провести некоторые манипуляции со списками смежности. Поэтому сразу сведем исходную задачу к нахождению самих компонент.

+

Лемма. Запустим dfs. Пусть \(A\) и \(B\) — две различные компоненты сильной связности, и пусть в графе конденсации между ними есть ребро \(A \to B\). Тогда:

+

\[ +\max\limits_{a \in A}(tout_a) > \max\limits_{b\in B}(tout_b) +\]

+

Доказательство. Рассмотрим два случая, в зависимости от того, в какую из компонент dfs зайдёт первым.

+

Пусть первой была достигнута компонента \(A\), то есть в какой-то момент времени dfs заходит в некоторую вершину \(v\) компоненты \(A\), и при этом все остальные вершины компонент \(A\) и \(B\) ещё не посещены. Но так как по условию в графе конденсаций есть ребро \(A \to B\), то из вершины \(v\) будет достижима не только вся компонента \(A\), но и вся компонента \(B\). Это означает, что при запуске из вершины \(v\) обход в глубину пройдёт по всем вершинам компонент \(A\) и \(B\), а, значит, они станут потомками по отношению к \(v\) в дереве обхода, и для любой вершины \(u \in A \cup B, u \ne v\) будет выполнено \(tout_v] > tout_u\), что и утверждалось.

+

Второй случай проще: из \(B\) по условию нельзя дойти до \(A\), а значит, если первой была достигнута \(B\), то dfs выйдет из всех её вершин ещё до того, как войти в \(A\).

+

Из этого факта следует первая часть решения. Отсортируем вершины по убыванию времени выхода (как бы сделаем топологическую сортировку, но на циклическом графе). Рассмотрим компоненту сильной связности первой вершины в сортировке. В эту компоненту точно не входят никакие рёбра из других компонент — иначе нарушилось бы условие леммы, ведь у первой вершины \(tout\) максимальный . Поэтому, если развернуть все рёбра в графе, то из этой вершины будет достижима своя компонента сильной связности \(C^\prime\), и больше ничего — если в исходном графе не было рёбер из других компонент, то в транспонированном не будет ребер в другие компоненты.

+

После того, как мы сделали это с первой вершиной, мы можем пойти по топологически отсортированному списку дальше и делать то же самое с вершинами, для которых компоненту связности мы ещё не отметили.

+
vector<int> g[maxn], t[maxn];
+vector<int> order;
+bool used[maxn];
+int component[maxn];
+int cnt_components = 0;
+
+// топологическая сортировка
+void dfs1(int v) {
+    used[v] = true;
+    for (int u : g[v]) {
+        if (!used[u]) 
+            dfs1(u);
+    order.push_back(v);
+}
+
+// маркировка компонент сильной связности
+void dfs2(int v) {
+    component[v] = cnt_components;
+    for (int u : t[v])
+        if (cnt_components[u] == 0)
+            dfs2(u);
+}
+
+
+// в содержательной части main:
+
+// транспонируем граф
+for (int v = 0; v < n; v++)
+    for (int u : g[v])
+        t[u].push_back(v);
+
+// запускаем топологическую сортировку
+for (int i = 0; i < n; i++)
+    if (!used[i])
+        dfs1(i);
+
+// выделяем компоненты
+reverse(order.begin(), order.end());
+for (int v : order)
+    if (component[v] == 0)
+        dfs2(v);
+

TL;DR:

+
    +
  1. Сортируем вершины в порядке убывания времени выхода.

  2. +
  3. Проходимся по массиву вершин в этом порядке, и для ещё непомеченных вершин запускаем dfs на транспонированном графе, помечающий все достижимые вершины номером новой компонентой связности.

  4. +
+

После этого номера компонент связности будут топологически отсортированы.

+

2-SAT

+

Ликбез. Конъюнкция — это «правильный» термин для логического «И» (обозначается \(\wedge\) или &). Конъюкция возвращает true тогда и только тогда, когда обе переменные true.

+

Ликбез. Дизъюнкция — это «правильный» термин для логического «ИЛИ» (обозначается \(\vee\) или |). Дизъюнкция возвращает false тогда и только тогда, когда обе переменные false.

+

Рассмотрим конъюнкцию дизъюнктов, то есть «И» от «ИЛИ» от каких-то перемений или их отрицаний. Например, такое выражение:

+
(a | b) & (!c | d) & (!a | !b)
+

Если буквами: (А ИЛИ B) И (НЕ C ИЛИ D) И (НЕ A ИЛИ НЕ B).

+

Можно показать, что любую логическую формулу можно представить в таком виде.

+

Задача satisfiability (SAT) заключается в том, чтобы найти такие значения переменных, при которых выражение становится истинным, или сказать, что такого набора значений нет. Для примера выше такими значениями являются a=1, b=0, c=0, d=1 (убедитесь, что каждая скобка стала true).

+

В случае произвольных формул эта задача быстро не решается. Мы же хотим решить её частный случай — когда у нас в каждой скобке ровно две переменные (2-SAT).

+

Казалось бы — причем тут графы? Заметим, что выражение \(a \, | \, b\) эквивалентно \(!a \rightarrow b \,| \, !b \rightarrow a\). Здесь «\(\rightarrow\)» означает импликацию («если \(a\) верно, то \(b\) тоже верно»). С помощью это подстановки приведем выражение к другому виду — импликативному.

+

Затем построим граф импликаций: для каждой переменной в графе будет по две вершины, (обозначим их через \(x\) и \(!x\)), а рёбра в этом графе будут соответствовать импликациям.

+

+

Заметим, что если для какой-то переменной \(x\) выполняется, что из \(x\) достижимо \(!x\), а из \(!x\) достижимо \(x\), то задача решения не имеет. Действительно: какое бы значение для переменной \(x\) мы бы ни выбрали, мы всегда придём к противоречию — что должно быть выбрано и обратное ему значение.

+

Оказывается, что это условие является не только достаточным, но и необходимым. Доказательством этого факта служит описанный ниже алгоритм.

+

Переформулируем данный критерий в терминах теории графов. Если из одной вершины достижима вторая и наоборот, то эти две вершины находятся в одной компоненте сильной связности. Тогда критерий существования решения звучит так: для того, чтобы задача 2-SAT имела решение, необходимо и достаточно, чтобы для любой переменной \(x\) вершины \(x\) и \(!x\) находились в разных компонентах сильной связности графа импликаций.

+

Пусть решение существует, и нам надо его найти. Заметим, что, несмотря на то, что решение существует, для некоторых переменных может выполняться, что из \(x\) достижимо \(!x\) или из \(!x\) достижимо \(x\) (но не одновременно). В таком случае выбор одного из значений переменной \(x\) будет приводить к противоречию, в то время как выбор другого — не будет. Научимся выбирать из двух значений то, которое не приводит к возникновению противоречий. Сразу заметим, что, выбрав какое-либо значение, мы должны запустить из него обход в глубину/ширину и пометить все значения, которые следуют из него, т.е. достижимы в графе импликаций. Соответственно, для уже помеченных вершин никакого выбора между \(x\) и \(!x\) делать не нужно, для них значение уже выбрано и зафиксировано. Нижеописанное правило применяется только к непомеченным ещё вершинам.

+

Утверждается следующее. Пусть \(\space{\rm comp}[V]\) обозначает номер компоненты сильной связности, которой принадлежит вершина \(V\), причём номера упорядочены в порядке топологической сортировки компонент сильной связности в графе компонентов (т.е. более ранним в порядке топологической сортировки соответствуют большие номера: если есть путь из \(v\) в \(w\), то \(\space{\rm comp}[v] \leq \space{\rm comp}[w]\)). Тогда, если \(\space{\rm comp}[x] < \space{\rm comp}[!x]\), то выбираем значение !x, иначе выбираем x.

+

Докажем, что при таком выборе значений мы не придём к противоречию. Пусть, для определённости, выбрана вершина \(x\) (случай, когда выбрана вершина \(!x\), доказывается также).

+

Во-первых, докажем, что из \(x\) не достижимо \(!x\). Действительно, так как номер компоненты сильной связности \(\space{\rm comp}[x]\) больше номера компоненты \(\space{\rm comp}[!x]\) , то это означает, что компонента связности, содержащая \(x\), расположена левее компоненты связности, содержащей \(!x\), и из первой никак не может быть достижима последняя.

+

Во-вторых, докажем, что из любой вершины \(y\), достижимой из \(x\) недостижима \(!y\). Докажем это от противного. Пусть из \(x\) достижимо \(y\), а из \(y\) достижимо \(!y\). Так как из \(x\) достижимо \(y\), то, по свойству графа импликаций, из \(!y\) будет достижимо \(!x\). Но, по предположению, из \(y\) достижимо \(!y\). Тогда мы получаем, что из \(x\) достижимо \(!x\), что противоречит условию, что и требовалось доказать.

+

Итак, мы построили алгоритм, который находит искомые значения переменных в предположении, что для любой переменной \(x\) вершины \(x\) и \(!x\) находятся в разных компонентах сильной связности. Выше показали корректность этого алгоритма. Следовательно, мы одновременно доказали указанный выше критерий существования решения.

+

Эйлеров цикл

+

Определение. Эйлеров путь — это путь в графе, проходящий через все его рёбра.

+

Определение. Эйлеров цикл — это эйлеров путь, являющийся циклом.

+

Для простоты в обоих случаях будем считать, что граф неориентированный.

+

Также существует понятие гамильтонова пути и цикла — они посещают все вершины по разу, а не рёбра. Нахождение гамильтонова цикла (задача коммивояжера, англ. travelling salesman problem) — одна из самых известных NP-полных задач, в то время как нахождение эйлерового цика решается за линейное время — и мы сейчас покажем, как.

+

Теорема. Эйлеров цикл существует тогда и только тогда, когда степени всех вершин чётны.

+

Доказательство. Необходимость показывается так: можно просто взять эйлеров цикл и ориентировать все его ребра в порядке обхода. Тогда из каждой вершины будет выходить столько же рёбер, сколько входить, а значит степень у всех вершин исходного неориентированного графа была четной.

+

Достаточность докажем конструктивно — предъявим алгоритм нахождения.

+
const int maxn = 1e5;
+set<int> g[maxn];
+
+void euler(int v) {
+    while (!g[v].empty()) {
+        u = *g[v].begin();
+        g[v].erase(g[v].begin());
+        dfs(u);
+    }
+    cout << v <<  " ";
+}
+

Если условие на четность степеней вершин выполняется, то этот алгоритм действительно выводит эйлеров цикл, то есть последовательность из \((m+1)\) вершин, между соседними есть связи.

+

Следствие. Эйлеров путь существует тогда и только тогда, когда количество вершин с нечётными степенями не превосходит 2.

+

Кстати, аналогичная теорема есть и для ориентированного графа (можете сами попытаться сформулировать).

+

Доказать это можно например через лемму о рукопожатиях.

+

Как теперь мы будем решать задачу нахождения цикла в предположении, что он точно есть. Давайте запустимся из произвольной вершины, пройдем по любому ребру и удалим его.

+
void euler(int v){
+    stack >s;
+    s.push({v, -1});
+    while (!s.empty()) {
+        v = s.top().first;
+        int x = s.top().second;
+        for (int i = 0; i < g[v].size(); i++){
+            pair e = g[v][i];
+            if(!u[e.second]){
+                u[e.second]=1;
+                s.push(e);
+                break;
+            }
+        }
+        if (v == s.top().first) {
+            if (~x) {
+                p.push_back(x);
+            }
+            s.pop();
+        }
+    }
+}
+

Упражнение. Сформулируйте и докажите аналогичные утверждения для случая ориентированного графа.

+ + diff --git a/ru/dp-optimizations.html b/ru/dp-optimizations.html new file mode 100644 index 0000000..a44834a --- /dev/null +++ b/ru/dp-optimizations.html @@ -0,0 +1,360 @@ + + + + + + + Пересчёт динамики по слоям - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Пересчёт динамики по слоям

+

В этой задаче мы рассмотрим 4 связанных между собой способа оптимизации динамики. Во всех четырёх мы будем решать одну и ту же задачу:

+
+

Даны \(n\) точек на прямой. Нужно найти \(m\) отрезков, покрывающих все точки, минимизировав при этом сумму квадратов их длин.

+
+

Базовое решение — это следующая динамика:

+
    +
  • \(f[i, j]\) — минимальная стоимость покрытия \(i\) первых (самых левых) точек, используя не более \(j\) отрезков.

  • +
  • Переход — перебор всех возможных последних отрезков, то есть \(f[i, j] = \min_{k < i} \{f[k, j-1] + (x_{i-1}-x_k)^2 \}\).

  • +
+

Итоговый ответ будет записан в \(f[n, m]\), а такое решение непосредственным перебором будет работать за \(O(n^2 m)\).

+
// x[] — отсортированный массив координат точек, индексация с нуля
+
+// квадрат длины отрезка с i-той до j-той точки
+int cost(int i, int j) { return (x[j]-x[i])*(x[j]-x[i]); }
+
+for (int i = 0; i <= m; i++)
+    f[0][k] = 0; // если нам не нужно ничего покрывать, то всё и так хорошо
+// все остальные f предполагаем равными бесконечности
+
+for (int i = 1; i <= n; i++)
+    for (int j = 1; j <= m; j++)
+        for (int k = 0; k < i; k++)
+            f[i][j] = min(f[i][j], f[k][j-1] + cost(k, i-1));
+

Заметим, что циклы по i и j можно поменять местами.

+

Разделяй-и-властвуй

+

Обозначим за \(opt[i, j]\) оптимальный \(k\), то есть тот, на котором \(f[i, j] = f[k, j-1] + (x_{i-1}-x_k)^2\) минимизируется. Для однозначности, если оптимальный индекс не один, то выберем среди них самый правый.

+

Утверждение. \(opt[i, j] \leq opt[i, j+1]\).

+

Интуиция такая: если у нас появился дополнительный отрезок, то последний отрезок нам не выгодно делать больше.

+

Что это нам даёт? Если мы уже знаем \(opt[i, l]\) и \(opt[i, r]\) и хотим посчитать \(opt[i, j]\) для какого-то \(j\) между \(l\) и \(r\), то мы можем сузить отрезок поиска оптимального индекса со всего \([0, i-1]\) до \([opt[i, l], opt[i, r]]\).

+

Будем делать следующее: заведем рекурсивную функцию, которая считает динамики для отрезка \([l, r]\), зная, что их \(opt\)-ы лежат между \(l'\) и \(r'\). Она берет середину отрезка \([l, r]\) и линейным проходом считает ответ для неё, а затем просто спускается дальше рекурсивно.

+
void solve(int l, int r, int _l, int _r, int k) {
+    if (l > r)
+        return; // отрезок пустой -- выходим
+    int t = (l + r) / 2, opt = _l;
+    for (int i = _l; i <= min(_r, t); i++) { 
+        int val = f[i+1][k-1] + cost(i, j);
+        if (val < f[t][k])
+            f[t][k] = val, opt = i;
+    }
+    solve(l, t-1, _l, opt, k);
+    solve(t+1, r, opt, _r, k);
+}
+

Затем последовательно вызовем её для каждого слоя:

+
for (int k = 1; k <= m; k++)
+    solve(0, n-1, 0, n-1, k);
+

Асимптотика. Теперь пересчет одного «слоя» динамики занимает \(O(n \log n)\) вместо \(O(n^2)\), потому что каждый раз рекурсивная функция уменьшает в два раза хотя бы один из отрезков. Так как максимальная глубина рекурсии будет \(O(\log n)\), то каждый элемент будет просмотрен не более \(O(\log n)\) раз.

+

Таким образом, мы улучшили асимптотику до \(O(n m \log n)\).

+

Оптимизация Кнута

+

Предыдущий метод опирался на тот факт, что \(opt[i, j] \leq opt[i, j+1]\). Асимптотику можно ещё улучшить, если \(opt\) монотонен ещё и по первому параметру:

+

\[ +opt[i-1, j] \leq opt[i, j] \leq opt[i, j+1] +\]

+

В задаче это выполняется примерно по той же причине: если нам нужно покрывать меньше точек, то последний отрезок будет начинаться не позже старого.

+

Будем просто для каждого состояния перебирать элементы непосредственно от \(opt[i-1, j]\) до \(opt[i, j+1]\) — можно идти в порядке увеличения \(i\) и уменьшения \(j\), и тогда эти \(opt\) уже будут посчитаны к нужному моменту.

+

Выясняется, что это работает быстро. Чтобы понять, почему, распишем количество элементов, которые мы просмотрим для каждого состояния, и просуммируем:

+

\[ +\sum_{i, j} (opt[i, j+1] - opt[i-1, j] + 1) = nm + \sum_{ij} (opt[i, j+1] - opt[i-1, j]) +\]

+

Заметим, что все элементы, кроме граничных, учитываются в сумме ровно два раза — один раз с плюсом, другой с минусом — а значит их можно сократить. Граничных же элементов \(O(n)\) и каждый из них порядка \(O(n)\). Значит, итоговая асимптотика составит \(O(n \cdot m + n \cdot n) = O(n^2)\).

+
for (int i = 1; i <= n; i++) {
+    for (int j = m; j >= 1; j--) {
+        for (int k = opt[i-1][j]; k <= opt[i][j+1]; k++) {
+            int val = f[i+1][k-1] + cost(i, j);
+            if (val < f[t][k])
+                f[t][k] = val, opt[i][j] = i;
+        }
+    }
+}
+

Реализация получилась очень лаконичной: она всего на 3 строчки длиннее, чем базовое решение.

+

Convex Hull Trick

+

Возьмём исходную формулу для \(f\) и раскроем скобки в cost:

+

\[ +f[i, j] = \min_{k < i} \{ f[k, j-1] + (x_{i-1}-x_k)^2 \} = \min_{k < i} \{ +f[k, j-1] + x_{i-1}^2 +- 2x_{i-1} x_k ++ x_k^2 +\} +\]

+

Заметим, что \(x_{i-1}^2\) не зависит от \(k\), значит его можно вынести. Под минимумом тогда останется

+

\[ +\underbrace{f[k, j-1] + x_k^2}_{a_k} +\underbrace{-2x_k}_{b_k} x_{i-1} +\]

+

Это выражение можно переписать как \(\min_k (a_k, b_k) \cdot (1, x_{i-1})\), где под «\(\cdot\)» имеется в виду скалярное произведение.

+

+

Пусть мы хотим найти оптимальное \(k\) для \(f[i][j]\). Представим все уже посчитанные релевантные динамики с предыдущего слоя как точки \((a_k, b_k)\) на плоскости. Чтобы эффективно находить среди них точку с минимальным скалярным произведением, можно поддерживать их нижнюю огибающую (вектор \((1, x_{i-1})\) «смотрит» всегда вверх, поэтому нам интересна только она) и просто бинпоиском будем находить оптимальную точку.

+

Хранить нижнюю огибающую можно просто в стеке. Так как добавляемые точки отсортированы по \(x\), её построение будет занимать линейное время, а асимптотика всего алгоритму будет упираться в асимптотику бинарного поиска, то есть будет равна \(O(n m \log n)\)

+
struct line {
+    int k, b;
+    line() {}
+    line(int a, int _b) { k = a, b = _b; }
+    int get(int x) { return k * x + b; }
+};
+
+vector<line> lines; // храним прямые нижней огибающей
+vector<int> dots; // храним x-координаты точек нижней огибающей
+//     ^ первое правило вещественных чисел
+//      считаем, что в dots лежит округленная вниз x-координата
+
+int cross(line a, line b) { // считаем точку пересечения
+                            // считаем a.k > b.k
+    int x = (b.b - a.b) / (a.k - b.k);
+    if (b.b < a.b) x--; // боремся с округлением у отрицательных чисел
+    return x;
+}
+
+
+void add(line cur) {
+    while (lines.size() && lines.back().get(dots.back()) > cur.get(dots.back())) {
+        lines.pop_back();
+        dots.pop_back();
+    }
+    if (lines.empty())
+        dots.push_back(-inf);
+    else 
+        dots.push_back(cross(lines.back(), cur));
+    lines.push_back(cur);
+}
+
+int get(int x) {
+    int pos = lower_bound(dots.begin(), dots.end(), x) - dots.begin() - 1;
+    return lines[pos].get(x);
+}
+

В случае нашей конкретной задачи, алгоритм можно и дальше соптимизировать, если вспомнить, что \(opt[i, j] \leq opt[i][j+1]\), то есть что оптимальная точка всегда будет «правее». Это позволяет вместо бинпоиска применить метод двух указателей:

+
// TODO: закомитьте кто-нибудь реализацию на гитхаб
+

Мы избавились от бинпоиска, и теперь алгоритм работает за \(O(n \cdot m)\).

+

Дерево Ли Шао

+

Существует другой подход к Convex Hull Trick: увидеть здесь не точки и оптимизацию скалярного произведения, а линии и нахождение минимума в точке среди этих линий.

+

+

Применительно к нашей задаче, выражение \(\min_k (a_k, b_k) \cdot (1, x_{i-1})\) можно раскрыть как \(\min_k (a_k + b_k \cdot x_{i-1})\) и представить как нахождение минимума в точке среди множества прямых вида \(y = a_k + b_k \cdot x\).

+

Дерево Ли Шао (англ. Li Chao segment tree, кит. 李超段树) — модификация дерева отрезков над множеством возможных \(x\), каждая вершина которого хранит в себе такую прямую, что если пройти по пути от корня до соответствующего листа, то максимум в данной точке будет наибольшее значение на пути.

+

+

Пусть в вершину пришло обновление — прямая new. Если в ней ничего не хранится, то запишем new в вершину и выйдем. Если там уже есть какая-то другая прямая old, то одна из них будет «доминировать» над другой хотя бы на одной из половин, а в другой будет либо пересекаться, либо тоже доминировать.

+

Если одна прямая полностью доминирует над другой, то мы её просто запишем в вершину, а про вторую забудем. Если же прямая доминирует только в одной из половин, то мы запишем её, а «проигравшую» прямую передадим в рекурсию в ту половину, где она может доминировать.

+
typedef int ftype;
+typedef complex<ftype> point;
+#define x real
+#define y imag
+
+ftype dot(point a, point b) {
+    return (conj(a) * b).x();
+}
+
+ftype f(point f, ftype x) {
+    return dot(f, {x, 1});
+}
+
+const int inf = 1e6 + 42;
+
+point ln[8 * inf];
+void add_line(point nw, int v = 1, int l = -inf, int r = inf) {
+    point ol = ln[v];
+    int m = (l + r) / 2;
+    bool lef = f(nw, l) > f(ol, l);
+    bool mid = f(nw, m) > f(ol, m);
+    ln[v] = mid ? nw : ol;
+    if(r - l == 1)
+        return;
+    if(lef != mid)
+        add_line(mid ? ol : nw, 2 * v, l, m);
+    else
+        add_line(mid ? ol : nw, 2 * v + 1, m, r);
+}
+
+int get(int x, int v = 1, int l = -inf, int r = inf) {
+    if(r - l == 1)
+        return f(ln[v], x);
+    int m = (l + r) / 2;
+    if(x < m)
+        return max(f(ln[v], x), get(x, 2 * v, l, m));
+    else
+        return max(f(ln[v], x), get(x, 2 * v + 1, m, r));
+}
+

Достаточно полезно сравнить между собой CHT и дерево Ли-Шао и понимать, в какой из ситуаций стоит применять каждую из этих структур. Адекватные реализации CHT требуют особых условий — точки должны быть отсортированы по \(x\). Если это выполнено, то работать алгоритм будет значительно быстрее, чем дерево Ли Шао, которое, в свою очередь, решает более общую задачу, но работает за \(O(\log MAXC)\) на запрос, а зачастую еще и требует неявную реализацию, если \(MAXC\) достаточно большое (неявная реализация схожа с неявным деревом отрезков).

+

Лямбда-оптимизация

+

Примечание. В научной литературе метод известен как дискретный метод множителей Лагранжа.

+

Рассмотрим немного другую задачу. Пусть нам нужно покрыть те же точки, но теперь нас не ограничивают жёстко в количестве отрезков, а просто штрафуют на какую-то константу \(\lambda\) за использование каждого. Нашу оптимизируемую функцию \(g\) можно выразить через \(f\) следующим образом:

+

\[ +g[i] = \min_{k < i} \{f[i, k] + k \cdot \lambda \} +\]

+

Однако её можно считать по более оптимальной формуле, не сводя к вычислению \(f\):

+

\[ +g[i] = \lambda + \min_{k < i} \{g[k] + (x_{i-1} - x_k)^2 \} +\]

+

Эту динамику можно посчитать за \(O(n)\) — мы это делали полстраницы назад с помощью Convex Hull Trick.

+

Наблюдение 1. Если в оптимальном решении для \(g_i\) мы для какого-то \(\lambda\) использовали ровно \(k\) отрезков, то это же решение будет оптимальным и для \(f[i][k]\).

+

Наблюдение 2. Если уменьшать \(\lambda\), то оптимальное количество отрезков для для \(g_i\) будет увеличиваться.

+

Основная идея оптимизации: сделаем бинпоиск по \(\lambda\), внутри которого будем находить оптимальное решение для \(g_i\) с таким \(\lambda\). Если оптимальное \(k\) больше \(j\), то следующая \(\lambda\) должна быть меньше, а в противном случае наоборот. Когда \(k\) совпадёт с \(j\), просто выведем «чистую» стоимость получившегося решения.

+

Таким образом, задача решается за \(O(n \log n + n \log m)\), если сортировку точек для CHT делать заранее, а не внутри бинпоиска.

+

Мы не учли только одну деталь: почему вообще существует такая \(\lambda\), что оптимальное \(k = j\). Возможно, что функция \(k(\lambda)\) через него «перескакивает». В общем случае это действительно проблема: одной лишь монотонности не достаточно, чтобы решать подобным образом произвольные задачи с ограничением на число объектов.

+

Утверждение. Функция \(f[i, j]\) нестрого вогнутая (то есть выпуклая вверх) по своему второму аргументу, то есть:

+

\[ +f[i, j] - f[i, j-1] \leq f[i, j+1] - f[i, j] +\]

+

Иными словами, «выгода» добавления следующего отрезка с каждым разом не увеличивается. Тогда если мы найдем минимальную \(\lambda\) такую, что \(k \ge j\), то \(f[i, k] = f[i, j]\).

+
pair<ll, int> dp[maxn]; // dp[i] - (ответ, число отрезков)
+
+void init() {
+    for (int i = 0; i < maxn; i++) {
+        dp[i] = make_pair(inf, 0);
+    }
+}
+
+pair<ll, int> check(ll x) { // это можно соптимизировать
+    init();
+    dp[0] = make_pair(0ll, 0); // 1-индексация
+    for (int i = 1; i <= n; i++) {
+        for (int j = 0; j < i; j++) {
+            dp[i] = min(dp[i], {dp[j].first + cost[j + 1][i] + x, dp[j].second + 1});
+        }
+    }
+    return dp[n];
+}
+
+ll solve() {
+    ll l = -1e14; // границы надо подбирать очень аккуратно!
+    ll r = 1;
+    while (l + 1 < r) {
+        ll mid = (l + r) / 2;
+        pair<ll, int> x = check(mid);
+        if (x.second >= k) {
+            l = mid;
+        }
+        else {
+            r = mid;
+        }
+    }
+    pair<ll, int> result = check(l);
+    return result.first - l * return.second; // вычитаем штрафы
+}
+}
+

Суммируем

+

TODO: сделать табличку

+
    +
  • Разделяйка: \(O(nm \log n)\), если cost такой, что opt монотонна по одному аргументу.
  • +
  • Кнут: \(O(nm)\), если cost такой, что opt монотонна по обоим аргументам.
  • +
  • CHT: \(O(nm)\). В оптимизируемой функции нужно увидеть скалярное произведение.
  • +
  • Лагранж: \(O(n \log n)\). Функция должна быть выпуклой.
  • +
+ + diff --git a/ru/eratosthenes.html b/ru/eratosthenes.html new file mode 100644 index 0000000..5d21b87 --- /dev/null +++ b/ru/eratosthenes.html @@ -0,0 +1,193 @@ + + + + + + + Решето Эратосфена - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Решето Эратосфена

+

Определение. Целое положительное число называется простым, если оно имеет ровно два различных натуральных делителя — единицу и самого себя. Единица простым числом не считается.

+

Решето Эратосфена (англ. sieve of Eratosthenes) — алгоритм нахождения всех простых чисел от \(1\) до \(n\).

+

Основная идея соответствует названию алгоритма: запишем ряд чисел \(1, 2,\ldots, n\), а затем будем вычеркивать

+
    +
  • сначала числа, делящиеся на \(2\), кроме самого числа \(2\),

  • +
  • потом числа, делящиеся на \(3\), кроме самого числа \(3\),

  • +
  • с числами, делящимися на \(4\), ничего делать не будем — мы их уже вычёркивали,

  • +
  • потом продолжим вычеркивать числа, делящиеся на \(5\), кроме самого числа \(5\),

  • +
+

…и так далее.

+

Самая простая реализация может выглядеть так:

+
vector<bool> sieve(int n) {
+    vector<bool> is_prime(n+1, true);
+    for (int i = 2; i <= n; i++)
+        if (is_prime[i])
+            for (int j = 2*i; j <= n; j += i)
+                prime[j] = false;
+    return is_prime;            
+}
+

Этот код сначала помечает все числа, кроме нуля и единицы, как простые, а затем начинает процесс отсеивания составных чисел. Для этого мы перебираем в цикле все числа от \(2\) до \(n\), и, если текущее число простое, то помечаем все числа, кратные ему, как составные.

+

Если память позволяет, то для оптимизации скорости лучше использовать не вектор bool, а вектор char — но он займёт в 8 раз больше места. Компьютер не умеет напрямую оперировать с битами, и поэтому при индексации к vector<bool> он сначала достаёт нужный байт, а затем битовыми операциями получает нужное значение, что занимает приличное количество времени.

+

Время работы

+

Довольно легко показать, что асимптотическое время работы алгоритма хотя бы не хуже, чем \(O(n \log n)\): даже если бы мы входили в цикл вычёркиваний для каждого числа, не проверяя его сначала на простоту, суммарно итераций было бы

+

\[ +\sum_k \frac{n}{k} = \frac{n}{2} + \frac{n}{3} + \frac{n}{4} + \ldots + \frac{n}{n} = O(n \log n) +\]

+

Здесь мы воспользовались асимптотикой гармонического ряда.

+

У исходного алгоритма асимптотика должна быть ещё лучше. Чтобы найти её точнее, нам понадобятся два факта про простые числа:

+
    +
  1. Простых чисел от \(1\) до \(n\) примерно \(\frac{n}{\ln n}\) .

  2. +
  3. Простые числа распределены без больших «разрывов» и «скоплений», то есть \(k\)-тое простое число примерно равно \(k \ln k\).

  4. +
+

Мы можем упрощённо считать, что число \(k\) является простым с «вероятностью» \(\frac{1}{\ln n}\). Тогда, время работы алгоритма можно более точнее оценить как

+

\[ +\sum_k \frac{1}{\ln k} \frac{n}{k} +\approx n \int \frac{1}{k \ln k} += n \ln \ln k \Big |_2^n += O(n \log \log n) +\]

+

Асимптотику алгоритма можно улучшить и дальше, до \(O(n)\).

+

Линейное решето

+

Основная проблема решета Эратосфена состоит в том, что некоторые числа мы будем помечать как составные несколько раз — а именно столько раз, сколько у них различных простых делителей. Чтобы достичь линейного времени работы, нам нужно придумать способ, как рассматривать все составные числа ровно один раз.

+

Обозначим за \(d(k)\) минимальный простой делитель числа \(k\) и заметим следующий факт: у составного числа \(k\) есть единственное представление \(k = d(k) \cdot r\), и при этом у числа \(r\) нет простых делителей меньше \(d(k)\).

+

Идея оптимизации состоит в том, чтобы перебирать этот \(r\), и для каждого перебирать только нужные множители — а именно все от \(2\) до \(d(r)\) включительно.

+

Алгоритм

+

Немного обобщим задачу — теперь мы хотим посчитать для каждого числа \(k\) на отрезке \([2, n]\) его минимальный простой делитель \(d_k\), а не только определить его простоту.

+

Изначально массив \(d\) заполним нулями, что означает, что все числа предполагаются простыми. В ходе работы алгортима этот массив будет постепенно заполняться. Помимо этого, будем поддерживать список \(p\) всех найденных на текущий момент простых чисел.

+

Теперь будем перебирать число \(k\) от \(2\) до \(n\). Если это число простое, то есть \(d_k = 0\), то присвоим \(d_k = k\) и добавим \(k\) в список \(p\).

+

Дальше, вне зависимости от простоты \(k\), начнём процесс расстановки значений в массиве \(d\) — переберем найденные простые числа \(p_i\), не превосходящие \(d_k\), и сделаем присвоение \(d_{p_i k} = p_i\).

+
const int n = 1e6;
+
+int d[n+1];
+vector<int> p;
+ 
+for (int k = 2; k <= n; k++) {
+    if (p[k] == 0) {
+        d[k] = k;
+        p.push_back(k);
+    }
+    for (int x : p) {
+        if (x > d[k] || x * d[k] > n)
+            break;
+        d[k * x] = x;
+    }
+}
+

Алгоритм требует как минимум в 32 раза больше памяти, чем обычное решето, потому что требуется хранить делитель (int, 4 байта) вместо одного бита на каждое число. Линейное решето хоть и имеет лучшую асимптотику, но на практике проигрывает также и по скорости оптимизированному варианту решета Эратосфена.

+

Применения

+

Массив \(d\) он позволяет искать факторизацию любого числа \(k\) за время порядка размера этой факторизации:

+

\[ +factor(k) = \{d(k)\} \cup factor(k / d(k)) +\]

+

Знание факторизации всех чисел — очень полезная информация для некоторых задач. Ленейное решето интересно не своим временем работы, а именно этим массивом минимальных простых делителей.

+ + diff --git a/ru/euclid.html b/ru/euclid.html new file mode 100644 index 0000000..b30b3fa --- /dev/null +++ b/ru/euclid.html @@ -0,0 +1,217 @@ + + + + + + + Алгоритм Евклида - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Алгоритм Евклида

+

Наибольшим общим делителем (англ. greatest common divisor) целых неотрицательных чисел \(a\) и \(b\) называется наибольшее число \(x\), которое делит одновременно и \(a\), и \(b\).

+

\[ +\gcd(a, b) = \max_{k: \; k|a \, \land \, k | b} k +\]

+

Когда оба числа равны нулю, результат не определён — подойдёт сколько угодно большое число. Однако в этом случае мы положим в этом случае \(\gcd\) равным тоже нулю, чтобы можно было использовать следующее правило: если одно из чисел равно нулю, то их \(\gcd\) равен второму числу.

+

Алгоритм Евклида находит \(\gcd\) двух чисел \(a\) и \(b\) за \(O(\log \min(a, b))\). Он известен ещё с IV века до нашей эры, а возможно и ранее.

+

Алгоритм основывается на следующей несложной формуле:

+

\[ +\gcd(a, b) = \begin{cases} +a, & b = 0 \\ +\gcd(b,\, a - b), & b > 0 +\end{cases} +\]

+

Здесь предполагается, что \(a > b\).

+

Докажем корректность этой формулы:

+
    +
  • Если \(g = \gcd(a, b)\) делит и \(a\), и \(b\), то их разность \((a-b)\) тоже будет делиться на \(g\).

  • +
  • Никакой больший делитель \(d\) числа \(b\) не может делить число \((a-b)\): если \(d > g\), то \(d\) не может делить \(a\), а значит и не делит \((a - b)\).

  • +
+

Прямая рекурсивная реализация:

+
int gcd(int a, int b) {
+    if (a < b)
+        swap(a, b);
+    if (b == 0)
+        return a;
+    else
+        return gcd(b, a - b);
+}
+

Этот алгоритм может работать долго — например, на паре \((10^9, 1)\) он сделает миллиард итераций. Идея дальнейшей оптимизации в том, чтобы вычитать из \(a\) не одно \(b\) за раз, а столько, чтобы в следующий раз \(a\) и \(b\) уже поменялись местами — чтобы новое \(b\) стало меньше нового \(a\).

+

Простой способ этого достичь — просто вычесть \(b\) из \(a\) сразу максимально возможное число раз, то есть взять вместо нового \(b\) остаток от деления \(a\) на \(b\):

+

\[ +\gcd(a, b) = \begin{cases} +a, & b = 0 \\ +\gcd(b,\, a \bmod b), & b > 0 +\end{cases} +\]

+

Можно показать, что каждые две итерации меньшее число уменьшится хотя бы в два раза, а следовательно алгоритм работает за \(O(\log \min (a, b))\).

+

Реализация:

+
int gcd(int a, int b) {
+    if (b == 0)
+        return a;
+    else
+        return gcd(b, a % b);
+}
+

Чуть более быстрая итеративная форма:

+
int gcd(int a, int b) {
+    wihle (b > 0) {
+        a %= b;
+        swap(a, b);
+    }
+    return a;
+}
+

В компиляторе gcc для этого уже есть встроенная функция __gcd, которая, впрочем, может непредсказуемо себя вести на отрицательных числах и \((0, 0)\).

+

Время работы

+

Оценка \(O(\log n)\) относится к худшему случаю. Асимптотика в среднем это более интересный вопрос, играющий существенную роль в некоторых алгоритмах.

+

+

Примечательно, что худшие входные данные для алгоритма — это соседние числа Фибоначчи. На графике они видны как синие точки в пропорциях золотого сечения.

+

Также иногда полезно знать, что нахождение \(\gcd\) группы из \(n\) чисел от \(1\) до \(A\) будет работать не за \(O(n \log A)\), а за \(O(n + \log A)\) — это несложно доказать по индукции.

+

Решение диофантовых уравнений

+

Для обычного использования \(gcd\) не нужно даже знать, как алгоритм устроен — он есть даже в компиляторе.

+

Расширенный алгоритм Евклида находит, помимо \(g = \gcd(a, b)\), такие целые коэффициенты \(x\) и \(y\), что

+

\[ +a \cdot x + b \cdot y = g +\]

+

Эта модификация алгоритма интересна, потому что с помощью неё можно искать обратный элемент по модулю: такой элемент \(a^{-1}\), что \(a \cdot a^{1} \equiv 1\), что то же самое, что найти решение в целых числах:

+

\[ +a^{-1} \cdot a + k \cdot m = 1 +\]

+

Заметим также, что решений бесконечно много: можно \(x\) увеличить на \(b \cdot y\), а \(y\) уменьшить на \(a \cdot x\), и равенство при этом не изменится.

+

Алгоритм будет тоже рекурсивный. Пусть мы посчитали нужные коэффициенты \(x'\) и \(y'\), когда рекурсивно считали \(\gcd(b, a \bmod b)\). Иными словами, у нас есть решение \((x', y')\) для пары \((b, a \bmod b)\):

+

\[ +b \cdot x' + (a \bmod b) \cdot y' = g +\]

+

Чтобы получить решение для исходной пары, запишем выражение \((a \bmod b)\) как \((a - \lfloor \frac{a}{b} \rfloor \cdot b)\) и подставим в приведенное выше равенство:

+

\[ +b \cdot x' + (a - \Big \lfloor \frac{a}{b} \Big \rfloor \cdot b) \cdot y' = g +\]

+

Теперь выполним перегруппировку слагаемых (сгруппируем по исходным \(a\) и \(b\)) и получим:

+

\[ +a \cdot \underbrace{y'}_x + b \cdot \underbrace{(x' - \Big \lfloor \frac{a}{b} \Big \rfloor \cdot y')}_y = g +\]

+

Сравнивая это с исходным выражением, получаем, что для иходных \(x\) и \(y\) подходят коэффициенты при \(a\) и \(b\).

+

Реализация:

+
int gcd(int a, int b, int &x, int &y) {
+    if (a == 0) {
+        x = 0;
+        y = 1;
+        return b;
+    }
+    int x1, y1;
+    int d = gcd(b % a, a, x1, y1);
+    x = y1 - (b / a) * x1;
+    y = x1;
+    return d;
+}
+

Эта рекурсивная функция по прежнему возвращает значение \(\gcd(a, b)\), но помимо этого записывает в переданные по ссылке переменные \(x\) и \(y\) искомые коэффициенты.

+ + diff --git a/ru/fenwick.html b/ru/fenwick.html new file mode 100644 index 0000000..fe0a687 --- /dev/null +++ b/ru/fenwick.html @@ -0,0 +1,235 @@ + + + + + + + Дерево Фенвика - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+
+

НЯ!
Эта статья полна любви и обожания.
Возможно, стоит добавить ещё больше?

+
+

Дерево Фенвика

+

Дерево Фенвика или двоичное индексированное дерево (англ. binary indexed tree) — структура данных, которая на многих задачах заменяет собой дерево отрезков, но при этом работает в 3-4 раза быстрее, занимает минимально возможное количество памяти (столько же, сколько и массив той же длины), намного быстрее пишется и легче обобщается на большие размерности.

+

Определение

+

Пусть дан массив \(a\) длины \(n\). Деревом Фенвика будем называть массив \(t\) той же длины, объявленный следующим образом:

+

\[ +t_i = \sum_{k=F(i)}^i a_k +\]

+

где \(F\) это какая-то функцию, для которой выполнено \(F(i) \leq i\). Конкретно её определим потом.

+

Запрос суммы. Когда нам нужна сумма на отрезке, мы будем сводить этот запрос к двум суммам на префиксе: \(sum(l, r) = sum(r) - sum(l-1)\). Оба этих запроса будем считать по формуле:

+

\[ +sum(k) = t_k + sum(F(k)-1) +\]

+

Запрос обновления. Когда мы изменяем \(k\)-ю ячейку исходного массива, мы обновляем все \(t_i\), в которых учтена эта ячейка.

+

\(F\) можно выбрать так, что и «спусков» при подсчете суммы, и интересных нам \(t_i\) при обновлении будет будет \(O(\log n)\). Популярны две функции:

+
    +
  • \(F_1(x) =\) x & (x + 1)
  • +
  • \(F_2(x) =\) x - (x & -x) + 1
  • +
+

Первый вариант описан на Викиконспектах и Емаксе и поэтому более известен. Второй, как мы дальше увидим, более простой для запоминания и кодинга, а так же более гибкий — например, там можно делать бинпоиск по префиксным суммам. Его мы и будем использовать.

+

Примечание. Наверное, меньше четверти умеющих писать эту структуру полностью понимают, как она работает. Анализ действительно весьма сложный, поэтому мы приведём его в конце. Рекумендуется пока что абстрагироваться и принять на веру, что любой префикс разбивается на \(O(\log n)\) отрезков вида \([F(i), i]\), а также что любой элемент входит в не более \(O(\log n)\) таких отрезков.

+

+

Реализация

+

Так как \(F(0) = 1 > 0\), то \([0, F(0)]\) не является корректным отрезком. Поэтому нам будет удобнее хранить массив в 1-индексации и не использовать \(t_0\).

+
int t[maxn];
+
+// возвращает сумму на префиксе
+int sum (int r) {
+    int res = 0;
+    for (; r > 0; r -= r & -r)
+        res += t[r];
+    return res;
+}
+
+int sum (int l, int r) {
+    return sum(r) - sum(l-1);
+}
+
+// обновляет нужные t
+void add (int k, int x) {
+    for (; k <= n; k += k & -k)
+        t[k] += x;
+}
+

Автор отмечает красивую симметрию в формулах r -= r & -r и k += k & -k, которой нет в «традиционной» версии.

+

Многомерный случай

+
+

\(k\)-мерное дерево Фенвика пишется в \((k+1)\) строчку

+
+

Нужно добавить всего одну такую же строчку в sum, add, а также при подсчете суммы на прямоугольнике вместо двух запросов к префиксным суммам использовать четыре.

+

sum перепишется следующим образом:

+
int sum (int r1, int r2) {
+    int res = 0;
+    for (int i = r1; i > 0; i -= i & -i)
+        for (int j = r2; j > 0; j -= j & -j)
+            ans += t[i][j];
+    return res;
+}
+

В \(k\)-мерном случае, в соответствии с принципом включений-исключений, для запроса суммы нужно \(2^k\) запросов суммы на префиксах.

+

Если размерности больше, чем позволяет память, то можно вместо массива t использовать хэш-таблицу — так потенциально потребуется \(O(q \log^2 A)\) памяти (\(A\) — максимальная координата), но это всё равно один из самых безболезненных способов решать достаточно простые задачи на двумерные структуры. Автор в своё время таким образом решил какую-то задачу на 2d-сумму с USACO 2017.

+

Бинпоиск

+

Оказывается, можно производить бинарный поиск (точнее, спуск) по префиксным суммам за \(O(\log n)\).

+
// возвращает индекс, на котором сумма уже больше
+int lower_bound (int s) {
+    int k = 0;
+    for (int l = logn; l >= 0; l--) {
+        if (k + (1<<l) <= n && t[k + (1<<l)] < s) {
+            k += (1<<l);
+            s -= t[k];
+        }
+    }
+    return k;
+}
+

Если знать, что \(F(x)\) удаляет последний бит \(x\), то принцип понятен: просто делаем спуск по бинарному дереву, как в ДО. Чем-то похоже на генерацию \(k\)-го лексикографического комбинаторного объекта: пытаемся увеличить следующий символ всегда, когда это возможно.

+

Отметим, что в «традиционной» индексации такое делать нельзя.

+

Ограничения на операцию

+

Дерево Фенвика можно использовать, когда наша операция обратима, а также когда трюк с префиксными суммами работает. Это обычно простые операции типа суммы, xor, умножения по модулю (если гарантируется, что на этот модуль ничего не делится). Минимум и gcd, отложенные операции и персистентность прикрутить в общем случае уже не получится — тогда уже нужно писать дерево отрезков.

+

Почему это работает

+

Итак, мы выбрали вариант с \(F(x)\) = x - (x & -x) + 1. Поймем, что означает x & -x.

+

Лемма. x & -x возвращает последний единичный бит в двоичной записи x.

+

Доказательство потребует знания, как в компьютерах хранятся целые числа. Чтобы процессор не сжигал лишние такты, проверяя знак числа при арифметических операциях, их хранят как бы по модулю \(2^k\), а первый бит отвечает за знак (0 для положительных и 1 для отрицательных). Поэтому когда мы хотим узнать, как выглядит отрицательное число, нужно его вычесть из нуля: \(-x = 0-x = 2^k-x\).

+

Как будет выглядеть -x в битовой записи? Ответ можно мысленно разделить на три блока:

+
    +
  • Первые сколько-то (возможно, нисколько) нулей с конца числа x ими же в ответе и останутся.
  • +
  • Потом, ровно на самом младшем единичном бите x, мы «займём» много единиц, так что весь префикс станет единицами. В ответе на этом месте точно будет единица.
  • +
  • Потом отменятся ровно те биты из этого префикса, которые были единицами в исходном числе.
  • +
+

Пример:

+

\[ +\begin{aligned} + +90 = 2+8+16+64 & = 0 \, 10110_2 \\ + -90 = 00000_2 - 10110_2 & = 1 \, 01010_2 \\ + \implies (+90) \& (-90) & = 0 \, 00010_2 \\ +\end{aligned} +\]

+

Теперь мы можем доказать нашу лемму. Когда мы сделаем &, в префиксе до младшего единичного бита все биты x и -x будут противоположными, младший единичный бит останется единичным, а на суффиксе все как было нулями, так и осталось. Следовательно, «выживет» только этот самый младший единичный бит, что мы и доказывали.

+

Следствие 1. sum будет работать за логарифм, а точнее за количество единичных битов в записи \(x\): на каждой итерации мы делаем x -= x & -x, то есть удаляем младший бит.

+

Следствие 2. add тоже будет работать за логарифм: каждую итерацию количество нулей на конце \(x\) увеличивается хотя бы на единицу.

+

Следствие 3. (Почему дерево Фенвика — дерево.)

+
    +
  • Длина отрезка, соответствующего любому \(t_i\) — степень двойки, причём начинается этот отрезок на индексе, кратном этой же степени двойки.

  • +
  • \(\implies\) Множества элементов, учтённых в произвольных \(t_i\) и \(t_j\), либо не пересекаются, либо одно является подмножеством другого.

  • +
  • \(\implies\) На \(t_i\) можно ввести отношение вложенности.

  • +
+

То есть, если напрячь воображение, то \(t\) можно рассматривать как лес деревьев. В частном случае, когда \(n\) является степенью двойки, дерево будет одно.

+

Теперь единственное, что осталось доказать ­— это корректность add. На самом деле, в add мы делаем ни что иное, как подъём от вершины до корня по всем предкам.

+

Как для \(x\) найти непосредственного родителя? Нужно найти минимальное число \(y > x\), у которого \(t_y\) будет включать \(x\). Иными словами, должно выполняться y >= x > y - (y & -y).

+

Дальше читателю предлагается самостоятельно попялиться в пример, чтобы понять, что x + (x & -x) — минимальное такое число:

+

\[ +\begin{aligned} + x = 90 = 2+8+16+64 & = 10110_2 \\ + y = 96 = 32 + 64 & = 11000_2 \\ + \end{aligned} +\]

+ + diff --git a/ru/fft.html b/ru/fft.html new file mode 100644 index 0000000..c1e22ee --- /dev/null +++ b/ru/fft.html @@ -0,0 +1,432 @@ + + + + + + + Быстрое преобразование Фурье - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Быстрое преобразование Фурье

+

Рассмотрим такую распространённую операцию как умножение двух целых чисел. Квадратичный алгоритм — умножения в столбик — все знают со школы. Долгое время предполагалось, что ничего быстрее придумать нельзя.

+

Первым эту гипотезу опроверг Анатолий Карацуба. Его алгоритм сводит умножение двух \(n\)-значных чисел к трём умножениям \(\frac{n}{2}\)-значных чисел, что даёт оценку времени работы

+

\[ +T(n)=3T\left(\dfrac n 2\right)+O(n)=O\left(n^{\log_2 3}\right)\approx O(n^{1.58}) +\]

+

Чтобы перейти к алгоритму с лучшей оценкой, нам нужно сначала установить несколько фактов о многочленах.

+

Умножение многочленов

+

Обратим внимание на то, что любое число можно представить многочленом:

+

\[ +\begin{aligned} +A(x) &= a_0 + a_1\cdot x + a_2 \cdot x^2 + \dots + a_n \cdot x^n +\\ &= a_0 + a_1\cdot 2 + a_2 \cdot 2^2 + \dots + a_n \cdot 2^n +\end{aligned} +\]

+

Основание x при этом может быть выбрано произвольно.

+

Чтобы перемножить два числа, мы можем перемножить соответствующие им многочлены, а затем произвести каррирование: пройтись от нижних разрядов получившегося многочлена и «сдвинуть» переполнившиеся разряды:

+
const int base = 10;
+
+vector<int> normalize(vector<int> a) {
+    int carry = 0;
+    for (int &x : a) {
+        x += carry;
+        carry = x / base;
+        x %= base;
+    }
+    while (carry > 0) {
+        a.push_back(carry % base);
+        carry /= base;
+    }
+    return a;
+}
+
+vector<int> multiply(vector<int> a, vector<int> b) {
+    return normalize(poly_multiply(a, b));
+}
+

Прямая формула для произведения многочленов имеет вид

+

\[ +\left(\sum_{i=0}^n a_i x^i\right)\cdot\left(\sum_{j=0}^m b_j x^j\right)=\sum_{k=0}^{n+m}x^k\sum_{i+j=k}a_i b_j +\]

+

Её подсчёт требует \(O(n^2)\) операций, что нас не устраивает. Подойдём к этой задаче с другой стороны.

+

Интерполяция

+

Теорема. Пусть есть набор различных точек \(x_0, x_1, \dots, x_{n}\). Многочлен степени \(n\) однозначно задаётся своими значениями в этих точках. (Коэффициентов у этого многочлена столько же, сколько и точек — прим. К. О.)

+

Доказательство будет конструктивным — можно явным образом задать многочлен, который принимает заданные значения \(y_0, y_1, \ldots, y_n\) в этих точках:

+

\[ +y(x)=\sum\limits_{i=0}^{n}y_i\prod\limits_{j\neq i}\dfrac{x-x_j}{x_i-x_j} +\]

+

Корректность. Проверим, что в точке \(x_i\) значение действительно будет равно \(y\):

+
    +
  1. Для \(i\)-го слагаемого внутреннее произведение будет равно единице, если вместо \(x\) подставить \(x_i\): в этом случае просто перемножается \((n-1)\) единица. Эта единица помножится на \(y_i\) и войдёт в сумму.

  2. +
  3. Для всех остальных слагаемых произведение занулится: один из множетелей будет равен \((x_i - x_i)\).

  4. +
+

Уникальность. Предположим, есть два подходящих многочлена степени \(n\)\(A(x)\) и \(B(x)\). Рассмотрим их разность. В точках \(x_i\) значение получившегося многочлена \(A(x) - B(x)\) будет равняться нулю. Если так, то точки \(x_i\) должны являться его корнями, и тогда разность можно записать так:

+

\[ +A(x) - B(x) = \alpha \prod_{i=0}^n (x-x_i) +\]

+

для какого-то числа \(\alpha\). Тут мы получаем противоречие: если раскрыть это произведение, то получится многочлен степени \(n+1\), который нельзя получить разностью двух многочленов степени \(n\).

+

Этот многочлен называется интерполяционным многочленом Лагранжа, а сама задача проведения многочлена через точки — интерполяцией.

+

+

Примечание. На практике интерполяцию решают методом Гаусса: её можно свести к решению линейного уравнения \(aX = y\), где \(X\) это матрица следующего вида:

+

\[ +\begin{pmatrix} + 1 & x_0 & x_0^2 & \ldots & x_0^n \\ + 1 & x_1 & x_1^2 & \ldots & x_1^n \\ + \vdots & \vdots & \vdots & \ddots & \vdots \\ + 1 & x_n & x_n^2 & \ldots & x_n^n \\ +\end{pmatrix} +\]

+

Важный факт: многочлен можно однозначно задать не только своими коэффициентами, но также корнями и значениями хотя бы в \((n+1)\)-ой точке.

+

Умножение через интерполяцию

+

Что происходит со значениями многочлена-произведения \(A(x) B(x)\) в конкретной точке \(x_i\)? Оно просто становится равным \(A(x_i) B(x_i)\).

+

Основная идея алгоритма: если мы знаем значения в каких-то различных \(n + m\) точках для обоих многочленов \(A\) и \(B\), то, попарно перемножив их, мы за \(O(n + m)\) операций можем получить значения в тех же точках для многочлена \(A(x) B(x)\) — а с их помощью можно интерполяцией получить исходный многочлен и решить задачу.

+
vector<int> poly_multiply(vector<int> a, vector<int> b) {
+    vector<int> A = evaluate(a);
+    vector<int> B = evaluate(b);
+    for (int i = 0; i < A.size(); i++)
+        A[i] *= B[i];
+    return interpolate(A);
+}
+

Если притвориться, что evaluate и interpolate работают за линейное время, то умножение тоже будет работать за линейное время.

+

К сожалению, непосредственное вычисление значений требует \(O(n^2)\) операций, а интерполяция — как методом Гаусса, так и через символьное вычисление многочлена Лагранжа — и того больше, \(O(n^3)\).

+

Но что, если бы мы могли вычислять значения в точках и делать интерполяцию быстрее?

+

Комплексные числа

+

Определение. Комплексные числа — это числа вида \(a + bi\), где \(a\) и \(b\) это обычные вещественные числа, а \(i\) это так называемая мнимая единица: это число, для которого выполняется равенство \(i^2 = -1\).

+

Комплексные числа ввели в алгебре, чтобы работать с корнями из отрицательных чисел: \(i\) в каком-то смысле равно \(\sqrt{-1}\). Так же, как и отрицательные числа, они как бы «не существуют» в реальном мире, а только в сознании математиков.

+

С комплексными числами можно работать почти так же, как с действительными. Они даже удобнее: все квадратные корни всегда извлекаются, все корни многочленов всегда находятся.

+

Комплексная плоскость

+

Комплексные числа удобно изображать на плоскости в виде вектора \((a, b)\) и считать через них всякую геометрию.

+

+

Модулем комплексного числа называется действительное число \(r = \sqrt{a^2 + b^2}\) . Геометрически, это длина вектора \((a, b)\).

+

Аргументом комплексного числа называется действительное число \(\phi \in (-\pi, \pi]\), для которого выполнено \(\tan \phi = \frac{b}{a}\). Геометрически, это значение угла между \((a, 0)\) и \((a, b)\). Для нуля — вектора \((0, 0)\) — аргумент не определён.

+

Таким образом комплексное число можно представить в полярных координатах:

+

\[ +a+bi = r ( \cos \phi + i \sin \phi ) +\]

+

Подобное представление удобно по следующей причине: чтобы перемножить два комплексных числа, нужно перемножить их модули и сложить аргументы.

+

Упражнение. Докажите это.

+

Формула эйлера

+

Определим число Эйлера \(e\) как число со следующим свойством:

+

\[ +e^{i\phi} = \cos \phi + i \sin \phi +\]

+

Просто введём такую нотацию для выражения \(\cos \phi + i \sin \phi\). Не надо думать, почему это так.

+

Геометрически, все такие точки живут на единичном круге:

+

+

Такая нотация удобна, потому что можно обращаться с \(e^{i\phi}\) как с обычной экспонентой. Пусть мы, например, хотим перемножить два числа на единичном круге с аргументами \(a\) и \(b\). Тогда это можно записать так:

+

\[ +(\cos a + i \sin a) \cdot (\cos b + i \sin b) = e^{i (a+b)} +\]

+

Упражнение. Проверьте это: раскройте скобки и проделайте немного алгебры.

+

Корни из единицы

+

У комплексных чисел есть много других замечательных свойств, но нам для алгоритма на самом деле потребуется только следующее:

+

Утверждение. Для любого натурального \(n\) есть ровно \(n\) комплексных «корней из единицы», то есть чисел \(w_k\), для которых выполнено:

+

\[ +w_k^n = 1 +\]

+

А именно, это будут числа вида:

+

\[ +w_k = e^{i \tau \frac{k}{n}} +\]

+

где \(\tau\) обозначает \(2 \pi\), «целый круг». Это довольно новая нотация.

+

На комплексной плоскости эти числа располагаются на единичном круге на равном расстоянии друг от друга:

+
+Все 9 комплексных корней степени 9 из единицы +
+

Первый корень \(w_1\) (точнее второй — единицу считаем нулевым корнем) называют образующим корнем степени \(n\) из единицы. Возведение его в нулевую, первую, вторую и так далее степени порождает последовательность нужных корней единицы, при этом на \(n\)-ном элементе последовательность зацикливается:

+

\[ +w_n = e^{i \tau \frac{n}{n}} = e^{i \tau} = e^{i \cdot 0} = w_0 = 1 +\]

+

Будем обозначать \(w_1\) как просто \(w\).

+

Упражнение. Докажите, что других корней быть не может.

+

Дискретное преобразование Фурье

+

Дискретным преобразованием Фурье называется вычисление значений многочлена в комплексных корнях из единицы:

+

\[ +y_j = \sum_{k=0}^{n-1} x_n e^{i\tau \frac{kj}{n}} = \sum_{k=0}^{n-1} x_n w_1^{kj} +\]

+

Обратным дискретным преобразованием Фурье называется, как можно догадаться, обратная операция — интерполяция коэффициентов \(x_i\) по значениям \(X_i\).

+

\[ +x_j = \frac{1}{n} \sum_{k=0}^{n-1} y_n e^{-i\tau \frac{kj}{n}} = \frac{1}{n} \sum_{k=0}^{n-1} y_n w_{n-1}^{kj} +\]

+

Почему эта формула верна? При вычислении ПФ мы практически применяем матрицу к вектору:

+

\[ +\begin{pmatrix} +w^0 & w^0 & w^0 & w^0 & \dots & w^0 \\ +w^0 & w^1 & w^2 & w^3 & \dots & w^{-1} \\ +w^0 & w^2 & w^4 & w^6 & \dots & w^{-2} \\ +w^0 & w^3 & w^6 & w^9 & \dots & w^{-3} \\ +\vdots & \vdots & \vdots & \vdots & \ddots & \vdots \\ +w^0 & w^{-1} & w^{-2} & w^{-3} & \dots & w^1 +\end{pmatrix}\begin{pmatrix} a_0 \\ a_1 \\ a_2 \\ a_3 \\ \vdots \\ a_{n-1} +\end{pmatrix} = \begin{pmatrix} y_0 \\ y_1 \\ y_2 \\ y_3 \\ \vdots \\ y_{n-1} +\end{pmatrix} +\]

+

То есть преобразование Фурье — это просто линейная операция над вектором: \(W a = y\). Значит, обратное преобразование можно записать так: \(a = W^{-1}y\).

+

Как будет выглядеть эта \(W^{-1}\)? Автор не будет пытаться изображать логичный способ рассуждений о её получении и сразу её приведёт:

+

\[ +W^{-1} = +\dfrac 1 n\begin{pmatrix} +w^0 & w^0 & w^0 & w^0 & \dots & w^0 \\ +w^0 & w^{-1} & w^{-2} & w^{-3} & \dots & w^{1} \\ +w^0 & w^{-2} & w^{-4} & w^{-6} & \dots & w^{2} \\ +w^0 & w^{-3} & w^{-6} & w^{-9} & \dots & w^{3} \\ +\vdots & \vdots & \vdots & \vdots & \ddots & \vdots \\ +w^0 & w^{1} & w^{2} & w^{3} & \dots & w^{-1} +\end{pmatrix} +\]

+

Проверим, что при перемножении \(W\) и \(W^{-1}\) действительно получается единичная матрица:

+
    +
  1. Значение \(i\)-того диагонального элемента будет равно \(\frac{1}{n} \sum_k w^{ki} w^{-ki} = \frac{1}{n} n = 1\).

  2. +
  3. Значение любого недиагонального (\(i \neq j\)) элемента \((i, j)\) будет равно \(\frac{1}{n} \sum_k w^{ik} w^{-jk} = \frac{1}{n} \sum_k w^k w^{i-j} = \frac{w^{i-j}}{n} \sum_k w^k = 0\), потому что все комплексные корни суммируются в ноль, то есть \(\sum w^k = 0\) (см. картинку — там всё симметрично).

  4. +
+

Внимательный читатель заметит симметричность форм \(W\) и \(W^{-1}\), а также формул для прямого и обратного преобразования. На самом деле, эта симметрия нам сильно упростит жизнь: для обратного преобразования Фурье можно использовать тот же алгоритм, только вместо \(w^k\) использовать \(w^{-k}\), а в конце результат поделить на \(n\).

+

Зачем это надо?

+

Напомним, что мы изначально хотели перемножать многочлены следующим алгоритмом:

+
    +
  1. Посчитаем значения в \(n+m\) каких-нибудь точках обоих многочленов

  2. +
  3. Перемножим эти значения за \(O(n+m)\).

  4. +
  5. Интерполяцией получим многочлен-произведение.

  6. +
+

В общем случае быстро посчитать интерполяцию и даже просто посчитать значения в точках нельзя, но для корней единицы — можно. Если научиться быстро считать значения в корнях и интерполировать (прямое и обратное преобразование Фурье), но мы можно решить исходную задачу.

+

Соответствующий алгоритм называется быстрым преобразованием Фурье (англ. fast Fourier transform). Он использует парадигму «разделяй-и-властвуй» и работает за \(O(n \log n)\).

+

Схема Кули-Тьюки

+

Обычно, алгоритмы «разделяй-и-властвуй» делят задачу на две половины: на первые \(\frac{n}{2}\) элементов и вторые \(\frac{n}{2}\) элементов. Здесь мы поступим по-другому: поделим все элементы на чётные и нечётные.

+

Представим многочлен в виде \(P(x)=A(x^2)+xB(x^2)\), где \(A(x)\) состоит из коэффициентов при чётных степенях \(x\), а \(B(x)\) — из коэффициентов при нечётных.

+

Пусть \(n = 2k\). Тогда заметим, что

+

\[ +w^{2t}=w^{2t \bmod 2k}=w^{2(t \bmod k)} +\]

+

Зная это, исходную формулу для значения многочлена в точке \(w^t\) можно записать так:

+

\[ +P(w^t) += A(w^{2t}) + w^t B(w^{2t}) += A\left(w^{2(t\bmod k)}\right)+w^tB\left(w^{2(t\bmod k)}\right) +\]

+

Ключевое замечание: корней вида \(w^{2t}\) в два раза меньше, потому что \(w^n = w^0\), и можно сказать, что.

+

У нас по сути в два раза меньше корней (но они так же равномерно распределены на единичной окружности) и в два раза меньше коэффициентов — мы только что успешно уменьшили нашу задачу в два раз.

+

Сам алгоритм заключается в следующем: рекурсивно посчитаем БПФ для многочленов \(A\) и \(B\) и объединим ответы с помощью формулы выше. При этом в рекурсии нам нужно считать значения на корнях степени не \(n\), а \(k = \frac{n}{2}\), то есть на всех «чётных» корнях степени \(n\) (вида \(w^{2t}\)).

+

Заметим, что если \(w\) это образующий корень степени \(n = 2k\) из единицы, то \(w^2\) будет образующим корнем степени \(k\), то есть в рекурсию мы можем просто передать другое значение образующего корня.

+

Таким образом, мы свели преобразование размера \(n\) к двум преобразованиям размера \(\dfrac n 2\). Следовательно, общее время вычислений составит

+

\[ +T(n)=2T\left(\dfrac n 2\right)+O(n)=O(n\log n) +\]

+

Заметим, что предположение о делимости \(n\) на \(2\) имело существенную роль. Значит, \(n\) должно быть чётным на каждом уровне, кроме последнего, из чего следует, что \(n\) должно быть степенью двойки.

+

Реализация

+

Приведём код, вычисляющий БПФ по схеме Кули-Тьюки:

+
typedef complex<double> ftype;
+const double pi = acos(-1);
+
+template<typename T>
+vector<ftype> fft(vector<T> p, ftype w) {
+    int n = p.size();
+    if(n == 1)else { {
+        return {p[0]};
+    else {
+        vector<T> AB[2];
+        for(int i = 0; i < n; i++)
+            AB[i % 2].push_back(p[i]);
+        auto A = fft(AB[0], w * w);
+        auto B = fft(AB[1], w * w);
+        vector<ftype> res(n);
+        ftype wt = 1;
+        int k = n / 2;
+        for(int i = 0; i < n; i++) {
+            res[i] = A[i % k] + wt * B[i % k];
+            wt *= w;
+        }
+        return res;
+    }
+}
+
+vector<ftype> evaluate(vector<int> p) {
+    while(__builtin_popcount(p.size()) != 1)
+        p.push_back(0);
+    return fft(p, polar(1., 2 * pi / p.size()));
+}
+

Как обсуждалось выше, обратное преобразование Фурье удобно выразить через прямое:

+
vector<int> interpolate(vector<ftype> p) {
+    int n = p.size();
+    auto inv = fft(p, polar(1., -2 * pi / n));
+    vector<int> res(n);
+    for(int i = 0; i < n; i++)
+        res[i] = round(real(inv[i]) / n);
+    return res;
+}
+

Теперь мы умеем перемножать два многочлена за \(O(n \log n)\):

+
vector<int> poly_multiply(vector<int> a, vector<int> b) {
+    vector<int> A = fft(a);
+    vector<int> B = fft(b);
+    for (int i = 0; i < A.size(); i++)
+        A[i] *= B[i];
+    return interpolate(A);
+}
+

Примечание. Приведённый выше код, являясь корректным и имея асимптотику \(O(n\log n)\), едва ли пригоден для использования на реальных контестах. Он имеет большую константу и далеко не так численно устойчивый, чем оптимальные варианты написания быстрого преобразования Фурье. Мы его приводим, потому что он относительно простой.

+

Читателю рекомендуется самостоятельно задуматься о том, как можно улучшить время работы и точность вычислений. Из наиболее важных недостатков:

+
    +
  • внутри преобразования не должно происходить выделений памяти

  • +
  • работать желательно с указателями, а не векторами

  • +
  • корни из единицы должны быть посчитаны наперёд

  • +
  • Следует избавиться от операций взятия остатка по модулю

  • +
  • Вместо вычисления преобразования с \(w^{-1}\) можно вычислить преобразование с \(w\), а затем развернуть элементы массива со второго по последний.

  • +
+

Здесь приведена одна из условно пригодных реализаций.

+

Но главная проблема в численной стабильности — мы нарушили первое правило действительных чисел. Однако, от неё можно избавиться.

+

Number-theoretic transform

+

Нам от комплексных чисел на самом деле нужно было только одно свойство: что у единицы есть \(n\) «корней». На самом деле, помимо комплексных чисел, есть и другие алгебраические объекты, обладающие таким свойством — например, элементы кольца вычетов по модулю.

+

Нам нужно просто найти такую пару \(m\) и \(g\) (играющее роль \(w_n^1\)), такую что \(g\) является образующим элементом, то есть \(g^n \equiv 1 \pmod m\) и при для всех остальных \(k < n\) все степени \(g^k\) различны по модулю \(m\). В качестве \(m\) на практике часто специально берут «удобные» модули, например

+

\[ +m = 998244353 = 7 \cdot 17 \cdot 2^{23} + 1 +\]

+

Это число простое, и при этом является ровно на единицу больше числа, делящегося на большую степень двойки. При \(n=2^23\) подходящим \(g\) является число \(31\). Заметим, что, как и для комплексных чисел, если для некоторого \(n=2^k\) первообразный корень - \(g\), то для \(n=2^{k-1}\) первообразным корнем будет \(g^2 (mod m)\). Таким образом, для \(m=998244353\) и \(n=2^k\) первообразный корень будет равен \(g=31 \cdot 2^{23-k} (mod m)\).

+

Реализация практически не отличается.

+
const int MOD = 998244353, W = 805775211, IW = 46809892;
+const int MAXN = (1 << 19), INV2 = 499122177;
+
+// W - первообразный корень MAXN-ной степени из 1, IW - обратное по модулю MOD к W
+// Первообразный корень (1 << 23)-й степени из 1 по модулю MOD равен 31; тогда первообразный корень (1 << X)-й степени для X от 1 до 23 равен (31 * (1 << (23 - X))) % MOD
+// INV2 - обратное к двум по модулю MOD
+// Данная реализация FFT перемножает два целых числа длиной до 250000 цифр за ~0.13 секунд без проблем с точностью и занимает всего 30 строк кода
+
+int pws[MAXN + 1], ipws[MAXN + 1];
+
+void init() {
+    pws[MAXN] = W; ipws[MAXN] = IW;
+    for (int i = MAXN / 2; i >= 1; i /= 2) {
+        pws[i] = (pws[i * 2] * 1ll * pws[i * 2]) % MOD;
+        ipws[i] = (ipws[i * 2] * 1ll * ipws[i * 2]) % MOD;
+    }
+}
+
+void fft(vector<int> &a, vector<int> &ans, int l, int cl, int step, int n, bool inv) {
+    if (n == 1) { ans[l] = a[cl]; return; }
+    fft(a, ans, l, cl, step * 2, n / 2, inv);
+    fft(a, ans, l + n / 2, cl + step, step * 2, n / 2, inv);
+    int cw = 1, gw = (inv ? ipws[n] : pws[n]);
+    for (int i = l; i < l + n / 2; i++) {
+        int u = ans[i], v = (cw * 1ll * ans[i + n / 2]) % MOD;
+        ans[i] = (u + v) % MOD;
+        ans[i + n / 2] = (u - v) % MOD;
+        if (ans[i + n / 2] < 0) ans[i + n / 2] += MOD;
+        if (inv) {
+            ans[i] = (ans[i] * 1ll * INV2) % MOD;
+            ans[i + n / 2] = (ans[i + n / 2] * 1ll * INV2) % MOD;
+        }
+        cw = (cw * 1ll * gw) % MOD;
+    }
+}
+

С недавнего времени некоторые проблемсеттеры начали использовать именно этот модулю вместо стандартного \(10^9+7\), чтобы намекнуть (или сбить с толку), что задача на FFT.

+

Применения

+
+

Даны две бинарные строки \(a\) и \(b\). Нужно найти такой циклический сдвиг строки \(b\), что количество совпадающих соответствующих символов с \(a\) станет максимально.

+
+

Сперва научимся для каждого циклического сдвига \(i\) второй строки считать количество совпадающих единиц \(c_i\). Это можно сделать за \(O(n^2)\) множеством разных способов, мы рассмотрим следующий: рассмотрим каждую единицу во втором числе, пусть она стоит на \(j\)-й позиции; для каждого \(l\) от \(0\) до \(n-1\), если \(a_l\) равно 1, то прибавим один к \(c_{i-j}\) (при этом \(i-j\) берётся по модулю \(n\)). Такой алгоритм верный, потому что по сути мы перебираем пары единиц, которые могут совпадать, и прибавляем +1 к количеству совпадающих единиц для соответствующего циклического сдвига. И тут мы можем заметить очень важную вещь: если перемножить числа, соответствующие \(a\) и \(b\), в столбик и не переносить разряды при сложении, то мы получим как раз массив \(c\) (с одним нюансом: его длина может быть больше \(n\), тогда нам нужно для всех \(i \geq n\) прибавить \(c_i\) к \(c_{i-n}\))! А перемножать длинные числа мы уже научились: это легко сделать при помощи БПФ. Таким образом, мы научились искать число совпадающих единиц; заметим, что мы можем инвертировать биты в строках и применить эквивалентный алгоритм, получив в итоге количества совпадающих нулей. Сложим соответствующие элементы в двух массивах и найдём индекс максимального. Также очень часто в задачах на FFT требуется не явно перемножить два полинома, а посчитать свёртку двух векторов. Прямой свёрткой векторов \(a\) длины \(n\) и \(b\) длины \(m\) называется вектор \(s\) длины \(n+m-1\) такой, что \(s_k = \Sigma_{i=0}^{k} a_i \cdot b_{k-i} (\forall k \in [0;n+m-2])\) (при этом считается, что несуществующие элементы равны нулю). Круговой (циклической) свёрткой векторов \(a\) и \(b\) длины \(n\) называется вектор \(s\) длины \(n\) такой, что \(s_k = \Sigma_{i=0}^{n-1} a_i \cdot b_{k-i} (\forall k \in [0; n-1])\) (при этом \({k-i}\) берётся по модулю \(n\)). Оказывается, что линейную свёртку можно считать через круговую: для этого дополним нулями оба вектора до одинаковой длины \(n+m-1\). Это очень легко доказать: если для некоторого \(k\) \(i \geq k+1\), то либо \(a_i\), либо \(b_{k-i}\) будут равны нулю. Если расписать выражение для прямого преобразования Фурье круговой свёртки и перенести множетили, то можно получить, что круговая свёртка равна вектору произведений многочленов с коэффициентами \(a\) и \(b\) в точках \(0,1,\dots n-1\). Возможно, когда-нибудь я это распишу.

+ + diff --git a/ru/games-on-graphs.html b/ru/games-on-graphs.html new file mode 100644 index 0000000..02ad7c4 --- /dev/null +++ b/ru/games-on-graphs.html @@ -0,0 +1,241 @@ + + + + + + + Теория игр - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Теория игр

+

Начнём с самого баянного примера математической игры, который можно вспомнить:

+
+

На столе лежит кучка из \(n\) спичек. Два игрока по очереди берут спички из кучки, разрешается взять одну, две или три спички. Тот, кто берет последнюю спичку, выигрывает. Требуется определить, кто выигрывает при оптимальной игре обоих игроков.

+
+

Все задачи такого типа решаются одним и тем же способом: посмотреть на решения для маленьких чисел и применить динамическое программирование. В данном случае состоянием динамики будет \(f_k\) — выиграет ли первый игрок, если спичек сейчас \(n\), и ход принадлежит ему. Пересчитывать её нужно так:

+

\[ f_k = \lnot f_{k-1} \lor \lnot f_{k-2} \lor \lnot f_{k-3} = \lnot (f_{k-1} \land f_{k-2} \land f_{k-3}) \]

+

то есть должен найтись переход в какое-то состояние динамики, которое является проигрышным (или, что эквивалентно, не должно быть пере динамики должны вести в проигрышные состояния).

+

Примечание. Конечно, здесь не нужна была никакая динамика, и ответ зависит только от того, делится ли \(n\) на 4, но мы всё равно рассмотрим этот пример в педагогических целях.

+

Игры на графах

+

В общем случае состояния игры могут представлять из себя гораздо более сложную структуру, чем «\(n\) спичек».

+

Любую игру можно описать в виде графа (возможно, бесконечного) состояний игры, между которыми есть переходы. Игроки поочередно выбирают, по какому переходу пройти. Некоторые состояния в этом графе помечены как терминальные

+

Состояние называется выигрышным, если игрок, начинающий в нём, побеждает, и проигрышным в противном случае. В графе могут быть циклы, и иногда обоим игрокам выгодно по ним ходить. Например, в шахматах бывают ситуации, когда выгодно повторение ходов (сама игра при этом всё равно не может длиться бесконечно, потому что троекратное повторение позиции приводит к ничье — подобные правила есть в большинстве стратегических игр). Такие вершины — в которых оптимальной стратегией будет хождение по циклу — назовём ничейными.

+

Подобные игры — где чтобы сделать одному игроку лучше, нужно сделать другому на столько же хуже — называют играми с нулевой суммой (почему такое название и что такое игры с ненулевой суммой — ниже прочитаете). Они почти всегда решаются определенным видом динамики на графе, который называется ретроанализом.

+

Ретроанализ

+

Обобщим пример со спичками и сформулируем критерии выгрышности и проигрышности:

+
    +
  • Вершина проигрышная — если все её переходы ведут в выигрышные вершины.
  • +
  • Вершина выигрышная — если из неё есть переход в проигрышную вершину.
  • +
  • Вершина ничейная — если она не выиграшная, и не проигрышная (у неё есть хотя бы один переход в другую ничейную и, возможно, сколько-то переходов в выигрышные).
  • +
+

Пользуясь этими критериями, можно разработать общий алгоритм: проверять все вершины на соответствие этим критериям и обновлять их статусы, пока вершины с неизвестным статусом не кончатся.

+

Корректность. Рассмотрим случай ациклической игры. Он проще: раз циклов нет, значит нет и ничейных вершин.

+

Рассмотрим граф неизвестных вершин \(U\). Будучи подграфом исходного, он тоже ациклический, а значит должна быть вершина \(v\), у которой нет исходящих рёбер в \(U\) — если рёбра есть, то они ведут в вершины с известным состоянием. Значит, на данном этапе мы и для вершины \(v\) определим выигрышность, которая будет зависеть только от того, ведут ли эти рёбра в какое-то проигрышное состояние.

+

Вернёмся к общему случаю — когда в графе могут быть циклы. Наш алгоритм не пометит ничейные вершины ничейными, потому что здесь возникает проблема курицы и яйца — изначально ничейных терминальных вершин нет, и вершину нельзя по определению пометить ничейной. Но выясняется, что алгоритм менять не надо — ничейными можно просто объявить все вершины, выигрышность которых определить не удалось.

+

Почему так? Пусть мы провели сколько-то итераций алгоритма, и он остановился, когда в графе остались ещё какие-то вершины с невыясненным состоянием, которые образуют подграф \(U\). В этом графе ни одна вершина не ведет в проигрышную — иначе она была бы помечена выигрышной, и алгоритм пошел бы дальше. Также, нет вершин без исходящих рёбер в \(U\) — иначе такая вершина была бы помечена проигрышной. Получается, оптимальной стратегией в этом подграфе будет всегда оставаться в нём — выходы есть только в выигрышные вершины — и, значит, все вершины в нём ничейные.

+

Асимптотика. Можно добавить все терминальные вершины в очередь, и поддерживать в ней список вершин, у которых мы определили выигрышность, но ещё не обработали.

+
    +
  • При обработке проигрышной вершины, надо пометить выигрышными все вершины, у которых есть ребро, ведущее в текущее (для этого нам надо хранить обратный граф).
  • +
  • При обработке выигрышной вершины, надо удалить все ребра в обратном графи и пометить проигрышными все вершины, у которых больше не осталось рёбер в неизвестные вершины, и которые ещё не были помечены раньше.
  • +
+

Асимптотика составит \(O(n + m)\), как и у любого bfs-а.

+
vector<int> g[maxn], t[maxn]; // списки смежности прямого и обратного графа
+int cnt[maxn]; // счётчик исходящих рёбер
+
+enum StatusType { win, loss, unknown };
+StatusType status[maxn]; // выгрышность вершины;
+// по умолчанию все кроме терминальных считаются unknown
+// те, кто в итоге остаются unknown -- ничейные
+
+queue<int> q = {/* нужно заранее добавить сюда все терминальные вершины*/};
+
+while (!q.empty()) {
+    int v = q.front();
+    q.pop();
+    for (int u : t[v]) {
+        cnt[u]--; // удаляем это ребро
+        if (status[v] == unknown) {
+            // из u есть ребро в проигрышную -- значит она выигрышная
+            if (status[v] == loss)
+                status[u] = win;
+            // все ребра u ведут в выигрышные вершины -- значит она проигрышная
+            if (status[v] == win && cnt[u] == 0)
+                status[u] = loss;
+            // если после проверок у вершины определилась выигрышность, то её можно добавить в очередь
+            if (status[v] != unknown)
+                q.push(u);
+        }
+    }
+}
+

Как обычно это бывает с динамиками, иногда проще написать рекурсивный подсчёт динамики, а не итеративный. Однако это работает только с ациклическими динамиками, а в общем графе могут быть циклы. Тем не менее, именно рекурсивную реализацию ретроанализа мы будем считать каноничной, потому что она в дальнейшем позволит делать некоторые отсечения и оптимизации:

+
StatusType dfs(int v) {
+    if (status[v] != unknown)
+        return status[v];
+    status[v] = loss;
+    // изменим статус, когда найдём переход в проигрышную вершину
+    for (int u : g[v])
+        if (dfs(u) == loss)
+            status[v] = win;
+    return status[v];
+}
+

TODO: можно ли здесь циклы учесть?

+

Минимаксные игры

+

Иногда у нас более богатая градация терминальных состояний, чем просто проигрышные и выигрышные. Такие игры в общем случае называются минимаксными — в терминальных вершинах могут быть записаны произвольные числа, задача первого игрока — придти в наибольшее, а второго — в наименьшее. Игроков будем называть Макс и Мин соответственно.

+

Типичный граф минимаксной игры выглядит следующим образом:

+

+

В не-листовых вершинах записаны значения игр в поддеревьях при оптимальной игре обоих соперников.

+

Решение таких задач ничем принципиально не отличается от ретроанализа, только теперь у нас «хорошесть» вершины — это минимум / максимум из переходов в детей.

+

Ретроанализ для больших графов

+

Иногда наша игра сложная, и все её состояния не вмещаются в память — например, шахматы или го. Это конечные ациклические игры, и в них однозначно определен победитель при оптимальной игре, которого в теории можно найти ретроанализом. Однако возникает проблема: алгоритм работает очень долго.

+

Для некоторых игр титаническими трудами были посчитаны исходы во всех возможных состояниях (шашкки, для шахмат — только база эндшпилей).

+

Ретроанализ нужно оптимизировать.

+

Ограничение перебора

+

Для этого надо идти по состояниям графа BFS-ом, а не DFS-ом, и останавливаться в момент, когда пройдено \(K\) ходов. При этом возникает только одна проблемы: как понять результат игры на рассматриваемой доске через \(K\) ходов, ведь игра еще не закончена. Для этого надо придумать приближенную функцию которая численно оценивает, насколько первый игрок выигрывает.

+

Для шахмат можно просто присвоить каждой фигуре сколько-то баллов и считать для каждого игрока эту сумму, а за функцию взять разность сумм у первого и второго игрока. Тогда если у первого игрока есть стратегия, с помощью которой он за \(K\) ходов гарантированно ест ферзя, не теряя много своих фигур, ретроанализ это обнаружит.

+

Ясно, что это дает существенное ускорение по времени: можно подобрать \(K\) так, чтобы время было достаточно маленьким. А если есть какой-то определенный лимит по времени, можно прекращать перебирать, когда закончится этот лимит. Но ход, который предлагает ретроанализ с такой оптимизацией, конечно, перестает быть оптимальным. Впрочем, оптимальную стратегию для шахмат пока найти не смогли (а для шашек, кстати, нашли).

+

Мемоизация позиций

+

В некоторые позиции можно придти более чем одним способом, а также есть группы позиций, про которые нам заранее известно, что их исход один и тот же (например, симметричные позиции в крестиках-ноликах). Нам не обязательно обрабатывать каждую из них каждый раз, когда попадём в неё — эффективнее работает .

+

Для шахмат (и не только) был разработан интересный способ хэширования. Вместо полиномиального хэша. Для каждой пары

+

Альфа-бета отсечение

+

+

Рассмотрим стандартный минимакс. На картинке в не-листовых вершинах написан выигрыш игрока, который ходит из неё первым.

+

На картинке изображена игра с нулевой суммой: в ней сумма выигрыша первого и второго игрока равна нулю, поэтому на картинке в вершине написан только выигрыш того игрока, который ходит из нее первым.

+

Идею оптимизации рассмотрим на частном примере: мы перебираем куда идти из состояния \(X\) (например, корень). Мы уже зашли в первого и второго сына, увидели, что если пойти в первого, то выигрыш второго игрока составит -3, а если во второго, то выигрыш составит -6. Наша задача - минимизровать его выигрыш, так как это максимизирует наш выигрыш. Пока что лучшим ходом из корня дерева является второй сын, и выигрыш будет равен 6.

+

Давайте зайдем в третьего сына, назовем его \(Y\), вдруг там второй сможет набрать больше, чем -6. Зайдем в первого сына \(Y\), обработаем полностью и заметим, что при таком ходе второй при оптимальной игре получит -5. Утверждается, что тогда ход во второго сына \(Y\) можно вообще не проверять. Почему? Потому что уже понятно, что если пойти из корня \(X\) в вершину \(Y\), то выигрыш второго будет хотя бы -5 (а возможно даже больше), а значит выигрыш первого будет не более, чем 5. Но ход во второго сына \(X\) уже дает 6, что больше, и рассматривать вершину \(Y\) далее бессмысленно, мы в нее уже точно не пойдем.

+

Отсечение таких лишних веток и называется альфа-бета-отсечением. Понятно, что оно может значительно ускорить перебор, так как мы выкидываем целые ветки перебора, и при этом эта оптимизация все еще находит оптимальный ход, в отличие от прошлой оптимизации.

+ +

Игры с ненулевой суммой

+

Теория Шпрага-Гранди

+

Рассмотрим игру «ним»: даны \(n\) кучек, в каждой из них по несколько камней. За один ход игрок может выбрать кучку и выбросить оттуда любое ненулевое число камней. Проигрыш наступпает, когда ходов больше не осталось, то есть когда все кучки пустые.

+

Немного переформулируем условие. Состояние нима однозначно описывается неупорядоченным набором неотрицательных чисел — как-нибудь пронумеруем их и обозначим количество камней в \(i\)-й как \(a_i\). Теперь, за один ход разрешается строго уменьшить любое из чисел. Терминальное состояние — когда все числа стали нулями.

+

Теорема. Состояние игры выигрышное тогда и только тогда, когда xor-сумма $ S = a_1 a_2 a_n $ размеров кучек отлична от нуля.

+

Доказательство проведём по индукции. Для терминального состояния xor-сумма равна нулю, и оно действительно проигрышное — база доказана. Теперь докажем переходы:

+
    +
  • Из состояния с нулевой xor-суммой все переходы ведут в выигрышные состояния, то есть в состояния с ненулевой суммой. В самом деле, достаточно убрать сколько угодно спичек из любой кучки — xor сумма изменится с нуля на $a_i b_i $, где \(b_i < a_i\) — это число камней в \(i\)-й кучке после нашего действия.
  • +
  • Второй переход сложнее — нужно показать, что если xor-сумма ненулевая, то всегда существует такой \(b_i < a_i\), что xor-сумма станет нулевой, то есть \(S \oplus a_i \oplus b_i = 0\). Для этого посмотрим на старший взведенный бит \(S\) и возьмем любой \(a_i\), у которого этот бит тоже взведен. Такой \(a_i\) найдётся хотя бы один — свойства ксора говорят, что их должно быть нечетное число. Из предыдущего равнства вытекает, что искомый \(b_i\) равен \(S \oplus a_i\), и выясняется, что это корректный новый размер кучки, то есть \(b_i < a_i\). Почему так? Потому что все старшие биты в выражении остались нетронутыми, \(k\)-й бит изменился на единицу, а что происходило с дальнейшими битами нам не важно, потому что эти изменения точно не больше, чем \(2^k\).
  • +
+

Автор любит конструктивные доказательства — из них сразу же вытекают алгоритмы, которые остается только реализовать. Получается, что оптимальная стратегия: посчитать xor-сумму всех \(a_i\), найти такой \(a_i\), у которого старший бит взведен, и заменить его на \(S \oplus a_i\).

+

Каждый раз, перед тем, как рассказать про решение нима, автор просит кого-нибудь непосвященного сыграть против него в ним, и со скрежетом ксорит в уме несколько бинарных чисел, пока все не убедятся в верности теории.

+

Зачем это надо? Есть много игр, в которых присутствует какой-то подобный цугцванг (шахматный термин — когда у соперника кончились хорошие ходы, и он бы просто стоял на месте, но правила это запрещают). Выясняется, что они все эквивалентны ниму — их графы с точки зрения выигрышности суммы отдельных игр работают также, как графы нима. Но чтобы доказать это, нам потребуется немного получше разобраться в том, как ним устроен.

+

Ним с увеличениями

+

Пусть у нас

+

Эквивалентность игр ниму

+

Игры с неполной информацией

+

Это называется минимаксоной теоремой, а более общее утверждение доказал Джон Нэш. Все это состояние называется равновесием Нэша, или эквилибриумом. Эквилибриум существует и в играх с большим числом игроков.

+

Любое доказательство использует слишком большое количество матана, чтобы его включать в блог про программирование, так что автор приводить его не будет.

+

Вы могли знать это имя по «Играм Разума». Не описано, чем он занимался, и какой вклад это внесло.

+

Дилемма заключенного

+

Камень-ножницы-бумага

+

Покер

+

Рассмотрим такую у

+ + diff --git a/ru/geometry.html b/ru/geometry.html new file mode 100644 index 0000000..e2dce0b --- /dev/null +++ b/ru/geometry.html @@ -0,0 +1,286 @@ + + + + + + + Вычислительная геометрия - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Вычислительная геометрия

+

Напомним, что отрезок, для которого указано, какой из его концов считается началом, а какой — концом, называется вектором. Вектор на плоскости можно задать двумя числами — его координатами по горизонтали и вертикали.

+
+vector +
+

Помимо очевидных сложения, вычитания и умножения на константу (скаляр — одно число), у векторов можно ввести и свои особенные операции, которые нам упростят жизнь.

+

Скалярное произведение (англ. dot product) — произведение длин векторов на косинус угла между ними. Для него справедлива следующая формула:

+

\[ +a \cdot b = x_a x_b + y_a y_b +\]

+

Она доказывается муторно и чисто технически, так что мы это делать не будем.

+

Геометрически, она равна проекции вектора \(b\) на вектор \(a\), помноженный на длину \(а\):

+
+dot +
+

У него есть полезные свойства:

+
    +
  • Скалярное произведение симметрично (\(a \cdot b = b \cdot a\)).
  • +
  • Перпендикулярные вектора должны иметь нулевое скалярное произведение.
  • +
  • Если угол острый, то скалярное произведение положительное.
  • +
  • Если угол тупой, то скалярное произведение отрицательное.
  • +
+

Векторное произведение (англ. cross product) — произведение длин векторов на синус угла между ними, причём знак этого синуса зависит от порядка операндов. Оно тоже удобно выражается в координатах:

+

\[ +a \times b = x_a y_b - y_a x_b +\]

+

Геометрически, это ориентированный объем параллелограмма, натянутого на вектора \(a\) и \(b\):

+
+cross +
+

Его свойства:

+
    +
  • Векторное произведение антисимметрично: \(a \times b = - (b \times a)\).
  • +
  • Коллинеарные вектора должны иметь нулевое векторное произведение.
  • +
  • Если \(b\) «слева» от \(a\), то векторное произведение положительное.
  • +
  • Если \(b\) «справа» от \(a\), то векторное произведение отрицательное.
  • +
+

Вообще говоря, векторное произведение определяется не так. Оно определено как вектор такой же длины, но перпендикулярный обоим исходным векторам. Это имеет применение в трёхмерной геометрии и физике, но нам об этом думать не надо.

+

Всякие проверки

+

Благодаря этим свойствам, почти все проверки в геометрии можно описать через них, а не уравнениями.

+

Принадлежность точки треугольнику. Пусть у нас есть треугольник \(ABC\) (заданный против часовой стрелки) и точка \(P\). Тогда она должна лежать слева от всех трёх векторов \(AB\), \(BC\) и \(CA\). Это условие задаст пересечение трёх полуплоскостей, которое и будет нужным треугольником.

+

\[ +\text{P лежит внутри ABC} \iff \begin{cases} +(B-A) \times (P-A) \geq 0 \\ +(C-B) \times (P-B) \geq 0 \\ +(A-C) \times (P-C) \geq 0 \\ +\end{cases} +\]

+

Площадь треугольника. Можно пользоваться готовыми формулами, а можно и свойством векторного произведения.

+

\[ +V = \frac{1}{2} (B-A) \times (C-A) +\]

+

Площадь произвольного многоугольника. Если многоугольник задан последовательностью вершин в каком-то порядке, то можно считать так: для каждого ребра добавим его ориентированную площадь от начала координат. Какие-то слагаемые будут положительными (которые на последнем слое, а какие-то — отрицательными).

+
+any +
+

Забудьте о формуле Герона и всегда считайте площади через векторное произведение.

+

Кстати, из формулы для площади треугольника следует, что площадь любой фигуры будет либо целым числом, либо рациональным с двойкой в знаменателе. Часто в в задачах входные данные целочисленные, и, чтобы оставаться в целых числах, когда мы считаем какую-нибудь площадь, иногда имеет смысл умножить все входные числа на \(2\) (см. «точность»).

+

Проверка на выпуклость. Можно пройтись по сторонам многоугольника и проверять векторным произведением, что мы поворачиваем всегда в одну сторону, то есть для всех последовательных точек \(a\), \(b\), \(c\) проверить, что \((b-a)\times(c-a) > 0\).

+

Пересекаются ли отрезки.

+
+segments +
+

Уравнение прямой

+

Прямую можно задать уравнением вида \(Ax + By + C = 0\). Полуплоскость можно задать таким же неравенством.

+

У прямой есть вектор нормали с координатами \((A, B)\). Он перпендиуклярен прямой, а в случае с полуплоскостью \(Ax + By + C \geq 0\) будет указывать в сторону самой полуплоскости.

+

Чтобы найти расстояние от точки \((x_0, y_0)\) до прямой \(Ax + By + C = 0\), можно воспользоваться следующей формулой:

+

\[ +d = \frac{|Ax_0+By_0+C|}{\sqrt{A^2+B^2}} +\]

+

Точка пересечения. По сути, найти точку пересечения двух прямых — это то же самое, что и найти точку, которая удовлетворяет обоим условиям их уравнений:

+

\[ +\begin{cases} +A_1 x + B_1 y + C_1 = 0 \\ +A_2 x + B_2 y + C_2 = 0 +\end{cases} +\implies +\begin{cases} +-x = \frac{B_1 y + C_1}{A_1} \\ +-x = \frac{B_2 y + C_2}{A_2} +\end{cases} +\implies +\frac{B_1 y + C_1}{A_1} = \frac{B_2 y + C_2}{A_2} +\implies +y = - \frac{A_1 C_2 - A_2 C_1}{A_1 B_2 - A_2 B_1} +\]

+

Аналогично, \(x = \frac{B_1 C_2 - B_2 C_1}{A_1 B_2 - A_2 B_1}\) (обратите внимание на знаки).

+

Заметим, что знаменатель может оказаться нулем. Это означает, что векторное произведение векторов нормали нулевое, а значит прямые параллельны (в частности, это могут быть совпадающие прямые). Этот случай нужно обрабатывать отдельно.

+

Как это кодить в C++

+

Небольшой ликбез по объектно-ориентированному программированию в C++. Создадим класс, который будет отвечать за все операции с точками. В C++ есть два способа это сделать: через struct и через class. Их основное отличие в том, что по умолчанию в class все поля приватные — к ним нет прямого доступа снаружи. Это нужно для дополнительной защиты, чтобы в крупных промышленных проектах никто случайно ничего не поломал, но на олимпиадах это не очень актуально.

+

Точка \(\simeq\) вектор. Будем считать точка и вектор это один и тот же объект, так как они оба — это просто пара чисел. Будем сопоставлять точке её радиус-вектор — вектор из начала координат, ведущий в эту точку. По принятой в математике и физике нотации, будем обозночать вектора как r. Вы можете обозвать их как point, pt, vec — как угодно.

+
struct r {
+    double x, y;
+    r() {}
+    r(int _x, int _y) { x = _x, y = _y; }
+};
+

Функция r внутри класса вызывается при инциализации объекта. Её называют конструктором, и её можно указывать разную для разных параметров. Здесь r()вернёт точку с неопределенными (какие оказались в памяти в тот момент) координатами, а r(x, y) вернет точку с координатами \((x, y)\).

+

Операции над векторами

+

Давайте напишем функцию, которая принимает вектора и что-то с ними делает. Например, считает длину:

+
double len(r a) { return sqrt(a.x*a.x + a.y*a.y); }
+

Операторы

+

В C++ можно перегружать почти все стандартные операторы, например, +, -, << и т. д.

+

Переопределим для будущих нужд + и -:

+
r operator+(r a, r b) { return r(a.x+b.x, a.y+b.y); }
+r operator-(r a, r b) { return r(a.x-b.x, a.y-b.y); }
+

Скалярное произведение:

+
int operator*(r a, r b) { return a.x*b.x + a.y*b.y; }
+

Векторное произведение:

+
int operator^(r a, r b) { return a.x*b.y - b.x*a.y; }
+

Ввод-вывод

+

Как вы думаете, как на самом деле работает cin >> x? Это тоже перегрузка оператора — >>. Делается это так:

+
istream& operator>>(istream &in, r &p) { 
+    in >> p.x >> p.y;
+    return in;
+}
+
+ostream& operator<<(ostream &out, r &p) { 
+    out << p.x << " " << p.y << endl;
+    return out;            
+}
+

Почему алгебра это плохо

+

Мы могли не создавать никаких структур и работать с уравнениями, описывающими геометрические объекты. Такой подход будет популярен на олимпиадах по математике, а не по программированию. Когда математик говорит «пересечем две прямые», он представляет громоздкое уравнение, с которым он потом будет работать.

+

Программист же хочет абстрагироваться и просто написать intersect(a, b), в корректности которого он точно уверен. Программист хочет разбить задачу на много маленьких кусочков и делать по отдельности, а не возиться с формулами.

+

Приведем несколько примеров конструктивного подхода.

+

Векторное представление прямой

+

Прямую можно задать не через уравнение, а через два вектора \(a\) и \(b\):

+

\[ +Ax + By + C = 0 \rightarrow r = at + b +\]

+

Чтобы это сделать, достаточно выбрать две любые точки на прямой:

+
// даны A, B, C (A^2 + B^2 != 0)
+r a, b;
+if (eq(A, 0)) // значит, это горизонтальная прямая
+    a = r(0, -C/B), b = r(1, -C/B);
+else
+    a = r(-C/A, 0), b = (1, -(C+B)/A, 1)
+

Отражение от прямой

+

Пусть нам надо отразить точку \((x_0, y_0)\) симметрично относительно заданной прямой \(ax+by+c=0\). Чисто в педагогических целях, начнём решать эту задачу как математики, чтобы никогда потом так не делать.

+

\[ +\Pr_a b = \frac{a \cdot b}{|a|} \frac{a}{|a|} = \frac{|a| |b| \cos \alpha}{|a|} \frac{a}{|a|} = |b| \cos \alpha \frac{a}{|a|} +\]

+

Геометрический смысл: длина на единичный вектор направления.

+

Мы не хотим раскрывать эти формулы покоординатно и предъявлять готовый ответ. Мы знаем, что он получится громоздким. Нам не жалко посчитать всё по частям — здесь нет смысла заниматься оптимизациями. Также мы хотим делать всё по частям, потому что так становится более наглядной логика алгоритма, и, как следствие, его проще дебажить.

+
// прямая r = at + b, точка c
+r pr (r a, r b, r c) {
+    c -= b; // пусть c и a выходят из одной точки
+    return b + (a*b / len(a) / len(a)) * a;
+}
+
+r reflect (r a, r b, r c) {
+    return c + 2*(pr(a, b, c)-c);
+}
+

Типичные баги

+

Точность

+
+

Первое правило действительных чисел — не использовать действительные числа

+
+

Все переменные типа double хранятся в компьютере неточно (ну а как вы представите ⅓ в двоичной системе счисления?). Поэтому при работе с даблами нужно всегда учитывать эту погрешность. Например, чтобы сравнить два дабла, надо проверить, что они отличаются по модулю меньше, чем на очень маленькое число eps:

+
const double eps = 1e-8;
+
+bool eq (double a, double b) { return abs(a-b) < eps }
+

Чтобы так не делать, старайтесь по возможности использовать только инты и абсолютную точность. Иногда есть трюки, позволяющие так делать: например, если в задаче все входные точки целочисленные и нас просят посчитать какую-то площадь, то можно все координаты домножить на два, и тогда ответ тоже будет целым (см. векторное произведение), который только при выводе нужно будет поделить на четыре.

+

\(0 \neq -0\)

+

Действительные числа так хранятся, что \(0\) и \(-0\) могут быть разными числами. Имейте это ввиду.

+

Область определения обратных функций

+

acos, asin и прочие обратные тригонометрические функций требуют, чтобы им на вход подавалось число от -1 до 1. Для безопасности, отмасштабируйте числа, перед тем как брать от них эти функции.

+ + diff --git a/ru/hashing.html b/ru/hashing.html new file mode 100644 index 0000000..96ee495 --- /dev/null +++ b/ru/hashing.html @@ -0,0 +1,277 @@ + + + + + + + Хэширование в строковых задачах - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Хэширование в строковых задачах

+

Хэш — это какая-то функция, сопоставляющая объектам какого-то множества числовые значения из ограниченного промежутка.

+

«Хорошая» хэш-функция:

+
    +
  • Быстро считается — за линейное от размера объекта время;
  • +
  • Имеет не очень большие значения — влезающие в 64 бита;
  • +
  • «Детерминированно-случайная» — если хэш может принимать \(n\) различных значений, то вероятность того, что хэши от двух случайных объектов совпадут, равна примерно \(\frac{1}{n}\).
  • +
+

Обычно хэш-функция не является взаимно однозначной: одному хэшу может соответствовать много объектов. Такие функции называют сюръективными.

+

Для некоторых задач удобнее работать с хэшами, чем с самими объектами. Пусть даны \(n\) строк длины \(m\), и нас просят \(q\) раз проверять произвольные две на равенство. Вместо наивной проверки за \(O(q \cdot n \cdot m)\), мы можем посчитать хэши всех строк, сохранить, и во время ответа на запрос сравнивать два числа, а не две строки.

+

+

Применения в реальной жизни

+
    +
  • Чек-суммы. Простой и быстрый способ проверить целостность большого передаваемого файла — посчитать хэш-функцию на стороне отправителя и на стороне получателя и сравнить.
  • +
  • Хэш-таблица. Класс unordered_set из STL можно реализовать так: заведём \(n\) изначально пустых односвязных списков. Возьмем какую-нибудь хэш-функцию \(f\) с областью значений \([0, n)\). При обработке .insert(x) мы будем добавлять элемент \(x\) в \(f(x)\)-тый список. При ответе на .find(x) мы будем проверять, лежит ли \(x\)-тый элемент в \(f(x)\)-том списке. Благодаря «равномерности» хэш-функции, после \(k\) добавлений ожидаемое количество сравнений будет равно \(\frac{k}{n}\) = \(O(1)\) при правильном выборе \(n\).
  • +
  • Мемоизация. В динамическом программировании нам иногда надо работать с состояниями, которые непонятно как кодировать, чтобы «разгладить» в массив. Пример: шахматные позиции. В таком случае нужно писать динамику рекурсивно и хранить подсчитанные значения в хэш-таблице, а для идентификации состояния использовать его хэш.
  • +
  • Проверка на изоморфизм. Если нам нужно проверить, что какие-нибудь сложные структуры (например, деревья) совпадают, то мы можем придумать для них хэш-функцию и сравнивать их хэши аналогично примеру со строками.
  • +
  • Криптография. Правильнее и безопаснее хранить хэши паролей в базе данных вместо самих паролей — хэш-функцию нельзя однозначно восстановить.
  • +
  • Поиск в многомерных пространствах. Детерминированный поиск ближайшей точки среди \(m\) точек в \(n\)-мерном пространстве быстро не решается. Однако можно придумать хэш-функцию, присваивающую лежащим рядом элементам одинаковые хэши, и делать поиск только среди элементов с тем же хэшом, что у запроса.
  • +
+

Хэшируемые объекты могут быть самыми разными: строки, изображения, графы, шахматные позиции, просто битовые файлы.

+

Сегодня же мы остановимся на строках.

+

Полиномиальное хэширование

+

Лайфхак: пока вы не выучили все детерминированные строковые алгоритмы, научитесь пользоваться хэшами.

+

Будем считать, что строка — это последовательность чисел от \(1\) до \(m\) (размер алфавита). В C++ char это на самом деле тоже число, поэтому можно вычитать из символов минимальный код и кастовать в число: int x = (int) (c - 'a' + 1).

+

Определим прямой полиномиальный хэш строки как значение следующего многочлена:

+

\[ +h_f = (s_0 + s_1 k + s_2 k^2 + \ldots + s_n k^n) \mod p +\]

+

Здесь \(k\) — произвольное число больше размера алфавита, а \(p\) — достаточно большой модуль, вообще говоря, не обязательно простой.

+

Его можно посчитать за линейное время, поддерживая переменную, равную нужной в данный момент степени \(k\):

+
const int k = 31, mod = 1e9+7;
+
+string s = "abacabadaba";
+long long h = 0, m = 1;
+for (char c : s) {
+    int x = (int) (c - 'a' + 1);
+    h = (h + m * x) % mod;
+    m = (m * k) % mod;
+}
+

Можем ещё определить обратный полиномиальный хэш:

+

\[ +h_b = (s_0 k^n + s_1 k^{n-1} + \ldots + s_n) \mod p +\]

+

Его преимущество в том, что можно написать на одну строчку кода меньше:

+
long long h = 0;
+for (char c : s) {
+    int x = (int) (c - 'a' + 1);
+    h = (h * k + x) % mod;
+}
+

Автору проще думать об обычных многочленах, поэтому он будет везде использовать прямой полиномиальный хэш и обозначать его просто буквой \(h\).

+

Зачем это нужно?

+

Используя тот факт, что хэш это значение многочлена, можно быстро пересчитывать хэш от результата выполнения многих строковых операций.

+

Например, если нужно посчитать хэш от конкатенации строк \(a\) и \(b\) (т. е. \(b\) приписали в конец строки \(a\)), то можно просто хэш \(b\) домножить на \(k^{|a|}\) и сложить с хэшом \(a\):

+

\[ +h(ab) = h(a) + k^{|a|} \cdot h(b) +\]

+

Удалить префикс строки можно так:

+

\[ +h(b) = \frac{h(ab) - h(a)}{k^{|a|}} +\]

+

А суффикс — ещё проще:

+

\[ +h(a) = h(ab) - k^{|a|} \cdot h(b) +\]

+

В задачах нам часто понадобится домножать \(k\) в какой-то степени, поэтому имеет смысл предпосчитать все нужные степени и сохранить в массиве:

+
const int maxn = 1e5+5;
+
+int p[maxn];
+p[0] = 1;
+
+for (int i = 1; i < maxn; i++)
+    p[i] = (p[i-1] * k) % mod;
+

Как это использовать в реальных задачах? Пусть нам надо отвечать на запросы проверки на равенство произвольных подстрок одной большой строки. Подсчитаем значение хэш-функции для каждого префикса:

+
int h[maxn];
+h[0] = 0; // h[k] -- хэш префикса длины k
+
+// будем считать, что s это уже последовательность int-ов
+
+for (int i = 0; i < n; i++) 
+    h[i+1] = (h[i] + p[i] * s[i]) % mod;
+

Теперь с помощью этих префиксных хэшей мы можем определить функцию, которая будет считать хэш на произвольном подотрезке:

+

\[ +h(s[l:r]) = \frac{h_r-h_l}{k^l} +\]

+

Деление по модулю возможно делать только при некоторых k и mod (а именно — при взаимно простых). В любом случае, писать его долго, и мы это делать не хотим.

+

Для нашей задачи не важно получать именно полиномиальный хэш — главное, чтобы наша функция возвращала одинаковый многочлен от одинаковых подстрок. Вместо приведения к нулевой степени приведём многочлен к какой-нибудь достаточно большой — например, к \(n\)-ной. Так проще — нужно будет домножать, а не делить.

+

\[ +\hat{h}(s[l:r]) = k^{n-l} (h_r-h_l) +\]

+
int hash_substring (int l, int r) {
+    return (h[r+1] - h[l]) * p[n-l] % mod;
+}
+

Теперь мы можем просто вызывать эту функцию от двух отрезков и сравнивать числовое значение, отвечая на запрос за \(O(1)\).

+

Упражнение. Напишите то же самое, но используя обратный полиномиальный хэш — этот способ тоже имеет право на существование, и местами он даже проще. Обратный хэш подстроки принято считать и использовать в стандартном виде из определения, поскольку там нет необходимости в делении.

+

Лайфхак. Если взять обратный полиномиальный хэш короткой строки на небольшом алфавите с \(k=10\), то числовое значение хэша строки будет наглядно соотноситься с самой строкой:

+

\[ +h(abacaba)=1213121 +\]

+

Этим удобно пользоваться при дебаге.

+

Примеры задач

+

Количество разных подстрок. Посчитаем хэши от всех подстрок за \(O(n^2)\) и добавим их все в std::set. Чтобы получить ответ, просто вызовем set.size().

+

Поиск подстроки в строке. Можно посчитать хэши от шаблона (строки, которую ищем) и пройтись «окном» размера шаблона по тексту, поддерживая хэш текущей подстроки. Если хэш какой-то из этих подстрок совпал с хэшом шаблона, то мы нашли нужную подстроку. Это называется алгоритмом Рабина-Карпа.

+

Сравнение строк (больше-меньше, а не только равенство). У любых двух строк есть какой-то общий префикс (возможно, пустой). Сделаем бинпоиск по его длине, а дальше сравним два символа, идущие за ним.

+

Палиндромность подстроки. Можно посчитать два массива — обратные хэши и прямые. Проверка на палиндром будет заключаться в сравнении значений hash_substring() на первом массиве и на втором.

+

Количество палиндромов. Можно перебрать центр палиндрома, а для каждого центра — бинпоиском его размер. Проверять подстроку на палиндромность мы уже умеем. Как и всегда в задачах на палиндромы, случаи четных и нечетных палиндромов нужно обрабатывать отдельно.

+

Хранение строк в декартовом дереве

+

Если для вас всё вышеперечисленное тривиально: можно делать много клёвых вещей, если «оборачивать» строки в декартово дерево. В вершине дерева можно хранить символ, а также хэш подстроки, соответствующей её поддереву. Чтобы поддерживать хэш, нужно просто добавить в upd() пересчёт хэша от конкатенации трёх строк — левого сына, своего собственного символа и правого сына.

+

Имея такое дерево, мы можем обрабатывать запросы, связанные с изменением строки: удаление и вставка символа, перемещение и переворот подстрок, а если дерево персистентное — то и копирование подстрок. При запросе хэша подстроки нам, как обычно, нужно просто вырезать нужную подстроку и взять хэш, который будет лежать в вершине-корне.

+

Если нам не нужно обрабатывать запросы вставки и удаления символов, а, например, только изменения, то можно использовать и дерево отрезков вместо декартова.

+

Вероятность ошибки и почему это всё вообще работает

+

У алгоритмов, использующих хэширование, есть один неприятный недостаток: недетерминированность. Если мы сгенерируем бесконечное количество примеров, то когда-нибудь нам не повезет, и программа отработает неправильно. На CodeForces даже иногда случаются взломы решений, использующих хэширование — можно в оффлайне сгенерировать тест против конкретного решения.

+

Событие, когда два хэша совпали, а не должны, называется коллизией. Пусть мы решаем задачу определения количества различных подстрок — мы добавляем в set \(O(n^2)\) различных случайных значений в промежутке \([0, m)\). Понятно, что если произойдет коллизия, то мы какую-то строку не учтем и получим WA. Насколько большим следует делать \(m\), чтобы не бояться такого?

+

Выбор констант

+

Практическое правило: если вам нужно хранить \(n\) различных хэшей, то безопасный модуль — это число порядка \(10 \cdot n^2\). Обоснование — см. парадокс дней рождений.

+

Не всегда такой можно выбрать один — если он будет слишком большой, будут происходить переполнения. Вместо этого можно брать два или даже три модуля и считать много хэшей параллельно.

+

Можно также брать модуль \(2^{64}\). У него есть несколько преимуществ:

+
    +
  • Он большой — второй модуль точно не понадобится.
  • +
  • С ним ни о каких переполнениях заботиться не нужно — если все хранить в unsigned long long, процессор сам автоматически сделает эти взятия остатков при переполнении.
  • +
  • С ним хэширование будет быстрее — раз переполнение происходит на уровне процессора, можно не выполнять долгую операцию %.
  • +
+

Всё с этим модулем было прекрасно, пока не придумали тест против него. Однако, его добавляют далеко не на все контесты — имейте это в виду.

+

В выборе же \(k\) ограничения не такие серьезные:

+
    +
  • Она должна быть чуть больше размера словаря — иначе можно изменить две соседние буквы и получить коллизию.
  • +
  • Она должна быть взаимно проста с модулем — иначе в какой-то момент всё может занулиться.
  • +
+

Главное — чтобы значения \(k\) и модуля не знал человек, который генерирует тесты.

+

Парадокс дней рождений

+
+

В группе, состоящей из 23 или более человек, вероятность совпадения дней рождения хотя бы у двух людей превышает 50%.

+
+

Более общее утверждение: в мультимножество нужно добавить \(\Theta(\sqrt{n})\) случайных чисел от 1 до n, чтобы какие-то два совпали.

+

Первое доказательство (для любителей матана). Пусть \(f(n, d)\) это вероятность того, что в группе из \(n\) человек ни у кого не совпали дни рождения. Будем считать, что дни рождения распределены независимо и равномерно в промежутке от \(1\) до \(d\).

+

\[ +f(n, d) = (1-\frac{1}{d}) \times (1-\frac{2}{d}) \times ... \times (1-\frac{n-1}{d}) +\]

+

Попытаемся оценить \(f\):

+

\[ +\begin{aligned} + e^x & = 1 + x + \frac{x^2}{2!} + \ldots & \text{(ряд Тейлора для экспоненты)} \\ + & \simeq 1 + x & \text{(аппроксимация для $|x| \ll 1$)} \\ + e^{-\frac{n}{d}} & \simeq 1 - \frac{n}{d} & \text{(подставим $\frac{n}{d} \ll 1$)} \\ + f(n, d) & \simeq e^{-\frac{1}{d}} \times e^{-\frac{2}{d}} \times \ldots \times e^{-\frac{n-1}{d}} & \\ + & = e^{-\frac{n(n-1)}{2d}} & \\ + & \simeq e^{-\frac{n^2}{2d}} & \\ +\end{aligned} +\]

+

Из последнего выражения более-менее понятно, что вероятность \(\frac{1}{2}\) достигается при \(n \approx \sqrt{d}\) и в этой точке изменяется очень быстро.

+

Второе доказательство (для любителей теорвера). Введем \(\frac{n(n-1)}{2}\) индикаторов — по одному для каждой пары людей \((i, j)\) — каждый будет равен единице, если дни рождения совпали. Ожидание и вероятность каждого индикатора равна \(\frac{1}{d}\).

+

Обозначим за \(X\) число совпавших дней рождений. Его ожидание равно сумме ожиданий этих индикаторов, то есть \(\frac{n (n-1)}{2} \cdot \frac{1}{d}\).

+

Отсюда понятно, что если \(d = \Theta(n^2)\), то ожидание равно константе, а если \(d\) асимптотически больше или меньше, то \(X\) стремится нулю или бесконечности соответственно.

+

Примечание: формально, из этого явно не следует, что вероятности тоже стремятся к 0 и 1.

+

Бонус: «мета-задача»

+

Дана произвольная строка, по которой известным только авторам задачи способом генерируется ответ yes/no. В задаче 100 тестов. У вас есть 20 попыток отослать решение. В качестве фидбэка вам доступны вердикты на каждом тесте. Вердиктов всего два: OK (ответ совпал) и WA. Попытки поделить на ноль, выделить терабайт памяти и подобное тоже считаются как WA.

+

«Решите» задачу.

+ + diff --git a/ru/hld.html b/ru/hld.html new file mode 100644 index 0000000..dd96daa --- /dev/null +++ b/ru/hld.html @@ -0,0 +1,209 @@ + + + + + + + Heavy-light декомпозиция - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Heavy-light декомпозиция

+

HL-декомпозиция — это мощный метод решения задач на запросы на путях, когда также есть запросы обновлений. Если запросов обновления нет, то лучше написать что-нибудь попроще.

+

+
+TODO: найти менее уродливую иллюстрацию +
+

Heavy-light декомпозицией корневого дерева называется результат следующего процесса: каждой вершины \(v\) посмотрим на всех её непосредственных детей \(u\), выберем среди них ребёнка \(u_{max}\) (с самым большим размером поддерева) и назовём ребро \((v, u)\) тяжелым (heavy), а все остальные рёбра — лёгкими (light). При этом, если самых «тяжелых» детей будет больше одного, то выберем любого.

+

Таким образом, дерево распалось на тяжелые и лёгкие рёбра. Докажем несколько полезных свойств такого разбиения.

+

Утверждение. Дерево разбивается на непересекающиеся пути из тяжелых рёбер.

+

Доказательство. В каждую вершину входит не более одного тяжелого ребра, и из каждой вершины исходит не более одного тяжелого ребра.

+

Назовём блоком либо лёгкое ребро, либо вертикальный путь из тяжелых рёбер.

+

Утверждение. На любом вертикальном пути будет не более \(O(\log n)\) блоков.

+

Доказательство разбивается на две части:

+
    +
  • Лёгких ребер на вертикальном пути будет не более \(O(\log n)\): рассмотрим самую нижнюю вершину и будем идти по вертикальному пути снизу вверх. Каждый раз, когда мы переходим по лёгкому ребру, размер поддерева текущей вершины увеличивается в два раза, потому что если вершина связана с родителем лёгким ребром, то у него есть какой-то другой ребёнок, который не легче текущего.
  • +
  • Непрерывных путей из тяжелых рёбер будет не более \(O(\log n\): если это не конец или начало пути, то каждый такой путь окружают два лёгких ребра, а их всего \(O(\log n)\).
  • +
+

Следствие. На любом пути будет не более \(O(\log n)\) блоков.

+

Ради этого мы всё и делали: теперь построим какую-нибудь структуру на каждом тяжелого пути (например, дерево отрезков), а при ответе на запрос (скажем, суммы на пути) разобьем его на \(O(\log n)\) запросов либо к подотрезкам тяжелых путей, либо к лёгким рёбрам.

+

Реализация

+

Большинство публичных реализаций HLD — это 120-150 строк кода. Мы же воспользуемся следующим трюком, который сильно упростит нам жизнь: перенумеруем вершины дерева так, что для каждого тяжелого пути все его вершины будут иметь последовательные номера.

+

А именно, на этапе подсчёта размера поддеревьев, изменим список смежности каждой вершины так, чтобы в самом начале шел её «тяжелый» ребёнок. Тогда, если запустить обычный эйлеров обход графа, то tin-ы и будут нужной нумерацией, потому что в каждой вершине мы шли в своего тяжелого ребёнка в первую очередь.

+

Теперь мы можем построить какую-нибудь структуру поверх массива размера \(n\) (дерево отрезков) и при запросе к какому-нибудь тяжелому пути делать запрос к отрезку в структуре.

+
vector<int> g[maxn];
+int s[maxn], p[maxn], tin[maxn], tout[maxn];
+int head[maxn]; // «голова» тяжелого пути, которому принадлежит v
+int t = 0;
+
+void sizes (int v = 0) {
+    s[v] = 1;
+    for (int &u : g[v]) {
+        sizes(u);
+        s[v] += s[u];
+        if (s[u] > s[g[v][0]])
+            // &u -- это ссылка, так что её легально использовать при swap-е
+            swap(u, g[v][0]);
+    }
+}
+
+void hld (int v = 0) {
+    tin[v] = t++;
+    for (int u : g[v]) {
+        // если это тяжелый ребенок -- его next нужно передать
+        // в противном случае он сам является головой нового пути
+        head[u] = (u == g[v][0] ? head[v] : u);
+        hld(u);
+    }
+    tout[v] = t;
+}
+

Как им решать задачи

+

Простейший пример задачи на HLD: дано дерево, каждой вершине которого приписано какое-то число, и поступают запросы двух типов:

+
    +
  1. Узнать минимальное число на пути между \(v_i\) и \(u_i\).
  2. +
  3. Изменить число у \(v_i\)-той вершины на \(x_i\).
  4. +
+

Подвесим дерево за произвольную вершину и построим на нём HL-декомпозицию с деревом отрезков в качестве внутренней структуры. Его код мы приводить не будем и посчитаем, что оно реализовано примерно так же, как в соответствующей статье и имеет методы upd(k, x) и get_min(l, r).

+
int val[maxn];
+segtree st(0, n);
+

При операции обновления нам нужно просто обновить нужную ячейку в дереве отрезков:

+
void upd (int v, int x) {
+    st.upd(tin[v], x);
+}
+

Запрос минимума сложнее: нам нужно разбить исходный запрос на запросы к вертикальным путям.

+
int ancestor (int a, int b) {
+    return tin[a] <= tin[b] && tin[b] < tout[a];
+}
+
+void up (int &a, int &b, int &ans) {
+    while (!ancestor(head[a], b)) {
+        ans = min(ans, st.get_min(tin[head[a]], tin[a]));
+        a = p[head[a]];
+    }
+}
+
+int get_min (int a, int b) {
+    int ans = inf;
+    up(a, b, ans);
+    up(b, a, ans);
+    if (!ancestor(a, b))
+        swap(a, b);
+    ans = min(ans, st.get_min(tin[a], tin[b]));
+    return ans;
+}
+

+

Замечания

+
    +
  • Если в рамках задачи нет запросов обновления, то можно прокэшировать запросы — заметим, что большинство запросов к структуре, которые надо сделать, находятся на префиксах тяжелых путей, и на них можно отвечать за \(O(1)\), и таких будет \(O(\log n)\) на запрос. Также придется сделать \(O(1)\) запросов к структуре, которые будут работать за \(O(\log n)\). Получаем \(O(\log n)\) на запрос.
  • +
  • Так как наша реализация HLD строит структуру данных на эйлеровом обходе дерева, то мы можем добавить запросы к поддеревьям (ведь поддеревья — это подотрезки эйлерова обхода).
  • +
+ + diff --git a/ru/img/centroid.jpg b/ru/img/centroid.jpg new file mode 100644 index 0000000..79fd12f Binary files /dev/null and b/ru/img/centroid.jpg differ diff --git a/ru/img/chernobyl.jpg b/ru/img/chernobyl.jpg new file mode 100644 index 0000000..7f4f66d Binary files /dev/null and b/ru/img/chernobyl.jpg differ diff --git a/ru/img/complex-circle.png b/ru/img/complex-circle.png new file mode 100644 index 0000000..3a95f48 Binary files /dev/null and b/ru/img/complex-circle.png differ diff --git a/ru/img/divide-and-conquer.png b/ru/img/divide-and-conquer.png new file mode 100644 index 0000000..6222d0b Binary files /dev/null and b/ru/img/divide-and-conquer.png differ diff --git a/ru/img/fenwick_ranges.png b/ru/img/fenwick_ranges.png new file mode 100644 index 0000000..cb3a38b Binary files /dev/null and b/ru/img/fenwick_ranges.png differ diff --git a/ru/img/hash-table.png b/ru/img/hash-table.png new file mode 100644 index 0000000..7ef107a Binary files /dev/null and b/ru/img/hash-table.png differ diff --git a/ru/img/img/centroid.jpg b/ru/img/img/centroid.jpg new file mode 100644 index 0000000..79fd12f Binary files /dev/null and b/ru/img/img/centroid.jpg differ diff --git a/ru/img/img/chernobyl.jpg b/ru/img/img/chernobyl.jpg new file mode 100644 index 0000000..7f4f66d Binary files /dev/null and b/ru/img/img/chernobyl.jpg differ diff --git a/ru/img/img/complex-circle.png b/ru/img/img/complex-circle.png new file mode 100644 index 0000000..3a95f48 Binary files /dev/null and b/ru/img/img/complex-circle.png differ diff --git a/ru/img/img/divide-and-conquer.png b/ru/img/img/divide-and-conquer.png new file mode 100644 index 0000000..6222d0b Binary files /dev/null and b/ru/img/img/divide-and-conquer.png differ diff --git a/ru/img/img/fenwick_ranges.png b/ru/img/img/fenwick_ranges.png new file mode 100644 index 0000000..cb3a38b Binary files /dev/null and b/ru/img/img/fenwick_ranges.png differ diff --git a/ru/img/img/hash-table.png b/ru/img/img/hash-table.png new file mode 100644 index 0000000..7ef107a Binary files /dev/null and b/ru/img/img/hash-table.png differ diff --git a/ru/img/img/safe-edge.png b/ru/img/img/safe-edge.png new file mode 100644 index 0000000..465e0a1 Binary files /dev/null and b/ru/img/img/safe-edge.png differ diff --git a/ru/img/img/scc.png b/ru/img/img/scc.png new file mode 100644 index 0000000..8c435b5 Binary files /dev/null and b/ru/img/img/scc.png differ diff --git a/ru/img/img/scooby-doo.jpg b/ru/img/img/scooby-doo.jpg new file mode 100644 index 0000000..06dc1fd Binary files /dev/null and b/ru/img/img/scooby-doo.jpg differ diff --git a/ru/img/img/stasyan.jpg b/ru/img/img/stasyan.jpg new file mode 100644 index 0000000..b43de4f Binary files /dev/null and b/ru/img/img/stasyan.jpg differ diff --git a/ru/img/safe-edge.png b/ru/img/safe-edge.png new file mode 100644 index 0000000..465e0a1 Binary files /dev/null and b/ru/img/safe-edge.png differ diff --git a/ru/img/scc.png b/ru/img/scc.png new file mode 100644 index 0000000..8c435b5 Binary files /dev/null and b/ru/img/scc.png differ diff --git a/ru/img/scooby-doo.jpg b/ru/img/scooby-doo.jpg new file mode 100644 index 0000000..06dc1fd Binary files /dev/null and b/ru/img/scooby-doo.jpg differ diff --git a/ru/img/stasyan.jpg b/ru/img/stasyan.jpg new file mode 100644 index 0000000..b43de4f Binary files /dev/null and b/ru/img/stasyan.jpg differ diff --git a/ru/imperfect-information.html b/ru/imperfect-information.html new file mode 100644 index 0000000..02a30b8 --- /dev/null +++ b/ru/imperfect-information.html @@ -0,0 +1,113 @@ + + + + + + + Игры с неполной информацией - Алгоритмика + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Игры с неполной информацией

+

Рассмотрим следующую ситуацию, известную как дилемма заключённого:

+
+

Двое преступников — А и Б — попались примерно в одно и то же время на сходных преступлениях. Полиция предполагает, что они действовали по сговору, и, изолировав их друг от друга, предлагает им одну и ту же сделку:

+
    +
  • Если один свидетельствует против другого, а тот хранит молчание, то первый освобождается за помощь следствию, а второй получает максимальный срок лишения свободы (10 лет).

  • +
  • Если оба молчат, их деяние проходит по более лёгкой статье, и каждый из них приговаривается к году тюрьмы.

  • +
  • Если оба свидетельствуют друг против друга, они получают по 2 года.

  • +
+

Каждый заключённый выбирает, молчать или свидетельствовать против другого. Однако ни один из них не знает точно, что сделает другой. Что произойдёт?

+
+

Игры, в которых у игроков (в данном случае — узников) есть некоторая неопределенность относительно действий других игроков (например, из-за одновременности действий), называются играми с неполной информацией.

+

Данную «игру» можно представить в виде следующей табилцы:

+
                                   | Б хранит молчание | Б даёт показания |
+А хранит молчание | (1, 1) | (10, 0) |
+А даёт показания | (0, 10) | (2, 2) |
+

Пары чисел в ячейках означают сроки, которые получают заключенные А и Б соответственно.

+

Предположим, что оба преступника заботятся только о минимизации собственного срока заключения. Тогда рассуждения каждого узника будут следующими:

+
    +
  • Если партнёр молчит, то лучше его предать и выйти на свободу (иначе — полгода тюрьмы).

  • +
  • Если партнёр свидетельствует, то лучше тоже свидетельствовать против него, чтобы получить 2 года (иначе — 10 лет) тюрьмы.

  • +
+

Стратегия «свидетельствовать» строго доминирует над стратегией «молчать», то есть свидетельствовать всегда лучше вне зависимости от действия другого узника. Оба узника приходят к этому выводу и в итоге получают по 2 года каждый.

+

С точки зрения группы лучше всего хранить молчание и получить по году, так как это уменьшит срок заключения каждого. Такие состояния называются Парето-эффективными, но, как мы видим, они не всегда являются стабильными.

+

Игры с нулевой суммой

+

Рассмотрим другой пример игры с неполной информацией: «камень-ножницы-бумага». Эта игра в каком-то смысле более «человечная»: игроки явно соревнуются друг с другом, и им не нужно делать сделки с совестью и предавать друг друга.

+

Определение. Игра называется с нулевой суммой, если один игрок выигрывает столько, сколько второй проигрывает.

+

Примечание. Дилемма заключенного — пример игры с ненулевой суммой.

+

В играх с нулевой суммой исходы можно записывать разницей между выгодой первого и второго игрока:

+
    | К2 | Н2 | Б2|
+К1 | 0 | +1 | -1|
+Н1 | -1 | 0 | +1|
+Б1 | +1 | -1 | 0 |
+

Каждая фиксированная стратегия (в данном случае выбор действия — камень, ножницы или бумага), которую может выбрать игрок, называется его чистой стратегией.

+

Смешанной стратегией игрока называется набор чистых стратегий, задаваемый вероятностями (относительными частотами) выбора соответствующих чистых стратегий. Задача обоих игроков — выбрать смешанную стратегию, максимизирующую свой выигрыш.

+

Понятно, что в этой игре любая чистая стратегия может быть доминирована (то есть, если всегда делать один и тот же ход, то оппонент сообразит, как побеждать).

+

Обозначим смешанную стратегию первого игрока за \(x\), а второго игрока за \(y\). Тогда ожидаемый счёт можно записать так:

+

\[ +V = xAy^T +\]

+

Действительно, эта формула раскрывается в \(\sum A_{ij} \cdot x(i) \cdot y(j)\).

+

Минимаксная теорема. Первый игрок хочет минимизировать это значение, а второй — максимизировать. Обозначим за \(V_a\) и \(V_b\) оптимальные значения, которые удаётся достичь игрокам вне зависимости от действий оппонента, то есть:

+

\[ +V_a = \min_x \max_y xAy^T +\\ V_b = \max_y \min_x xAy^T +\]

+

Утверждение: \(V_a = V_b\).

+

Доказательство. Запишем матрицу \(A'\), приписав к \(A\) справа единичную матрицу:

+

Будем доказывать от противного: покажем, что ситуация \(V_a < 0 < V_b\) невозможна, а значит для любого \(t\) ситуация невозможна.

+

Рассмотрим выпуклую оболочку \(C\) её столбцов.

+

Предположим, что \(\vec{0} \in C\).

+

Тогда существуют \(z_1, z_2, \ldots z_{n+m}\) такие что \(0 \leq z_i \leq 1\) и \(\sum z_i = 1\) (то есть вероятностное распределение).

+

$sum_{j=1}^n a_{ij} z_{ij} $

+

В этой игре оптимальной

+

Несимметричные игры

+

Минимаксная теорема

+

Докажем очень частный случай. Это утверждение оказывается верно, и даже в случае многопользовательских игр и игр с ненулевой суммой, но доказательства этих фактов содержат слишком много матана, чтобы добавлять их навключать сайт по программированию.

+

Пусть имеется матрица \(A\) размера \(n \times m\). Задача

+ + diff --git a/ru/index.html b/ru/index.html new file mode 100644 index 0000000..75ffb16 --- /dev/null +++ b/ru/index.html @@ -0,0 +1,266 @@ + + + + + + + Алгоритмика + + + + + + + + + + + + + + +

Алгоритмика

+
+

Структуры данных

+ +
+
+

Графы

+ +
+
+

Строки

+ +
+
+
+

Общие техники

+ +
+
+

Деревья

+ +
+
+

Высшая математика

+ +
+
+
+

Теория чисел

+ +
+
+

Теория игр

+ +
+
+

Динамика

+ +
+
+
+

Комбинаторика

+ +
+
+

Геометрия

+ +
+
+

Разное

+ +
+
+
+

+Спасибо: (в алфавитном порядке) +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+Константину Амеличеву, + +Андрею Гаркавому, + +Максиму Деб Натху, + +Роману Дееву, +
+Дмитрию Дубровину, + +Артему Захаренко, + +Григорию Иваненко, + +Павлу Калугину, +
+Ивану Клигунову, + +Павлу Колесникову, + +Ольге Коломытцевой, + +Александру Кулькову, +
+Глебу Лобанову, + +Александру Мамаеву, + +Ибрагиму Мамилову, + +Баиру Михайлову, +
+Андрею Москалеву, + +Луке Никифорову, + +Владимиру Новикову, + +Яну Олеринскому, +
+Артему Рябову, + +Артему Степанову, + +Марии Тряпицыной, + +Андрею Филипюку, +
+Андрею Чулкову, + +Булату Шелхонову +
+

+…и много кому ещё за участие в подготовке статей, указания на ошибки и прочую помощь. +

+

+Проект открытый, живёт на гитхабе; разрабатывает и поддерживает Сергей Слотин. +

+ +

+🄯 2017–2019 +

+
+ + + diff --git a/ru/karatsuba.html b/ru/karatsuba.html new file mode 100644 index 0000000..41411f0 --- /dev/null +++ b/ru/karatsuba.html @@ -0,0 +1,312 @@ + + + + + + + Многочлены и алгоритм Карацубы - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Многочлены и алгоритм Карацубы

+

В 1960-м году Андрей Колмогоров и несколько других советских пионеров информатики собрались на научном семинаре и выдвинули «гипотезу \(n^2\)»: невозможно перемножить два \(n\)-значных числа, быстрее, чем за \(O(n^2)\). Это подразумневает, что умножение «в столбик», придуманное шумерами как минимум четыре тысячи лет назад и никем на тот момент не побитое, является асимптотически оптимальным алгоритмом умножения двух чисел.

+

Через неделю 23-летний аспирант Анатолий Карацуба предложил метод умножения с оценкой времени работы \(O(n^{\log_2 3})\) и тем самым опроверг гипотезу.

+

Историческое примечание: эта задача сейчас решается за \(O(n \log n)\) с помощью алгоритма быстрого преобразования Фурье, который к концу 50-х годов уже изобрели, но использовали только «по назначению»: для обработки сигналов, а не для умножения чисел.

+

Умножение многочленов

+

Любое число можно можно представить в виде многочлена, если вместо \(x\) подставить основание системы счисления:

+

\[ +\begin{aligned} +A(x) &= a_0 + a_1\cdot x + a_2 \cdot x^2 + \dots + a_n \cdot x^n +\\ &= a_0 + a_1\cdot 2 + a_2 \cdot 2^2 + \dots + a_n \cdot 2^n +\end{aligned} +\]

+

Наивная формула для умножения многочленов, если раскрыть все скобки, выглядит так:

+

\[ +\left(\sum_{i=0}^n a_i x^i\right)\cdot\left(\sum_{j=0}^m b_j x^j\right)=\sum_{k=0}^{n+m}x^k\sum_{i+j=k}a_i b_j +\]

+

Единственный нюанс: в случае чисел, когда мы хотим получить их реальное представление, нужно предварительно провести «каррирование»:

+
const int base = 10;
+
+void carry(int *a, int n) {
+    int d = 0;
+    for (int i = 0; i < n; i++) {
+        a[i] += d;
+        d = a[i] % base;
+        a[i] /= base;
+    }
+}
+

Основание системы счисления может быть выбрано произвольно, но из соображений производительности его имеет смысл выбрать его как можно большим, избегая при этом переполнений. Например, при выборе основания \(10^6\) и использовании массива long long размера \(10^6\) (т. е. в данном случае перемножаются \((6 \cdot 10^6)\)-значные числа), ничего переполниться после одного умножения не должно: максимальное значение по формуле будет \((10^6)^3 = 10^{18}\), что помещается в 64 бит.

+

Мастер-теорема

+

Основная схема алгоритма очень простая: в нём умножение двух чисел длины \(n\) небольшим алгебраическим трюком сводится к трём умножениям чисел длины \(\frac{n}{2}\). Примечательно, что нужные формулы были ещё у Чарльза Бэббиджа, работавшего в начале XIX века над механическим компьютером для подсчета налогов для британской короны, однако он не обратил внимания на возможность использования лишь трёх рекурсивных умножений вместо четырех.

+

То, почему он работает за такую странную асимптотику, весьма неочевидно, и для этого мы сначала докажем более мощную теорему, которая говорит об асимптотике большого класса алгоритмов вида «разделяй-и-властвуй», заменяющих исходную задачу на \(a\) задач размера \(\frac{n}{b}\).

+

Мастер-теорема. Пусть имеется рекуррента:

+

\[ +T(n) = \begin{cases} +a T(\frac{n}{b}) + \Theta(n^c), & n > n_0 +\\ \Theta(1), & n \leq n_0 +\end{cases} +\]

+

Тогда:

+
    +
  • A. Если \(c > \log_b a\), то \(T(n) = \Theta(n^c)\).
  • +
  • B. Если \(c = \log_b a\), то \(T(n) = \Theta(n^c \log n)\).
  • +
  • C. Если \(c < \log_b a\), то \(T(n) = \Theta(n^{\log_b a})\).
  • +
+
+

+
+

Доказательство. Рассмотрим «дерево рекурсии» этого соотношения. В нём будет \(log_b n\) уровней. На \(k\)-том уровне будет \(a^k\) вершин, каждая из которых будет стоить \((\frac{n}{b^k})^c\) операций. Просуммируем значения во всех вершинах по всем уровням:

+

\[ +T(n) = \sum_{k=0}^{\log_b n} a^k (\frac{n}{b^k})^c = n^c \sum_{k=0}^{\log_b n} (\frac{a}{b^c})^k +\]

+

A. Если \(c > \log_b a\), то \(\sum (\frac{a}{b^с})^k\) это сумма убывающей геометрической прогрессии, которая не зависит от \(n\) и просто равна какой-то константе. Значит, \(T(n) = \Theta(n^c)\).

+

B. Если \(c = \log_b a\), то

+

\[ +T(n) = n^c \sum_{k=0}^{\log_b n} (\frac{a}{b^c})^k = n^c \sum_{k=0}^{\log_b n} 1^k = \Theta(n^c \log_b n) +\]

+

C. Если \(c < \log_b a\), то так как сумма прогрессии асимптотически эквивалентна своему старшему элементу,

+

\[ +T(n) = n^c \sum_{k=0}^{\log_b n} (\frac{a}{b^c})^k = \Theta(n^c (\frac{a}{b^c})^{\log_b n}) = \Theta(n^c \cdot \frac{a^{\log_b n}}{n^c}) = \Theta(a^{\log_b n}) = \Theta(n^{\log_b a}) +\]

+

Примечание. Для более точных оценок асимптотики «мерджа» теорема ничего не говорит. Например, если мердж занимает \(\Theta(n \log n)\) и задача разбивается каждый раз на две части, то асимптотика будет равна:

+

\[ +\sum_{k=0}^{\log n} n \log \frac{n}{2^k} += \sum_{k=0}^{\log n} n (\log n - k) += n \sum_{k=0}^{\log n} k += \Theta (n \log^2 n) +\]

+

В то же время эта рекуррента под условия теоремы не попадает. Можно лишь получить неточные границы \(\Omega (n \log n)\) и \(O(n^{1+\epsilon})\), если подставить \(c = 1\) и \(c = 1 + \epsilon\) соответственно. Заметим, что \(n \log n\) и \(n \log^2 n\) асимптотически меньше \(n^{1+\epsilon}\), каким бы маленьким \(\epsilon\) ни был.

+

Алгоритм Карацубы

+

Пусть у нас есть два многочлена \(a(x)\) и \(b(x)\) равной длины \(n = 2k\) и мы хотим их перемножить. Разделим их коэффициенты на две равные части и представим как:

+

\[ +a(x) = a_1 (x) + x^k a_2(x) +\\b(x) = b_1 (x) + x^k b_2(x) +\]

+

Теперь рекурсивно вычислим многочлены-произведения \(p_1\) и \(p_2\):

+

\[ +p_1(x) = a_1(x) \cdot b_1(x) +\\ +p_2(x) = a_2(x) \cdot b_2(x) +\]

+

А также многочлен \(t\):

+

\[ +t(x) = ( a_1(x) + a_2(x) ) \cdot (b_1(x) + b_2(x)) +\]

+

Результат умножения исходных многочленов теперь можно посчитать по следующей формуле — внимание, алгебра:

+

\[ +c(x) = a(x) \cdot b(x) = p_1(x) + x^k \cdot (t(x) - p_1(x) - p_2(x)) + x^{2k} \cdot p_2(x) +\]

+

Корректность формулы можно проверить, просто выполнив нужные подстановки.

+

Обратим внимание, что результат умножения — многочлен размера \(2 n\).

+

Анализ

+

Если посчитать необходимые операции, то выясняется, что для перемножения двух многочленов размера \(n\) нам нужно посчитать три произведения — \(p_1\), \(p_2\) и \(t\) — размера \(\frac{n}{2}\) и константное количество сложений, вычитаний и сдвигов (домножений на \(x^k\)), которые суммарно можно выполнить за \(O(n)\).

+

Пролистав пол-экрана выше, можно убедиться, что асимптотика всего алгоритма будет \(\Theta (n^{\log_2 3}) \approx \Theta (n^{1.58})\): в данном случае наша задача разбивается на \(a = 3\) части в \(b = 2\) раз меньшего размера, а объединение происходит за \(O(n)\).

+

Реализация

+

Для простоты будем предполагать, что \(n\) это степень двойки. Если это не так, то в зависимости от обстоятельств это можно исправить одним из двух костылей:

+
    +
  1. Можно дополнить коэффициенты многочлена нулями до ближайшей степени двойки — в худшем случае это будет работать в \(2^{1.58} \approx 3\) раза дольше.

  2. +
  3. Можно «отщепить» последний коэффициент от многочленов и свести задачу размера \(2k + 1\) к задаче размера \(2k\) и константному количество сложений.

  4. +
+

В этой статье мы будем использовать первый метод.

+

Основные соображения по поводу эффективной реализации:

+
    +
  • Нужно выделять как можно меньше лишней памяти, для чего нужно переиспользовать имеющиеся массивы.

  • +
  • Все арифметические операции нужно реализовать как простые линейные проходы по массивам, чтобы компилятор смог их векторизовать.

  • +
  • Вместо использования базы вида if (n == 1) c[0] = a[0] * b[0], имеет смысл, начиная с какого-то размера задачи, использовать более эффективное наивное умножение за квадрат.

  • +
+

Код основной рекурсивной процедуры:

+
void karatsuba(int *a, int *b, int *c, int n) {
+    if (n <= 64) {
+        for (int i = 0; i < n; i++)
+            for (int j = 0; j < n; j++)
+                c[i + j] += a[i] * b[j];
+    }
+    else {
+        int k = n / 2;
+        int l[k], r[k], t[n] = {0};
+        for (int i = 0; i < k; i++) {
+            l[i] = a[i] + a[k + i];
+            r[i] = b[i] + b[k + i];
+        }
+        karatsuba(l, r, t, k); // считает t
+        karatsuba(a, b, c, k); // считает p1
+        karatsuba(a + k, b + k, c + n, k); // считает p2
+        int *t1 = t, *t2 = t + k;
+        int *s1 = c, *s2 = c + k, *s3 = c + 2 * k, *s4 = c + 3 * k;
+        for (int i = 0; i < k; i++) {
+            int c1 = s2[i] + t1[i] - s1[i] - s3[i];
+            int c2 = s3[i] + t2[i] - s2[i] - s4[i];
+            c[k + i] = c1;
+            c[n + i] = c2;
+        }
+    }
+}
+

После трёх рекурсивных вызовов массив \(c\) — это конкатенация \(p_1\) и \(p_2\).

+

После этого, для подсчета самого многочлена \(c\) проще всего мысленно разделить разделить его на четыре равные части, а многочлен \(t\) — на две половины \(t_1\) и \(t_2\), а затем посмотреть на формулу и подумать, как изменится каждая часть:

+
    +
  • \(s_1\): не меняется — это первая половина \(p_1\)

  • +
  • \(s_2\): выражается как \(s_2 + t_1 - s_1 - s_3\), то есть изменяется на «первую» половину \(t - p_1 - p_2\)

  • +
  • \(s_3\): выражается как \(s_3 + t_2 - s_2 - s_4\), то есть изменяется на «торую» половину \(t - p_1 - p_2\)

  • +
  • \(s_4\): не меняется — это вторая половина \(p_2\)

  • +
+

Из-за векторизации важно использовать максимально «лёгкий» тип данных и при возможности компилировать с AVX:

+
#pragma GCC optimize("O3")
+#pragma GCC target("avx2")
+

Реализация достаточно эффективна: она может перемножить два многочлена размера \(4 \cdot 10^5\) за секунду,

+

Умножение многочленов в комбинаторике

+

В олимпиадных задачах длинное умножение само по себе встречается крайне редко. Гораздо чаще оно используется для подсчета каких-либо комбинаторных объектов через умножение многочленов.

+

Для примера мы решим задачу «Вор в магазине» с Educational Codeforces Round:

+
+

Дано \(n \leq 1000\) типов предметов разных положительных стоимостей. Каждая стоимость не превосходит \(1000\). Предметов каждого типа доступно бесконечное количество.

+

Требуется определить всевозможные суммы предметов, которые можно набрать, взяв ровно \(k\) предметов.

+
+

Составим многочлен степени \(1000\), в котором коэффициент при \(x_i\) равен единице, если в наборе существует предмет со стоимостью \(i\), и \(0\) в противном случае.

+

Если возвести этот многочлен в \(k\)-тую степень, то его можно записать так:

+

\[ +\sum_k (x^k \sum_{t_1+\ldots+t_k=i} a_{t_1} a_{t_2} \ldots a_{t_k}) +\]

+

Если посмотреть на эту формулу комбинаторно, то в получившемся многочлене коэффициент при \(x_i\) равен количеству способов набрать сумму \(i\), взяв ровно \(k\) предметов — а в задаче нас по сути интересует, равно ли это число нулю или нет.

+

Для возведения многочлена в \(k\)-тую степень можно использовать бинарное возведение в степень, а для умножения многочленов — алгоритм Карацубы.

+
const int maxn = (1<<20);
+int a[maxn], res[maxn];
+
+// мы не всегда хотим модифицировать исходные массивы при перемножении
+// поэтому напишем обертку, которая работает, как *=
+void mul(int *x, int *y, int n) {
+    int tx[n], ty[n];
+    memcpy(tx, x, sizeof tx);
+    memcpy(ty, y, sizeof ty);
+    memset(x, 0, sizeof tx);
+    karatsuba(tx, ty, x, n);
+}
+
+void binpow(int k) {
+    res[0] = 1;
+    int len = 1024;
+    while (k > 1) {
+        if (k & 1)
+            mul(res, a, len);
+        mul(a, a, len);
+        k /= 2;
+        len *= 2;
+    }
+    mul(res, a, len);
+}
+
+// осталось скормить функции binpow бинарный массив a,
+// и res будет содержать a в k-той степени
+

В асимптотике будет учитёно только последнее (самое больше) умножение.

+

Решение в условиях задачи работает за 3 секунды с ограничением в 5. Его можно значительно ускорить, если вместо int-ов использовать байты или даже биты по аналогии с битсетом, потому что нас не интересует количество способов набрать какую-то сумму, а только равно ли это число нулю.

+

Развитие идеи

+

Похожий метод можно применить к матричному умножению — это называется алгоритмом Штрассена. В нём две матрицы разбиваются на \(8 = 4 + 4\) частей, перемножаются блочно, и сложной алгеброй от одного из 8 умножений получается избавиться, что даёт асимптотику \(O(n^{\log_7 8}) \approx O(n^{2.81})\).

+

И в алгоритме Штрассена, и в алгоритме Карацубы можно достичь и лучшей асимптотики, если разбивать объекты на большее число частей. Однако, в реальности это не применяется, потому что в асимптотиках подобных улгоритмов скрыта непрактично большая константа.

+ + diff --git a/ru/lca.html b/ru/lca.html new file mode 100644 index 0000000..0fc2e05 --- /dev/null +++ b/ru/lca.html @@ -0,0 +1,260 @@ + + + + + + + Корневые деревья - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Корневые деревья

+

Дерево называется корневым, если оно ориентированно, и из какой-то вершины (называемой корнем) можно попасть во все остальные.

+

Примеры корневых деревьев:

+
    +
  • наследование классов в языках программирования (если множественное наследование запрещено),
  • +
  • дерево факторизации числа на простые (в общем случае не уникальное),
  • +
  • иерархия в какой-нибудь организации,
  • +
  • дерево парсинга математичеких выражений.
  • +
+

Задачи на корневые деревья весьма бесполезны в реальной жизни, но зато очень интересны с алгоритмической точки зрения, и поэтому часто встречаются на олимпиадах по программированию.

+

+

Свойства dfs

+

Посчитаем для каждой вершины времена входа (\(tin\)) и выхода (\(tout\)) из неё во время эйлерова прохода:

+
vector<int> g[maxn];
+int p[maxn], tin[maxn], tout[maxn];
+int t = 0;
+
+void dfs (int v) {
+    tin[v] = t++;
+    for (int u : g[v])
+        dfs(u);
+    tout[v] = t; // иногда счётчик тут тоже увеличивают
+}
+

У этих массивов много полезных свойств:

+
    +
  • Вершина \(u\) является предком \(v\) \(\iff tin_v \in [tin_u, tout_u)\). Эту проверку можно делать за константу.
  • +
  • Два полуинтервала — \([tin_v, tout_v)\) и \([tin_u, tout_u)\) — либо не пересекаются, либо один вложен в другой.
  • +
  • В массиве \(tin\) есть все числа из промежутка от 0 до \(n-1\), причём у каждой вершины свой номер.
  • +
  • Размер поддерева вершины \(v\) (включая саму вершину) равен \(tout_v - tin_v\).
  • +
  • Если ввести нумерацию вершин, соответствующую \(tin\)-ам, то индексы любого поддерева всегда будут каким-то промежутком в этой нумерации.
  • +
+

Запросы на поддеревьях

+

Последнее свойство на самом деле очень важное. Его можно использовать для обработки разных запросов на поддеревьях, сводя их к запросам на подотрезках, которые уже можно решать стандартными методами — например, через дерево отрезков.

+

Пример задачи:

+
+

Есть корневое дерево. Рядом с каждой вершиной записано число. Поступают два типа запросов: прибавить ко всем вершинам на каком-то поддереве число \(x_i\) и найти значение числа у вершины \(v_i\).

+
+

Выпишем все числа у вершин в один массив, в позиции, соответствующие их \(tin\)-ам. Что такое «прибавить на поддереве» относительно этого массива? Это то же, самое, что прибавить какую-то константу на каком-то подотрезке, а это можно делать любой достаточно продвинутой структурой.

+

Запросы на уровнях

+
+

Дано корневое дерево. Требуется отвечать на запросы нахождения \(d_i\)-того предка вершины \(v_i\), то есть вершины-предка, находящейся на расстоянии \(d_i\) от \(v_i\).

+
+

Создадим \(h\) векторов, где \(h\) — высота дерева. Для каждой вершины, во время прохода в dfs, добавим её \(tin\) в вектор, соответствующей её глубине. Получаем \(h\) отсортированных векторов.

+

Теперь заметим, что внутри одного вектора все отрезки поддеревьев вершин — \([tin_v, tout_v)\) — тоже не пересекаются, а значит ещё и отсортированы. Тогда ответа на запрос мы можем просто взять \(tin\) вершины-запроса, посмотреть на вектор нужного уровня и за \(O(\log n)\) сделать бинпоиск по нужному отрезку.

+

Также существует другой способ, требующий \(O(1)\) времени на запрос, но \(O(n \log n)\) памяти на предпосчёт — лестничная декомпозиция.

+

Наименьший общий предок

+

Для большого класса задач требуется решить следующую вспомогательную:

+
+

Дано корневое дерево. Требуется отвечать на запросы нахождения наименьшего общего предка вершин \(u_i\) и \(v_i\), то есть вершины \(w\), которая лежит на пути от корня до \(u_i\), на пути от корня до \(v_i\), и при этом самую глубокую (нижнюю) из всех таких.

+
+

По-английский эта задача называется Least Common Ancestor. Есть много разных способов её решать, и мы рассмотрим только основные.

+

+

Для лучшего понимания — медленно (за линейное время) наименьшего общего предка можно искать так:

+
bool a (int u, int v) {
+    return tin[u] <= tin[v] && tin[v] < tout[u];
+}
+
+int lca (int u, int v) {
+    while (!ancestor(u, v))
+        u = p[u];
+    return u;
+}
+

Двоичные подъемы

+

Предпосчитаем для каждой вершины её 1-го предка, 2-го предка, 4-го и так далее. Сохраним их в двумерном массиве up размера \(n \times \lceil \log n \rceil\): в up[v][d] будет храниться предок вершины \(v\) на расстоянии \(2^d\), а если такой вершины не существует, то корень.

+

Такой препроцессинг можно выполнить за \(O(n \log n)\), используя тот факт, что предок на расстоянии \(2^{d+1}\) — это предок на расстоянии \(2^d\) предка на расстоянии \(2^d\):

+
int up[maxn][logn];
+
+void dfs (int v) {
+    for (int l = 1; l < logn; l++)
+        up[v][l] = up[up[v][l-1]][l-1];
+    tin[v] = t++;
+    for (int u : g[v]) {
+        up[u][0] = v;
+        dfs(u);
+    }
+    tout[v] = t;
+}
+

Пусть теперь поступил запрос нахождения LCA — пара вершин \((u, v)\):

+
    +
  • Проверим, не является ли одна вершина предком другой — в таком случае она и является результатом.
  • +
  • Иначе, пользуясь массивом up, будем подниматься по предкам одной из них, пока не найдём самую высокую вершину, которая ещё не является предком другой. Следующая за ней будет искомым LCA.
  • +
+

Подробнее про второй пункт. Присвоим \(i = \lceil \log n \rceil\) и будем уменьшать эту переменную на единицу, пока up[v][i] не перестанет быть предком \(u\). Когда это произойдёт, подвинем указатель на \(2^i\)-го предка \(v\) и продолжим дальше:

+
int lca (int v, int u) {
+    if (a(v, u)) return v;
+    if (a(u, v)) return u;
+    for (int l = logn-1; l >= 0; l--)
+        if (!ancestor(up[v][l], u))
+            v = up[v][l];
+    return up[v][0];
+}
+

Указатель up[v][i] изначально явдяеься корнем дерева, а затем будет каждую итерацию спускаться на \(2^i\). Когда он станет потомком lca, нам достаточно подняться всего лишь один раз, потому что два раза прыгнуть на расстояние \(2^i\) это то же самое, что один раз прыгнуть на \(2^{i+1}\), а мы могли это сделать на предыдущем шаге.

+

Асимптотика. Препроцессинг занимает \(O(n \log n)\), потому что таков размер массива up, и каждый его элемент вычисляется за константу. Ответ на произвольный запрос будет работать за \(O(\log n)\), потому что фактически мы делаем один бинпоиск.

+

Запросы на путях

+

Пусть нас вместо LCA спрашивают, например, о минимуме на произвольном пути (на всех рёбрах записаны какие-то числа).

+

Мы можем сделать такой же предподсчет, как в методе двоичных подъемов, но хранить вместе с номером \(2^d\)-ого предка минимум на соответствующем пути.

+

Заметим, что минимум на пути от \(u\) до \(v\) — это минимум от минимума на пути от \(u\) до \(lca(u, v)\) и от минимума на пути от \(v\) до \(lca(u, v)\). В свою очередь, оба этих минимума — это минимум на всех двоичных подъемах до LCA.

+
int get_min (int v, int u) {
+    int res = inf;
+    for (int l = logn-1; l >= 0; l--)
+        if (!ancestor(up[v][l], u))
+            v = up[v][l], res = min(res, mn[v][l]);
+    for (int l = logn-1; l >= 0; l--)
+        if (!ancestor(up[u][l], v))
+            u = up[u][l], res = min(res, mn[u][l]);
+    return min({res, mn[v][0], mn[u][0]})
+}
+

Аналогичным образом можно считать сумму, gcd, полиномиальный хэш и много других странных функций на пути, но только в статическом случае (когда у нас нет обновлений). Для динамического случая существует весьма сложный метод, называемый heavy-light декомпозицией.

+

Сведение LCA к RMQ

+

Подойдём к задаче нахождения наименьшего общего предка с другой стороны.

+

Пройдёмся по дереву dfs-ом и выпишем два массива: глубины вершин и номера вершин. Записывать мы их будем как когда при входе в вершину, так и при выходе.

+

Пусть теперь поступил запрос: найти LCA вершин \(v\) и \(u\). Для определенности предположим, что \(tin_v < tin_u\), то есть \(v\) в обходе встретилась раньше. Посмотрим на часть выписанного пути между моментом, когда мы вышли из \(v\) и моментом, когда мы в первый раз вошли в \(u\). Где-то на этом отрезке мы должны были прийти в наименьший общий предок, так как любой простой путь между двумя вершинами в дереве единственный. При этом мы на этом пути не поднимались куда-то выше LCA, а значит LCA — это самая высокая вершина на этом пути.

+

Получается, что чтобы найти LCA, можно найти позицию минимума на отрезке \([tout_v, tin_u]\) в массиве глубин (первый выписанный массив) и посмотреть, какой вершине она соответствует в эйлеровом обходе (второй выписанный массив). Таким образом, задачу LCA можно свести к задаче RMQ (нахождению минимума на отрезке), что можно сделать тем же деревом отрезков.

+

Разреженная таблица

+

На практике асимптотику мы особо не улучшили — пока что всё равно требуется \(O(\log n)\) времени на запрос в дерево отрезков, хоть преподсчёт и будет уже линейным.

+

Асимптотику времени запроса можно улучшить, используя тот факт, что мы на самом деле решаем задачу static RMQ, то есть у нас нет изменений этого массива. Для этого есть более подходящая структура — разреженная таблица. Она позволяет отвечать на запрос минимума за \(O(1)\), но использует \(O(n \log n)\) операций на препроцессинг (с малой константой). Подробнее про неё можно почитать в отдельной статье.

+

Алгоритм Фарах-Колтона и Бендера

+

Примечание: алгоритм нахождения RMQ, описываемый в этой секции, абсолютно бесполезен на практике, однако очень интересен с теоретической точки зрения.

+

Оказывается, что и LCA, и static RMQ можно считать за \(O(1)\) времени на запрос и \(O(n)\) времени на предпосчёт.

+

На самом деле, мы решаем не совсем полноценную задачу RMQ. Мы работаем не со всеми массивами целых чисел от 1 до \(n\), а только с некоторыми — с теми, в которых любые два элемента отличаются ровно на единицу, потому что каждый переход это либо спуск, либо подъём в dfs. Это ограничение позволяет находить минимум на подотрезках подобных массивов быстрее.

+

Сделаем следующее: раз каждые два элемента отличаются на единицу, то сопоставим исходному массиву глубин булевый массив размера \((n-1)\): на \(i\)-той позиции будет стоять единица, если следующее значение больше, и ноль в противном случае. Этот массив нужно будет хранить в бинарном виде, чтобы можно было за константу получать булеву маску небольших подотрезков.

+

Первая часть предподсчёта. Возьмем константу \(k = \lfloor \frac{\log n}{2} \rfloor\), и разделим исходный массив на блоки по \(k\) элементов. На каждом блоке посчитаем минимум, а над этими минимумами построим sparse table.

+

Всего блоков таких блоков \(O(\frac{2 n}{\log n})\), и поэтому построение работает за линейное время:

+

\[ +O(\frac{2 n}{\log n} \log \frac{2 n}{\log n}) = O(\frac{2 n}{\log n} (\log 2n - \log \log n)) = O(n) +\]

+

Вторая часть предподсчёта. Посчитаем для каждой возможной маски подъёмов / спусков размера \(k\) максимальный спуск на ней — то есть пройдёмся по ней, поддерживая разницу встретившихся нулей и единиц, и запомним минимальное значение этого баланса. Это можно сделать за длину маски, помоноженное на их количество:

+

\[ +O(k \cdot 2^k) = O(\frac{\log n}{2} 2^{\frac{\log n}{2}}) = O(\sqrt n \log n) +\]

+

Возможных масок получается немного — ради этого мы и делили логарифм на два при определении \(k\)

+

Запрос. Нам нужно с помощью посчитанных структур найти RMQ на каком-то отрезке \([l, r]\). Он включает в себя какие-то последовательные блоки и сколько-то оставшихся ячеек слева и справа, не вошедших ни в какой целый блок.

+
    +
  • Для блочной части мы можем просто сделать запрос в sparse table — он будет работать за константу.

  • +
  • Для обеих не-блочных частей посчитаем ещё по кандидату на ответ. Для этого нужно прибавить к граничному элементу предподсчитанное значение минимума на маске оставшихся неблочных элементов — её можно за константу получить битовыми операциями над булевым массивом.

  • +
+

Просто посчитать все суффиксные и префиксные минимумы для всех блоков, чтобы обрабатывать второй случай, к сожалению, нельзя — есть один частный случай, когда запрос маленький и не накрывает никакой блок целиком. В данном случае нужно просто взять граничный элемент и прибавить к нему минимум от нужной маски из массива подъёмов.

+

Наоборот. Этот алгоритм очень важен с теоретической точки зрения, потому что позволяет решать, не только LCA, но и в общем случае static RMQ за линейное время.

+

Построим декартово дерево, в котором в качестве ключей \(x_i\) возьмём индексы элементов, а в качестве приоритетов \(y_i\) возьмём сами значения. Декартово дерево могло получиться несбалансированным (так как нет рандомизации приоритетов), но это нам и не нужно. Дальше просто применим описанный выше алгоритм к этому дереву, и теперь для нахождения минимума в исходном массиве можно просто запросить общего предка \(l\)-той и \(r\)-той вершины в дереве — его приоритет в декартовом дереве и будет искомым минимумом.

+

Чуть более подробно и с реализацией (автор это никогда не кодил и вам не советует) можно почитать у Емакса. Впрочем, на практике этот алгоритм использовать нецелесообразно из-за большой константы: слишком много чего нужно считать, чтобы избавиться от логарифма в асимптотике.

+ + diff --git a/ru/linalg.html b/ru/linalg.html new file mode 100644 index 0000000..0663258 --- /dev/null +++ b/ru/linalg.html @@ -0,0 +1,394 @@ + + + + + + + Ликбез по линейной алгебре - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Ликбез по линейной алгебре

+

Определение. Функция \(f: \mathbb{R}^n \to \mathbb{R}^m\) называется линейной, если для неё выполнено:

+
    +
  1. \(f(x+y) = f(x) + f(y)\)
  2. +
  3. \(f(ax) = a f(x), \; a \in \mathbb{R}\)
  4. +
+

Например, линейными являются:

+
    +
  • Функция, которая «растягивает» вектор в \(k\) раз: \(f(x) = k x\).
  • +
  • Функция, которая поворачиват вектор на плоскости на угол \(\theta\).
  • +
  • Функция, которая проецирует трёхмерный вектор на какую-нибудь плоскость.
  • +
  • Скалярное произведение \(f(x, y) = x \cdot y = \sum x_ky_k\) также линейно по обоим параметрам.
  • +
+

Линейная алгебра занимается изучением линейных функций.

+

Из одних лишь двух пунктов в определении можно вывести много полезных свойств:

+
    +
  • Сумма линейных функций является линейной функцией.
  • +
  • Композиция линейных функций \(f(g(x)) = (f \circ g)(x)\) является линейной функцией.
  • +
  • Сумма линейных функций коммутативна: \(f+g = g+f\).
  • +
  • Сумма линейных функций ассоциативна: \((f+g)+h = f+(g+h)\).
  • +
  • Композция линейных функций ассоциативна: \((f \circ g) \circ h = f \circ (g \circ h) = f \circ g \circ h\).
  • +
  • Композиция в общем случае не коммутативна. Пример: \(f(x) = (-x_2, x_1)\) — поворот точки на плоскости на прямой угол, \(g(x) = (x_1, 0)\) — проекция на \(Ox\). Почти для всех точек плоскости важен порядок этих двух операций.
  • +
+

Матрицы

+

Можно показать, что любую линейную функцию \(f: \mathbb{R}^n \to \mathbb{R}^m\) можно представить в следующем виде:

+

\[ +f(x) = +\begin{pmatrix} +a_{11} x_1 + a_{12} x_2 + \ldots + a_{1n} x_n \\ +a_{21} x_1 + a_{22} x_2 + \ldots + a_{2n} x_n \\ +\ldots \\ +a_{m1} x_1 + a_{m2} x_2 + \ldots + a_{mn} x_n \\ +\end{pmatrix} +\]

+

Матрицы ввели просто как очень компактную запись этих коэффициентов \(a_{ij}\).

+

\[ +A = +\begin{pmatrix} +a_{11} & a_{12} & \ldots & a_{1n} \\ +a_{21} & a_{22} & \ldots & a_{2n} \\ +\vdots & \vdots & \ddots & \vdots \\ +a_{m1} & a_{m2} & \ldots & a_{mn} \\ +\end{pmatrix} +\]

+

Каждой линейной функции \(f\) из \(\mathbb{R}^n\) в \(\mathbb{R}^m\) соответствует какая-то матрица \(A\) размера \(n \times m\) (первое число это количество строк, второе — столбцов) и наоборот. Элемент на пересечении \(i\)-ой строки и \(j\)-го столбца будем обозначать \(A_{ij}\). Не перепутайте.

+

Если вектор — это упорядоченный набор скаляров, то матрицу можно рассматривать как вектор векторов. Вектор, в частности, можно представить как матрицу, у которой одна из размерностей равна единице — тогда его называют вектор-столбец либо вектор-строка.

+
typedef vector<vector<int>> matrix;
+

Ещё есть тензоры — ими называют все объекты ещё более высокого порядка: векторы матриц (трёхмерный тензор), матрицы матриц (четырёхмерный тензор) и векторы матриц матриц и так далее.

+

+

У тензоров есть своя интересная алгебра, но в контекстах, в которых с ними сталкивается обычный программист, никакая алгебра, как правило, не подразумневается, и этот термин используется лишь потому, что в словосочетании «многомерный массив» слишком много букв.

+

Матричное умножение

+

Пусть линейной функции \(f\) соответствует матрица \(A\), а функции \(g\) соответствует матрица \(B\). Тогда композиции этих функций \(h = f \circ g\) будет соответствовать произведение \(C\) матриц \(A\) и \(B\), определяемое следующим образом:

+

\[ +C = AB: \; C_{ij} = \sum_{i=1}^{k} A_{ik} B_{kj} +\]

+

Читатель может убедиться в этом, расписав, какие коэффициенты получаются, если формулы из \(g\) подставить в \(f\).

+

При перемножении матриц руками удобно думать так: элемент на пересечении \(i\)-го столбца и \(j\)-той строки — это скалярное произведение \(i\)-той строки \(A\) и \(j\)-того столбца \(B\). Заметим, что это накладывает ограничение на размерности перемножаемых матриц: если первая матрица имеет размер \(n \times k\), то вторая должна иметь размер \(k \times m\), то есть «средние» размерности обязательно должны совпадать.

+

+

Исходное выражение для \(f(x)\) теперь можно компактно записать как \(f(x) = Ax\) вместо \(m\) уравнений с \(n\) слагаемыми в каждом.

+

Напишем функцию, реализующую матричное умножение:

+
const int n, k, m;
+
+matrix matmul(matrix a, matrix b) {
+    matrix c(n, vector<int>(m, 0));
+    for (int i = 0; i < n; i++)
+        for (int j = 0; j < m; j++)
+            for (int t = 0; t < k; t++)
+                c[i][j] += a[i][t] * b[t][j];
+    return c;
+}
+

Такая реализация хоть и наиболее простая, но не оптимальная ввиду особенностей работы кэша (см. раздел «Оптимизации матричного умножения»).

+

Свойства матриц

+

К матрицам не нужно относиться как к табличкам, в которых стоят какие-то числа. Каждой матрице соответствует какая-то линейная функция, как-то преобразующая вектора. Центральными объектами линейной алгебры являются именно линейные функции, а не матрицы.

+

Благодаря этому взаимно однозначному соотношению все ранее упомянутые свойства линейных функций переносятся и на матрицы:

+
    +
  • Сумма матриц \(A\) и \(B\) тоже является матрицей: \(C = A+B: \; C_{ij} = A_{ij} + B_{ij}\).
  • +
  • Сумма матриц коммутативна: \(A+B = B+A\).
  • +
  • Сумма матриц ассоциативна: \((A+B)+C = A+(B+C)\).
  • +
  • Умножение матриц ассоциативно: \((AB)C = A(BC) = ABC\).
  • +
  • Умножение матриц в общем случае не коммутативно.
  • +
+

Есть специальная матрица \(I\) (иногда обозначается \(E\)), которая называется единичной и относительно умножения ничего не делает, то есть ведёт себя как единица. У неё единицы на главной диагонали и нули вне неё:

+

\[ +I_3 = \begin{pmatrix} +1 & 0 & 0 \\ +0 & 1 & 0 \\ +0 & 0 & 1 \\ +\end{pmatrix} +\]

+

Некоторые линейные функции обратимы (например, поворот на угол), некоторые — необратимы (например, проекция). Понятие обратимости можно продолжить и на матрицы.

+

Определение. Матрица \(A\) является обратимой, если существует матрица \(A^{-1}\) такая, что \(A \cdot A^{-1} = A^{-1} \cdot A = I\).

+

Из формулы следует, что матрица \(A\) должна быть квадратной, но этого не всегда достаточно. У неквадратных матриц обратных матриц не существует. Обратную матрицу можно находить за \(O(n^3)\) методом Гаусса, который будет рассмотрен чуть позже.

+

Примеры матриц

+

Матрица «увеличь всё в два раза»:

+

\[ +\begin{pmatrix} +2 & 0 & 0 \\ +0 & 2 & 0 \\ +0 & 0 & 2 \\ +\end{pmatrix} +\]

+

Матрица «поменяй \(x\) и \(y\) местами»:

+

\[ +\begin{pmatrix} +0 & 1 \\ +1 & 0 \\ +\end{pmatrix} +\]

+

Матрица поворота на угол \(\alpha\) на плоскости:

+

\[ +\begin{pmatrix} +\cos \alpha & -\sin \alpha \\ +\sin \alpha & \cos \alpha \\ +\end{pmatrix} +\]

+

Матрица проецирования на плоскость \(xy\) в трёхмерном пространстве:

+

\[ +\begin{pmatrix} +1 & 0 & 0 \\ +0 & 1 & 0 \\ +0 & 0 & 0 \\ +\end{pmatrix} +\]

+

Применения вне геометрии

+

Пока что все примеры были про геометрию. Так просто проще думать: представлять в уме повороты и растяжения векторов на плоскости легче, чем рассуждать о свойствах абстрактных n-мерных пространств.

+

Однако мы программисты, и нас в основном интересуют задачи вне геометрии.

+

Динамическое программирование

+

Переходы в некоторых динамиках иногда можно выразить в терминах матричного умножения.

+

Рассмотирм конкретный пример. Пусть у нас есть ориентированный граф \(G\), заданный своей матрицей смежности, и нам нужно найти количество путей из \(s\) в \(t\) через ровно \(k\) переходов. Размер графа маленький по сравнению с \(k\) (скажем, несколько сотен вершин, в то время как \(k\) это число порядка \(10^{18}\)).

+

Обычно, мы бы ввели динамику \(f_{i,v}\), равную количеству способов дойти до \(v\)-той вершины через ровно \(i\) переходов. Пересчёт — перебор предпоследней вершины в пути:

+

\[ +f_{i,v} = \sum_u f_{i-1,u} \cdot G_{uv} +\]

+

Выясняется, что вектор \(f_{i}\) зависит от \(f_{i-1}\) линейно: его \(v\)-тый элемент получается скалярным умножением \(f_{i-1}\) и \(v\)-того столбца матрицы смежности \(G\).

+

Значит, имеет место следующее равенство:

+

\[ +f_i = f_{i-1} G +\]

+

(Поэтому \(G\) и называют матрицей смежности.)

+

Получается, \(f_k\) можно раскрыть как \(f_k = f_0 \cdot G \cdot G \cdot \ldots \cdot G\). Вспоминая, что умножение матриц коммутативно, по аналогии со скалярами для вычисления \(G^k\) мы можем применить бинарное возведение в степень:

+

\[ +A^8 = A^{2^{2^{2}}} = ((AA)(AA))((AA)(AA)) +\]

+

По этой формуле нам нужно \(\log k\) раз перемножить две матрицы размера \(n\), что суммарно будет работать за \(O(n^3 \log k)\).

+
const int n;
+
+matrix binpow(matrix a, int p) {
+    // создадим единичную матрицу
+    matrix b(n, vector<int>(n, 0));
+    for (int i = 0; i < n; i++)
+        b[i][i] = 1;
+
+    while (p > 0) {
+        if (p & 1)
+            b = matmul(b, a);
+         a = matmul(a, a);
+         p >>= 1;
+    }
+
+    return b;
+}
+

В получившейся матрице в ячейке \(g_{ij}\) будет находиться количество способов дойти из \(i\)-той вершины в \(j\)-тую, используя ровно \(k\) переходов. Ответом нужно просто вывести \(g_{st}\).

+

Модификации задачи:

+
    +
  • Практически не меняя сам алгоритм, можно решить задачу «с какой вероятностью мы попадём из вершины \(s\) в вершину \(t\)», если вместо булевой матрицы смежности даны вероятности, с которыми мы переходим из вершины в вершину в марковской цепи.

  • +
  • Если нам не нужно количество способов, а только сам факт, можно ли дойти за ровно \(k\) переходов, то можно обернуть матрицу в битсеты и сильно ускорить решение.

  • +
  • Если нас спрашивают «за не более, чем \(k\) переходов», то вместо \(k\)-той степени матрицы мы можем подобным методом посчитать сумму геометрической прогрессии.

  • +
  • Эту технику можно применить и к другим динамикам, где нужно посчитать количество способов что-то сделать — иногда очень неочевидными способами. Например, можно решить такую задачу: найти количество строк длины \(k \approx 10^{18}\), не содержащих данные маленькие запрещённые подстроки. Для этого нужно построить граф «легальных» переходов в Ахо-Корасике, возвести его матрицу смежности в \(k\)-тую степень и просуммировать в нём первую строчку.

  • +
+

В некоторых изощрённых случаях в матричном умножении вместо умножения и сложения нужно использовать другие операции, которые ведут себя как умножение и сложение. Пример задачи: «найти путь от \(s\) до \(t\) с минимальным весом ребра, использующий ровно \(k\) переходов»; здесь нужно возводить в \((k-1)\)-ую степень матрицу весов графа, и вместо обеих операций использовать минимум матрице весов графа.

+

Линейные рекурренты

+

Расмотрим другой пример — последовательность Фибоначчи. Ну а чем не динамика?

+

\[ +f_{n} = f_{n-1} + f_{n-2} +\]

+

Как мы видим, \(n\)-ный элемент линейно выражается через два предыдущих.

+

Вообще говоря, последовательностей Фибоначчи много — им не обязательно начинаться с \(0\) и \(1\). Для однозначной идентификации всей последовательности достаточно знать позиции и значения любых двух различных элементов. Поэтому в качестве состояния динамики введём такой вектор-столбец:

+

\[ +\begin{pmatrix} +f_{n+1} \\ +f_{n+2} \\ +\end{pmatrix} += +\begin{pmatrix} +0 + f_{n+1} \\ +f_{n} + f_{n+1} \\ +\end{pmatrix} += +\begin{pmatrix} +0 & 1 \\ +1 & 1 \\ +\end{pmatrix} +\begin{pmatrix} +f_{n} \\ +f_{n+1} \\ +\end{pmatrix} +\]

+

(Также можно «шагать» в обратном направлении, если обратить эту матрицу.)

+

Обозначим за \(A\) эту матрицу перехода. Чтобы посчитать \(n\)-ное число Фибоначчи, нужно применить \(n\) раз эту матрицу к вектору \((f_0, f_1) = (0, 1)\). Как и в предыдущем случае, мы можем воспользоваться бинарным возведением в степень: операция матричного умножения ассоциативна, поэтому применить \(n\) раз матрицу перехода к вектору равносильно одному применению к нему матрицы перехода, возведённой \(n\)-ную степень. Её можно посчитать за \(O(\log n)\), потому что размер матрицы на этот раз константный.

+

В общем случае, линейная рекуррента \(f_n = a_1 f_{n-1} + a_2 f_{n-2} + \ldots + a_k f_{n-k}\) имеет такую матрицу перехода:

+

\[ +\begin{pmatrix} +0 & 1 & 0 & \ldots & 0 \\ +0 & 0 & 1 & \ldots & 0 \\ +\vdots & \vdots & \vdots & \ddots & \vdots \\ +0 & 0 & 0 & \ldots & 1 \\ +a_k & a_{k-1} & a_{k-2} & \ldots & a_1 \\ +\end{pmatrix} +\]

+

Собственные векторы

+

Очень часто у матриц есть собственные векторы — те, которые не меняют направление при применении этой матрицы к ним.

+

\(Av = kv\), где \(k \neq 0\). Число \(k\) называется собственным числом матрицы.

+

\(Av - kv = (A-kI)v = 0\).

+

Вообще, собственные вектора в линейной алгебре имеют почти такое же значение, как простые числа в арифметике.

+

Линейная независимость

+

Базисом множества называется набор векторов, через линейную комбнацию которых единственным способом можно выразить все вектора этого множества и только их.

+

Базисы есть не только в линейной алгебре. Например, \(\{1, x, x^2\}\) является базисом всех квадратных трёхчленов. Или \(\{\neg, \land, \lor\}\) является базисом всех логических выражений (то есть всё можно выразить через И, ИЛИ и НЕ). В произвольном языке программирования можно выделить какой-то набор команд, через который можно будет написать всё что угодно, и он тоже в этом смысле будет базисом.

+

Система векторов \(\{a_1, a_2, \dots, a_n\}\) называется линейно зависимой, если существует такой набор действительных коэффициентов \((\lambda_1, \lambda_2, \dots, \lambda_n)\), что \(\lambda_j \neq 0\) хотя бы для одного \(j\) и \(\lambda_1 \cdot a_1 + \lambda_2 \cdot a_2 + \dots + \lambda_n \cdot a_n = 0\). В противном случае система называется линейно независимой. Например, базис \(N\)-мерного пространства - это линейно независимый набор из \(N\) векторов. (Доказательство: пусть базис линейно зависим. Тогда вектор нулевой длины можно выразить хотя бы двумя способами)

+

Два столбца (или две строки) матрицы называются линейно зависимыми, если два образованных ими вектора являются линейно зависимыми (коллинеарными). Рангом матрицы \(rang\) \(A\) называется максимальное число \(r\) такое, что можно выбрать \(r\) попарно линейно независимых столбцов или строк в матрице.

+

Системы уравнений и метод Гаусса

+

Иногда встречаются задачи, требующие решения системы линейных уравнений. Очень большая часть из них на самом деле над полем \(\mathbb{Z}_2\) — то есть все числа по модулю 2. К примеру: есть \(n\) переключателей лампочек, каждый активированный переключатель меняет состояние (включает или выключает) какого-то подмножества из \(n\) лампочек. Известно состояние всех лампочек, нужно восстановить состояние переключателей.

+

Нас по сути просят решить следующую систему:

+

\[ +\begin{cases} +a_{11} x_1 + a_{12} x_2 + \ldots + a_{1n} x_n \equiv b_1 \pmod 2\\ +a_{21} x_1 + a_{22} x_2 + \ldots + a_{2n} x_n \equiv b_2 \pmod 2\\ +\ldots \\ +a_{n1} x_1 + a_{n2} x_2 + \ldots + a_{nn} x_n \equiv b_n \pmod 2 +\end{cases} +\]

+

Здесь \(x\) — состояния переключателей, \(b\) — состояния лампочек, \(A\) — информация о том, влияет ли переключатель на лампочку.

+

Метод Крамера неоптимален — там \(O(n^4)\) операций.

+

В таком случае можно значительно ускорить и упростить обычный метод Гаусса:

+
t gauss (matrix a) {
+    for (int i = 0; i < n; i++) {
+        int nonzero = i;
+        for (int j = i+1; j < n; j++)
+            if (a[j][i])
+                nonzero = j;
+        swap(a[nonzero], a[i]);
+        for (int j = 0; j < n; j++)
+            if (j != i && a[j][i])
+                a[j] ^= a[i];
+    }
+    t x;
+    for (int i = 0; i < n; i++)
+        x[i] = a[i][n] ^ a[i][i];
+    return x;
+}
+

Код находит вектор \(x\) из уравнения \(Ax = b\) при условии, что решение существует и единственно. Для простоты кода, предполагается, что вектор \(b\) приписан справа к матрице \(A\).

+

Часто эту систему нужно решить по модулю 2. Тогда код значительно упрощается и ускоряется (опять же, см. Битсет).

+

Оптимизации матричного умножения

+

Наивная реализация матричного умножения из начала статьи неоптимальна из-за паттерна обращения к элементам b: мы каждый раз двигаем указатель в памяти на \(m\) шагов вперёд, что приводит к лишним загрузкам кэш-линий и не позволяет компилятору применить автовекторизацию.

+

Однако, чтобы это исправить, достаточно перед всеми циклами транспонировать b, то есть поменять каждый её \((i, j)\)-тый элемент на \((j, i)\)-тый (пусть, для определённости, \(i < j\)). Такая реализация будет работать в ~5 раз быстрее, и её уже довольно трудно соптимизировать дальше.

+

Дальше трудности возникают лишь при работе большими матрицами, которые не помещаются целиком в кэш — мы ведь для каждой строчки \(C\) каждый раз заново проходимся по всем элементам \(A\) и \(B\). Тогда можно поступить следующим образом: разделить матрицы \(A\) и \(B\) на, например, 4 равные части, и перемножить блочно:

+

\[ +AB = +\begin{pmatrix} +A_{11} & A_{12} \\ +A_{21} & A_{22} \\ +\end{pmatrix} +\times +\begin{pmatrix} +B_{11} & B_{12} \\ +B_{21} & B_{22} \\ +\end{pmatrix} += +\begin{pmatrix} +A_{11} B_{11} + A_{12} B_{21} & A_{11} B_{11} + A_{12} B_{21} \\ +A_{11} B_{11} + A_{12} B_{21} & A_{11} B_{11} + A_{12} B_{21} \\ +\end{pmatrix} +\]

+

При рекурсивном вычислении этой формулы кэш будет использован оптимально вне зависимости от его размера, а также размера кэш-линии (такие алгоритмы называют cache-oblivious).

+

Наука знает и более быстрые способы перемножить матрицы, но они имеют весьма большую константу, и более эффективны только на матрицах от тысяч элементов. На практике достаточно транспонирования одной из матриц и итерации по второй размерности во всех трёх массивах.

+ + diff --git a/ru/link_cut.html b/ru/link_cut.html new file mode 100644 index 0000000..63d7fc2 --- /dev/null +++ b/ru/link_cut.html @@ -0,0 +1,260 @@ + + + + + + + Splay-дерево - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Splay-дерево

+

Splay-дерево (дерево Тарьяна-Слейтора) - двоичное дерево поиска, созданное Робертом Тарьяном и Даниелем Слейтером специально для ускорения работы другой структуры данных, которую мы рассмотрим позже. Splay-дерево позволяет быстро получать доступ к недавно использованным данным (вершинам) засчёт их “поднятия” к корню и имеет амортизированное время работы \(\hat{O}(n\,log\,n)\).

+

Свойства

+

Основная операция в splay-дереве - это операция \(expose(v)\), выполняющая балансировку дерева. Она при помощи серии операций \(rotate\) делает вершину \(v\) корнем дерева. Поворот вокруг ребра \((v, u)\) (где \(u\) - предок \(v\); будем обозначать эту операцию за \(rotate(v)\)) “поднимает” \(v\) на уровень вверх, при этом “опуская” \(u\) и не нарушая свойств двоичного дерева поиска. По сути, splay-дерево - это детерминированное декартово дерево с амортизированным временем работы (из-за чего нельзя эффективно сделать splay-дерево персистентным). Базовая функция для работы splay-дерева - \(find(x)\) - находит вершину \(u\) с максимальным ключом, не большим \(x\). Реализуется \(find(x)\) так же, как и в других деревьях поиска - обычным спуском, но после обязательно нужно выполнить \(expose(u)\). Через \(find(x)\) реализуются операции \(split\) и \(merge\): + \(split(x, k)\) - так же, как и в декартовом дереве, принимает исходное дерево \(x\) и возвращает два дерева \(l\) и \(r\), причём все ключи в \(l\) меньше \(k\), а все ключи в \(r\) - не меньше \(k\). Выполним операцию \(find(k)\) в дереве \(x\), после которой найденная вершина \(u\) окажется корнем \(x\); заметим, что тогда ключи всех вершин в поддереве левого сына \(u\) (обозначим его за \(l\)) будут меньше \(k\), то есть мы можем “отрезать” левое поддерево от \(u\) и вернуть пару \({l, u}\). + \(merge(l, r)\) - принимает два дерева \(l\) и \(r\) (причём все ключи в \(l\) меньше всех ключей в \(r\)) и возвращает новое дерево \(x\), состоящее из всех вершин \(l\) и \(r\). При помощи операции \(find\) найдём в \(r\) вершину с минимальным ключом, после чего она станет корнем \(r\); заметим, что у неё не будет левого сына, тогда мы просто сделаем этим левым сыном всё дерево \(l\). Остальные операции реализуются через эти три (например, \(add(x)\), \(remove(x)\) и т.д).

+

Реализация \(expose(v)\)

+

Если реализовать \(expose(v)\) простой серией поворотов \(rotate(v)\) (пока \(v\) не станет корнем), то можно подобрать пример, когда она всегда будет отрабатывать за \(O(n)\) (например, если дерево - это один путь, подвешенный за конец, и \(find\) каждый раз затрагивает другой конец пути). Для достижения асимптотики \(\hat{O}(n\,log\,n)\) при реализации \(expose(v)\) используются три вспомогательные операции - zig, zig-zig и zig-zag - которые являются комбинациями операций \(rotate\).

+

\(zig-zig(v)\)

+

Обозначим за \(p(x)\) предка вершины \(x\), а за \(L(x)\) - функцию, возвращающую 0, если \(x\) - левый сын \(p(x)\) или \(p(x)\) не существует, и 1, если \(x\) - правый сын \(p(x)\). Операция \(zig-zig(v)\) применяется в том случае, если \(v\) не является непосредственным сыном корня, и \(L(x)=L(p(x))\). \(zig-zig(v)\) состоит из двух операций - \(rotate(v)\) и \(rotate(w)\) (где \(w\) - предок \(v\) до выполнения \(zig-zig(v)\)) - которые выполняются именно в таком порядке.

+

\(zig-zag(v)\)

+

Операция \(zig-zag(v)\) применяется в том случае, если \(v\) не является непосредственным сыном корня, и \(L(x)\neq L(p(x))\). \(zig-zig(v)\) состоит из двух операций - \(rotate(w)\) и \(rotate(v)\) (где \(w\) - предок \(v\) до выполнения \(zig-zag(v)\)) - которые выполняются именно в таком порядке.

+

\(zig(v)\)

+

Операция \(zig(v)\) применяется в том случае, если \(v\) является непосредственным сыном корня, и представляет собой одну операцию \(rotate(v)\).

+

Итоговый алгоритм

+

Таким образом, для реализации \(expose(v)\) мы просто в цикле до тех пор, пока \(v\) не станет корнем, рассматриваем три случая: 1. \(v\) - непосредственный сын корня. Тогда выполняем \(zig(v)\). 2. \(L(v) = L(p(v))\) - тогда выполняем \(zig-zig(v)\). 3. \(L(v) \neq L(p(v)\) - тогда выполняем \(zig-zag(v)\).

+

Неявное splay-дерево

+

Давайте считать, что splay-дерево хранит некоторый массив, причём все элементы массива, за которые отвечают вершины в левом поддереве \(x\), находятся левее элемента \(x\), а все элементы в правом поддереве - правее \(x\) - как в декартовом дереве по неявному ключу. Тогда \(find(k)\) будет искать в дереве вершину, отвечающую за \(k\)-й слева элемент этого массива. Таким образом, неявное splay-дерево решает те же задачи, что и неявное декартово дерево. На практике splay-дерево обычно оказывается в несколько раз быстрее, чем декартово дерево, из-за малой константы (см. время работы), поэтому если не получается упихать декартово дерево, есть много времени и опыт написания splay-дерева, то можно попытаться использовать его (однако, опять же, персистентность прикрутить не получится).

+

Реализация

+
struct node {
+    int x, sz = 1;
+    int p = -1, l = -1, r = -1;
+    bool lft = 0;
+
+    node() {}
+
+    node(int x) : x(x) {}
+};
+
+node v[maxn];
+int root = -1, mx = 0;
+
+int gsz(int x) {
+    return (x == -1 ? 0 : v[x].sz);
+}
+
+void upd(int x) {
+    v[x].sz = 1 + gsz(v[x].l) + gsz(v[x].r);
+}
+
+// Отсоединяет вершину от предка, обновляя необходимые параметры
+
+void disconnect(int x) {
+    if (x == -1 || v[x].p == -1) return;
+    if (v[v[x].p].l == x) v[v[x].p].l = -1;
+    else v[v[x].p].r = -1;
+    upd(v[x].p); v[x].p = -1; v[x].lft = 0;
+}
+
+// Делает вершину x левым или правым (в зависимости от lft) сыном y
+
+void connect(int x, int y, bool lft) {
+    if (x == -1 || y == -1) return;
+    v[x].lft = lft; v[x].p = y;
+    if (lft) v[y].l = x;
+    else v[y].r = x;
+    upd(y);
+}
+
+void rotate(int x) {
+    int y = v[x].p, z = v[y].p, yl = v[y].lft, xl = v[x].lft;
+    disconnect(x); disconnect(y);
+    if (xl) {
+        int b = v[x].r;
+        disconnect(b); connect(b, y, 1); connect(y, x, 0);
+    } else {
+        int b = v[x].l;
+        disconnect(b); connect(b, y, 0); connect(y, x, 1);
+    }
+    connect(x, z, yl);
+}
+
+// Если вместо expose написать splay, то больше людей поймут, что вы написали splay-дерево, посмотрев код посылки на кф 
+
+void splay(int x) {
+    if (x == -1) return;
+    while (v[x].p != -1) {
+        int y = v[x].p;
+        if (v[y].p == -1) {
+            rotate(x);
+            break;
+        }
+        if (v[x].lft == v[y].lft) {
+            rotate(y);
+            rotate(x);
+        } else {
+            rotate(x);
+            rotate(x);
+        }
+    }
+    root = x;
+}
+
+// Функция find, которую я зачем-то назвал get
+
+int get(int x, int k) {
+    if (x == -1) return -1;
+    while (1) {
+        if (gsz(v[x].l) == k)
+            break;
+        if (k < gsz(v[x].l)) {
+            x = v[x].l;
+        } else {
+            k -= (gsz(v[x].l) + 1);
+            x = v[x].r;
+        }
+    }
+    splay(x);
+    return x;
+}
+
+pair<int, int> split(int x, int k) {
+    if (x == -1) return {-1, -1};
+    if (k == 0) return {-1, x};
+    int aut = get(x, k - 1);
+    int gg = v[aut].r;
+    disconnect(gg);
+    return {aut, gg};
+}
+
+int merge(int l, int r) {
+    if (l == -1) return r;
+    if (r == -1) return l;
+    int bs = gsz(l);
+    int aut = get(l, bs - 1);
+    connect(r, aut, 0);
+    return aut;
+}
+

Это первая и последняя реализация splay-дерева, которую я писал, поэтому код такой длинный. Самое болезненное - дебагать обновление параметров вершин при переподвешивании.

+

Время работы

+

Сперва введём необходимые обозначения: \(t_i\) - это время работы splay-дерева на \(i\)-м запросе, \(T\) - это суммарное время работы дерева на всех запросах. Доказательство времени работы будем проводить при помощи метода потенциалов. Заключается он в следующем: пусть есть структура данных размера \(n\), и мы хотим оценить амортизированное время её работы на \(q\) запросах. Для каждого из \(q+1\) состояний структуры введём величину \(\Phi_{i}\) - потенциал. Также введём величину \(a_i=t_i+\Phi_{i}-\Phi_{i-1}\) - стоимость \(i\)-й операции. Утверждается, что если \(a_i=O(f(n,q))\) для некоторой функции \(f\) и \(\Phi_{i}=O(n\cdot f(n,q))\), то \(T=O(f(n,q))\). Доказательство этого факта довольно простое, мы не будем его приводить, и перейдём непосредственно к доказательству времени работы splay-дерева. Обозначим за \(C(v)\) размер поддерева вершины \(v\). Тогда потенциалом \(\Phi\) нашего дерева назовём сумму двоичных логарифомв \(C(v)\) по всем вершинам \(v\) дерева (для краткости обозначим \(r(v)=log_2\,C(v)\)). Заметим, что эта величина неотрицательная и не больше \(O(n\,log\,n)\). ааааааа что за жесть интересно зачем я вообще это пишу если есть викиконспекты (видимо потому что прилагать чистый код было бы тупо)

+

Link-cut tree

+

Рассмотрим следующую задачу:

+
+

Дан лес из \(n\) вершин. Приходят запросы трёх типов: + \(link(u,\,v)\) - соединить вершины \(u\) и \(v\) ребром + \(cut(u,\,v)\) - удалить ребро между вершинами \(u\) и \(v\) + \(get(u,\,v)\) - найти длину пути между \(u\) и \(v\) Нужно ответить на все запросы третьего типа. Гарантируется, что после каждого запроса граф остаётся лесом.

+
+

Существует структура данных, которая решает эту задачу, с амортизированным временем работы \(O(n\,log\,n)\) - Link-Cut Tree, изобретённая Тарьяном и Слейтором (именно для достижения такой оценки и создавалось splay-дерево).

+

Основная идея

+

Подвесим каждое дерево нашего леса за некоторую вершину и ориентируем рёбра в сторону корня. Введём операцию \(expose(x)\), которая делает вершину \(x\) корнем своего дерева. Пусть старым корнем была вершина \(y\), и мы применили операцию \(expose(x)\). После этого нам нужно сменить ориентацию каждого ребра на пути между \(x\) и \(y\) на противоположную, чтобы ничего не сломалось. Рассмотрим, как мы тогда будем обрабатывать наши запросы: + \(link(u, v)\) - в дереве вершины \(u\) выполним операцию \(expose(u)\), в дереве вершины \(v\) - операцию \(expose(v)\), и затем просто соединим \(u\) с \(v\) ребром, и вершина, в сторону которой будет ориентировано новое ребро, станет новым корнем дерева. + \(cut(u, v)\) - просто удалим ребро между вершинами \(u\) и \(v\); вершина, в сторону которой было ориентировано ребро, останется в старом дереве, а другая вершина окажется в новом, её и следует назначить корнем нового дерева. + \(get(u,\,v)\) - выполним операцию \(expose(u)\) и найдём глубину вершины \(v\). Если реализовать операцию \(expose\) напрямую, то каждый запрос в худшем случае будет обрабатыватся за \(O(n)\); однако при помощи структур данных можно добиться амортизированного \(O(n\,log^2\,n)\) или даже \(O(n\,log\,n)\).

+

Ускорение работы

+

Мы описали в общих чертах алгоритм; теперь нужно понять, как выполнять эти операции быстро. Оказывается, что если мы разобьём каждое дерево на набор вершинно непересекающихся вертикальных путей и будем поддерживать эти пути при помощи декартовых деревьев по неявному ключу, то суммарная асимптотика алгоритма будет равна \(O(n\,log^2\,n)\), что будет доказано в конце. Однако если вместо декартовых деревьев использовать splay-деревья, то асимптотика будет даже \(O(n\,log\,n)\), и доказательство этого факта я не знаю. Разберём, как же работает быстрый алгоритм. Изначально мы строим декомпозицию каждого дерева на пути, причём не важно, какие именно, поэтому можно просто сделать \(N\) путей длины 1. Пути мы храним в виде множества splay-деревьев, и для каждого пути мы храним указатель на предка самой высокой вершины. Когда нам нужно выполнить операцию \(expose(x)\), мы “прыгаем” вверх по путям, попутно сливая их в один (то есть из вершины \(x\) мы перемещаемся в верхнюю вершину пути, в котором лежит \(x\); затем мы перемещаемся на одну вершину вверх и новый путь со старым (причём может оказаться так, что мы попали не на конец пути, а на середину, и тогда придётся “обрезать” путь в этом месте), затем прыгаем вверх по этому пути и т.д.). Затем просто выполняем разворот сегмента полученного объединённого пути, отвечающего за путь \((x; y)\). Казалось бы - совсем не ясно, почему этот алгоритм работает быстрее предыдущего. Однако выясняется очень любопытный факт: какие бы запросы не приходили, структура как бы сама оптимизирует себя, и итоговая асимптотика в любом случае оказывается \(O(n\,log^{(2)}\,n)\).

+

TODO

+ + diff --git a/ru/links.html b/ru/links.html new file mode 100644 index 0000000..703e622 --- /dev/null +++ b/ru/links.html @@ -0,0 +1,123 @@ + + + + + + + Ссылки - Алгоритмика + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Ссылки

+

Русскоязычные ресурсы:

+ +

Англоязычные ресурсы:

+ +

Платформы для соревнований:

+
    +
  • CodeForces
  • +
  • AtCoder
  • +
  • TopCoder
  • +
  • HackerRank
  • +
  • CodeChef
  • +
+

Архивы задач:

+
    +
  • Timus
  • +
  • Informatics
  • +
+

Студенческие сборы:

+
    +
  • Петрозаводские сборы
  • +
  • Moscow Workshops ICPC
  • +
+

Олимпиадные школы:

+
    +
  • Летняя Компьютерная Школа
  • +
  • Летняя Олимпиадная Школа (МФТИ)
  • +
  • Школа Олимпиадной Подготовки (Иннополис)
  • +
  • Летняя школа по компьютерным наукам (ВШЭ)
  • +
  • Зимняя Компьютерная Школа (МФТИ)
  • +
+

Кружки для школьников в Москве:

+
    +
  • Tinkoff Generation
  • +
  • Московская школа программистов
  • +
  • Яндекс.Лицей (8-9 класс, Python)
  • +
  • Школа Летово (5-8 класс)
  • +
  • Кружки в ЦПМ
  • +
  • 1С: Клуб Программистов
  • +
  • Комбалг
  • +
+

Школьные олимпиады:

+
    +
  • Всероссийская олимпиада школьников по программированию
  • +
  • Открытая олимпиада школьников по программированию
  • +
  • Московская олимпиада школьников
  • +
  • Олимпиада Университета Иннополис
  • +
  • Технокубок
  • +
+

Студенческие олимпиады:

+
    +
  • International Collegiate Programming Contest
  • +
+

Документы:

+
    +
  • IOI Syllabus
  • +
  • Перечень олимпиад РСОШ
  • +
+

Паблики с мемами:

+ + + diff --git a/ru/matching.html b/ru/matching.html new file mode 100644 index 0000000..d61128a --- /dev/null +++ b/ru/matching.html @@ -0,0 +1,224 @@ + + + + + + + Паросочетания - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Паросочетания

+

Задача. Пусть есть \(n\) мальчиков и \(m\) девочек. Про каждого мальчика и про каждую девочку известно, с кем они не против танцевать. Нужно составить как можно больше пар, в которых партнёры хотят танцевать друг с другом.

+

+

Формализуем эту задачу, представив мальчиков и девочек как вершины в двудольном графе, рёбрами которого будет отношение «могут танцевать вместе».

+

Паросочетанием \(M\) называется набор попарно несмежных рёбер графа (иными словами, любой вершине графа должно быть инцидентно не более одного ребра из \(M\)).

+

Все вершины, у которых есть смежное ребро из паросочетания (т.е. которые имеют степень ровно один в подграфе, образованном \(M\)), назовём насыщенными этим паросочетанием.

+

Мощностью паросочетания назовём количество рёбер в нём. Наибольшим (максимальным) паросочетанием назовём паросочетание, мощность которого максимальна среди всех возможных паросочетаний в данном графе, а совершенным — где все вершины левой доли им насыщенны.

+

Паросочетания можно искать в любых графах, однако этот алгоритм неприятно кодить, и он работает за \(O(n^3)\), так что сегодня мы сфокусируемся только на двудольных графах. Будем в дальнейшем обозначать левую долю графа как \(L\), а правую долю как \(R\).

+

Цепью длины \(k\) назовём некоторый простой путь (т.е. не содержащий повторяющихся вершин или рёбер), содержащий ровно \(k\) рёбер.

+

Чередующейся цепью относительно некоторого паросочетания назовём простой путь длины \(k\) в которой рёбра поочередно принадлежат/не принадлежат паросочетанию.

+

Увеличивающей цепью относительно некоторого паросочетания назовём чередующуюся цепь, у которой начальная и конечная вершины не принадлежат паросочетанию.

+

+

Здесь красными помечены вершины паросочетания, а в графе есть увеличивающая цепь: \(1 \to 8 \to 4 \to 6 \to 3 \to 7\).

+

Зачем нужны увеличивающие цепи? Оказывается, можно с их помощью увеличивать паросочетание на единицу (отсюда и название). Можно взять такой путь и провести чередование — убрать из паросочетания все рёбра, принадлежащие цепи, и, наоборот, добавить все остальные. Всего в увеличивающей цепи нечетное число рёбер, а первое и последнее были не в паросочетании. Значит, мощность паросочетания увеличилась ровно на единицу.

+

В примере добавятся синие рёбра \((1, 8)\), \((3, 7)\) и \((4, 6)\), а удалятся красные \((3, 6)\) и \((4, 8)\). С ребром \((2, 5)\) ничего не случится — оно не в увеличивающей цепи. Таким образом, размер паросочетания увеличится на единицу.

+

Алгоритм Куна в этом и заключается — будем искать увеличивающую цепь, пока ищется, и проводить чередование в ней. Увеличивающие цепи удобны тем, что их легко искать: можно просто запустить поиск пути из произвольной свободной вершины из левой доли в какую-нибудь свободную вершину правой доли в том же графе, но в котором из правой доли можно идти только по рёбрам паросочетания (то есть у вершин правой доли будет либо одно ребро, либо ноль). Это можно делать как угодно (для упражнения автор рекомендует явно строить такой граф, искать путь и явно проводить чередования), однако устоялась эффективная реализация в виде dfs на 20 строчек кода, приведённая ниже.

+
const int maxn;
+
+vector<int> g[maxn]; // будем хранить только рёбра из левой доли в правую
+int mt[maxn]; // с какой вершиной сматчена вершина правой доли (-1, если ни с какой)
+bool used[maxn]; // вспомогательный массив для поиска пути dfs-ом
+
+// dfs возвращает, можно ли найти путь из вершины v
+// в какую-нибудь вершину правой доли
+// если можно, то ещё и проводит чередование
+bool dfs(int v) {
+    if (used[v])
+        return false;
+    used[v] = true;
+    for (int u : g[v]) {
+        // если вершина свободна, то можно сразу с ней соединиться
+        // если она занята, то с ней можно соединиться только тогда,
+        // когда из её текущей пары можно найти какую-нибудь другую вершину
+        if (mt[u] == -1 || dfs(mt[u])) {
+            mt[u] = v;
+            return true;
+        }
+    }
+    return false;
+}
+
+
+// где-то в main:
+
+memset(mt, -1, sizeof mt);
+for (int i = 0; i < n; i++) {
+    memset(used, 0, sizeof mt);
+    if (dfs(i))
+        cnt++;
+}
+

Корректность

+

Для доказательства алгоритма нам будет достаточно ещё доказать, что если увеличивающие цепи уже не ищутся, то паросочетание в принципе нельзя увеличить.

+

Теорема (Бержа). Паросочетание без увеличивающих цепей является максимальным.

+

Доказательство проведём от противного: пусть есть два паросочетания вершин \(|A| \leq |B|\), и для \(A\) нет увеличивающих путей, и покажем, как найти этот путь и увеличить \(A\) на единицу.

+

Раскрасим ребра из паросочетания, соответствующего \(A\) в красный цвет, \(B\) — в синий, а ребра из обоих паросочетаний — в пурпурный. Рассмотрим граф из только красных и синих ребер. Любая компонента связности в нём представляет собой либо путь, либо цикл, состоящий из чередующихся красных и синих ребер. В любом цикле будет равное число красных и синих рёбер, а так как всего синих рёбер больше, то должен существовать путь, начинающийся и оканчивающийся синим ребром — он и будет увеличивающей цепью для \(A\), а значит \(A\) не оптимальное, и мы получили противоречие.

+

Скорость работы

+

Такой алгоритм ровно \(n\) раз ищет увеличивающий путь, каждый раз просматривая не более \(m\) рёбер, а значит работает за \(O(nm)\).

+

Что примечательно, его можно не бояться запускать на ограничениях и побольше (\(n, m \approx 10^4\)), потому что для него есть мощные неасимптотические оптимизации:

+
    +
  • Eго можно жадно инициализировать (просто заранее пройтись по вершинам левой доли и сматчить их со свободной вершиной правой, если она есть).

  • +
  • Можно не заполнять нулями на каждой итерации массив used, а использовать следующий трюк: хранить в нём вместо булева флага версию последнего изменения, а конкретно – номер итерации, на которой это значение стало true. Если этот номер меньше текущего номера итерации, то мы можем воспринимать это значение как false. В каком-то смысле это позволяет эмулировать очищение массива за константу.

  • +
  • Очень часто граф приходит из какой-то другой задачи, природа которой накладывает ограничения на его вид. Например, в задачах на решетках (когда есть двумерный массив, и соседние клетки связаны друг с другом) граф двудольный, но степень каждой вершины маленькая, и граф имеет очень специфичную структуру, и на нём алгоритм Куна работает быстрее, чем ожидается из формулы \(n \times m\). Контрпримеры в таких задачах на самом деле почти всегда можно сгенерировать, но авторы редко так заморачиваются.

  • +
+

Вообще говоря, увлекаться ускорением алгоритма Куна не стоит — существует более асимптотически быстрый алгоритм. Задача нахождения максимального паросочетания — частный случай задачи о максимальном потоке, и если применить алгоритм Диница к двудольным графам с единичной пропускной способностью, то выясняется, что работать он будет за \(O(m \sqrt n)\).

+

Покрытие путями DAG-а

+

Сводить задачи к поиску максимального паросочетания обычно не очень трудно, но в некоторых случаях самому додуматься сложно. Разберём одну такую известную задачу. Дан ориентированный ациклический граф \(G\) (англ. directed acyclic graph). Требуется покрыть его наименьшим числом путей, то есть найти наименьшее множество простых путей, где каждая вершина принадлежит ровно одному пути.

+

Построим соответствующие изначальному графу \(G\) два двудольных графа \(H\) и \(\overline{H}\) следующим образом:

+
    +
  • В каждой доле графа \(H\) будет по \(n\) вершин. Обозначим их через \(a_i\) и \(b_i\) соответственно.
  • +
  • Для каждого ребра \((i, j)\) исходного графа \(G\) проведём соответствующее ребро \((a_i, b_j)\) в графе \(H\).
  • +
  • Теперь из графа \(H\) сделаем граф \(\overline{H}\), добавив обратное ребро \((b_i, a_i)\) для каждого \(i\).
  • +
+

Если мы рассмотрим любой путь \(v_1, v_2, \ldots, v_k\) в исходном графе \(G\), то в графе \(\overline{H}\) ему будет соответствовать путь \(a_{v_1}, b_{v_2}, a_{v_2}, b_{v_3}, \ldots, a_{v_{k-1}}, b_{v_k}\). Обратное тоже верно: любой путь, начинающийся в левой доле \(\overline{H}\) и заканчивающийся в правой будет соответствовать какому-то пути в \(G\).

+

Итак, есть взаимно однозначное соответствие между путями в \(G\) и путями \(\overline{H}\), идущими из левой доли в правую. Заметим, что любой такой путь в \(\overline{H}\) — это паросочетание в \(H\) (напомним, это \(\overline{H}\) без обратных рёбер). Получается, любому пути из \(G\) можно поставить в соответствие паросочетание в \(H\), и наоборот. Более того, непересекающимся путям в \(G\) соответствуют непересекающиеся паросочетания в \(H\).

+

Заметим, что если есть \(p\) непересекающихся путей, покрывающих все \(n\) вершин графа, то они вместе содержат \(r = n - p\) рёбер. Отсюда получаем, что чтобы минимизировать число путей \(p\), мы должны максимизировать число рёбер \(r\) в них.

+

Мы теперь можем свести задачу к нахождению максимального паросочетания в двудольном графе \(H\). После нахождения этого паросочетания мы должны преобразовать его в набор путей в \(G\). Это делается тривиальным алгоритмом: возьмем \(a_1\), посмотрим, с какой \(b_k\) она соединена, посмотрим на \(a_k\) и так далее. Некоторые вершины могут остаться ненасыщенными — в таком случае в ответ надо добавить пути нулевой длины из каждой из этих вершин.

+

Лемма Холла

+

Лемма Холла (или: теорема о свадьбах) — очень удобный критерий в задачах, где нужно проверить, что паросочетание существует, но при этом не требуется строить его явно.

+

+

Лемма Холла. Полное паросочетание существует тогда и только тогда, когда любая группа вершин левой доли соединена с не меньшим количеством вершин правой доли.

+

Доказательство. В одну сторону понятно — если совершенное паросочетание есть, то для любого подмножества вершин левой доли можно взять вершины правой, соединенные с ним паросочетанием.

+

В другую сложнее — нужно воспользоваться индукцией. Будем доказывать, что если паросочетание не полное, то можно в таком графе найти увеличивающую цепь, и с её помощью увеличить паросочетание на единицу.

+

База индукции: одна вершина из \(L\), которая по условию соединена с хотя бы одной вершиной из \(R\).

+

Индукционный переход: пусть после \(k < n\) шагов построено паросочетание \(M\). Докажем, что в \(M\) можно добавить вершину \(v\) из \(L\), не насыщенную паросочетанием.

+

Рассмотрим множество вершин \(H\) — все вершины, достижимые из \(x\), если можно ходить из правой доли в левую только по рёбрам паросочетания, а из левой в правую — по любым (мы такой граф по сути строим, когда ищем увеличивающую цепь в алгоритме Куна)

+

Тогда в \(H\) найдется вершина \(y\) из \(R\), не насыщенная паросочетанием. Иначе, если такой вершины нет, то получается, что если рассмотреть вершины \(H_L\) (вершины левой доли, насыщенные паросочетанием), то для них не будет выполнено условие, что \(|H_L| \leq |N(H_L)|\) (здесь \(N(X)\) — множество вершин, соединенным паросочетанием с \(X\)).

+

Тогда должен существовать путь из \(x\) в \(y\), и он будет увеличивающим для паросочетания \(M\), потому что из \(R\) в \(L\) мы всегда шли только по ребрам паросочетания. Проведя чередование вдоль этого пути, получим большее паросочетание, следовательно предположение индукции верно.

+

Минимальное вершинное покрытие

+

Задача. Дан граф. Назовем вершинным покрытием такое множество вершин, что каждое ребро графа инцидентно хотя бы одной вершине из множества. Необходимо найти вершинное покрытие наименьшего размера.

+

Следует заметить, что в общем случае это очень сложная задача, но для двудольных графов она имеет достаточно простое решение.

+

Теорема. \(\mid V_{min} \mid \le \mid M \mid\), где \(V_{min}\) — минимальное вершинное покрытие, а \(M\) — максимальное паросочетание.

+

Доказательство. \(\mid V_{min} \mid \ge \mid M \mid\), поскольку \(M\) — множество независимых ребер. Теперь приведем алгоритм, который строит вершинное покрытие размера \(\mid M \mid\). Очевидно, оно будет минимальным.

+

Алгоритм. Мысленно ориентируем ребра графа: ребра из \(M\) проведем из правой доли в левую, остальные — из левой в правую, после чего запустим обход в глубину из всех вершин левой доли, не включенных в \(M\).

+

+

Заметим, что граф разбился на несколько множеств: \(L^+, L^-, R^+, R^-\), где “плюсовые” множества — это множества посещенных в процессе обхода вершин. В графе такого вида не бывает ребер \(L^+ \rightarrow R^-\), \(L^- \leftarrow R^+\) по очевидным соображениям. Ребер \(L^+ \leftarrow R^-\) не бывает, потому что в противном случае паросочетание \(M\) не максимальное — его можно дополнить ребрами такого типа.

+

\[ +L^- \cup R^+ = V_{min} +\]

+

Понятно, что данное множество покроет все ребра. Осталось выяснить, почему \(L^- \cup R^+\). Это верно потому, что \(L^- \cup R^+\) покрывает все ребра \(M\) ровно один раз (ведь ребра \(L^- \rightarrow R^+\) не принадлежат \(M\)), а также потому, что в нем нет вершин, не принадлежащих \(M\) (для \(L^-\) это справедливо по определению, для \(R^+\) можно провести доказательство от противного с использованием чередующихся цепей).

+

Упражнение. Подумайте, как это можно применить к решению задачи о нахождении максимального независимого множества.

+

Для ноулайферов: матроиды

+

С весьма большой вероятностью матроиды вам никогда не пригодятся в школьных олимпиадах, однако, если вам совсем нечего делать, можете про них почитать.

+

Математика вообще занимается тем, что обобщает всякие объекты и старается формулировать все теоремы максимально абстрактно. Так, концепцию хороших подмножеств (паросочетаний) обобщает понятие матроида. Практическое применение — жадный алгоритм Радо-Эдмондса, который нужен для обоснования большого числа жадников, где нужно набрать какое-то подмножество минимального / максимального веса. Сам алгоритм максимально простой: давайте отсортируем эти объекты по весу и будем добавлять их в таком порядке в наше хорошее множество, если оно после добавления остается хорошим.

+

Применимо к паросочетаниям: пусть у вершин левой доли есть вес, и нам нужно набрать максимальное паросочетание минимального веса. Тогда выясняется, что можно просто отсортировать вершины левой доли по весу и пытаться в таком порядке добавлять их в паросочетание стандартным алгоритмом Куна.

+ + diff --git a/ru/matroid.html b/ru/matroid.html new file mode 100644 index 0000000..0260c17 --- /dev/null +++ b/ru/matroid.html @@ -0,0 +1,198 @@ + + + + + + + Матроиды - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Матроиды

+

Понятие матроида нужно, чтобы придумывать и доказывать некоторые жадные алгоритмы в задачах, где нужно набрать какое-то множество объектов максимального веса. Например, минимальный остов, максимальное вершинно-взвешенное паросочетание или выполнение заказов с ограничениями по времени.

+

Если вы не специалист по абстрактной алгебре, рекомендуется подсматривать примеры матроидов в конце статьи.

+

Определение

+

Матроидом называется пара \((X, I)\), где \(X\) — множество элементов, называемое носителем матроида, а \(I\) — некоторое множество подмножеств \(X\), называемое семейством независимых множеств. В матроиде должны выполняться следующие свойства:

+
    +
  • Пустое множество является независимым: \(\varnothing \in I\)

  • +
  • Любое подмножество независимого множества тоже независимо:

    +

    \[ +A \subset B, B \in I \implies A \in I +\]

  • +
  • Если в независимом множестве \(A\) меньше элементов, чем в независимом множестве \(B\), то будет существовать элемент из \(B\), дополняющий \(A\) до независимого множества размера \(|A|+1\):

    +

    \[ +A, B \in I, |A| < |B| \implies \exists x \in B \setminus A: A \cup \{x\} \in I +\]

  • +
+

Матроид называется взвешенным, если на нем есть аддитивная весовая функция: \(w(A) = \sum w(a_i)\).

+

Алгоритм Радо-Эдмондса

+

Пусть нам нужно найти независимое множество, которое будет включать как можно больше элементов и при этом иметь как можно меньший вес. Утверждается, что можно просто отсортировать элементы по возрастанию весов и пытаться в таком порядке добавлять их в ответ:

+
X.sort()
+s = []
+for x in X:
+    if good(s + [x]):
+        s += [x]
+

Здесь под good имеется в виду \(s \cup x \in I\).

+

Корректность этого алгоритма для произвольного матроида следует из следующего утверждения:

+

Теорема Радо-Эдмондса. Пусть \(A \in I\) — множество минимального веса среди всех независимых подмножеств \(X\) мощности \(k\). Возьмем \(x: A \cup x \in I,\;x \notin A,\;w(x)\) — минимальна. Тогда \(A \cup x\) — множество минимального веса среди независимых подмножеств \(X\) мощности \(k + 1\).

+

Доказательство:

+

Рассмотрим \(B\) — множество минимального веса среди независимых подмножеств \(X\) мощности \(k + 1\).

+

Из свойств матроида: \(\exists y \in B \setminus A : A \cup y \in I\).

+

Тогда верны два неравенства:

+

\[ +\begin{cases} +w(A \cup y) = w(A) + w(y) \geq w(B) \implies w(A) \geq w(B) - w(y) \\ +w(B \setminus y) = w(B) - w(y) \geq w(A) \implies w(A) \leq w(B) - w(y) +\end{cases} +\]

+

Величина \(w(A)\) с двух сторон ограничивает величину \(w(B) - w(y)\). Значит, они равны. Cледовательно, \(w(A \cup y) = w(A) + w(y) = w(B)\).

+

Получаем, что если объединить множество \(A\) с \(x\) — минимальным из таких, что \(A \cup x \in I\), — то получим множество минимального веса среди независимых подмножеств \(X\) мощности \(k + 1\).

+

Иными словами, если у нас есть оптимальное \(k\)-элементарное независимое множество, то мы можем индуктивно построить оптимальное \((k+1)\)-элементарное множество, добавив к нему минимальный элемент, оставляющий построенное множество независимым.

+

Примеры

+

Для доказательства жадников нужно просто (а иногда не просто) показать, что рассматриваемое множество является матроидом. Первые два критерия доказывать тривиально, третий сложнее.

+

Минимальный остов

+

Рассмотрим неориентированный граф \(G = (V, E)\). Пусть \(I\) — множество лесов графа (ациклических подмножеств \(E\)). Тогда \(M = (E, I)\) является матроидом:

+
    +
  • Граф без ребер является лесом.
  • +
  • Если удалить из леса ребра, он останется лесом.
  • +
  • Пусть есть два леса \(|A| \leq |B|\). В \(A\) будет \(|V| - |A|\) компонент связности, в \(B\) будет \(|V|-|B|\) компонент связности. Так как в \(B\) компонент связности меньше, то будет существовать какое-то ребро \(x\), связывающее две компоненты связности из \(A\). Его и возьмем: \(A \cup \{x\}\) тоже будет лесом, так как \(x\) только соединило две разные компоненты связности.
  • +
+

Применив к этому матроиду теорему Радо-Эдмондса, мы получаем обоснование алгоритма Крускала для нахождения минимального остова.

+

Расписания

+

Пусть у нас есть \(n\) заданий, на выполнение каждого требуется \(1\) час. Награда за выполнение \(i\)-го задания не позже \(d_i\)-того часа равна \(w_i\). В один час разрешено сделать только одно задание. Цель — максимизировать сумму наград.

+

Назовём правильными те наборы заданий, для которых существует порядок, при котором можно их всех успеть сделать до их дедлайнов. Проверять фиксированный набор можно так: отсортировать по дедлайнам (\(d_i\)) и проверить, что \(d_i \geq i\) для всех \(i\).

+

Тогда \(M =\) (множество всех заданий, множество правильных наборов заданий) является матроидом:

+
    +
  • Пустой набор заданий всегда можно сделать.
  • +
  • Если у нас стало меньше заданий, то их сделать мы тоже успеем.
  • +
  • Пусть есть два правильных набора \(|A| \leq |B|\). Тогда в \(B\) будет существовать задание \(x\) с дедлайном позже \(|A|\). Все задания \(A\) можно сделать не позже \(|A|\)-го часа, а в \((|A|+1)\)-й час будем делать \(x\). Значит, \(A \cup x\) — тоже правильный набор.
  • +
+

Значит, можно отсортировать задания по убыванию их стоимости и пытаться в таком порядке добавлять в ответ, проверяя правильность какой-нибудь структурой данных.

+

Паросочетания

+

Рассмотрим двудольный граф \(G = (L, R, E)\). Пусть \(I\) — множество наборов вершин левой доли, которых можно покрыть каким-нибудь паросочетанием. Тогда \(M = (L, I)\) является матроидом:

+
    +
  • Любое паросочетание покрывает пустое множество вершин.
  • +
  • Исходное паросочетание покрывает также и любое подмножество исходных вершин.
  • +
  • Пусть есть два множества вершин \(|A| \leq |B|\). Раскрасим ребра из паросочетания, соответствующего \(A\) в красный цвет, \(B\) — в синий, а ребра из обоих паросочетаний — в пурпурный. Рассмотрим граф из красных и синих ребер. Любая компонента связности в нём представляет собой либо путь, либо цикл, состоящий из чередующихся красных и синих ребер. В любом цикле будет равное число красных и синих ребер, а так как всего синих ребер больше, то должен существовать путь, начинающийся и оканчивающийся синим ребром. Поменяем в этом пути красный и синий цвета и сделаем пурпурные ребра обратно красными. Теперь в графе из красных ребер на одно ребро больше, а значит к множеству \(A\) добавилась какая-то вершина из левой доли, принадлежавшая ранее \(B\).
  • +
+

Пусть у вершин левой доли есть веса, и нам нужно найти максимальное паросочетание минимального веса. Согласно теореме, мы можем отсортировать вершины левой доли по их весам, а затем пытаться добавлять их в паросочетание. Сделать это можно стандартным алгоритмом Куна.

+

Линейно независимые вектора

+

Множество \(n\)-мерных векторов называется линейно независимым, если никакой его вектор нельзя получить линейной комбинацией (взвешенной суммой) других.

+

Рассмотрим какое-то множество векторов \(V\). Пусть \(I\) — множество всех его линейно независимых подмножеств. Тогда \(M = (V, I)\) является матроидом:

+
    +
  • В пустом множестве нельзя получить какой-либо вектор.
  • +
  • Подмножество линейно независимого множество тоже линейно независимо: способов набрать вектора стало ещё меньше.
  • +
  • Пусть есть два линейно независимых множества \(A\) и \(B\), и \(B\) больше. Пусть в нём нельзя выбрать вектор, совместимый с \(A\). Тогда получается, что все вектора \(B\) можно выразить из \(A\). Значит, размерность \(B\) уж точно не больше — противоречие.
  • +
+

Пример задачи: набрать базис максимального веса, если каждый вектор сколько-то стоит.

+

Примечание: \(Z_2\) — это тоже пространство, и там тоже можно ввести линейную независимость. Это значит, что то же самое распространяется на задачи в духе «набрать максимальное множество чисел, из которых ксором нельзя получить ноль».

+ + diff --git a/ru/mincost-maxflow.html b/ru/mincost-maxflow.html new file mode 100644 index 0000000..88bf598 --- /dev/null +++ b/ru/mincost-maxflow.html @@ -0,0 +1,229 @@ + + + + + + + Поток минимальной стоимости - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Поток минимальной стоимости

+

Рассмотрим ориентированный граф \(G = (V, E)\) с истоком \(s\) и стоком \(t\), в котором у каждого ребра \((u, v)\) задана целая стоимость \(w_{uv}\) и целая положительная пропускная способность \(c_{uv}\). Требуется найти максимальный поток, стоимость которого минимальна:

+

\[ \sum_{(u, v) \in E} f_{uv} \to \max \] \[ \sum_{(u, v) \in E} f_{uv} w_{uv} \to \min \]

+

Заметим, что рёбра отрицательной стоимости по условию возможны. Мы дополнительно предполагаем, что циклов отрицательного веса нет.

+

Модифицируем сеть, добавив стандартным образом обратные рёбра, позволяющие «отменять» операции: для каждого ребра \((u, v)\) добавим \((v, u)\), для которого \(c_{vu} = 0\) и \(w_{vu} = -w_{uv}\). Напомним, что остаточной сетью называется граф из рёбер, остаточная пропускная способность которых ненулевая (\(c_{uv}-f_{uv} > 0\)).

+

Критерий оптимальности

+

Если в остаточной сети нет циклов отрицательного веса, то поток оптимален (и наоборот).

+

Доказательство:

+

\(\rightarrow\) Рассмотрим произвольный неоптимальный поток \(f\) и оптимальный поток \(f^*\). Рассмотрим разность \(f^*-f\). Она является циркуляцией, а любая циркуляция может быть разложена на сумму простых циклов. Хотя бы один из этих циклов будет иметь отрицательную стоимость, так как стоимость \(f^*\) меньше стоимости \(f\), что противоречит предположению.

+

\(\leftarrow\) Пусть цикл существует, тогда мы можем пропустить поток по этому циклу и получить поток меньшей стоимости.

+

Отмена циклов

+

Этот критерий сразу даёт нам относительно простой алгоритм: найдем какой-нибудь максимальный поток и будем «отменять» циклы отрицательного веса в остаточной цепи, пока такие циклы существуют. Искать цикл нам придётся не более \(mUC\) раз где \(U\) — величина потока, \(C\) — максимальная пропускная способность ребра. Этой величиной ограничен модуль минимальной стоимости ответа, а каждый отмененный цикл уменьшает ответ хотя бы на единицу.

+

Если искать цикл алгоритмом Форда-Беллмана, то асимптотика алгоритма составит \(O(m^2nUC)\) (предполагая, что какой-нибудь максимальный поток мы уже нашли).

+

Дополняющие пути

+

Вспомним общий алгоритм поиска максимального потока, основанный на теореме Форда-Фалкерсона: найти какой-нибудь дополняющий путь, пропустить по нему поток и модифицировать сеть, снова найти дополняющий путь и так далее, пока путь из истока в сток существует. Что будет, если мы каждый раз будем искать не произвольный путь, а путь минимальной стоимости? Утверждается, что такой алгоритм найдет максимальный поток минимальной стоимости.

+

Утверждение. Алгоритм не создает в остаточной сети циклов отрицательного веса.

+

Изначально в остаточной сети нет циклов отрицательного веса. Мы нашли минимальный путь из \(s\) в \(t\) и модифицировали сеть, возможно добавив какие-то обратные рёбра. Могли ли из-за этих рёбер появиться циклы отрицательного веса? Пусть какое-нибудь обратное ребро \((v, u)\) находится в цикле отрицательного веса. Тогда есть путь Из \(u\) в \(v\) стоимости меньше, чем \(w_{uv}\). Но такое не могло произойти: если бы такой путь существовал, то на этапе поиска дополняющего пути мы выбрали бы его вместо ребра \((u, v)\).

+

Для поиска дополняющего пути можно использовать алгоритм Форда-Беллмана. Асимптотика в данном случае составит \(O(nmU)\) — искать каждый дополняющий путь мы будем не более \(U\) раз.

+

Почему мы не использовали алгоритм Дейкстры? Проблема в рёбрах отрицательного веса. Даже если в исходном графе их нет, они могут в процессе алгоритма появиться как обратные. Покажем, как изменить веса рёбер так, чтобы они стали неотрицательными, но информация о кратчайших путях не утратилась: это можно сделать, если дать каждой вершине так называемый «потенциал», который будет учитываться при пересчете стоимостей ребер.

+

Потенциалы Джонсона

+

Потенциалом вершины \(v\) будем называть расстояние \(d_v\) от вершины \(s\). Рассмотрим граф из всех достижимых вершин и тех же рёбер, только с изменёнными весами:

+

\[ w_{uv}' = w_{uv} + d_u - d_v \]

+

Утверждение 1. Веса всех рёбер графа неотрицательные.

+

Доказательство. Пусть вес какого-то ребра \((u, v)\) отрицателен, то есть \(w_{uv}' = w_{uv} + d_u - d_v < 0\). Тогда \(d_u + w_{uv} < d_v\), и нарушилось неравенство треугольника: почему мы тогда не использовали ребро \((u, v)\), когда искали кратчайший путь до \(v\)?

+

Аналогично можно показать, что рёбра на кратчайших путях из \(s\) имеют нулевую стоимость. Заметим, что стоимость обратных рёбер на кратчайших путях тоже будет нулевой: \[ w_{vu}' = w_{vu} + d_v - d_u = -w_{uv} - d_u + d_v = -(w_{uv}) = 0 \]

+

Утверждение 2. Кратчайшие пути между любыми вершинами остались кратчайшими.

+

Доказательство. Распишем новую стоимость пути из \(a\) в \(z\).

+

\[ +\begin{aligned} +w_{ab}' + \ldots + w_{yz}' +&= (w_{ab} + \ldots + w_{yz}) + (d_a + \ldots + d_y) - (d_b + \ldots + d_z) +\\&= (w_{ab} + \ldots + w_{yz}) + d_a - d_z +\end{aligned} +\]

+

Получаем, что стоимость всех путей из \(a\) в \(z\) лишь изменилась на константу.

+

Более того, если мы добавим или удалим некоторые рёбра из графа, потенциалы тоже никак не повлияют на кратчайшие пути.

+

Заметьте, что в доказательстве мы не использовали то, что \(d_v\) — кратчайшие расстояния. Это вообще могут быть произвольные числа.

+

Утверждение 3. Когда мы проталкиваем поток вдоль кратчайшего пути, удаляя ребра и возможно добавляя обратные, веса в изменённом графе тоже остались корректными (все рёбра неотрицательного веса и все кратчайшие пути остались кратчайшими).

+

Доказательство. Все добавленные обратные рёбра на кратчайшем пути будут иметь нулевую стомость (утверждение 1), а добавления или удаления рёбер на кратчайшие пути не повлияли (утверждение 2).

+

Итоговый алгоритм

+
    +
  • Модифицируем сеть, добавив обратные рёбра.
  • +
  • Если в исходном графе есть рёбра отрицательного веса (но нет циклов отрицательного веса), то посчитать изначальные потенциалы (расстояния) алгоритмом Форда-Беллмана. Иначе достаточно положить потенциалы изначально равными нулю.
  • +
  • Пока максимальный поток не найден:
  • +
    • +
    • Посчитать алгоритмом Дейкстры кратчайшие расстояния от \(s\), используя для веса формулу с потенциалами, записать их в \(d\).
    • +
  • +
    • +
    • Протолкнуть максимально возможный поток вдоль кратчайшего пути \(s \leadsto t\), обновить остаточную сеть.
    • +
  • +
+

Реализация

+

Ниже приведено решение задачи о назначениях (паросочетание минимального веса). Для нахождения дополняющего пути используется алгоритм Дейкстры для плотных графов (без приоритетной очереди — каждую итерацию ищется минимум за \(O(n)\)).

+
    +
  • cost, cap — параметры сети
  • +
  • pot — потенциалы
  • +
  • par — предок вершины в алгоритме Дейкстры (нужен для проталкивания потока)
  • +
  • d — временный массив для алгоритма Дейкстры, куда будут записаны новые расстояния
  • +
+
const int maxn = 305, inf = 1e9;
+
+int n;
+int cost[maxn][maxn], cap[maxn][maxn];
+int d[maxn], pot[maxn], par[maxn];
+
+bool dijkstra (int s, int t) {
+    used[maxn] = {0};
+
+    fill(d, d+n, inf);
+    d[s] = 0;
+
+    while (1) {
+        int v = -1;
+        for (int u = 0; u < n; u++)
+            if (!used[u] && (v == -1 && d[u] < d[v]))
+                v = u;
+        if (v == -1 || d[v] == inf)
+            break;
+        used[v] = 1;
+        for (int u = 0; u < n; u++) {
+            int w = cost[v][u] + pot[v] - pot[u];
+            if (cap[v][u] && d[u] > d[v] + w) {
+                d[u] = d[v] + w;
+                par[u] = v;
+            }
+        }
+    }
+
+    return d[t] < inf;
+}
+
+int mincost_maxflow (int s, int t) {
+    int ans = 0;
+    while (dijkstra(s, t)) {
+        memcpy(pot, d, sizeof(d));
+        int delta = inf;
+        for (int v = t; v != s; v = par[v])
+            delta = min(delta, cap[par[v]][v]);
+        for (int v = t; v != s; v = par[v]) {
+            cap[par[v]][v] -= delta;
+            cap[v][par[v]] += delta;
+            ans += cost[par[v]][v]*delta;
+        }
+    }
+    return ans;
+}
+

Асимптотика

+

В общем случае, алгоритм работает за \(O(U m \log n)\) или \(O(U n^2)\) в случае плотных графов.

+

В наиболее популярных задачах рёбра обычно с единичной пропускной способностью, и \(U \leq n\) или \(U \leq m\). Например, в задаче о назначениях \(U = n\), и алгоритм работает за \(O(n^3)\), что совпадает с асимптотикой венгерского алгоритма.

+ + diff --git a/ru/mst.html b/ru/mst.html new file mode 100644 index 0000000..01d1b42 --- /dev/null +++ b/ru/mst.html @@ -0,0 +1,316 @@ + + + + + + + Минимальные остовы - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Минимальные остовы

+

Рассмотрим следующую задачу:

+
+

Авиакомпания содержит \(m\) рейсов между \(n\) городами, \(i\)-ый из них обходится в \(w_i\) рублей, причём из любого города можно добраться до любого другого. В стране наступил кризис, и нужно отказаться от как можно большего числа из них таким образом, что содержание оставшиъся рейсов будет наиболее дешевым.

+
+

Иными словами, нужно найти дерево минимального веса, которое является подграфом данного неориентированного графа. Такие деревья называют остовами (каркас, скелет; ударение на первый слог, но так мало кто произносит). По-английски — minimum spanning tree (дословно, минимальное покрывающее дерево).

+

Почему дерево? Потому что в противном случае там был бы цикл, из которого можно удалить какое-то ребро и получить более оптималный ответ. А если это больше, чем одно дерево, то какие-то две вершины остаются несвязны.

+

Вообще, следующие утверждения про деревья являются эквивалентными:

+
    +
  • Граф — дерево.
  • +
  • В графе из \(n\) вершин есть \(n-1\) рёбер и нет циклов.
  • +
  • Из любой вершины можно дойти в любую другую единственным образом.
  • +
+

Лемма о безопасном ребре

+

Назовем подграф \(T\) графа \(G\) безопасным, если они является подграфом какого-то минимального остова.

+

Назовем ребро безопасным, если при добавлении его в подграф \(T\) получившийся подграф \(T'\) тоже является безопасным, то есть подграфом какого-то минимального остова.

+

Все алгоритмы для поиска минимального остова опираются на следующее утверждение:

+

Лемма о безопасном ребре. Рассмотрим произвольный разрез (удалили некоторые рёбра так, что граф распался на две части) какого-то подграфа минимального остова. Тогда ребро минимального веса, пересекающее этот разрез (то есть соединяющее их при добавлении) является безопасным.

+

Доказательство: Рассмотрим какой-то минимальный остов, в котором этого ребра нет. Если его добавить, то образуется цикл, из которого можно выкинуть ребро не меньшего веса, получив ответ точно не хуже. Противоречие.

+

+

Получается, что мы можем действовать жадно — на каждом шаге добавлять ребро минимального веса, которое увеличивает наш остов.

+

Алгоритм Прима

+

Один из подходов — строить минимальный остов постепенно, добавляя в него рёбра по одному.

+
    +
  • Изначально остов — одна произвольная вершина.
  • +
  • Пока минимальный остов не найден, выбирается ребро минимального веса, исходящее из какой-нибудь вершины текущего остова в вершину, которую мы ещё не добавили. Добавляем это ребро в остов и начинаем заново, пока остов не будет найден.
  • +
+

Этот алгоритм очень похож на алгоритм Дейкстры, только тут мы выбираем следующую вершину с другой весовой функцией — вес соединяющего ребра вместо суммарного расстояния до неё.

+

Совсем наивная реализация за \(O(nm)\) — каждый раз перебираем все рёбра:

+
const int maxn = 1e5, inf = 1e9;
+vector from, to, weight;
+bool used[maxn]
+
+// считать все рёбра в массивы
+
+used[0] = 1;
+for (int i = 0; i < n-1; i++) {
+    int opt_w = inf, opt_from, opt_to;
+    for (int j = 0; j < m; j++)
+        if (opt_w > weight[j] && used[from[j]] && !used[to[j]])
+            opt_w = weight[j], opt_from = from[j], opt_to = to[j]
+    used[opt_to] = 1;
+    cout << opt_from << " " << opt_to << endl;
+}
+

Реализация за \(O(n^2)\):

+
const int maxn = 1e5, inf = 1e9;
+bool used[maxn];
+vector< pair<int, int> > g[maxn];
+int min_edge[maxn] = {inf}, best_edge[maxn];
+min_edge[0] = 0;
+
+// ...
+
+for (int i = 0; i < n; i++) {
+    int v = -1;
+    for (int u = 0; u < n; j++)
+        if (!used[u] && (v == -1 || min_edge[u] < min_edge[v]))
+            v = u;
+
+    used[v] = 1;
+    if (v != 0)
+        cout << v << " " << best_edge[v] << endl;
+
+    for (auto e : g[v]) {
+        int u = e.first, w = e.second;
+        if (w < min_edge[u]) {
+            min_edge[u] = w;
+            best_edge[u] = v;
+        }
+    }
+}
+

Можно не делать линейный поиск оптимальной вершины, а поддерживать его в приоритетной очереди, как в алгоритме Дейкстры. Получается реализация за \(O(m \log n)\):

+
set< pair<int, int> > q;
+int d[maxn];
+
+while (q.size()) {
+    v = q.begin()->second;
+    q.erase(q.begin());
+
+    for (auto e : g[v]) {
+        int u = e.first, w = e.second;
+        if (w < d[u]) {
+            q.erase({d[u], u});
+            d[u] = w;
+            q.insert({d[u], u});
+        }
+    }
+}
+

Про алгоритм за \(O(n^2)\) забывать не стоит — он работает лучше в случае плотных графов.

+

Система непересекающихся множеств

+

Система непересекающихся множеств (англ. disjoint set union) — структура данных, которая используется для хранения информации о связности компонент. Она нам потребуется для описания следующего подхода — алгоритма Крускала.

+

Изначально имеется несколько элементов, каждый из которых находится в отдельном (своём собственном) множестве. Структура поддерживает две операции:

+
    +
  • Объединить два каких-либо множества.
  • +
  • Запросить, в каком множестве сейчас находится указанный элемент.
  • +
+

Обе операции выполняются в среднем почти за \(O(1)\) (но не совсем — этот сложный вопрос будет разъяснен позже).

+

Множества элементов мы будем хранить в виде деревьев: одно дерево соответствует одному множеству. Корень дерева — это представитель (лидер) множества. Заведём массив _p, в котором для каждого элемента мы храним номер его предка в дереве. Для корней деревьев будем считать, что их предки — они сами.

+

Наивная реализация, которую мы потом ускорим:

+
int _p[maxn];
+
+int p(int v) {
+    if (_p[v] == v)
+        return v;
+    else
+        return p(_p[v]);
+}
+
+void unite(int a, int b) {
+    a = p(a), b = p(b);
+    _p[a] = b;
+}
+
+for (int i = 0; i < n; i++)
+    _p[i] = i;
+

Эвристика сжатия пути. Оптимизируем работу функции p. Давайте перед тем, как вернуть ответ, запишем его в _p от текущей вершины, то есть переподвесим его за самую высокую.

+
+Паблик «Странные опросы для спортивных программистов» +
+

Следующие две эвристики похожи по смыслу и стараются оптимизировать высоту дерева, выбирая оптимальный корень для переподвешивания.

+

Ранговая эвристика. Будем хранить для каждой вершины её ранг — высоту её поддереа. При объединении деревьев будем делать корнем нового дерева ту вершину, у которой ранг больше, и пересчитывать ранги (ранг у лидера должен увеличиться на единицу, если он совпадал с рангом другой вершины). Эта эвристика оптимизирует высоту дерева напрямую.

+

Весовая эвристика. Будем вместо ранга хранить размеры поддеревьев для каждой вершины, а при объединении — подвешивать за более «тяжелую».

+

Финальная реализация, использующая весовую эвристику и эвристику сжатия путей:

+
int _p[maxn], s[maxn];
+
+int p (int v) { return (_p[v] == v) ? v : _p[v] = p(_p[v]); }
+
+void unite(int a, int b) {
+    a = p(a), b = p(b);
+    if (s[a] > s[b])
+        swap(a, b);
+    s[b] += s[a];
+    _p[a] = b;
+}
+
+// где-то в main:
+
+for (int i = 0; i < n; i++)
+    _p[i] = i;
+

Автор предпочитает именно весовую эвристику, потому что часто в задачах размеры компонент требуются сами по себе.

+

Асимптотика

+

Эвристика сжатия путей улучшает асимптотику до \(O(\log n)\) в среднем. Здесь используется именно амортизированная оценка — понятно, что в худшем случае нужно будет сжимать весь бамбук за \(O(n)\).

+

Индукцией несложно показать, что весовая и ранговая эвристики ограничивают высоту дерева до \(O(\log n)\), а соответственно и асимптотику тоже.

+

При использовании эвристики сжатия плюс весовой или ранговой асимптотика будет \(O(a(n))\), где \(a(n)\) — обратная функция Аккермана (очень медленно растущая функция, для всех адекватных чисел не превосходящая 4).

+

Тратить время на изучения доказательства или даже чтения статьи на Википедии про функцию Аккермана автор не рекомендует.

+

Алгоритм Крускала

+

Отсортируем рёбра и будем пытаться добавлять их в остов в порядке возрастания их весов. Если ребро соединяет какие-то две уже соединенные вершины, то проигнорируем его, иначе оно является безопасным, и его можно добавить.

+

Звучит очень просто — отсортировать все рёбра, пройтись по ним циклом и делать проверку, что вершины в разных компонентах. Наивная проверка будет работать за \(O(m \log m + n^2)\), но асимптотику можно улучшить до \(O(m \log m)\) (до стоимости сортировки), если для проверок использовать систему непересекающихся множеств.

+
// (w, (a, b))
+vector< pair< int, pair<int, int> > > edges;
+
+sort(edges.begin(), edges.end());
+
+for (auto e : edges) {
+    int a = e.first.first, b = e.first.second;
+    // компоненты разные, если лидеры разные
+    if (p(a) != p(b)) {
+        // добавим ребро (a, b)
+        unite(a, b);
+    }
+}
+

Алгоритм Борувки

+

Лемма. Для любой вершины минимальное инцидентное ей реборо является безопасным.

+

Доказательство. Пусть есть минимальный остов, в котором для какой-то вершины \(v\) нет её минимального инцидентного ребра. Тогда, если добавить это ребро, образуется цикл, из которого можно удалить другое ребро, тоже инцидентное \(v\), но имеющее не меньший вес.

+

Алгоритм Борувки опирается на этот факт и заключается в следующем:

+
    +
  1. Для каждой вершины найдем минимальное инцидентное ей ребро.
  2. +
  3. Добавим все такие рёбра в остов (это безопасно — см. лемму) и сожмем получившиеся компоненты, то есть объединим списки смежности вершин, которые эти рёбра соединяют.
  4. +
  5. Повторяем шаги 1-2, пока в графе не останется только одна вершина-компонента.
  6. +
+

Алгоритм может работать неправильно, если в графе есть ребра, равные по весу. Пример: «треугольник» с одинаковыми весами рёбер. Избежать это можно введя какой-то дополнительный порядок на рёбрах — например, сравнивая пары из веса и номера ребра.

+

Асимптотика

+

Заметим, что на каждой итерации каждая оставшаяся вершина будет задействована в «мердже». Это значит, что количество вершин-компонент уменьшится хотя бы вдвое, а значит всего итераций будет не более \(O(\log n)\)

+

На каждой итерации мы можем просматриваем почти все рёбра, так что конечное время работы составит \(O(m \log n)\).

+

Зачем это нужно?

+

Алгоритм неприятно реализовывать. Настолько неприятно, что автор это делать не будет. Однако, алгоритм очень полезен на практике, потому что в «реальных» графах он работает за линейное время.

+

Утверждение. В случае планарных графов алгоритм работает за \(O(n)\).

+

Доказательство. Из формулы Эйлера нам известно, что рёбер в планарном графе \(O(n)\). Так как подграф планарного графа тоже всегда планарен, то после каждой итерации размер нашей задачи уменьшается в честные 2 раза — меньше становится не только вершин, но и рёбер тоже. Значит, алгоритм будет работать за \(O(n) + O(\frac{n}{2}) + O(\frac{n}{4}) + \ldots = O(n)\).

+

Также, в отличие от алгоритмов Прима и Крускала, его можно легко распараллелить. «Параллельная сложность» у него \(O(\log v)\): нужно каждую итерацию просто искать минимум по оставшимся рёбрам.

+

Полезные свойства и классические задачи

+
    +
  • Если веса всех рёбер различны, то остов будет уникален.
  • +
  • Минимальный остов является также и остовом с минимальным произведением весов рёбер (замените веса всех рёбер на их логарифмы)
  • +
  • Минимальный остов является также и остовом с минимальным весом самого тяжелого ребра.
  • +
  • Если вы решаете задачу, где ребра не добавляются, а удаляются, и нужно поддерживать минимальный остов, то можно попробовать решать задачу «с конца» и применить алгоритм Крускала.
  • +
  • Алгоритм Крускала — частный случай алгоритма Радо-Эдмондса.
  • +
+

Персистентная СНМ

+

СНМ — структура данных на ссылках, и её тоже можно сделать персистентной. В СНМ мы изменяем массивы, а массивы можно сделать персистентными через персистентное ДО (только так, проще не получается — многие пытались).

+

Здесь есть нюанс — амортизированные структуры не очень хорошо дружат с персистентностью. Поэтому нам придется отказаться от эвристики сжатия путей, и поэтому асимптотика составит \(O(n \log^2 n)\) времени и памяти — один логарифм от самого СНМа, другой от персистентного ДО.

+

Динамическая связность

+

Dynamic Connectivity Problem:

+
+

Даны \(n\) запросов добавления ребра (+), удаления ребра (- и какого-то запроса про граф (?), например, о связности двух вершин.

+
+

О решении этой задачи в online и в offline можете почитать в этом посте.

+ + diff --git a/ru/no-deallocation.html b/ru/no-deallocation.html new file mode 100644 index 0000000..f6699e4 --- /dev/null +++ b/ru/no-deallocation.html @@ -0,0 +1,140 @@ + + + + + + + Неудаляющий аллокатор - Алгоритмика + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Неудаляющий аллокатор

+

Программы на C могут выделять память под объекты двумя способами: на стэке (stack allocation) и на куче (heap allocation).

+

В первом случае поддеживается указатель на конец памяти, который при создании объекта увеличивается на нужное число байт, а при удалении — уменьшается. Как в стэке. Этот способ используется, например, когда вы создаете временные переменные внутри функции или тела цикла — когда они исчезают из области видимости, компилятор автоматически генерируют инстрикции для их удаления.

+

Во втором подходе программа вежливо просит операционную систему выделить сколько-то байт памяти как ей удобно и вернуть на них указатель, а также гарантировать, что другие процессы не будут иметь к ней никакого доступа (хотя компьютер — вещь сложная, и это не всегда получается). В этом случае программист сам должен думать об удалении объектов, когда они становятся не нужны. Для создания объектов таким образом нужно вызвать оператор new, а при удалении вызывать оператор delete.

+

Низкоуровневая работа с памятью — это, пожалуй, основная причина, почему люди любят и ненавидят C и C++. Если не удалять объекты, выделенные на куче, то возникает утечка памяти, но в условиях олимпиады это нам не важно — за несколько секунд времени редко удается выделить больше памяти, чем доступно.

+

Пожертвовав «правильным» освобождением памяти, можно значительно ускорить — иногда в полтора-два раза — скорость работы работы структур данных, которые используют delete (в частности, большинство контейнеров STL).

+

Для этого можно глобально переопределить new и delete:

+
const int max_memory = 1e8;
+
+int pos_memory = 0;
+char memory[max_memory];
+
+void* operator new(size_t n) {
+    char *res = memory + pos_memory;
+    pos_memory += n;
+    assert(pos_memory <= max_memory);
+    return (void*) res;
+}
+void operator delete(void *){}
+

Оператор new принимает количество байт памяти, которые требуется выделить, и возвращает указатель на выделенную память — у нас он возвращает и увеличивает конец «стэка».

+

Оператор delete принимает указатель на начало памяти и освобождает её. В нашем случае, он просто не делает ничего.

+ + diff --git a/ru/palindromes.html b/ru/palindromes.html new file mode 100644 index 0000000..e5df53a --- /dev/null +++ b/ru/palindromes.html @@ -0,0 +1,230 @@ + + + + + + + Палиндромы - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Палиндромы

+

Палиндром — это фраза, которая читается одинаково слева направо и справа налево. Например:

+
    +
  • «was it a car or a cat I saw?»

  • +
  • «а роза упала на лапу Азора»

  • +
  • «abacaba» (палиндром нечётной длины)

  • +
  • «abba» (палиндром чётной длины)

  • +
+

Для простоты, мы будем рассматривать только последовательности из строчных латинских букв.

+

Палиндромы — не самые часто встречающиеся в реальной жизни объекты, однако задачи на палиндромы любят давать на соревнованиях по спортивному программированию. В этой статье мы опишем эффективные способы их представления.

+

Алгоритм Манакера

+

Пусть есть строка \(s\) и мы хотим найти в ней все подпалиндромы.

+

Мы сразу сталкиваемся с очевидной трудностью: их в строке может быть \(O(n^2)\), что можно видеть на примере строки \(s = aa \ldots a\). Поэтому будем использовать следующий формат: для каждой позиции \(s_i\) найдём наибольший палиндром, центр которого совпадает с \(s_i\) (чётные и нечётные палиндромы будем рассматривать отдельно). Половину его длины, округлённую вниз, будем называть радиусом.

+

Наивное решение — перебрать \(s_i\), а для него вторым циклом находить наибольшую искомую длину:

+
vector<int> pal_array(string s) {
+    int n = s.size();
+
+    // окружим строку спецсимволами, чтобы не рассматривать выход за границы
+    s = "#" + s + "$";
+
+    // в этом массиве будем хранить расстояние от центра до границы палиндрома
+    vector<int> t(n, 0);
+
+    for(int i = 1; i <= n; i++)
+        while (s[i - t[i - 1]] == s[i + t[i - 1]])
+            r[i-1]++;
+
+    return r;
+}
+

Тот же пример \(s = aa\dots a\) показывает, что данная реализация работает за \(O(n^2)\).

+

Для оптимизации применим идею, знакомую из алгоритма z-функции: при инициализации \(t_i\) будем пользоваться уже посчитанными \(t\). А именно, будем поддерживать \((l, r)\) — интервал, соответствующий самому правому из найденных подпалиндромов. Тогда мы можем сказать, что часть наибольшего палиндрома с центром в \(s_i\), которая лежит внутри \(s_{l:r}\), имеет радиус хотя бы \(\min(r-i, \; t_{l+r-i})\). Первая величина равна длине, дальше которой произошел бы выход за пределы \(s_{l:r}\), а вторая — значению радиуса в позиции, зеркальной относительно центра палиндрома \(s_{l:r}\).

+

+vector<int> manacher_odd(string s) {
+    int n = (int) s.size();
+    vector<int> d(n, 1);
+    int l = 0, r = 0;
+    for (int i = 1; i < n; i++) {
+        if (i < r)
+            d[i] = min(r - i + 1, d[l + r - i]);
+        while (i - d[i] >= 0 && i + d[i] < n && s[i - d[i]] == s[i + d[i]])
+            d[i]++;
+        if (i + d[i] - 1 > r)
+            l = i - d[i] + 1, r = i + d[i] - 1;
+    }
+    return d;
+}
+

Так же, как и z-функция, алгоритм работает за линейное время: цикл while запускается только когда \(t_i = r - i\) (иначе палиндром уже во что-то упёрся), и каждая его итерация сдвигает увеличивает \(r\) на единицу. Так как \(r \leq n\), получаем, что суммарно эти циклы сделают \(O(n)\) итераций.

+

Для случая чётных палиндромов меняется только индексация:

+
vector<int> manacher_even(string s) {
+    int n = (int) s.size();
+    vector<int> d(n, 0);
+    int l = -1, r = -1;
+    for (int i = 0; i < n - 1; i++) {
+        if (i < r)
+            d[i] = min(r - i, d[l + r - i - 1]);
+        while (i - d[i] >= 0 && i + d[i] + 1 < n && s[i - d[i]] == s[i + d[i] + 1])
+            d[i]++;
+        if (i + d[i] > r)
+            l = i - d[i] + 1, r = i + d[i];
+    }
+    return d;
+}
+

Также можно было не писать отдельно две реализации, а воспользоваться следующим трюком — сделать замену:

+

\[ +S = s_1 s_2 \dots s_n \to S^* = s_1 \# s_2 \# \dots \# s_n +\]

+

Теперь нечётные палиндромы с центром в \(s_i\) соответствуют нечётным палиндромам исходной строки, а нечётные палиндромы с центром в \(\#\) — чётным.

+

Дерево палиндромов

+

Дерево палиндромов (англ. palindromic tree, EERTREE) — структура данных, использующая другой, более мощный формат хранения информации обо всех подпалиндромах, чем размеры \(n\) палиндромов. Она была предложена Михаилом Рубинчиком на летних петрозаводских сборах в 2014-м году.

+

Лемма. В строке есть не более \(n\) различных подпалиндромов.

+

Доказательство. Пусть мы дописываем к строке по одному символу и в данный момент, записав \(r\) символов, имеем наибольший суффикс-палиндром \(s_{l:r}\). Пусть у него, в свою очередь, есть суффикс-палиндром \(s_{l':r} = t\). Тогда он также имеет более раннее вхождение в строку как \(s_{l:l+r-l'} = t\). Таким образом, с каждым новым символом у строки появляется не более одного нового палиндрома, и если таковой есть, то это всегда наибольший суффикс-палиндром.

+

Этот факт позволяет сопоставить всем палиндромам строки сопоставить следующую структуру: возьмём от каждого палиндрома его правую половину (например, \(caba\) для \(abacaba\) или \(ba\) для \(abba\); будем рассматривать пока что только чётные палиндромы) и добавим все эти половины в префиксное дерево — получившуюся структуру и будем называть деревом палиндромов.

+

Наивный алгоритм построения будет в худшем случае работать за \(O(n^2)\), но это можно делать и более эффективно.

+

Построение за линейное время

+

Будем поддерживать наибольший суффикс-палиндром. Когда мы будем дописывать очередной символ \(c\), нужно найти наибольший суффикс этого палиндрома, который может быть дополнен символом \(c\) — это и будет новый наидлиннейший суффикс-палиндром.

+

Для этого поступим аналогично алгоритму Ахо-Корасик: будем поддерживать для каждого палиндрома суффиксную ссылку \(l(v)\), ведущую из \(v\) в её наибольший суффикс-палиндром. При добавлении очередного символа, будем подниматься по суффиксным ссылкам, пока не найдём вершину, из которой можно совершить нужный переход.

+

Если в подходящей вершине этого перехода не существовало, то нужно создать новую вершину, и для неё тоже понадобится своя суффиксная ссылка. Чтобы найти её, будем продолжать подниматься по суффиксным ссылкам предыдущего суффикс-палиндрома, пока не найдём второе такое место, которое мы можем дополнить символом \(c\).

+
const int maxn = 1e5, k = 26;
+
+int s[maxn], len[maxn], link[maxn], to[maxn][k];
+
+int n, last, sz;
+
+void init() {
+    s[n++] = -1;
+    link[0] = 1;
+    len[1] = -1;
+    sz = 2;
+}
+
+int get_link(int v) {
+    while (s[n-len[v]-2] != s[n-1])
+        v = link[v];
+    return v;
+}
+
+void add_char(int c) {
+    s[n++] = c;
+    last = get_link(last);
+    if (!to[last][c]) {
+        len[sz] = len[last] + 2;
+        link[sz] = to[get_link(link[last])][c];
+        to[last][c] = sz++;
+    }
+    last = to[last][c];
+}
+

Здесь мы использовали обычный массив для хранения переходов. Как и для любых префиксных деревьев, вместо него можно использовтать бинарное дерево поиска, хэш-таблицу, односвязный список и другие структуры, позволяющие обменять время на память, немного изменив асимптотику.

+

Асимптотика

+

Покажем линейность алгоритма. Рассмотрим длину наибольшего суффикс-палиндрома строки. Каждый новый символ увеличивает её не более, чем на 2. При этом каждый переход по суффиксной ссылке уменьшает её, поэтому нахождение первого суффикс-палиндрома амортизировано работает за линейное время.

+

Аналогичными рассуждениями о длине второго суффикс-палиндрома (его длина увеличивается тоже не более, чем на 2) получаем, что пересчёт суффиксных ссылок при создании новых вершин тоже суммарно работает за линейное время.

+ + diff --git a/ru/pollard.html b/ru/pollard.html new file mode 100644 index 0000000..9446854 --- /dev/null +++ b/ru/pollard.html @@ -0,0 +1,173 @@ + + + + + + + Ро-алгоритм Полларда - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Ро-алгоритм Полларда

+

Наука умеет раскладывать целые числа на множители за \(O(n^\frac{1}{4})\). Описываемый в этой статье алгоритм рандомизированный, поэтому нам сначала понадобится доказать одно утверждение из теории вероятностей.

+

Парадокс дней рождений

+

Пусть \(f(n, d)\) это вероятность того, что в группе из \(n\) человек ни у кого не совпали дни рождения. Будем считать, что дни рождения распределены независимо и равномерно в промежутке от \(1\) до \(d\).

+

\[ +f(n, d) = (1-\frac{1}{d}) \times (1-\frac{2}{d}) \times ... \times (1-\frac{n-1}{d}) +\]

+

Попытаемся оценить \(f\):

+

\[ +\begin{aligned} + e^x & = 1 + x + \frac{x^2}{2!} + \ldots & \text{(ряд Тейлора для экспоненты)} \\ + & \simeq 1 + x & \text{(аппроксимация для $|x| \ll 1$)} \\ + e^{-\frac{n}{d}} & \simeq 1 - \frac{n}{d} & \text{(подставим $\frac{n}{d} \ll 1$)} \\ + f(n, d) & \simeq e^{-\frac{1}{d}} \times e^{-\frac{2}{d}} \times \ldots \times e^{-\frac{n-1}{d}} & \\ + & = e^{-\frac{n(n-1)}{2d}} & \\ + & \simeq e^{-\frac{n^2}{2d}} & \\ +\end{aligned} +\]

+

Из формулы более-менее понятно, что вероятность \(\frac{1}{2}\) достигается при \(n \approx \sqrt{d}\) и в этой точке изменяется быстро. Для самого алгоритма нам понадобится следующее:

+

Следствие. В мультимножество нужно добавить \(O(\sqrt{n})\) случайных чисел от 1 до \(n\), чтобы какие-то два совпали.

+

\(\rho\)-алгоритм Полларда

+

Итак, мы хотим факторизовать число \(n\). Предположим, что \(n = p q\) и \(p \approx q\). Понятно, что труднее случая, наверное, нет. Алгоритм итеративно ищет наименьший делитель и таким образом сводит задачу к как минимум в два раза меньшей.

+

Возьмём произвольную «достаточно случайную» с точки зрения теории чисел функцию. Например \(f(x) = (x+1)^2 \mod n\).

+

Граф, в котором из каждой вершины есть единственное ребро \(x \to f(x)\), называется функциональным. Если в нём нарисовать «траекторию» произвольного элемента — какой-то путь, превращающийся в цикл — то получится что-то похожее на букву \(\rho\) (ро). Алгоритм из-за этого так и назван.

+

+

Рассмотрим траекторию какого-нибудь элемента \(x_0\): {\(x_0\), \(f(x_0)\), \(f(f(x_0))\), \(\ldots\)}. Сделаем из неё новую последовательность, мысленно взяв каждый элемент по модулю \(p\) — наименьшего из простых делителей \(n\).

+

Утверждение. Ожидаемая длина цикла в этой последовательности \(O(\sqrt[4]{n})\).

+

Доказательство: так как \(p\) — меньший делитель, то \(p \leq \sqrt n\). Теперь просто подставлим в следствие из парадокса дней рождений: в множество нужно добавить \(O(\sqrt{p}) = O(\sqrt[4]{n})\) элементов, чтобы какие-то два совпали, а значит последовательность зациклилась.

+

Если мы найдём цикл в такой последовательности — то есть такие \(i\) и \(j\), что \(f^i(x_0) \equiv f^j(x_0) \pmod p\) — то мы сможем найти и какой-то делитель \(n\), а именно \(\gcd(|f^i(x_0) - f^j(x_0)|, n)\) — это число меньше \(n\) и делится на \(p\).

+

Алгоритм по сути находит цикл в этой последовательности, используя для этого стандартный алгоритм («черепаха и заяц»): будем поддерживать два удаляющихся друг от друга указателя \(i\) и \(j\) (\(i = 2j\)) и проверять, что \(f^i(x_0) \equiv f^j(x_0) \pmod p\), что эквивалентно проверке \(\gcd(|f^i(x_0) - f^j(x_0)|, n) \not \in \{ 1, n \}\).

+
typedef long long ll;
+
+inline ll f(ll x) { return (x+1)*(x+1); }
+
+ll find_divisor(ll n, ll seed = 1) {
+    ll x = seed, y = seed;
+    ll divisor = 1;
+    while (divisor == 1 || divisor == n) {
+        // двигаем первый указатель на шаг
+        y = f(y) % n;
+        // а второй -- на два
+        x = f(f(x) % n) % n;
+        // пытаемся найти общий делитель
+        divisor = __gcd(abs(x-y), n);
+    }
+    return divisor;
+}
+

Так как алгоритм рандомизированный, при полной реализации нужно учитывать разные детали. Например, что иногда делитель не находится (нужно запускать несколько раз), или что при попытке факторизовать простое число он будет работать за \(O(\sqrt n)\) (нужно добавить отсечение по времени).

+

Примечания

+

Формально, мы показали, что алгоритм работает за \(O(\sqrt[4]{n} \log n)\) за счёт поиска \(\gcd\), но сложной теорией чисел можно доказать, что этого логарифма в асимптотике на самом деле нет.

+

Факторизация больших чисел интересна в контексте криптографии — на предположение невозможности факторизации за линейное время опирается, например, алгоритм RSA.

+

Существуют также субэкспоненциальные, но не полиномиальные алгоритмы факторизации. Человечество умеет факторизовывать числа порядка \(2^{200}\).

+

Алгоритм Шора позволяет факторизовывать числа за полиномиальное время на квантовом компьютере. Но на 2019 год все квантовые вычисления проще симулировать на обычном компьютере. Самое большое число, факторизованное на реальном квантовом компьютере — 4088459.

+ + diff --git a/ru/probability.html b/ru/probability.html new file mode 100644 index 0000000..02190a4 --- /dev/null +++ b/ru/probability.html @@ -0,0 +1,423 @@ + + + + + + + Теорвер для алгоритмистов - Алгоритмика + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Теорвер для алгоритмистов

+

В вузах теорию вероятностей очень долго аксиоматизируют перед тем, как перейти к чему-то полезному. Сейчас продемонстрируем, зачем.

+

[Парадокс Бертрана](https://ru.wikipedia.org/wiki/%D0%9F%D0%B0%D1%80%D0%B0%D0%B4%D0%BE%D0%BA%D1%81_%D0%91%D0%B5%D1%80%D1%82%D1%80%D0%B0%D0%BD%D0%B0_(%D0%B2%D0%B5%D1%80%D0%BE%D1%8F%D1%82%D0%BD%D0%BE%D1%81%D1%82%D1%8C%29):

+
+

Рассмотрим равносторонний треугольник, вписанный в окружность. Наудачу выбирается хорда окружности. Какова вероятность того, что выбранная хорда длиннее стороны треугольника?

+
+

Оказывается, можно придумать как минимум три способа решать задачу, которые все выглядят адекватно, но при этом дают разные ответы.

+
    +
  1. Наудачу выберем две точки на окружности и проведём через них хорду. Чтобы посчитать искомую вероятность, представим, что треугольник повёрнут так, что одна из его вершин совпадает с концом хорды. Заметим, что если другой конец хорды лежит на дуге между двумя другими вершинами треугольника, то длина хорды больше стороны треугольника. Длина рассмотренной дуги равна трети длины окружности, значит искомая вероятность равна \(\frac13\).

  2. +
  3. Зафиксируем радиус окружности, наудачу выберем точку на радиусе. Построим хорду, перпендикулярную зафиксированному радиусу, проходящую через выбранную точку. Для нахождения искомой вероятности, представим, что треугольник повёрнут так, что одна из его сторон перпендикулярна зафиксированному радиусу. Хорда длиннее стороны треугольника, если её центр ближе к центру, чем точка пересечения треугольника с зафиксированным радиусом. Сторона треугольника делит пополам радиус, следовательно вероятность выбрать хорду длиннее стороны треугольника \(\frac12\).

  4. +
  5. Выберем наудачу произвольную точку внутри круга и построим хорду с центром в выбранной точке. Хорда длиннее стороны равностороннего треугольника, если выбранная точка находится внутри круга, вписанного в треугольник. Площадь вписанного круга есть \(\frac14\) от площади большего, значит исходная вероятность равна \(\frac14\).

  6. +
+

Как мы увидели, от формального определения «случайной хорды» непосредственно зависит ответ. Каждый раз, когда в истории математики появляется подобный приводящий к протеворечиям парадокс, математики паникуют и начинают всё формализовывать и аксиоматизировать. Так появилась теория вероятностей.

+

Аксиоматика

+

Случайной переменной или случайной величиной называется переменная, принимающая различые значения случайно. Сама по себе, случайная переменная только описывает возможные состояния; она должна быть связана с вероятностным распределением, которые указывает, насколько вероятно каждое из состояний.

+

Например, можно ввести случайную переменную, соответствующую исходу броска шестигранного кубика — она принимает значения из вероятностного пространства \(\Omega = \{1, 2, 3, 4, 5, 6\}\), причём с равной вероятностью: \(p(x=i) = \frac{1}{6}\).

+

Непрерывные распределения

+

Случайные переменные могут быть не только дискретными, но и непрерывными. Например, мы можем ввести величину: случайное число от 0 до 1.

+

Плотность вероятности.

+

Ожидание и дисперсия

+

Нормальное распределение

+

Парадокс дней рождений

+

Условные вероятности

+

Теория информации

+

+

Случайные переменные могут быть дискретными или непрерывными. Дискретные переменные имеют конечное (броски кубика) или счётное количество состояний, а непрерывные переменные принимают значения на континууме.

+

Функцию X : R будем называть случайной величиной. Она сопостав-

+

ляет каждому элементарному исходу x какое-то число.

+

Например, мы можем ввести случайную переменную для исходов броска шестигранного кубика.

+

Случайные переменные могут быть дискретными или непрерывными. Дискретные переменные имеют конечное или счётное количество состояний. Непрерывные переменные принимают действительные значения.

+

Вероятностным пространством \(\Omega\) называют какое-то множество элементарных исходов. Это, в общем-то, любое множество: например, \(\{1, 2, 3, 4, 5, 6\}\) - исходы на кубике, или \(\{(x, y) | 1 \leq x, y \leq 6\}\) - исходы на двух последовательно брошенных кубиках.

+

Событием \(A\) в этом пространстве называется некоторое подмножество вероятностного пространства \(\Omega\), например, пустое, или само \(\Omega\), или \(\{1, 2, 6\}\) — подмножество \(\{1, 2, 3, 4, 5, 6\}\).

+

События можно пересекать, объединять, дополнять — они же подмножества.

+

На некоторых событиях (в том числе элементарных исходах) определена функция \(P\), которую называют вероятностью. Она удовлетворяет следующим условиям:

+
    +
  1. \(P(A \cup B) = P(A) + P(B)\), если они не пересекаются (\(A \cap B = \emptyset\))

  2. +
  3. \(P(\Omega) = 1\)

  4. +
+

Чаще всего (если ничего не указывает на обратное) на элементарных исходах вероятности равны — это называется равномерным распределением.

+

Во всех этих задачах первого раздела в решениях нужно первым делом описать вероятностное пространство.

+% +

% % TODO куда-то передвинуть % Андрей, Серёжа и Лёша едут в электричке из Долгопрудного. Отсеки по 6 человек, по 12 в вагоне, вагонов 9. Они хотели бы найти места, где они могут сесть втроем. Они заглянули в вагон и увидели там 7 свободных мест. Они хотят узнать, имеет ли смысл ходить по поезду в поиске свободного места. Помогите им, посчитав матожидание числа вагонов (вероятность найти место?), которые им нужно пройти.

+

% TODO: какое-то из чисел нужно пофиксить так, чтобы ответ был 1/2 Андрей и Серёжа играют в игру. Они подбрасывают монетку \(N\) раз. Если орлов выпало \(K\) и больше, то побеждает Андрей. В противном случае побеждает Серёжа (ничья не предусмотрена). С какой вероятностью победит Серёжа?

+
    +
  1. N = 3, K = 2

  2. +
  3. N = 99, K = 50

  4. +
  5. N = 100, K = 50

  6. +
+

Каждый школьник на сборах изучает хотя бы один из трех языков: Java, Python и C++. Вероятности, что случайно выбранный школьник изучает соответственно Java, Python и C++ равны \(0.4\), \(0.5\) и \(0.6\). Вероятность, что школьник изучает Java и Python, равна \(0.2\). Вероятность, что школьник изучает Python и C++, равна \(0.3\). Вероятность, что школьник изучает C++ и Java, равна \(0.2\).

+

Найдите вероятность, что школьник изучает все три языка программирования. Найдите вероятность того, что школьник изучает только C++.

+

Петя случайным образом разбивает число 10 на сумму трех слагаемых (целые, больше нуля). Опишите вероятностное пространство и вычислите вероятность того, что среди слагаемых будет число 4. Порядок слагаемых в разложении важен.

+

На шахматной доске размера \(n \times n\) случайно размещают \(n\) ладей. Найдите вероятность следующих событий:

+
    +
  1. \(A = \{\)ладьи не бьют друг друга\(\}\)

  2. +
  3. \(B = \{\)ладьи не бьют друг друга, и на главной диагонали нет никаких фигур\(\}\)

  4. +
+Докажите, что вероятность того, что на выборах с участием двух кандидатов, в которых первый набрал \(p\) голосов, а второй набрал \(q \leq p\), первый будет опережать второго в течение всего времени подсчета, равна \(\frac{p-q}{p+q}\). +

В этих задачах используется красивый прием — вероятность равна отношению каких-нибудь площадей.

+

Случайная точка \(A\) имеет равномерное распределение в прямоугольнике со сторонами \(1\) и \(2\). Найдите вероятность следующих событий:

+
    +
  1. расстояние от точки \(A\) до любой стороны прямоугольника не превосходит \(0.1\)

  2. +
  3. расстояние от точки \(A\) до ближайшей большей стороны прямоугольнике меньше, чем до ближайшей меньшей стороны.

  4. +
+

\(X\) и \(Y\) равномерно распределены на отрезке \([0, 1]\). Какая вероятность того, что \(X^2 + Y^2 \leq 1\)?

+

Петя и Вася договорились встретиться с 12:00 до 13:00, но не договорились в какое время, поэтому каждый из них решил прийти в случайное время, подождать 10 минут и уйти. С какой вероятностью они встретятся?

+Найти вероятность того, что из трех наудачу взятых отрезков длиной не более, чем 1, можно составить треугольник. +

По определению, вероятность \(A\) при условии \(B\) равна \(P(A | B) = \frac{P(A \cap B)}{P(B)}\). Это называется условной вероятностью, и в бытовом смысле это вероятность события \(A\), если мы уже знаем, что событие \(B\) точно произошло (мы как бы сужаем вероятностное пространство до \(B\), отсюда и формула).

+

По определению, \(A \perp B\) (\(A\) независимо с \(B\)), если \(P(A \cap B) = P(A) P(B)\). Это совпадает с бытовым понятием независимости событий. Если монетку кидают несколько раз, то считается, что эти броски независимы.

+

Помещение освещается фонарем с тремя лампами. Вероятность перегорания одной лампы в течение года равна \(0.3\). Найдите вероятность того, что в течение года хотя бы одна лампа не перегорит.

+
    +
  1. Докажете формулу Байеса: \[P(A|B) = \frac{P(B|A)P(A)}{P(B)}\]
  2. +
+

По смыслу это очень крутая формула — обычно ты знаешь вероятность событий при каких-то условиях, а вот вероятности выполнения этих условий неочевидны. С помощью этих формул можно посчитать вероятность условий при условии выполнения этих событий.

+
    +
  1. Докажите формулу полной вероятности: \[P(A) = P(A|B_1)P(B_1) + ... + P(A|B_n)P(B_n)\] при условии \[\Omega = B_1 \cup ... \cup B_n (B_i \cap B_j = \emptyset)\]
  2. +
+

Это важная и интуитивно понятная формула — событие просто разбивается на непересекающиеся подслучаи, считаются вероятности событий в этих случаях, и усредняется с весами, равными вероятностям этих случаев.

+

Из 30 стрелков 12 попадает в цель с вероятностью 0,6, 8 - с вероятностью 0,5 и 10 – с вероятностью 0,7. Наудачу выбранный стрелок произвел выстрел, поразив цель. К какой из этих трех групп вероятнее всего принадлежал этот стрелок? Найдите вероятность его принадлежности к этой группе (при условии, что он действительно попал).

+% Я не понял ничего про эту задачу % +

% Вы опрашиваете людей на выходе из избирательного участка, кто за кого проголосовал. Всего есть два кандидата — А и Б. Вы опросили 100 людей, из которых 55 проголосовали за А. С какой вероятностью кандидат А победит?

+

Привести примеры, показывающие, что равенства

+

\[P(B|A) + P(B|\overline A) = 1\]

+

\[P(B|A) + P(\overline B|\overline A) = 1\]

+

неверны.

+

События \(A_1\), …, \(A_k\) по определению независимы в совокупности, если для любого их подмножества \(A_{i_1}\), …, \(A_{i_t}\) верно равенство \(P(A_{i_1}, ..., A_{i_t}) = P(A_{i_1}) \times ... \times P(A_{i_t})\)).

+

Приведите пример трех попарно независимых событий, которы, тем не менее, в совокупности зависимы.

+Пусть \(A\), \(B\), \(C\) – попарно независимые равновероятные события, причем \(A \cap B \cap C = \emptyset\). Найдите максимально возможное значение \(P(A)\). +

Функцию \(X: \Omega \rightarrow\mathbb{R}\) будем называть случайной величиной. Она сопоставляет каждому элементарному исходу какое-то число. Тогда можно определить и \(P(X \in A)\) - вероятность, что случайная величина \(X\) лежит в каком-то подмножестве действительных чисел \(A\), потому что \(X \in A\) — это событие в вероятностом пространстве \(\Omega\).

+

Функцией распределения называют \(F_X(x) = P(X \leq x)\).

+

Плотностью распределения называют либо \(\rho_X(k) = P(X = k)\) (в дискретном случае), либо \(\rho_X(x) = F_X(x)'\) — производную функции распределения.

+

Найдите и нарисуйте функции распределения и плотности следующих случайных величин:

+
    +
  1. \(X = 1\)

  2. +
  3. $X =

    +\[\begin{cases} + 0, & p \\ + 1, & 1-p +\end{cases}\] +

    $

  4. +
  5. \(X \sim U[0, 1]\) (\(X\) равномерно распределена на отрезке \([0, 1]\))

  6. +
+

Петя кидает монету, с вероятностью \(p\) выпадает орел и он прекращает кидать ее, с вероятностью \(1-p\) выпадает решка и он кидает ее еще раз. Пусть \(X\) - это количество бросков. Найдите \(\rho_X(x)\) и \(F_X(x)\).

+% +% % Это лучше рассказать просто так % Мост может выдержать до \(k\) машин. Чтобы проехать по нему, требуется 1 час. В каждую очень малую единицу времени заезжает машина с вероятностью \(\lambda\). Найдите вероятность того, что мост рухнет в промежуток времени \(t\), то есть по нему за это время проедет более \(k\) машин. +

Мат. ожиданием случайной величины \(X\), которая принимает значения \(a_0\), \(a_1\), … называют \[E[X] = a_0 P(X = a_0) + a_1 P(X = a_1) + ... = \sum_{x} x p_X(x) \]

+

Если X принимает несчетное число значение (например, равномерное распределение на отрезке \([0, 1]\)), то мат. ожидание определяется как интеграл фукнции \(x\) умножить на плотность (достаточно понимать, что интеграл - это площадь под графиком этой функции): \[E[X] = \int\limits_{-\infty}^{\infty} x \rho (x) dx\]

+

Дисперсия определяется как \(D[X] = E[(X-E[X])^2]\) — средний квадрат отклонения случайной величины от ее мат. ожидания.

+

В бытовом смысле мат. ожидание — это среднее значение случайное величины, а именно если взять несколько независимых одинаково распределенных случайных величин, то их среднее действительно будет стремиться к мат. ожиданию (например на физике делают несколько опытов и усредняют ответ именно для этого).

+

В бытовом смысле дисперсия — это насколько случайная величина шумная.

+

Самая главная вещь в теории вероятностей: мат. ожидание линейно:

+

\[\begin{align*} +E[X+Y] & = \sum_{x, y} (x+y) p(x, y) +\\ & = \sum_{x, y} x p(x, y) + \sum_{x, y} y p(x, y) +\\ & = \sum_x x p(x) \sum_y p(y) + \sum_y y p(y) \sum_x p(x) +\\ & = \sum_x x p(x) + \sum_y y p(y) +\\ & = E[X] + E[Y] +\end{align*}\]

+

Если \(X \perp Y\) (это значит, что все события вида \(X \in A\) независимы всем событиям вида \(Y \in B\)), то еще и произведение можно снимать:

+

\[ E[X \cdot Y] = E[X] \cdot E[Y] \]

+

% Дисперсия суммы, произведения, домноженя на константу?

+

Найдите мат. ожидания и дисперсии следующих случайных величин:

+
    +
  1. \(X = 1\)

  2. +
  3. $X =

    +\[\begin{cases} + 0, & p \\ + 1, & 1-p +\end{cases}\] +

    $

  4. +
  5. \(X \sim U[0, 1]\) (\(X\) равномерно распределена на отрезке \([0, 1]\))

  6. +
  7. Случайная величина из задачи 4.2 (геометрическое распределение) [10-11 классам надо честно найти, 8-9 классам можно погуглить чему равна сумма ряда, так как для его нахождения нужно уметь дифференцировать]

  8. +
+

Докажите, что \[D[X] = E[X^2] - E[X]^2\]

+

Кинули кубик \(N\) раз, найдите мат. ожидание числа выпавших шестерок.

+Подсказка: случайную величину \(X[\)количество выпавших шестерок\(]\) можно представить как \[X = I_1 + ... + I_N\] где $I_k = +\[\begin{cases} + 1, & $на k-м кубике выпало $ 6 \\ + 0, & $иначе$ +\end{cases}\] +

$ Подсказка 2: используйте после этого линейность мат. ожидания

+

Взяли \(N\) вершин и каждую пару разных вершин соединили с вероятностью \(0.5\). Найдите

+
    +
  1. мат. ожидание числа ребер

  2. +
  3. мат. ожидание числа треугольников

  4. +
+

Взяли \(N\) вершин и каждую пару разных вершин соединили с вероятностью \(0.5\). Найдите

+
    +
  1. дисперсию числа ребер

  2. +
  3. дисперсию числа треугольников

  4. +
+

% ты знаешь доказательство «на пальцах»? % доказательство чего? ЦПТ % может не надо? может в следующий раз? % Ну, хоть как-то убедить в правильности % Там, про выборы и соцопросы рассказать % мне кажется, это можно упомянуть и не доказывать

+

% кстати, зацени http://sereja.me/a/pollard

+

%Метод Монте-карло и его относительная ошибка.

+

Есть замечательное нормальное распределение \(N(a, \sigma^2)\) — это распределение со страшной плотностью \[\rho(x) = \frac{1}{\sigma\sqrt{2\pi}}e^{-{\frac{(x-a)^2}{2\sigma^2}}}\] Причем его мат. ожидание равно \(a\), а дисперсия равна \(\sigma^2\).

+

Оно замечательно тем, что если взять много независимых случайных величин одного любого распределения \(X\), то распределение их среднего арифметического примерно равно \(N(E[X], \frac{D[X]}{N})\). Этот факт (более строгий, конечно) называется Центральной Предельной Теоремой.

+

Нарисуйте, как распределена (график плотности, или просто значения плотности) сумма значений на N игральных кубиках при

+
    +
  1. N = 1

  2. +
  3. N = 2

  4. +
  5. N = 3

  6. +
+

d*) N = 5

+

e*) N = 100

+

Подсказка: для 5 и 100 можно написать программу и нарисовать гистгорамму.

+Известно, что сумма двух нормально распределенных случайных величин — тоже нормально распределенная случайная величина. Пусть \(X \sim N(0, 1), Y \sim N(5, 4)\), найдите параметры (мат. ожидание и дисперсию) у случайной величины \(2X + Y\). +

Про рандомизированные алгоритмы мы очень часто говорили фразу “в среднем работает за O(n)”. Это означает, что количество операций — это случайная величина, и её мат. ожидание — O(n).

+

Докажите, что Quicksort со случайным выбором опорного элемента на любой фиксированной перестановке работает в среднем за \(O(n\log{n})\) сравнений.

+

Подсказка 1: разбейте количество сравнений на простые случайные величины и используйте линейность мат. ожидания.

+

Подсказка 2: докажите лемму — два числа \(x_i\) и \(x_j\) сравниваются только в том случае, если опорным элементом среди всех элементов между ними (в упорядоченном массиве) первым опорным элементом был выбран один из этих двух.

+

Найдите мат. ожидание глубины вершины случайного бинарного дерева (например, декартово дерево таким является), а именно такого дерева, у которого приоритеты вершин распределены одинаково, и по приоритетам дерево является кучей.

+

Подсказка: Докажите лемму — для любых \(i\neq k\) , \(x_i\) является предком \(x_k\) тогда и только тогда, когда \(x_i\) имеет наибольший приоритет среди \(x_{min(i, k)}\), $x_{min(i, k) + 1} … x_{max(i, k)} $.

+

Предполагая, что вероятность того, что число \(k\) простое, равно \(\frac{1}{\ln k}\), покажите, что обычное решето Эратосфена работает за \(O(n \log \log n)\).

+% +

% Предполагая, что простые числа распределены как в прошлой задаче, «докажите» гипотезу Гольдбаха.

+

Дано корневое дерево. Каждую итерацию выбирается вершина (равновероятно из всех оставшихся), и удаляется всё поддерево, соответствующее этой вершине. Найти, сколько ходов в среднем будет продолжаться этот процесс, то есть матожидание номера итерации, на которой будет удалён корень дерева.

+

Пусть при мердже двух деревьев мы делаем подвешивание не за вершину с большим приоритетом, а следующим образом:

+

где \(L\) и \(R\) это размеры соответствующих деревьев.

+

Покажите, что каждая вершина всё так же равновероятно будет корнем дерева.

+% +% +

% Дан следующий код:

+

% % что-то не работает, как исправить? % \begin{lstlisting}[language=Python] % x = 0 % while x < 1: % x += random() % \end{lstlisting}

+

% Требуется посчитать матожидание x.

+

% (random в питоне возвращает случайное действительное число от 0 до 1.)

+% +

% Пьяница стоит на краю обрыва (обрыв слева). С вероятностью \(p\) он идёт на один шаг вправо, с вероятностью \(p-1\) — на один шаг влево. Посчитайте вероятность того, что пьяница когда-либо упадёт.

+% +

% Вы стартуете с \(A\) долларов и играете в азартную игру, постоянно делая ставку один доллар, которая дает вам два доллара с вероятностью \(p < \frac{1}{2}\). Для оплаты смены в лагере Юнивёрсум вы хотите в какой-то момент получить \(B > A\) долларов, и как только это произойдет, вы играть прекратите. Найдите вероятность, что вы уйдете с деньгами. Вы либо уходите с \(B\) долларами, либо разоряетесь. Возможно, вам придётся играть бесконечно.

+

Плотность распределения

+

Пусть есть некоторая случайная величина \(X\). Плотностью распределения \(X\) называется функция \(p(x)\), отражающая относительную вероятность наступления события \(X=x\) (то есть относительно всех остальных возможных событий). Например, для случайной величины с конечным множеством элементарных исходов \(\Omega\) \(p(x)=P(X=x)\) (где \(P(A)\) - вероятность наступления события \(A\)). Обычно функция плотности вероятности предполагается нормированной на единицу (то есть площадь под графиком равна 1). Функцией распределения случайной численной величины \(F(x)\) называется вероятность попадания \(X\) в луч \((-\infty;\,x]\) (или \(\int_{-\infty}^{x}p(x)\,dx\)).

+

Матожидание

+

Предположим, у нас есть случайная численная величина \(X\) с функцией плотности \(p(x)\). Тогда математическое ожидание случайной величины равно сумме \(P(A) \cdot A\) для всех возможных исходов \(A\). TODO

+

Дисперсия

+

Какие два числа лучше всего описывают распределение?

+

Нормальное распределение

+

Центральная предельная теорема названа так пафосно вполне обоснованно.

+

Она говорит, что нам достаточно про каждое слагаемое знать всего два числа — ожидание и дисперсию.

+

\[ f(x) = \frac{1}{2\sqrt{\pi}} e^{-\frac{(x-\mu)^2}{\sigma^2}} \]

+

Доказать это очень трудно. Даже со ссылками на мощные теоремы оно займёт не одну страницу. Обычно доказательство рассказывают в середине второго курса.

+

Трудно даже доказать, что это распределение, т. е. что.

+

Применения

+

Пусть в некоторой стране есть два кандидата в президенты, назовём их Путин и Навальный.

+

Мы спросили у 1000 случайных избирателей бинарный вопрос, и 510 из них сказали, что будут голосовать за Путина. С какой вероятностью он победит? Теорема говорит, что число голосов, как

+

Линейные рекурренты

+

Чтобы решать следующие задачи, нам нужно будет использовать следующий факт:

+

+

Доказательство мы не приведем.

+

В частности, таким образом получается формула для чисел Фибоначчи.

+

\[ f_n = \ldots \]

+

Кто бы мог подумать, что все эти иррациональности и степени сократятся и вообще дадут целое число?..

+

Классика

+

Парадокс дней рождения.

+

Это на самом деле очень часто используемый результат. Так можно считать вероятность коллизии хэшей, а также он используется во многих теоретико-числовых алгоритмах, в которых используется предположения (весьма справедливые) о распределении простых чисел.

+

Пьяница. Человек стоит на краю обрава и идёт в его сторону с вероятностью p. С какой вероятностью он когда-либо в него упадёт?

+

TODO: история про эстетическое удовольствие, азарт и смысл посещения казино. Казино. Мы приходим в казино с 1000$ и следующим образом проводим там время: ставим по 1$, пока не обанкротимся или не выиграем 1100$. Какая вероятность того, что мы уйдём с деньгами?

+

Парадокс дней рождений

+
+

В группе, состоящей из 23 или более человек, вероятность совпадения дней рождения хотя бы у двух людей превышает 50%.

+
+

Более общее утверждение: в мультимножество нужно добавить \(\Theta(\sqrt{n})\) случайных чисел от 1 до n, чтобы какие-то два совпали.

+

Первое доказательство (для любителей матана). Пусть \(f(n, d)\) это вероятность того, что в группе из \(n\) человек ни у кого не совпали дни рождения. Будем считать, что дни рождения распределены независимо и равномерно в промежутке от \(1\) до \(d\).

+

\[ +f(n, d) = (1-\frac{1}{d}) \times (1-\frac{2}{d}) \times ... \times (1-\frac{n-1}{d}) +\]

+

Попытаемся оценить \(f\):

+

\[ +\begin{aligned} + e^x & = 1 + x + \frac{x^2}{2!} + \ldots & \text{(ряд Тейлора для экспоненты)} \\ + & \simeq 1 + x & \text{(аппроксимация для $|x| \ll 1$)} \\ + e^{-\frac{n}{d}} & \simeq 1 - \frac{n}{d} & \text{(подставим $\frac{n}{d} \ll 1$)} \\ + f(n, d) & \simeq e^{-\frac{1}{d}} \times e^{-\frac{2}{d}} \times \ldots \times e^{-\frac{n-1}{d}} & \\ + & = e^{-\frac{n(n-1)}{2d}} & \\ + & \simeq e^{-\frac{n^2}{2d}} & \\ +\end{aligned} +\]

+

Из последнего выражения более-менее понятно, что вероятность \(\frac{1}{2}\) достигается при \(n \approx \sqrt{d}\) и в этой точке изменяется очень быстро.

+

Второе доказательство (для любителей теорвера). Введем \(\frac{n(n-1)}{2}\) индикаторов — по одному для каждой пары людей \((i, j)\) — каждый будет равен единице, если дни рождения совпали. Ожидание и вероятность каждого индикатора равна \(\frac{1}{d}\).

+

Обозначим за \(X\) число совпавших дней рождений. Его ожидание равно сумме ожиданий этих индикаторов, то есть \(\frac{n (n-1)}{2} \cdot \frac{1}{d}\).

+

Отсюда понятно, что если \(d = \Theta(n^2)\), то ожидание равно константе, а если \(d\) асимптотически больше или меньше, то \(X\) стремится нулю или бесконечности соответственно.

+

Примечание: формально, из этого явно не следует, что вероятности тоже стремятся к 0 и 1.

+

Бонус: «мета-задача»

+

Дана произвольная строка, по которой известным только авторам задачи способом генерируется ответ yes/no. В задаче 100 тестов. У вас есть 20 попыток отослать решение. В качестве фидбэка вам доступны вердикты на каждом тесте. Вердиктов всего два: OK (ответ совпал) и WA. Попытки поделить на ноль, выделить терабайт памяти и подобное тоже считаются как WA.

+

«Решите» задачу.

+

Принцип максимального правдоподобия

+

Энтропия

+

Энтропией называется минимальное число бит, которым теоретически возможно сжать сообщение. Эта величина важна, потому что на практике если её можно посчитать, то сжатие с соответствующей кратностью реально достижимо.

+

Шумный канал.

+

Пусть у вас есть 1тб данных и два китайских терабайтника, на каждый из которых можно записать столько данных, но каждый бит имеет вероятность 10% записаться на противоположный. Требуется сохранить данные с первого раза без потерь. Совсем без потерь.

+

Причём это делается почти впритык.

+

Теорвер для алгоритмистов

+

Вы, наконец, узнаете, почему ДД работает за логарифм, почему быстрая сортировка быстрая, какой модуль нужно выбирать для хэшей, сколько сэмплов нужно выбирать для монте-карло и т. д.

+

Следующие строчки позволят нам генерировать распределения и рисовать графики, не обращайте внимание.

+

Аксиоматика

+

В вузах теорвер очень долго аксиоматизируют перед тем, как перейти к чему-то полезному. Сейчас поясним, зачем.

+

[Парадокс Бертрана](https://ru.wikipedia.org/wiki/%D0%9F%D0%B0%D1%80%D0%B0%D0%B4%D0%BE%D0%BA%D1%81_%D0%91%D0%B5%D1%80%D1%82%D1%80%D0%B0%D0%BD%D0%B0_(%D0%B2%D0%B5%D1%80%D0%BE%D1%8F%D1%82%D0%BD%D0%BE%D1%81%D1%82%D1%8C%29):

+
+

Рассмотрим равносторонний треугольник, вписанный в окружность. Наудачу выбирается хорда окружности. Какова вероятность того, что выбранная хорда длиннее стороны треугольника?

+
+

Оказывается, можно придумать хотя бы три способа решать задачу, которые выглядят адекватно, но все дают разные ответы.

+
    +
  1. Наудачу выберем две точки на окружности и проведём через них хорду. Чтобы посчитать искомую вероятность, представим, что треугольник повёрнут так, что одна из его вершин совпадает с концом хорды. Заметим, что если другой конец хорды лежит на дуге между двумя другими вершинами треугольника, то длина хорды больше стороны треугольника. Длина рассмотренной дуги равна трети длины окружности, значит искомая вероятность равна \(\frac13\).

  2. +
  3. Зафиксируем радиус окружности, наудачу выберем точку на радиусе. Построим хорду, перпендикулярную зафиксированному радиусу, проходящую через выбранную точку. Для нахождения искомой вероятности, представим, что треугольник повёрнут так, что одна из его сторон перпендикулярна зафиксированному радиусу. Хорда длиннее стороны треугольника, если её центр ближе к центру, чем точка пересечения треугольника с зафиксированным радиусом. Сторона треугольника делит пополам радиус, следовательно вероятность выбрать хорду длиннее стороны треугольника \(\frac12\).

  4. +
  5. Выберем наудачу произвольную точку внутри круга и построим хорду с центром в выбранной точке. Хорда длиннее стороны равностороннего треугольника, если выбранная точка находится внутри круга, вписанного в треугольник. Площадь вписанного круга есть \(\frac14\) от площади большего, значит исходная вероятность равна \(\frac14\).

  6. +
+

Как мы увидели, от формального определения «случайной хорды» непосредственно зависит ответ.

+

Каждый раз, когда в истории математики появляется подобный приводящий к протеворечиям парадокс, математики паникуют и начинают всё формализовывать и аксиоматизировать. Так появилась теория вероятностей (внимание: не «ти», а «тей»).

+

Перейдем к самим определениям:

+

Функцию \(X : \Omega \to R\) будем называть случайной величиной. Она сопостав- ляет каждому элементарному исходу какое-то число.

+

Распределения

+

Геометрическое распределение.

+

Парадокс дней рождений

+

Пусть \(f(n, d)\) это вероятность того, что в группе из \(n\) человек ни у кого не совпали дни рождения. Будем считать, что дни рождения распределены независимо и равномерно в промежутке от \(1\) до \(d\).

+

\[f(n, d) = (1-\frac{1}{d}) \times (1-\frac{2}{d}) \times ... \times (1-\frac{n-1}{d})\]

+

Попытаемся оценить \(f\):

+

\[ +\begin{align} + \begin{aligned} + e^x & = 1 + x + \frac{x^2}{2!} + \ldots & \text{(ряд Тейлора для экспоненты)} \\ + & \simeq 1 + x & \text{(аппроксимация для $|x| \ll 1$)} \\ + e^{-\frac{n}{d}} & \simeq 1 - \frac{n}{d} & \text{(подставим $\frac{n}{d} \ll 1$)} \\ + f(n, d) & \simeq e^{-\frac{1}{d}} \times e^{-\frac{2}{d}} \times \ldots \times e^{-\frac{n-1}{d}} & \\ + & = e^{-\frac{n(n-1)}{2d}} & \\ + & \simeq e^{-\frac{n^2}{2d}} & \\ + \end{aligned} +\end{align} +\]

+

Матожидание

+

Математическим ожиданием случайной величины \(X\), которая принимает значения \(x_1, x_2, \ldots\) называется

+

\[ E[X] = \sum_{x \in S} p_S(x) \cdot x \]

+

Самое главное для нас свойство — ожидание линейно:

+

\[ +\begin{align*} +E[X+Y] & = \sum_{x, y} (x+y) p(x, y) +\\ & = \sum_{x, y} x p(x, y) + \sum_{x, y} y p(x, y) +\\ & = \sum_x x p(x) \sum_y p(y) + \sum_y y p(y) \sum_x p(x) +\\ & = \sum_x x p(x) + \sum_y y p(y) +\\ & = E[X] + E[Y] +\end{align*} +\]

+

Акцентируем внимание: \(X\) и \(Y\) вообще не важно какие. Возможно, они связаны как-то очень сложно, но это не важно. Как мы потом, увидем, это свойство очень упрощает вычисление матожиданий каких-то очень сложных шняг.

+

Также, в частности его можно домножать на константу.

+

Теперь можно перейти к практическим задачам.

+

Геометрическое распределение

+
+

Петя кидает монету, с вероятностью \(p\) выпадает орел и он прекращает кидать ее, с вероятностью \(1-p\) выпадает решка и он кидает ее еще раз. Пусть \(X\) - это количество бросков. Найдите \(\rho_X(x)\) и \(F_X(x)\).

+
+

Неподвижные точки в перестановке

+

Когда автор пришел на первое занятие по английскому в МФТИ, препод устроила следующую игру на знакомство: разделила всех студентов на две команды. В каждой команде про своих членов загадываются факты: кто-то мечтал стать акробатом, кто-то смотрит анимэ и всё в таком духе. Команде соперников сообщались только эти факты, и им нужно было отгадать, кому какой принадлежит. Побеждает команда, которая отгадала больше фактов о соперниках. Нас было 11 — простое число, никак не делящееся на равные команды, и поэтому в одной команде было 5 человек, а в другой 6. Автору стало интересно: если отбросить все психологические аспекты и делать все рандомно, у какой команды вероятность победить выше?

+

Следует заметить, что все-таки ожидание правильно отгаданных вопросов не очень помогает выяснить, у какой команды есть преимущество и какое.

+

Высота декартова дерева

+

Высота дерева. Высота в среднем логарифмическая. Любознательные могут ознакомиться с типа доказательством, но это не обязательно.

+

Глубина вершины — это количество родителей (прим. К. О.). Введем индикатор. $E[h] = n $.

+

Асимптотика quicksort-а

+

Тут на самом деле будет примерно так же, как с ДД. Нас интересует суммарное число сравнений. Просуммируем для каждой пары элементов вероятность, что они будут сравнены.

+

Как для пары определить эту вероятность? Посмотрим на все элементы между ними, будь они в отсортированном массиве.

+

Внезапно возникает гармонический ряд, ну а дальше мы знаем.

+

Дисперсия

+

Дисперсия — это количественная метрика. Это не что-то абстрактное, как могли рассказать в школе на теорвере, а формально определенная величина, имеющая кучу всяких полезных свойств.

+

Дисперсия определяется как средний квадрат отклонения случайной величины от ее матожидания:

+

\[ D[X] = E[(X − E[X])^2] \]

+

Её проще считать по другой формуле, разложив квадрат внутри ожидания:

+

\[ +\begin{align} +D[X] &= E[(X − E[X])^2] +\\ &= E[X^2 - 2 \cdot X \cdot E[X] + E[X]^2] +\\ &= E[X^2] - E [\underbrace{2 \cdot E[X]}_{const} \cdot X ] + E[E[X]^2] +\\ &= E[X^2] - 2 E[X]^2 + E[X]^2 +\\ &= E[X^2] - E[X]^2 +\end{align} +\]

+

Эту формулу мы будем использовать для вывода разных свойств.

+

У дисперсии очень много крутых свойств.

+

\[ D[k X] = E[k^2 X^2] - E[k X]^2 = k^2 (E[X^2] - E[X]^2) = k^2 D[X] \]

+

Предполагаем, что \(X\) и \(Y\) независимы.

+

\[ +\begin{align} +D[X + Y] &= E[(X+Y)^2] - E[X+Y]^2 +\\ &= E[X^2 + X Y + Y^2] - (E[X] + E[Y])^2 +\\ &= E[X^2] + E[Y^2] - E[X]^2 - E[Y]^2 +\\ &= D[X] + D[Y] +\end{align} +\]

+

Важное отличие от свойств матожидания: дисперсию так просто можно считать только для независимых величин.

+

Закон больших чисел

+

Закон больших чисел — принцип, который описывает результат выполнения одного и того же эксперимента много раз. Согласно закону, среднее значение конечной выборки из фиксированного распределения близко к математическому ожиданию этого распределения.

+

У матожидания (константы) стандартное обозначение \(\mu\) (мю), а у дисперсии \(\sigma\) (сигма).

+

\[ M_n = \frac{X_1 + \ldots + X_n}{n} \]

+

\[ E[M_n] = \frac1n E[X_1 + \ldots + X_n] = \mu \]

+

Это немного очевидное равенство. Теперь нас интересует, насколько точно оно в реальности достигается:

+

\[ D[M_n] = \frac{1}{n^2} D[X_1 + \ldots + X_n] = \frac{\sigma}{n} \]

+

С одной стороны, оно домножается на \(\frac{1}[n^2}\) из-за усреднения, с другой — на \(n\) из-за суммирования.

+

Нормальное распределение

+

Когда вы в каких-нибудь библиотеках для анализа данных просите показать какой-то summary, то чаще всего вы увидите два числа: матожидание и дисперсию.

+

Центральная предельная теорема названа так пафосно вполне обоснованно.

+

Она говорит, что нам достаточно про каждое слагаемое знать всего два числа — ожидание и дисперсию.

+

\[ f(x) = \frac{1}{2\sqrt{\pi}} e^{-\frac{(x-\mu)^2}{\sigma^2}} \]

+

Доказать это очень трудно. Даже со ссылками на мощные теоремы оно займёт не одну страницу. Обычно доказательство рассказывают в середине второго курса.

+

Трудно даже доказать, что это распределение, т. е. что \(\int_{-\inf}^\inf f(x) dx = 1\).

+

Метод Монте-Карло

+

Алгоритмы вида «давайте посчитаем значения в разных случайных точках и усредним» называются методами монте-карло.

+

Пусть у нас есть единичный квадрат и несколько сотен кружков. Нам нужно посчитать долю области квадрата, которая не покрывается ни одним из кругов, с точностью до 1%.

+

Можно просто делать так: тыкать в 10000 случайных точек и проверять для каждой, является ли она «хорошей», а затем вывести \(\frac{\text{хорошие}}{\text{хорошие} + \text{плохие}}\) в качестве ответа.

+

Сдать можно сюда.

+

Хэш-таблицы

+

Вообще, хэш — это такая функция, которая сопоставляет элементу какой-то другой элемент. С точки зрения криптоанализа, она должна быть трудно обратимой, а с точки зрения алгоритмиста — генерирующей равномерные игреки.

+

Можно делать так если мы планируем хранить \(n\) элементов, то нужно завести \(\Theta(n)\) ячеек, каждая из которых будет на самом деле односвязным списокм.

+

Это будет работать, потому что из-за рандома в среднем в каждой ячейке будет по одному элементу. Но константа у такого решения большая.

+

Стандартная хэш-таблица из STL по непонятным автору причинам работает очень медленно. Во многих задачах она является самой нагруженной структурой. Её можно написать в 3-5 раз быстрее.

+ + diff --git a/ru/reciprocal.html b/ru/reciprocal.html new file mode 100644 index 0000000..7d16de1 --- /dev/null +++ b/ru/reciprocal.html @@ -0,0 +1,204 @@ + + + + + + + Обратный элемент по модулю - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Обратный элемент по модулю

+

Часто в задачах требуется посчитать что-то по простому модулю (чаще всего \(10^9 + 7\)). Это делают для того, чтобы участникам не приходилось использовать длинную арифметику, и они могли сосредоточиться на самой задаче.

+

Обычные арифметические операции выполняются не сильно сложнее — просто нужно брать модули и заботиться о переполнении. Например:

+
c = (a + b) % mod;
+c = (mod + a - b) % mod;
+c = a * b % mod;
+

Но вот с делением возникают проблемы — мы не можем просто взять и поделить. Пример: \(\frac{8}{2} = 4\), но \(\frac{8 \% 5 = 3}{2 \% 5 = 2} \neq 4\).

+

Нужно найти некоторый элемент, который будет себя вести как \(\frac{1}{a} = a^{-1}\), и вместо «деления» домножать на него. Назовем такой элемент обратным.

+

Способ 1: бинарное возведение в степень

+

Если модуль \(p\) простой, то решением будет \(a^{-1} \equiv a^{p-2}\). Это следует из малой теоремы Ферма:

+

Теорема. \(a^p \equiv a \pmod p\) для всех \(a\), не делящихся на \(p\).

+

Доказательство. (для понимания несущественно, можно пропустить)

+

\[ +\begin{aligned} +a^p &= (\underbrace{1+1+\ldots+1+1}_\text{$a$ раз})^p +\\ &= \sum_{x_1+x_2+\ldots+x_a = p} P(x_1, x_2, \ldots, x_a) & \text{(раскладываем по определению)} +\\ &= \sum_{x_1+x_2+\ldots+x_a = p} \frac{p!}{x_1! x_2! \ldots x_a!} & \text{(какие слагаемые не делятся на $p$?)} +\\ &\equiv P(p, 0, \ldots, 0) + \ldots + P(0, 0, \ldots, p) & \text{(все остальные не убьют $p$ в знаменателе)} +\\ &= a +\end{aligned} +\]

+

Здесь \(P(x_1, x_2, \ldots, x_n) = \frac{k}{\prod (x_i!)}\) это мультиномиальный коеффициент — количество раз, которое элемент \(a_1^{x_1} a_2^{x_2} \ldots a_n^{x_n}\) появится при раскрытии скобки \((a_1 + a_2 + \ldots + a_n)^k\).

+

Теперь два раза «поделим» наш результат на \(a\).

+

\[ a^p \equiv a \implies a^{p-1} \equiv 1 \implies a^{p-2} \equiv a^{-1} \]

+

Получается, что \(a^{p-2}\) ведет себя как \(a^{-1}\), что нам по сути и нужно. Посчитать \(a^{p-2}\) можно за \(O(\log p)\) бинарным возведением в степень.

+

Приведем код, который позволяет считает \(C_n^k\).

+
int t[maxn]; // факториалы, можно предподситать простым циклом
+
+// бинарное возведение в степень
+int bp (int a, int n) {
+    int res = 1;
+    while (n) {
+        if (n & 1) res = res * a % mod;
+        a = a * a % mod;
+        n >>= 1;
+    }
+    return res;
+}
+
+// находит обратный элемент как a^(p-2)
+int inv (int x) {
+    return bp(x, mod-2);
+}
+
+int c (int n, int k) {
+    return t[n] * inv(t[k]) % mod * inv(t[n-k]) % mod;
+}
+

Способ 2: диофантово уравнение

+

Диофантовыми уравнениями называют такие штуки:

+

\[ ax + by = 1 \]

+

Требуется решить их в целых числах, то есть \(a\) и \(b\) известны, и нужно найти такие целые (возможно, отрицательные) \(x\) и \(y\), чтобы равенство выполнялось. Решают такие вещи расширенным алгоритмом Евклида. TODO: описать, как он работает.

+

Подставим в качестве \(a\) и \(b\) соответственно \(a\) и \(m\)

+

\[ ax + my = 1 \]

+

Одним из решений уравнения и будет \(a^{-1}\), потому что если взять уравнение по модулю \(m\), то получим

+

\[ ax + by = 1 \iff ax \equiv 1 \iff x \equiv a^{-1} \pmod m \]

+

Преимущества этого метода над возведением в степень:

+
    +
  • Если обратное существует, то оно найдется даже если модуль не простой. Способ с бинарным возведением тоже можно заставить работать с произвольным модулем, но это будет намного труднее.
  • +
  • Алгоритм проще выполнять руками.
  • +
+

Сам автор почти всегда использует возведение в степень.

+

Почему \(10^9+7\)?

+
    +
  1. Это выражение довольно легко вбивать (1e9+7).
  2. +
  3. Простое число.
  4. +
  5. Достаточно большое.
  6. +
  7. int не переполняется при сложении.
  8. +
  9. long long не переполняется при умножении.
  10. +
+

Кстати, \(10^9 + 9\) обладает теми же свойствами. Иногда используют и его.

+

Предподсчёт обратных факториалов за линейное время

+

Пусть нам нужно зачем-то посчитать все те же \(C_n^k\), но для больших \(n\) и \(k\), поэтому асимптотика \(O(n \log m)\) нас не устроит. Оказывается, мы можем сразу предподсчитать все обратные ко всем факториалам.

+

Если у нас уже написан inv, то нам не жалко потратить \(O(\log m)\) операций, посчитав \(m!^{-1}\).

+

После этого мы будем считать \((m-1)!^{-1}\) как \(m!^{-1} m = \frac{1}{1 \cdot 2 \cdot \ldots \cdot (m-1)}\).

+
int f[maxn];
+f[0] = 1;
+for (int i = 1; i < maxn; i++)
+    f[i] = i*f[i-1] % mod;
+
+int r[maxn];
+r[maxn-1] = inv(f[maxn-1])
+for (int i = maxn-1; i >= 1; i--)
+    r[i-1] = r[i]*i % mod;
+

TODO: техника с сайта емакса.

+ + diff --git a/ru/segtree.html b/ru/segtree.html new file mode 100644 index 0000000..a81b568 --- /dev/null +++ b/ru/segtree.html @@ -0,0 +1,403 @@ + + + + + + + Дерево отрезков - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Дерево отрезков

+

Замечание. Почти везде мы будем использовать полуинтервалы — обозначаемые как \([l, r)\) — вместо отрезков. Несмотря на контринтуитивность, это немного упростит код и вообще является хорошей практикой в программировании, подобно нумерации с нуля.

+

Дерево отрезков — очень мощная и гибкая структура данных, позволяющая быстро отвечать на самые разные запросы на отрезках.

+

Рассмотрим конкретную задачу:

+
+

Дан массив \(a\) из \(n\) целых чисел, нужно уметь отвечать на запросы двух типов:

+
    +
  1. Изменить значение в ячейке (т. е. отреагировать на присвоение a[k] = x).

  2. +
  3. Вывести сумму элементов \(a_i\) на отрезке с \(l\) по \(r\).

  4. +
+

Оба запроса нужно обрабатывать за время \(O(\log n)\).

+
+

Чтобы решить задачу, сделаем с исходным массивом следующие манипуляции:

+

Посчитаем сумму всего массива и где-нибудь запишем. Потом разделим его пополам и посчитаем сумму на половинах и тоже где-нибудь запишем. Каждую половину потом разделим пополам ещё раз, и так далее, пока не придём к отрезкам длины 1.

+

Эту последовательность разбиений можно представить в виде дерева. Корень этого дерева соответствует отрезку \([0, n)\), а каждая вершина (не считая листьев) имеет ровно двух сыновей, которые тоже соответствуют каким-то отрезкам. Отсюда и название — «дерево отрезков».

+

+

Строить его можно рекурсивной функцией:

+
    +
  • Если вершина является листом, взять в качестве суммы значение соответствующей ячейки.
  • +
  • Если вершина является отрезком, разделить его на два и в качестве суммы взять сумму его детей.
  • +
+

Разные свойства

+

Высота такого дерева есть величина \(\Theta(\log n)\): на каждом новом уровне длина отрезка уменьшается вдвое. Этот факт будет ключевым для оценки асимптотики.

+

Более того, любой полуинтервал разбивается на \(O(\log n)\) неперекрывающихся полуинтервалов, соответствующих в вершинам дерева: с каждого уровня нам достаточно не более двух отрезков.

+

Дерево также содержит менее \(2n\) вершин: первый уровень дерева отрезков содержит одну вершину (корень), второй уровень — в худшем случае две вершины, на третьем уровне в худшем случае будет четыре вершины, и так далее, пока число вершин не достигнет \(n\). Таким образом, число вершин в худшем случае оценивается суммой \(n + \frac{n}{2} + \frac{n}{4} + \frac{n}{8} + \ldots + 1 < 2n\). Значит, оно линейное по памяти.

+

При \(n\), отличных от степеней двойки, не все уровни дерева отрезков будут полностью заполнены. Например, при \(n=3\) левый сын корня есть отрезок \([0, 2)\), имеющий двух потомков, в то время как правый сын корня — отрезок \([2, 3)\), являющийся листом.

+

Ок, как это нам поможет?

+

Опишем теперь, как с помощью такой структуры решить задачу.

+

Запрос обновления. Нам нужно обновить значения в вершинах таким образом, чтобы они соответствовали новому значению \(a[k] = x\).

+

Изменим все вершины, в суммах которых участвует \(k\)-тый элемент. Их будет \(\Theta(\log n)\) — по одной с каждого уровня.

+

Это можно реализовать как рекурсивную функцию: ей передаётся текущая вершина дерева отрезков, и эта функция выполняет рекурсивный вызов от одного из двух своих сыновей (от того, который содержит \(k\)-ый элемент в своём отрезке), а после этого — пересчитывает значение суммы в текущей вершине точно таким же образом, как мы это делали при построении дерева отрезков.

+

Запрос суммы. Мы знаем, что во всех вершинах лежат корректные значения.

+

Сделаем тоже рекурсивную функцию, рассмотрев три случая:

+
    +
  • Если отрезок вершины лежит целиком в отрезке запроса, то вернуть записанную в ней сумму.
  • +
  • Если отрезки вершины и запроса не пересекаются, то вернуть 0.
  • +
  • Иначе разделиться рекурсивно на 2 и вернуть сумму этой функции от обоих детей.
  • +
+

Чтобы разобраться, почему это работает за \(O(\log n)\), нужно оценить количество «интересных» отрезков — тех, которые порождают новые вызовы рекурсии. Это будут только те, которые содержат границу запросов — остальные сразу завершатся. Обе границы отрезка содержатся в \(O(\log n)\) отрезках, а значит и итоговая асимптотика будет такая же.

+

Ликбез по C++

+

Наша реализация будет на указателях. Никто не говорит, что она самая лучшая (см. раздел «Другие реализации»), но она самая понятная. Вам может поначалу показаться, что она слишком сложная, но позже вы поймёте её преимущества.

+

Но сначала нам нужно рассказать про объектно-ориентированное программирование и некоторые фишки C++. Если вы их уже знаете, то можете пропускать этот раздел.

+

Объект — это сущность, которой можно посылать сообщения и которая может на них реагировать, используя свои данные. Инкапсулировать логику в объекты на самом деле очень удобно. Дереву отрезков не важно знать, как устроен окружающий мир, а миру не важно, как внутри устроено дерево отрезков — это просто какая-то структура, которая умеет делать нужные операции за \(O(\log n)\).

+

В C++ есть два способа объявлять классы (объект — это экземпляр класса): через struct и через class. Их основное отличие в том, что по умолчанию в class все поля приватные: к ним нет прямого доступа снаружи. Это нужно для дополнительной защиты, чтобы в крупных промышленных проектах никто случайно ничего не поломал, но на олимпиадах это не очень актуально.

+

У классов есть поля (переменные) и методы (функции, привязанные к объектам). Среди них есть особые, например конструктор — он вызывается при создании объекта. Чтобы объявить конструктор класса в C++, нужно объявить внутри него метод с тем же названием, что и у самого класса.

+
struct A {
+    int param1, param2; // тут можно что-то хранить
+    char param3 = 'k';
+    A (int var) {
+        // эта часть называется конструктором
+        // ...
+    }
+    void do_something () {
+        // это какой-то другой метод
+        // ...
+    }
+}; // <- не забудьте точку с запятой
+

Другое важное понятие — указатель. Память можно представлять как просто очень большой массив. На самом деле, когда мы создаем какой-то объект, отдельная программа (аллокатор) выделяет место в массиве (оперативной памяти) под этот объект и возвращает позицию (указатель) на место в этом массиве.

+

Указатели нам нужны для того, чтобы хранить ссылки на детей. Имея указатель на объект, можно делать всё то же, что и имея сам объект, только синтаксис немного поменяется:

+
A x(179);
+x.do_something();
+x.param1 = 57;
+
+A *y = new A(42); // new возвращает адрес, по которому можно найти объект
+y->do_something();
+y.param3 = '!';
+

Оффтоп: вы не задумывались, почему мы перешли с 32-битных процессоров на 64-битные? Каждый указатель ссылается на байт — более точный адрес менеджер памяти выделять не умеет. Поэтому 32-битный компьютер умеет работать только с не более, чем \(2^{32}\) байтами памяти — это ровно 4 гигабайта — что с какого-то момента начало нехватать. Большинство операций в любом компьютере — это операции с памятью, и разрядность повысили именно из-за этого, а не чтобы операции с long long быстрее считались

+

Реализация

+

Примечание. Данная реализация неэффективна по времени и памяти (см. раздел «другие реализации»). Мы её приводим в педагогических целях.

+

Общий план реализации любых структур данных:

+
    +
  1. Полностью понять все инварианты — как должна выглядеть структура, какие значения должны принимать поля, etc.
  2. +
  3. Формально описать, что должны делать методы и за какую асимптотику.
  4. +
  5. Решить много отдельных задач, реализуя методы, не нарушающие инварианты.
  6. +
+
struct segtree {
+    int lb, rb; // левые и правые границы отрезков
+    int sum = 0; // сумма на текущем отрезке
+    segtree *l = 0, *r = 0;
+    segtree (int _lb, int _rb) {
+        lb = _lb, rb = _rb;
+        if (lb + 1 < rb) {
+            // если не лист, создаем детей
+            int t = (lb + rb) / 2;
+            l = new segtree(lb, t);
+            r = new segtree(t, rb);
+        }
+    }
+    void add (int k, int x) {
+        sum += x;
+        if (l) {
+            if (k < l->rb)
+                l->add(k, x);
+            else
+                r->add(k, x);
+        }
+    }
+    int get_sum (int lq, int rq) {
+        if (lb >= lq && rb <= rq)
+            // если мы лежим полностью в отрезке запроса, вывести сумму
+            return sum;
+        if (max(lb, lq) >= min(rb, rq))
+            // если мы не пересекаемся с отрезком запроса, вывести ноль
+            return 0;
+        // иначе всё сложно -- запускаемся от детей и пусть они там сами решают
+        return l->get_sum(lq, rq) + r->get_sum(lq, rq);
+    }
+};
+
+

Посчитать число беспорядков в перестановке из \(n\) элементов (беспорядок или инверсия — это пара чисел \(i < j\), для которых \(p_i > p_j\)).

+
+

Эта задача решается просто, если уметь писать сортировку слиянием вручную. Но мы пойдем по другому пути. Создадим ДО для суммы на \(n\) элементов, изначально заполненное нулями. Теперь будем проходить по этому массиву слева направо. Когда обрабатываем очередное число \(x\), будем делать две вещи:

+
    +
  • Запросим сумму от \(k\) до \(n\) в ДО.
  • +
  • Добавим единичку в \(k\)-тую позицию в ДО.
  • +
+

Так мы для каждой инверсии учтём её, когда запросим сумму для её правого элемента. Таким образом, мы решили эту задачу за \(O(n \log n)\) запросов.

+
+

Даны \(n\) точек на плоскости с целыми координатами от 1до \(n\). Требуется ответить на \(m\) запросов количества точек на прямоугольнике.

+
+

Ответим на все запросы в оффлайн, используя метод сканирующей прямой:

+
    +
  • Разобьем запросы суммы на прямоугольнике на два запроса суммы на префиксах — сумма на прямоугольнике \([x_1, x_2] \times [y_1, y_2]\) равна сумме на прямоугольнике \([0, x_2] \times [y_1, y_2]\) минус сумма на прямоугольнике \([0, x_1] \times [y_1, y_2]\).
  • +
  • Отсортируем теперь все точки и префиксные запросы по их \(x\). При этом, если у точки и запроса одинаковый \(x\), то точка должна идти раньше.
  • +
  • Пройдёмся по ним в таком порядке и будем решать задачу для одномерной суммы: у нас есть операция «сделать +1 в \(y_i\)» и «вывести сумму с \(y_1\) по \(y_2\)».
  • +
+

Отложенные операции

+

Пусть теперь наш запрос обновления — это присвоение значения \(x\) всем элементам некоторого отрезка \([l, r)\), а не только одному.

+

Мы не хотим спускаться до каждого элемента, где меняется сумма — их может быть очень много. Мы схитрим, и при запросе присваивания будем, по возможности, помечать некоторые вершины, что они и все их дети «покрашены» в какое-то число. Непосредственно спускаться до листьев мы не будем.

+

Например, если пришел запрос «присвой число \(x\) на всем массиве», то мы вообще фактических присвоений делать не будем — только оставим пометку в корне дерева, что оно покрашено.

+

Когда нам позже понадобятся правильные значения таких вершин и их детей, мы будем делать «проталкивание» информации из текущей вершины в её сыновей: если метка стоит, пересчитаем сумму текущего отрезка и передадим эту метку сыновьям. Когда нам потом понадобятся сыновья, мы будем делать то же самое. Подобная операция будет гарантировать корректность данных в вершине ровно к тому моменту, когда они нам понадобятся.

+

Понятно, что от использования таких «запаздывающих» обновлений асимптотика никак не ухудшается, и мы можем всё так же решить задачу за \(O(n \log n)\).

+

При реализации создадим вспомогательную функцию push, которая будет производить проталкивание информации из этой вершины в обоих её сыновей. Вызывать её стоит в самом начале обработки любого запроса — тогда она гарантирует, что в текущей вершине и её сыновьях все значения корректны.

+
struct segtree {
+    int lb, rb;
+    int sum = 0, assign = -1;
+    segtree *l = 0, *r = 0;
+    segtree (int _lb, int _rb) {
+        lb = _lb, rb = _rb;
+        if (lb + 1 < rb) {
+            int t = (lb + rb) / 2;
+            l = new segtree(lb, t);
+            r = new segtree(t, rb);
+        }
+    }
+    void push () {
+        if (assign != -1) {
+            sum = (rb-lb) * assign;
+            if (l) { // если дети есть
+                l->assign = assign;
+                r->assign = assign;
+            }
+        }
+        assign = -1;
+    }
+    void upd (int lq, int rq, int x) {
+        push();
+        if (lq <= lb && rb <= rq)
+            assign = x;
+        else if (l && max(lb, lq) < min(rb, rq)) {
+            // если есть дети и отрезок запроса хоть как-то пересекается с нашим
+            l->upd(lq, rq, x);
+            r->upd(lq, rq, x);
+            // ...дальше они сами разберутся
+        }
+    }
+    int get_sum (int lq, int rq) {
+        push();
+        if (lb >= lq && rb <= rq)
+            return sum;
+        if (max(lb, lq) >= min(rb, rq))
+            return 0;
+        return l->get_sum(lq, rq) + r->get_sum(lq, rq);
+    }
+};
+

По-английски эта техника называется lazy propagation. Очень важно научиться её писать — она часто встречается на олимпиадах.

+

Идея «давайте будем всё делать в последний момент» применима не только в ДО, но и в других структурах и в реальной жизни.

+

Динамическое построение

+

А что, если у нас все индексы лежать не от в пределах \(10^5\), а, например, \(10^9\). Все асимптотики нас по прежнему устраивают (\(\log_2 10^6 \approx 20\), \(\log_2 10^9 \approx 30\)), кроме этапа построения.

+

Можно решить эту проблему так: откажемся от явного создания всех вершин дерева изначально. Изначально создадим только лишь корень, а остальные вершины будем создавать на ходу, когда в них потребуется записать что-то не дефолтное — как в lazy propagation.

+

Реализовать это можно так же, как и с push-ем: в начале всех методов будем проверять, что дети-вершины созданы, и создавать их, если это не так.

+
struct segtree {
+    int lb, rb;
+    int sum = 0;
+    segtree *l = 0, *r = 0;
+    segtree (int _lb, int _rb) {
+        lb = _lb, rb = _rb;
+        // а тут ничего нет
+    }
+    void extend () {
+        if (!l && lb + 1 < rb) {
+            int t = (lb + rb) / 2;
+            l = new segtree(lb, t);
+            r = new segtree(t, rb);
+        }
+    }
+    void add (int k, int x) {
+        extend();
+        sum += x;
+        if (l) {
+            if (k < l->rb)
+                l->add(k, x);
+            else
+                r->add(k, x);
+        }
+    }
+    int get_sum (int lq, int rq) {
+        if (lb >= lq && rb <= rq)
+            return sum;
+        if (max(lb, lq) >= min(rb, rq))
+            return 0;
+        extend();
+        return l->get_sum(lq, rq) + r->get_sum(lq, rq);
+    }
+};
+

Но вообще, в большинстве случаев, использовать динамическое построение — это как стрелять из пушки по воробьям. Если все запросы известны заранее, то их координаты можно просто сжать перед обработкой запросов. Автор обычно делает это так:

+
vector<int> compress (vector<int> a) {
+    vector<int> b = a;
+    sort(b.begin(), b.end());
+    b.erase(unique(b.begin(), b.end()), b.end());
+    for (int &x : a) 
+        x = int(lower_bound(b.begin(), b.end(), x) - b.begin());
+    return a;
+}
+

Персистентность

+

Структуры данных называют персистентными, если их можно быстро «откатить» до произвольного предыдущего состояния.

+

Известны персистентные версии многих структур: стека, очереди, СНМ, ДО. В случае со структурами данных на ссылках есть следующий общий подход: во всех методах, меняющих значения в вершинах, будем копировать ссылки на детей перед тем, как в них переходить и что-либо менять. Таким образом, мы всегда будем делать копию вершины перед тем, как что-либо менять в ней самой или её потомках. Вершины в момент \(t\) никогда не будут ссылаться на вершины, измененные после этого, и поэтому ничего не сломается.

+

У персистентных структур есть один минус: они обычно требуют больше памяти. В случае ДО мы будем создавать \(O(\log n)\) новых вершин на запрос, что означает общее потребление памяти \(O(m \log n)\).

+
struct segtree {
+    int lb, rb;
+    int sum = 0;
+    segtree *l = 0, *r = 0;
+    segtree (int _lb, int _rb) {
+        lb = _lb, rb = _rb;
+        if (lb != rb) {
+            int t = (lb + rb) / 2;
+            l = new segtree(lb, t);
+            r = new segtree(t, rb);
+        }
+    }
+    void copy () {
+        if (l) {
+            l = new segtree(*l);
+            r = new segtree(*r);
+        }
+    }
+    void add (int k, int x) {
+        copy();
+        sum += x;
+        if (l) {
+            if (k < l->rb) l->add(k, x);
+            else r->add(k, x);
+        }
+    }
+    int get_sum (int lq, int rq) {
+        // этот метод ничего не меняет -- он и так хороший
+        if (lq <= lb && rb <= rq)
+            return sum;
+        if (max(lb, lq) >= min(rb, rq))
+            return 0;
+        return l->get_sum(lq, rq) + r->get_sum(lq, rq);
+    }
+};
+
+

Даны \(n\) точек на плоскости. Нужно в онлайн ответить на \(q\) запросов суммы на прямоугольнике.

+
+

Если бы можно было отвечать в оффлайн, мы бы воспользовались методом сканирующей прямой — но так делать мы не можем. Вместо этого мы будем таким же образом добавлять точки в порядке увеличения \(x_i\) и декомпозировать запрос суммы на два, но при ответе на эти запросы мы будем доставать соответствующую версию ДО, которую мы получили, обработав нужное количество точек. Таким образом, можно отвечать на запросы в онлайн, но с \(O(n \log n)\) памяти.

+
+

Дан отрезок из \(n\) чисел от 1 до \(n\). Требуется ответить на \(q\) запросов \(k\)-той порядковой статистики на подотрезке.

+
+

Сделаем такой стандартный препроцессинг: пройдёмся с персистентным деревом отрезков для суммы по массиву. Когда будем обрабатывать элемент \(k\), добавим единицу к \(k\)-ому элементу.

+

Дальше определим разность деревьев как дерево отрезков, которое соответствует разности массивов. Заметим, что он неотрицательный. Его можно получить неявно, спускаясь одновременно в двух ДО и вместо sum использовать везде sum_r - sum_l.

+

Что будет находиться в разности \(r\)-го и \(l\)-го дерева? Там будут количества вхождений чисел на этом отрезке. В таком ДО не составить труда сделать спуск, который находит последнюю позицию, у которой сумма на соответствующем префиксе не превышает \(k\) — она и будет ответом.

+
+

Дан массив из \(n\) элементов. Требуется ответить на \(m\) запросов, есть ли на отрезке \([l, r]\) доминирующий элемент — тот, который встречается на нём хотя бы \(\frac{r-l}{2}\) раз.

+
+

У этой задачи есть удивительно простое решение — взять около 100 случайных элементов и каждый проверить, является ли он доминирующим (это можно проверить за \(O(\log n)\), посчитав для каждого значения отсортированный список позиций, на которых он встречается, и сделав два бинпоиска). Вероятность ошибки в худшем случае равна \(\frac{1}{2^{100}}\), и ей на практике можно пренебречь.

+

Но проверять 100 сэмплов — долго. Можно построить такое же ДО, как в прошлой задаче, и решать задачу «найти число, большее \(\frac{n}{2}\) в массиве на \(n\) элементов». Это тоже будет спуском по ДО: каждый раз идём в того сына, где сумма больше. Если в листе, куда мы пришли, значение больше нужного, возвращаем true, иначе false.

+

Другие реализации

+

Реализация на указателях проста и легко расширяема, но очень медленная и неэффективная по памяти: нужно хранить сами указатели, структура перестаёт помещаться в кэш, нужно много лишних раз ходить по ссылкам в память, только чтобы получить нужные вершины.

+

Если динамическое построение и персистентность для решения задачи не нужны, есть альтернативы, которые в несколько раз быстрее:

+

На массивах. Можно ввести несложную нумерацию вершин, позволяющую при спуске в ребёнка пересчитывать его номер. Это позволит не хранить границы текущего отрезка. Подробнее у Емакса.

+

«ДО снизу». Можно делать все операции итеративно — так получится раз в 7 быстрее, но писать что-либо нетривиальное (например, массовые операции) так будет намного труднее. Подробнее смотрите в посте с CodeForces.

+ + diff --git a/ru/shortest-paths.html b/ru/shortest-paths.html new file mode 100644 index 0000000..2a9966a --- /dev/null +++ b/ru/shortest-paths.html @@ -0,0 +1,163 @@ + + + + + + + Кратчайшие пути в графе - Алгоритмика + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Кратчайшие пути в графе

+

Рассмотрим взвешенный граф, то есть у всех его ребер есть вес - некоторое число. Можно представить, что это цена, за которую мы можем по нему проехать.

+

Как его хранить? Давайте просто в списке смежности вместо номеров вершин соседа хранить пару (номер соседа, вес ребра до него).

+

Давайте решать задачу посика кратчайшего пути в графе - мы хотим за наименьшую стоимость проехать из вершины \(A\) в \(B\).

+

Обычно будем считать, что в графе нет циклов отрицательного веса - иначе кратчайшее расстояниие может быть равно минус бесконечности.

+

Для данной задачи есть несколько алгоритмов решения.

+

Алгоритм Флойда

+

Мы с вами уже знаем динамическое программирование, давайте рассмотри следующую динамику:

+

\(d_{i j}^k\) - это длина кратчайшего пути от вершины \(i\) до \(j\), используя как промежуточные только вершины из первых \(k\) = \(d_{i j}^k\) (напоминает рюкзак).

+

База динамики (\(k = 0\)) определяется только путями из одного ребра. Если есть ребро из \(i\) в \(j\) стоимостью \(c\), то \(d_{i j}^{0}\) = \(c\). Если таких ребер несколько, то, конечно, надо взять минимум.

+

Если мы хотим посчитать \(d_{i j}^{k}\), то у нас есть два варианта

+
    +
  1. Не брать на пути нигде \(k\)-ую вершину, тогда \(d_{i j}^{k}\) = \(d_{i j}^{k - 1}\)

  2. +
  3. Взять где-нибудь в пути k-ую вершину, тогда путь разбивается на две части - от \(i\) до \(k\) и от \(k\) до \(j\), раз итоговый путь кратчайший, то и и эти два пути должны быть кратчайшми, а значит формула \(d_{i j}^{k}\) = \(d_{i k}^{k - 1}\) + \(d_{k j}^{k - 1}\)

  4. +
+

В результате ответ - \(d_{A B}^{n}\),

+

Можете подумать, как по такой динамике восстановить сам путь.

+

Также заметим, что вместо трехмерной динамики в этом алгоритме можно использовать двухмерную, храня в \(dp_{ij}\) последнее известное значение из \(dp_{ij}^0\), \(dp_{ij}^1\) \(dp_{ij}^2\), \(\ldots\), \(dp_{ij}^n\).

+
for(int k = 0; k < n; k++){
+    for(int i = 0; i < n; i++){
+        for(int j = 0; j < n; j++){
+            a[i][j] = min(a[i][j], a[i][k] + a[k][j]);
+        }
+    }
+}
+

Теоретическое задание

+

Подумайте, как находить циклы отрицательного веса с помощью Флойда.

+

Плюсы алгоритма в том, что он находит расстояние сразу от всех вершин графа до остальных, а минус - алгоритм работает за \(O(N^3)\);

+

Практическое задание

+

Первые две задачи на алгоритм Флойда.

+

Алгоритм Дейкстры

+

Для этого алгоритма придется рассматривать только графы без отрицательных рёбер.

+

Алгоритм Дейкстры решает немного другую задачу: он находит расстояние от одной вершины \(A\) до каждой вершины. Давайте для каждой вершины хранить расстояние до нее из вершины \(A\) в массиве \(d\). Например: * \(d[A] = 0\) * \(d[x] = \infty\), если x не достижима из A

+

Назовем релаксацией обновление ответа для вершины \(b\) через ребро \((a, b, c)\) таким способом: \(d[b] = \min(d[b], d[a] + c)\).

+
    +
  • При таком действии ответ не может стать лучше, чем кратчайшее расстояние до \(b\), так как мы просто пользуемся путем для вершины \(a\) и продлеваем его в \(b\).
  • +
  • Если мы прорелаксировали все ребра в кратчайшем пути в правильном порядке до вершины \(b\), то мы получим кратчайший путь до \(b\).
  • +
+

Теперь давайте каждый раз доставать вершину, для которой расстояние от А сейчас минимально, и мы еще ее не смотрели и затем обновлять всех ее соседей. Допустим вершина с минимальным расстоянием - x, тогда надо прорелаксировать все ребра из нее.

+

Давайте докажем корректность алгоритма по индукции:

+

База) Первой вершиной мы всегда рассмотрим \(A\), но для нее верно, что расстояние от \(А\) до \(А\) = 0;

+

Шаг) Мы достали вершину \(i\), нам известно, что для всех ее предков ответ - корректен, но тогда, допустим, что для \(i\)-ой вершины мы нашли ответ больший, чем надо, тогда это значит, что мы должны прийти из еще не рассмотренной вершины, но так как ребер отрицательного веса в графе нет, то такое невозможно \(\Rightarrow\) мы доказали

+

https://visualgo.net/en/sssp?slide=1

+

Есть две возможные реализации алгоритма

+
    +
  1. реализация помощью нахождения минимального расстояния внутри массива за линию. Так как мы для каждого шага находим минимальную вершину, то мы сделаем не более \(N\) шагов, но при этом на каждом шаге мы находим минимум за линию, то есть алгоритм работает за \(O(N^2)\).
  2. +
+
for (int i = 0; i < n; ++i){
+    int uk = -1;
+    for (int j = 0; j < n; j++){
+        if ((mark[j] == 0) && ((uk == -1) || (d[j] < d[uk]))){
+                uk = j;
+        }
+    }
+    for (int j = 0; j < n; j++){
+        if ((j != uk) && (g[uk][j] != -1)) d[j] = min(d[j], g[uk][j] + d[uk]);
+    }
+    mark[uk] = 1;
+}
+
    +
  1. Реализация за \(O(MlognN + NlogN)\) с помощью нахождения минимального расстояния внутри кучи/сета за логарифм. Так как для каждой вершины мы сделаем не более одного извлекания из структуры + каждое ребро мы используем максимум два раза. Для этого давайте в выбранной структуре хранить пару (расстояние, вершина); Первый код реализован с set, второй с кучей. Так как в куче нет компаратора на возрастание, то надо либо сделать свой, либо домножить расстояние на -1;
  2. +
+
while (used.size()) {
+    int v = (*(used.begin())).second;
+    for (int i = 0; i < g[v].size(); i++) {
+        int to = g[v][i].first, c = g[v][i].second;
+        if (dist[v] + c < dist[to]) {
+            used.erase({dist[to], to});
+            dist[to] = dist[v] + to;
+            used.insert({dist[to], to});
+        }
+    }
+    used.erase({dist[v], v});
+}
+
while (!q.empty()) {
+        int v = q.top().second;
+        q.pop();
+    for (int j = 0; j < g[v].size(); ++j) {
+        int to = g[v][j].first, c = g[v][j].second;
+        if (d[v] + c < d[to]) {
+            d[to] = d[v] + c;
+            q.push({-d[to], to});
+        }
+    }
+}
+

Из асимптотики понятно, что при большом количестве ребер первый алгоритм писать лучше, иначе второй.

+

Теоретическое задание

+

Приведите примеры, когда каждый из алгоритмов работает лучше.

+

Недостаток алгоритма Дейкстры всего один - он не работает, если в графе есть ребра отрицательного веса.

+

Практическое задание

+

3-5 Задача на алгоритм Дейкстры.

+

Форд-Беллман

+

Этот алгоритм решает ту же задачу, что и Дейкстра, но зато может работать с отрицательными ребрами!

+

Давайте заведем массив расстояний, как и в дейкстре, для стартовой вершины расстояние = 0. Алгоритм состоит в релаксации каждого ребра в графе \(N-1\) раз.

+

Это работает потому, что в кратчайшем пути не больше, чем \(N-1\) ребро, и если мы прорелаксируем их в таком порядке, этот путь найдется. После \(N-1\) прохода по всем ребрам и их релаксации мы точно это сделаем и найдем кратчайший путь.

+

Также в этом случае удобнее хранить список ребер явно вместо списка смежности.

+
int from[m], to[m], cost[m];
+for (int i = 0; i < n - 1; ++i) {
+        for (int j = 0; j < m; ++j) {
+                d[to[j]] = min(d[to[j]], d[from[j]] + cost[j]);
+    }
+}
+

Теоретическое задание

+

Подумайте, как найти цикл отрицательного веса с помощью этого алгоритма.

+

Практическое задание

+

Решите задачи 6 и 7.

+

Ссылка на контест

+

https://informatics.msk.ru/mod/statements/view.php?id=33380#1

+ + diff --git a/ru/sorting.html b/ru/sorting.html new file mode 100644 index 0000000..24ee812 --- /dev/null +++ b/ru/sorting.html @@ -0,0 +1,394 @@ + + + + + + + Сортировки и O-нотация - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Сортировки и O-нотация

+

Задача сортировки массива заключается в том, чтобы расставить его элементы в определённом порядке (чаще всего — по неубыванию: каждый элемент должен быть больше или равен предыдущему).

+

Будет полезно вместе с описанем алгоритмов смотреть их визуализацию.

+

Сортировка пузырьком

+

Наш первый подход будет заключаться в следующем: обозначим за \(n\) длину массива и \(n\) раз пройдёмся раз пройдемся по нему, меняя два соседних элемента, если первый больше второго.

+

Как каждую итерацию максимальный элемент «всплывает» словно пузырек к концу массива — отсюда и название.

+
void bubble_sort(vector<int>& array) {
+    int n = (int) array.size();
+    for (int i = 0; i < n; i++) {
+        for (int j = 0; j < n - 1; j++) {
+            // сравниваем элемент со следующим
+            // и меняем местами, если следующий меньше
+            if (array[j] > arr[j + 1]) {
+                swap(array[j], array[j + 1]);
+            }
+        }     
+    }
+}
+
+vector<int> a = {1, -3, 7, 88, 7};
+bubble_sort(a);
+for (auto elem : a) {
+    cout << elem << " ";
+}
+// -3 1 7 7 88
+

После \(i\) шагов алгоритма сортировки пузырьком последние \((i + 1)\) чисел всегда отсортированы, а значит алгоритм работает корректно.

+

Упражнение. Алгоритм можно немного ускорить. Подумайте, какие лишние элементы мы перебираем. Как нужно изменить границы в двух циклах for, чтобы не делать никаких бесполезных действий?

+

Сортировка выбором

+

Другим способом является сортировка выбором минимума (или максимума).

+

Чтобы отсортировать массив, просто \(n\) раз выберем минимум среди еще неотсортированных чисел и поставим его на свое место. На \(i\)-ом шаге будем искать минимум на отрезке \([i, n - 1]\) и менять его местами с \(i\)-тым элементом, после чего отрезок \([0, i]\) будет отсортирован.

+

Содержательная часть будет выглядеть так:

+
for (i = 0; i < n - 1; i++) {
+    for (j = i + 1; j < n; j++) {
+        if (a[i] > a[j]) {
+            swap(a[j], a[i]);
+        }
+    }
+}
+

Сортировка вставками

+

Определение. Префиксом длины \(i\) будем называть первые \(i\) элементов массива.

+

Тогда пусть на \(i\)-ом шаге у нас уже будет отсортирован префикс до \(i\)-го элемента. Чтобы этот префикс увеличить, нужно взять элемент, идущий после него, и менять с левым соседом, пока этот элемент наконец не окажется больше своего левого соседа. Если в конце он больше левого соседа, но меньше правого, то это будет означать, что мы правильно вставили этот элемент в отсортированную часть массива.

+
for (int i = 1; i < n; i++) {
+    for (int j = i; j > 0; j--) {
+        if (a[j - 1] < a[j]) {
+            break;
+        }
+        swap(a[j], a[j - 1]);
+    }
+}
+

Сортировка подсчетом

+

Предыдущие три алгоритма работали с массивами, в которых лежат абсолютно любые объекты, которые можно сравнивать: любые числа, строки, пары, другие массивы — почти все что угодно.

+

Но особых случаях, когда элементы принадлежат какому-то маленькому множеству, можно использовать другой алгоритм — сортировку подсчетом.

+

Пусть, например, нам гарантируется, что все числа натуральные и лежат в промежутке от \(1\) до \(100\). Тогда есть такой простой алгоритм:

+
    +
  • Создадим массив размера \(100\), в котором будем хранить на \(k\)-ом месте, сколько раз число \(k\) встретилось в этом массиве.

  • +
  • Пройдемся по всем числам исходного массива и увеличим соответствующее значение массива на \(1\).

  • +
  • После того, как мы посчитали, сколько раз каждое число встретилось, можно просто пройтись по этому массиву и вывести \(1\) столько раз, сколько встретилась \(1\), вывести \(2\) столько раз, сколько встретилась \(2\), и так далее.

  • +
+

Время работы такого алгоритма составляет \(O(M+N)\), где \(M\) — число возможных значений, \(N\) — число элементов в массиве. Сейчас мы расскажем, что же это означает.

+

О-нотация

+

Часто требуется оценить, сколько времени работает алгоритм. Но тут возникают проблемы:

+
    +
  • на разных компьютерах время работы всегда будет слегка отличаться;
  • +
  • чтобы измерить время, придётся запустить сам алгоритм, но иногда приходится оценивать алгоритмы, требующие часы или даже дни работы.
  • +
+

Зачастую основной задачей программиста становится оптимизировать алгоритм, выполнение которого займёт тысячи лет, до какого-нибудь адекватного времени работы. Поэтому хотелось бы уметь предсказывать, сколько времени займёт выполнение алгоритма ещё до того, как мы его запустим.

+

Для этого сначала попробуем оценить число операций в алгоритме. Возникает вопрос: какие именно операции считать. Как один из вариантов — учитывать любые элементарные операции:

+
    +
  • арифметические операции с числами: +, -, *, /
  • +
  • сравнение чисел: <, >, <=, >=, ==, !=
  • +
  • присваивание: a[0] = 3
  • +
+

При этом надо учитывать, как реализованы некоторые отдельные вещи в самом языке. Например, в питоне срезы массива (array[3:10]) копируют этот массив, то есть этот срез работает за 7 элементарных действий. А swap, например, может работать за 3 присваивания.

+

Упражнение. Попробуйте посчитать точное число сравнений и присваиваний в сортировках пузырьком, выбором, вставками и подсчетом в худшем случае (это должна быть какая формула, зависящая от \(n\) — длины массива).

+

Чтобы учесть вообще все элементарные операции, ещё надо посчитать, например, сколько раз прибавилась единичка внутри цикла for. А ещё, например, строчка n = len(array) — это тоже действие. Поэтому даже посчитав их, сразу очевидно, какой из этих алгоритмов работает быстрее — сравнивать формулы сложно. Хочется придумать способ упростить эти формулы так, чтобы:

+
    +
  • не нужно было учитывать много информации, не очень сильно влияющей на итоговое время;
  • +
  • легко было оценивать время работы разных алгоритмов для больших чисел;
  • +
  • легко было сравнивать алгоритмы на предмет того, какой из них лучше подходит для тех или иных входных данных.
  • +
+

Для этого придумали \(O\)-нотацию — асимптотическое время работы вместо точного (часто его ещё называют асимптотикой).

+

Определение. Пусть \(f(n)\) - это какая-то функция. Говорят, что алгоритм работает за \(O(f(n))\), если существует число \(C\), такое что алгоритм работает не более чем за \(C \cdot f(n)\) операций.

+

В таких обозначениях можно сказать, что

+
    +
  • сортировка пузырьком работает за \(O(n^2)\);
  • +
  • сортировка выбором работает за \(O(n^2)\);
  • +
  • сортировка вставками работает за \(O(n^2)\);
  • +
  • сортировка подсчетом работает за \(O(n + m)\).
  • +
+

Это обозначение удобно тем, что оно короткое и понятное, а также оно не зависит от умножения на константу или прибавления константы. Например, если алгоритм работает за \(O(n^2)\), то это может значить, что он работает за \(n^2\), за \(n^2 + 3\), за \(\frac{n(n-1)}{2}\) или даже за \(1000 \cdot n^2 + 1\) действие. Главное — что функция ведет себя как \(n^2\), то есть при увеличении \(n\) (в данном случае это длина массива) он увеличивается как некоторая квадратичная функция. Например, если увеличить \(n\) в 10 раз, время работы программы увеличится приблизительно в 100 раз.

+

Все рассуждения про то, сколько операций в swap или считать ли отдельно присваивания, сравнения и циклы — отпадают. Каков бы ни был ответ на эти вопросы, они меняют ответ лишь на константу, а значит асимптотическое время работы алгоритма никак не меняется.

+

Первые три сортировки именно поэтому называют квадратичными — они работают за \(O(n^2)\). Сортировка подсчетом может работать намного быстрее — она работает за \(O(n + m)\), а если в задаче \(M \leq N\), то это вообще линейная функция \(O(n)\).

+

Упражнение. Найдите асимптотику данных функций, маскимально упростив ответ (например, до \(O(n)\), \(O(n^2)\) и т. д.):

+
    +
  • \(\frac{N}{3}\)
  • +
  • \(\frac{N(N-1)(N-2)}{6}\)
  • +
  • \(1 + 2 + 3 + \ldots + N\)
  • +
  • \(1^2 + 2^2 + 3^2 + \ldots + N^2\)
  • +
  • \(\log{N} + 3\)
  • +
  • \(179\)
  • +
  • \(10^{100}\)
  • +
+

Упражнение. Найдите асимптотическое время работы данных функций:

+
def f(n):
+    s = 0
+    for i in range(n):
+        for j in range(n):
+            s += i * j
+    return s
+
def g(n):
+    s = 0
+    for i in range(n):
+        s += i
+    for i in range(n):
+        s += i * i
+    return s
+
def h(n):
+    if n == 0:
+        return 1
+    return h(n - 1) * n
+

Упражнение. Найдите лучшее время работы алгоритмов, решающих данные задачи:

+
    +
  • Написать числа от \(1\) до \(n\).
  • +
  • Написать все тройки чисел от \(1\) до \(n\).
  • +
  • Найти разницу между максимумом и минимумом в массиве.
  • +
  • Найти число единиц в бинарной записи числа \(n\).
  • +
+

Сортировки за \(O(n \log n)\)

+

Сортировка очень часто применяется как часть решения олимпиадных задач. В таких случаях обычно не пишут её заново, а используют встроенную.

+

Пример на Python:

+
a = [1, 5, 10, 5, -4]
+a.sort()
+

Пример на C++:

+
#include <vector>
+#include <algorithm>
+#include <iostream>
+
+using namespace std;
+
+int main() {
+    vector<int> v = {1, 5, 10, 5, -4};
+    sort(v.begin(), v.end());
+}
+

В разных языках она может быть реализована по-разному, но везде она работает за \(O(n \log n)\), и, обычно, неплохо оптимизирована. Далее мы опишем два подхода к её реализации.

+

Сортировка слиянием

+
+

Найти количество пар элементов \(a\) и \(b\) в отсортированном массиве, такие что \(b - a > K\).

+
+

Наивное решение: бинарный поиск. Будем считать, что массив уже отсортирован. Для каждого элемента \(a\) найдем первый справа элемент \(b\), который входит в ответ в паре с \(a\). Нетрудно заметить, что все элементы, большие \(b\), также входят в ответ. Итоговая асимптотика \(O(n\log n)\).

+

А можно ли быстрее?

+

Да, давайте перебирать два указателя — два индекса \(first\) и \(second\). Будем перебирать \(first\) просто слева направо и поддерживать для каждого \(first\) первый элемент справа от него, такой что \(a[second] - a[first] > K\) как \(second\). Тогда в пару к \(a=a[first]\) подходят ровно \(n-second\) элементов массив начиная с \(second\).

+
int second = 0, ans = 0;
+for (int first = 0; first < n; ++first) {
+    while (second != n && a[second] - a[first] <= r) {
+        second++;
+    }
+
+    ans += n - second;
+}
+

За сколько же работает это решение? С виду может показаться, что за \(O(n^2)\), но давайте посмотрим сколько раз меняется значение переменной \(second\). Так как оно изначально равняется нулю, только увеличивается и не может превысить \(n\), то суммарно операций мы сделаем \(O(n)\).

+

Это называется метод двух указателей — так как мы двигаем два указателя first и second одновременно слева направо по каким-то правилам. Обычно его используют на одном отсортированном массиве.

+

Давайте разберем еще примеры.

+

Слияние

+

Еще пример двух указателей на нескольких массивах.

+

Пусть у нас есть два отсортированных по неубыванию массива размера \(n\) и \(m\). Хотим получить отсортированный массив размера \(n + m\) из исходных.

+

Пусть первый указатель будет указывать на начало первого массива, а второй, соответственно, на начало второго. Из двух текущих элементов, на которые указывают указатели, выберем наименьший и положим на соответствующую позицию в новом массиве, после чего сдвинем указатель. Продолжим этот процесс пока в обоих массивах не закончатся элементы. Тогда код будет выглядеть следующим образом:

+
int a[n + 1], b[m + 1], res[n + m];
+
+a[n] = INF; // Создаем в конце массива фиктивный элемент, который заведомо больше остальных
+b[m] = INF; // Чтобы избежать лишних случаев
+
+for (int i = 0; i < n; ++i) {
+    cin >> a[i];
+}
+
+for (int j = 0; j < m; ++j) {
+    cin >> a[j];
+}
+
+int i = 0, j = 0;
+for (int k = 0; k < n + m; ++k) {
+    if (a[i] < b[j]) {
+        res[k] = a[i];
+        i++;
+    } else {
+        res[k] = b[j];
+        j++;
+    }
+}
+

Итоговая асимптотика: \(O(n + m)\).

+

Сортировка слиянием

+

Давайте подробно опишем как использовать операцию слияния для сортировки за \(O(n\log n)\).

+

Пусть у нас есть какой-то массив.

+
int a[8] = {7, 2, 5, 6, 1, 3, 4, 8};
+

Сделаем такое предположение. Пусть мы уже умеем как-то сортировать массив размера \(n\). Тогда научимся сортировать массив размера \(2n\). Давайте разобьем наш массив на две половины, отсортируем каждую из них, а после это сделаем слияние двух массивов, которое мы научились делать за \(O(n)\) в данных условиях. Также заметим, что массив размера \(1\) уже отсортирован, тогда мы можем делать это процедуру рекурсивно. Тогда для данного массива \(a\) это будет выглядеть следующим образом:

+
// (7  2  5  6  1  3  4  8)
+// (7  2  5  6)(1  3  4  8)
+// (7  2)(5  6)(1  3)(4  8)
+// (2  7)(5  6)(1  3)(4  8)
+// (2  5  6  7)(1  3  4  8)
+// (1  2  3  4  5  6  7  8)
+
+#include  // Воспользуемся встроенной функцией merge
+
+void merge_sort(vector &v, int l, int r) { // v - вектор, который хотим отсортировать
+    if (r - l == 1) {                            // l и r - полуинтервал, который хотим отсортировать
+        return;
+    }
+
+    int mid = (l + r) / 2;
+    merge_sort(v, l, mid);
+    merge_sort(v, mid, r);
+    vector temp(r - l); // временный вектор
+    merge(v.begin() + l, v.begin() + mid, v.begin() + mid, v.begin() + r, c.begin());
+    for (int i = 0; i < r - l; ++i) {
+        v[i + l] = temp[i];
+    }
+    return;
+}
+

Так сколько же работает это решение?

+

Пускай \(T(n)\) — время сортировки массива длины \(n\), тогда для сортировки слиянием справедливо \(T(n)=2T(n/2)+O(n)\) \(O(n)\) — время, необходимое на то, чтобы слить два массива длины n. Распишем это соотношение:

+

\(T(n)=2T(n/2)+O(n)=4T(n/4)+2O(n)=\ldots=T(1)+\log(n)O(n)=O(n\log(n)).\)

+

Количество инверсий

+

Пусть у нас есть некоторая перестановка \(a\). Инверсией называется пара индексов \(i\) и \(j\) такая, что \(i < j\) и \(a[i] > a[j]\).

+
+

Найти количество инверсий в данной перестановке.

+
+

Очевидно, что эта задача легко решается обычным перебором двух индексов за \(O(n^2)\):

+
int a[n], ans = 0;
+
+for (int i = 0; i < n; ++i) {
+    for (int j = i + 1; j < n; ++j) {
+        if (a[i] > a[j]) {
+            ans++;
+        }
+    }
+}
+
+cout << ans << endl;
+

Внезапно эту задачу можно решить используя сортировку слиянием, слегка модифицируя её. Оставим ту же идею. Пусть мы умеем находить количество инверсий в массиве размера \(n\), научимся находить количество инверсий в массиве размера \(2n\).

+

Заметим, что мы уже знаем количество инверсий в левой половине и в правой половине массива. Осталось лишь посчитать число инверсий, где одно число лежит в левой половине, а второе в правой половине. Как же их посчитать?

+

Давайте подробнее рассмотрим операцию merge левой и правой половины (которую мы ранее заменили на вызов встроенной функции merge). Первый указатель указывает на элемент левой половины, второй указатель указывает на элемент второй половины, мы смотрим на минимум из них и этот указатель вдигаем вправо.

+

Рассмотрим число \(A\) в левой половине. В скольки инверсиях между половинами оно участвует? В стольки, сколько чисел в правой половине меньше, чем оно. Знаем ли мы это количество? Да! Ровно в тот момент, когда мы число \(A\) вносим в слитый массив, второй указатель указывает на первое число в правой половине, которое больше чем \(A\).

+

Значит в тот момент, когда мы добавляем число \(A\) из левой половины, к ответу достаточно прибавить индекс второго указателя (минус начало правой половины). Так мы учтем все инверсии между половинами.

+

Быстрая сортировка

+

Быстрая сортировка заключается в том, что на каждом шаге мы находим опорный элемент, все элементы, которые меньше его кидаем в левую часть, остальные в правую, а затем рекурсивно спускаемся в обе части.

+
void quicksort(int l, int r){
+    if (l < r){
+        int index = (l + r) / 2; /* index - индекс опорного элемента для 
+        начала сделаем его равным середине отрезка*/
+        index = divide(l, r, index); /* divide - функция разбивающие элементы 
+        на меньшие и больше/равные a[index], 
+        при этом функция возвращает границу разбиения*/
+        quicksort(l, index);
+        quicksort(index + 1, r);
+    }
+}
+

Давайте оценим асимптотику данной сортировки. На случайных данных она работает за \(O(NlogN)\) , так как каждый раз мы будем делить массив на две примерно равные части, то есть суммарно размер рекурсии будет около логарифма и при этом на каждом этапе рекурсии мы просмотрим не более, чем размер массива. Однако можно легко найти две проблемы, одна - одинаковые числа, а вторая - если вдруг середина - минимум или максимум.

+

Существуют несколько выходов из этой ситуации :

+
    +
  1. Давайте если быстрая сортировка работает долго, то запустим любую другую сортировку за \(NlogN\).

  2. +
  3. Давайте делить массив не на две, а на три части(меньше, равны, больше).

  4. +
  5. Чтобы избавиться от проблемы с максимумом/минимумом в середине, давайте брать случайный элемент.

  6. +
+

Поиск \(k\)-ой порядковой статистики за \(O(N)\)

+

Пусть дан массив \(A\) длиной \(N\) и пусть дано число \(K\). Задача заключается в том, чтобы найти в этом массиве \(K\)-ое по величине число, т.е. \(K\)-ую порядковую статистику.

+

Давайте поймем, что в быстрой сортировке мы можем узнать, сколько элементов меньше данного, тогда рассмотрим три случая

+
    +
  1. количество чисел, меньше данного = \(k - 1\), тогда наше число - ответ.

  2. +
  3. количество чисел, меньше данного >= \(k\), тогда спускаемся рекурсивно в левую часть и ищем там ответ.

  4. +
  5. количество чисел, меньше данного < \(k\), спускаемся в правую ищем (\(k\) - левая - 1) - ое число.

  6. +
+

За сколько же это работает, из быстрой сортировки мы имеем, что размер убывает приблизительно в 2 раза, то есть мы имеем сумму \(\sum_{k=1}^n {2 ^ k} = {2^{k+1}-1}\) что в нашем случае это максимум равно \(2 * N - 1\), то есть \(O(N)\).

+

Также в C++ эта функция уже реализована и называется nth_element .

+ + diff --git a/ru/sparse-table.html b/ru/sparse-table.html new file mode 100644 index 0000000..5afaf08 --- /dev/null +++ b/ru/sparse-table.html @@ -0,0 +1,222 @@ + + + + + + + Разреженная таблица - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Разреженная таблица

+

Разреженная таблица (англ. sparse table) — структура данных, позволяющая отвечать на запросы минимума на отрезке за \(O(1)\) с препроцессингом за \(O(n \log n)\) времени и памяти.

+

Определение. Разреженная таблица — это следующий двумерный массив размера \(\log n \times n\):

+

\[ +t[k][i] = \min \{ a_i, a_{i+1}, \ldots, a_{i+2^k-1} \} +\]

+

По-русски: считаем минимумы на каждом отрезке длины \(2^k\).

+

Такой массив можно посчитать за его размер, итерируясь либо по \(i\), либо по \(k\):

+

\[ +t[k][i] = \min(t[k-1][i], t[k-1][i+2^{k-1}]) +\]

+

Имея таком массив, мы можем для любого отрезка быстро посчитать минимум на нём. Заметим, что у любого отрезка имеется два отрезка длины степени двойки, которые пересекаются, и, главное, покрывают его и только его целиком. Значит, мы можем просто взять минимум из значений, которые соответствуют этим отрезкам.

+

+

Последняя деталь: для того, чтобы константа на запрос стала настоящей, нужно научиться считать сам логарифм за константу. Для этого можно воспользоваться доступной в GCC функцией __lg. Она внутри использует инструкцию clz (“count leading zeros”), которая присутствует в большинстве современных процессоров и возвращает количество нулей до первой единицы в бинарной записи, из чего за несколько процессорных тактов можно получить нужный округленный логарифм.

+
int a[maxn], mn[logn][maxn];
+
+int rmq(int l, int r) { // полуинтервал [l; r)
+    int t = __lg(r - l);
+    return min(mn[t][l], mn[t][r - (1 << t)]);
+}
+
+// Это считается где-то в первых строчках main:
+
+memcpy(mn[0], a, sizeof a);
+
+for (int l = 0; l < logn - 1; l++)
+    for (int i = 0; i + (2 << l) <= n; i++)
+        mn[l+1][i] = min(mn[l][i], mn[l][i + (1 << l)]);
+

Для больших таблиц порядок итерирования и расположение данных в памяти сильно влияет на скорость построения — это связано с работой кэшей. Но в большинстве случаев время построения не так критично.

+

Упражнение. Подумайте, в чём недостатки других 4 вариантов итерирования и layout-а.

+

Интересно, что последний цикл не векторизуется компилятором автоматически (видимо, потому что std::min это что-то сложное внутри), и поэтому замена тела цикла на конструкцию вида (x < y ? x : y) ускоряет построение ещё в ~2 раза.

+

Применения

+

Разреженная таблица является статической структурой данных, то есть её нельзя дёшево обновлять (но можно достраивать на ходу — см. задачу «Антиматерия» с РОИ-2017).

+

Разреженную таблицу часто применяют для решения задачи о наименьшем общем предке, так как её можно свести к RMQ.

+

2d Static RMQ

+

Эту структуру тоже можно обобщить на большие размерности. Пусть мы хотим посчитать RMQ на подквадратах. Тогда вместо массива t[k][i] у нас будет массив t[k][i][j], в котором вместо минимума на отрезах будет храниться минимум на квадратах тех же степеней двоек. Получение минимума на произвольном квадрате тогда уже распадется на четыре минимума на квадратах длины \(2^k\).

+

В общем же случае от нас просят минимум на прямоугольниках \(d\)-мерного массива. Тогда делаем предподсчет, аналогичный предыдущему случаю, только теперь тут будет \(O(n \log^d n)\) памяти и времени на предподсчет — нужно хранить минимумы на всех гипер-прямоугольниках со сторонами степени двойки.

+

Ограничения на операцию

+

Разреженную таблицу можно применять не только для минимума или максимума. От операции требуется только ассоциативность (\(a ∘ (b ∘ c) = (a ∘ b) ∘ c\)), коммутативность (\(a ∘ b = b ∘ a\)) и идемпотентность (\(a ∘ a = a\)). Например, её можно применять для нахождения \(\gcd\).

+

Если операция не идемпотентна, то для нахождения её результата можно действовать так: возьмём самый длинный упирающийся в левую границу запроса отрезок, прибавим его к ответу, сдвинем указатель на его правый конец и будем так продолжать, пока не обработаем весь запрос целиком.

+
int sum(int l, int r) { // [l, r)
+    int res = 0;
+    for (int d = logn - 1; d >= 0; d--) {
+        if (l + (1 << d) < r) {
+            res += t[l][d];
+            l += (1 << d);
+        }
+    }
+    return res;
+}
+

Это работает быстрее, чем, например, дерево отрезков, но тоже асимптотически за \(O(\log n)\), да ещё и с дополнительной памятью. Но есть способ это ускорить.

+

Disjoint Sparse Table

+

Мы хотим иметь какую-то структуру, которая может считать функцию \(f\) на отрезке, при том что \(f\) не удовлетворяет условию идемпотентности. Стандартная разреженная таблица тут не подойдёт — в ней нельзя найти \(O(1)\) непересекающихся отрезков.

+

Сделаем следующее: мысленно построим на массиве дерево отрезков и (уже не мысленно) для каждого его отрезка \([l, r)\) посчитаем \(f\) на всех отрезках от его центрального элемента — то есть от элемента с индексом \(m = \lfloor \frac{l + r}{2} \rfloor\) — до всех остальных элементов \(k \in [l, r)\). Для каждого элемента массива будет \(O(\log n)\) центральных, а значит суммарно на это потребуются те же \(O(n \log n)\) времени и памяти.

+

Утверждение. Любой запрос \([l, r)\) разбивается на \(O(1)\) непересекающихся предпосчитанных интервалов.

+

Доказательство. Возьмем самый высокий центральный элемент \(m\), принадлежащий запросу. Его отрезок полностью покрыает запрос — если бы это было не так, то самым высоким был бы не \(m\), а какая-то из его границ . Раз отрезок запроса \([l, r)\) полностью покрыт, и \(m\) лежит внутри него, то \([l, r)\) можно разбить на предпосчитанные \([l, m)\) и \([m, r)\).

+

Решать задачу мы так и будем: найдём нужный центральный элемент и сделаем два запроса от него.

+

Реализация

+

Сложная часть — найти этот центральный элемент за константное время — станет чуть проще, если мы будем работать только с массивами длины степени двойки и, соответственно, полными деревьями отрезков. Массивы неподходящей длины дополним до ближайшей степени двойки специальным нейтральным элементом, зависящим от самой операции (например, \(0\) для сложения или \(1\) для умножения).

+

Будем хранить всю структуру (предпосчитанные значения на отрезках) в массиве t[logn][maxn], в котором первым параметром будет уровень в дереве отрезков (число \(d\) для отрезков размера \(2^d\)), а вторым — граница соответствующего интервала (число \(k\)). Этой информации достаточно, чтобы однозначно восстановить отрезок.

+

Для ответа на запрос нам достаточно найти только уровень нужного центрального элемента. Чтобы научиться делать это эффективно, нам понадобится немного поразмышлять о природе дерева отрезков.

+

Заметим, что любая вершина \(k\)-того уровня соответствует какому-то отрезку \([l, l + 2^k)\), причём \(l\) делится на \(2^k\). Двоичное представление всех индексов на этом отрезке будет иметь какой-то общий префикс, а последние \(k\) знаков будут различными.

+

Нам нужно найти уровень нужного центрального элемента — это то же самое, что и уровень наименьшего общего отрезка для элементов \(l\) и \(r\). Используя предыдущий факт, получаем, что искомый уровень будет равен позиции самого значимого бита, который отличается у чисел \(l\) и \(r\). Его можно найти за константное время выражением \(h_{[l,r)]} = \lfloor \log_2 (l \oplus r) \rfloor\), если заранее предпосчитать логарифмы.

+

Для примера, построим DST для умножения по составному модулю:

+
const int maxn = (1 << logn);
+int a[maxn], lg[maxn], t[logn][maxn];
+
+const int neutral = 1;
+int f(int a, int b) {
+    return (a * b) % 1000;
+}
+
+void build(int l, int r, int level = logn - 1) {
+    int m = (l + r) / 2;
+
+    int cur = neutral;
+    for (int i = m + 1; i < r; i++) {
+        cur = f(cur, a[i]);
+        t[level][i] = cur;
+    }
+
+    cur = neutral;
+    for (int i = m; i >= l; i--) {
+        cur = f(cur, a[i]);
+        t[level][i] = cur;
+    }
+
+    if (r - l > 1) {
+        build(l, mid, level+1);
+        build(mid, r, level+1);
+    }
+}
+
+int rmq(int l, int r) { // [l, r)
+    int level = lg[l ^ r];
+    int res = t[level][l];
+    // и, если правый отрезок не пустой:
+    if (r & ((1 << lg[l ^ r]) - 1)))
+        res = f(res, t[level][r]);
+    return res;
+}
+

TODO: очень вероятно, тут есть баги.

+ + diff --git a/ru/sprague-grundy.html b/ru/sprague-grundy.html new file mode 100644 index 0000000..c046273 --- /dev/null +++ b/ru/sprague-grundy.html @@ -0,0 +1,56 @@ + + + + + + + Теория Шпрага-Гранди - Алгоритмика + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Теория Шпрага-Гранди

+

TODO

+ + diff --git a/ru/sqrt.html b/ru/sqrt.html new file mode 100644 index 0000000..d10c021 --- /dev/null +++ b/ru/sqrt.html @@ -0,0 +1,369 @@ + + + + + + + Корневая оптимизация - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Корневая оптимизация

+

Корневые эвристики — это обобщённое название различных методов и структур данных, опирающихся на тот факт, что если мы разделим какое-то множество из \(n\) элементов на блоки по \(\sqrt{n}\) элементов, то самих этих блоков будет не более \(\sqrt{n}\).

+

Центральное равенство этой статьи: \(\sqrt x = \frac{x}{\sqrt x}\).

+

Корневая декомпозиция на массивах

+

Сделаем вид, что про дерево отрезков мы не знаем, и рассмотрим следующую задачу:

+
+

Дан массив \(a\) длины \(n\) и \(q\) запросов одного из двух типов:

+
    +
  1. Найти сумму на отрезке \([l, r]\).

  2. +
  3. Увеличить все элементы на отрезке [l, r] на \(x\).

  4. +
+
+

Разделим весь массив на блоки по \(c \approx \sqrt{n}\) элементов и посчитаем сумму на каждом блоке. Так как блоки не пересекаются, суммарно это будет работать за \(O(n)\).

+
// c это и количество блоков, и также их размер; оно должно быть чуть больше корня
+const int maxn = 1e5, c = 330;
+int a[maxn], b[c];
+int add[c];
+
+for (int i = 0; i < n; i++)
+    b[i / c] += a[i];
+

Заведем также массив add размера \(\sqrt n\), который будем использовать для отложенной операции прибавления на блоке. Будем считать, что реальное значение \(i\)-го элемента равно a[i] + add[i / c].

+

Теперь мы можем отвечать на запросы первого типа за \(O(\sqrt n)\) на запрос:

+
    +
  1. Для всех блоков, лежащих целиком внутри запроса, просто возьмём уже посчитанные суммы и прибавим к ответу.

  2. +
  3. Для блоков, пересекающихся с запросом только частично (их два — правый и левый), проитерируемся по нужным элементам и прибавим к ответу.

  4. +
+
int sum(int l, int r) {
+    int res = 0;
+    while (l <= r) {
+        // если мы находимся в начале блока и он целиком в запросе
+        if (l % c == 0 && l + c - 1 <= r) {
+            res += b[l / c];
+            l += c; // мы можем прыгнуть сразу на блок
+        }
+        else {
+            res += a[l] + add[l / c];
+            l += 1;
+        }
+    }
+    return res;
+}
+

Обновление пишется примерно так же:

+
void upd(int l, int r, int x) {
+    while (l <= r) {
+        if (l % c == 0 && l + c - 1 <= r) {
+            b[l / c] += c * x;
+            add[l / c] += x;
+            l += c;
+        }
+        else {
+            a[l] += x;
+            l++;
+        }
+    }
+ }
+

Обе операции будут работать за \(O(\sqrt n)\), потому что нужных «центральных» блоков всегда не более \(\sqrt n\), а в граничных блоках суммарно не более \(2 \sqrt n\) элементов.

+

Корневая эвристика на запросах

+

Сделаем вид, что запросов обновления нет. Тогда бы мы решали это задачу просто массивом префиксных сумм.

+
int s[maxn]; // [0, r)
+s[0] = 0
+
+for (int i = 0; i < n; i++)
+    s[i+1] = s[i] + a[i];
+

Подойдём теперь к задаче с другой стороны: разобьём на корневые блоки не массив, а запросы к нему. Будем обрабатывать каждый блок запросов независимо от других с помощью массива префикных сумм, который мы будем честно пересчитывать каждые \(\sqrt q\) запросов.

+

На каждый запрос суммы мы можем потратить \(O(1)\) времени на запрос к префиксным суммам, плюс \(\sqrt q\) времени на поправку на всех запросах из буфера.

+
struct query { int l, r, x; };
+vector<query> buffer; // запросы, не учтенные в префиксных суммах
+
+int sum(int l, int r) {
+    int res = s[r + 1] - s[l];
+    for (query q : buffer)
+        // пересечем запрос суммы со всеми запросами
+        res += q.x * max(0, min(r, q.r) - max(l, q.l));
+    return res;
+}
+
+void rebuild() {
+    vector<int> d(n, 0);
+    // массив дельт
+
+    for (query q : buffer) {
+        d[q.l] += x;
+        d[q.r + 1] -= x;
+    }
+    buffer.clear();
+
+    int delta = 0, running_sum = 0;
+    for (int i = 1; i <= n; i++) {
+        p[i] += running_sum;
+        delta += d[i];
+        running_sum += delta;
+    }
+}
+
+void upd(int l, int r, int x) {
+    buffer.push_back({l, r, x});
+    if (buffer.size() == c)
+        // буфер слишком большой; надо пересчитать префиксные суммы и очистить его
+        rebuild();
+}
+

Такое решение будет работать за \(O(n \sqrt q + q \sqrt q)\).

+

Превращение статических структур в динамические

+

Эту технику можно применять не только к массиву префиксных сумм, но и к любым статическим структурам данных.

+
+

Требуется добавлять точки в выпуклую оболочку и уметь находить касательные (то есть находить точку, которая максимизирует скалярное произведение \(a_i x + b_i y\)).

+
+

Мы можем так же каждые \(\sqrt q\) запросов перестраивать выпуклую оболочку, а при ответе на запрос касательной помимо кандидата из построенной выпуклой оболочки рассмотреть дополнительно \(\sqrt q\) скалярных произведений из точек из буфера.

+

Теперь чуть сложнее:

+
+

Требуется добавлять и удалять точки из выпуклой оболочки и уметь находить касательные. Запросы известны заранее.

+
+

Разобьём запросы на блоки явно, и будем обрабатывать их по отдельности. На каждом блоке построим выпуклую оболочку только тех точек, которые существуют на всём её блоке, а остальные запросы сохраним в буфер. При ответе на запрос касательной помимо кандидата из построенной оболочки будем рассматривать все точки, которые существуют на момент данного запроса — найти все такие точки можно за \(\sqrt q\), проанализировав историю текущего блока.

+

Часто, если удалений в задаче нет или их можно как-то эмулировать, можно применить похожую, но более мощную технику: поддерживать \(O(\log n)\) структур (например, выпуклых оболочек) размеров степени двойки, причём так, что нет двух структур одинакового размера. При добавлении новой точки мы создаем для неё новую структуру размера 1. Дальше, если структура размера 1 уже существует, то мы объединяем эти две структуры и создаём структуру размера 2. Если структура размера 2 уже существовала, то мы объединяем её и создаём структуру размера 4, и так далее. Запрос мы будем передавать во все \(O(\log n)\) базовых структур и объединять ответы.

+

«Алгоритм Мо»

+

Обсудим еще одно решение этой же задачи: на этот раз будем группировать запросы и по временным, и по пространственным признакам. Пусть все запросы даны нам заранее, и запросов обновления нет. Опять же притворимся, что про префиксные суммы мы не слышали.

+

Сгруппируем все запросы в корневые блоки по их левой границе, внутри каждого блока отсортируем запросы по правой границе и будем обрабатывать каждый такой блок по отдельности. Будем проходиться по запросам блока, поддерживая сумму текущего отрезка. Для первого отрезка посчитаем сумму честно, а для всех остальных будем каждый раз сдвигать его границы и обновлять сумму — по одному элементу за раз.

+

Трюк в том, что правая граница суммарно сдвинется на \(O(n)\), потому что отрезки отсортированы, а левая — каждый раз на \(O(\sqrt n)\). Итоговая асимптотика решения будет \(O(q \sqrt n + n \sqrt n)\): изменение левых границ суммарно по всем блокам займёт \(O(q \sqrt n)\) операций, а правых — \(O(n \sqrt n)\),

+
struct query { int l, r, idx; };
+// idx -- это номер запроса, на который нужно ответить
+
+int a[maxn];
+vector<query> b[c];
+int ans[maxq]; // массив ответов на запросы
+
+
+// где-то в main:
+
+for (query q : queries)
+    b[q.l / c].push_back(q);
+
+for (int i = 0; i < c; i++) {
+    sort(b[i].begin(), b[i].end(), [](query a, query b){
+        return a.r < b.r;
+    });
+}
+
+for (int i = 0; i < c; i++) {
+    int l = i * c, r = i * c - 1; // границы текущего отрезка
+    int s = 0; // сумма на текущем отрезке
+    for (query q : b[i]) {
+        while (r < q.r)
+            s += a[++r];
+        while (l < q.l)
+            s -= a[l++];
+        while (l > q.l)
+            s += a[--l];
+        ans[q.idx] = s;
+    }
+}
+

Конкретно для этой задачи этот алгоритм не нужен, однако он полезен для ответа на более сложные вопросы: найти \(k\)-ую порядковую статистику на отрезке, его медиану, количество различных элементов, наиболее часто встречающийся элемент и так далее. Во всех этих случаях нужно просто вместо суммы поддерживать какую-нибудь структуру — например, хэш-таблицу — отвечающую за множество элементов на отрезке, и пересоздавать её между блоками.

+

Примечание. Алгоритм назван по имени какого-то неизвестного китайца и принят под этим названием в фольклоре ICPC. Автору это не нравится, но что поделать: другого названия не придумали.

+

Вариации

+

На деревьях. В некоторых задачах требуется считать результаты операций на дереве. Например: рядом с каждой вершиной есть число, и нам нужно возвращать количества различных значений на путях.

+

Идея примерно такая же, как при сведении LCA к RMQ. Мы можем выписать эйлеров обход дерева (каждую вершину выписываем дважды — в моменты входа и выхода), и теперь задача сводится к задаче на массиве, только с одним исключением: мы должны добавлять в структуру только те элементы, которые содержатся на отрезке ровно 1 раз.

+

Использовать корневую декомпозицию как структуру. Часто от внутренней структуры в алгоритме Мо требуется что-то сложное, и мы пребегаем к использованию каких-то «тяжелых» структур, вроде дерева отрезков. Но данный случай отличается от обычных задач на обработку запросов: у нас обновлений \(O(n \sqrt n)\), а запросов всего \(O(n)\). Здесь эффективнее вместо дерева отрезков, требующего \(O(\log n)\) времени на оба типа запросов, взять структуру, которая быстро работает при обновлениях, и не очень быстро на самих запросах — например, корневую декомпозицию, которая работает за \(O(1)\) и \(O(\sqrt n)\) соответственно.

+

На самом деле, это примерно единственное место, где корневую эвристику как структуру использовать эффективнее всего — внутри другой корневой эвристики.

+

«3D Мо». Если очень захотеть, этот подход можно применять и в задачах, где надо обрабатывать запросы обновления. Для этого нужно ввести третий параметр get-запроса \(t\), который будет равен числу update-запросов до текущего get.

+

Снова отсортируем все get-запросы, но на этот раз в порядке \((\frac{t}{n^{\frac 2 3}}, \frac{l}{n ^{\frac{2}{3}}}, r)\), и обработаем их таким же алгоритмом, только в трёх измерениях. Время работы нового подхода будет \(O(n^{\frac{5}{3}})\), что доказывается аналогично исходному алгоритму.

+

Деление на тяжелые и легкие объекты

+

Всем известный алгоритм факторизации за корень опирается на тот факт, что каждому «большому» делителю \(d \geq \sqrt n\) числа \(n\) соответствует какой-то «маленький» делитель \(\frac{n}{d} \leq n\).

+

Подобное полезное свойство (что маленькие объекты маленькие, а больших объектов не много) можно найти и у других объектов.

+

Длинные и короткие строки

+

710F. Операции над множеством строк. Требутся в онлайне обрабатывать три типа операций над множеством строк:

+
    +
  1. Добавить строку в множество.
  2. +
  3. Удалить строку из множества.
  4. +
  5. Для заданной строки, найти количество её вхождений как подстроку среди всех строк множества.
  6. +
+

Одно из решений следующее: разделим все строки на короткие (\(|s| < \sqrt l\)) и длинные (\(|s| \geq \sqrt l\)), где \(l\) означает суммарную длину всех строк. Заметим, что длинных строк немного — не более \(\sqrt l\).

+

С запросами будем справляться так:

+
    +
  • Заведём хэш-таблицу, и когда будем обрабатывать запрос добавления или удаления, будем прибавлять или отнимать соответственно единицу по хэшам всех её коротких подстрок. Это можно сделать суммарно за \(O(l \sqrt l)\): для каждой строки нужно перебрать \(O(\sqrt l)\) разных длин и окном пройтись по всей строке.

  • +
  • Для запроса третьего типа для короткой строки, просто посчитаем её хэш и посмотрим на значение в хэш-таблице.

  • +
  • Для запроса третьего типа для длинной строки, мы можем позволить себе посмотреть на все неудалённые строки, потому что таких случаев будет немного, и если мы можем за линейное время найти все вхождения новой строки, то работать это будет тоже за \(O(l \sqrt l)\). Например, можно посчитать z-функцию для всех строк вида \(s\#t\), где \(s\) это строка из запроса, а \(t\) это строка из множества; здесь, правда, есть нюанс: \(s\) может быть большой, а маленьких строк \(t\) много — нужно посчитать z-функцию сначала только от \(s\), а затем виртуально дописывать к ней каждую \(t\) и досчитывать функцию.

  • +
+

Иногда отдельный подход к тяжелым и лёгким объектам не требуется, но сама идея помогает увидеть, что некоторые простые решения работают быстрее, чем кажется.

+

Треугольники в графе

+

Рассмотрим другую задачу:

+
+

Дан граф из \(n\) вершин и \(m \approx n\) рёбер. Требуется найти в нём количество циклов длины три.

+
+

Будем называть вершину тяжелой, если она соединена с более чем \(\sqrt n\) другими вершинами, и лёгкой в противном случае.

+

Попытаемся оценить количество соединённых вместе троек вершин, рассмотрев все возможные 4 варианта:

+
    +
  1. В цикле нет тяжелых вершин. Рассмотрим какое-нибудь ребро \((a, b)\) цикла. Третья вершина \(c\) должна лежать в объединении списков смежности \(g_a\) и \(g_b\), а раз обе эти вершины лёгкие, то таких вершин найдётся не более \(\sqrt n\). Значит, всего циклов этого типа может быть не более \(O(m \sqrt n)\).

  2. +
  3. В цикле одна тяжелая вершина. Аналогично — есть одно «лёгкое» ребро, а значит таких циклов тоже \(O(m \sqrt n)\).

  4. +
  5. В цикле две тяжелые вершины — обозначим их как \(a\) и \(b\), а лёгкую как \(c\). Зафиксируем пару \((a, c)\) — способов это сделать \(O(m)\), потому что всего столько рёбер. Для этого ребра будет не более \(O(\sqrt n)\) рёбер \((a, b)\), потому что столько всего тяжелых вершин. Получается, что всего таких циклов может быть не более \(O(m \sqrt n)\).

  6. +
  7. Все вершины тяжелые. Аналогично — тип третьей вершины в разборе предыдущего случая нигде не использовался; важно лишь то, что тяжелых вершин \(b\) немного.

  8. +
+

Получается, что циклов длины 3 в графе может быть не так уж и много — не более \(O(m \sqrt n)\).

+

Само решение максимально простое: отсортируем вершины графа по их степени, ориентируем ребра \(v \rightarrow u, v \le u\); теперь внутренним циклом будем перебирать пути \(v \rightarrow u \rightarrow w, v \le u \le w\), а потом проверять существование ребра \(v \rightarrow w\).

+
vector<int> g[maxn], p(n); // исходный граф и список номеров вершин
+iota(p.begin(), p.end(), 0); // 0, 1, 2, 3, ...
+
+// чтобы не копипастить сравнение:
+auto cmp = [&](int a, int b) {
+    return g[a].size() < g[b].size() || (g[a].size() == g[b].size() && a < b);
+};
+
+// в таком порядке мы будем перебирать вершины
+sort(p.begin(), p.end(), cmp);
+
+// теперь удалим все лишние рёбра (ведущие в более тяжелые вершины)
+for (int v = 0; v < n; v++) {
+    vector<int> &t = g[v];
+    // отсортируем их и удалим какой-то суффикс
+    sort(t.begin(), t.end(), cmp);
+    while (t.size() > 0 && cmp(t.back(), v))
+        t.pop_back();
+    reverse(t.begin(), t.end());
+}
+
+// рядом с каждой вершиной будем хранить количество
+// ранее просмотренных входящих рёбер (v -> w)
+vector<int> cnt(n, 0);
+int ans = 0;
+
+for (int v : p) {
+    for (int w : g[v])
+        cnt[w]++;
+    for (int u : g[v])
+        for (int w : g[u])
+            ans += cnt[w]; // если в графе нет петель, то cnt[w] это 0 или 1
+    // при переходе к следующему v массив нужно занулить обратно
+    for (int w : g[v])
+        cnt[w]--;
+}
+

Задачу также можно было решить чуть более прямолинейно, переместив все проверки внутрь основного цикла, но это сказалось бы на скорости работы.

+

Рюкзак за \(O(S \sqrt S)\)

+

Если у нас есть \(n\) предметов с весами \(w_1\), \(w_2\), \(\ldots\), \(w_n\), такими что \(\sum w_i = S\), то мы можем решить задачу о рюкзаке за время \(O(S \cdot n)\) стандартной динамикой. Чтобы решить задачу быстрее, попытаемся сделать так, чтобы число предметов \(n\) стало \(O(\sqrt S)\).

+

Заметим, что количество различных весов среди будет \(O(\sqrt S)\), потому что если среди них есть \(k\) различных чисел, то:

+

\[ +S = w_1 + w_2 + \ldots + w_n \geq 1 + 2 + \ldots + k = \frac{k \cdot (k+1)}{2} +\]

+

Откуда значит, что \(k \leq 2\sqrt S\).

+

Рассмотрим теперь некоторый вес \(x\), который \(k\) раз встречается в наборе весов. «Разложим» \(k\) по степеням двойки и вместо всех \(k\) вхождений этого веса добавим веса \(x\), \(2 \cdot x\), \(4 \cdot x\), \(\ldots\), \((c - 1 - 2^t) \cdot x\), где \(t\) это максимальное целое число, для которого выполняется \(2^t − 1 \leq c\). Легко видеть, что все суммы вида \(q \cdot x\) (\(q \leq k\)) и только их по-прежнему можно набрать.

+

Алгоритм в этом и заключается — проведем данную операцию со всеми уникальными значениями весов и после чего запустим стандартное решение. Уже сейчас легко видеть, что новое количество предметов будет \(O(\sqrt S \log S)\), потому что для каждого веса мы оставили не более \(\log S\) весов, а всего различных весов было \(O(\sqrt S)\).

+

Упражнение. Доказать, что предметов на самом деле будет \(O(\sqrt S)\).

+

Примечание. Классическое решение рюкзака можно ускорить на несколько порядков, если использовать bitset или векторизовать его основной цикл.

+

Бакеты

+

Пусть нам нужно находить всё ту же сумму, но теперь у нас запрос обновления выглядит так: \(i\)-тый элемент прыгает на \(d\) позиций вправо или влево, смещая, соответственно, \(d\) элементов между начальной и конечной позицией на единицу вправо или влево. Сумму нужно находить уже в новой индексации. Про декартово дерево автор снова просит читателя на время забыть.

+

Объединим предыдущие подходы: теперь не только будем делить массив на блоки примерно по корню, но и каждый корень запросов будем перестраивать всю структуру.

+

А именно, теперь мы будем работать не с чётко разделённые блоки, а с корзинами переменного размера, в которых хранятся небольшие массивы с элементами, а также сумма на всей корзине. До начала обработки запросов добавим в каждую из них примерно по \(\sqrt n\) последовательных элементов.

+

При нахождении суммы мы будем действовать как раньше, а при запросе обновления найдём две нужные корзины (из какой корзины элемент удаляется и в какую добавляется) и полностью их пересоздадим — за их суммарный размер. Чтобы корзины не стали слишком большими, просто будем каждые \(\sqrt n\) запросов их полностью перестраивать.

+

Такое подход всё ещё будет работать за \(O(m \sqrt n)\), но позволяет решать задачи, в которых меняется порядок элементов. Реальный пример: IOI 2011 «Dancing Elephants».

+

Подбор константы

+

На скорость работы очень сильно влияет размер блока. Мы для простоты использовали одну и ту же константу и для количества блоков, и для их размера, но на практике их часто нужно подбирать.

+

Иногда асимптотики «тяжелой» и «лёгкой» части получаются разными, потому что мы где-то не смогли обойтись без какой-нибудь структуры, которая внесла лишний логарифм. Чаще всего, в качестве оптимального размера блока \(c^*\) можно взять что-то близкое к решению уравнения \(g \cdot \frac{n}{c} = f \cdot c\), где \(f\) и \(g\) это асимптотики блочной и не-блочной частей соответственно.

+

Впрочем, надо также учитывать, что походы в какое-нибудь декартово дерево совсем не в логарифм раз медленнее линейного прохода по массиву.

+ + diff --git a/ru/sse.html b/ru/sse.html new file mode 100644 index 0000000..b6f1956 --- /dev/null +++ b/ru/sse.html @@ -0,0 +1,503 @@ + + + + + + + Векторизация - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Векторизация

+

Рассмотрим следующую программу, в которой считается сумма одномерного целочисленного массива:

+
#pragma GCC optimize("O3")
+// ^ включает самый "агрессивный" уровень оптимизации
+// то же самое, что добавить флаг "-O3" при компиляции из консоли
+
+#include <iostream>
+using namespace std;
+
+const int n = 1e5;
+int a[n], s = 0;
+
+int main() {
+    for (int t = 0; t < 100000; t++)
+        for (int i = 0; i < n; i++)
+            s += a[i];
+
+    return 0;
+}
+

Если скомпилировать этот код под GCC без всяких дополнительных настроек и запустить, он отработает за 2.43 секунды.

+

Добавим теперь следующую магическую директиву в самое начало программы:

+
#pragma GCC target("avx2")
+// ...остальное в точности как было
+

Скомпилировавшись и запустившись при тех же условиях, программа завершается уже за 1.24 секунды. Это почти в два раза быстрее, при том, что сам код и уровень оптимизации мы не меняли.

+

Чтобы понять, что здесь происходит, нам нужно сначала разъяснить некоторые особенности работы современных компьютеров. (Знающие ассемблер могут отмотать примерно до ⅓ статьи.)

+

Complex Instruction Set Computing

+

Раньше, во времена, когда компьютеры назывались ЭВМ-ами и занимали целую комнату, увеличение производительности происходило в основном за счёт увеличения тактовой частоты. Тактовая частота условно равна количеству инструкций, выполняемому процессором за единицу времени. (На современных процессорах это не так — разные инструкции занимают разное время, которое ещё и может зависеть от разных обстоятельств.)

+

Помимо жесткого физического ограничения на максимально возможную тактовую частоту, такой подход в какой-то момент просто перестал быть экономически оправданным: прямое увеличение тактовой частоты приводит к более чем линейному потреблению энергии, и, следовательно, выделению тепла, которое к тому же нужно ещё как-то выводить.

+

Поэтому вендоры, в погоне за более дешёвым флопсом за доллар, пошли по другому пути: стали добавлять более сложные инструкции, которые делают сразу много полезных действий за раз. Но есть и недостаток: микросхема от добавления новых инструкций сильно усложняется, что может стать критичным во многих других применениях. В связи с этим, все архитектуры стали делиться на два типа:

+
    +
  • RISC (англ. reduced instruction set computer), в которых длина кода (идентификатора) самой инструкции ограничена, а значит ограничено и само количество инструкций. Самые первые компьютеры относились к этому типу и могли не иметь даже отдельных инструкций для умножения и деления. Такие процессоры требуют меньше транзисторов, и как следствие сами меньше, дешевле и потребляют меньше энергии. Самое популярное семейство архитектур называется arm и используется почти на всех современных мобильных устройствах.

  • +
  • CISC (англ. complex instruction set computer), к которому относят всё, что не RISC — в них длина команды не фиксирована, что позволяет поддерживать практически произвольное количество инструкций. Самое популярное семейство архитектур называется x86 и используется почти на всех современных десктопах и серверах.

  • +
+

+

Новые инструкции стали добавлять постепенно, причём разные в зависимости от области применения.

+
    +
  • В обычных CPU довольно быстро добавили инструкцию, которая принимает числа \((x, y)\) и загружает в регистр данные по адресу \(a \cdot x + y\). Это полезно при индексации массивов — не нужно отдельно индекс считать.

  • +
  • На графических сопроцессорах появилась отдельная инструкция, которую называют «saxpy» (сокращенно от выражения s += a * x + y), которая полезна, например, при перемножении матриц.

  • +
  • В последние GPU от Nvidia добавили «tensor core» — отдельную схему, которая перемножает две матрицы \(4 \times 4\) и прибавляет к третьей, как бы производя \(4 \times 4 \times 4 = 64\) умножений и \(4 \times 4 = 16\) сложений за раз, что сильно ускоряет алгоритмы блочного матричного умножения.

  • +
+

В этой статье мы сфокусируемся на отдельном виде инструкций, которые позволяют выполнять одну и ту же операцию сразу на какой-то последовательности данных. Эта концепция называется SIMD-параллелизмом (англ. single instruction, multiple data).

+

Streaming SIMD Extensions

+

SSE — это обобщённое название всех SIMD-инструкций для x86.

+

Работают они следующим образом. Помимо обычных регистров (самых близких к процессору ячеек памяти, с которыми он непосредственно работает), есть дополнительные, вмещающие не 64, а 128, 256 или даже 512 бит — в зависимости от поддерживаемой версии SSE. В эти регистры загружается последовательные блоки из памяти, над ним производится какая-то последовательность операций, и итоговый результат записывается обратно в память. Сами операции обычно логически разбивают эту булеву последовательность на блоки, например, по 32 бит, и работают уже с ними, причём одновременно.

+

+

Подобным способом довольно легко получается оптимизировать простые циклы, производящие какие-нибудь независимые друг от друга операции над векторами (массивами) — поэтому сам такой подход называют векторизацией.

+

Например, какое-нибудь сложение двух int-овых массивов удаётся таким образом соптимизировать в \(\frac{512}{32} = 16\) раз, если процессор поддерживает AVX512, а операции битсета — в 512 раз (реализация из STL, по всей видимости, SSE не использует, поэтому даже c = a | b работает примерно в три раза дольше, чем просто пройтись for-ом по массиву int-ов).

+

Очень часто SSE используют для работы с действительными числами, и в этой ситуации возникает прямой trade-off между точностью вычислений и скоростью работы: например, вместо double можно использовать float, и тогда в один и тот же регистр поместится в два раза больше чисел. По этой причине в последнее время стали развиваться различные методы квантизации: перевода исходных данных в какой-то более дискретизированный формат на входе какой-нибудь процедуры (например, матричного умножения) и восстановления в исходный формат на выходе.

+

Конкретный набор инструкций и размеры регистров зависят от вендора и поколения архитектуры. На данный момент (лето 2019 года) большинство процессоров архитектуры x86 производит Intel, поэтому мы сконцентрируемся именно на их наборе инструкций.

+

+

Поддержка SIMD-инструкций добавлялись постепенно, сохраняя обратную совместимость. Если третий пентиум в 1999-м году умел работать с регистрами размера 128, то в самых современных i7 есть 512-битные регистры. Автор не является специалистом в проектировании микропроцессоров, но предполагает, что регистры больше 64 байт (512 бит) появятся не скоро, потому что это уже больше размера кэш-линии

+

Чтобы разработчикам не нужно было предоставлять отдельные оптимизированные бинарники под каждую конкретную архитектуру, информация о поддержке наборов инструкций процессором зашита в ассемблерную инструкцию cpuid, которую можно просто вызвать в рантайме и всё узнать: например так.

+

В компиляторе GCC есть встроенная функция __builtin_cpu_supports, которая берёт строчку-название набора инструкций (“sse”, “avx2”, “avx512f” и т. п.) и возвращает целое число — ноль или какую-то степень двойки. Эта функция работает так: входная строка во время компиляции переводится в нужную степень двойки, которая в рантайме просто AND-ится с маской из cpuid и возвращается — всё ради эффективности.

+
#include <iostream>
+using namespace std;
+
+int main() {
+    cout << __builtin_cpu_supports("sse") << endl;
+    cout << __builtin_cpu_supports("sse2") << endl;
+    cout << __builtin_cpu_supports("avx") << endl;
+    cout << __builtin_cpu_supports("avx2") << endl;
+    cout << __builtin_cpu_supports("avx512f") << endl;
+
+    return 0;
+}
+

Экономя время читателю: сервера CodeForces и большинство онлайн джаджей на момент написания статьи поддерживают AVX2, то есть умеют полноценно работать с 256-битными регистрами.

+

C++ intrinsics

+

SSE это те же чистые ассемблерные инструкции. Языки с каким-либо более верхним уровнем абстракции напрямую работать с ними уже не могут. Однако не обязательно писать на чистом ассемблере, чтобы их использовать — разработчики компиляторов уже позаботились об этом за вас и сделали встроенные функции-обёртки, которые называют интринзиками (англ. intrinsic — «внутренний»).

+

Чтобы их подключить, нужно указать include на соответствующий заголовочный файл, а также сказать компилятору о том, что мы хотим использовать конкретный набор или наборы инструкций. В примере из начала статьи мы сделали именно это, прописав target("avx2") — компилятор получил доступ к более широким регистрам и продвинутым инструкциям для них, и смог соптимизировать программу примерно в два раза (по умолчанию включены 128-битные sse и sse2, поэтому в 2, а не в \(\frac{256}{32} = 8\)).

+

По аналогии с <bits/stdc++.h>, в GCC есть такой же заголовочный файл <x86intrin.h>, включающий в себя сразу все SSE-интринзики. Шаблон любителя засоренных неймспейсов и избыточно долгой компиляции может начинаться так:

+
#pragma GCC target("avx2")
+#pragma GCC optimize("O3")
+
+#include <x86intrin.h>
+#include <bits/stdc++.h>
+
+using namespace std;
+

Пример. Простой цикл, в котором складывают два массива 64-битных действительных чисел, на SSE-интринзиках будет выглядеть так:

+
double a[100], b[100], c[100];
+
+for (int i = 0; i < 100; i += 4) {
+    // загрузим два отрезка по 256 бит в свои регистры
+    __m256d x = _mm256_loadu_pd(&a[i]);
+    __m256d y = _mm256_loadu_pd(&b[i]);
+    // - 256 означает размер регистров
+    // - d означает "double"
+    // - pd означает "packed double"
+
+    // просуммируем числа и положим результат в другой регистр:
+    __m256d z = _mm256_add_pd(x, y);
+    // запишем содержимое регистра в память:
+    _mm256_storeu_pd(&c[i], z);
+}
+

Если размер массива не кратен размеру регистра, то программа может отработать некорректно. В этом случае можно поступить одним из двух способов:

+
    +
  1. Добавить в конец массива «нейтральные» элементы, дополнив его до удобной длины.

  2. +
  3. Обработать через SSE столько элементов, сколько получается, а оставшиеся обработать отдельно.

  4. +
+

Например, вот так можно считать сумму на массиве произвольного размера:

+
int sum(int a[], int n) {
+    int res = 0;
+
+    // создадим регистр, в котором будем хранить 8 текущих сумм
+    __m256i x = _mm256_setzero_si256();
+    for (int i = 0; i + 8 < n; i += 8) {
+        __m256i y = _mm256_loadu_si256((__m256i*) &a[i]);
+        x = _mm256_add_epi32(x, y);
+    }
+
+    // сложим все 8 чисел в регистре в одно
+    int *b = (int*) &x;
+    for (int i = 0; i < 8; i++)
+        res += b[i];
+
+    // добавим остаток массива
+    for (int i = (n / 8) * 8; i < n; i++)
+        res += a[i];
+
+    return res;
+}
+

Имена команд. Большинство команд кодируются как _mm<размерность>_<действие>_<тип>.

+

Несколько других примеров:

+
    +
  • _mm_add_epi16 — складывает две группы 16-битных extended packed integer, проще говоря \(\frac{128}{16} = 8\) short-ов (в инструкциях, где размер регистра не указан, он равен 128).

  • +
  • _mm256_acos_pd — принимает один регистр, содержащий 4 double-ов, и возвращает их арк-косинусы.

  • +
  • _mm256_broadcast_sd — бродкастит (копирует) double из памяти во все четыре слота в регистре.

  • +
  • _mm256_ceil_pd — округляет double к ближайшему int-у вверх.

  • +
  • _mm256_cmpeq_epi32 — сравнивает запакованные int-ы и возвращает вектор-маску, в которой для полностью совпавших элементов будет по 32 единицы.

  • +
  • _mm256_blendv_ps — по заданной маске берёт значения либо из первого массива, либо из второго. Часто применяется для замены if-а.

  • +
+

Комбинаторно получается огромное количество различных функций. Полная документация по ним — Intel Intrinsics Guide — лежит в закладках браузера у каждого уважающего себя performance engineer-а.

+

Выравнивание. Отдельно стоит отметить одну деталь: операции чтения и записи имеют по две версии — load / loadu и store / storeu. Буква «u» здесь означает «unaligned» (англ. невыровненный). Первые корректно работают только тогда, когда весь считываемый блок помещается на одну кэш-линию (если это не так, то в рантаеме вызвется segfault), в то время как unaligned версия работает всегда и везде.

+

Иногда, особенно когда операция «лёгкая», это отличие имеет большое значение — если не получается «выровнять» память, то производительность может резко упасть (хотя бы потому, что нужно загружать две кэш-линии вместо одной).

+

Например, так складывать два массива:

+
void aplusb_unaligned() {
+    for (int i = 3; i + 7 < n; i += 8) {
+        __m256i x = _mm256_loadu_si256((__m256i*) &a[i]);
+        __m256i y = _mm256_loadu_si256((__m256i*) &b[i]);
+        __m256i z = _mm256_add_epi32(x, y);
+        _mm256_storeu_si256((__m256i*) &c[i], z);
+    }
+}
+

…будет на 30% медленнее, чем так:

+
void aplusb_aligned() {
+    for (int i = 0; i < n; i += 8) {
+        __m256i x = _mm256_load_si256((__m256i*) &a[i]);
+        __m256i y = _mm256_load_si256((__m256i*) &b[i]);
+        __m256i z = _mm256_add_epi32(x, y);
+        _mm256_store_si256((__m256i*) &c[i], z);
+    }
+}
+

Если предположить, что в первом варианте начало массива совпадает с началом кэш-линии, а её размер 64 байта, то примерно половина loadu и storeu будут «плохими».

+

Вручную «выровнять» память для последовательного чтения через load в случае с массивами можно так:

+
alignas(32) float a[n];
+
+for (int i = 0; i < n; i += 8) {
+    __m256 x = _mm256_load_ps(&a[i]);
+    // ...
+}
+

Указатель на начало массива теперь будет кратен 32 байтам, то есть размеру sse-блока. Теперь любое чтение и запись гарантированно будет внутри кэш-линии.

+

Типизация. Вообще, загружать и сохранять данные интринзиками и вообще использовать __m-типы на самом деле не обязательно — всё можно сделать и обычным reinterpret_cast-ом. Все данные хранятся в одном и том же формате, и разные типы нужны просто для проверки типов и избежания связанных ошибок.

+

Для каждой размерности регистра есть 3 типа данных. На примере AVX: __m256 для float, __m256d для double и __m256i для разных int-ов.

+

Некоторые операции есть только для какого-то одного типа (например, тот же _mm256_blendv_ps не имеет аналога для 32-битных int-ов), однако будут работать с другими типами абсолютно так же. Поэтому, to make compiler happy, нужно применять к ним преобразования типов, которые не будут стоить дополнительных инструкций в рантайме. Они все имеют такой формат: _mm<размерность>_cast<откуда>_<куда>.

+

Loop unrolling. Добавление флага unroll-loops (или прагмы: #pragma GCC optimize("unroll-loops")) позволяет компилятору делать «размотку» циклов, то есть преобразовывать код вида:

+
for (int i = 1; i < n; i++)
+    a[i] = (i % b[i]);
+

…во что-то такое:

+
int i;
+for (i = 1; i < n - 3; i += 4) {
+    a[i] = (i % b[i]);
+    a[i + 1] = ((i + 1) % b[i + 1]);
+    a[i + 2] = ((i + 2) % b[i + 2]);
+    a[i + 3] = ((i + 3) % b[i + 3]);
+}
+
+for (; i < n; i++)
+    a[i] = (i % b[i]);
+

Такая техника может сильно ускорить лёгкие циклы, в которых пересчёт индикатора занимает сравнимое с телом цикла время, но увеличивает количество инструкций. Это не всегда полезно: сам бинарник будет весить больше, а также, если ёмкости кэша команд не хватит, то эффективность цикла может даже существенно снизиться.

+

Нетривиальный пример

+

Пусть нам зачем-то понадобилось возвести \(10^8\) чисел в какие-то степени.

+

Сравним два решения: обычное и векторизованное. Тестировать подходы будем таким кодом:

+
#pragma GCC optimize("O3")
+#pragma GCC target("avx2")
+
+#include <x86intrin.h>
+#include <bits/stdc++.h>
+
+using namespace std;
+
+typedef unsigned long long ull;
+typedef __m256i reg;
+
+const int n = 1e8;
+alignas(32) unsigned bases[n], results[n], powers[n];
+
+void timeit(void (*f)()) {
+    // запускает другую функцию и меряет время её исполнения
+    clock_t start = clock();
+    f();
+    cout << double(clock() - start) / CLOCKS_PER_SEC << endl;
+
+    for (int i = 0; i < 10; i++)
+        cout << results[i] << " ";
+    cout << endl;
+}
+
+int main() {
+    for (int i = 0; i < n; i++) {
+        bases[i] = rand();
+        powers[i] = rand();
+    }
+
+    // timeit(binpow_simple);
+    // timeit(binpow_sse);
+
+    return 0;
+}
+

В SSE весьма сложно делить int-ы (см. примечания ниже), поэтому будем считать всё по модулю \(2^{32}\), то есть просто переполняя естественным образом unsigned int.

+

Напишем стандартное итеративное бинарное возведение в степень:

+
void binpow_simple() {
+    for (int i = 0; i < n; i++) {
+        unsigned a = bases[i], p = powers[i];
+
+        unsigned res = 1;
+        while (p > 0) {
+            if (p & 1)
+                res = (res * a);
+            a = (a * a);
+            p >>= 1;
+        }
+
+        results[i] = res;
+    }
+}
+

Этот код работает за 9.47 секунды.

+

Теперь попробуем векторизованную версию:

+
void binpow_sse() {
+    const reg ones = _mm256_set_epi32(1, 1, 1, 1, 1, 1, 1, 1);
+    for (int i = 0; i < n; i += 8) {
+        reg a = _mm256_load_si256((__m256i*) &bases[i]);
+        reg p = _mm256_load_si256((__m256i*) &powers[i]);
+        reg res = ones;
+
+        // на самом деле, здесь никакого цикла не будет
+        // -- компилятор это развернёт в 32 отдельных блока операций
+        for (int i = 0; i < 32; i++) {
+            // чтобы не писать if, посчитаем для каждого элемента его множитель:
+            // это либо единица, либо a, в зависимости от значения нижнего бита p
+            // маски элементов, которые нужно домножать на a:
+            reg mask = _mm256_cmpeq_epi32(_mm256_and_si256(p, ones), ones);
+            // смешаем два вектора по маске:
+            reg mul = _mm256_blendv_epi8(ones, a, mask);
+            // res *= mul:
+            res = _mm256_mullo_epi32(res, mul);
+            // a *= a:
+            a = _mm256_mullo_epi32(a, a);
+            // p >>= 1:
+            p = _mm256_srli_epi32(p, 1);
+        }
+
+        _mm256_store_si256((__m256i*) &results[i], res);
+    }
+}
+

Эта реализация уже работает за 0.7 секунды — в 13.5 раз быстрее. При этом там и дальше есть, что оптимизировать.

+

Трудности автовекторизации

+

В самом начале статьи мы приводили пример кода, в котором уже оптимизированный бинарник получается без каких-либо изменений, кроме подключения нужного таргета компиляции. Зачем тогда вообще программисту делать что-либо ещё?

+

Дело в том, что иногда — очень редко — программист всё-таки умнее компилятора, потому что знает про задачу чуть больше.

+

Рассмотрим этот же пример, убрав из него всё лишнее:

+
void sum(int a[], int b[], int c[], int n) {
+    for (int i = 0; i < n; i++)
+        c[i] = a[i] + b[i];
+}
+

Почему эту функцию нельзя заменить на векторизованный вариант автоматически?

+

Во-первых, потому что это не всегда корректно. Предположим, что a[] и c[] пересекаются, причём так, что указатели на начало массивов отличаются на 1-2 позиции. Ну, мало ли — может, мы такой изощрённой свёрткой хотели посчитать последовательность Фибоначчи. Тогда в simd-блоках данные будут пересекаться, и наблюдаемое поведение будет совсем не то, которое мы хотели.

+

Во-вторых, мы ничего не знаем про выравнивание этих массивов, и можем потерять производительность здесь (для больших циклов неактуально — компилятор оба «края» обрабатывает отдельными циклами).

+

На самом деле, когда компилятор подозревает, что функция будет использована для больших циклов, то на высоких уровнях оптимизации он сам вставит runtime-проверки на эти случаи и сгенерирует два разных варианта: через SSE и «безопасный».

+

Но выполнять эти проверки в рантайме не хочется, поэтому можно сообщить компилятору, что мы уверены, что ничего не сломается:

+
#pragma GCC ivdep
+for (int i = 0; i < n; i++)
+    // ...
+

Здесь «ivdep» означает ignore vector dependencies — данные внутри цикла ни от чего не зависят.

+

Существует много других способов намекнуть компилятору, что конкретно мы имели в виду, но в сложных случаях — когда внутри цикла используются if-ы или вызываются какие-нибудь внешние функции — проще спуститься до уровня интринзиков и написать всё самому.

+

Gather и scatter

+

Долгое время большом препятствием на пути к оптимизации был тот факт, что чтобы применить SIMD-инструкцию к данным, нужно сначала собрать их всех в одном регистре, что в некоторых случаях может быть дороже, чем выгода от векторизации — например, при перемножении разреженных матриц.

+

Чтобы это ускорить, в AVX2 добавили два новых класса операций: gather и scatter. Первый принимает указатели на место в памяти и загружает их содержимое в правильном порядке в нужный регистр, а второй наоборот — принимает регистр со значениями и записывает их в указанные места в памяти.

+

+

Эти операции дают значительное ускорение, но при этом важно помнить, что они работают не в 8 и вообще не в константное количество раз быстрее, потому что они упираются не только в процессор, но и в память.

+

Нам, спортивным программистам, не очень интересна разреженная линейная алгебра, поэтому рассмотрим другой поучительный пример работы с несплошной памятью: оптимизацию дерева Фенвика. На этот раз интринзики писать не будем и целиком положимся на автовекторизацию.

+
#pragma GCC optimize("03")
+#pragma GCC target("avx2")
+
+#include <bits/stdc++.h>
+using namespace std;
+
+const int logn = 20;
+const int n = (1<<logn), m = 1e5;
+int t[n], q[m], res[m], rs[m];
+//  ^ фенвик, запросы, результаты
+
+int sum(int r) {
+    int res = 0;
+    for (; r > 0; r -= r & -r)
+        res += t[r];
+    return res;
+}
+
+void simple_fenwick() {
+    for (int i = 0; i < m; i++)
+        res[i] = sum(q[i]);
+}
+
+void vectorized_fenwick() {
+    memcpy(rs, q, sizeof q);
+    
+    // те же самые циклы фенвика, только вдоль запросов;
+    // вместо остановки по условию, цикл исполняется logn раз,
+    // из которых сколько-то последних итераций ничего не изменят
+    for (int l = 0; l < logn; l++) {
+        for (int i = 0; i < m; i++) {
+            int x = rs[i];  // это будет заменено на gather
+            res[i] += t[x]; // это будет заменено на add
+            x -= (x & -x);  // это будет заменено на ещё 3 операции
+            rs[i] = x;      // это будет заменено на storeu
+        }
+    }
+    // так как (x & -x) равен последнему биту,
+    // а t[0] равен нулю, то алгоритм работает корректно,
+    // хоть и в среднем половина операций исполняется впустую    
+}
+
+void timeit(void (*f)()) {
+    clock_t start = clock();
+    for (int i = 0; i < 1000; i++)
+        f();
+    cout << double(clock() - start) / CLOCKS_PER_SEC << endl;
+}
+
+int main() {
+    for (int i = 0; i < m; i++)
+        q[i] = rand() % n;
+
+    timeit(simple_fenwick);
+    timeit(vectorized_fenwick);
+
+    return 0;
+}
+

Эксперименты с памятью всегда интересно проводить с разными размерами структуры:

+
    +
  • При \(n \approx 10^4\) векторизованный вариант быстрее в ~4 раза.

  • +
  • При \(n \approx 10^5\) векторизованный вариант быстрее в ~3 раза.

  • +
  • При \(n \approx 10^6\) векторизованный вариант быстрее в ~2 раза.

  • +
  • При \(n \approx 10^7\) векторизованный вариант медленнее в ~3 раза.

  • +
+

Такие результаты объясняются тем, что с ростом \(n\) структура начинает не вмещаться в разные уровни кэша и становится больше зависеть не от операций с регистрами, а от количества обращений в память — а векторизованная реализация здесь проигрывает, потому что в среднем делает их в два раза больше, чем надо.

+

При \(n \approx 10^7\) дерево Фенвика не влезает даже в L3, и программа начинает почти каждый раз ходить в RAM — именно поэтому скачок там настолько существенный.

+

Разное

+

С++ в ассемблер. Посмотреть на генерируемые инструкции можно так:

+
g++ -S program.cpp -o program.s
+

Это позволяет понять, векторизует ли уже компилятор код или нет (названия векторных инструкций начинаются с буквы v). Во многих IDE есть удобные плагины, позволяющие выяснять это для конкретных функций.

+

Если указать флаг -fopt-info-vec-optimized, то компилятор прямо укажет на операции, которые он смог векторизовать:

+
g++ -fopt-info-vec-optimized program.cpp -o run
+

Можно поменять optimized на missed или all, чтобы посмотреть причины, почему не получилось векторизовать другие.

+

Распечатать вектор. Для дебага помогает такой код:

+
template<typename T>
+void print(T var) {
+    unsigned *val = (unsigned*) &var;
+    for (int i = 0; i < 4; i++)
+        cout << bitset<32>(val[i]) << " ";
+    cout << endl;
+}
+

В данном случае он выводит 4 группы по 32 бита из 128-битного вектора.

+

Деление. В SSE нет операции деления int-ов, но есть для float-ов и производных. Также нет взятия остатка от деления, что осложняет вычисления в комбинаторике.

+

Для деления 32-битных целых чисел их можно аккуратно скастовать к даблу, поделить так, и скастовать обратно — точности хватит, хоть это и будет медленно.

+

Умножение работает в несколько раз быстрее деления, и поэтому для ускорения деления float-ов на известную константу \(d\) есть следующий трюк: заменить выражение \(x / d\) на \(x \cdot \frac{1}{d}\), и при этом \(\frac{1}{d}\) посчитать во время компиляции.

+

Для целочисленных типов такое сделать немного сложнее — нужно заменить деление на умножение и битовый сдвиг. Для этого нужно приблизить \(\frac{1}{d} \approx \frac{m}{2^s}\), подобрав «магическое» число \(m\) и степень двойки \(s\), такие что что x / d == (x * m) >> s для всех x.

+

Можно показать, что такая пара чисел всегда существует, и компилятор сам оптимизирует деление на константу подобным образом. Вот, например, сгенерированные инструкции для деления unsigned long long на \(10^9 + 7\):

+
movq    %rdi, %rax
+movabsq $-8543223828751151131, %rdx ; загружает магическую константу в регистр
+mulq    %rdx                        ; делает умножение
+movq    %rdx, %rax
+shrq    $29, %rax                   ; делает битовый сдвиг результата
+

Здесь для умножения используется «mixed precision» инструкция mulq, которая берёт два 64-битных числа и записывает 128-битный результат их умножения в два 64-битных регистра (lo, hi).

+

Для деления long-ов на SSE такой способ пока что не работает: аналогичная инструкция добавилась только в AVX512.

+ + diff --git a/ru/stl.html b/ru/stl.html new file mode 100644 index 0000000..196235b --- /dev/null +++ b/ru/stl.html @@ -0,0 +1,411 @@ + + + + + + + C++ и Standard Template Library (STL) - Алгоритмика + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

C++ и Standard Template Library (STL)

+

Первый плюс плюсов - STL, она содержит в себе множество алгоритмов и структур, чтобы ее использовать вам нужно писать

+
// либо один раз такую строчку
+using namespace std;
+
+// либо каждый раз писать перед каждой функцией и структурой из STL std::
+std::vector
+
  File "<ipython-input-1-3fdc4c3a8d74>", line 1
+    (/, либо, один, раз, такую, строчку)
+     ^
+SyntaxError: invalid syntax
+

https://en.cppreference.com/w — это сайт с документацией по языку C++. Там вы можете найти много полезной информации как о самом языке, так и о его стандартных библиотеках. Здесь же вы сможете найдите краткое изложение самого полезного из STL.

+

Поговорим об обычных сишных массивах

+
T a[x]; // инициализация массива a типа T размера x
+

Если вы пишите так, прекращайте, у этого метода много проблем, давайте напишем правильную реализацию и обговорим плюсы и минусы.

+
array<T, x> a;
+a.begin(), a.end() // указатели (итераторы) на начало и конец массива соответственно (конец массива = после последнего элемента)
+a.front(), a.back() // возвращает ссылку на первый и последний элемент соответственно
+cout << a.at(5); // метод at позволяет узнать не вышли ли мы за границы массива
+

Плюсы :

+
    +
  1. Указатели (итераторы) на начало и конец - прекрасная вещь, которую мы обсудим позже

  2. +
  3. Сишные массивы уже сейчас противоречат шаблону, в будущем их хотят вообще убрать, так что лучше от них отказаться сейчас

  4. +
  5. Быстрый переход от массива к вектору

  6. +
+

Минусы :

+
    +
  1. Тяжело отвыкнуть от старого массива.
  2. +
+

vector

+

vector — это динамический массив. Это означает, что его размер может меняться во время исполнения программы, вы можете добавлять элементы в конец и так далее. Чтобы объявить пустой vector, способный содержать в себе целые числа типа T, необходимо воспользоваться следующей конструкцией:

+
vector<T> vector_name;
+

Здесь T — это тип элементов, которые будут содержаться в vector, а vector_name — имя самого vector. Как и другие контейнеры C++, vector не может содержать элементы разных типов!

+

Чтобы добавить элемент в конец вектора, необходимо воспользоваться функцией push_back. Эта функция работает в среднем за \(O(1)\) .

+

Существуют два способа обращения к ‘vector’. Оба мы уже обсудили в массивах.

+
vector<int> a;
+cout << a[5];
+cout << a.at(5);
+

Кроме этого вам также могут потребоваться следующие методы :

+
vector<int> a;
+a.push_back(x); // вставляет x в конец a
+a.size(); // возращает размер вектора а
+a.resize(x) // сделать размер вектора = x, либо удаляются последние элементы, либо добавляются нули
+a.resize(x, y) // сделать размер вектора = x, добавляются y
+// Также можно изначально задать размер и элементы массива
+vector<int> a(10); // Размер вектора - 10 элементов, каждый из которых равен 0
+vector<int> b(5, 123); // Размер - 5 элементов, каждый из которых равен 123
+vector<int> c = {1, 2, 3} // явная инициализация
+

Как работает vector?

+

Как уже было сказано, добавление в конец вектора работает в среднем за \(O(1)\). Это означает, что, если вы сделаете \(n\) операций push_back, они будут в сумме работать за \(O(n)\). (Но при этом некоторые из них могли работать и за линейное время!)

+

У vector есть 2 важные величины: size и capacity — размер и вместимость. Размер — это то, сколько элементов сейчас находится в векторе. Вместимость — то, под сколько элементов памяти выделено. Когда size < capacity, push_back просто добавляет новый элемент в первую свободную ячейку уже выделенной памяти, поэтому работает за \(O(1)\). Когда size = capacity, так сделать не удастся. Поэтому, происходит следующее: 1. capacity увеличивается примерно в 2 раза. 2. Выделяется область памяти, вмещающая capacity элементов. 3. Элементы из старой области памяти копируются в новую. 4. Старая область памяти освобождается.

+

Поймём, почему амортизированное время работы push_back действительно равно \(O(1)\). Пусть сейчас capacity = \(n\). Тогда мы выделяли \(n + \frac{n}{2} + \frac{n}{4} + \dots < 2n\) памяти. На копирование также ушло не более \(2n\) операций. Следовательно, так как операций push_back было хотя бы \(\frac{n}{2}\), каждая операция в среднем работала за \(O(1)\).

+

Получить capacity у vector можно с помощью одноимённой функции. Рассмотрим пример того, как изменяется capacity.

+
vector<int> a;
+for (int i = 0; i < 10; i++) {
+    a.push_back(i);
+    cout << "Size: " << a.size() << ", capacity " << a.capacity() << "\n";
+}
+
+/* Будет выведено:
+   Size: 1, capacity 1
+   Size: 2, capacity 2
+   Size: 3, capacity 4
+   Size: 4, capacity 4
+   Size: 5, capacity 8
+   Size: 6, capacity 8
+   Size: 7, capacity 8
+   Size: 8, capacity 8
+   Size: 9, capacity 16
+   Size: 10, capacity 16
+*/
+

pair

+

pair — это тип, содержащий пару значений, притом значения могут быть разных типов. Объявление пары выглядит так:

+
pair<T1, T2> p;
+

Здесь T1 и T2 — это имена первого и второго типов, соответственно.

+

Первый элемент пары — это p.first; второй — p.second.

+

make_pair(a, b) — функция, которая создаёт пару \((a, b)\).

+

Рассмотрим пример работы с pair.

+
pair<int, double> p = make_pair(1, 2.0);
+pair<int, double> q = {1, 2.5}; // другой способ инициализировать пару
+cout << p.first << " " << p.second << "\n";
+

queue

+

В c++ уже реализована такая структура, как очередь, она названа queue.

+
queue<T> q; //очередь типа T
+

Очередь - структура, реализующая принцип FIFO (первый пришел - первый вышел), то есть для очереди существуют две основные функции : Вставить в конец и достать с начала.

+
q.front(); // ссылка на первый элемент
+q.back(); // ссылка на последний элемент
+q.push(x); // добавить в конец
+q.pop(); //удалить с начала
+

deque

+

deque - структура, позволяющая работать и с началом и концом одновременно, то есть вставка и удаление с двух сторон

+
deque<T> name; // дек типа T с названием name
+name.front(), name.back(); // первый и последний элемент соответственно
+name.pop_front(), name.pop_back(); // удаление первого и последнего элемента
+name.push_front(x), name.push_back(x); // вставка x в начало/конец
+

Очередь и дек будут более подробно рассмотрены на одном из следующих занятий.

+

Итераторы

+

Итератор — это объект, указывающий на элемент контейнера. Чтобы получить элемент, на который указывает итератор it, необходимо воспользоваться оператором разыменования: *it. Также если вам нужно перейти к следующему элементу надо использовать инкремент: ++it.

+

Есть несколько категорий итераторов: - InputIterator. Он поддерживает только операции разыменования и инкремента, притом после того, как был произведён инкремент, все копии его предыдущего значения могут стать невалидными. - ForwardIterator. Поддерживает то же, что и InputIterator, но итератор, указывающий на какой-то конкретный элемент, можно инкрементировать сколько угодно раз. - BidirectionalIterator. Поддерживает то же, что и ForwardIterator, но также есть возможность производить декремент (it--) — переходить к предыдущему элементу коллекции. - RandomAccessIterator. Поддерживает то же, что и BidirectionalIterator, но также есть возможность переходить к элементу коллекции, который находится от данного на каком-то расстоянии \(k\). Так, например, для итератора it возможны следующии операции: it + k, it - k, it += k, it -= k. Также можно находить расстояние между двумя позициями, на которые указывают итератора. Так, например, выражение a - b будет означать расстояние между двумя элементами коллекции, на которые указывают итераторы a и b.

+

Рассмотрим использование итераторов на примере vector. Для вектора a итератор на его первый элемент можно получить так: a.begin(). Также есть функция, которая возвращает итератор на фиктивный элемент, следующий за последним элементом вектора: a.end(). Таким образом, весь a задаётся полуинтервалом [a.begin(); a.end()) (левый конец включается, правый — нет).

+

Итераторы у vector относятся к категории RandomAccessIterator, то есть например мы можем узнать размер vector \(a\), просто взяв a.begin() - a.end(). При этом у vector типа vector<T> итератор будет иметь тип vector<T>::iterator.

+

Рассмотрим пример работы с итераторами у vector.

+
vector<int> a = {1, -2, 3, 100};
+
+vector<int>::iterator first_element = a.begin();
+cout << *first_element << "\n"; // выведет 1
+first_element++;
+cout << *first_element << "\n"; // выведет -2
+first_element--;
+cout << *first_element << "\n"; // выведет 1
+auto third_element = first_element + 2; // пользуемся типом auto, чтобы не писать длинное имя типа
+cout << *third_element << "\n"; // выведет 3
+cout << (third_element - first_element) << "\n"; // выведет 2
+
+// вывод всех элементов вектора с использованием итераторов
+for (auto it = a.begin(); it != a.end(); it++) {
+    cout << *it << " ";
+}
+cout << "\n"; // но для таких целей лучше использовать range-based for loop:
+  
+for (auto& it : a) {
+    cout << a << " ";
+}
+

list

+

list - структура, которая поддерживает быструю вставку и удаление элементов из любой позиции в контейнере. Быстрый произвольный доступ, к сожалению, не поддерживается (то есть мы не можем быстро взять \(i\)-й элемент). Он реализован в виде двусвязного списка

+
list<T> name; // создание листа типа Т с именем name
+name.insert(it, x); // вставка после итератора it переменной x
+name.erase(it); // удаляет элемент, на который указывает итератор
+name.front(), name.back(); // начало и конец name
+

set

+

set — это коллекция, которая содержит множество уникальных упорядоченных элементов.

+

Чтобы добавить элемент в set, есть функция insert. В случае, если элемент уже был в множестве, ничего не происходит.
+Чтобы удалить элемент из set, есть функция erase(в нее можно передать либо итератор на элемент, либо просто элемент). В случае, если элемента не было в множестве, ничего не происходит.
+Чтобы посмотреть, если ли элемент в set, есть функция count. Она вернёт \(0\), если элемента нет в множестве, и \(1\), если он есть. Также есть метод find,  который возвращает итератор на элемент или end, если элемента нет.

+

Все операции с элементами set (добавление, удаление, поиск) работают за \(O(\log n\)), где \(n\) — количество элементов в нём, так как он реализован с помощью сбалансированного двоичного дерева поиска.

+

Итераторы set относятся к категории BidirectionalIterator и имеют тип set<T>::iterator. Начало set можно получить с помощью функции begin, конец — с помощью функции end. Как и в случае с вектором, end указывает на конец полуинтервала. Инкремент и декремент итераторов set также работают за логарифмическое время.

+

Стоит отметить, что, так как элементы в set упорядочены, с помощью begin и end можно искать наименьший/наибольший элемент в set. Чтобы найти наименьший элемент, больший или равный заданному, есть функция lower_bound.
+Чтобы найти наименьший элемент, строго больший заданному, есть функция upper_bound.
+Каждая из этих функций возвращает итератор на искомый элемент или end(), если такого элемента не существует.

+

set может содержать только элементы тех типов, для которых определён оператор <, поскольку ему важен порядок элементов.

+

Рассмотрим пример простейших операций с set.

+
set<int> s;
+
+s.insert(3); // s = {3}
+s.insert(2); // s = {2, 3}
+cout << s.size() << "\n"; // выведет 2
+
+s.insert(3); // 3 не будет добавлено ещё раз, так как уже присутствует в множестве
+cout << s.size() << "\n"; // выведет 2
+
+s.insert(5); // s = {2, 3, 5}
+cout << s.count(3) << "\n"; // выведет 1
+cout << s.count(4) << "\n"; // выведет 0
+
+s.erase(3); // s = {2, 5}
+s.insert(6); // s = {2, 5, 6}
+
+set<int>::iterator it1 = s.find(5);
+it1++;
+cout << *it1 << "\n"; // выведет 6
+
+auto it2 = s.lower_bound(1);
+cout << *it2 << "\n"; // выведет 2, так как это первый элемент >= 1
+
+auto it3 = s.upper_bound(2);
+cout << *it3 << "\n"; // выведет 5, так как это первый элемент > 2.
+
+auto it4 = s.upper_bound(10);
+if (it4 == s.end()) {
+    cout << "No element > 10\n"; // аккуратно, если разыменуете it4, получите undefined behaviour!
+}
+
+// вывод всех элементов сета с использованием итераторов; элементы следуют в порядке возрастания
+for (auto it = s.begin(); it != s.end(); it++) {
+    cout << *it << " ";
+}
+cout << "\n"; // но для таких целей лучше использовать range-based for loop!
+

multiset

+

multiset — то же, что и set, но может содержать повторяющиеся элементы.

+

count работает за \(O(\log n + c)\), где \(c\) — количество искомых элементов. Поэтому, чтобы проверить наличие элемента \(el\) в multiset s, надо воспользоваться: s.find(el) != s.end().

+

erase удаляет все элементы с таким значением. Чтобы удалить один надо делать так: `s.erase(s.find(el)).

+

Примеры применения сета для решения задач

+

Очень часто использование сета позволяет решить задачу, которая решается и абсолютно другим способом. Иногда более сложным, а иногда и более простым. Чаще всего сет используется, если нужно сделать что-то, связанное с количеством разных элементов или с минимумом или максимум какого-то множества.

+

1) Задача Девшука или Юноша

+

Условие: http://codeforces.com/contest/236/problem/A

+

Условие вкратце: Найти чётность числа различных символов в строке.

+

Решение: вставим все символы в сет и проверим четность размера сета.

+

Заметим, что эту задачу можно легко решить и подсчетом (как в сортировке подсчетом), так как символов бывает очень мало.

+

2) A и B и ошибки компиляции

+

Условие: http://codeforces.com/contest/519/problem/B

+

Условие вкратце: Из массива убрали ровно одно число и перемешали элементы. Затем сделали так еще раз. Найдите, какие два элемента исчезли.

+

Решение: Добавим все числа в 3 различных мультисета, затем просто пройдемся по ним и найдем первый элемент, которого во втором меньше, чем в первои, но есть в первом. Мы нашли первую ошибку, вторая находится точно также.

+

Заметим, что эту задачу можно было бы легко решить и просто отсортировав все числа, что асимптотически тоже \(O(N log N)\).

+

3) Минимум на отрезке

+

Условие: https://informatics.msk.ru/mod/statements/view3.php?chapterid=756

+

Условие вкратце: Найти минимум на каждом отрезке длины \(K\) в массиве.

+

Решение: Давайте положим в мультисет первые \(K\) элементов. Далее мы будем двигать это “окно”: добавлять новый элемент справа и убирать самый левый элемент. Каждый раз будем выводить минимум в сете, который лежит в s.begin().

+

Также эта задача решается такими структурами данных как

+
    +
  1. Очередь с минимумом (за \(O(N)\)!)

  2. +
  3. Дерево отрезков

  4. +
  5. Sparse Table

  6. +
  7. Куча

  8. +
+

Но решить задачу сетом гораздо проще.

+

map

+

map — это ассоциативный контейнер: он содержит пары ключ-значение, при этом все ключи уникальны. Внутри контейнера все ключи упорядочены по возрастанию. Так же, как и в set, операции работают за логарифмическое время.

+

Объявление map выглядит так: map<T1, T2> map_name, где T1 — тип ключа, T2 — тип значения.

+

Доступ к элементам map осуществляется с помощью оператора []. map, аналогично set, поддерживает поиск по ключу с помощью find, lower_bound, upper_bound. При разыменовании итератора получается пара, первый элемент которой — ключ, второй — значение.

+

При обращении к несуществующему элементу map с помощью [], значение инициализируется значением по умолчанию для данного типа.

+

Рассмотрим работу map на примере:

+
map<int, int> a;
+a[13] = 5;
+a[2] = 7;
+cout << a[2] << "\n"; // выведет 7
+a[2]++;
+cout << a[2] << "\n"; // выведет 8
+a[100] = 42;
+
+/* Этот цикл выведет 3 строки:
+   2 8
+   13 5
+   100 42
+
+   Обратите внимание, что ключи упорядочены.
+*/
+for (auto el : a) {
+    cout << el.first << " " << el.second << "\n";
+}
+
+
+map<string, int> b;
+b["Bob"]--;
+b["Alice"] += 2;
+b["Dan"] = 123;
+
+/* Этот цикл выведет 2 строки:
+   Alice 2
+   Bob -1
+   Dan 123
+*/
+for (auto el : b) {
+    cout << el.first << " " << el.second << "\n";
+}
+
+map<string, vector<int>> c;
+c["wow"].push_back(2);
+c["abc"] = {2, -1, 17};
+cout << c["abc"].size() << "\n"; // выведет 3
+

Unordered структуры данных

+

Единственная проблема set и map - то что они работают за \(\log(n)\), что в некоторых задачах долго. Тогда возникает идея построить их не на двоичном дереве, а например на хештаблице (о ней вы узнаете на втором курсе), тогда unordered_set поддерживает вставку и удаление за \(O(1)\), единственная проблема - он содержит элементы в неотсортированном порядке, то есть мы уже не сможем искать минимум, максимум.

+

На питоне встроенные set и dict - это именно аналоги unordered_set и unordered_map. Упорядоченного сета и мэпа на питоне нет.

+
unordered_set<int> a;
+a.insert(x);
+a.erase(x);
+

Полезные функции из algorithm

+

swap

+

swap(a, b) — обменивает значения переменных a и b местами.

+
int a = 5;
+int b = 3;
+cout << a << " " << b << "\n"; // выведет 5 3
+swap(a, b);
+cout << a << " " << b << "\n"; // выведет 3 5
+

min_element и max_element

+

min_element(first, last) — возвращает итератор на минимум на полуинтервале [first; last).
+max_element(first, last) — возвращает итератор на максимум на полуинтервале [first; last).

+

Если минимумов/максимумов несколько, то возвращается первое вхождение.

+
vector<int> numbers = {5, 3, 1, 2, 1};
+auto it = min_element(numbers.begin(), numbers.end());
+cout << *it << " " << (it - numbers.begin()) << "\n"; // выведет 1 2
+

reverse

+

reverse(first, last) — переворачивает полуинтервал [first; last) (элементы идут в обратном порядке).

+
vector<int> a = {5, 2, 3, 10, 17};
+reverse(a.begin(), a.begin() + 3);
+for (int x : a) {
+    cout << x << " ";
+}
+cout << "\n";
+

В этом примере будет выведено 3 2 5 10 17.

+

sort, unique и компараторы

+

sort(first, last) — сортирует полуинтервал [first; last).

+
vector<int> a = {5, 2, 10, 11, 2, 3};
+sort(a.begin(), a.end()); // сортируем весь вектор
+for (int x : a) {
+    cout << x << " ";
+}
+cout << "\n";
+

В этом примере будет выведено 2 2 3 5 10 11.

+

Функция sort может принимать третий параметр — компаратор. Компаратор — это функция, которая принимает два объекта и возвращает true, если первый строго меньше второго, и false иначе.

+

Допустим, нам хотелось бы отсортировать числа по возрастанию их последней цифры, а при совпадении — по самому значению. Тогда мы могли бы написать следующий код:

+
bool cmp(int a, int b) {
+    return make_pair(a % 10, a) < make_pair(b % 10, b);
+}
+
+// это внутри main
+vector<int> a = {30, 32, 12, 7, 15};
+sort(a.begin(), a.end(), cmp);
+for (int x : a) {
+    cout << x << " ";
+}
+cout << "\n";
+

В данном примере, как мы и хотели, будет выведено 30 12 32 15 7.

+

unique(first, last) — принимает полуинтервал и удаляет все последовательные повторения элементов в нём. Функция возвращает итератор на конец полуинтервала, соответствующему уникализированным элементам. Значения элементов, которые следуют после этого полуинтервала, становятся неопределёнными. Поэтому рекомендуется использовать функцию unique, например, вместе с функцией resize.

+
vector<int> a = {5, 5, 5, 1, 5, 4, 4, 7, 1};
+a.resize(unique(a.begin(), a.end()) - a.begin());
+for (int x : a) {
+    cout << x << " ";
+}
+cout << "\n";
+

В данном примере будет выведено 5 1 5 4 7 1.

+

Часто требуется сначала отсортировать элементы, а потом убрать все повторения. Это делается следующей комбинацией:

+
sort(a.begin(), a.end());
+a.resize(unique(a.begin(), a.end()) - a.begin());
+

nth_element

+

Функция ставит на переданную позицию элемент, который был бы на этом месте после сортировки массива(работает за линию).

+
nth_element(begin, need, end); // need - позиция отсортированного массива, 
+//begin, end - итераторы на начало и конец места, которое надо сортировать.
+

next_permutation, prev_permutation

+

Генерирует следующую и предыдущую перестановку массива на отрезке с l по r;

+
next_permutation(l, r);//l, r - итераторы
+prev_permutation(a.begin(), a.end());
+

merge

+
merge(начало первой последовательности, конец первой последовательности, начало 
+второй последовательности, конец второй последовательности, куда вставлять);
+

Слияние двух массивов, которое используется в сортировке слиянием.

+ +

Все эти функции принимают полуинтервал [first; last) и значение value. Полуинтервал должен быть упорядочен по отношению element < value (сначала те элементы, которые удовлетворяют этому, потом остальные).

+

lower_bound — возвращает первый элемент, больший или равный value.
+upper_bound — возвращает первый элемент, строго больший value.
+binary_search — возвращает, присутствует ли value на этом полуинтервале.

+
vector<int> a = {1, 5, 5, 6, 7, 10};
+
+auto it1 = lower_bound(a.begin(), a.end(), 5);
+cout << (it1 - a.begin()) << "\n"; // выведет 1
+
+auto it2 = upper_bound(a.begin(), a.end(), 5);
+cout << *it2 << "\n"; // выведет 6
+
+if (binary_search(a.begin(), a.end(), 7)) {
+    cout << "There is an element = 7\n"; // это будет выведено
+}
+

Внимание!

+

Не используйте lower_bound, upper_bound, binary_search вместе с set/map! Они будут работать за линейное время. Используйте их собственные функции: set::lower_bound (вызывается через .) и так далее.

+

Ускорение ввода и вывода

+

Стандартные cin и cout работают очень медленно. Чтобы исправить это, в начале вашей функции main пишите следующее:

+
ios::sync_with_stdio(0);
+cin.tie(0);
+cout.tie(0);
+

Это позволяет ускорить ввод и вывод в разы!

+

Также крайне не рекомендуется использовать endl (кроме интерактивных задач). Используйте "\n". Они отличаются тем, что endl делает flush вывода, то есть сразу же выводит то, что вы хотите. Если вы будете использовать "\n", вывод будет накапливаться, а потом единожды выводиться, что гораздо быстрее.

+

Задание

+

Это просто олимпиадные задачи, но почти в любой задаче можно использовать STL, чтобы упростить себе жизнь.

+
    +
  • Решите как можно больше задач в контесте на информатиксе: https://informatics.msk.ru/mod/statements/view3.php?id=34778&chapterid=756#1
  • +
  • Решите как можно больше задач в контесте на codeforces: http://codeforces.com/group/g92L0id9Yb/contest/229989
  • +
+ + diff --git a/ru/stress-test.html b/ru/stress-test.html new file mode 100644 index 0000000..4585669 --- /dev/null +++ b/ru/stress-test.html @@ -0,0 +1,253 @@ + + + + + + + Стресс-тестирование - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Стресс-тестирование

+
    +
  • Метод поиска багов, заключающийся в генерации случайных тестов и сравнивании результатов двух решений
  • +
  • Очень полезен на школьных олимпиадах, когда есть много времени, или когда уже написано решение на маленькие подгруппы
  • +
+

Суть такая:

+
    +
  • Есть решение smart — быстрое, но в котором есть баг, который хотим найти
  • +
  • Пишем решение stupid — медленное, но точно корректное
  • +
  • Пишем генератор gen — печатает какой-то корректный тест, сгенерированный случайно
  • +
  • Кормим всё в скрипт checker, который n раз генерирует тест, даёт его на ввод stupid-у и smart-у, сравнивает выводы и останавливается, когда они отличаются
  • +
+

Как это выглядит в реальной жизни

+

Задача. Есть массив чисел \(1 \le a_1 ... a_n \le 10^9\). Найдите значение минимального элемента.

+

Приведем код решения stupid, который будем использовать в качестве эталонного:

+
int a[maxn];
+
+void stupid() {
+    int n;
+    cin >> n;
+    for (int i = 0; i < n; i++)
+        cin >> a[i];
+    int ans = 1e9;
+    for (int i = 0; i < n; i++)
+        ans = min(ans, a[i]);
+    cout << ans;
+}
+

Пусть у нас есть решение smart, которое содержит ошибку в границах цикла:

+
int a[maxn];
+
+void smart() {
+    int n;
+    cin >> n;
+    for (int i = 0; i < n; i++)
+        cin >> a[i];
+    int ans = 1e9;
+    for (int i = 1; i < n; i++)
+        ans = min(ans, a[i]);
+    cout << ans;
+}
+

Даже в таком примере можно долго искать ошибку, если подбирать случайные тесты руками и проверять ответ на правильность, поэтому мы хотим найти тест, на котором два решения будут давать разный ответ, чтобы впоследствии найти ошибку в smart.

+

Стресс-тестирование inline

+

Примечание. Автор не рекомендует так делать, но многим такой подход кажется проще для понимания.

+

Суть в следующем:

+
    +
  • Все решения и генераторы помещаются в отдельные методы.
  • +
  • Тесты рекомендуется передавать либо строками, либо через файл, но особо уверенные в себе могут использовать глобальные переменные.
  • +
  • Быть аккуратным с очищением глобальных переменных.
  • +
  • Запустить и получить тест.
  • +
  • Profit.
  • +
+
int a[maxn];
+int n;
+
+int stupid() {
+    int n;
+    cin >> n;
+    int ans = 1e9;
+    for (int i = 0; i < n; i++)
+        ans = min(ans, a[i]);
+    return ans;
+}
+
+int smart() {
+    int n;
+    cin >> n;
+    int ans = 1e9;
+    for (int i = 1; i < n; i++)
+        ans = min(ans, a[i]);
+    return ans;
+}
+
+void gen() {
+    n = rand() % 10 + 1;
+    for (int i = 0; i < n; i++) {
+        a[i] = rand();
+    }
+}
+
+int main() {
+    for (int i = 0; i < 100; i++) {
+        gen();
+        if (smart() != stupid()) {
+            cout << "WA" << endl;
+            cout << n << endl;
+            for (int j = 0; j < n; j++) {
+                cout << a[j] << ' ';
+            }
+            break;
+        }
+        cout << "OK" << endl;
+    }
+    return 0;
+}
+

Script-based стресс-тестирование

+

Суть в следующем:

+
    +
  • Все решения и генераторы помещаются в отдельные файлы.
  • +
  • Тесты рекомендуется передавать через перенаправление потоков ввода-вывода.
  • +
  • Быть аккуратным не надо — мы работаем с тем же самым решением, которое отправим в тестирующую систему.
  • +
  • Запустить и получить тест.
  • +
  • Если вы не работаете под Linux, то начните уже наконец работать под Linux.
  • +
  • Если вы не знаете Python, то выучите уже наконец Python.
  • +
  • Profit.
  • +
+

Файлы stupid.cpp, smart.cpp и gen.py содержат уже понятный нам код.

+

Вот примерный код скрипта checker.py:

+
import os, sys
+
+_, f1, f2, gen, iters = sys.argv # первый аргумент - 'checker.py' поэтому "откинем" его с помощью _
+
+for i in range(int(iters)):
+    print('Test', i+1)
+    os.popen('python3 %s > test.txt' % gen)
+    v1 = os.popen('./%s < test.txt' % f1).readlines()
+    v2 = os.popen('./%s < test.txt' % f2).readlines()
+    if v1 != v2:
+        print("FAIL!\nInput:")
+        print(*(open("text.txt").readlines()))
+        print("Correct output:")
+        print(*v1)
+        print("Wrong output:")
+        print(*v2)
+        sys.exit()
+print("No output differences found.")
+
    +
  • Автор обычно запускает его командой python3 checker.py stupid smart gen.py 100, предварительно скомпилировав stupid и smart в ту же директорию, что и сам checker.py.
  • +
  • При желании можно компилировать прямо внутри скрипта.
  • +
  • Не забывайте, что если хотя бы одна из программ не выводит перевод строки в конце файла, то чекер посчитает, что вывод разный.
  • +
  • Если задача подразумевает неоднозначный вывод (к примеру, вывести индекс минимума — таких может быть несколько), то вместо v1 != v2 следует использовать сторонний скрипт compare.py.
  • +
  • Скрипт написан под Linux. Для Windows нужно убрать «./» во всех системных вызовах и вместо “python3” писать “python”.
  • +
+

Примечание. Ну такой вот примерно рецепт усредненный, потому что вариаций масса. Берется неправильное решение, оно не работает, рабочий код — это не про код моего бати. Он берет это решение, вываливает его в скрипт и начинает запускать. Добавляет огромное количество тестов, крайних случаев, рандома и МАКСТЕСТОВ! для проверки. Все это прогоняется вместе с медленным решением. Потом скрипт находит баг и системный блок остужается на балконе. Потом батя заносит тест и щедро заполнив код отладочным выводом начинает дебажить. При этом параллельно ест и засыпает крошками клавиатуру. Ест и приговаривает полушепотом ух ###. При этом у него на лбу аж пот выступает. Любезно мне иногда предлагает подебажить, но я отказываюсь. Надо ли говорить о том какой код получается потом? Вонища такая, что тестирующая система падает.

+ + diff --git a/ru/string-searching.html b/ru/string-searching.html new file mode 100644 index 0000000..3d7c355 --- /dev/null +++ b/ru/string-searching.html @@ -0,0 +1,252 @@ + + + + + + + Поиск строки в строке - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Поиск строки в строке

+

Рассмотрим задачу, которая возникает каждый раз, когда вы делаете ctrl+f:

+
+

Есть большой текст \(t\). Нужно найти все вхождения строки \(s\) в него.

+
+

Наивное решение со сравнением всех подстрок \(t\) длины \(|s|\) со строкой \(s\) работает за \(O(|t| \cdot |s|)\). Если текст большой, то длинные слова в нем искать становится очень долго.

+

Однако существует множество способов решить эту задачу за \(O(|s| + |t|)\), два самых распространённых и простых из них: через префикс-функцию и через z-функцию (примечание: не «зи», а «зет»).

+

Префикс-функция

+

Определение. Префикс-функцией от строки \(s\) называется массив \(p\), где \(p_i\) равно длине самого большого префикса строки \(s_0 s_1 s_2 \ldots s_i\), который также является и суффиксом \(i\)-того префика (не считая весь \(i\)-й префикс).

+

Например, самый большой префикс, который равен суффиксу для строки «aataataa» — это «aataa»; префикс-функция для этой строки равна \([0, 1, 0, 1, 2, 3, 4, 5]\).

+
vector<int> slow_prefix_function(string s) {
+    int n = (int) s.size();
+    vector<int> p(n, 0);
+    for (int i = 1; i < n; i++)
+        for (int len = 1; len <= i; len++)
+            // если префикс длины len равен суффиксу длины len
+            if (s.substr(0, len) == s.substr(i - len + 1, len))
+                p[i] = len;
+    return p;
+}
+

Этот алгоритм пока что работает за \(O(n^3)\), но позже мы его ускорим.

+

Как это поможет решить исходную задачу?

+

Давайте пока поверим, что мы умеем считать префикс-функцию за линейное от размера строки, и научимся с помощью нее искать подстроку в строке.

+

Соединим подстроки \(s\) и \(t\) каким-нибудь символом, который не встречается ни там, ни там — обозначим пусть этот символ #. Посмотрим на префикс-функцию получившейся строки \(s\#t\).

+
string s = "choose";
+string t =
+    "choose life. choose a job. choose a career. choose a family. choose a fu...";
+
+cout << s + "#" + t << endl;
+cout << slow_prefix_function(s + "#" + t) << endl;
+
choose#choose life. choose a job. choose a career. choose a family. choose a fu...
+0000000123456000000012345600000000123456000100000001234560000000000012345600000000
+

Видно, что все места, где значения равны 6 (длине \(s\)) — это концы вхождений \(s\) в текст \(t\).

+

Такой алгоритм (посчитать префикс-функцию от \(s\#t\) и посмотреть, в каких позициях она равна \(|s|\)) называется алгоритмом Кнута-Морриса-Пратта.

+

Как её быстро считать

+

Рассмотрим ещё несколько примеров префикс-функций и попытаемся найти закономерности:

+
aaaaa
+01234
+
+abcdef
+000000
+
+abacabadava
+00101230101
+

Можно заметить следующую особенность: \(p_{i+1}\) максимум на единицу превосходит \(p_i\).

+

Доказательство. Если есть префикс, равный суффиксу строки \(s_{:i+1}\), длины \(p_{i+1}\), то, отбросив последний символ, можно получить правильный суффикс для строки \(s_{:i}\), длина которого будет ровно на единицу меньше.

+

Попытаемся решить задачу с помощью динамики: найдём формулу для \(p_i\) через предыдущие значения.

+

Заметим, что \(p_{i+1} = p_i + 1\) в том и только том случае, когда \(s_{p_i} =s_{i+1}\). В этом случае мы можем просто обновить \(p_{i+1}\) и пойти дальше.

+

Например, в строке \(\underbrace{aabaa}t\overbrace{aabaa}\) выделен максимальный префикс, равный суффиксу: \(p_{10} = 5\). Если следующий символ равен будет равен \(t\), то \(p_{11} = p_{10} + 1 = 6\).

+

Но что происходит, когда \(s_{p_i}\neq s_{i+1}\)? Пусть следующий символ в этом же примере равен не \(t\), а \(b\).

+
    +
  • \(\implies\) Длина префикса, равного суффиксу новой строки, будет точно меньше 5.
  • +
  • \(\implies\) Помимо того, что искомый новый супрефикс является суффиксом «aabaab», он ещё является префиксом подстроки «aabaa».
  • +
  • \(\implies\) Значит, следующий кандидат на проверку — это значение префикс-функции от «aabaa», то есть \(p_4 = 2\), которое мы уже посчитали.
  • +
  • \(\implies\) Если \(s_2 = s_{11}\) (т. е. новый символ совпадает с идущим после префикса-кандидата), то \(p_{11} = p_2 + 1 = 2 + 1 = 3\).
  • +
+

В данном случае это действительно так (нужный префикс — «aab»). Но что делать, если, в общем случае, \(p_{i+1} \neq p_{p_i+1}\)? Тогда мы проводим такое же рассуждение и получаем нового кандидата, меньшей длины — \(p_{p_{p_i}}\). Если и этот не подошел — аналогично проверяем меньшего, пока этот индекс не станет нулевым.

+
vector<int> prefix_function(string s) {
+    int n = (int) s.size();
+    vector<int> p(n, 0);
+    for (int i = 1; i < n; i++) {
+        // префикс функция точно не больше этого значения + 1
+        int cur = p[i - 1];
+        // уменьшаем cur значение, пока новый символ не сматчится
+        while (s[i] != s[cur] && cur > 0)
+            cur = p[cur - 1];
+        // здесь либо s[i] == s[cur], либо cur == 0
+        if (s[i] == s[cur])
+            p[i] = cur + 1;
+    }
+    return p;
+}
+

Асимптотика. В худшем случае этот while может работать \(O(n)\) раз за одну итерацию, но в среднем каждый while работает за \(O(1)\).

+

Префикс-функция каждый шаг возрастает максимум на единицу и после каждой итерации while уменьшается хотя бы на единицу. Значит, суммарно операций будет не более \(O(n)\).

+

Z-функция

+

Немногого более простая для понимания альтернатива префикс-функции — z-функция.

+

Z-функция от строки \(s\) определяется как массив \(z\), такой что \(z_i\) равно длине максимальной подстроки, начинающейся с \(i\)-й позиции, которая равна префиксу \(s\).

+

\[ +\underbrace{aba}c\overbrace{aba}daba \hspace{1em} (z_4 = 3) +\]

+
vector<int> slow_z_function (string s) {
+    int n = (int) s.size();
+    vector<int> z(n, 0); // z[0] считается не определенным
+    for (int i = 1; i < n; i++)
+        // если мы не вышли за границу и следующие символы совпадают
+        while (i + z[i] < n && s[z[i]] == s[i + z[i]])
+            z[i]++;
+    return z;
+}
+
aaaaa
+04321
+
+abcdef
+000000
+
+abacabadava
+00103010101
+

Z-функцию можно использовать вместо префикс-функции в алгоритме Кнута-Морриса-Пратта — только теперь нужные позиции будут начинаться c \(|s|\), а не заканчиваться. Осталось только научиться её искать за \(O(n)\).

+

Как её быстро считать

+

Будем идти слева направо и хранить z-блок — самую правую подстроку, равную префиксу, которую мы успели обнаружить. Будем обозначать его границы как \(l\) и \(r\) включительно.

+

Пусть мы сейчас хотим найти \(z_i\), а все предыдущие уже нашли. Новый \(i\)-й символ может лежать либо правее z-блока, либо внутри него:

+
    +
  • Если правее, то мы просто наивно перебором найдем \(z_i\) (максимальный отрезок, начинающийся с \(s_i\) и равный префиксу), и объявим его новым z-блоком.
  • +
  • Если \(i\)-й элемент лежит внутри z-блока, то мы можем посмотреть на значение \(z_{i-l}\) и использовать его, чтобы инициализировать \(z_i\) чем-то, возможно, отличным от нуля. Если \(z_{i-l}\) левее правой границы \(z\)-блока, то \(z_i = z_{i-l}\) — больше \(z_i\) быть не может. Если он упирается в границу, то «обрежем» его до неё и будем увеличивать на единичку.
  • +
+
vector<int> z_function (string s) {
+    int n = (int) s.size();
+    vector<int> z(n, 0);
+    int l = 0, r = 0;
+    for (int i = 1; i < n; i++) {
+        // если мы уже видели этот символ
+        if (i <= r)
+            // то мы можем попробовать его инициализировать z[i - l],
+            // но не дальше правой границы: там мы уже ничего не знаем
+            z[i] = min(r - i + 1, z[i - l]);
+        // дальше каждое успешное увеличение z[i] сдвинет z-блок на единицу
+        while (i + z[i] < n && s[z[i]] == s[i + z[i]])
+            z[i]++;
+        // проверим, правее ли мы текущего z-блока
+        if (i + z[i] - 1 > r) {
+            r = i + z[i] - 1;
+            l = i;
+        }
+    }
+    return z;
+}
+

Асимптотика. В алгоритме мы делаем столько же действий, сколько раз сдвигается правая граница z-блока — а это \(O(n)\).

+

Сравнение

+

В целом они зет- и префикс-функции очень похожи, но алгоритм Кнута-Морриса-Пратта есть во всех классических учебниках по программированию, а про z-функцию почему-то мало кто знает кроме олимпиадных программистов.

+

Про префикс-функцию важно ещё знать, что она онлайновая — достаточно считать следующий символ, и сразу можно узнать значение.

+

Упражнение 1. Дан массив префикс-функции. Исходная строка не дана. Вычислите за \(O(n)\) зет-функцию этой строки.

+

Упражнение 2. Дан массив зет-функции. Исходная строка не дана. Вычислите за \(O(n)\) префикс-функцию этой строки.

+ + diff --git a/ru/suffix-array.html b/ru/suffix-array.html new file mode 100644 index 0000000..3c7ea33 --- /dev/null +++ b/ru/suffix-array.html @@ -0,0 +1,221 @@ + + + + + + + Суффиксный массив - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Суффиксный массив

+

Суффиксный массив, автомат и дерево обобщённо называют суффиксными структурами данных. Они применяются в множестве различных задач, встречающихся как на олимпиадах, так и на практике.

+

Суффиксные структуры часто (но не всегда) взаимозаменяемые, и более того, конвертируются друг в друга за линейное время. Суффиксный массив — самый простой из них, поэтому мы с него и начнём.

+

+
Паблик с тупыми шутками про проганье» +
+

Мотивация

+

Суффиксным массивом строки \(s\) называется перестановка индексов начал её суффиксов, которая задаёт их порядок в порядке лексикографической сортировки. Иными словами, чтобы его построить, нужно выполнить сортировку всех суффиксов заданной строки.

+

+

Как это использовать. Пусть вы решили основать ООО «Ещё Один Поисковик», и чтобы получить финансирование, вы хотите сделат хоть что-то минимально работающие — просто научиться искать по ключевому слову документы, включающие его, а также позиции их вхождения (в 90-е это был бы уже довольно сильный MVP). Простыми алгоритмами (полиномиальными хэшами, z- и префикс-функцией и даже Ахо-Корасиком) это сделать быстро нельзя, а суффиксными структурами — можно.

+

В случае с суффиксным массивом можно сделать следующее: найти бинарным поиском первый суффикс в суффиксном массиве, который меньше искомого слова, а также последний, который меньше. Все суффиксы между этими двумя будут включать искомую строку как префикс.

+

Работать такой алгоритм будет за \(O(|t| \log |s|)\), и позже это можно будет оптимизировать до \(O(|t| + \log |s|)\), что является одним из самых оптимальных алгоритмов поиска.

+

Теперь научимся его строить.

+

Построение за \(O(n \log n)\)

+

Для удобства мы допишем в конец строки какой-нибудь символ, который лексикографически меньше любого другого, и будем выполнять сортировку не суффиксов, а циклических сдвигов. Строки мы, соответственно, тоже будем рассматривать циклические. Для стандартной ASCII обычно выбирают либо «$» либо «#», либо просто нулевой символ, если это c-string (в языке Си все строки и так заканчиваются нулевым символом). Легко убедиться, что сортировка таких циклических сдвигов эквивалентна сортировке суффиксов — можно просто убрать всё, что идёт после доллара.

+

Мы могли бы просто взять перестановку от \(0\) до \(n\), написать компаратор, который сравнивает соответствующие суффиксы, и скормить это в std::sort, что будет работать за \(O(n^2 \log n)\), потому что внутреннее сравнение работает за \(O(n)\). Однако, если сравнивать суффиксы хэшами, то уже тут можно получить \(O(n \log^2 n)\). Но это не самый быстрый и удобный алгоритм.

+

Наш алгоритм будет состоять из \(\lceil \log n \rceil\) этапов. На \(k\)-том этапе мы будем рассматривать циклические подстроки длины \(2^k\). На последнем этапе мы отсортируем строки длины \(\geq n\) (это легально — они ведь циклические), и мы получим нужный суффиксный массив.

+

Заметим, что, в отличие сортировки суффиксов, сортировка подстрок не всегда однозначная — они могут быть одинаковыми. Поэтому на каждой фазе алгоритм помимо перестановки \(p\) индексов циклических подстрок мы будем поддерживать для каждой циклической подстроки, начинающейся в позиции \(i\) с длиной 2^k, номер \(c_i\) класса эквивалентности, которому эта подстрока принадлежит (давать их будем таким образом, чтобы они сохраняли порядок: меньшим подстрокам соответствуют меньшие \(c_i\)). Количество классов эквивалентности будем хранить в переменной cls (изначально она равна количеству различных символов).

+

Пример: \(s = aaba\). Этапов будет 3: для подстрок длины 1, 2 и 4.

+

\[ + p_0 = (0, 1, 3, 2) \;\;\; c_0 = (0, 0, 1, 0) +\\ p_1 = (0, 3, 1, 2) \;\;\; c_1 = (0, 1, 2, 0) +\\ p_2 = (3, 0, 1, 2) \;\;\; c_2 = (1, 2, 3, 0) +\]

+

Как настоящие программисты, мы нумеруем этапы с нуля, поэтому на нулевом этапе мы отсортируем строки длины \(2^0 = 1\), то есть просто символы. Это мы сделаем сортировкой подсчётом.

+

Следующие этапы нужно проводить, используя информацию с предыдущих. Можем снова создать перестановку и применить к ней std::sort со своим компаратором.

+

Как быстро сравнить две подстроки? Мы можем использовать \(c_i\) — каждой строке длины \(2^k\) сопоставить биграмму (строку из двух символов), а именно строка \(s[i..i+2^k-1]\) с точки зрения сортировки будет эквивалентна паре \((c_i, c_{i+2^{k-1}})\). Соответственно, можно просто написать компаратор, который смотрит только на эти пары, и работает за \(O(1)\). Однако, это всё ещё будет работать за \(O(n \log^2 n)\), потому что каждый этап будет работать за \(O(n \log n\)).

+

Примечание. Зачастую этот способ построения работает быстро, поскольку имеет небольшую константу.

+

Оптимизация до \(O(n \log n)\). Сортировка слиянием оптимальна только тогда, когда мы ничего не знаем про сортируемые значения — в нашем случае это не так. Воспользуемся цифровой сортировкой — сначала отсортируем биграммы по второму символу, а потом по первому. Все вторые элементы уже упорядочены (массив \(p\) с предыдущего этапа). Теперь, чтобы упорядочить их по первому, нам надо просто от каждого элемента в \(p\) отнять \(2^{k-1}\). Таким образом, можно проводить этап за \(O(n)\).

+
// строка -- это последовательность чисел от 1 до размера алфавита
+vector<int> suffix_array (vector<int> &s) {
+    s.push_back(0);  // добавляем нулевой символ в конец строки
+    int n = (int) s.size(),
+        cnt = 0,  // вспомогательная переменная: счётчик для сортировки 
+        cls = 0;  // количество классов эквивалентности
+    vector<int> c(n), p(n);
+    
+    map< int, vector<int> > t;
+    for (int i = 0; i < n; i++)
+        t[s[i]].push_back(i);
+    
+    // «нулевой» этап
+    for (auto &x : t) {
+        for (int u : x.second)
+            c[u] = cls, p[cnt++] = u;
+        cls++;
+    }
+    
+    // пока все суффиксы не стали уникальными
+    for (int l = 1; cls < n; l++) {
+        vector< vector<int> > a(cls);  // массив для сортировки подсчётом
+        vector<int> _c(n);  // новые классы эквивалентности
+        int d = (1<<l)/2;
+        int _cls = cnt = 0;  // новое количество классов
+        
+        for (int i = 0; i < n; i++) {
+            int k = (p[i]-d+n)%n;
+            a[c[k]].push_back(k);
+        }
+        
+        for (int i = 0; i < cls; i++) {
+            for (size_t j = 0; j < a[i].size(); j++) {
+                // если суффикс начинает новый класс эквивалентности
+                if (j == 0 || c[(a[i][j]+d)%n] != c[(a[i][j-1]+d)%n])
+                    _cls++;
+                _c[a[i][j]] = _cls-1;
+                p[cnt++] = a[i][j];
+            }
+        }
+        
+        c = _c;
+        cls = _cls;
+    }
+    
+    return vector<int>(p.begin()+1, p.end());
+}
+

TODO: переписать это

+

Наибольшие общие префиксы

+

Для многих применений очень часто требуется искать длину наибольшего общего префикса (англ — largest common prefix) для различных суффиксов строки. Например, просто чтобы сравнить две подстроки, мы можем найти общий префикс соответствующих суффиксов и посмотреть на следующий символ — так же, как мы делали с хэшами.

+

Пусть мы знаем \(lcp\) для всех соседей в суффиксном массиве. Тогда для того, чтобы найти lcp(s[i:], s[j:]), надо найти \(\min_{k=c[i]}^{c[j-1]}\)lcp(s[p[k]:], s[p[k + 1]:]) (для доказательства можно представить массив \(lcp\) в виде гистограммы). Тогда есть мотивация посчитать массив \(lcp\), в котором окажутся наибольшие общие префиксы соседних суффиксов, а после считать минимумы на отрезках в этом массиве (к примеру, с помощью разреженных таблиц).

+

Осталось придумать способ быстро посчитать массив \(lcp\). Можно воспользоваться идеей из построения суффиксного массива за \(O(n \log^2 n)\) — с помощью хэшей и бинпоиска находить \(lcp\) для каждой пары соседей. Такой метод работает за \(O(n \log n)\), но является не самым удобным и популярным.

+

Алгоритм Касаи, Аримуры, Арикавы, Ли, Парка. В простонародье называется как угодно, но не исходным способом (алгоритм Касаи, алгоритм пяти корейцев, итд.). Используется для подсчета \(lcp\) за \(O(n)\). Автору алгоритм кажется чем-то похожим на z-функцию по своей идее. Пусть мы уже посчитали \(lcp[i]\) (считаем, что \(i\) — позиция суффикса в уже построенном суфмассе). Заметим, что \(lcp[c[p[i] + 1]] \ge lcp[i] - 1\).

+

Доказательство — если для \(p[i]\)-го суффикса была общая часть длины \(lcp[i]\) с \(p[i + 1]\)-ым суффиксом, то у \(p[i] + 1\)-го суффикса гарантированно будет общая часть длины \(lcp[i] - 1\) с \(p[i + 1] + 1\)-ым суффиксом. Заметим, что этот суффикс уже не обязательно будет соседом \(p[i] + 1\)-го в суффиксном массиве, но из свойства \(lcp\) двух произвольных суффиксов получим, что \(lcp[c[p[i] + 1]] \ge lcp[i] - 1\). Вычислять точное значение \(lcp\) мы будем наивным образом — увеличиваем, пока увеличивается, а вот брать стартовое значение будем, опираясь на это неравенство. В таком случае правильный порядок вычисления массива \(lcp\) — от длинных суффиксов к коротким.

+

Для того, чтобы оценить сложность алгоритма, посчитаем, сколько раз мы сделаем наивное прибавление. \(lcp[i] \le n\), а также мы сделаем не более \(n\) вычитаний единицы при переходах. Тогда суммарное число прибавлений — \(O(n)\). Следовательно, и сам алгоритм будет работать за \(O(n)\).

+

Код алгоритма:

+
    vector<int> calc_lcp(vector<int> &val, vector<int> &c, vector<int> &p) {
+        int n = val.size();
+        int current_lcp = 0;
+        vector<int> lcp(n);
+        for (int i = 0; i < n; i++) {
+            if (c[i] == n - 1)
+                continue;
+            int nxt = p[c[i] + 1];
+            while (max(i, nxt) + current_lcp < n && val[i + current_lcp] == val[nxt + current_lcp])
+                current_lcp++;
+            lcp[c[i]] = current_lcp;
+            current_lcp = max(0, current_lcp - 1);
+        }
+        return lcp;
+    }
+ + diff --git a/ru/suffix-automaton.html b/ru/suffix-automaton.html new file mode 100644 index 0000000..567ea95 --- /dev/null +++ b/ru/suffix-automaton.html @@ -0,0 +1,272 @@ + + + + + + + Суффиксный автомат - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Суффиксный автомат

+

Если вы не знаете, что такое префиксное дерево или автомат, рекомендуется сначала почитать про Ахо-Корасик.

+

Рассмотрим задачу:

+
+

Дан текст \(T\) и \(n\) строк \(s_i\), для каждой из которых нужно узнать, встречается ли она в тексте.

+
+

Есть два основных подхода к решению этой задачи. Первый: алгоритм Ахо-Корасик, который строит по набору строк автомат, распознающий эти строки в потоке текста. Второй: использование суффиксных структур, под которыми обычно подразумневают суффиксный массив, суффиксный автомат или суффикское дерево.

+

Данная статья посвящена последним двум из них.

+

Наивное решение

+

Возьмем все суффиксы \(T\) и объединим их в бор, в котором каждой подстроке \(T\) будет соответствовать ровно одна вершина. С его помощью можно за \(O(|T|^2)\) времени на построение и \(O(|s_i|)\) времени на запрос узнавать, входит ли \(s_i\) в \(T\).

+

Идея дальнейшей оптимизации заключается в убирании «лишних» состояний в этом боре.

+

От квадратиченой сложности можно избавиться двумя разными способами — ведущими, соответственно, либо к суффиксному автомату, либо к суфффиксному дереву.

+

Сжатое суффиксное дерево. Любой путь от корня в этом боре будет подстрокой \(T\), а значит, если из вершины \(v\) нет исходящий рёбер, то можно заменить путь на ребро, храня рядом с ним всю строку. Непосредственно хранить строку при этом не обязательно — она есть где-то как подстрока \(T\), а значит можно просто хранить пару чисел \([l, r]\), указывающую на её местоположение.

+

Выясняется, что такая структура данных занимает \(O(|T|)\) памяти. Чтобы это понять, нужно оценить количество «развилок» — мест, где путь разъеденяется на два.

+

Пусть мы бор строли путем добавления суффиксов. Тогда, каждая новая строка совпадала каким-то префиксом с уже имеющейся, а потом отпочковалась и породила новый путь. Получается, что вершин тут не больше, чем строк.

+

Сжатое суффиксное дерево тоже годится для нашей задачи: только теперь по рёбрам нужно шагать виртуально, просматривая один символ за другим.

+

Существуют алгоритмы, которые строят сжатое суффиксное дерево эффективно — например, алгортим Укконена. Мы их рассматривать в этой статье не будем и сразу перейдём ко второму методу.

+

Суффиксным автоматом строки \(s\) называется минимальный (с наименьшим количеством вершин) детерминированный (нет двух различных путей, соответствующих одинаковой строке) автомат, принимающий все различные подстроки \(s\) и только их.

+

Выясняется, что он тоже маленький, и если хранить переходы в std::map, то автомат можно построить за \(O(n log k)\) времени и \(O(n)\) памяти, где \(k\) — размер алфавита (обычно \(k\) считают константой, и тогда построение происходит вообще за \(O(n)\)).

+

Сперва обозначим основные свойства суффиксного автомата и его состояний, затем опишем алгоритм, а затем поймём, как и почему он работает.

+

Любое состояние \(V\) автомата будет принимать некоторый набор строк \(T\), причём если отсортировать эти строки по возрастанию длины, то для любой строки \(s_i \in T (i < |T|)\) следующая по порядку строка - это \(s_{i+1} = cs_i\) для некоторого символа \(c\). Исходя из определения суффиксного автомата, для любого префикса исходной строки \(S\) существуют несколько состояний, принимающих его суффиксы: состояние \(V_1\), принимающее суффиксы длин от 1 до некоторого \(k_1\), состояние \(V_2\), принимающее суффиксы длин от \(k_1+1\) до \(k_2\), и т.д. до состояния, принимающего самые длинные суффиксы данного префикса, включая сам префикс. Из каждого состояния \(X\), кроме корневого, существует суффиксная ссылка: пусть \(X\) принимает некоторую строку \(p\), тогда суффиксная ссылка из \(X\) ведёт в отличное от \(X\) состояние, принимающее суффикс \(p\) максимальной длины. Также из каждого состояния \(X\), кроме корневого, ведёт ещё одна ссылка, мы назовём её префиксной: пусть самая длинная принимаемая \(X\) строка - это \(p\), тогда префиксная ссылка из \(X\) указывает на состояние, принимающее префикс \(p\) на единицу меньшей длины.

+

Теперь можем перейти к алгоритму построения суффиксного автомата. Наш алгоритм будет “индуктивным”: он будет перестраивать автомат, коррекно построенный для строки \(s\), получая из него автомат для строки \(sc\).

+

Предположим, что мы построили суффиксный автомат для строки \(s\), удовлетворяющий всем требованиям, и теперь добавляем символ \(c\). Пусть \(X\) - состояние, которое принимало строку \(s\) до добавления \(c\). Сперва мы обязаны создать новое состояние \(U\), так как появилась строка \(sc\), которая ранее не входила в \(s\). Затем, мы должны добавить переход из \(X\) в \(U\) по символу \(c\) (потому что должно быть состояние, принимающее \(sc\)). Таким образом, наше новое состояние \(U\) теперь принимает некоторый набор суффиксов \(sc\). Перейдём по суффиксной ссылке из \(X\). Тогда есть два случая: 1. Перехода по \(c\) из текущего состояния нет. Тогда мы можем и должны добавить переход по символу \(c\), ведущий в \(U\): теперь \(U\) принимает ещё больший набор суффиксов. Снова перейдём по суффиксной ссылке из текущего состояния и продолжим пытаться строить переходы. 2. Переход по \(c\) из текущего состояния есть. Таким образом, для оставшихся суффиксов \(sc\) уже существовуют свои состояния, на этом моменте следует остановиться и перейти к следующей части алгоритма.

+

Опять есть два случая: первый - ни из одного состояния, в котором мы побывали, перехода по \(c\) не нашлось, и в итоге мы перешли по суффиксной ссылке из корня “в никуда”. Тогда все суффиксы \(sc\) принимаются одним состоянием \(U\), так что суффиксную ссылку из \(U\) мы проведём в корень, префиксную - в \(X\) - и завершим алгоритм.

+

Второй случай: в какой-то момент мы попали в состояние \(P\), из которого уже был переход по \(c\). Этот случай сложнее, и он также предполагает два возможных варианта. Обозначим за \(Q\) состояние, в которое ведёт переход по символу \(c\) из \(P\). Пусть самая длинная строка, принимаемая \(P\) - это \(l\). Снова рассмотрим два случая: 1. Префиксная ссылка из \(Q\) ведёт в \(P\). Это означает, что состояние \(Q\), как и все достижимые по суфф. ссылкам из \(Q\) состояния, принимает только суффиксы \(sc\): исходя из определения префиксной ссылки, \(l\) является самой длинной строкой, принимаемой \(Q\), с приписанным в конце символом \(c\), то есть суффиксом \(sc\), а все остальные строки, принимаемыми \(P\), являются суффиксами самой длинной. 2. Префиксная ссылка из \(Q\) ведёт не в \(P\). Это означает, что существует строка, принимаемая \(Q\), длина которой больше, чем \(lc\); но такая строка если и является суффиксом \(sc\), то из одного из ранее посещённых (в первой части алгоритма) состояний есть переход по \(c\) - противоречие; значит, \(Q\) помимо суффиксов \(sc\) также принимает строки, не являющиеся суффиксами \(sc\), что недопустимо. Поэтому мы должны сделать две версии \(Q\): одна принимает строки старого \(Q\), являющиеся суффиксами \(sc\) - назовём их основным набором, а вторая - все остальные строки \(Q\) - назовём их дополнительным набором. Для этого клонируем состояние \(Q\): создадим новое состояние \(Q'\), которое будет отвечать за строки основного набора, скопируем в него все переходы, которые были в \(Q\) и скопируем суффиксную ссылку из \(Q\). Префиксную ссылку из \(Q'\) проведём в \(P\). Состояние \(Q\) теперь будет отвечать только за строки дополнительного набора, поэтому мы должны перенаправить его суффссылку в \(Q'\). Суффиксная ссылка из \(U\) также должна указывать на \(Q'\), т.к. все суффиксы \(sc\) большей длины уже принимаются \(U\). Осталось только перенаправить некоторые переходы, ведущие в \(Q\), на состояние \(Q'\): заметим, что это будут в точности все переходы по \(c\) в \(Q'\) из всех состояний, достижимых по суффиксным ссылкам из \(P\), включая \(P\).

+

Для строки длины 0 суффиксный автомат - это единственное, корневое состояние. Таким образом, мы получили корректный алгоритм построения суффиксного автомата.

+

Связь между суффиксным автоматом и суффиксным деревом

+

Рассмотрим ребро в суффиксном дереве строки \(T\), а точнее все подстроки \(T\), которым соответствует или “внутренняя” вершина ребра, или вершина, в которой ребро заканчивается. Для любой строки \(x\) и любой пары строк \(a\), \(b\) из рассматриваемого множества строк, строки \(xa\) и \(xb\) являются или не являются префиксами строки \(s\) одновременно.

+

Пусть \(|a| < |b|\). Тогда \(a\) является предком \(b\) в боре, то есть, её префиксом, значит, её множество вхождений точно содержит множество вхождений строки \(b\). Допустим, существует позиция \(|x|\), в которой есть вхождение строки \(a\), но не строки \(b\).

+

Рассмотрим строку \(a'\), которая является максимальным префиксом строки \(b\), который можно встретить в той позиции. Если \(a'\) не упирается в конец строки, то её можно продолжить, как минимум, двумя различными символами чтобы она осталась подстрокой \(T\). Значит, соответствующая ей вершина в дереве имеет степень больше двух и должна разбивать ребро, на котором находится, что конфликтует с предположением о том, что \(a\) и \(b\) взяты с одного ребра.

+

Если же \(a'\) нельзя продолжить, то она всё ещё должна разбивать ребро, т.к. является суффиксом строки и её вершина не будет удалена при сжатии рёбер.

+

Аналогичным образом можно показать, что для любой строки \(a\) все строки \(b\) с таким же множеством строк \(x\) находятся на соответствующем строке \(a\) ребре, то есть, есть биекция между рёбрами суффиксного дерева и множествами левых позиций вхождений строк в \(T\). Отсюда:

+

Для любого состояния \(q\) суффиксного автомата строки \(T\) найдётся вершина \(q'\) суффиксного дерева развернутой строки \(T\) такая, что множество строк, принимаемых состоянием \(q\), совпадает с развёрнутым множеством строк, таких что соответствующая им вершина в дереве лежит на ребре, ведущем в \(q'\) (включая строку, соответствующую \(q'\)).

+

Это целиком описывает состояния автомата и позволяет разработать алгоритм его построения. Код суффиксного автомата можно найти в конце статьи.

+

Время работы

+

Достаточно очевидно, что вершин в автомате (и, соответственно, префиксных и суффиксных ссылок) не более \(2n\) - действительно, для каждого символа добавляется ровно одно состояние и клонируется максимум одно. Переходов, очевидно, не более \(nk\), однако можем дать более строгую оценку сверху в \(3n\). Сплошных переходов - т.е. ведущих в состояние \(X\) из состояния, достижимого по префиксной ссылке \(X\) - не более \(2n\). Рассмотрим переходы, не являющиеся сплошными. Состояния, принимающие суффиксы строки, назовём терминальными. Каждому из оставшихся переходов поставим в соответствие строку \(t_{1}ct_{2}\), где \(c\) - символ на ребре, \(t_1\) - самая длинная строка, принимаемая “истоком” перехода, а \(t_2\) - самая длинная строка, ведущая из “стока” перехода до некоторого терминального состояния. Заметим, что \(t_1\) и \(t_2\) образованы только сплошными переходами (т.к. если от одного состояния до другого есть некоторый путь, то есть не менее короткий путь, проходящий только по сплошным переходам). Также заметим, что во-первых, полученная строка будет являться некоторым суффиксом исходной, а во-вторых, так как все переходы кроме одного в таком пути сплошные, все такие строки будут различными. Из этих двух фактов следует, что количество несплошных переходов не более \(n\), так как у строки ровно \(n\) суффиксов. Таким образом, переходов в автомате не более \(3n\), а ссылок не более \(4n\), то есть автомат линеен по памяти. По сути, доказав асимптотику по памяти, мы почти доказали время работы, и неочевидным остаётся только то, за сколько работает перенаправление переходов после клонирования. Заметим, что если мы добавляли символ \(c\) и клонировали состояние \(Q\) и получили \(Q'\), то не может быть такого, что через какое-то время мы добавим символ \(c\) и нам пришлось клонировать \(Q'\) и, соответственно, перенаправлять переходы по \(c\), ведущие в \(Q'\). Доказательство: из \(P\) обязательно есть переход по символу \(c\) в \(Q'\), причём этот переход сплошной, и такие переходы есть только из \(P\) и некоторых достижимых из него по суффиксным ссылкам состояний. Также заметим, что для некоторого \(k \geq 0\) и только для него из всех состояний, достижимых из \(P\) за \(k\) переходов по суффиксным ссылкам, будет существовать переход в \(Q'\). Таким образом, ни один клон не будет клонирован повторно, а все остальные вершины будут клонированы максимум один раз, то есть каждый переход будет перенаправлен максимум один раз, а переходов линейное число, поэтому суммарно будет совершено \(O(n)\) действий для перенаправления. Итак, мы доказали, что суффиксный автомат линеен по памяти и требует \(O(n log k)\) времени на построение. TODO доказательство неверное

+

Применение в решении задач

+
    +
  1. Число различных подстрок. Дана строка \(s\), необходимо посчитать

    +

    количество её различных подстрок. В каждом состоянии встречаются

    +

    строки длины от \(len(link(q)) + 1\) до \(len(q)\). Всего

    +

    \(len(q) - len(link(q))\) строк. Просуммировав эту величину по всем

    +

    состояниям, получим ответ.

    +

    Упражнение: решите эту же задачу за \(O(n)\), учитывая, что к строке

    +

    \(s\) символы дописываются по одному и после каждого нового символа

    +

    необходимо сказать текущее число различных подстрок строки \(s\).

    +

    Упражнение*: Возьмём задачу из предыдущего упражнения и скажем,

    +

    что теперь мы можем не только дописывать символы в конец, но и

    +

    удалять их с начала строки. Вам требуется отвечать на те же

    +

    запросы. Время работы решения всё ещё должно линейно зависеть от

    +

    размера входа. Подсказка: иногда алгоритм Укконена также бывает

    +

    полезен.

  2. +
  3. Поиск подстрок в тексте. Пропустив строку через автомат мы

    +

    сможем сказать, входит ли она в текст. Допустим, мы хотим узнать

    +

    какую-то информацию о её вхождениях. Например, нам нужно выдать

    +

    любое конкретное вхождение. Как мы уже знаем, каждому вхождению

    +

    соответствует строка \(x\) такая что \(ax\) — суффикс \(s\). Или, проще

    +

    говоря, путь из состояния \(q\) в какое-то финальное состояние.

    +

    Динамикой по автомату как ациклическому ориентированному графу мы

    +

    можем найти длину какого-нибудь такого пути (например, для

    +

    определённости минимального или максимального). Отметим, что

    +

    аналогичной динамикой считаются многие другие полезные значения,

    +

    например, количество строк в правом контексте состояния (или, что то

    +

    же самое, количество вхождений строк из состояния в \(s\)).

    +

    Альтернативным решением будет обратиться к дереву суффиксных ссылок,

    +

    которое, как мы помним, является суффиксным деревом для \(s^T\). Как

    +

    мы упоминали в самом начале, любая подстрока строки \(s\) является

    +

    префиксом одного из суффиксов исходной строки. Таким образом, если

    +

    мы запишем в каждую “суффиксную” вершину индекс соответствующего ей

    +

    суффикса, то все позиции вхождений строки \(t\) в \(s\) можно будет

    +

    обнаружить в поддереве вершины, которая соответствует строке \(t\).

    +

    Значит, в частности, динамикой можно будет найти самое первое или

    +

    самое последнее вхождение.

    +

    Более того, учитывая, что в последнем случае мы работали с деревом,

    +

    мы можем обойти его таким образом, чтобы на каждом шаге иметь в

    +

    вершине множество возможных позиций, в которых встречаются строки из

    +

    соответствующего состояния. Для этого нужно применить идею быстрого

    +

    слияния множеств, когда мы всегда добавляем элементы из меньшего

    +

    множества в большее, а не наоборот. Тогда такой обход потребует

    +

    \(O(n \log n)\) операций добавления в множество, т.к. каждый раз когда

    +

    мы переносим между множествами элемент \(k\), размер нового множества

    +

    будет как минимум, в два раза больше старого, в котором он хранился.

    +

    Упражнение*: дана строка \(s\). Найти число строк \(t\) таких, что

    +

    они имеют хотя бы \(3\) непересекающихся вхождения в строку \(s\).

  4. +
  5. Наибольшая общая подстрока. Нам дано \(k\) строк

    +

    \(s_1, s_2, \dots, s_k\). Нужно найти наибольшую строку \(t\), которая

    +

    встречается в каждой из строк \(s_i\). Одно из возможных решений —

    +

    построить автомат для строки \(s_1 t_1 s_2 t_2 \dots s_n t_n\), где

    +

    \(t_i\) — уникальный для каждой строки символ-разделитель. Теперь мы

    +

    можем завести динамику \(dp[q][i]\), в которой хранить \(1\), если из

    +

    состояния \(q\) можно добраться до состояния, из которого есть переход

    +

    по \(t_i\), не проходя при этом через другие символы-разделители. Это

    +

    будет равносильно тому, что строки из \(q\) входят в \(s_i\). Как и в

    +

    прошлый раз, динамику можно пересчитывать по топологической

    +

    сортировке автомата как ориентированного ациклического графа. Итого

    +

    решение будет работать за \(O(k \cdot \sum |s_i|)\).

    +

    Упражнение*: решить указанную задачу за \(O(\sum |s_i|)\).

    +

    Модификация суффиксного автомата

    +

    Эта модификация была придумана и рассказана Филиппом Грибовым, в частности, в рамках программы третьего курса кружка по алгоритмам Tinkoff Generation (2018-2019). Суть заключается в следующем: заметим, что при построении и доказательстве асимптотики на самом деле мы никак не использовали, что строится автомат только для одной строки. То есть после того, как мы построили суффиксный автомат по некоторой строке, мы можем сказать, что вершина, принимающая всю строку (last) - это корень автомата, и спокойно добавить в автомат вторую строку, и после этого автомат будет принимать все различные подстроки как первой, так и второй строк. Очевидно, можно так же построить автомат и для трёх, четырёх и т.д. строк, что даёт нам просто решать такие сложные задачи, как следующая:

  6. +
+
+

Дан словарь строк, изначально пустой. Приходят запросы трёх типов: добавить строку в словарь, если её нет, удалить строку из словаря, если она есть, и найти суммарное количество вхождений строк словаря в заданный текст. Задача в онлайн.

+
+

Сперва введём вспомогательное понятие. Подавтоматом состояния \(U\) суффиксного автомата называется такой его подграф, что в нём есть пути по всем подстрокам самой длинной принимаемой \(U\) строки и только по ним. Обойти подавтомат можно простой рекурсивной функцией типа DFS: по сути, для этого нам нужно пройтись по всем суффиксам всех префиксов (или наоборот) данной строки, то есть если мы находимся в состоянии \(X\), то мы либо ничего не делаем, если \(X\) уже была посещена этим обходом, либо запускаем функцию сначала от вершины, достижимой по префиксной ссылке, а затем по суффиксной. Теперь можно перейти к решению задачи. Для обработки запроса первого типа просто добавим строку в суффиксный автомат и пометим последнюю вершину как терминальную, т.е. отвечающую за одну из строк. Для обработки запроса второго типа найдём вершину, отвечающую за данную строку, и если она есть, просто снимем терминальную пометку. Для обработки запроса третьего типа добавим заданную строку в суффиксный автомат и пройдёмся по её подавтомату, при этом запоминая для каждой вершины количество вершин с терминальной меткой в её подавтомате. Тогда если мы попали в уже посещённую во время обхода подавтомата вершину, то просто прибавим уже посчитанный для неё ответ. Осталось только понять асимптотику, ведь если в автомате есть несколько строк, то может случиться так, что у некоторых размер подавтомата будет порядка квадрата от их длины. Пусть суммарная длина всех строк запросов это \(m\). Давайте разделим строки на тяжёлые (длина больше \(\sqrt{m}\)) и лёгкие (длина не больше \(\sqrt{m}\)). тяжёлых строк не больше \(\sqrt{m}\), и при этом размер подавтомата любой строки не больше \(O(m)\), поэтому суммарное время обработки тяжёлых строк не больше $O(m ). Лёгких строк может быть много, и размер подавтомата строки длиной \(l\) может быть \(O(l^2)\), что не больше, чем \(O(\sqrt{m})\); суммарная длина всех строк не больше \(m\), а значит суммарное время на обработку всех лёгких строк также порядка \(O(m \sqrt{m})\). Таким образом, мы получили довольно простое решение такой задачи за \(O(m \sqrt{m})\) с небольшой константой, которое заходит даже для \(m\) порядка \(10^6\). И таким образом можно строить не только суффиксный автомат по нескольким строкам: можно “подвешивать” одну строку к другой и т.д.

+

Реализация

+
struct node {
+  int link = -1, p = -1, len = 0; // Суффиксная, префиксная ссылки и максимальная длина принимаемой состоянием строки, соответственно
+  char pc = '#'; // Символы, переходы по которым ведут в состояние
+  map<char, int> next; // Переходы по символам
+
+  node() {}
+
+  node(int p, int len, char pc) : p(p), len(len), pc(pc) {}
+};
+
+node v[2 * MAXN]; // MAXN - максимально возможная суммарная длина строк
+int mx = 0; // номер последнего добавленного состояния
+
+int add_char(int ls, char c) { // ls - состояние, к которому мы "подвешиваем" следующий символ, c - сам символ
+  if (v[ls].next.find(c) != v[ls].end()) return v[ls].next[c]; // Если переход по символу c из ls уже был, то ничего добавлять не надо
+  v[++mx] = node(ls, v[ls].len + 1, c);
+  int p = ls;
+  for (; p != -1 && v[p].next.find(c) == v[p].end(); p = v[p].link)
+      v[p].next[c] = mx;
+  if (p == -1) {
+      v[mx].link = 0; // Если перехода по c не нашлось
+      return mx;
+  }
+  int q = v[p].next[c];
+  if (v[q].p == p) {
+      v[mx].link = q;
+      return mx;
+  }
+  v[++mx] = node(p, v[p].len + 1, c); // Клонирование
+  v[mx].next = v[q].next; v[mx].link = v[q].link;
+  v[q].link = v[mx - 1].link = mx;
+  for (; p != -1 && v[p].next[c] == q; p = v[p].link)
+      v[p].next[c] = mx;
+  return mx - 1;
+}
+
+int used[MAXN * 2]; // Массив времён посещения обходом подавтомата
+
+void subautomaton(int x, int tm) { // Обход подавтомата
+  if (x == -1 || used[x] == tm) return;
+
+  used[x] = tm;
+  //....
+
+  subautomaton(v[x].p, tm);
+  subautomaton(v[x].link, tm);
+}
+ + diff --git a/ru/treap.html b/ru/treap.html new file mode 100644 index 0000000..65ad307 --- /dev/null +++ b/ru/treap.html @@ -0,0 +1,410 @@ + + + + + + + Декартово дерево - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Декартово дерево

+

Рене Декарт (фр. René Descartes) — великий французский математик и философ XVII века.

+

Рене Декарт не является создателем декартова дерева, но он является создателем декартовой системы координат, которую мы все знаем и любим.

+

Декартово дерево же определяется и строится так:

+
    +
  • Нанесём на плоскость набор из \(n\) точек. Их \(x\) зачем-то назовем ключом, а \(y\) приоритетом.
  • +
  • Выберем самую верхнюю точку (с наибольшим \(y\), а если таких несколько — любую) и назовём её корнем.
  • +
  • От всех вершин, лежащих слева (с меньшим \(x\)) от корня, рекурсивно запустим этот же процесс. Если слева была хоть одна вершина, то присоединим корень левой части в качестве левого сына текущего корня.
  • +
  • Аналогично, запустимся от правой части и добавим корню правого сына.
  • +
+

Заметим, что если все \(y\) и \(x\) различны, то дерево строится однозначно.

+

Если нарисовать получившуюся структуру на плоскости, то получится действительно дерево — по традиции, корнем вверх:

+

+

Таким образом, декартово дерево — это одновременно бинарное дерево по \(x\) и куча по \(y\). Поэтому ему придумали много альтернативных названий:

+
    +
  • Дерамида (дерево + пирамида)

  • +
  • ПиВо (пирамида + дерево)

  • +
  • КуРево (куча + дерево)

  • +
  • Treap (tree + heap)

  • +
+

Бинарные деревья

+

С небольшими модификациями, декартово дерево умеет всё то же, что и любое бинарное дерево поиска, например:

+
    +
  • Добавить число \(x\) в множество.

  • +
  • Определить, есть ли в множестве число \(x\).

  • +
  • Найти первое число, не меньшее \(x\) (lower_bound).

  • +
  • Найти количество чисел в промежутке \([l, r]\).

  • +
+

При этом все операции — за \(O(\log n)\).

+

На самом деле, бинарных деревьев очень много. В большинстве из них время выполнения операций пропорционально высоте дерева, поэтому в них придумываются разные инварианты, позволяющие эту высоту минимизировать до \(O(\log n)\).

+

Приоритеты и асимптотика

+

В декартовом дереве логарифмическая высота дерева гарантируется не инвариантами и эвристиками, а законами теории вероятностей: оказывается, что если все приоритеты (\(y\)) выбирать случайно, то средняя глубина вершины будет логарифмической. Поэтому ДД ещё называют рандомизированным деревом поиска.

+

Теорема. Ожидание глубины вершины в декартовом дереве равно \(O(\log n)\).

+

Доказательство. Введем функцию \(a(x, y)\) равную единице, если \(x\) является предком \(y\), и нулем в противном случае. Такие функции называются индикаторами.

+

Глубина вершины равна количеству её предков — прим. К. О. Таким образом, она равна

+

\[ +d_i = \sum_{j=1}^n a(j, i) +\]

+

Её матожидание равно

+

\[ +E[d_i] = E[\sum_{j \neq i} a(j, i)] = \sum_{j \neq i} E[a(j, i)] = \sum_{j \neq i} E[a(j, i)] = \sum_{j \neq i} p(j, i) +\]

+

где \(p(x, y)\) это вероятность, что \(a(x, y) = 1\). Здесь мы воспользовались важным свойством линейности: матожидание суммы чего угодно равна сумме матожиданий этого чего угодно.

+

Теперь осталось посчитать эти вероятности и сложить. Но сначала нам понадобится вспомогательное утверждение.

+

Лемма. Вершина \(x\) является предком \(y\), если у неё приоритет больше, чем у всех вершин из полуинтервала \((x, y]\) (без ограничения общности, будем считать, что \(x < y\)).

+

Необходимость. Если это не так, то где-то между \(x\) и \(y\) есть вершина с большим приоритетом, чем \(x\). Она не может быть потомком \(x\), а значит \(x\) и \(y\) будут разделены.

+

Достаточность. Если справа будет какая-то вершина с большим приоритетом, то её левым сыном будет какая-то вершина, которая будет являться предком \(x\). Таким образом, всё, что справа от \(y\), ни на что влиять не будет.

+

У всех вершин на любом отрезке одинаковая вероятность иметь наибольший приоритет. Объединяя этот факт с результатом леммы, мы можем получить выражение для искомых вероятностей:

+

\[ +p(x, y) = \frac{1}{y-x+1} +\]

+

Теперь, чтобы найти матожидание, эти вероятности надо просуммировать:

+

\[ +E[d_i] = \sum_{j \neq i} p(j, i) = \sum_{j \neq i} \frac{1}{|i-j|+1} \leq \sum_{i=1}^n \frac{1}{n} = O(\log n) +\]

+

Перед последним переходом мы получили сумму гармонического ряда.

+

Примечательно, что ожидаемая глубина вершин зависит от их позиции: вершина из середины должна быть примерно в два раза глубже, чем крайняя.

+

Упражнение. Выведите из этого доказательства асимптотику quicksort.

+

Реализация

+

Декартово дерево удобно писать на указателях и структурах.

+

Создадим структуру Node, в которой будем хранить ключ и приоритет, а также указатели на левого и правого сына. Указателя на корень дерева достаточно для идентификации всего дерева. Поэтому, когда мы будем говорить «функция принимает два дерева» на самом деле будут иметься в виду указатели на их корни. К нулевому указателю же мы будем относиться, как к «пустому» дереву.

+
struct Node {
+    int key, prior;
+    Node *l = 0, *r = 0;
+    Node (int _key) { key = _key, prior = rand(); }
+};
+

Объявим две вспомогательные функции, изменяющие структуру деревьев: одна будет разделять деревья, а другая объединять. Как мы увидим, через них можно легко выразить почти все функции, которые нам потом понадобятся.

+

Merge

+

Принимает два дерева (два корня, \(L\) и \(R\)), про которые известно, что в левом все вершины имеют меньший ключ, чем все в правом. Их нужно объединить в одно дерево так, чтобы ничего не сломалось: по ключам это всё ещё дерево, а по приоритетами — куча.

+

Сначала выберем, какая вершина будет корнем. Здесь всего два кандидата — левый корень \(L\) или правый \(R\) — просто возьмем тот, у кого приоритет больше.

+

Пусть, для однозначности, это был левый корень. Тогда левый сын корня итогового дерева должен быть левым сыном \(L\). С правым сыном сложнее: возможно, его нужно смерджить с \(R\). Поэтому рекурсивно сделаем merge(l->r, r) и запишем результат в качестве правого сына.

+
Node* merge (Node *l, Node *r) {
+    if (!l) return r;
+    if (!r) return l;
+    if (l->prior > r->prior) {
+        l->r = merge(l->r, r);
+        return l;
+    }
+    else {
+        r->l = merge(l, r->l);
+        return r;
+    }
+}
+

Split

+

Принимает дерево и ключ \(x\), по которому его нужно разделить на два: \(L\) должно иметь все ключи не больше \(x\), а \(R\) должно иметь все ключи больше \(x\).

+

В этой функции мы сначала решим, в каком из деревьев должен быть корень, а потом рекурсивно разделим его правую или левую половину и присоединим, куда надо:

+
typedef pair<Node*, Node*> Pair;
+
+Pair split (Node *p, int x) {
+    if (!p) return {0, 0};
+    if (p->key <= x) {
+        Pair q = split(p->r, x);
+        p->r = q.first;
+        return {p, q.second};
+    }
+    else {
+        Pair q = split(p->l, x);
+        p->l = q.second;
+        return {q.first, p};
+    }
+}
+

Пример: вставка

+

merge и split сами по себе не очень полезные, но помогут написать все остальное.

+

Вот так, например, будет выглядеть код, добавляющий \(x\) в дерево.

+
Node *root = 0;
+
+void insert (int x) {
+    Pair q = split(root, x);
+    Node *t = new Node(x);
+    root = merge(q.first, merge(t, q.second));
+}
+

Пример: модификация для суммы на отрезке

+

Иногда нам нужно написать какие-то модификации для более продвинутых операций.

+

Например, нам может быть интересно иногда считать сумму чисел на отрезке. Для этого в вершине нужно хранить также своё число и сумму на своем «отрезке».

+
struct Node {
+    int val, sum;
+    // ...
+};
+

При merge и split надо будет поддерживать эту сумму актуальной.

+

Вместо того, чтобы модифицировать и merge, и split под наши хотелки, напишем вспомогательные функцию upd, которую будем вызывать при обновлении детей вершины.

+
void sum (Node* v) { return v ? v->sum : 0; }
+// обращаться по пустому указателю нельзя -- выдаст ошибку
+
+void upd (Node* v) { v->sum = sum(v->l) + sum(v->r) + v->val; }
+

В merge и split теперь можно просто вызывать upd перед тем, как вернуть вершину, и тогда ничего не сломается:

+
Node* merge (Node *l, Node *r) {
+    // ...
+    if (...) {
+        l->r = merge(l->r, r);
+        upd(l);
+        return l;
+    }
+    else {
+        // ...
+    }
+}
+
typedef pair<Node*, Node*> Pair;
+
+Pair split (Node *p, int x) {
+    // ...
+    if (...) {
+        // ...
+        upd(p);
+        return {p, q.second};
+    }
+    else {
+        // ...
+    }
+}
+

Тогда при запросе суммы нужно просто вырезать нужный отрезок и запросить эту сумму:

+
int sum (int l, int r) {
+    Pair rq = split(root, r);
+    Pair lq = split(rq.first, l);
+    int res = sum(lq.second);
+    root = merge(lq.first, merge(lq.second, rq.second));
+    return res;
+}
+

Неявный ключ

+

Обычное декартово дерево — это структура для множеств, каждый элемент которых имеет какой-то ключ. Эти ключи задают на этом множестве какой-то порядок, и все запросы к ДД обычно как-то привязаны к этому порядку.

+

Но что, если у нас есть запросы, которые этот порядок как-то нетривиально меняют? Например, если у нас есть массив, в котором нужно уметь выводить сумму на произвольном отрезке и «переворачивать» произвольный отрезок. Если бы не было второй операции, мы бы просто использовали индекс элемента в качестве ключа, но с операцией переворота нет способа их быстро поддерживать актуальными.

+

Решение такое: выкинем ключи, а вместо них будем поддерживать информацию, которая поможет неявно восстановить ключ, когда он нам будет нужен. А именно, будем хранить вместе с каждой вершиной размер её поддерева:

+
struct Node {
+    int key, prior, size = 1;
+    //              ^ размер поддерева
+    Node *l = 0, *r = 0;
+    Node (int _key) { key = _key, prior = rand(); }
+};
+

Размеры поддеревьев будем поддерживать по аналогии с суммой — напишем вспомогательную функцию, которую будем вызывать после каждого структурного изменения вершины.

+
int size (Node *v) { return v ? v->size : 0; }
+
+void upd (Node *v) { v->size = 1 + size(v->l) + size(v->r); }
+

merge не меняется, а вот в split нужно использовать позицию корня вместо его ключа.

+

Про split теперь удобнее думать как “вырежи первые k элементов”.

+
typedef pair<Node*, Node*> Pair;
+
+Pair split (Node *p, int k) {
+    if (!p) return {0, 0};
+    if (size(p->l) + 1 <= k) {
+        Pair q = split(p->r, k - size(p->l) - 1);
+        //                   ^ правый сын не знает количество вершин слева от него
+        p->r = q.first;
+        upd(p);
+        return {p, q.second};
+    }
+    else {
+        Pair q = split(p->l, k);
+        p->l = q.second;
+        upd(p);
+        return {q.first, p};
+    }
+}
+

Всё. Теперь у нас есть клёвая гибкая структура, которую можно резать как угодно.

+

Пример: ctrl+x, ctrl+v

+
Node* ctrlx (int l, int r) {
+    Pair q1 = split(root, r);
+    Pair q2 = split(q1.first, l);
+    root = merge(q2.first, q1.second);
+    return q2.second;
+}
+
void ctrlv (Node *v, int k) {
+    Pair q = split(root, k);
+    root = merge(q.first, merge(v, q.second));
+}
+

Пример: переворот

+

Нужно за \(O(\log n)\) обрабатывать запросы переворота произвольных подстрок: значение \(a_l\) поменять с \(a_r\), \(a_{l+1}\) поменять с \(a_{r-1}\) и т. д.

+

Будем хранить в каждой вершине флаг, который будет означать, что её подотрезок перевернут:

+
struct Node {
+    bool rev;
+    // ...
+};
+

Поступим по аналогии с ДО — когда мы когда-либо встретим такую вершину, мы поменяем ссылки на её детей, а им самим передадим эту метку:

+
void push (node *v) {
+    if (v->rev) {
+        swap(v->l, v->r);
+        if (v->l)
+            v->l->rev ^= 1;
+        if (v->r)
+            v->r->rev ^= 1;
+    }
+    v->rev = 0;
+}
+

Аналогично, эту функцию будем вызывать в начале merge и split.

+

Саму функцию reverse реализуем так: вырезать нужный отрезок, поменять флаг.

+
void reverse (int l, int r) {
+    Pair q1 = split(root, r);
+    Pair q2 = split(q1.first, l)
+    q2.second->rev ^= 1;
+    root = merge(q2.first, merge(q2.second, q1.second));
+}
+

Небольшой рефакторинг

+

Реализация большинства операций всегда примерно одинаковая — вырезаем отрезок с \(l\) по \(r\), что-то с ним делаем и склеиваем обратно.

+

Дублирующийся код — это плохо. Давайте используем всю мощь плюсов и определим функцию, которая принимает другую функцию, которая уже делает полезные вещи на нужном отрезке.

+
auto apply (int l, int r, auto f) {
+    Pair q1 = split(root, r);
+    Pair q2 = split(q1.first, l)
+    q2.second = f(q2.second);
+    root = merge(q2.first, merge(q2.second, q1.second));
+}
+
+void reverse (Node *v) {
+    if (v)
+        v->rev ^= 1;
+}
+

Применять её нужно так:

+
apply(l, r, reverse);
+

Это работает в плюсах, начиная с g++14.

+

Для простых операций можно даже написать лямбду:

+
apply(l, r, [](Node *v){
+    if (v)
+        v->rev ^= 1;
+});
+

Персистентность

+

Так же, как и с ДО, персистентной версией ДД можно решать очень интересные задачи.

+
+

Дана строка. Требуется выполнять в ней копирования, удаления и вставки в произвольные позиции.

+
+

Построим персистентное ДД. Тогда просто вызвав два split-а, мы можем получить копию любой подстроки (указатель вершину), которую потом можно вставлять куда угодно, при этом оригинальную подстроку мы не изменим.

+
+

Дана строка. Требуется выполнять в ней копирования, удаления, вставки в произвольные позиции и сравнение произвольных подстрок.

+
+

Можно в вершинах хранить полиномиальный хэш соответствующей подстроки. Тогда мы можем проверять равенство подстрок сравниванием хэшей вершин, полученных теми же двумя сплитами.

+

Чтобы полноценно сравнивать стоки лексикографически, можно применить бинарный поиск: перебрать длину совпадающего суффикса, и, когда она найдется, посмотреть на следующий символ.

+

Реализация почти такая же, как и для всех персистентных структур на ссылках — перед тем, как идти в какую-то вершину, нужно создать её копию и идти в неё. Создадим для этого вспомогательную функцию copy:

+
Node* copy (Node *v) { return new Node(*v); }
+

Во всех методах мы будем начинать с копирования всех упоминаемых в ней вершин. Например, персистентный split начнётся так:

+
Pair split (Node *p, int x) {
+    p = copy(p);
+    // ...
+}
+

В ДО просто создавать копии вершин было достаточно. Этого обычно достаточно для всех детерминированных структур данных, но в ДД всё сложнее. Оказывается существует тест, который «валит» приоритеты: можно раскопировать много версий одной вершины, а все остальные — удалить. Тогда у всех вершин будет один и тот же приоритет, и дерево превратится в «бамбук», в котором все операции будут работать за линию.

+

У этой проблемы есть очень элегантное решение — избавиться от приоритетов, и делать теперь следующее переподвешивание: если размер левого дерева равен \(L\), а размер правого \(R\), то будем подвешивать за левое с вероятностью \(\frac{L}{L+R}\), иначе за правое.

+

Теорема. Такое переподвешивание эквивалентно приоритетам.

+

Доказательство. Покажем, что все вершины всё так же имеют равную вероятность быть корнем. Докажем по индукции:

+
    +
  • Лист имеет вероятность 1 быть корнем себя (база индукции)
  • +
  • Переход индукции — операция merge. Любая вершина левого дерева была корнем с вероятностью \(\frac{1}{L}\) (по предположению индукции), а после слияния она будет корнем всего дерева с вероятностью \(\frac{1}{L} \cdot \frac{L}{L+R} = \frac{1}{L+R}\). С вершинами правого дерева аналогично.
  • +
+

Получается, что при таком переподвешивании всё так же каждая вершина любого поддерева равновероятно могла быть его корнем, а на этом основывалось наше доказательство асимптотики ДД.

+
Node* merge (Node *l, Node *r) {
+    if (!l) return r;
+    if (!r) return l;
+    l = copy(l), r = copy(r);
+    if (rand() % (size(l) + size(r)) < size(l)) {
+        // ...
+    }
+    else {
+        // ...
+    }
+}
+

Философский вопрос: можно ли декартово дерево называть декартовым, если из него удалить и \(x\), и \(y\)?

+ + diff --git a/ru/trie.html b/ru/trie.html new file mode 100644 index 0000000..ab33c7e --- /dev/null +++ b/ru/trie.html @@ -0,0 +1,164 @@ + + + + + + + Бор - Алгоритмика + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Бор

+

Префиксное дерево или бор — это структура данных для компактного хранения строк.

+

Он устроен в виде дерева, где на ребрах между вершинами написана символы, а некоторые вершины помечены терминальными. Бор хранит ровно те строки, которые получаются, если выписать подряд все буквы на путях от корня до терминальных вершин.

+
+trie +
+

Бор можно удобно использовать для разных задач: * Хранение строк — занимает гораздо меньше места, чем массив или сет строк. * Сортировка строк — по бору можно пройтись dfs-ом и вывести все строки в лексикографическом порядке.

+
    +
  • Сет строк — как мы увидим, в нем легко добавлять и удалять слова, а также проверять, если ли слово в бореи.
  • +
+

Реализация

+

Бор состоит из ссылающихся друг на друга вершин. В вершине обычно хранится такая информация: * терминальная ли вершина, * ссылки на детей, * возможно, какая-нибудь дополнительная зависящая от задачи информация о слове, если вершина терминальная. Например, количество таких слов — так можно реализовать мультисет.

+
const int k = 26;
+
+struct Vertex {
+    Vertex* to[k] = {0};
+    bool terminal = 0;
+};
+
+Vertex *root = new Vertex();
+

Чтобы добавить слово в бор, нужно пройти от корня по символам слова. Если перехода по для очередного символа нет — создать его, иначе пройти по уже существующему. Последнюю вершину нужно пометить терминальной.

+
void add_string (string &s) {
+    v = root;
+    for (char c : s) {
+        c -= 'a';
+        if (!v->to[c]) 
+            v->to[c] = new Vertex();
+        v = v->to[c];
+    }
+    v->terminal = true;
+}
+

Чтобы проверить, есть ли слово в боре, нужно пройти от корня по символам слова. Если в конце оказались в терминальной вершине — то есть. Если оказались в нетерминальной или когда-нибудь потребовалось пройтись по несуществущей ссылке — то есть.

+

Удалить слово можно лениво, просто дойдя до него и убрав флаг терминальности.

+

Как хранить ссылки

+

Хранить ссылки на детей не обязательно в массиве. Возможно, наш алфавит большой — у нас тогда просто не хватит памяти инициализировать столько массивов, большинство из которых будут пустыми.

+

В этом случае можно придумать какой-нибудь другой способ хранить отображение из символа в ссылку на вершину, например бинарном дереве (map) или хэш-таблице (unordered_map). Они будут работать дольше (но лишь в константу раз), но зато потребление памяти в них будет линейным. У map-а есть ещё одно преимущество, что он хранит ссылки уже отсортированными по символам — так можно отсортировать строки, например.

+

Учитывайте, что писать бор можно по-разному, особенно когда решаете задачи с жестокими ограничениями.

+

Суффиксные ссылки

+
+

Пусть заданы \(n\) плохих слов и большой текст \(t\). Нужно найти суммарное количество их вхождений в этот текст.

+
+

Эту и много других задач помогают решать суффиксные ссылки. Суффиксная ссылка для вершины \(v\) — это вершина, которой соответствует наидлиннейший суффикс строки, соответствующей вершине \(v\), и присутствующий в боре. Будем считать, что мы их умеем быстро находить.

+

Добавим все плохие слова в бор. Будем считывать строку и с помощью суффиксных ссылок поддерживать самый длинный суффикс текущей строки, который принимает бор. Тогда, для конкретной позиции, мы можем быстро посчитать, какие плохие слова на нём заканчиваются — ровно те, до которых можно дойти по суффиксным ссылкам (по определению, суффиксная ссылка ведёт в наидлиннейший суффикс, присутствующий в боре). Информацию о количестве таких слов можно посчитать заранее динамикой в графе из суффиксных ссылок.

+

Алгоритм Ахо-Корасик позволяет строить суффиксные ссылки для произвольного бора за \(O(nk)\), где \(n\) и \(k\) это суммарный размер строк и размер алфавита соответственно. Его описание вынесенов в отдельную статью.

+ + diff --git a/web/sitemap.xml b/sitemap.xml similarity index 100% rename from web/sitemap.xml rename to sitemap.xml diff --git a/src/en b/src/en deleted file mode 160000 index 8e11791..0000000 --- a/src/en +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8e11791c94521d0ee202d84c3be6b2dd84b70eec diff --git a/src/ru b/src/ru deleted file mode 160000 index 2230d71..0000000 --- a/src/ru +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2230d71a193f116aa4046fa1651ae9392702cfe1 diff --git a/src/tg b/src/tg deleted file mode 160000 index a4aa48f..0000000 --- a/src/tg +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a4aa48f70da8116b5983ae00e57a6d516a68a10e diff --git a/web/template.html b/template.html similarity index 100% rename from web/template.html rename to template.html diff --git a/tg/basic-data-structures.html b/tg/basic-data-structures.html new file mode 100644 index 0000000..df18c47 --- /dev/null +++ b/tg/basic-data-structures.html @@ -0,0 +1,334 @@ + + + + + + + Стек и другие структуры данных - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Стек и другие структуры данных

+
    +
  • Стек
  • +
  • Правильные скобочные последовательности
  • +
  • Обратная польская запись
  • +
  • Стек в рекурсии
  • +
  • Задача о наибольшем прямоугольнике
  • +
  • Очередь и дек
  • +
  • Односвязные и двусвязные списки
  • +
+

Стек

+

Мы уже знаем, что такое стек. Это структура данных, которая хранит элементы упорядоченно и умеет отвечать на две операции за \(O(1)\):

+
    +
  • push(x) - положить элемент x в конец стека
  • +
  • pop() - снять и вернуть элемент, лежащий в конце стека
  • +
+

То есть это структура данных, где действия происходят только с элементом, лежащим в конце. Выполняется принцип FILO (First In - Last Out) - последним вынется тот элемент, который мы положили первым, если сначала положить все элементы, а потом все вынуть.

+

Часто для удобства у стека еще есть операции * size() - размер стека * empty() - проверка на пустоту * clear() - очистить стек

+

Как стек удобно использовать динамический массив: * в Питоне это list, причем push = append, pop = pop * в C++ это vector, причем push = push_back, а операция pop заменяется на две - back возвращает последний элемент, а pop_back вынимает его

+
a = []
+a.append(5)
+a.append(3)
+a.append(10)
+print(a.pop())
+a.append(12)
+print(a.pop())
+
10
+12
+

Правильные скобочные последовательности

+

Есть несколько определений Правильной скобочной последовательности - ПСП.

+
    +
  1. Неформальное определение
  2. +
+

ПСП - это строка из открывающих и закрывающих скобок, который получается из арифметических выражений удалением всего, кроме скобок.

+

Например: из выражения \((1 + 2) * (3 + 100 * (3 / 2))\) получается ПСП \(()(())\). А вот \()(())\) не получится из никакого выражения.

+
    +
  1. Явное определение
  2. +
+

ПСП - это строка из открывающих и закрывающих скобок, в которой все скобки можно разделить на пары, где первая скобка - открывающая, а вторая - закрывающая, открывающая идет раньше закрывающей, и никакие две пары не пересекаются.

+

Например: \(((())())\) - ПСП, так как разбивает на вот такие непересекающиеся пары: \(\textbf{(}\underline{(}\overline{(}\overline{)}\underline{)}\textit{()}\textbf{)}\). А вот строку \((()\) нельзя разбить на пары - там нечетное число скобок.

+
    +
  1. Рекурсивное определение
  2. +
+
    +
  • пустая строка - это ПСП
  • +
  • если \(A\) - это ПСП, то \((A)\) - это тоже ПСП
  • +
  • если \(A\) и \(B\) - это ПСП, то \(AB\) - это тоже ПСП
  • +
+

Например: пустая строка - ПСП, значит \(()\) - ПСП, значит \((())\) - ПСП, значит \((())()\) - ПСП, значит \(((())())\) - ПСП. А вот \(())(()\) не получится по этим правилам никак.

+
    +
  1. Определение через баланс ПСП - это строка из открывающих и закрывающих скобок. Давайте определим баланс на префиксе длины \(n\) как разница числа открывающих и закрывающих скобок на этом префиксе. Тогда в ПСП должны выполняться два свойства:
  2. +
+
    +
  • любой баланс больше или равен 0
  • +
  • баланс всей строки равен 0
  • +
+

То есть на любом префиксе открывающих скобок не меньше, чем закрывающих, а во всей строке их равное число.

+

Оказывается, именно последним определением удобно пользоваться, чтобы определить, является ли строка ПСП. А именно, давайте пройдемся слева направо и будем прибавлять \(+1\), если встретим открывающую скобку, и \(-1\), если встретим закрывающую скобку. И достаточно проверить, что баланс всегда неотрицателен, и равен нулю в конце.

+

Еще можно определить ПСП с разными видами скобок. Например, \(([](\{\}))\) - это ПСП, а \([(])\) - нет. В явное определение надо просто добавить, что скобки в одной паре должны быть одного вида. В рекурсивное определение нужно добавить правила вида “если \(A\) - это ПСП, то \([A]\) - это тоже ПСП” для всех видов скобок.

+

А вот определение через баланс так легко не обобщается, а ведь мы именно его хотим использовать для алгоритма проверки на ПСП. Для расширения придется использовать стек:

+

ПСП - это такая строка из открывающих и закрывающих скобок разного типа, если построении стека открытых скобок при прохождении по строке не возникает ошибки, а в конце стек пустой. А именно, давайте заведем пустой стек, пройдемся слева направо и будем класть в конец стека открывающую скобку, если мы ее встретили, и вынимать её, если встретили закрывающую - при этом надо проверить, что вынимаемся открытая скобка того же типа, что и встреченная закрывающая.

+

Например: строка \(\{[([])()]{}\}\). В ходе алгоритм стек будет меняться так:

+
    +
  • пустой
  • +
  • \(\{\)
  • +
  • \(\{[\)
  • +
  • \(\{[(\)
  • +
  • \(\{[([\)
  • +
  • \(\{[(\) - убранная \([\) подходит \(]\)
  • +
  • \(\{[\) - убранная \((\) подходит \()\)
  • +
  • \(\{[(\)
  • +
  • \(\{[\) - убранная \((\) подходит \()\)
  • +
  • \(\{\) - убранная \([\) подходит \(]\)
  • +
  • \(\{\{\)
  • +
  • \(\{\) - убранная \(\{\) подходит \(\}\)
  • +
  • пустой - убранная \(\{\) подходит \(\}\)
  • +
+

Все убранные скобки подошли встреченным закрытым, а в конце стек пустой - а значит это ПСП.

+

А вот строка \([{]}\) - это не ПСП: * пустой * \([\) * \([\{\) * ошибка, так как \(\{\) в конце стека не подходит \(]\)

+

Такой алгоритм работает за \(O(N)\) - так как мы проходимся по массиву и каждый раз делаем одну из операций со стеком - либо push, либо pop.

+

Обратная польская запись

+

Стек также часто используют для вычислений в каком-нибудь сложном порядке. Например, интересно, как именно компьютер вычисляет выражение \((1 + 2) * (100 * (3 / 2) + 3) + 4\). Его вычислять сложно, давайте перепишем его в другом формате, в котором его вычислять проще:

+

\([1, 2, +, 100, 3, 2, /, *, 3, +, *, 4, +]\)

+

Такой список чисел и операций называют обратной польской записью. Она вычисляется так: нужно проходить слева направо и * если встречается число - кладем его в стек * если встречается операция - снимаем с конца стека два числа, применяем к ним эту операцию, и кладем результат в стек

+

Например для этого выражения стек будет вести себя так: * [] * \([1]\) * \([1, 2]\) * \([3]\) * \([3, 100]\) * \([3, 100, 3]\) * \([3, 100, 3, 2]\) * \([3, 100, 1.5]\) * \([3, 150]\) * \([3, 150, 3]\) * \([3, 153]\) * \([459]\) * \([459, 4]\) * \([463]\)

+

То, что осталось в конце в стеке - это и есть результат.

+

Стек в рекурсии

+

На самом деле каждый раз, когда вы используете рекурсию, вы используете стек, ведь она реализована с его помощью.

+

А именно, представьте, что вы вызываете функцию f(n), и внутри нее вызывается другая функция g(n). Но ведь когда g(n) перестанет выполняться, вам нужно продолжить выполнять f(n). А значит все это время, пока вы вычисляете g(n), вам нужно хранить информацию о всех аргументах и локальных переменных внутри функции f(n), и даже место, где вы там остановились. Удобно хранить всю эту информацию в стеке - когда вы вызываете функцию, все содержимое прошлой функции сохраняется в стек, а когда функция перестает выполняться - прошлая функция снимает со стека последнюю функцию, и продолжает ее выполнять.

+

Именно поэтому все задачи на рекурсию можно решить и без рекурсии, и возможно это даже будет быстрее. Достаточно лишь написать while, который вынимает функцию вместе со всей информацией про ее выполенени с верха стека, выполняет её до следующего шага, где нужно вызвать другую функцию, кладет на верх стека обновленную функцию и новую вызванную функцию. И так, пока стек не станет пустым.

+

Кстати, стек рекурсии ограничен в некоторых языках прогрмамирования (например, питон), и его нужно поднимать:

+
import sys
+sys.setrecursionlimit(10000)
+

В C++ стек рекурсии ограничен лишь вашей оперативной памятью.

+

Пример задачи на рекурсию, которую теоретически можно переписать под стек - это Ханойские башни.

+

Условие. > У вас есть три стержня (= стека), на первом находятся \(N\) дисков, причем чем диск ниже, тем он шире. Можно переносить верхний диска одного стержня на верх другого стержня, если только под ним не будет находиться меньший по ширине диск. Нужно вывести последовательность операций, после которой все диски перенесутся на третий стержень.

+

Решение. > Достаточно делать как в динамическом программировании - формализовать задачу и свести ее к предыдущим. Давайте функция hanoi(n, from, to, middle) будет выводить все операции с переносом \(n\) стержней с верха стержня \(from\) на стержень \(to\), при условии, что их можно еще пользоваться стержнем \(middle\) (и при этом на стержнях \(to\) и \(middle\) все диски шире, чем эти \(n\), чтобы их можно было класть сверху). Тогда на самом деле эта операция разбивается на такие: * hanoi(n - 1, from, middle, to) - перенести \(n-1\) диск на средний стержень * print(from + ” -> ” + to) - перенести самый широкий диск на финальный стержень * hanoi(n - 1, middle, to, from) - перенести \(n-1\) диск со среднего стержаня на финальный

+

Алгоритм работает за \(O(2^n)\), так как вызывает два таких же алгоритма для \(n-1\).

+

Задача о наибольшем прямоугольнике

+

Условие. > У вас есть гистограмма - набор прямоугольников шириной \(1\) и высотой \(N\), идущих слева направо вплотную, причем они все “стоят” на земле - их нижняя координата \(y\) равна \(0\). Пример гистограммы:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Задача - найти наибольший по площади прямоугольник, лежащий внутри такой клеточной гистограммы.

+
+

Решение. Заметим, что нижняя граница наибольшего прямоугольника - это всегда \(y = 0\). Наибольший прямоугольник должно быть невозможно рашсирить ни в одну из сторон. Давайте просто переберем все такие прямоугольники и выберем из них максимальный по площади.

+

Давайте идти слева направо по вертикальным прямоугольникам гистограммы и хранить такую “лесенку” - в стеке будут лежать пройденные столбики (индекс и высота), но только те, которые нужны, чтобы столбцы строго возрастали, и при этом лесенка заканчивалась последним рассмотренным столбцом.

+

Строить её нужно так: давайте рассмотрим новый столбец. Если его высота больше, чем у последнего в стеке (предыдущего столбца), то просто кладём его в стек, и на этом всё. Если его высота меньше или равна, чем у последнего, то нужно вынимать столбцы с конца стека, пока высота нового столбца не будет наконец больше, чем у последнего в стеке. В конце нужно просто вынуть все столбцы из стека (для этого удобно просто в конец положить фиктивный столбец высоты ноль).

+

При вынимании столбца из стека нужно посчитать площадь максимального прямоугольника, который включает этот столбец. Высоту мы уже знаем, надо определить его площадь. Заметим, что его левая координата - это индекс столбца, который лежит перед этим столбцом в стеке (это самый правый столбец, который левее удаляемого и при этом ниже по высоте) плюс один. А правая координата - это та, которую мы сейчас рассматриваем (раз нам нужно удалить этот столбец).

+

Как это работает? Наибольший прямоугольник упирается верхом хотя бы в один столбец, а значит когад мы его будем удалять, мы учтем этот прямоугольник.

+

Так можно за \(O(N)\) найти площадь максимального прямоугольника в такой гистограмме.

+

Заметим, что к этой задаче можно свести задачу поиска максимального белого прямоугольника в прямоугольнике с черными и белыми прямоугольниками - нужно для каждой клетки посчитать, сколько клеток поряд наверх из нее - это белые клетки (с помощью простого динамического программирования), и после этого перебрать \(N\) строк и решить задачу для гистограммы, низ которой является этой строкой - все высоты как раз мы теперь знаем.

+

Так мы решим эту задачу за \(O(N^2)\) - а это размер прямоугольника, так что быстрее и не получится.

+

Очередь и дек

+

Очередь - это структура данных, которая тоже хранит упорядоченные элементы с такими операциями за \(O(1)\): * push(x) - положить элемент в конец очереди * pop() - вынуть и вернуть элемент из начала очереди

+

Выполняется принцип FIFO (First In - First Out) - кто первый пришел, тот первый и ушел. Очередь удобно использовать для моделирования реальных очередей, ведь они позволяют честно распределить что-то - кто первый пришел, тот первый и получил. Также очередь часто используют для алгоритмов с несколькими независимыми процессами - удобно хранить очередь задач, которые нужно выполнить, и процесс, когда освобождется, берет из очереди самую ранее добавленную задачу и берется ее выполнять.

+

Дек - это структура данных, которая тоже хранит упорядоченные элементы с такими операциями за \(O(1)\): * push_back(x) - положить элемент в конец дека * push_front(x) - положить элемент в начало дека * pop_back() - вынуть и вернуть элемент из конца дека * pop_front() - вынуть и вернуть элемент из начала дека

+

То есть очередь и стек можно реализовать с помощью дека. Чаще всего удобно вместо очереди использовать именно дек.

+

Обратите внимание, что дек не умеет обращаться к элементу по его номеру, он умеет работать только с крайними элементами.

+

В Питоне есть collections.deque:

+
from collections import deque
+d = deque()
+d.append(5)
+d.append(100)
+d.appendleft(10)
+print(d.pop())
+d.append(1000)
+print(d.pop())
+print(d.popleft())
+print(d.popleft())
+
100
+1000
+10
+5
+

В C++ также есть deque, но, как и в векторе, pop_back и pop_front не возвращают объект.

+
#include <deque>
+...
+deque<int> d;
+d.push_back(1);
+d.push_back(2);
+d.push_front(10);
+cout << d.back();
+d.pop_back();
+cout << d.front();
+d.pop_front();
+cout << d.front();
+d.pop_front();
+
+

Односвязные и двусвязные списки

+

Осторожно: эти списки никак не связаны со списками из питона (которые на самом деле динамические массивы).

+

Проблема стека: нельзя добавлять и удалять элементы в середине.

+

Давайте хранить массив \(values\) с нужными нам элементами, и для каждого элемента еще хранить (в структуре или в отдельном массиве \(next\)) индекс следующего элемента. И, конечно, хранить индекс самого первого элемента \(start\). Будем называть такую структуру данных односвязным списком.

+

Чтобы пройтись по всему односвязному списку, нужно начать с индекса первого элемента и постепенно переходить по индексу следующего элемента (\(i\) -> \(next[i]\)), пока \(next[i] \neq i\).

+

Тогда если у нас есть индекс какого-то элемента \(index\), мы за \(O(1)\) можем вставить после него еще один - нужно добавить в массив справа еще один элемент, пусть он получил индекс \(new\), и сделать \(next[new] = next[index], next[index] = new\).

+

Часто бывает удобно хранить еще предыдущий элемент помимо следующего, чтобы можно было проходить по списку справа налево, и добавлять элемент перед каким-нибудь. Такой список называется двусвязным.

+

Задание

+

Решите как можно больше задач из контеста https://informatics.msk.ru/mod/statements/view.php?id=33318

+ + diff --git a/tg/bfs.html b/tg/bfs.html new file mode 100644 index 0000000..0eb0b64 --- /dev/null +++ b/tg/bfs.html @@ -0,0 +1,303 @@ + + + + + + + Кратчайшие пути в графах. BFS. Dijkstra. - Tinkoff Generation + + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Кратчайшие пути в графах. BFS. Dijkstra.

+

Задача

+
+

Дан ориентированный граф \(G = (V, E)\), а также вершина \(s\).
+Найти длину кратчайшего пути от \(s\) до каждой из вершин графа. Длина пути — количество рёбер в нём.

+
+

BFS

+

BFS — breadth-first search, или же поиск в ширину.

+

Этот алгоритм позволяет решать следующую задачу.

+

Алгоритм работает следующим образом.

+
    +
  1. Создадим массив \(dist\) расстояний. Изначально \(dist[s] = 0\) (поскольку расстояний от вершины до самой себя равно \(0\)) и \(dist[v] = \infty\) для \(v \neq s\).
  2. +
  3. Создадим очередь \(q\). Изначально в \(q\) добавим вершину \(s\).
  4. +
  5. Пока очередь \(q\) непуста, делаем следующее: +
      +
    1. Извлекаем вершину \(v\) из очереди.
    2. +
    3. Рассматриваем все рёбра \((v, u) \in E\). Для каждого такого ребра пытаемся сделать релаксацию: если \(dist[v] + 1 < dist[u]\), то мы делаем присвоение \(dist[u] = dist[v] + 1\) и добавляем вершину \(u\) в очередь.
    4. +
  6. +
+

Визуализации:

+
    +
  • https://visualgo.net/mn/dfsbfs

  • +
  • https://www.hackerearth.com/practice/algorithms/graphs/breadth-first-search/visualize/

  • +
+

Интуитивное понимание алгоритма

+

Можно представить, что мы поджигаем вершину \(s\). Каждый шаг алгоритма — это распространение огня на соседние вершины. Понятно, что огонь доберётся до вершины по кратчайшему пути.

+

Заметьте, что этот алгоритм очень похож на DFS — достаточно заменить очередь на стек и поиск в ширину станет поиском в глубину. Действительно, оба алгоритма при обработке вершины просто записывают всех непосещенных соседей, в которые из неё есть ребро, в структуру данных, и после этого выбирает следующую вершину для обработки в структуре данных. В DFS это стек (благодаря рекурсии), поэтому мы сначала записываем соседа, идем в обрабатываем его полностью, а потом начинаем обрабатывать следующего соседа. В BFS это очередь, поэтому мы кидаем сразу всех соседей, а потом начинаем обрабатывать вообще другую вершину - ту непосещенную, которую мы положили в очередь раньше всего.

+

Оба алгоритма позволяют обойти граф целиком - посетить каждую вершину ровно один раз. Поэтому они оба подходят для таких задач как: * поиск компонент связности * проверка графа на двудольность * построение остова

+

Реализация на C++

+

n — количество вершин в графе; adj — список смежности

+
vector<int> bfs(int s) {
+    // длина любого кратчайшего пути не превосходит n - 1,
+    // поэтому n - достаточное значение для "бесконечности";
+    // после работы алгоритма dist[v] = n, если v недостижима из s
+    vector<int> dist(n, n);
+    dist[s] = 0;
+    queue<int> q;
+    q.push(s);
+
+    while (!q.empty()) {
+        int v = q.front();
+        q.pop();
+        for (int u : adj[v]) {
+            if (dist[u] > dist[v] + 1) {
+                dist[u] = dist[v] + 1;
+                q.push(u);
+            }
+        }
+    }
+
+    return dist;
+}
+

Свойства кратчайших путей

+

Обозначение: \(d(v)\) — длина кратчайшего пути от \(s\) до \(v\).

+

Лемма 1. > Пусть \((u, v) \in E\), тогда \(d(v) \leq d(u) + 1\).

+

Действительно, существует путь из \(s\) в \(u\) длины \(d(u)\), а также есть ребро \((u, v)\), следовательно, существует путь из \(s\) в \(v\) длины \(d(u) + 1\). А значит кратчайший путь из \(s\) в \(v\) имеет длину не более \(d(u) + 1\),

+

Лемма 2. > Рассмотрим кратчайший путь от \(s\) до \(v\). Обозначим его как \(u_1, u_2, \dots u_k\) (\(u_1 = s\) и \(u_k = v\), а также \(k = d(v) + 1\)).
+> Тогда \(\forall (i < k): d(u_i) + 1 = d(u_{i + 1})\).

+

Действительно, пусть для какого-то \(i < k\) это не так. Тогда, используя лемму 1, имеем: \(d(u_i) + 1 > d(u_{i + 1})\). Тогда мы можем заменить первые \(i + 1\) вершин пути на вершины из кратчайшего пути из \(s\) в \(u_{i + 1}\). Полученный путь стал короче, но мы рассматривали кратчайший путь — противоречие.

+

Корректность

+

Утверждение. > 1. Расстояния до тех вершин, которые были добавлены в очередь, посчитаны корректно. > 2. Вершины лежат в очереди в порядке неубывания расстояния, притом разность между кратчайшими расстояними до вершин в очереди не превосходит \(1\).

+

Докажем это по индукции по количеству итераций алгоритма (итерация — извлечение вершины из очереди и дальнейшая релаксация).

+

База очевидна.
+Переход. Сначала докажем первую часть. Предположим, что \(dist[v] + 1 < dist[u]\), но \(dist[v] + 1\) — некорректное расстояние до вершины \(u\), то есть \(dist[v] + 1 \neq d(u)\). Тогда по лемме 1: \(d(u) < dist[v] + 1\). Рассмотрим предпоследнюю вершину \(w\) на кратчайшем пути от \(s\) до \(u\). Тогда по лемме 2: \(d(w) + 1 = d(u)\). Следовательно, \(d(w) + 1 < dist[v] + 1\) и \(d(w) < dist[v]\). Но тогда по предположению индукции \(w\) была извлечена раньше \(v\), следовательно, при релаксации из неё в очередь должна была быть добавлена вершина \(u\) с уже корректным расстоянием. Противоречие.
+Теперь докажем вторую часть. По предположению индукции в очереди лежали некоторые вершины \(u_1, u_2, \dots u_k\), для которых выполнялось следующее: \(dist[u_1] \leq dist[u_2] \leq \dots \leq dist[u_k]\) и \(dist[u_k] - dist[u_1] \leq 1\). Мы извлекли вершину \(v = u_1\) и могли добавить в конец очереди какие-то вершины с расстоянием \(dist[v] + 1\). Если \(k = 1\), то утверждение очевидно. В противном случае имеем \(dist[u_k] - dist[u_1] \leq 1 \leftrightarrow dist[u_k] - dist[v] \leq 1 \leftrightarrow dist[u_k] \leq dist[v] + 1\), то есть упорядоченность сохранилась. Осталось показать, что \((dist[v] + 1) - dist[u_2] \leq 1\), но это равносильно \(dist[v] \leq dist[u_2]\), что, как мы знаем, верно.

+

Время работы

+

Из доказанного следует, что каждая достижимая из \(s\) вершина будет добавлена в очередь ровно \(1\) раз, недостижимые вершины добавлены не будут. Каждое ребро, соединяющее достижимые вершины, будет рассмотрено ровно \(2\) раза. Таким образом, алгоритм работает за \(O(V+ E)\) времени, при условии, что граф хранится в виде списка смежности.

+

Неориентированные графы

+

Если дан неориентированный граф, его можно рассматривать как ориентированный граф с двумя обратными друг другу ориентированными рёбрами.

+

Восстановление пути

+

Пусть теперь заданы 2 вершины \(s\) и \(t\), и необходимо не только найти длину кратчайшего пути из \(s\) в \(t\), но и восстановить какой-нибудь из кратчайших путей между ними. Всё ещё можно воспользоваться алгоритмом BFS, но необходимо ещё и поддерживать массив предков \(p\), в котором для каждой вершины будет храниться предыдущая вершина на кратчайшем пути.

+

Поддерживать этот массив просто: при релаксации нужно просто запоминать, из какой вершины мы прорелаксировали в данную. Также будем считать, что \(p[s] = -1\): у стартовой вершины предок — некоторая несуществующая вершина.

+

Восстановление пути делается с конца. Мы знаем последнюю вершину пути — это \(t\). Далее, мы сводим задачу к меньшей, переходя к нахождению пути из \(s\) в \(p[t]\).

+

Реализация BFS с восстановлением пути

+
// теперь bfs принимает 2 вершины, между которыми ищется пути
+// bfs возвращает кратчайший путь из s в t, или же пустой vector, если пути нет
+vector<int> bfs(int s, int t) {
+    vector<int> dist(n, n);
+    vector<int> p(n, -1);
+    dist[s] = 0;
+    queue<int> q;
+    q.push(s);
+
+    while (!q.empty()) {
+        int v = q.front();
+        q.pop();
+        for (int u : adj[v]) {
+            if (dist[u] > dist[v] + 1) {
+                p[u] = v;
+                dist[u] = dist[v] + 1;
+                q.push(u);
+            }
+        }
+    }
+    
+    // если пути не существует, возвращаем пустой vector
+    if (dist[t] == n) {
+        return {};
+    }
+
+    vector<int> path;
+    while (t != -1) {
+        path.push_back(t);
+        t = p[t];
+    }
+    
+    // путь был рассмотрен в обратном порядке, поэтому его нужно перевернуть
+    reverse(path.begin(), path.end());
+    return path;
+}
+

Проверка принадлежности вершины кратчайшему пути

+
+

Дан ориентированный граф \(G\), найти все вершины, которые принадлежат хотя бы одному кратчайшему пути из \(s\) в \(t\).

+
+

Запустим из вершины \(s\) в графе \(G\) BFS — найдём расстояния \(d_1\). Построим транспонированный граф \(G^T\) — граф, в котором каждое ребро заменено на противоположное. Запустим из вершины \(t\) в графе \(G^T\) BFS — найдём расстояния \(d_2\).

+

Теперь очевидно, что \(v\) принадлежит хотя бы одному кратчайшему пути из \(s\) в \(t\) тогда и только тогда, когда \(d_1(v) + d_2(v) = d_1(t)\) — это значит, что есть путь из \(s\) в \(v\) длины \(d_1(v)\), а затем есть путь из \(v\) в \(t\) длины \(d_2(v)\), и их суммарная длина совпадает с длиной кратчайшего пути из \(s\) в \(t\).

+

Кратчайший цикл в ориентированном графе

+
+

Найти цикл минимальной длины в ориентированном графе.

+
+

Попытаемся из каждой вершины найти кратчайший цикл, проходящий через неё, с помощью BFS. Это делается аналогично обычному BFS: мы должны найти расстояний от вершины до самой себя, при этом не считая, что оно равно \(0\).

+

Итого, у нас \(|V|\) запусков BFS, и каждый запуск работает за \(O(|V| + |E|)\). Тогда общее время работы составляет \(O(|V|^2 + |V| |E|)\). Если инициализировать массив \(dist\) единожды, а после каждого запуска BFS возвращать исходные значения только для достижимых вершин, решение будет работать за \(O(|V||E|)\).

+

Задача

+
+

Дан взвешенный ориентированный граф \(G = (V, E)\), а также вершина \(s\). Длина ребра \((u, v)\) равна \(w(u, v)\). Длины всех рёбер неотрицательные.
+Найти длину кратчайшего пути от \(s\) до каждой из вершин графа. Длина пути — сумма длин рёбер в нём.

+
+

Алгоритм Дейкстры

+

Алгоритм Дейкстры решает приведённую выше задачу. Он работает следующим образом.

+
    +
  1. Создать массив \(dist\) расстояний. Изначально \(dist[s] = 0\) и \(dist[v] = \infty\) для \(v \neq s\).
  2. +
  3. Создать булёв массив \(used\), \(used[v] = 0\) для всех вершин \(v\) — в нём мы будем отмечать, совершалась ли релаксация из вершины.
  4. +
  5. Пока существует вершина \(v\) такая, что \(used[v] = 0\) и \(dist[v] \neq \infty\), притом, если таких вершин несколько, то \(v\) — вершина с минимальным \(dist[v]\), делать следующее: +
      +
    1. Пометить, что мы совершали релаксацию из вершины \(v\), то есть присвоить \(used[v] = 1\).
    2. +
    3. Рассматриваем все рёбра \((v, u) \in E\). Для каждого ребра пытаемся сделать релаксацию: если \(dist[v] + w(v, u) < dist[u]\), присвоить \(dist[u] = dist[v] + w(v, u)\).
    4. +
  6. +
+

Иными словами, алгоритм на каждом шаге находит вершину, до которой расстояние сейчас минимально и из которой ещё не была произведена релаксация, и делает её.

+

Посчитаем, за сколько работает алгоритм. Мы \(V\) раз ищем вершину минимальным \(dist\), поиск минимума у нас линейный за \(O(V)\), отсюда \(O(V^2)\). Обработка ребер у нас происходит суммарно за \(O(E)\), потому что на каждое ребро мы тратим \(O(1)\) действий. Так мы находим финальную асимптотику: \(O(V^2 + E)\).

+
+

Реализация на C++

+

Рёбра будем хранить как pair<int, int>, где первое число пары — куда оно ведёт; а второе — длина ребра.

+
// INF - infinity - бесконечность
+const long long INF = (long long) 1e18 + 1;
+
+vector<long long> dijkstra(int s) {
+    vector<long long> dist(n, INF);
+    dist[s] = 0;
+    vector<bool> used(n);
+    
+    while (true) {
+        // находим вершину, из которой будем релаксировать
+        int v = -1;
+        for (int i = 0; i < n; i++) {
+            if (!used[i] && (v == -1 || dist[i] < dist[v])) {
+                v = i;
+            }
+        }
+        
+        // если не нашли подходящую вершину, прекращаем работу алгоритма
+        if (v == -1) {
+            break;
+        }
+        
+        for (auto &e : adj[v]) {
+            int u = e.first;
+            int len = e.second;
+            if (dist[u] > dist[v] + len) {
+                dist[u] = dist[v] + len;
+            }
+        }
+    }
+    
+    return dist;
+}
+

Восстановление пути

+

Восстановление пути в алгоритме Дейкстры делается аналогично восстановлению пути в BFS (и любой динамике).

+

Дейкстра на сете

+

Искать вершину с минимальным \(dist\) можно гораздо быстрее, используя такую структуру данных как очередь с приоритетом. Нам нужно хранить пары \((dist, index)\) и уметь делать такие операции: * Извлечь минимум (чтобы обработать новую вершину) * Удалить вершину по индексу (чтобы уменьшить \(dist\) до какого-то соседа) * Добавить новую вершину (чтобы уменьшить \(dist\) до какого-то соседа)

+

Для этого используют, например, кучу или сет. Удобно помимо сета хранить сам массив dist, который его дублирует, но хранит элементы по порядку. Тогда, чтобы заменить значение \((dist_1, u)\) на \((dist_2, u)\), нужно удалить из сета значение \((dist[u], u)\), сделать \(dist[u] = dist_2;\) и добавить в сет \((dist[u], u)\).

+

Данный алгоритм будет работать за \(V O(log V)\) извлечений минимума и \(O(E log V)\) операций уменьшения расстояния до вершины (может быть сделано после каждого ребра). Поэтому алгоритм работает за \(O(E log V)\).

+

Заметьте, что этот алгоритм не лучше и не хуже, чем без сета, который работает за \(O(V^2 + E)\). Ведь если \(E = O(V^2)\) (граф почти полный), то Дейкстра без сета работает быстрее, а если, наример, \(E = O(V)\), то Дейкстра на сете работает быстрее. Учитывайте это, когда выбираете алгоритм.

+ + diff --git a/tg/binary-search.html b/tg/binary-search.html new file mode 100644 index 0000000..d48f377 --- /dev/null +++ b/tg/binary-search.html @@ -0,0 +1,250 @@ + + + + + + + Бинарный поиск - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Бинарный поиск

+
    +
  • Линейный поиск
  • +
  • Бинарный поиск
  • +
  • Бинарный поиск с вещественными числами
  • +
  • Поиск максимума выпуклой функции: тернарный поиск, бинарный поиск
  • +
  • Бинарный поиск по ответу
  • +
+

Линейный поиск

+

Мы считаем, что вы уже знаете линейный поиск, а именно умеете решать задачи такого типа:

+
    +
  • Проверить, есть ли в массиве число X
  • +
  • Найти максимум в массиве
  • +
  • Найти сумму чисел в массиве
  • +
  • Найти первое четное число в массиве
  • +
+

Все такие задачи решаются с помощью одного прохода по массиву с помощью цикла for. Все такие алгоритмы работают за \(O(n)\). И даже можно понять, что быстрее, чем \(O(n)\) решить ни одну из этих задач не получится.

+

Задание

+

Убедитесь, что вы умеете решать эти задачи. Докажите, что быстрее, чем O(n) их решить в худшем случае нельзя.

+

Бинарный поиск

+

Однако иногда найти число X в массиве можно и быстрее! Для этого надо добавить условие на то, что массив отсортирован. Но давайте начнем не с этого.

+

Задание

+

Я загадал число X от 1 до 100. Вы можете спрашивать, больше ли мое число чем число T, я отвечаю “да” или “нет”. За сколько вопросов в худшем случае вы сможете найти число X? Как нужно действовать?

+

Решение и состоит в идее бинарного (двоичного) поиска - нужно первым вопросом спросить “число X больше, чем 50?”. После этого, если ответ “нет”, надо спросить “число X больше, чем 25”? И так далее, нужно уменьшать отрезок возможных значений в два раза каждый раз.

+

Почему нужно делить обязательно пополам? Почему бы не спросить “число X больше, чем 80?” первым же вопросом? Но если вдруг ответ “нет”, то мы останемся с 80 вариантами вместо 100. То есть деление отрезка ровно пополам гарантирует, что в худшем случае мы останемся не более чем с половиной вариантов.

+

Чтобы понять, как быстро это работает, введём новую математическую функцию. Логарифмом по основанию \(a\) от \(b\) будем называть число \(c\), такое что \(a ^ c = b\). Обозначается как \(\log_a b = c\). Чаще всего мы будем работать с двоичным логарифмом, то есть в какую степень \(c\) нужно возвести двойку, чтобы получить \(b\). Поэтому договоримся, что запись \(\log n\) означает двочный логарифм \(n\).

+

Теперь вернёмся к нашей задаче. Можно понять, что такой алгоритм работает как раз за \(O(\log n)\) вопросов (если число 100 на заменить абстрактную переменную \(n\)). Несложно убедиться, что именно логарифм раз нужно поделить число на два, чтобы получилось 1.

+

Общий принцип

+

А теперь представьте такую задачу: у вас есть массив, состоящий из некоторого количества подряд идущих нулей, за которыми следует какое-то количество подряд идущих единиц.

+
a = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]
+n = len(a)
+n
+
14
+

Вам дан массив, и вам нужно найти позицию первой единицы, то есть найти такое место, где заканчиваются нули, и начинаются единицы. Это можно сделать с помощью линейного поиска за один проход по массиву, но хочется сделать это быстрее.

+

Давайте обратимся к идее бинарного поиска. Посмотрим на элемент посередине массива. Если это нуль, то первую единицу стоит искать в правой половину массива, а если единица - то в левой.

+

Есть много способов писать бинарный поиск, и в его написании люди очень часто путаются. Очень удобно в данном случае воспользоваться инвариантом (это слово значит “постоянное свойство”):

+

Пусть переменная left всегда указывает на \(0\) в массиве, а переменная right всегда указывает на \(1\) в массиве.

+

Дальше мы будем переменные left и right постепенно сдвигать друг к другу, и в какой-то момент они станут соседними числами. Это и будет означать, что мы нашли место, где заканчиваются нули и начинаются единицы.

+

Чему равны left и right изначально, когда мы ничего про массив не знаем? Первая приходящая в голову идея - поставить их на \(0\) и \(n-1\) соответственно. Увы, в общем случае это может быть неверно, потому что a[0] может быть единицей, а a[n-1] может быть нулём. Правильнее сделать вот так:

+
left = -1
+right = n
+

То есть изначально left и right указывают на несуществующие индексы. Но это нормально - например в массиве [1, 1, 1, 1] в конце алгоритма как раз должно быть left == -1, right == 0.

+

Осталось нам написать цикл while:

+
while right - left > 1:
+    middle = (left + right) // 2 # именно такая формула для среднего индекса между left и right
+    if a[middle] == 1:
+        right = middle # right всегда должна указывать на 1
+    else:
+        left = middle # left всегда должна указывать на 0
+print left, right
+print a[left], a[right]
+
8 9
+0 1
+

Мы решили задачу для ноликов и единичек, но это легко обобщается на абсолютно любую задачу, где есть какое-то свойство, которое в начале массива не выполняется, а потом выполняется.

+

Например, если мы хотим найти, есть ли число \(X\) в отсортированном массиве, то мы просто представим, что \(0\) - это числа, меньшие \(X\), а \(1\) - это числа, большие или равные \(X\). Тогда достаточно найти первую “единицу” и проверить, равно ли это число \(X\).

+
a = [1, 3, 4, 10, 10, 10, 11, 80, 80, 81] # отсортированный массив
+def bin_search(a, x):
+    n = len(a)
+    left = -1
+    right = n
+    while right - left > 1:
+        middle = (left + right) // 2
+        if a[middle] >= x: # практически единственная строка, которая меняется от задачи к задаче
+            right = middle
+        else:
+            left = middle
+    if right != n and a[right] == x: # ответ лежит в right
+        return True
+    else:
+        return False
+
+print (bin_search(a, 1))
+print (bin_search(a, 10))
+print (bin_search(a, 20))
+print (bin_search(a, 79))
+print (bin_search(a, 80))
+print (bin_search(a, 81)
+
True
+True
+False
+False
+True
+True
+

Задание

+

Придумайте, как с помощью бинарного поиска решить такие задачи: * Найти первое число, равное X в отсортированном массиве, или вывести, что таких чисел нет * Найти последнее число, равное X в отсортированном массиве, или вывести, что таких чисел нет * Посчитать, сколько раз встречается число X в отсортированном массиве (в решении помогают два предыдущих пункта) * Дан массив чисел, первая часть состоит из нечетных чисел, а вторая - из четных. Найти индекс, начиная с которого все числа четные.

+

Все эти задачи решаются бинарным поиском за \(O(\log{n})\). Правда нужно понимать, что в чистом виде такую задачу решать двоичным поиском бессмысленно - ведь чтобы создать массив размера \(n\), уже необходимо потратить \(O(n)\) операций.

+

Поэтому зачастую такие задачи сформулированы таким образом:

+

Дан отсортированный массив размера \(n\). Нужно ответить на \(m\) запросов вида “встречается ли число \(x_i\) в массиве n”?

+

Задание

+

Найдите время работы, за которое решается эта задача?

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

Решение: Такая задача решается за \(O(n + m\log{n})\) - нужно создать массив за \(O(n)\) и \(m\) раз запустить бинарный поиск.

+

Задание

+

Решите 3 первые задачи в этом контесте:

+

https://informatics.msk.ru/moodle/mod/statements/view.php?id=33216

+

Бинарный поиск с вещественными числами

+

У нас все еще есть функция f(x), которая сначала равна 0, а потом равна 1, и мы хотим найти это место, где она меняет значение. Но теперь аргумент функции - вещественное число. Например: * \(f(x) = 1\), если \(x^2 > 2\) * \(f(x) = 0\), если \(x^2 \leq 2\)

+

Понятно, что при \(x = \sqrt 2\) \(f(x) = 0\), а при любом даже немного большем значении \(f(x) = 1\). Если мы научимся решать такую задачу, то мы научимся находить корень из двух!

+

Увы, возникает проблема: действительные числа хранятся в компьютере неточно

+
# известный пример
+0.1 + 0.1 + 0.1
+
0.30000000000000004
+

Тем более не сможем найти точное значение \(\sqrt 2\), потому что это бесконечная непериодическая дробь. Так что давайте снова воспользуемся бинарным поиском, причем всегда \(f(left) = 0\), \(f(right) = 1\), и мы остановимся тогда, когда left и right будет очень-очень близко.

+

И тут снова возникает проблема. Помимо того, что бесконечную дробь в принципе невозможно точно хранить в компьютере, ещё и арифметические операции понижают эту точность. Поэтому, чтобы явно не использовать разность между правым и левым указателем, можно задать фиксированное число шагов, которое будет выполняться.

+

Так как мы знаем, что двоичный поиск работает за двоичный логарифм, можно сказать, что на угадывание десятичного разряда числа потребуется примерно три шага бинпоиска (т. к. $ $). Значит, например, если нам нужно посчитать значение функции до шести знаков после запятой, то нам нужно ещё примерно 18 шагов уже после того, как расстояние между left и right достигло одного.

+

Чтобы каждый раз об этом не думать, можно считать, что ста шагов бинпоиска хватит для почти любых разумных целей.

+
left = 0.0 # 0^2 < 2, а значит f(0) = 0
+right = 10.0 # 10^2 > 2, а значит f(10) = 1
+for i in range(100):
+    middle = (left + right) / 2 # теперь деление не нацело, а вещественное
+    if middle ** 2 > 2:
+        right = middle # right всегда должна указывать на 1
+    else:
+        left = middle # left всегда должна указывать на 0
+print left, right
+print left ** 2, right ** 2
+
1.41421356237 1.41421356237
+2.0 2.0
+

Вот мы и нашли корень из 2 с достаточно высокой точностью.

+

На самом деле, так можно искать ноль любой непрерывной функции (мы сейчас искали ноль функции \(x^2 - 2\)), у которой вы знаете значение меньше нуля и значение больше нуля.

+

Задание

+

Придумайте, как с помощью вещественного бинпоиска найти * \(\sqrt[\leftroot{-2}\uproot{2}17]{1000}\) * какой-нибудь корень уравнения \(x^4 + 3x = 5\)

+

Поиск максимума выпуклой функции: тернарный поиск, бинарный поиск

+

Так мы только что научились находить корень непрерывной функции, у которой мы знаем значение меньше и больше 0. Но можно ли найти с помощью бинарного поиска локальный максимум функции? Можно!

+

Как известно, локальный максимум функции \(f\) - это просто такое \(x_0\), что для всех близких к нему \(x\) значения \(f(x) < f(x_0)\). Для непрерывных функций выполняется более крутая вещь: слева от максимума функция возрастает, а справа от максимума функция убывает. Так это как раз отличное условие для нашего вещественного бинарного поиска!

+

Если вы знаете \(x_1\) такое, что в его окрестности f(x) возрастает, и \(x_2\) такое, что в его окрестности f(x) убывает, то можно запустить между ними бинпоиск и найти точку \(x_0\) такую, что слева от нее возрастает значение функции, а справа - убывает. Это и есть локальный максимум.

+

А если функция выпуклая, то она вообще выглядит красиво: сначала возрастает, потом максимум, потом убывает.

+

Проблема только в одном: как по точке понять, в ее окрестности значение функции убывает или возрастает? Достаточно тыкнуть две точки очень-очень рядом с ней и сравнить их значения!

+

Задание

+

Придумайте, как с помощью вещественного бинпоиска найти * максимум функции \(x - e^x\) (она выпуклая, и максимум ровно один) * какой-нибудь локальный максимум функции \(31x+x^3-x^4\)

+

Тернарный поиск

+

Другой способ искать максимум - это тернарный поиск. Пусть известно, что максимум находится между left и right. Поделим отрезок на три равные части: * middle_left = (2 * left + right) / 3 * middle_right = (left + 2 * right) / 3

+

Тогда если f(middle_left) < f(middle_right), то можно спокойно заменить left на middle_left (максимум точно не левее middle_left), а если f(middle_left) > f(middle_right), то можно спокойно заменить right на middle_right. Он будет работать не за двоичный логарифм, а за логарифм по основанию полтора, что больше (но асимптотически то же самое, так как отличается в константу раз).

+

Оба способа работают быстро, и обобщаются на дискретный случай (то, что было в начале - когда дан массив, значения в котором сначала возрастают, а потом убывают). Но проблема есть в том, что если функция нестрого возрастает и нестрого убывает, а именно если там есть отрезки постоянства, то алгоритм не работает. В случае, когда значения функции равны, никак нельзя понять, с какой стороны искать максимум - он может быть с любой стороны.

+

Задание

+

Решите 4 и 5 задачи в этом контесте:

+

https://informatics.msk.ru/moodle/mod/statements/view.php?id=33216

+

Бинарный поиск по неотсортированному массиву

+

Заметьте, что в первоначальной задаче условие на то, что сначала идут нули, а потом идут единицы несущественно. Главное, чтобы мы знали индекс, который показывает на 0, и индекс, который показывает на 1. После этого бинарным поиском мы таким же способом найдем пару соседних нуля и единицы в массиве.

+

Поэтому бинарный поиск работает и не для возрастающих массивов / функций, если наша задача состоит именно в поиске двух соседних индексов, в которых условие выполняется и не выполняется.

+

Например, если мы знаем, что \(f(x_0) < 0\) и \(f(x_1) > 0\), и функция непрерывная, то бинарным поиском можно найти ноль этой функции между \(x_0\) и \(x_1\), даже если функция не монотонная!

+

Или, например, если нужно в массиве найти соседние четное и нечетное числа, и известно положение какого-то четного числа и какого-то нечетного числа, то это тоже можно легко сделать с помощью бинарного поиска.

+

Полезно иметь это в виду, это применяется в нескольких задачах контестов.

+

Бинарный поиск по ответу

+

Рассмотрим такую задачу:

+

Пример: “Корова в стойла”

+

Условие: На прямой расположены N стойл (даны их координаты на прямой), в которые необходимо расставить K коров так, чтобы минимальное расcтояние между коровами было как можно больше. Гарантируется, что \(1 < K < N\).

+

Решение:

+

Если решать задачу в лоб, то вообще неясно что делать. Нужно решать обратную задачу: давайте предположим, что мы знаем это расстояние X, ближе которого коров ставить нельзя. Тогда сможем ли мы расставить самих коров?

+

Ответ - да, можно ставить их довольно просто: самую первую ставим в самое левое стойло, это всегда выгодно. Следующие несколько стойл надо оставить пустыми, если они на расстоянии меньше X. В самое левое стойло из оставшихся надо поставить вторую корову и так далее. Даже ясно как это писать: надо идти слева направо по отсортированному массиву стойл, хранить координату последней коровы, и либо пропускать стойло, либо ставить в него новую корову.

+

То есть если мы знаем расстояние X, то мы можем за O(n) проверить, можно ли расставить K коров на таком расстоянии. Ну так давайте запустим бинпоиск по X, ведь при слишком маленьком X коров точно можно расставить, а при слишком большом - нельзя, и как раз эту границу и просят найти в задаче (“как можно больше”).

+

Осталось точно определить границы, то есть изначальные значения left и right. Нам точно хватит расстояния 0, так как гарантируется, что коров меньше, чем стойл. И точно не хватит расстояния max_coord - min_coord + 1, так как по условию есть хотя бы 2 коровы.

+
coords = [2, 5, 7, 11, 15, 20] # координаты стойл
+k = 3 # число коров
+
+def is_correct(x): # проверяем, можно ли поставить K коров в стойла, если между коровами расстояние хотя бы x
+    cows = 1
+    last_cow = coords[0]
+    for c in coords:
+        if c - last_cow >= x:
+            cows += 1
+            last_cow = c
+    return cows >= k
+
+left = 0 # расставить коров на расстоянии хотя бы 0 можно всегда
+right = max(coords) - min(coords) + 1 # при таком расстоянии даже 2 коровы поставить нельзя
+while right - left != 1:
+    middle = (left + right) // 2
+    if is_correct(middle): # проверяем, можно ли поставить K коров в стойла, если между коровами расстояние хотя бы middle
+        left = middle # left всегда должна указывать на ситуацию, когда можно поставить коров
+    else:
+        right = middle # right всегда должна указывать на ситуацию, когда нельзя поставить коров
+print left # left - максимальное расстояние, на котором можно расставить коров в стойла
+
9
+

Общий принцип

+

Такой метод и называется бинпоиск по ответу. Он очень важный и очень распространен на олимпиадах, очень рекомендую решать на него задачи.

+

По сути мы просто взяли задачу “найдите максимальное X, такое что какое-то свойство от X выполняется” и решили её бинпоиском. Самое сложное - увидеть такую формулировку в задаче. Поэтому рассмотрим еще один пример.

+

Пример: “Очень Легкая Задача”

+

Условие: есть два принтера, один печатает лист раз в \(x\) минут, другой раз в \(y\) минут. За сколько минут они напечатают \(N\) листов? \(N > 0\)

+

Решение: Здесь, в отличие от предыдущей задачи, кажется, существует прямое решение с формулой. Но вместо того, чтобы о нем думать, можно просто свести задачу к обратной. Давайте подумаем, как по числу минут \(T\) (ответу) понять, сколько листов напечатается за это время? Очень легко: \[\lfloor\frac{T}{x}\rfloor + \lfloor\frac{T}{y}\rfloor\]

+

Ясно, что за \(0\) минут \(N\) листов распечатать нельзя, а за \(xN\) минут один только первый принтер успеет напечатать \(N\) листов. Поэтому \(0\) и \(xN\) - это подходящие первые границы для бинарного поиска.

+

Примечание: заметьте, что задача в контесте немного отличается! Прочитайте внимательно условие.

+

Задание

+

Решите как можно больше задач в практическом контесте:

+

https://informatics.msk.ru/mod/statements/view.php?id=34097

+

Там могут встречаться задачи как на бинпоиск по ответу, так и на тернарный поиск по ответу.

+ + diff --git a/tg/bridges.html b/tg/bridges.html new file mode 100644 index 0000000..eefc6fd --- /dev/null +++ b/tg/bridges.html @@ -0,0 +1,139 @@ + + + + + + + Мосты и точки сочленения - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Мосты и точки сочленения

+

Время входа и выхода

+

Вспомним обычный поиск в глубину. Он рекурсивно обходит весь граф, и посещает каждую вершину ровно один раз. Посещенные вершины при этом отмечаются в массиве used.

+

Давайте его немного модифицируем, а именно, давайте сохраним в какой-нибудь массив, в каком порядке мы посещали вершины. Есть два способа это сделать - посчитать время входа DFS-а в каждую вершину и время выхода.

+

Давайте назовем массивы \(\space{\rm tin}\) и \({\rm tout}\), где \({\rm tin}\) - время входа, а \(\space{\rm tout}\) - время выхода из вершины.

+

Как их заполнить: давайте заведем таймер, отвечающий за время на текущий момент программы и будем обновлять информацию, когда заходим/выходим из вершины:

+
int timer = 0;
+
+void dfs (int v) {
+    tin[v] = timer++;
+    for (int i = 0; i < g[v].size(); ++i) {
+        if (!used[g[v][i]]) {
+            dfs(g[v][i]);
+    }
+  }
+  tout[v] = timer++;
+}
+

Время входа или выхода пригождается во многих алгоритмах, давайте разберем несколько.

+

Мосты

+

Пусть дан связный неориентированный граф. Мост - ребро, при удалении которой граф становится несвязным.

+

Первым делом введем новые виды ребер :

+
    +
  1. Ребра самого ДФС (tree edge или ребро дерева).

  2. +
  3. Ребра не посещенные в ДФС (back edge или. обратное ребро).

  4. +
+

Пусть есть ребро \(u \rightarrow v\), в каком случае оно является мостом, понятно, что обратное ребро не может быть мостом, потому что мы уже посещали обе вершины и следовательно есть путь по ребрам самого ДФС. Теперь разберемся с ребрами ДФС. Они являются мостом, только если нет пути по обратным ребрам в какого-то из предков \(v\). Так как иначе мы можем удалить это ребро, но при этом останется путь по обратным ребрам.

+

Давайте докажем, что если путь до предка существует, то существует и путь, такой, что нам достаточно пройти по одному обратному ребру. Пусть такого пути не существует, но при этом есть какой-то путь из \(n\) обратных ребер, тогда давайте посмотрим куда могут вести эти ребра, они могут вести либо в поддерево вершины \(u\), либо в нее саму, либо в наддерево, в случае поддерева или самой вершины мы можем их убрать, так как есть путь по ребрам ДФС, в случае наддерева же нам достаточно сохранить только одно ребро в наддерево.

+

Тогда давайте введем \(dp_{i}\) - минимальный \(\space{\rm tin}\) такой вершины, до которой мы можем добраться за какое-то количество ребер ДФС и одно обратное. Как его пересчитывать. Очень просто : у нас есть три возможных значения динамики.

+
    +
  1. \(dp_{i} = min(tin_{i}, dp_{i})\)

  2. +
  3. \(dp_{i} = min(dp_{i}, dp_{son})\), где \(son\) - сын \(i\)-й вершины

  4. +
  5. \(dp_{i} = min(dp_{i}, tin_{back})\), где \(back\) - вершина, для которой есть обратное ребро из \(i\)-й вершины.

  6. +
+

Научимся понимать, какое ребро - ребро ДФС, а какое обратное. Если ребро ведет в уже использованную вершины, то это обратное ребро, иначе ребро ДФС.

+
int timer, tin[MAXN], dp[MAXN];
+
+void dfs(int u, int p) {
+    used[u] = true;
+    tin[u] = dp[v] = timer++;
+    for (auto to : g[v]) {
+        if (to == p) {
+            continue;
+        }
+        if (used[to]) {
+            dp[u] = min(dp[u], tin[to]);
+        }
+        else {
+            dfs(to, u);
+            dp[u] = min(dp[u], dp[to]);
+        }
+        if (dp[to] > tin[u]) {
+            //ребро - мост
+        }
+    }
+}
+

Точки сочленения

+

Точка сочленения - вершина, при удалении которой граф становится несвязным.

+

Первое, что надо заметить, что мост и точка сочленения очень похожи, а что если они и ищутся одинаково. Давайте подумаем, что такое точка сочленения в рамках обратных ребер, это вершина у которой можно добраться до предка используя обратные и ребра ДФС вниз. Но тогда как изменится нахождение точек сочленения, единственное отличие, что у мостов надо было чтобы у предка было время входа строго больше, сейчас же нам достаточно и нестрогого неравенства, так как если из вершины можно добраться до нее самой, то она все равно точка сочленения. Также вылезает один крайний случай, а именно корень, так как в корень мы всегда войдем раньше других вершин, но тут решение очень простое, давайте посмотрим, сколько несвязных поддеревьев у корня.

+
int timer, tin[MAXN], dp[MAXN];
+
+void dfs(int u, int p = -1) {
+    used[u] = 1;
+    tin[u] = dp[u] = timer++;
+    int children = 0;
+    for (auto to : g[u]) {
+        if (to == p)  {
+            continue;
+        }
+        if (used[to]) {
+            dp[u] = min(dp[u], tin[to]);
+        }
+        else {
+            dfs(to, u);
+            dp[u] = min(dp[u], dp[to]);
+        }
+        if (dp[to] >= tin[u] && p != -1) {
+            //v - точка сочленения;
+        }
+        ++children;
+    }
+    if (p == -1 && children > 1) {
+        //u - точка сочленения;
+    }
+}
+ + diff --git a/tg/combinatorics.html b/tg/combinatorics.html new file mode 100644 index 0000000..674a086 --- /dev/null +++ b/tg/combinatorics.html @@ -0,0 +1,89 @@ + + + + + + + Комбинаторика - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Комбинаторика

+

Иногда в задачах требуется подсчитать количество некоторых объектов, количество способов сделать что-то. При этом обычно это количество слишком велико, чтобы можно было перечислить все объекты в явном виде.

+

Например, можно посчитать количество перестановок из \(n\) различных элементов следующим образом. Понятно, что на первое место в перестановке мы можем поставить любой из \(n\) элементов, на второе — любой из \(n - 1\) оставшихся, и так далее. Для последнего места останется только 1 элемент. Значит, число перестановок \(n\) элементов равно \(n\cdot(n-1)\cdot\ldots\cdot2\cdot1\), что сокращенно обозначается как \(n!\)

+

Принято считать, что \(0!=1\)

+

Биномиальные коэффициенты

+

Посчитаем количество способов выбрать \(k\) элементов из \(n\) различных (называется числом сочетаний из \(n\) по \(k\) и обозначается \(C_n^k\)). Точно так же на первое место мы можем поставить любой из \(n\) элементов, на второе — любой из \(n - 1\) оставшихся, на \(k\)-е место останется \(n - k + 1\) вариант, то есть всего вариантов получится \(n\cdot(n-1)\cdot\ldots\cdot(n-k+1)=\frac{n!}{(n-k)!}\). Заметим, что так мы посчитали число упорядоченных наборов из \(k\) элементов и чтобы получить количество способов выбрать \(k\) элементов из \(n\) нужно поделить результат на \(k!\), ведь, как было установлено ранее, именно столько есть спобосов упорядочить \(k\) элементов. В итоге получается \(C_n^k=\frac{n!}{k!(n-k)!}\)

+

Есть и другой способ подсчета \(C_n^k\) для \(0 < k < n\). Посмотрим на первый элемент. Если он входит в набор, то осталось выбрать \(k-1\) из \(n-1\) оставшихся. Иначе нужно выбрать \(k\) из \(n-1\) оставшихся. Значит, \(C_n^k=C_{n-1}^{k}+C_{n-1}^{k-1}\). Так можно найти все \(C_n^k\) до какого-то \(n\) за \(O(n^2)\).

+

Таким образом, \(C_n^k\) легко представлять в виде бесконечного треугольника, где на сторонах стоят 1, а остальные числа равны сумме двух верхних. Легко видеть, что \(C_n^k\) это \(k\)-е число в строке номер \(n\). Это называют треугольником Паскаля.

+
+Треугольник Паскаля +
+

Число путей черепашки

+

Пусть, в левом верхнем углу прямоугольного поля стоит черепашка, которая может ходить только на одну клетку вправо или вниз. Сколько у нее способов попасть в правый нижний угол?

+

Бином Ньютона

+

Рассмотрим разложение выражения \((a+b)^n\). Докажем, что коэффициент при \(a^kb^{n-k}\) равен \(C_n^k\). Действительно \((a+b)^n=(a+b)(a+b)\cdots(a+b)~\)(\(n\) раз). Чтобы получить член \(a^kb^{n-k}\) надо из \(k\) скобок выбрать \(a\), а из остальных \(b\). Как мы уже знаем, это можно сделать \(C_n^k\). Значит, \((a+b)^n=\sum\limits_{k=0}^nC_n^ka^kb^{n-k}\).

+

Из этого следует, что коэффициенты при разложении \((a+b)^n\) являются строкой треугольника Паскаля.

+

Бином Ньютона часто используется для доказательства различных комбинаторных формул.

+

##Полиномиальные коэффициенты

+

Посчитаем теперь количество разбиений множества на \(m\) множеств размеров \(k_1,k_2\ldots,k_m\). Это называется полиномиальные коэффициенты и обозначается \(C(k_1,k_2,\ldots,k_m)\) Будем набирать эти множества по очереди. Количество способов выбрать первое подмножесто равно \(C_n^{k_1}\), второе \(C_{n-k_1}^{k_2}\) и так далее. Итоговая формула \(C(k_1,k_2,\ldots,k_m)=C_n^{k_1}\cdot C_{n-k_1}^{k_2}\cdots C_{k_m}^{k_m}=\frac{n!}{k_1!k_2!\cdots k_m!}\).

+

Аналогично биному Ньютона, такие числа являются коэффициентами при членах \(x_1^{k_1}x_2^{k_2}\cdots x_m^{k_m}\) выражения \((x_1+\ldots+x_m)^n\). Доказательство тоже аналогично доказательству из предыдущего раздела и оставляется в качестве упражнения.

+

Числа Каталана

+

Посчитаем количество правильных скобочных последовательностей (ПСП). Интуитивно понятно, что это, но формально они определятюся так: - Пустая последовательность является ПСП - Если \(A\) является ПСП, то \((A)\) является ПСП - Если \(A\) и \(B\) являются ПСП, то \(AB\) является ПСП.

+

Теперь, посчитаем количество ПСП с \(2n\) скобками. Это называется числами Каталана и обозначается \(C_n\). Рассмотрим первое место, где разность количества открывающих и закрывающих скобок впервые становится равна 0 и часть ПСП до этого места обозначим \((A)\), а часть после обозначим \(B\). Понятно, что \(A\) и \(B\) являются ПСП меньшего размера (возможно, пустыми).

+

Теперь \(C_n\) посчитать легко, перебирая размер \(A\).

+

\(C_0=1\)

+

\(C_n=C_0C_{n-1}+C_1C_{n-2}+\ldots+C_{n-1}C_0\)

+

Полезные формулы

+
    +
  • \(C_n^k=C_n^{n-k}\)
  • +
  • \(C_n^0+C_n^1+C_n^2+\ldots+C_n^n=2^n\)
  • +
  • \(C_n^0-C_n^1+C_n^2-\ldots+(-1)^nC_n^n=0\)
  • +
  • \(C_n=\frac{1}{n+1}C_{2n}^n\)
  • +
+

В качестве упражнения попробуйте их доказать.

+ + diff --git a/tg/debug.html b/tg/debug.html new file mode 100644 index 0000000..0709db6 --- /dev/null +++ b/tg/debug.html @@ -0,0 +1,143 @@ + + + + + + + Как дебажить программу - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Как дебажить программу

+

Введение

+

Обычный алгоритм решения задачи выглядит так:

+
    +
  1. Придумать решение.

  2. +
  3. Написать код решения (или лучше части решения).

  4. +
  5. Тестировать решение на своем компьютере (или лучше потестировать часть решения)

  6. +
+

3+) Если не работает, то дебажить, пока не заработает на всех тестах.

+
    +
  1. Потестировать в тестирующей системе
  2. +
+

4+) Если не работает, то придумывать тест, на котором не работает на своем компьютере. Если не находится - напишите стресс-тестирование

+

4++) Если все работает - внимательно читать код, добавить проверки в опасные места, перечитывать условие задачи, пока не найдете место, где либо вы не поняли задачу, либо ваша программа работает по-разному у вас и на сервере

+

4+++) Спросить преподавателя. Впрочем, этим методом можно и нужно пользоваться на более ранних стадиях, если вы только начинаете заниматься программированием

+

Здесь будут собраны советы о том, что делать в пунктах, начиная с 2.

+

Про первый пункт советов не будет :( Разве что совет, что придумать решение необходимо ДО того, как писать код.

+

Что делать, пока пишешь код

+

Проследите путь каждой переменной и каждого цикла

+

Нужно для каждой переменной понимать * чему она равна изначально * как она меняется в какой части программы * какие значения она может понимать, а какие нет.

+

Также и для каждого цикла нужно понимать * какая будет первая итерация * в какой момент он закончится * сколько всего будет итераций * нет ли где-то выхода за границы массива

+

Пишите по кодстайлу

+
    +
  • ставьте отступы
  • +
  • ставьте пробелы вокруг арифметических знаков
  • +
  • пишите понятные, и главное, мало похожие друг на друга названия переменных
  • +
  • делите программу на логические части (про это подробнее следующий совет)
  • +
  • Изучите подробности общепринятого кодстайл: TODO: ВСТАВИТЬ ССЫЛКУ НА КАКОЙ-НИБУДЬ КОДСТАЙЛ
  • +
+

Зачем? * увидеть ошибку в структурированном коде гораздо проще, чем в каше * чужому человеку и вам в будущем читать ваш непонятный код будет гораздо сложнее и неприятнее * в промышленном программировании именно так все и пишут, полезно привыкнуть

+

Делите код на функции как можно более сильно

+

Почему? * в длинном сплошном коде легко не заметить ошибку * отдельные функции очень просто тестировать * если функция применяется несколько раз, то не нужно копировать код, что хорошо, так как при копировании кода можно скопировать ошибку, увидеть ее, исправить в одном месте, а в другом она * в промышленном программировании именно так все и пишут, полезно привыкнуть

+

Вот пример, когда в программе без функций найти ошибку тяжело: (код Сережи Карпышева)

+
# Задача - найти точку пересечения двух прямых
+
+#include <iostream>
+#include <cmath>
+using namespace std;
+
+int main(){
+    long long x1, y1, x2, y2;
+    cin >> x1 >> y1 >> x2 >> y2;
+    long long x3, y3, x4, y4;
+    cin >> x3 >> y3 >> x4 >> y4;
+    if((x1-x2)*(y4-y3)-(y1-y2)*(x4-x3)==0){
+        if((x1-x3)*(y2-y3)-(y1-y3)*(x2-x3)==0){
+            cout << 2;
+            return 0;
+        }
+        cout << 0;
+        return 0;
+    }
+    cout << 1 << ' ';
+    double a1 = y2 - y1;
+    double b1 = x1 - x2;
+    double c1 = x1*y2-y1*x2;
+    double a2 = y4 - y3;
+    double b2 = x3 - x4;
+    double c2 = x4*y3-y4*x3;
+    double x =(b1*c2-b2*c1)/(b2*a1-b1*a2);
+    cout << x << ' ';
+    cout << (c1-c2+a1*x-a2*x)/(b2-b1);
+
+    return 0;
+}
+

Если ты - не Сережа Карпышев в октябре 2017 года, то этот код тебе отдебажить будет очень тяжело. Что здесь можно сделать для увеличения понятности кода: * Структура Point вместо пары чисел x1, y1 * Cчитывание точки происходит 4 раза, удобно сделать для этого функцию или переопределить оператор >> * Вектор разности двух точек считается где-то 6 раз, удобно сделать для этого функцию * Векторное произведение двух векторо считается 6 раз, удобно сделать для этого функцию * Проверку на параллельность двух векторов удобно вынести в отдельную функцию * Координаты прямой a, b, c удобно вынести в отдельную функцию, считающую их по двум точкам * Возможно даже структура Line вместо тройки чисел a, b, c * Координаты пересечения двух прямых удобно считать через функцию от двух прямых, и работать там только с их координатами

+

Если все это сделать, то код станет понятным, в функции из одной строчки ошибиться очень тяжело. А еще каждую функцию можно легко потестировать отдельно, разобрать крайние случаи для каждой отдельной функции.

+

Как дебажить, если знаешь тест, на котором не работает

+

TODO описать процесс и виды дебага

+

Самые частые ошибки, если у вас все работает, а в тестирующей системе нет

+

Выход за границы массива

+

Если у вас ошибка, особенно на C++ - это почти всегда выход за границы массива. Когда вы обращаетесь на C++ к элементу в массиве/векторе, которого не существует, ваша программа по стандарту C++ может сделать все, что угодно (хоть отформатировать диск, но вряд ли такое будет :D). Такая штука называется undefined behaviour.

+

Обычно при запросе к несуществующему элементу массива просто возвращается число из тех битов, которые лежат на том месте в памяти. У вас на компьютере там может быть 0, а на informatics - какое угодно другое число.

+

Поэтому: * C++ чаще всего не видит эту ошибку и работает дальше. * Ваша программа может работать по-разному у вас и на сервере.

+

Следите за индексами массивов - это то место, в котором вы должны досконально понимать, какие вы берете индексы и какого размера у вас массив, и всегда это проверять.

+

Слишком маленький тип данных

+

Нужно знать, что * int содержит числа примерно от \(-2 * 10^9\) до \(2 * 10^9\) * unsigned int содержит числа примерно от \(0\) до \(4 * 10^9\) * long long содержит числа примерно от \(-9 * 10^18\) до \(9 * 10^18\) * unsigned long long содержит числа примерно от \(0\) до \(18 * 10^18\)

+

И всегда проверять, что когда вы * складываете * умножаете * вычитаете

+

числа, они все еще помещаются в выбранный вами тип.

+

Например, в задаче про быстрое возведение в степень, по условию гарантируется, что \(x, N, P \leq 2 * 10^9.\) Это значит, что в тип int они влезут.

+

НО! В ходе алгоритма вы иногда умножаете два числа. При этом, если вы все делаете правильно, вы работаете всегда только с ОСТАТКАМИ по модулю \(P\), а значит с числами до \(2 * 10^9\). Чтобы ПЕРЕМНОЖИТЬ два таких числа, они должны быть типа long long, иначе произведение двух int-ов будет до \(4 * 10^18\), что в int не влезает, произойдет переполнение. Поэтому в этой задаче обязательно нужно использовать long long, и не забывать брать после каждого действия по модулю.

+

Крайние случаи

+

Если вам дана задача и ограчение \(0 <= N <= 10^6\), и вы убедились, что программа работает верно для каких-нибудь \(3, 5, 20\), то это может значить, что программа работает, но не на всех \(N\). Например, для \(N = 0\) может быть какой-то отдельный случай, когда решение не работает. То же самое и для \(N = 1, 2\). А для \(N = 10^6\) может быть проблема с тем, что она не влезает в память или что-нибудь переполняется (как в пункте 2). Если у вас WA на каком-то тесте, а у вас “на компе все работает”, то начинайте искать тест, на котором и у вас не работает. И в первую очередь смотрите на крайние значения. Если в задаче не одно \(N\), а много сложных входных данных, то придумайте самую стрёмную ситуацию, которую сможете, и проверьте, работает ли ваша программа на ней. Если вам повезет - вы найдете тест, на котором она не работает.

+

Вы решаете не ту задачу

+

Перечитайте условие, возможно, вы просто праивльно решили не ту задачу, которую надо сдать, а немножко другую. Возможно, вы забыли про какой-то важный случай или принципиально делаете что-то не то.

+

Стресс-тестирование

+

Но если вы достаточно много тестировали свою задачу, то вам может помочь стресс-тестирование

+

TODO

+ + diff --git a/tg/dfs-plus.html b/tg/dfs-plus.html new file mode 100644 index 0000000..7d56a8f --- /dev/null +++ b/tg/dfs-plus.html @@ -0,0 +1,194 @@ + + + + + + + Продвинутый DFS - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Продвинутый DFS

+

Время входа и выхода

+

Вспомним обычный поиск в глубину. Он рекурсивно обходит весь граф, и посещает каждую вершину ровно один раз. Посещенные вершины при этом отмечаются в массиве used.

+

Давайте его немного модифицируем, а именно, давайте сохраним в какой-нибудь массив, в каком порядке мы посещали вершины. Есть два способа это сделать - посчитать время входа DFS-а в каждую вершину и время выхода.

+

Давайте назовем массивы \(\space{\rm tin}\) и \({\rm tout}\), где \({\rm tin}\) - время входа, а \(\space{\rm tout}\) - время выхода из вершины.

+

Как их заполнить: давайте заведем таймер, отвечающий за время на текущий момент программы и будем обновлять информацию, когда заходим/выходим из вершины:

+
int timer = 0;
+
+void dfs (int v) {
+    tin[v] = timer++;
+    for (int i = 0; i < g[v].size(); ++i) {
+        if (!used[g[v][i]]) {
+            dfs(g[v][i]);
+    }
+  }
+  tout[v] = timer++;
+}
+

Время входа или выхода пригождается во многих алгоритмах, давайте разберем несколько.

+

Топологическая сортировка

+

Задача о топологической сортировке графа звучит так:

+
+

Дан ориентированный граф, нужно расставить его вершины в массиве так, чтобы все ребра шли вправо по массиву.

+
+

Эта же задача очень часто встречается в реальной жизни. Например, у вас есть много зависимостей вида “в библиотеке А используется библиотека B”, и вам нужно загрузить все библиотеки в таком порядке, чтобы никогда не загружать A до B.

+

Или, например, у вас есть список зависимых дел - “чтобы сделать А, надо сначала сделать B”, и вам нужно понять, в каком порядке делать все дела.

+

Во-первых, сразу заметим, что граф с циклом топологически отсортировать не получится - как ни располагай цикл в массиве, все время идти вправо по ребрам цикла не получится, потому что в итоге должен оказаться в самой левой вершине цикла.

+

Во-вторых, верно обратное! Если цикла нет, то его обязательно можно топологически отсортировать. Попытаемся придумать как, заодно придумаем алгоритм.

+

Заметим, что вершину, из которой не ведет ни одно ребро, можно всегда поставить последней. И такая вершина всегда есть в графе без циклов (если всегда есть внешнее ребро, то есть цикл). Из этого сразу следует доказательство: просто будем класть в массив вершину, из которой ничего не ведет и убирать ее из графа. Массив потом надо будет развернуть.

+

Алгоритм же сразу получается, если внимательно посмотреть на время выхода вершин. Вершина, из которой мы вышли в DFS-е первой - как раз та, из которой ничего не выходит. Ведь если из нее есть ребро, то только в уже посещенную, но мы не могли выйти из нее, ведь мы берем самую первую из которой мы вышли. Значит, это одна из серых вершин, в котороые мы вошли, и не вышли, и это цикл.

+

Из этого следует, что достаточно просто брать вершины в порядке выхода из DFS, то есть в конце DFS, например, просто класть эту вершину в конец массива с ответом. Ну и этот массив надо удет перевернуть, чтобы все ребра шли вправо, а не влево.

+

Компоненты сильной связности

+

Мы только что научились топологически сортировать ациклические графы. А что же делать с циклическими графами? Ведь в них тоже хочется найти какую-то структуру.

+

Для этого обычно вводят такое понятие как “сильная связность”. В ориентированных графах две вершины связаны сильно, если существует путь из одной в другую и наоборот. Проще говоря, они обе лежат на каком-то цикле.

+

Понятно, что такое отношение транзитивно: если А и B сильно связны, и B и C сильно связны, то A и C тоже сильно связны. Поэтому все вершины распадаются на такие компоненты сильной связности - такое разбиение вершин, что внутри одной компоненты все вершины сильно связаны, а между вершинами разных компонент сильно связности нет.

+
+Example +
+

Самый простой пример сильно-связной компоненты - это цикл. Но это может быть и полный граф, и любое сложное пересечение нескольких циклов.

+

Часто рассматривают граф самих компонент сильной связности. На катинке выше их шесть, и между ними остаются ребра из изначально графа. Очевидно, что такой граф уже удет ациклических: иначе компоненты на цикле нужно было бы объединить в одну.

+

Конденсация графа

+

Задача о конденсации графа звучит так:

+
+

Дан ориентированный граф \(G\), множество вершин которого \(V\) и множество рёбер — \(E\). Конденсацией назовем сжатие каждой компоненты сильной связности в одну вершину. Каждой вершине графа конденсации соответствует компонента сильной связности графа \(G\), а ориентированное ребро между двумя вершинами \(C_i\) и \(C_j\) графа конденсации проводится, если найдётся пара вершин \(u \in C_i, v \in C_j\), между которыми существовало ребро в исходном графе, т.е. \((u,v) \in E\).

+
+
+

Необходимо найти, какие вершины лежат в каждой компоненте сильной связности и построить граф конденсации.

+
+

Теорема. Запустим DFS. Пусть \(C\) и \(C^\prime\) — две различные компоненты сильной связности, и пусть в графе конденсации между ними есть ребро \((C,C^\prime)\). Тогда \(\max\limits_{c\in C}(\space{\rm tout}[c]) > \max\limits_{c^\prime\in C^\prime}({\rm tout}[c^\prime])\).

+

При доказательстве возникают два принципиально различных случая в зависимости от того, в какую из компонент первой зайдёт обход в глубину$:

+

Первой была достигнута компонента \(C\). Это означает, что в какой-то момент времени обход в глубину заходит в некоторую вершину \(v\) компоненты \(C\), при этом все остальные вершины компонент \(C\) и \(C^\prime\) ещё не посещены. Но, т.к. по условию в графе конденсаций есть ребро \((C,C^\prime)\), то из вершины \(v\) будет достижима не только вся компонента \(C\), но и вся компонента \(C^\prime\). Это означает, что при запуске из вершины \(v\) обход в глубину пройдёт по всем вершинам компонент \(C\) и \(C^\prime\), а, значит, они станут потомками по отношению к \(v\) в дереве обхода в глубину, т.е. для любой вершины \(u \in C \cup C^\prime, u \ne v\) будет выполнено \({\rm tout}[v] > {\rm tout}[u]\), ч.т.д.

+

Обратный случай рассматривается проще, из \(C^\prime\) нельзя добраться до \(C\), а следовательно доказано.

+

Из этого и следует первая часть решения - давайте отсортируем вершины по убыванию времени выхода (будто топсорт, но на циклическом графе). Рассмотрим компоненту сильной связности первой вершины, назовем ее \(C^\prime\). В эту компоненту точно нет никаких рёбер из других компонент (иначе \(\max\limits_{c\in C}(\space{\rm tout}[c]) > \max\limits_{c^\prime\in C^\prime}({\rm tout}[c^\prime])\), а первая вершина - это вообще-то максимум времени выхода). Поэтому если мы развернем все ребра, то из этой вершины все еще будет достижима своя компонента сильной связности \(C^\prime\), и больше точно ничего - если раньше не было ребер из других компонент, то после разворота ребер не стало ребер в другие компоненты.

+

Так что второй шаг такой - разворачиваем ребра, запускаем DFS в таком порядке, ищем компоненты связности как в обычном графе. Попутно можно строить и граф конденсации.

+
vector<int> g[N], gr[N];
+bool used[N];
+vector<int> order, component;
+ 
+void dfs1(int v) {
+    used[v] = true;
+    for (int i = 0; i < g[v].size(); i++) {
+        if (!used[g[v][i]]) {
+            dfs1(g[v][i]);
+        }
+    }
+    order.push_back (v);
+}
+ 
+void dfs2(int v) {
+    used[v] = true;
+    component.push_back(v);
+    for (int i = 0; i < gr[v].size(); i++) {
+        if (!used[gr[v][i]]) {
+            dfs2(gr[v][i]);
+        }
+    }
+}
+ 
+int main() {
+    for (;;) {
+        g[a].push_back(b);
+        gr[b].push_back(a);
+    }
+    for (int i = 0; i < n; i++){
+        if (!used[i]) {
+            dfs1(i);
+        }
+    }
+    for (int i = 0; i < n; i++) {
+        used[i] = 0;
+    }
+    reverse(order.begin(), order.end());
+    for (int i = 0; i < n; i++) {
+        int v = order[i];
+        if (!used[v]) {
+            dfs2 (v);
+        }
+    }
+}
+

Еще раз вкратце:

+
    +
  1. Сортируем вершины в порядке убывания времени выхода.

  2. +
  3. Проходимся по массиву вершин в том порядке на равернутом графе и запускаем на нем DFS, выделяя компоненты сильной связности.

  4. +
+

2-SAT

+

Вспомним, что такое конъюнкция (логическое И, обозначается знаком * или \(\vee\) или &.) и дизъюнкция (логическое ИЛИ, обозначается знаком + или \(\wedge\) или |), конъюкция возвращает 1 тогда и только тогда, когда обе переменные - 1, а дизъюнкция, возвращает 0, когда обе - 0.

+

Давайте теперь возьмем конъюнкцию дизъюнктов, а именно И от ИЛИ от переменных (или НЕ переменных). Например, такое выражение:

+
(a | b) & (!c | d) & (!a | !b) // (А ИЛИ B) И (НЕ C ИЛИ D) И (НЕ A ИЛИ НЕ B)
+

Задача SAT заключается в том, чтобы найти такие значения переменных, при которых это выражение становится истинным, ии сказать, что таких нет. Заметьте, что у нас в каждой скобке в этом примере ровно две переменные, в таком случае задача называется 2-SAT, и именно ее мы и хотим решить. Для примера выше решением является \(a=1, b=0, c=0, d=1\) (убедитесь, что все скобки стали True).

+

Причем тут графы? А вот часто в разных математических задачах надо внезапно ввести граф и он тут же помогает решить задачу.

+

Приведем сначала выоажение к другому виду - импликативному. Заметим, что выражение вида a | b эквивалентно \(!a \rightarrow b\) или\(!b \rightarrow a\).

+

Построим граф импликаций: для каждой переменной в графе будет по две вершины, обозначим их через \(x_{i}\) и \(!x_{i}\). Рёбра в графе будут соответствовать импликациям.

+

Теперь заметим, что если для какой-то переменной \(x_{i}\) выполняется, что из \(x_{i}\) достижимо \(!x_{i}\), а из \(!x_{i}\) достижимо \(x_{i}\), то задача решения не имеет. Действительно, какое бы значение для переменной \(x_{i}\) мы бы ни выбрали, мы всегда придём к противоречию - что должно быть выбрано и обратное ему значение. Оказывается, что это условие является не только достаточным, но и необходимым (доказательством этого факта будет описанный ниже алгоритм). Переформулируем данный критерий в терминах теории графов. Напомним, что если из одной вершины достижима вторая и наоборот, то эти две вершины находятся в одной компоненте сильной связности. Тогда мы можем сформулировать критерий существования решения следующим образом:

+

Для того, чтобы данная задача 2-SAT имела решение, необходимо и достаточно, чтобы для любой переменной \(x_{i}\) вершины \(x_{i}\) и \(!x_{i}\) находились в разных компонентах сильной связности графа импликаций.

+

Пусть решение существует и нам надо его найти.

+

Заметим, что, несмотря на то, что решение существует, для некоторых переменных может выполняться, что из \(x\) достижимо \(!x\) или из \(!x\) достижимо \(x\). В таком случае выбор одного из значений переменной \(x\) будет приводить к противоречию, в то время как выбор другого - не будет. Научимся выбирать из двух значений то, которое не приводит к возникновению противоречий. Сразу заметим, что, выбрав какое-либо значение, мы должны запустить из него обход в глубину/ширину и пометить все значения, которые следуют из него, т.е. достижимы в графе импликаций. Соответственно, для уже помеченных вершин никакого выбора между \(x\) и \(!x\) делать не нужно, для них значение уже выбрано и зафиксировано. Нижеописанное правило применяется только к непомеченным ещё вершинам.

+

Утверждается следующее. Пусть \(\space{\rm comp}[V]\) обозначает номер компоненты сильной связности, которой принадлежит вершина \(V\), причём номера упорядочены в порядке топологической сортировки компонент сильной связности в графе компонентов (т.е. более ранним в порядке топологической сортировки соответствуют большие номера: если есть путь из \(v\) в \(w\), то \(\space{\rm comp}[v] \leq \space{\rm comp}[w]\)). Тогда, если \(\space{\rm comp}[x] < \space{\rm comp}[!x]\), то выбираем значение !x, иначе выбираем x.

+

Докажем, что при таком выборе значений мы не придём к противоречию. Пусть, для определённости, выбрана вершина \(x\) (случай, когда выбрана вершина \(!x\), доказывается также).

+

Во-первых, докажем, что из \(x\) не достижимо \(!x\). Действительно, так как номер компоненты сильной связности \(\space{\rm comp}[x]\) больше номера компоненты \(\space{\rm comp}[!x]\) , то это означает, что компонента связности, содержащая \(x\), расположена левее компоненты связности, содержащей \(!x\), и из первой никак не может быть достижима последняя.

+

Во-вторых, докажем, что из любой вершины \(y\), достижимой из \(x\) недостижима \(!y\). Докажем это от противного. Пусть из \(x\) достижимо \(y\), а из \(y\) достижимо \(!y\). Так как из \(x\) достижимо \(y\), то, по свойству графа импликаций, из \(!y\) будет достижимо \(!x\). Но, по предположению, из \(y\) достижимо \(!y\). Тогда мы получаем, что из \(x\) достижимо \(!x\), что противоречит условию, что и требовалось доказать.

+

Итак, мы построили алгоритм, который находит искомые значения переменных в предположении, что для любой переменной \(x\) вершины \(x\) и \(!x\) находятся в разных компонентах сильной связности. Выше показали корректность этого алгоритма. Следовательно, мы одновременно доказали указанный выше критерий существования решения.

+

Проверка на двудольность

+

Корректной раскраской графа в два цвета назывется такая раскраска, что никакое ребро не соединяет две вершины одного цвета. Графы, которые можно так раскрасить, называют еще двудольными.

+

Пусть первая вершина графа принадлежит первой доли, тогда все вершины, для которых есть ребро в первую, должны принадлежить второй и так далее, но тогда заметим, что если в какой-то момент мы хотем покрасить уже покращенную вершину не в ее цвет, то граф не двудольный, проверить это можно например с помощью дфса : запустим дфс от каждой еще не посешенной вершины.

+
int n;
+vector<int> g[N];
+
+vector<bool> part(n, -1);
+void dfs(int u, int color) {
+    if (part[u] != color && part[u] != -1) {
+        cout << "NO";
+        exit(0);
+    }
+    if (part[u] == color) {
+        return;
+    }
+    for (int i = 0; i < g.size(); i++) {
+        dfs(g[u][i], 1 - color);
+    }
+}
+ + diff --git a/tg/dfs.html b/tg/dfs.html new file mode 100644 index 0000000..e88e969 --- /dev/null +++ b/tg/dfs.html @@ -0,0 +1,210 @@ + + + + + + + Графы — определения, деревья, хранение и поиск в глубину - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Графы — определения, деревья, хранение и поиск в глубину

+

Основные определения

+

Формальное определение:

+

Графом \(G\) называется пара множеств \(G = (V, E\), где \(V(G)\) — непустое конечное множество элементов, называемых вершинами графа, а \(E\) — множество пар элементов из \(V\) (необязательно различных), называемых ребрами графа. \(E = \{(u , v)\ | u, v \in V\}\) — множество ребер графа \(G\), состоящее из пар вершин \((u, v)\). Ребро \((u, v)\) соединяет вершины \(u\) и \(v\).

+

Простое определение:

+

Граф - это набор вершин (точек) и соединяющих их отрезков (рёбер).

+
+Примеры графа +
+

Две вершины, соединенные ребром, называют смежными вершинами. Обычно в задачах \(N\) - количество вершин, а \(M\) - ребер. Количество ребер, исходящее из вершины называют степенью вершины \(d(v)\). Для вершины \(a\) ребро \((a, b)\) называется инцидентным ей. На рисунке ниже вершине 8 инцидентно только ребро (4, 8), а вершине 10 ребра (2, 10) и (5, 10).

+

Теоретическое задание

+

Назовите степень 1-ой и 6-ой вершины и какие ребра инциденты им.

+
+1 +
+

Если какие-то две вершины соединены более, чем одним ребром, то говорят, что граф содержит кратные ребра. Если ребро соединяет вершину саму с собой, то такое ребро называют петлей.

+

Простой граф не содержит петель и кратных ребер. Если не сказано ничего про наличие петель и кратных ребер, мы будем всегда считать, что граф простой.

+

Теоретическое задание

+

Сколько может быть рёбер в простом графе в \(N\) вершинами?

+
+2 +
+

Теоретическое задание

+

Найдите цикл размера 4 и петлю в этом непростом графе.

+

Также часто рассматривают ориентированные графы — это графы, у которых ребра имеют направление, а иначе граф – неориентированный.

+
+

Хранение графа в программе

+

Чаще всего в задачах по программмированию вершины графа - это числа от \(0\) до \(N-1\), чтобы удобно было обращаться к ним как к индексам в разных массивах.

+

Также чаще всего вам дают считать граф как просто список всех рёбер в нем (но не всегда, конечно). Как оптимально считать и сохранить граф? Есть 3 способа.

+

Для графа существуют несколько основных способов хранения:

+
    +
  1. Матрица смежности. Давайте хранить двумерную матрицу \(A_{nn}\), где для данного графа G верно, что если \(A_{ij}\) = 1, то две вершины \(i\) и \(j\) являются смежными, иначе вершины \(i\) и \(j\) смежными не являются.
  2. +
+
+Граф G и Матрица смежности +
+

Мы храним для каждой из \(N\) вершин информацию, есть ли ребро в другие вершины, то есть суммарно мы храним \(N^2\) ячеек, а следовательно асимптотика по памяти - \(O(N^2)\).

+
    +
  1. Список смежности. Давайте для каждой из \(N\) вершин хранить все смежные с ней, для этого нам потребуется любая динамическая структура, например vector в с++.
  2. +
+
// как сделать список по матрице
+vector<vector<int> > g(n);
+for (int i = 0; i < n; ++i){
+    for (int j = 0;j < n;++j){
+        cin >> a;
+        if (a) g[i].push_back(j);
+    }
+}
+// список по списку ребер
+cin >> n >> m;
+vector<vector<int> > g(n);
+for (int i = 0; i < m; ++i){
+  cin >> v1 >> v2;
+  g[v1].push_back(v2);
+  g[v2].push_back(v1);
+}
+

Здесь асимптотика по памяти и времени считывания - \(O(N + M)\), так как мы храним для каждой вершины, куда есть ребра, то есть \(2 M\) ребер, а также суммарно \(N\) векторов.

+

Плотные графы, имеющие большое количество ребер следует хранить при помощи матрицы смежности, а разреженные графы, имеющие малое количество ребер, оптимальнее при помощи списка.

+
    +
  1. Список рёбер. Иногда граф явно вообще не требуется, а хватает хранить просто список ребер, который нам дают на вход.
  2. +
+

Заметьте, что все эти способы обощаются на случай ориентированных графов - при этом матрица смежности становится неориетированной: если есть ребро из вершины \(i\) в вершину \(j\), то сделаем \(A_{ij} = 1\), а \(A_{ji} = 0\), если только нет обратного ребра тоже. А в списке смежности в ориентированном случае при считывании ребра \((u, v)\) будем добавлять только \(v\) в список соседей \(u\), но не наоборот.

+

Практическое задание

+

Для окончательного закрепления темы советую решить первые 2 задачи.

+

Деревья

+

Дерево - это связный неориентированный граф без циклов.

+
+Пример дерева +
+

Свойства дерева:

+
    +
  1. У дерева с хотя бы 2 вершинами всегда есть висячая вершина - вершина степени 1.
  2. +
+

Действительно, если начать из любой вершины идти по непосещенным ранее вершинам, то в какой-то момент мы прекратим это делать, ведь граф конечный. При этом если из этой вершины не может быть ребер в непосещенные вершины - ведь тогда прекращать рано, и не может быть ребер в посещенные ребра (помимо предыдущей) - ведь тогда есть цикл. А значит, есть ребро только в предыдущую вершину, значит степень равна 1.

+
    +
  1. У дерева с хотя бы 2 вершинами всегда есть две висячие вершины.
  2. +
+

Действительно, если предыдущий алгоритм начать из висячей вершины, то мы уткнемся в другую висячую вершину.

+
    +
  1. У дерева с \(N\) вершинами всегда ровно \(N-1\) ребро.
  2. +
+

Давайте отрезать от дерева его висячие вершины - при этом число вершин уменьшится на один, число ребер тоже уменьшится на один, а граф останется деревом. Раз граф остается деревом, у него все время будет висячая вершина, пока \(N > 1\). В какой-то момент останется только одна вершина и ноль ребер. Раз мы отрезали столько же вершин, сколько ребер, и получили 1 вершину и 0 ребер, значит изначально вершин было ровно на одну больше.

+
    +
  1. Между любыми двумя вершинами в дереве есть ровно один простой путь.
  2. +
+

Действительно, если их два, то в графе есть цикл. Быть ноль их не может - ведь граф связный.

+
    +
  1. Дерево - это минимальный по числу рёбер связный граф на \(N\) вершинах.
  2. +
+

Действительно, если есть связный граф, в котором меньше, чем \(N-1\) ребро, то давайте уберем из его цикла ребро. Граф при этом остается связным, а число ребер уменьшается. Давайте повторять это, пока в какой-то момент циклов в графе не будет, а значит осталось дерево. Но мы уже доказали, что в дереве \(N-1\) ребро, это противоречие, ведь у нас сначала было меньше ребер, а мы еще и удалили сколько-то.

+

DFS (Алгоритм обхода графа в глубину)

+

Обход в глубину — простой, но многофункциональный алгоритм обхода графа по ребрам. Самое главное, что он может — это проверить, какие вершины достижимы из данной.

+

При обходе графа мы используем вспомогательный массив used, в котором храним 1, если вершина была посещена или 0 иначе. В начале мы считаем, что все вершины не использовались, затем мы выбираем одну вершину, помечаем ее посещенной и запускаемся рекурсивно из всех ее соседей, тогда мы посетим все вершины, которые достижимы из данной, если же остались вершины с used = 0 значит они недостижимы.

+

Красивая визуализация: https://visualgo.net/en/dfsbfs

+
void dfs (int v) {
+  used[v] = 1;
+  for (auto to : g[v]) {
+      if (!used[to]) {
+          dfs(to);
+      }
+  }
+}
+

Давайте оценим сложность алгоритма. Так как мы проверяем, что вершина еще не использовалась, то всего мы пройдет каждую вершину 1 раз, но при этом и ребро между двумя вершинами, мы рассматриваем только когда рассматривается один конец, то есть мы просмотрим каждое ребро не более одного раза, суммарно получаем оценку \(O(N + M)\).

+

Практическое задание

+

Задачи 3-5 в контесте.

+

Поиск компонент связности графа

+

Путем в графе называется последовательность вершин \(v_i \in 𝑉\), \(i = 1...k\) таких, что две последовательные вершины в пути соединены ребром, \(k\) - длина пути. Граф называется связным, если для любых двух его вершин существует путь между ними. Граф всегда можно разбить на непересекающиеся связные подмножества (возможно одно), между которыми рёбер нет, они называются компонентами связности.

+

Поиск в глубину dfs будет обходить ту компоненту связности, из вершины которой, он был вызван. Поэтому для поиска компонент связности можно каждый раз вызываться из любой непосещенной вершины и тогда в результате мы посетим все вершины, а следовательно и найдем все компоненты связности.

+

Граф с тремя компонентами связности

+
for (int i = 0; i < n; ++i){
+    if (!used[i]) {
+        amount++;
+        dfs(i);
+    }
+}
+

Практическое задание

+

На данную тему задачи 6 и 10 в контесте.

+

Остовное дерево

+

Остованым деревом в связном графе называется любое подмножество ребер, которое является деревом на всех вершинах. То есть любой способ выкинуть несколько ребер так, чтобы осталось дерево на N вершинах и N-1 ребро выделяет в графе остовное дерево.

+

Обход графа удобно использовать для выделения этого остовного дерева - если выделить каждое ребро, по которому мы прошли в обходе, то получится остовное дерево. Действительно, мы обойдем все вершины, и при этом никогда не пойдем в вершину, в которой уже были, поэтому циклов там не будет. Так что достаточно после прохода по любому ребру добавлять его в ответ.

+

Практическое задание

+

7 задача в контесте на выделение остовного дерева в графе.

+

Раскраска графа в два цвета

+

Корректной раскраской графа в два цвета назывется такая раскраска, что никакое ребро не соединяет две вершины одного цвета. Графы, которые можно так раскрасить, называют еще двудольными.

+

С помощью обхода графа легко проверить граф на двудольность и даже вывести цвет каждой вершины - достаточно выделить каждую.

+

Практическое задание

+

8 задача в контесте на раскраску графа в два цвета

+

Поиск циклов в графе

+

Циклом в графе \(G\) называется ненулевой путь, ведущий из вершины \(v\) в саму себя. Граф называют ацикличным, если в нем нет циклов.

+

В обычном dfs мы используем два цвета (1 - вершина посещена, 0 - не посещена), если же нам надо найти цикл, то давайте хранить 3 цвета:

+
    +
  • 0 - вершина не просмотрена
  • +
  • 1 - мы входили DFS-ом в эту вершину, но еще не вышли (а значит из нее есть путь до текущей),
  • +
  • 2 - мы входили DFS-ом в эту вершину
  • +
+

Заметим, что цикл будет тогда и только тогда, когда мы пытаемся войти в вершину с цветом 1.

+
void dfs (int v) {
+    used[v] = 1;
+    for (size_t i=0; i < g[v].size(); ++i) {
+        int to = g[v][i];
+        if (used[to] == 0){
+            p[to] = v;
+            dfs (to);
+        }
+        else if (used[to] == 1 && to != p[v]) {
+            cycle = true;
+        }
+    }
+    used[v] = 2;
+}
+

В неориентированном графе также надо дополнительно рассмотреть случай, когда мы идем в предка - это циклом все-таки не считается, для этого нужно отдельно добавить второй аргумент prev, где хранить предыдущую вершину в dfs, и никогда не идти в неё.

+

Практическое задание

+

9 задача в контесте на поиск цикла в графе.

+

#Ссылка на контест

+

https://informatics.msk.ru/mod/statements/view3.php?id=33377

+ + diff --git a/tg/dp-basics.html b/tg/dp-basics.html new file mode 100644 index 0000000..5539cba --- /dev/null +++ b/tg/dp-basics.html @@ -0,0 +1,531 @@ + + + + + + + Динамическое программирование — базовые понятия - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Динамическое программирование — базовые понятия

+
    +
  • Общие принципы
  • +
  • Одномерная динамика: кузнечик
  • +
  • Двумерная динамика: черепашка
  • +
  • Восстановление ответа в динамике
  • +
+

Общие принципы

+

Рассмотрим такую задачу: найти N-е число Фибоначчи.

+

Числа Фибоначчи определяются так: \(F_0 = 0, F_1 = 1, F_n = F_{n-2} + F_{n-1}\).

+

Эту задачу можно решить рекурсивно:

+
def fibonacchi(n):
+    if n == 0:
+        return 0
+    if n == 1:
+        return 1
+    return fibonacchi(n - 2) + fibonacchi(n - 1)
+
+print fibonacchi(20)
+
6765
+

Однако это будет работать очень долго. 20-е число посчитать еще можно будет, а 40-е число - нет. И не потому, что числа большие. А потому, что мы будем делать слишком много лишней работы. Число операций будет экспоненциально относительно \(N\).

+

Почему? Потому что, чтобы посчитать N-е число, нам нужно будет независимо посчитать (N-1)-е число и (N-2)-е число, и это в минимум в два раза больше действий, чем нужно для (N-2). А значит, для подсчета N-го числа Фибоначчи необходимо 2 раза посчитать (N-2)-е число, и это занимает в два раза больше времени, а значит это хотя бы \(2^\frac{N}{2}\) действий.

+

Это ну слишком долго, и главное, что это легко исправляется. Давайте просто не считать лишних действий - если мы один раз посчитали \(F_k\), то давайте запомним, чему оно равно, и в следующий раз, когда оно нам понадобится, мы используем его сразу. Удобнее всего сохранить числа Фибоначчи прямо в массиве:

+
N = 20
+
+fib = [None] * (N + 1) # создали массив из None от 0 до N включительно
+fib[0] = 0 
+fib[1] = 1 # заполнили изначальные позиции
+for i in range(2, N + 1): # обходим массив по порядку слева направо
+    fib[i] = fib[i - 2] + fib[i - 1] # формула считает новое число через предыдущие!
+
+print fib[N] # ответ лежит на N-ом месте
+
6765
+

И этот способ легко работает для больших \(N\), так как работает за \(O(N)\) - всего один проход по массиву (правда здесь ответ мы будем считать по модулю \(10^9\), потому что иначе числа получатся слишком слишком большими):

+
N = 200000
+
+fib = [0] * (N + 1)
+fib[1] = 1
+for i in range(2, N + 1):
+    fib[i] = (fib[i - 2] + fib[i - 1]) % (10 ** 9)
+
+print fib[N]
+
175853125
+

Это и называется динамическим программированием (или динамикой, ДП). Основная идея состоит в том, чтобы * свести задачу для \(N\) к задаче для чисел, меньших, чем \(N\) (с помощью формулы) * хранить все ответы в массиве * заполнить начало массива вручную (для которых формула не работает) * обойти массив и заполнить ответы по формуле * вывести ответ откуда-то из этого массива

+

Чтобы решить задачу по динамике вы должны ответить на 5 вопросов: * Что лежит в массиве? (самый важный вопрос чаще всего) * Как инициализировать начало массива? * Как обходить массив? (чаще всего слева направо, но не всегда) * Какой формулой считать элементы массива? * Где в массиве лежит ответ?

+

Одномерная динамика: кузнечик

+

Рассмотрим такую задачу:

+

Есть полоска \(1\times N\), кузнечик стоит на первой клетке, он может прыгать вперед на 1, 2, 3 клетки. Сколько есть способов добраться от начальной клетки до последней?

+

Как решать такие задачи? Нужно придумать рекуррентную формулу, как ответ для N зависит от ответа для меньших чисел.

+

Очень помогает посмотреть на маленькие числа (!! одна из самых важных идей для придумывания решений):

+

Пусть dp[x] - это количество способов добраться от 1 клетки до клетки номер x.

+
    +
  • dp[1] = 1 способ (стоять на месте)
  • +
  • dp[2] = 1 способ (\(1 \rightarrow 2\))
  • +
  • dp[3] = 2 способа (\(1 \rightarrow 2 \rightarrow 3\) и \(1 \rightarrow 3\))
  • +
  • dp[4] = 4 способа (\(1 \rightarrow 2 \rightarrow 3 \rightarrow 4\) и \(1 \rightarrow 3 \rightarrow 4\) и \(1 \rightarrow 2 \rightarrow 4\) и \(1 \rightarrow 4\))
  • +
  • dp[5] = 7 способов (\(1 \rightarrow 2 \rightarrow 3 \rightarrow 4 \rightarrow 5\) и \(1 \rightarrow 3 \rightarrow 4 \rightarrow 5\) и \(1 \rightarrow 2 \rightarrow 4 \rightarrow 5\) и \(1 \rightarrow 4 \rightarrow 5\) и \(1 \rightarrow 2 \rightarrow 3 \rightarrow 5\) и \(1 \rightarrow 3 \rightarrow 5\) и \(1 \rightarrow 2 \rightarrow 5\))
  • +
+

Дальше становится сложнее. Но можно заметить закономерность. А можно и не заметить, но зато если мы сейчас придумаем формулу, мы легко проверим, работает ли она. Заодно мы получили наши значения на маленьких числах, которые нам все равно понадобится вбить в программу.

+

Какой последний прыжок кузнечика в его пути до N-й клетки? Один из трех вариантов: * \((N - 1) \rightarrow N\) * \((N - 2) \rightarrow N\) * \((N - 3) \rightarrow N\)

+

То есть все пути до \(N\) разбиваются на 3 группы. Причем мы знаем сколько путей в каждой группе. В первой из них ровно dp[N - 1] путей - столько путей идут до (N-1)-й клетки, и дальше идет еще один прыжок. Во второй и третьей группах поэтому тоже dp[N - 2] и dp[N-3] путей.

+

Так что формула получается такой: dp[N] = dp[N - 3] + dp[N - 2] + dp[N - 1].

+

Очень похоже на числа Фибоначчи, да? Можете посмотреть на числа, которые мы уже выписали, там все отлично подходит:

+

dp[4] = 4 = 1 + 1 + 2 = dp[1] + dp[2] + dp[3]

+

dp[5] = 7 = 1 + 2 + 4 = dp[2] + dp[3] + dp[4].

+

Так что программа пишется легко:

+
N = 20
+dp = [0] * (N + 1)
+dp[1] = 1
+dp[2] = 1
+dp[3] = 2
+for i in range(4, N + 1):
+    dp[i] = dp[i - 3] + dp[i - 2] + dp[i - 1]
+print(dp)
+
[0, 1, 1, 2, 4, 7, 13, 24, 44, 81, 149, 274, 504, 927, 1705, 3136, 5768, 10609, 19513, 35890, 66012]
+

Давайте изменим немного задачу: Теперь некоторые из клеток закрыты. То есть нам известно про конкретные клетки, что на них кузнечик прыгать не может. Тогда задача все еще решается так же, только нужно убедиться, что dp[x] = 0 для всех запрещенных x!

+

Также немного перепишем код, чтобы не писать отдельно случаи для 2 и 3, а также чтобы не писать в формуле сумму трех чисел (а представьте, что в задаче не 3, а 100). Будем инициализировать только dp[1]. А ко всем следующим значениям dp[i] будет прибавлять dp[i - k], где k = 1, 2, 3. Причем, если i - k < 1, то мы будем игнорировать такие клетки, и этим самым мы избавились от необходимости прописывать ответ для dp[2] и dp[3].

+
N = 20
+BAD_CELLS = [2, 3, 6, 13]
+
+dp = [0] * (N + 1)
+is_bad = [0] * (N + 1)
+for bad in BAD_CELLS:
+    is_bad[bad] = 1
+print(is_bad)
+
[0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
+
if not is_bad[1]: 
+    dp[1] = 1
+for i in range(2, N + 1):
+    if not is_bad[i]:
+        for k in range(1, 4):
+            if i - k >= 1:
+                dp[i] += dp[i - k]
+print(dp)
+
[0, 1, 0, 0, 1, 1, 0, 2, 3, 5, 10, 18, 33, 0, 51, 84, 135, 270, 489, 894, 1653]
+

Двумерная динамика: черепашка

+

Теперь рассмотрим такую задачу:

+

На каждой клетке двумерной таблички написано, сколько там лежит монет. Черепашка стоит в клетке 1x1 (верхней левой), и может двигаться только на одну клетку вниз, или на одну клетку вправо. Нужно найти максимальное число монет, которое может набрать черепашка по пути к нижней правой клетке NxM.

+

Первое, что приходит в голову - это просто идти черепашкой в ту клетку из соседних, где лежит больше монет. К сожалению, эта жадная стратегия не всегда работает. Например, на такой доске жадная черепашка пошла бы по следу из единичек, хотя гораздо выгоднее пойти сначала по нулям, а потом найти там большие горстки монет (40, 70, 100):

+
COINS = [
+    [0,   1,   1,   1,   1,   1],
+    [0,   0,   0,   0,   0,   1],
+    [0,   40,  70,  0,   0,   1],
+    [100, 0,   0,   0,   0,   1]
+]
+N = 4
+M = 6
+

Тут нас снова спасает динамика. Давайте сводить задачу к предыдущей! Задачей назовем “сколько максимально монет можно набрать на пути от \(0\times0\) до \(i\times j\)” (заменим 1-нумерацию на 0-нумерацию). Будем хранить это в двумерном массиве dp в клетке dp[i][j].

+

Сразу понятны некоторые свойства этого массива: * Он размера NxM * dp[0][0] = COINS[0][0] * ответ на всю задачу лежит в dp[N - 1][M - 1]

+

Но гораздо важнее придумать формулу для подсчета dp[i][j] через предыдущие. Легко посчитать первую строку и первый столбец: * dp[0][k] = dp[0][k - 1] + COINS[0][k] * dp[k][0] = dp[k - 1][0] + COINS[k][0]

+

Так как до этих клеток есть ровно один путь.

+

Но что делать, если есть много путей до клетки dp[i][j]? Снова разобьем их на на несколько групп в зависимости от последнего хода (! важный трюк, запомните). Последний ход был: * либо из [i][j - 1] * либо из [i - 1][j]

+

Поэтому формула для максимального числа монет такая: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + COINS[i][j].

+

Ну все, достаточно пройтись правильно по двумерному массиву (построчно сверху вних, а в каждой строке слева направо) и заполнить этот массив.

+
dp = [[None] * M for i in range(N)]
+
+for i in range(N):
+    for j in range(M):
+        if i == 0 and j == 0:
+            dp[0][0] = COINS[0][0]
+        elif i == 0:
+            dp[0][j] = dp[0][j - 1] + COINS[0][j]
+        elif j == 0:
+            dp[i][0] = dp[i - 1][0] + COINS[i][0]
+        else:
+            dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + COINS[i][j]
+    print(dp[i])
+
[0, 1, 2, 3, 4, 5]
+[0, 1, 2, 3, 4, 6]
+[0, 41, 111, 111, 111, 112]
+[100, 100, 111, 111, 111, 113]
+

В отличие от жадного алгоритма, динамическое программирование находит оптимальное решение - это, в данном случае, 113 монет.

+

Восстановление ответа

+

В последней задаче было здорово найти, что в оптимальном пути черепашки набирается 113 монет, но интересно, что именно это за путь. Такую задачу называют восстановлением ответа в динамике.

+

Есть два способа, которыми можно это сделать.

+
    +
  1. Хранить в массив prev откуда ты пришел в эту клетку.
  2. +
+

Когда мы выбираем максимум из левой и верхней клетки, мы на самом деле решаем, какой последний ход будет в оптимальном пути до этой клетки - сверху или слева, и берем ответ для той клетки, сложнный с монетами в этой клетке. Давайте координаты клетки, откуда мы пришли, хранить в массиве prev. Или, в данном случае, можно хранить не координаты а просто 1, если пришли слева, и 0, если пришли сверху.

+
dp = [[None] * M for i in range(N)]
+prev = [[None] * M for i in range(N)]
+
+for i in range(N):
+    for j in range(M):
+        if i == 0 and j == 0:
+            dp[0][0] = COINS[0][0]
+            prev[0][0] = -1 # это самое начало, предыдущей клетки нет
+        elif i == 0:
+            dp[0][j] = dp[0][j - 1] + COINS[0][j]
+            prev[0][j] = 0 # слева пришли
+        elif j == 0:
+            dp[i][0] = dp[i - 1][0] + COINS[i][0]
+            prev[i][0] = 1 # сверху пришли
+        else:
+            dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + COINS[i][j]
+            if dp[i - 1][j] > dp[i][j - 1]:
+                prev[i][j] = 1
+            else:
+                prev[i][j] = 0
+    print(prev[i])
+
[-1, 0, 0, 0, 0, 0]
+[1, 1, 1, 1, 1, 1]
+[1, 1, 0, 0, 0, 0]
+[1, 0, 1, 0, 0, 1]
+

И, чтобы восстановить ответ, надо просто пройтись с конца по этим клеткам до самого начала, и развернуть получившуюся последовательность.

+
i, j = N - 1, M - 1
+answer = []
+answer_directions = []
+while i > 0 or j > 0:
+    if prev[i][j] == 1:
+        i -= 1
+        answer_directions.append('DOWN')
+    else:
+        j -= 1
+        answer_directions.append('RIGHT')
+    answer.append((i, j))
+print answer[::-1] # reverse
+print answer_directions[::-1] # reverse
+
[(0, 0), (0, 1), (1, 1), (2, 1), (2, 2), (2, 3), (2, 4), (2, 5)]
+['RIGHT', 'DOWN', 'DOWN', 'RIGHT', 'RIGHT', 'RIGHT', 'RIGHT', 'DOWN']
+
    +
  1. Вместо хранения массива prev догадаться по массиву dp, откуда именно черепашка пришла в эту клетку.
  2. +
+

В данном примере это довольно легко. Если мы уже посчитали весь массив dp, то теперь можно начиная с конца легко понять, пришла черепашка туда сверху или слева в оптимальном маршруте - она пришла из клетки с максимальным числом монет.

+
i, j = N - 1, M - 1
+answer = []
+answer_directions = []
+while i > 0 or j > 0:
+    if i != 0 and (j == 0 or dp[i - 1][j] > dp[i][j - 1]):
+        i -= 1
+        answer_directions.append('DOWN')
+    else:
+        j -= 1
+        answer_directions.append('RIGHT')
+    answer.append((i, j))
+print answer[::-1] # reverse
+print answer_directions[::-1] # reverse
+
[(0, 0), (0, 1), (1, 1), (2, 1), (2, 2), (2, 3), (2, 4), (2, 5)]
+['RIGHT', 'DOWN', 'DOWN', 'RIGHT', 'RIGHT', 'RIGHT', 'RIGHT', 'DOWN']
+

Задание

+

Решите как можно больше задач из простого контеста:

+

https://informatics.msk.ru/mod/statements/view.php?id=33233#1

+

А также решите как можно больше задач из сложного контеста:

+

https://informatics.msk.ru/mod/statements/view.php?id=35808#1

+

Разбор еще нескольких задач

+

Давайте разберем еще несколько задач.

+

Последовательности без 3 единиц подряд

+

Условие:

+

Определите количество последовательностей из нулей и единиц длины \(N\), в которых никакие три единицы не стоят рядом.

+

Решение:

+

Давайте хранить в \(dp[N]\) ровно число таких последовательностей длины \(N\) (это первое, что должно приходить в голову).

+

Давайте посмотрим для начала для маленьких чисел:

+
    +
  • \(dp[0] = 1 (\text{пустая последовательность})\)
  • +
  • \(dp[1] = 2 (0, 1)\)
  • +
  • \(dp[2] = 4 (00, 01, 10, 11)\)
  • +
  • \(dp[3] = 7 (000, 001, 010, 011, 100, 101, 110)\)
  • +
  • \(dp[4] = 13 (0000, 0001, 0010, 0011, 0100, 0101, 0110, 1000, 1001, 1010, 1011, 1100, 1101)\)
  • +
+

Сходу закономерность можно не увидеть. Нужно догадаться сгруппировать эти числа по том, сколько в конце единичек. Например, для dp[4]:

+
    +
  • 0 единичек в конце: \(0000, 0010, 0100, 0110, 1000, 1010, 1100\) - их ровно семь, как для \(N=3\), потому что первые 3 числа могут быть любые (но без трех единиц подряд), а четвертое - 0
  • +
  • 1 единичка в конце: \(0001, 0101, 1001, 1101\) - их ровно четыре, как для \(N=2\), потому что первые 2 числа могут быть любые (но без 3 единиц подряд), а два последних - 01
  • +
  • 2 единички в конце: \(0011, 1011\) - их ровно две, как для \(N=1\), потому что первое число может быть любым (но без 3 единиц подряд), а три последних - 011
  • +
+

Так мы замечаем и доказываем формулу: \(dp[N] = dp[N-1] + dp[N-2] + dp[N-3]\)

+

Теперь за \(O(N)\) ее легко посчитать.

+

Лесенки

+

Условие:

+

Лесенкой называется набор кубиков, в котором каждый горизонтальный слой содержит меньше кубиков, чем слой под ним. Подсчитать количество различных лесенок, которые могут быть построены из N кубиков.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Решение:

+

Если считать, что \(dp[N]\) - это число лесенок из \(N\) кубиков, то никакой закономерности скорее всего найти не получится.

+
    +
  • \(dp[1] = 1\)
  • +
+ + + + + + +
+
    +
  • \(dp[2] = 1\)
  • +
+ + + + + + + +
+
    +
  • \(dp[3] = 2\)
  • +
+ + + + + + + + + + + +
+ + + + + + + + +
+
    +
  • \(dp[4] = 2\)
  • +
+ + + + + + + + + + + + + +
+ + + + + + + + + +
+
    +
  • \(dp[5] = 3\)
  • +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + +
+
    +
  • \(dp[6] = 4\)
  • +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + +
+

По числам построить закономерность сложно, и по самим лесенкам тоже. Не видно какого-то рассуждения вида “ко всем лесенкам размера N-1 можно положить на любой слой еще один кубик”, иногда ведь и нельзя.

+

Тут нам помогает введение дополнительного параметра. Нас просят решить одномерную задачу (для \(N\)), а мы решим двумерную задачу для \(n\) и \(m\). Давайте зафиксируем размер самого нижнего слоя и назовем его размер \(m\).

+

То есть $dp[n][m] = $“число лесенок, состоящих из \(N\) кубиков, таких, что самый нижний слой состоит из \(M\) кубиков”.

+

Как это связано с нашей задачей? Ответ на нашу задачу равен \(dp[N][1] + dp[N][2] + \ldots + dp[N][N]\).

+

Какая есть формула для такой постановки задачи? Размер нижнего слоя у нас зафиксирован и равен \(m\), осталость \(n-m\) кубиков на верхних слоях. Логично перебрать размер второго снизу слоя, который может быть любым от \(1\) до \(m-1\): \(dp[n][m] = dp[n-m][1] + dp[n-m][2] + \ldots + dp[n-m][m-1]\)

+

Это все пир условии, что \(n \geq m\). Иначе \(dp[n][m] = 0\).

+

Теперь осталось понять как инициализировать этот массив и аккуратно по нему пройтись. Давайте инициализируем его ровно для случая \(n=0, m=0\):

+

\(dp[0][0]\) = 1

+

Пройдемся по массиву сначала для всех m при n = 1, потому для всех m при всех n = 2 и так далее - то есть по строчкам обычными двумя циклами \(for\).

+

Для \(m > 0\) в ячейках \(dp[0][m]\) наш алгоритм будет работать и возвращать 0, так как \(n < m\).

+

Для всех \(n > 0\) наша формула будет находить ответ через числа с меньшим n, а значит алгоритм корректен.

+

Он заполняет табличку размера \(N^2\), обрабатывая каждую клетку за \(O(N)\) операций, итоговое время работы - \(O(N^3)\).

+ + diff --git a/tg/dp-subsets.html b/tg/dp-subsets.html new file mode 100644 index 0000000..5a8d7ba --- /dev/null +++ b/tg/dp-subsets.html @@ -0,0 +1,89 @@ + + + + + + + Динамика по подмножествам - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Динамика по подмножествам

+

Иногда в качестве состояния динамики нужно брать множество. Это множество можно потом перебрать либо втупую, либо использовать в динамике.

+

Множество удобно представлять в виде битовой маски.

+

Перед тем, как разбирать конкретные задачи, несколько советов по поводу кодинга:

+

Числа хранятся в памяти компьютера просто как последовательность битов.

+
с = a & b; // побитовое И
+c = a | b; // побитовое ИЛИ
+c = a ^ b; // побитовое исключающее ИЛИ (XOR)
+c = ~a;    // побитовое НЕ
+

Битовым операциям соответствуют операции над подмножествами: «И» соответствуют пересечение, «ИЛИ» соответствует объединение и так далее.

+

Также тут работуют такие операции, как двоичные сдвиги. По сути, a<<l означает домножить на \(2^l\), а a>>l означает поделить на \(2^l\) с остатком. Аккуратнее с переполнением!

+

Очень часто нужно проверять, входит ли какой-то элемент в множество. Аналогично, стоит ли единица на какой-то позиции в маске.

+
+

Рюкзак по подмножествам

+
+

Даны \(n \leq 20\) слитков золота, каждый весом до \(10^9\) кг. У вас есть рюкзак, вмещающий \(W\) кг. Какой максимальный вес можно в нем унести за один раз?

+
+

Можно просто перебрать все множества и посмотреть, какого они суммарного веса.

+
int w[20] = { ... };
+
+int ans = 0;
+
+for (int mask = 0; mask < (1<<n); mask++) {
+    int sum = 0;
+    for (int i = 0; i < n; i++)
+        for (mask>>i&1)
+            sum += w[i];
+    if (sum < w && sum > ans)
+        ans = sum;
+}
+

При итеративной реализации динамики нужно не забывать о том, что обработка состояний в должна осуществляться от простых случаев к сложным. Перебор всех масок по возрастанию это гарантирует — подумайте, почему.

+

Турнир на выбывание

+

Тут нужно немножко подумать, как устроен теорвер.

+

На самом деле, это очень общая постановка задачи. Почти всегда состояние будет описываться каким-то очень большим количеством параметров и кодируются битовыми масками.

+ + diff --git a/tg/euler-path.html b/tg/euler-path.html new file mode 100644 index 0000000..99f8a69 --- /dev/null +++ b/tg/euler-path.html @@ -0,0 +1,87 @@ + + + + + + + Эйлеров путь и цикл - Tinkoff Generation + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Эйлеров путь и цикл

+

Эйлеров путь - это путь в графе, проходящий через все его рёбра. Эйлеров цикл - это эйлеров путь, являющийся циклом.

+

Научимся понимать, есть ли эйлеров цикл/путь в графе, давайте считать, что граф неориентированный. Изначально поймем, что граф должен состоять из одной компоненты и может содержать изолированные вершины.

+

Чтобы проверить, существует ли эйлеров путь, нужно воспользоваться следующей теоремой.

+
+

Пусть дан неориентированный связный цикл. Эйлеров цикл существует тогда и только тогда, когда степени всех вершин чётны. Эйлеров путь существует тогда и только тогда, когда количество вершин с нечётными степенями равно двум (или нулю, в случае существования эйлерова цикла).

+
+

Кстати, аналогичная теорема есть и для ориентированного графа (можете сами попытаться сформулировать).

+

Доказать это можно например через лемму о рукопожатиях.

+

Как теперь мы будем решать задачу нахождения цикла в предположении, что он точно есть. Давайте запустимся из произвольной вершины, пройдем по любому ребру и удалим его.

+
+
void findeulerpath(int v){
+    stack >s;
+    s.push({v, -1});
+    while (!s.empty()) {
+        v = s.top().first;
+        int x = s.top().second;
+        for (int i = 0; i < g[v].size(); i++){
+            pair e = g[v][i];
+            if(!u[e.second]){
+                u[e.second]=1;
+                s.push(e);
+                break;
+            }
+        }
+        if (v == s.top().first) {
+            if (~x) {
+                p.push_back(x);
+            }
+            s.pop();
+        }
+    }
+}
+ + diff --git a/tg/greedy.html b/tg/greedy.html new file mode 100644 index 0000000..a596dd7 --- /dev/null +++ b/tg/greedy.html @@ -0,0 +1,138 @@ + + + + + + + Жадный алгоритм - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Жадный алгоритм

+

Это не какой-то алгоритм, а скорее простая идея о том, как решаются многие задачи.

+

Пример задачи: Размен монет

+

Условие

+

Есть купюры и монеты номиналами: \(1, 5, 10, 50, 100, 1000, 5000\) рублей. В банкомате неограниченное количество купюр каждого номинала. Константин хочет снять со счёта \(n\) рублей. Нужно определить минимальное суммарное количество купюр и монет, которое может выдать банкомат, чтобы сумма получилась ровно \(n\).

+

Решение

+

Выпишем первые несколько ответов на задачу:

+
ans[1] = 1; # 1
+ans[2] = 2; # 1 1
+ans[3] = 3; # 1 1 1
+ans[4] = 4; # 1 1 1 1
+ans[5] = 1; # 5
+ans[6] = 2; # 5 1
+ans[7] = 3; # 5 1 1
+ans[8] = 4; # 5 1 1 1
+ans[9] = 5; # 5 1 1 1 1
+ans[10] = 1; # 10
+

Хочется сказать, что оптимальным алгоритмом будет следующее: взять максимум купюр номинала \(1000\), из остатка взять максимум купюр номинала \(500\) и т.д. Причем это будет верным решением! Такой подход в решении называются “жадностью”. А алгоритмы, работающие таким образом, “жадными”.

+

Общая идея жадного алгоритма

+

В общем смысле жадный алгоритм - это брать элементы в порядке уменьшения чего-нибудь, брать самый большой элемент первым. Эта одна простая идея объединяет множество разных задач.

+

Чем-то это похоже на математический “принцип крайнего” - всегда будет полезно посмотреть на самый крайний (максимальный или минимальный элемент), очень часто решение начинается именно с него.

+

Доказательство решения

+

Плохо просто предполагать, что в задаче жадный алгоритм работает, потому что часто бывает, что хоть жадность и кажется очевидным решением в задаче, то это оказывается неверным. Поэтому прежде, чем писать жадность, стоит её доказать.

+

Вместо того, чтобы доказывать утверждение про жадность для конкретной задачи про числа \(1, 5, 10, 50, 100, 1000, 5000\), давайте сформулируем и докажем более сильное:

+
+

Если каждый следующий номинал делится на предыдущий, то жадный алгоритм работает.

+
+

Доказательство утверждения:

+

Пусть купюры имели номинал \(a_1, \ldots, a_k\). Пусть максимальная купюра имеет достоинство \(a_k\) и \(a_k < n\), но в оптимальном ответе ее нет. Давайте рассмотрим такой оптимальный ответ и найдем противоречие.

+

Пусть в оптимальном ответе купюра с номиналом \(a_i\) встретилась \(b_i\) раз. Если \(b \geq \frac{a_{i+1}}{a_i}\) раз, то \(\frac{a_{i+1}}{a_i}\) купюр можно легко заменить на одну купюру достоинства \(a_{i+1}\), а значит это не оптимальный ответ. Следовательно, \(b \leq \frac{a_{i+1}}{a_i} - 1\)

+

Давайте посчитаем, какого достоинства может быть сумма всех достоинств всех купюр в оптимальном ответе, если не считать максимальные купюры. Это будет \[a_1 b_1 + a_2 b_2 + \ldots + a_{k-1} b_{k-1} \leq a_1 (\frac{a_2}{a_1} - 1) + a_2 (\frac{a_3}{a_2} - 1) + \ldots + a_{k-1} (\frac{a_k}{a_{k-1}} - 1) +=\] \[= (a_2 - a_1) + (a_3 - a_2) + \ldots + (a_k - a_{k-1}) = a_k - a_1 < a_k\]

+

Из этого делаем вывод, что если \(n \geq a_k\), то в оптимальный ответ всегда придется взять максмальную купюру размера \(a_k\), потому что меньшие купюры просто не смогут оптимальном ответе давать так много. Отсюда и следует корректность жадного алгоритма.

+

Теперь, когда жадность доказана, можно предъявить алгоритм:

+

+vector<int> n = {1, 2, 5, 10, 50, 100, 1000, 2000, 5000}
+int sums, ans = 0;
+
+cin >> sums;
+for (int i = 8; i >= 0; i--) {
+    ans += sums / n[i];
+    sums %= n[i];
+}
+cout << sums << endl;
+

Пример задачи: Задача о рюкзаке с делимыми предметами

+

Условие

+

Пусть есть рюкзак с вместимостью не более, чем \(W\) грамм (\(W\) - целое) и \(n\) предметов весом \(w_i\) грамм и стоимостью \(c_i\) за грамм. Мы умеем отрезать от любого предмета целое количество грамм. Требуется набрать рюкзак максимальной стоимости.

+

Решение

+

Также будем решать эту задачу жадно. Отсортируем предметы по убыванию “плотности ценности” \(\frac{c_i}{w_i}\) и будем брать их жадно. От последнего предмета, который не влезет полностью, возьмем часть.

+

Доказательство

+

Давайте представим, что мы уже поделили все предметы на кусочки веса 1 грамм, при этом их ценность стала равна \(\frac{c_i}{w_i}\). Понятно, что из кусочков одинакого веса 1 грамм всегда оптимально просто взять кусочки с максимальной ценностью.

+

Заметим, что в жадном алгоритме мы как раз и набираем максимальные по \(\frac{c_i}{w_i}\) кусочки веса 1.

+

Предьявим алгоритм:

+
pair<int, int> items[n];
+
+int c, w, W;                 // стоимость и вес предмета 
+cin >> W; 
+for (int i = 0; i < n; ++i) { 
+    cin >> c >> w;
+    items[i] = {c, w};
+}
+
+sort(items, items + n);
+
+int ans = 0;
+for (int i = n - 1; i >= 0; --i) {
+    ans += min(items[i].second, W) * items[i].first;
+    W -= min(items[i].second, W);
+}
+cout << ans << endl;
+

Итоговая асимптотика: \(O(n + n\log n) = O(n\log n)\).

+

Заметим, что если предметы резать нельзя, такой алгоритм не сработает. Как решать задачу для такого случая обсудим на одной из следующих лекций.

+

Пример задачи: Выбор заявок

+

Условие

+

Даны заявки на проведение занятий в некоторой аудитории. В каждой заявке указаны начало и конец занятия \((s_i\) и \(f_i\) для \(i\)-ой заявки). Нужно из всех заявок оставить как можно больше так, чтобы они не пересекались. При этом если одна заявка закончилась во время \(t\), а следующая началась во время \(t\), то их можно ставить подряд.

+

Решение

+

Здесь жадность становится не такой уже очевидной, потому что неясно в каком порядке рассматривать заявки, те непонятно как “жадно” их набирать.

+

Давайте посмотрим на самую первую по времени конца заявку. Заметьте, что нам всегда выгодно включить её в оптимальный ответ - она заканчивается раньше всех остальных, а поэтому если в оптимальном ответе самая первая заявка - другая, мы можем безболезненно заменить её на самую первую по времени конца, и новых пересечений не появится, так как мы просто сдвинули самую первую заявку еще левее.

+

Раз всегда есть оптимальный ответ, в котором выбрана эта самая левая по времени конца заявка, давайте её возьмем, и выберем самую первую по времени конца заявку из оставшихся, не пересекающихся с той.

+

Из такого рассуждения про одну самую левую по времени конца заявку следует сразу и общее жадное решение задачи - нужно идти слева направо по заявкам, которые отсортированы по времени конца, и брать новую, если можем, то есть если её начало не раньше, чем конец самой последней уже выбранной.

+

Задание

+

Вам нужно решить как можно больше задач из этого контеста:

+

https://informatics.msk.ru/mod/statements/view3.php?id=35449

+ + diff --git a/tg/index.html b/tg/index.html new file mode 100644 index 0000000..acdca12 --- /dev/null +++ b/tg/index.html @@ -0,0 +1,70 @@ + + + + + + + Tinkoff Generation + + + + + + + + + + + + + +

Tinkoff Generation

+

Тут (возможно, временно) лежат некоторые конспекты первого курса Tinkoff Generation.

+
    +
  1. Сортировки
  2. +
  3. Бинарный поиск
  4. +
  5. Теория чисел
  6. +
  7. C++ STL
  8. +
  9. Жадные алгоритмы
  10. +
  11. Введение в динамическое программирование
  12. +
  13. Задача о рюкзаке, НВП и НОП
  14. +
  15. Метод сканирующей прямой
  16. +
  17. Стек и другие структуры данных
  18. +
  19. Поиск в глубину
  20. +
  21. Поиск в ширину
  22. +
  23. Нахождение кратчайших путей
  24. +
  25. Метод двух указателей и mergesort
  26. +
  27. Quicksort
  28. +
  29. Продвинутый DFS
  30. +
  31. Мосты и точки сочленений
  32. +
  33. Эйлеров путь
  34. +
  35. Комбинаторика
  36. +
  37. Перебор объектов
  38. +
  39. Дебаг
  40. +
+ + diff --git a/tg/knapsack-gis-gcs.html b/tg/knapsack-gis-gcs.html new file mode 100644 index 0000000..3ec923f --- /dev/null +++ b/tg/knapsack-gis-gcs.html @@ -0,0 +1,203 @@ + + + + + + + Динамическое программирование — рюкзак, НВП, НОП - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Динамическое программирование — рюкзак, НВП, НОП

+

Предполагается, что вы уже знакомы с базовыми понятиями динамического программирования и помните бинарный поиск.

+
    +
  • Задача о рюкзаке
  • +
  • НВП
  • +
  • НОП
  • +
  • Динамика по префиксу и значению последнего элемента
  • +
  • Ленивая динамика
  • +
+

Задача о рюкзаке

+

0-1 Рюкзак

+

В самой простой форме задача о рюкзаке формулируется так: > Даны \(n\) предметов с весами \(a_1,\ldots,a_n\). Определите, на какой максимальный вес можно набрать предметов в рюкзак вместимости \(W\).

+

Для решения этой задачи воспользуемся динамическим программированием. Обозначим за \(dp[i][j]\) состояние, когда мы рассмотрели первые \(i\) предметов и набрали ими \(j\) веса. \(dp[i][j]=True\), если такая ситуация возможна, иначе \(dp[i][j]=False\).

+

Для каждого состояния \(dp[i][j]\), которое возможно получить, мы можем либо взять предмет номер \(i\) и попробовать обновить ответ из состояния \(dp[i-1][j-a[i]]\), либо не брать его и обновить ответ из \(dp[i-1][j]\). Очевидно, что мы можем получить 0 веса, рассмотрев 0 предметов.

+
dp[0][0] = True
+for i in range(1, n + 1):
+    for j in range(0, W + 1):
+        dp[i][j] = dp[i - 1][j]
+        if a[i] <= j and dp[i - 1][j - a[i]]:
+            dp[i][j] = True
+

Ответом будет максимальное \(j\), такое что \(dp[n][j]=True\). Таким образом, мы получили решение за \(O(nW)\)

+

0-1 Рюкзак со стоимостями

+

Немного усложним задачу. Пусть, теперь предметы имеют не только веса, но и стоимости \(c_1,\ldots,c_n\). Соответственно, надо набрать предметов с наибольшей суммарной стоимостью, но весом не превосходящим \(W\). Теперь в \(dp[i][j]\) будем хранить не просто возможно ли получить из первых \(i\) предметов набор веса \(j\), а максимальную суммарную стоимость такого набора. Если же такой набор получить невозможно, то по-прежнему \(dp[i][j]=0\). Переходы такие же как и в прошлой задаче. Изначально \(dp\) заполнено 0, так как для любого непустого набора мы пока не знаем, как его получить, а путой набор имеет стоимость 0.

+
for i in range(1, n + 1):
+    for j in range(0, W + 1):
+        dp[i][j] = dp[i - 1][j]
+        if a[i] <= j:
+            dp[i][j] = max(dp[i][j], dp[i - 1][j - a[i]] + c[i])
+

Ответом на задачу будет максимальное \(dp[n][j]\). Такое решение тоже работает за \(O(nW)\).

+

Если так получилось, что веса большие, а стоимости маленькие, можно поменять их местами и считать минимальный вес при данной набранной стоимости. Поменять местами значение динамики и параметр — довольно распространенный трюк в динамическом программировании.

+

Рюкзак с ограниченным числом предметов

+

Пусть, теперь предметов каждого типа может быть несколько, то есть даны не только веса и стоимости, но и максимальные количества каждого из предметов \(k_1,\ldots,k_n\). Тогда для каждого состояния \(dp[i][j]\) переберем, сколько мы взяли предметов такого типа и сделаем переход из каждого соответствующего состояния. Понятно, что мы не сможем взять более, чем \(\lfloor\frac{W}{a_i}\rfloor\) предметов каждого типа.

+
for i in range(1, n + 1):
+    for j in range(0, W + 1):
+        for cnt in range(min(k[i], W // a[i]) + 1):
+            if a[i] * cnt <= j:
+                dp[i][j] = max(dp[i][j], dp[i - 1][j - a[i] * cnt] + c[i] * cnt)
+

Такое решение работает за \(O(nW^2)\), так как \(k_i\) могут быть очень большими, а \(a_1=1\).

+

Можете попробовать решить эту задачу за \(O(nW\log K)\), где \(K\) — максимальное из \(k_i\).

+

Рюкзак с неограниченным числом предметов

+

Пусть, теперь каждого предмета будет не \(k_i\), а вообще бесконечно. Оказывается, задача стала только проще. Вернемся к обычному рюкзаку с весами и стоимостями. Единственное отличие будет в том, что теперь мы можем делать второй переход не из предыдущей строки, а прямо из текущей. При этом заметим, что для каждого состояния достаточно рассмотреть взятие только одного предмета данного типа, поскольку взятие двух и более будет рассмотрено одновременно.

+
for i in range(1, n + 1):
+    for j in range(0, W + 1):
+        dp[i][j] = dp[i - 1][j]
+        if a[i] <= j:
+            dp[i][j] = max(dp[i][j], dp[i][j - a[i]] + c[i])
+

Такое решение работает за \(O(nW)\).

+

Если \(W\) велико, а \(a_i\) достаточно малы, можно построить решение за \(O(n + A^3)\), где \(A\) — максимальное из \(a_i\). Заметим, что если \(W\) достаточно большое, то большая часть рюкзака будет занята предметами одного и того же типа с максимальной удельной стоимостью. Можно доказать, что одним и тем же предметом будет занято не менее \(W-A^2\) веса. Таким образом, можно за \(O(n)\) выбрать предмет с максимальным удельным весом, а для оставшихся предметов запустить предыдущий алгоритм, который сработает за \(O(A^3)\), так как имеет смысл рассматривать не более \(A\) предметов, а максимальный вес \(A^2\).

+

Восстановление ответа

+

Во всех рассмотренных задачах восстановление ответа делается стандартным образом: нужно из ответа пройтись обратно до начала.

+
    +
  • первый способ - это построить массив prev, где для каждой ячейки \(dp[i][j]\) хранить, берем мы предмет i, или не берем предмет \(i\).
  • +
  • второй способ - это определять это на ходу, смотря, какое из значений \(dp[i - 1][j]\) или \(dp[i - 1][j - a[i]] + c[i]\) больше.
  • +
+

Наибольшая возрастающая подпоследовательность

+

Пусть, дана последовательность из \(n\) чисел \(a_1,\ldots,a_n\). Требуется найти длину ее наибольшей возрастающей подпоследовательности (НВП), то есть длину такой наибольшей последовательности индексов \(i_1<i_2<\ldots<i_k\), что \(a[i_1]<a[i_2]<\ldots<a[i_k]\).

+

Пример: в последовательности \(100, \underline{20}, \underline{75}, 0, -40, \underline{80}, -10, \underline{120}, 110\) наибольшей возрастающей подпоследовательность является \(20, 75, 80, 120\): она имеет длину \(4\). Возрастающих подпоследовательностей длины 5 здесь нет.

+

НВП за \(O(N^2)\)

+

Давайте решать наивно через динамческое программирование - то есть хранить в \(dp[i]\) ровно то, что нам надо найти - длину НВП для первых \(i\) чисел.

+

\(dp[0] = 0\). Но как найти формулу, выражающую \(dp[i]\) через предыдущин значения?

+

Ну, есть два варианта: * \(i\)-ое число не входит в НВП. Тогда \(dp[i] = 1\) * \(i\)-ое число входит в НВП. Тогда \(dp[i] = 1 + dp[k]\), где \(k\) - индекс предыдущего числа в этой НВП. Так давайте просто его переберем. При этом надо учесть, что \(a[k]\) должно быть меньше, чем \(a[i]\)!

+

Итогвоая формула получается такая:

+

\[dp[i] = \max(1, 1 + \max\limits_{k | a[k] < a[i]}dp[k])\]

+

Этот алгоритм работает за \(O(N^2)\): у нас \(O(N)\) состояний динамики, и каждое из них мы считаем за \(O(N)\) действий, пока ищем этот максимум.

+

Ответ восстанавливается тем же способом: для каждого состояния нужно сохранить, где был этот максимум - там и есть предыдущее число в НВП.

+

НВП за \(O(N\log{N})\)

+

Решим эту задачу чуть более нестандартным динамическим программированием, где \(min\_end[i]\) будет обозначать минимальное число, на которое может заканчиваться НВП длины \(i\). При этом мы будем постепенно обрабатывать числа слева направо, и в этом массиве будет храниться только информация про все НВП в уже обработанном начале последовательности.

+

Изначально \(min\_end[0]=-\infty, min\_end[i]=\infty\) для \(i>0\). В качестве \(\infty\) надо выбрать число, которое заведомо больше любого из \(a_i\), аналогично с \(-\infty\).

+

Рассматривая очередной элемент, попробуем продлить им каждую подпоследовательность:

+
n = 6
+a = [6, 1, 5, 3, 4, 2] # НВП: 1, 3, 4
+inf = 100
+min_end = [-inf] + [inf] * n
+for i in range(n):
+    for j in range(n):
+        if min_end[j - 1] < a[i] < =min_end[j]:
+            min_end[j] = a[i]
+print(dp)
+
---------------------------------------------------------------------------
+
+NameError                                 Traceback (most recent call last)
+
+<ipython-input-1-930ff2a8d8b0> in <module>()
+      7         if min_end[j - 1] < a[i] < min_end[j]:
+      8             min_end[j] = a[i]
+----> 9 print(dp)
+
+
+NameError: name 'dp' is not defined
+

Ответом будет максимальный такой индекс \(j\), что \(min\_end[j] \neq 0\). Это решение работает за \(O(n^2)\).

+

Его можно значительно ускорить, заметив два факта: - На любом шаге \(min\_end[i-1]\leq min\_end[i]\). Это легко доказать от противного. - Из предыдущего факта следует, что любое \(a[i]\) обновит максимум одно значение динамики, так как попадет максимум в один интервал.

+

Значит, для поиска \(j\), которое обновится можно воспользоваться бинарным поиском. Это решение уже работает за \(O(n\log n)\).

+

Наибольшая общая подпоследовательность

+

Даны две последовательности \(a_1,\ldots,a_n\) и \(b_1,\ldots,b_m\). Требуется найти длину их наибольшей общей подпоследовательности (НОП), то есть длину наибольшей таких последовательностей \(i_1<\ldots<i_k\) и \(j_1<\ldots<j_k\), что \(a[i_1]=b[j_1],\ldots,a[i_k]=b[j_k]\).

+

Решим эту задачу с помощью динамического программирования, где \(dp[i][j]\) будет обозначать длину НОП, если мы рассмотрели префиксы последовательностей длины \(i\) и \(j\).

+

Тогда заметим, что есть две ситуации, когда мы считаем \(dp[i][j]\): * \(a_i \neq b_j\), тогда хотя бы один из этиз символов не содержится в НОП, иначе она заканчивается на два разных символа. В этом случае \(dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])\) * \(a_i = b_j\), тогда несложно доказать, что точно есть максимальная НОП, в которую входят ОБА этих символа, а значит \(dp[i][j] = 1 + dp[i - 1][j - 1]\).

+

А на пустых префиксах ответ 0.

+
a = [1, 100, 2, 100, 3]
+b = [10, 10, 1, 2, 3, 10] # НОП: 1,2,3
+n = len(a)
+m = len(b)
+dp = [[0 for j in range(m + 1)] for i in range(n + 1)]
+for i in range(1, n + 1):
+    for j in range(1, m + 1):
+        dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
+        if a[i - 1] == b[j - 1]:
+            dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + 1)
+    print(dp[i])
+
[0, 0, 0, 1, 1, 1, 1]
+[0, 0, 0, 1, 1, 1, 1]
+[0, 0, 0, 1, 2, 2, 2]
+[0, 0, 0, 1, 2, 2, 2]
+[0, 0, 0, 1, 2, 3, 3]
+

Ответом является максимальное число в массиве \(dp\). Решение работает за \(O(nm)\).

+

Ответ при это восстанавливается классическим способом - с конца. Нам все еще нужно просто в каждой ячейке смотреть - если символы в ней равны, то нужно уменьшить \(i\) и \(j\), иначе только один из них - так, чтобы НОП был максимален.

+

Задание

+

Сведите задачу НВП к задаче НОП.

+

Задание

+

Найдите НОП двух перестановок длины \(n\) за \(O(n\log n)\).

+

Динамика по префиксу и значению последнего элемента

+

Пусть, дана последовательность \(a_1,\ldots,a_n\), с максимальным значением \(A\). Требуется найти длину наибольшей такой подпоследовательности, что ее элементы отличаются на более, чем на 1. Воспользуемся динамическим программированием, где \(dp[j]\) будет обозначать ответ с последним взятым элементом, равным \(j\). Будем обновлять и хранить актуалььным весь массив \(dp\) целиком, проходясь по массиву \(a\) слева направо.

+

Соответственно для каждого \(i\) переходы можно делать только из таких \(j\), что \(|a[i]-j|\leq 1\).

+
for i in range(1, n + 1):
+    dp[a[i]] += 1
+    if a[i] > 0:
+        dp[a[i]] = max(dp[a[i]], dp[a[i] - 1] + 1)
+    if a[i] < A:
+        dp[a[i]] = max(dp[a[i]], dp[a[i] + 1] + 1)
+

Это решение за \(O(n + A)\).

+

Заметим, что вот эти две идеи встречаются в задачах наиболее часто: * хранить в \(dp[i]\) ответ для \(i\)-ого префикса. Как в рюкзаке (где можно пользоваться \(i\) первыми предметами), НВП(где ответ на префиксе длины \(i\)) и НОП (где ответ для префиксов длины \(i\) и \(j\)). * хранить в \(dp[i]\) ответ для последовательностей, заканчивающихся на \(i\).

+

Ленивая динамика

+

Если сложно придумать порядок обхода таким образом, чтобы все предыдущие значения уже были посчитаны, то можно вместо циклов использовать рекурсивную функцию и запоминать посчитанный результат, чтобы не считать несколько раз одно и то же.

+

Решим, например, обычную задачу о рюкзаке таким образом. Изначально все \(dp[i][j]=-1\), это будет обозначать, что значение еще не посчитано, кроме \(dp[0][j]=0\).

+
def calc(i, j):
+    if dp[i][j] == -1:
+        dp[i][j] = calc(i - 1, j)
+        if a[i] <= j:
+            dp[i][j] = max(dp[i][j], calc(i - 1, j - a[i]) + c[i])
+    return dp[i][j]         
+
+answer = 0
+for j in range(W + 1):
+    answer = max(answer, calc(n, j))
+

Время работы так же составит \(O(nW)\), так как каждое значение мы считаем только один раз, но истинное время работы будет в несколько раз больше, потому что константа на вызовы функции значительно выше чем на простой цикл.

+

Задание

+

Решите как можно больше задач из этих двух контестов:

+

https://informatics.msk.ru/mod/statements/view.php?id=35888

+

https://informatics.msk.ru/mod/statements/view.php?id=33257 ### Дополнительная сложная задача https://csacademy.com/contest/round-61/task/strictly-increasing-array/statement/

+ + diff --git a/tg/mergesort.html b/tg/mergesort.html new file mode 100644 index 0000000..3da3b27 --- /dev/null +++ b/tg/mergesort.html @@ -0,0 +1,170 @@ + + + + + + + Два указателя, сортировка слиянием - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Два указателя, сортировка слиянием

+

Количество пар с разницей, больше чем K

+
+

Найти количество пар элементов \(a\) и \(b\) в отсортированном массиве, такие что \(b - a > K\).

+
+

Наивное решение: бинарный поиск. Будем считать, что массив уже отсортирован. Для каждого элемента \(a\) найдем первый справа элемент \(b\), который входит в ответ в паре с \(a\). Нетрудно заметить, что все элементы, большие \(b\), также входят в ответ. Итоговая асимптотика \(O(n\log n)\).

+

А можно ли быстрее?

+

Да, давайте перебирать два указателя — два индекса \(first\) и \(second\). Будем перебирать \(first\) просто слева направо и поддерживать для каждого \(first\) первый элемент справа от него, такой что \(a[second] - a[first] > K\) как \(second\). Тогда в пару к \(a=a[first]\) подходят ровно \(n-second\) элементов массив начиная с \(second\).

+
int second = 0, ans = 0;
+for (int first = 0; first < n; ++first) {
+    while (second != n && a[second] - a[first] <= r) {
+        second++;
+    }
+    
+    ans += n - second;
+}
+

За сколько же работает это решение? С виду может показаться, что за \(O(n^2)\), но давайте посмотрим сколько раз меняется значение переменной \(second\). Так как оно изначально равняется нулю, только увеличивается и не может превысить \(n\), то суммарно операций мы сделаем \(O(n)\).

+

Это называется метод двух указателей — так как мы двигаем два указателя first и second одновременно слева направо по каким-то правилам. Обычно его используют на одном отсортированном массиве.

+

Давайте разберем еще примеры.

+

Максимальная разница, но не больше K

+
+

Найти в отсортированном массиве два числа \(a\) и \(b\) такие, что \(b - a \leq K\), и при этом \(b-a\) максимально.

+
+

Давайте просто переберем \(first\) слева направо как указатель на \(a\), и будем поддерживать \(second\) как указатель на максимальный такой индекс, что \(a[second] - a[first]\leq K\). Для этого достаточно просто после каждого сдвига \(first\) на один сдвигать \(second\) вправо, пока это условие не перестанет выполняться.

+

Заметим, что таким образом мы перебрем все пары \(a\) и \(b\), такие что \(b - a \leq K\), и их при этом нельзя увеличить вправо (иначе мы бы увеличили). Очевидно, максимальная разница лежит именно в одной из такой пар, так что мы ее найдем.

+

Три массива

+

Чаще всего метод двух указателей применяют к одному отсортированному массиву. Но иногда можно применить его и на несколько, например, три массива.

+
+

Найти в трех отсортированных массивах элементы \(a_i\), \(b_j\) и \(c_k\) такие, что \(|\max(a_i, b_j, c_k) - \min(a_i, b_j, c_k)|\) минимально.

+
+

Для этого достаточно перебирать указатель \(first\) на первый массив, \(second\) на второй массив и \(third\) на третий массив. Давайте теперь медленно увеличивать минимум из этих трех элементов так, чтобы максимум увеличился как можно меньше.

+

Изначально все указатели указывают на индекс \(0\). После этого надо один из указателей сдвинуть направо. Какой? Тот, который указывает на минимальный из элементов. Почему? Если сдвинуть любой другой, то максимум возрастет, а минимум нет, такие комбинации вообще рассматривать бесполезно.

+

Так что мы просто сдвигаем каждый раз указатель на минимум из трех элементов, если есть куда его двигать, иначе заканчиваем. Каждый раз обновляем ответ. Так мы переберем всех разумные тройки элементов.

+

Слияние

+

Еще пример двух указателей на нескольких массивах.

+

Пусть у нас есть два отсортированных по неубыванию массива размера \(n\) и \(m\). Хотим получить отсортированный массив размера \(n + m\) из исходных.

+

Пусть первый указатель будет указывать на начало первого массива, а второй, соответственно, на начало второго. Из двух текущих элементов, на которые указывают указатели, выберем наименьший и положим на соответствующую позицию в новом массиве, после чего сдвинем указатель. Продолжим этот процесс пока в обоих массивах не закончатся элементы. Тогда код будет выглядеть следующим образом:

+
int a[n + 1], b[m + 1], res[n + m];
+
+a[n] = INF; // Создаем в конце массива фиктивный элемент, который заведомо больше остальных
+b[m] = INF; // Чтобы избежать лишних случаев
+
+for (int i = 0; i < n; ++i) {
+    cin >> a[i];
+}
+
+for (int j = 0; j < m; ++j) {
+    cin >> a[j];
+}
+
+int i = 0, j = 0;
+for (int k = 0; k < n + m; ++k) {
+    if (a[i] < b[j]) {
+        res[k] = a[i];
+        i++;
+    } else {
+        res[k] = b[j];
+        j++;
+    }
+}
+

Итоговая асимптотика: \(O(n + m)\).

+

Сортировка слиянием

+

Давайте подробно опишем как использовать операцию слияния для сортировки за \(O(n\log n)\).

+

Пусть у нас есть какой-то массив.

+
int a[8] = {7, 2, 5, 6, 1, 3, 4, 8};
+

Сделаем такое предположение. Пусть мы уже умеем как-то сортировать массив размера \(n\). Тогда научимся сортировать массив размера \(2n\). Давайте разобьем наш массив на две половины, отсортируем каждую из них, а после это сделаем слияние двух массивов, которое мы научились делать за \(O(n)\) в данных условиях. Также заметим, что массив размера \(1\) уже отсортирован, тогда мы можем делать это процедуру рекурсивно. Тогда для данного массива \(a\) это будет выглядеть следующим образом:

+
// (7 2 5 6 1 3 4 8)
+// (7 2 5 6) (1 3 4 8)
+// (7 2) (5 6) (1 3) (4 8)
+// (2 7) (5 6) (1 3) (4 8)
+// (2 5 6 7) (1 3 4 8)
+// (1 2 3 4 5 6 7 8)
+
+#include <algorithm> // Воспользуемся встроенной функцией merge
+
+void merge_sort(vector<int> &v, int l, int r) { // v - вектор, который хотим отсортировать
+    if (r - l == 1) {                            // l и r - полуинтервал, который хотим отсортировать
+        return;
+    }
+    
+    int mid = (l + r) / 2;
+    merge_sort(v, l, mid);
+    merge_sort(v, mid, r);
+    vector<int> temp(r - l); // временный вектор
+    merge(v.begin() + l, v.begin() + mid, v.begin() + mid, v.begin() + r, c.begin());
+    for (int i = 0; i < r - l; ++i) {
+        v[i + l] = temp[i];
+    }
+    return;
+}
+

Так сколько же работает это решение?

+

Пускай \(T(n)\) — время сортировки массива длины \(n\), тогда для сортировки слиянием справедливо \(T(n)=2T(n/2)+O(n)\) \(O(n)\) — время, необходимое на то, чтобы слить два массива длины n. Распишем это соотношение:

+

\(T(n)=2T(n/2)+O(n)=4T(n/4)+2O(n)=\ldots=T(1)+\log(n)O(n)=O(n\log(n)).\)

+

Количество инверсий

+

Пусть у нас есть некоторая перестановка \(a\). Инверсией называется пара индексов \(i\) и \(j\) такая, что \(i < j\) и \(a[i] > a[j]\). > Найти количество инверсий в данной перестановке.

+

Очевидно, что эта задача легко решается обычным перебором двух индексов за \(O(n^2)\):

+
int a[n], ans = 0;
+
+for (int i = 0; i < n; ++i) {
+    for (int j = i + 1; j < n; ++j) {
+        if (a[i] > a[j]) {
+            ans++;
+        }
+    }
+}
+
+cout << ans << endl;
+

Внезапно эту задачу можно решить используя сортировку слиянием, слегка модифицируя её. Оставим ту же идею. Пусть мы умеем находить количество инверсий в массиве размера \(n\), научимся находить количество инверсий в массиве размера \(2n\).

+

Заметим, что мы уже знаем количество инверсий в левой половине и в правой половине массива. Осталось лишь посчитать число инверсий, где одно число лежит в левой половине, а второе в правой половине. Как же их посчитать?

+

Давайте подробнее рассмотрим операцию merge левой и правой половины (которую мы ранее заменили на вызов встроенной функции merge). Первый указатель указывает на элемент левой половины, второй указатель указывает на элемент второй половины, мы смотрим на минимум из них и этот указатель вдигаем вправо.

+

Рассмотрим число \(A\) в левой половине. В скольки инверсиях между половинами оно участвует? В стольки, сколько чисел в правой половине меньше, чем оно. Знаем ли мы это количество? Да! Ровно в тот момент, когда мы число \(A\) вносим в слитый массив, второй указатель указывает на первое число в правой половине, которое больше чем \(A\).

+

Значит в тот момент, когда мы добавляем число \(A\) из левой половины, к ответу достаточно прибавить индекс второго указателя (минус начало правой половины). Так мы учтем все инверсии между половинами.

+

Задание

+

Решите как можно больше задач из теоретического контеста https://informatics.msk.ru/mod/statements/view3.php?id=38944

+

Решите как можно больше задач практического контеста https://codeforces.com/group/g92L0id9Yb/contest/236738

+ + diff --git a/tg/number-theory.html b/tg/number-theory.html new file mode 100644 index 0000000..8267e09 --- /dev/null +++ b/tg/number-theory.html @@ -0,0 +1,507 @@ + + + + + + + Теория чисел - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Теория чисел

+
    +
  • Простые числа
  • +
  • Разложение на простые множители
  • +
  • Решето Эратосфена
  • +
  • Линейное решето Эратосфена*
  • +
  • НОД и НОК
  • +
  • Алгоритм Евклида
  • +
  • Расширенный алгоритм Евклида*
  • +
  • Операции по модулю
  • +
  • Быстрое возведение в степень
  • +
  • Деление по простому модулю*
  • +
+

Простые числа

+

Простым называется натуральное число, которое делится только на единицу и на себя. Единица при этом простым числом не считается. Составным числом называют непростое число, которое еще и не единица.

+

Примеры простых чисел: \(2\), \(3\), \(5\), \(179\), \(10^9+7\), \(10^9+9\).

+

Примеры составных чисел: \(4\), \(15\), \(2^{30}\).

+

Еще одно определение простого числа: \(N\) — простое, если у \(N\) ровно два делителя. Эти делители при этом равны \(1\) и \(N\).

+

Проверка на простоту за линию

+

С точки зрения программирования интересно научиться проверять, является ли число \(N\) простым. Это очень легко сделать за \(O(N)\) - нужно просто проверить, делится ли оно хотя бы на одно из чисел \(2, 3, 4, \ldots, N-1\) . \(N > 1\) является простым только в случае, если оно не делится на на одно из этих чисел.

+
def is_prime(n):
+    if n == 1:
+        return False
+    for i in range(2, n): # начинаем с 2, так как на 1 все делится; n не включается
+        if n % i == 0:
+            return False
+    return True
+
+for i in range(1, 10):
+    print(i, is_prime(i))
+
(1, False)
+(2, True)
+(3, True)
+(4, False)
+(5, True)
+(6, False)
+(7, True)
+(8, False)
+(9, False)
+

Проверка на простоту за корень

+

Алгоритм можно ускорить с \(O(N)\) до \(O(\sqrt{N})\).

+

Пусть \(N = a \times b\), причем \(a \leq b\). Тогда заметим, что \(a \leq \sqrt N \leq b\).

+

Почему? Потому что если \(a \leq b < \sqrt{N}\), то \(ab \leq b^2 < N\), но \(ab = N\). А если \(\sqrt{N} < a \leq b\), то \(N < a^2 \leq ab\), но \(ab = N\).

+

Иными словами, если число \(N\) равно произведению двух других, то одно из них не больше корня из \(N\), а другое не меньше корня из \(N\).

+

Из этого следует, что если число \(N\) не делится ни на одно из чисел \(2, 3, 4, \ldots, \lfloor\sqrt{N}\rfloor\), то оно не делится и ни на одно из чисел \(\lceil\sqrt{N}\rceil + 1, \ldots, N-2, N-1\), так как если есть делитель больше корня (не равный \(N\)), то есть делитель и меньше корня (не равный 1). Поэтому в цикле for достаточно проверять числа не до \(N\), а до корня.

+
def is_prime(n):
+    if n == 1:
+        return False
+    # Удобно вместо for i in range(2, n ** 0.5) писать так:
+    i = 2
+    while i * i <= n:
+        if n % i == 0:
+            return False
+        i += 1
+    return True
+
+for i in [1, 2, 3, 10, 11, 12, 10**9+6, 10**9+7]:
+    print(i, is_prime(i))
+
(1, False)
+(2, True)
+(3, True)
+(10, False)
+(11, True)
+(12, False)
+(1000000006, False)
+(1000000007, True)
+

Разложение на простые множители

+

Любое натуральное число можно разложить на произведение простых, и с такой записью очень легко работать при решении задач. Разложение на простые множители еще называют факторизацией.

+

\[11 = 11 = 11^1\] \[100 = 2 \times 2 \times 5 \times 5 = 2^2 \times 5^2\] \[126 = 2 \times 3 \times 3 \times 7 = 2^1 \times 3^2 \times 7^1\]

+

Рассмотрим, например, такую задачу:

+

Условие: Нужно разбить \(N\) людей на группы равного размера. Нам интересно, какие размеры это могут быть.

+

Решение: По сути нас просят найти число делителей \(N\). Нужно посмотреть на разложение числа \(N\) на простые множители, в общем виде оно выглядит так:

+

\[N= p_1^{a_1} \times p_2^{a_2} \times \ldots \times p_k^{a_k}\]

+

Теперь подумаем над этим выражением с точки зрения комбинаторики. Чтобы «сгенерировать» какой-нибудь делитель, нужно подставить в степень \(i\)-го простого число от 0 до \(a_i\) (то есть \(a_i+1\) различное значение), и так для каждого. То есть делитель \(N\) выглядит ровно так: \[M= p_1^{b_1} \times p_2^{b_2} \times \ldots \times p_k^{b_k}, 0 \leq b_i \leq a_i\] Значит, ответом будет произведение \((a_1+1) \times (a_2+1) \times \ldots \times (a_k + 1)\).

+

Алгоритм разложения на простые множители

+

Применяя алгоритм проверки числа на простоту, мы умеем легко находить минимальный простой делитель числа N. Ясно, что как только мы нашли простой делитель числа \(N\), мы можем число \(N\) на него поделить и продолжить искать новый минимальный простой делитель.

+

Будем перебирать простой делитель от \(2\) до корня из \(N\) (как и раньше), но в случае, если \(N\) делится на этот делитель, будем просто на него делить. Причем, возможно, нам понадобится делить несколько раз (\(N\) может делиться на большую степень этого простого делителя). Так мы будем набирать простые делители и остановимся в тот момент, когда \(N\) стало либо \(1\), либо простым (и мы остановились, так как дошли до корня из него). Во втором случае надо еще само \(N\) добавить в ответ.

+

Напишем алгоритм факторизации:

+
def factorize(n):
+    factors = []
+    i = 2
+    while i * i <= n: # перебираем простой делитель
+        while n % i == 0: # пока N на него делится
+            n //= i # делим N на этот делитель
+            factors.append(i)
+        i += 1
+    # возможно, в конце N стало большим простым числом,
+    # у которого мы дошли до корня и поняли, что оно простое
+    # его тоже нужно добавить в разложение
+    if n > 1:
+        factors.append(n)
+    return factors
+
+for i in [1, 2, 3, 10, 11, 12, 10**9+6, 10**9+7]:
+    print(i, '=', ' x '.join(str(x) for x in factorize(i)))
+
1 = 
+2 = 2
+3 = 3
+10 = 2 x 5
+11 = 11
+12 = 2 x 2 x 3
+1000000006 = 2 x 500000003
+1000000007 = 1000000007
+

Задание

+

За сколько работает этот алгоритм?

+

.

+

.

+

.

+

.

+

Решение

+

За те же самые \(O(\sqrt{N})\). Итераций цикла while с перебором делителя будет не больше, чем \(\sqrt{N}\). Причем ровно \(\sqrt{N}\) операций будет только в том случае, если \(N\) - простое.

+

А итераций деления \(N\) на делители будет столько, сколько всего простых чисел в факторизации числа \(N\). Понятно, что это не больше, чем \(O(\log{N})\).

+

Задание

+

Докажите, что число \(N\) имеет не больше, чем \(O(\log{N})\) простых множителей в факторизации.

+

Разные свойства простых чисел*

+

Вообще, про простые числа известно много свойств, но почти все из них очень трудно доказать. Вот еще некоторые из них:

+
    +
  • Простых чисел, меньших \(N\), примерно \(\frac{N}{\ln N}\).
  • +
  • N-ое простое число равно примерно \(N\ln N\).
  • +
  • Простые числа распределены более-менее равномерно. Например, если вам нужно найти какое-то простое число в промежутке, то можно их просто перебрать и проверить — через несколько сотен какое-нибудь найдется.
  • +
  • Для любого \(N \ge 2\) на интервале \((N, 2N)\) всегда найдется простое число (Постулат Бертрана)
  • +
  • Впрочем, существуют сколь угодно длинные отрезки, на которых простых чисел нет. Самый простой способ такой построить - это начать с \(N! + 2\).
  • +
  • Есть алгоритмы, проверяющие число на простоту намного быстрее, чем за корень.
  • +
  • Максимальное число делителей равно примерно \(O(\sqrt[3]{n})\). Это не математический результат, а чисто эмпирический — не пишите его в асимптотиках.
  • +
  • Максимальное число делителей у числа на отрезке \([1, 10^5]\) — 128
  • +
  • Максимальное число делителей у числа на отрекзке \([1, 10^9]\) — 1344
  • +
  • Максимальное число делителей у числа на отрезке \([1, 10^{18}]\) — 103680
  • +
  • Наука умеет факторизовать числа за \(O(\sqrt[4]{n})\), но об этом как-нибудь в другой раз.
  • +
  • Любое число больше трёх можно представить в виде суммы двух простых (гипотеза Гольдбаха), но это не доказано.
  • +
+

Решето Эратосфена

+

Часто нужно не проверять на простоту одно число, а найти все простые числа до \(N\). В этом случае наивный алгоритм будет работать за \(O(N\sqrt N)\), так как нужно проверить на простоту каждое число от 1 до \(N\).

+

Но древний грек Эратосфен предложил делать так:

+

Запишем ряд чисел от 1 до \(N\) и будем вычеркивать числа: * делящиеся на 2, кроме самого числа 2 * затем деляющиеся на 3, кроме самого числа 3 * затем на 5, затем на 7, и так далее и все остальные простые до n. Таким образом, все незачеркнутые числа будут простыми — «решето» оставит только их.

+

Красивая визуализация

+

Задание

+

Найдите этим способом на бумажке все простые числа до 50, потом проверьте с программой:

+
N = 50
+prime = [1] * (N + 1)
+prime[0], prime[1] = 0, 0
+for i in range(2, N + 1): # можно и до sqrt(N)
+    if prime[i]:
+        for j in range(2 * i, N + 1, i): # идем с шагом i, можно начиная с i * i
+            prime[j] = 0
+for i in range(1, N + 1):
+    if prime[i]:
+        print(i)
+
2
+3
+5
+7
+11
+13
+17
+19
+23
+29
+31
+37
+41
+43
+47
+

У этого алгоритма можно сразу заметить несколько ускорений.

+

Во-первых, число \(i\) имеет смысл перебирать только до корня из \(N\), потому что при зачеркивании составных чисел, делящихся на простое \(i > \sqrt N\), мы ничего не зачеркнем. Почему? Пусть существует составное \(M \leq N\), которое делится на %i%, и мы его не зачеркнули. Но тогда \(i > \sqrt N \geq \sqrt M\), а значит по ранее нами доказанному утверждению \(M\) должно делиться и на простое число, которое меньше корня. Но это значит, что мы его уже вычеркнули.

+

Во-вторых, по этой же самое причине \(j\) имеет смысл перебирать только начиная с \(i^2\). Зачем вычеркивать \(2i\), \(3i\), \(4i\), …, \((i-1)i\), если они все уже вычеркнуты, так как мы уже вычеркивали всё, что делится на \(2\), \(3\), \(4\), …, \((i-1)\).

+

Асимптотика

+

Такой код будет работать за \(O(N \log \log N)\) по причинам, которые мы пока не хотим объяснять формально.

+

Гармонический ряд

+

Научимся оценивать асимптотику величины \(1 + \frac{1}{2} + \ldots + \frac{1}{N}\), которая нередко встречается в задачах, где фигурирует делимость.

+

Возьмем \(N\) равное \(2^i - 1\) и запишем нашу сумму следующим образом: \[\left(\frac{1}{1}\right) + \left(\frac{1}{2} + \frac{1}{3}\right) + \left(\frac{1}{4} + \ldots + \frac{1}{7}\right) + \ldots + \left(\frac{1}{2^{i - 1}} + \ldots + \frac{1}{2^i - 1}\right)\]

+

Каждое из этих слагаемых имеет вид \[\frac{1}{2^j} + \ldots + \frac{1}{2^{j + 1} - 1} \le \frac{1}{2^j} + \ldots + \frac{1}{2^j} = 2^j \frac{1}{2^j} = 1\]

+

Таким образом, наша сумма не превосходит \(1 + 1 + \ldots + 1 = i \le 2\log_2(2^i - 1)\). Тем самым, взяв любое \(N\) и дополнив до степени двойки, мы получили асимптотику \(O(\log N)\).

+

Оценку снизу можно получить аналогичным образом, оценив каждое такое слагаемое снизу значением \(\frac{1}{2}\).

+

Попытка объяснения асимптотики** (для старших классов)

+

Мы знаем, что гармонический ряд \(1 + \frac{1}{2} + \frac{1}{3} + \ldots + \frac{1}{N}\) это примерно \(\log N\), а значит \[N + \frac{N}{2} + \frac{N}{3} + \ldots + \frac{N}{N} \sim N \log N\]

+

А что такое асимптотика решета Эратосфена? Мы как раз ровно \(\frac{N}{p}\) раз зачеркиваем числа делящиеся на простое число \(p\). Если бы все числа были простыми, то мы бы как раз получили \(N \log N\) из формули выше. Но у нас будут не все слагаемые оттуда, только с простым \(p\), поэтому посмотрим чуть более точно.

+

Известно, что простых чисел до \(N\) примерно \(\frac{N}{\log N}\), а значит допустим, что k-ое простое число примерно равно \(k ln k\). Тогда

+

\[\sum_{\substack{2 \leq p \leq N \\ \text{p is prime}}} \frac{N}{p} \sim \frac{1}{2} + \sum_{k = 2}^{\frac{N}{\ln N}} \frac{N}{k \ln k} \sim \int_{2}^{\frac{N}{\ln N}} \frac{N}{k \ln k} dk =N(\ln\ln\frac{N}{\ln N} - \ln\ln 2) \sim N(\ln\ln N - \ln\ln\ln N) \sim N \ln\ln N\]

+

Но вообще-то решето можно сделать и линейным.

+

Задание

+

Решите 5 первых задач из этого контеста:

+

https://informatics.msk.ru/mod/statements/view.php?id=34271

+

Линейное решето Эратосфена*

+

Наша цель — для каждого числа до \(N\) посчитать его минимальный простой делитель. Будем хранить его в массиве min_d. Параллельно будем хранить и список всех найденных простых чисел primes - это ровно те числа \(x\), у которых \(min\_d[x] = x\).

+

Основное утверждение такое:

+
+

Пусть у числа \(M\) минимальный делитель равен \(a\). Тогда, если \(M\) составное, мы хотим вычеркнуть его ровно один раз при обработке числа \(\frac{M}{a}\).

+
+

Мы также перебираем число \(i\) от \(2\) до \(N\). Если \(min\_d[i]\) равно 0 (то есть мы не нашли ни один делитель у этого числа еще), значит оно простое - добавим в primes и сделаем \(min\_d[i] = i\).

+

Далее мы хотим вычеркнуть все числа \(i \times k\) такие, что \(k\) - это минимальный простой делитель этого числа. Из этого следует, что необходимо и достаточно перебрать \(k\) в массиве primes, и только до тех пор, пока \(k < min\_d[i]\). Ну и перестать перебирать, если \(i \times k > N\).

+

Алгоритм пометит все числа по одному разу, поэтому он корректен и работает за \(O(N)\).

+
N = 30
+primes = []
+min_d = [0] * (N + 1)
+
+for i in range(2, N + 1):
+    if min_d[i] == 0:
+        min_d[i] = i
+        primes.append(i)
+    for p in primes:
+        if p > min_d[i] or i * p > N:
+            break
+        min_d[i * p] = p
+    print(i, min_d)
+print(min_d)
+print(primes)
+
2 [0, 0, 2, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+3 [0, 0, 2, 3, 2, 0, 2, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+4 [0, 0, 2, 3, 2, 0, 2, 0, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+5 [0, 0, 2, 3, 2, 5, 2, 0, 2, 3, 2, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0]
+6 [0, 0, 2, 3, 2, 5, 2, 0, 2, 3, 2, 0, 2, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0]
+7 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 0, 2, 0, 2, 3, 0, 0, 0, 0, 0, 3, 0, 0, 0, 5, 0, 0, 0, 0, 0]
+8 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 0, 2, 0, 2, 3, 2, 0, 0, 0, 0, 3, 0, 0, 0, 5, 0, 0, 0, 0, 0]
+9 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 0, 2, 0, 2, 3, 2, 0, 2, 0, 0, 3, 0, 0, 0, 5, 0, 3, 0, 0, 0]
+10 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 0, 2, 0, 2, 3, 2, 0, 2, 0, 2, 3, 0, 0, 0, 5, 0, 3, 0, 0, 0]
+11 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 0, 2, 3, 2, 0, 2, 0, 2, 3, 2, 0, 0, 5, 0, 3, 0, 0, 0]
+12 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 0, 2, 3, 2, 0, 2, 0, 2, 3, 2, 0, 2, 5, 0, 3, 0, 0, 0]
+13 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 0, 2, 0, 2, 3, 2, 0, 2, 5, 2, 3, 0, 0, 0]
+14 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 0, 2, 0, 2, 3, 2, 0, 2, 5, 2, 3, 2, 0, 0]
+15 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 0, 2, 0, 2, 3, 2, 0, 2, 5, 2, 3, 2, 0, 2]
+16 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 0, 2, 0, 2, 3, 2, 0, 2, 5, 2, 3, 2, 0, 2]
+17 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 0, 2, 3, 2, 0, 2, 5, 2, 3, 2, 0, 2]
+18 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 0, 2, 3, 2, 0, 2, 5, 2, 3, 2, 0, 2]
+19 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 0, 2, 5, 2, 3, 2, 0, 2]
+20 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 0, 2, 5, 2, 3, 2, 0, 2]
+21 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 0, 2, 5, 2, 3, 2, 0, 2]
+22 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 0, 2, 5, 2, 3, 2, 0, 2]
+23 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 23, 2, 5, 2, 3, 2, 0, 2]
+24 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 23, 2, 5, 2, 3, 2, 0, 2]
+25 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 23, 2, 5, 2, 3, 2, 0, 2]
+26 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 23, 2, 5, 2, 3, 2, 0, 2]
+27 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 23, 2, 5, 2, 3, 2, 0, 2]
+28 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 23, 2, 5, 2, 3, 2, 0, 2]
+29 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 23, 2, 5, 2, 3, 2, 29, 2]
+30 [0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 23, 2, 5, 2, 3, 2, 29, 2]
+[0, 0, 2, 3, 2, 5, 2, 7, 2, 3, 2, 11, 2, 13, 2, 3, 2, 17, 2, 19, 2, 3, 2, 23, 2, 5, 2, 3, 2, 29, 2]
+[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
+

Этот алгоритм работает асимптотически быстрее, чем обычное решето. Но на практике, если писать обычное решето Эратсфена с оптимизациями, то оно оказывается быстрее линейнего. Также линейное решето занимает гораздо больше памяти - ведь в обычном решете можно хранить просто \(N\) бит, а здесь нам нужно \(N\) чисел и еще массив primes.

+

Зато один из «побочных эффектов» алгоритма — он неявно вычисляет факторизацию всех чисел от \(1\) до \(N\). Ведь зная минимальный простой делитель любого числа от \(1\) до \(N\) можно легко поделить на это число, посмотреть на новый минимальный простой делитель и так далее.

+

НОД и НОК

+

Введем два определения.

+

Наибольший общий делитель (НОД) чисел \(a_1, a_2, \ldots, a_n\) — это максимальное такое число \(x\), что все \(a_i\) делятся на \(x\).

+

Наименьшее общее кратное (НОК) чисел \(a_1, a_2, \ldots, a_n\) — это минимальное такое число \(x\), что \(x\) делится на все \(a_i\).

+

Например, * НОД(18, 30) = 6 * НОД(60, 180, 315) = 15 * НОД(1, N) = 1 * НОК(12, 30) = 6 * НОК(1, 2, 3, 4) = 12 * НОК(1, \(N\)) = \(N\)

+

Зачем они нужны? Например, они часто возникают в задачах.

+

Условие: Есть \(N\) шестеренок, каждая \(i\)-ая зацеплена с \((i-1)\)-ой. \(i\)-ая шестеренка имеет \(a_i\) зубчиков. Сколько раз нужно повернуть полносьтю первую шестеренку, чтобы все остальные шестеренки тоже вернулись на изначальное место?

+

Решение: Когда одна шестеренка крутится на 1 зубчик, все остальные тоже крутятся на один зубчик. Нужно найти минимальное такое число зубчиков \(x\), что при повороте на него все шестеренки вернутся в изначальное положение, то есть \(x\) делится на все \(a_i\), то есть это НОК(\(a_1, a_2, \ldots, a_N\)). Ответом будет \(\frac{x}{a_1}\).

+

Еще пример задачи на применение НОД и НОК:

+

Условие: Город — это прямоугольник \(n\) на \(m\), разделенный на квадраты единичного размера. Вертолет летит из нижнего левого угла в верхний правый по прямой. Вертолет будит людей в квартале, когда он пролетает строго над его внутренностью (границы не считаются). Сколько кварталов разбудит вертолёт?

+

Решение: Вертолет пересечет по вертикали \((m-1)\) границу. С этим ничего не поделать — каждое считается как новое посещение какого-то квартала. По горизонтали то же самое — \((n-1)\) переход в новую ячейку будет сделан.

+

Однако еще есть случай, когда он пересекает одновременно обе границы (то есть пролетает над каким-нибудь углом) — ровно тот случай, когда нового посещения квартала не происходит. Сколько таких будет? Ровно столько, сколько есть целых решений уравнения \(\frac{n}{m} = \frac{x}{y}\). Мы как бы составили уравнение движения вертолёта и ищем, в скольки целых точках оно выполняется.

+

Пусть \(t = НОД(n, m)\), тогда \(n = at, m = bt\).

+

Тогда \(\frac{n}{m} = \frac{a}{b} = \frac{x}{y}\). Любая дробь с натуральными числителем и знаменателем имеет ровно одно представление в виде несократимой дроби, так что \(x\) должно делиться на \(a\), а \(y\) должно делиться на \(b\). А значит, как ответ подходят \((a, b), (2a, 2b), (3a, 3b), \cdots, ((t-1)a, (t-1)b)\). Таких ответов ровно \(t = НОД(n, m)\)

+

Значит, итоговый ответ: \((n-1) + (m-1) - (t-1)\).

+

Кстати, когда \(НОД(a, b) = 1\), говорят, что \(a\) и \(b\) взаимно просты.

+
+

Алгоритм Евклида

+

Осталось придумать, как искать НОД и НОК. Понятно, что их можно искать перебором, но мы хотим хороший быстрый способ.

+

Давайте для начала научимся искать \(НОД(a, b)\).

+

Мы можем воспользоваться следующим равенством: \[НОД(a, b) = НОД(a, b - a), b > a\]

+

Оно доказывается очень просто: надо заметить, что множества общих делителей у пар \((a, b)\) и \((a, b - a)\) совпадают. Почему? Потому что если \(a\) и \(b\) делятся на \(x\), то и \(b-a\) делится на \(x\). И наоборот, если \(a\) и \(b-a\) делятся на \(x\), то и \(b\) делится на \(x\). Раз множства общих делитей совпадают, то и максимальный делитель совпадает.

+

Из этого равенства сразу следует следующее равенство: \[НОД(a, b) = НОД(a, b \operatorname{\%} a), b > a\]

+

(так как \(НОД(a, b) = НОД(a, b - a) = НОД(a, b - 2a) = НОД(a, b - 3a) = \ldots = НОД(a, b \operatorname{\%} a)\))

+

Это равенство дает идею следующего рекурсивного алгоритма:

+

\[НОД(a, b) = НОД(b \operatorname{\%} a, a) = НОД(a \operatorname{\%} \, (b \operatorname{\%} a), b \operatorname{\%} a) = \ldots\]

+

Например: \[НОД(93, 36) = \] \[= НОД(36, 93\space\operatorname{\%}36) = НОД(36, 21) = \] \[= НОД(21, 15) = \] \[= НОД(15, 6) = \] \[= НОД(6, 3) = \] \[= НОД(3, 0) = 3\]

+

Задание:

+

Примените алгоритм Евклида и найдите НОД чисел: * 1 и 500000 * 10, 20 * 18, 60 * 55, 34 * 100, 250

+
+

По-английски наибольший общий делительgreatest common divisor. Поэтому вместо НОД будем в коде писать gcd.

+
def gcd(a, b):
+    if b == 0:
+        return a
+    return gcd(b, a % b)
+
+print(gcd(1, 500000))
+print(gcd(10, 20))
+print(gcd(18, 60))
+print(gcd(55, 34))
+print(gcd(100, 250))
+print(gcd(2465473782, 12542367456))
+
1
+10
+6
+1
+50
+6
+

Вообще, в C++ такая функция уже есть в компиляторе g++ — называется __gcd. Если у вас не Visual Studio, то, скорее всего, у вас g++. Вообще, там много всего интересного.

+

А за сколько оно вообще работает?

+

Задание

+

Докажите, что алгоритм Евклида для чисел \(N\), \(M\) работает за \(O(\log(N+M))\).

+

Кстати, интересный факт: самыми плохими входными данными для алгоритма Евклида являются числа Фибоначчи. Именно там и достигается логарифм.

+

Как выразить НОК через НОД

+
+

\(НОК(a, b) = \frac{ab}{НОД(a, b)}\)

+
+

По этой формуле можно легко найти НОК двух чисел через их произведение и НОД. Почему она верна?

+

Посмотрим на разложения на простые множители чисел a, b, НОК(a, b), НОД(a, b).

+

\[ a = p_1^{a_1}\times p_2^{a_2}\times\ldots\times p_n^{a_n} \] \[ b = p_1^{b_1}\times p_2^{b_2}\times\ldots\times p_n^{b_n} \] \[ ab = p_1^{a_1+b_1}\times p_2^{a_2+b_2}\times\ldots\times p_n^{a_n+b_n} \]

+

Из определений НОД и НОК следует, что их факторизации выглядят так: \[ НОД(a, b) = p_1^{min(a_1, b_1)}\times p_2^{min(a_2, b_2)}\times\ldots\times p_n^{min(a_n, b_n)} \] \[ НОК(a, b) = p_1^{max(a_1, b_1)}\times p_2^{max(a_2, b_2)}\times\ldots\times p_n^{max(a_n, b_n)} \]

+

Тогда посчитаем \(НОД(a, b) \times НОК(a, b)\): \[ НОД(a, b)НОК(a, b) = p_1^{min(a_1, b_1)+max(a_1, b_1)}\times p_2^{min(a_2, b_2)+max(a_2, b_2)}\times\ldots\times p_n^{min(a_n, b_n)+max(a_n, b_n)} =\] \[ = p_1^{a_1+b_1}\times p_2^{a_2+b_2}\times\ldots\times p_n^{a_n+b_n} = ab\]

+

Формула доказана.

+

Как посчитать НОД/НОК от более чем 2 чисел

+

Для того, чтобы искать НОД или НОК у более чем двух чисел, достаточно считать их по цепочке:

+
+

\(НОД(a, b, c, d, \ldots) = НОД(НОД(a, b), c, d, \ldots)\)

+
+
+

\(НОК(a, b, c, d, \ldots) = НОК(НОК(a, b), c, d, \ldots)\)

+
+

Почему это верно?

+

Ну просто множество общих делителей \(a\) и \(b\) совпадает с множеством делителей \(НОД(a, b)\). Из этого следует, что и множество общих делителей \(a\), \(b\) и еще каких-то чисел совпадает с множеством общих делителей \(НОД(a, b)\) и этих же чисел. И раз совпадают множества общих делителей, то и наибольший из них совпадает.

+

С НОК то же самое, только фразу “множество общих делителей” надо заменить на “множество общих кратных”.

+

Задание

+

Решите задачи F, G, H, I из этого контеста:

+

https://informatics.msk.ru/mod/statements/view.php?id=34271

+

Расширенный алгоритм Евклида*

+

Очень важным для математики свойством наибольшего общего делителя является следующий факт:

+
+

Для любых целых \(a, b\) найдутся такие целые \(x, y\), что \(ax + by = d\), где \(d = \gcd(a, b)\).

+
+

Из этого следует, что существует решение в целых числах, например, у таких уравнений: * \(8x + 6y = 2\) * \(4x - 5y = 1\) * \(116x + 44y = 4\) * \(3x + 11y = -1\)

+

Мы сейчас не только докажем, что решения у таких уравнений существуют, но и приведем быстрый алгоритм нахождения этих решений. Здесь нам вновь пригодится алгоритм Евклида.

+

Рассмотрим один шаг алгоритма Евклида, преобразующий пару \((a, b)\) в пару \((b, a \operatorname{\%} b)\). Обозначим \(r = a \operatorname{\%} b\), то есть запишем деление с остатком в виде \(a = bq + r\).

+

Предположим, что у нас есть решение данного уравнения для чисел \(b\) и \(r\) (их наибольший общий делитель, как известно, тоже равен \(d\)): \[bx_0 + ry_0 = d\]

+

Теперь сделаем в этом выражении замену \(r = a - bq\):

+

\[bx_0 + ry_0 = bx_0 + (a - bq)y_0 = ay_0 + b(x_0 - qy_0)\]

+

Tаким образом, можно взять \(x = y_0\), а \(y = (x_0 - qy_0) = (x_0 - (a \operatorname{/} b)y_0)\) (здесь \(/\) обозначает целочисленное деление).

+

В конце алгоритма Евклида мы всегда получаем пару \((d, 0)\). Для нее решение требуемого уравнения легко подбирается — \(d * 1 + 0 * 0 = d\). Теперь, используя вышесказанное, мы можем идти обратно, при вычислении заменяя пару \((x, y)\) (решение для чисел \(b\) и \(a \operatorname{\%} b\)) на пару \((y, x - (a / b)y)\) (решение для чисел \(a\) и \(b\)).

+

Это удобно реализовывать рекурсивно:

+
def extended_gcd(a, b):
+    if b == 0:
+        return a, 1, 0
+    d, x, y = extended_gcd(b, a % b)
+    return d, y, x - (a // b) * y
+
+a, b = 3, 5
+res = extended_gcd(a, b)
+print("{3} * {1} + {4} * {2} = {0}".format(res[0], res[1], res[2], a, b))
+
3 * 2 + 5 * -1 = 1
+

Но также полезно и посмотреть, как будет работать расширенный алгоритм Евклида и на каком-нибудь конкретном примере. Пусть мы, например, хотим найти целочисленное решение такого уравнения: \[116x + 44y = 4\] \[(2\times44+28)x + 44y = 4\] \[44(2x+y) + 28x = 4\] \[44x_0 + 28y_0 = 4\] Следовательно, \[x = y_0, y = x_0 - 2y_0\] Будем повторять такой шаг несколько раз, получим такие уравнения: \[116x + 44y = 4\] \[44x_0 + 28y_0 = 4, x = y_0, y = x_0 - 2y_0\] \[28x_1 + 16y_1 = 4, x_0 = y_1, y_0 = x_1 - y_1\] \[16x_2 + 12y_2 = 4, x_1 = y_2, y_1 = x_2 - y_2\] \[12x_3 + 4y_3 = 4, x_2 = y_3, y_2 = x_3 - y_3\] \[4x_4 + 0y_4 = 4, x_3 = y_4, y_3 = x_4 - 3 y_4\] А теперь свернем обратно: \[x_4 = 1, y_4 = 0\] \[x_3 = 0, y_3 =1\] \[x_2 = 1, y_2 =-1\] \[x_1 = -1, y_1 =2\] \[x_0 = 2, y_0 =-3\] \[x = -3, y =8\]

+

Действительно, \(116\times(-3) + 44\times8 = 4\)

+

Задание

+

Решите задачу J из этого контеста:

+

https://informatics.msk.ru/mod/statements/view.php?id=34273

+

Операции по модулю

+

Выражение \(a \equiv b \pmod m\) означает, что остатки от деления \(a\) на \(m\) и \(b\) на \(m\) равны. Это выражение читается как «\(a\) сравнимо \(b\) по модулю \(m\)».

+

Еще это можно опрделить так: \(a\) сравнимо c \(b\) по модулю \(m\), если \((a - b)\) делится на \(m\).

+

Все целые числа можно разделить на классы эквивалентности — два числа лежат в одном классе, если они сравнимы по модулю \(m\). Говорят, что мы работаем в «кольце остатков по модулю \(m\)», и в нем ровно \(m\) элементов: \(0, 1, 2, \cdots, m-1\).

+

Сложение, вычитение и умножение по модулю определяются довольно интуитивно — нужно выполнить соответствующую операцию и взять остаток от деления.

+

С делением намного сложнее — поделить и взять по модулю не работает. Об этом подробнее поговорим чуть дальше.

+
a = 30
+b = 50
+mod = 71
+
+print('{} + {} = {} (mod {})'.format(a, b, (a + b) % mod, mod))
+print('{} - {} = {} (mod {})'.format(a, b, (a - b) % mod, mod)) # на C++ это может не работать, так как модуль от отрицательного числа берется странно
+print('{} - {} = {} (mod {})'.format(a, b, (a - b + mod) % mod, mod)) # на C++ надо писать так, чтобы брать модулю от гарантированно неотрицательного числа
+print('{} * {} = {} (mod {})'.format(a, b, (a * b) % mod, mod))
+# print((a / b) % mod) # а как писать это, пока неясно
+
30 + 50 = 9 (mod 71)
+30 - 50 = 51 (mod 71)
+30 - 50 = 51 (mod 71)
+30 * 50 = 9 (mod 71)
+

Задание

+

Посчитайте: * \(2 + 3 \pmod 5\) * \(2 * 3 \pmod 5\) * \(2 ^ 3 \pmod 5\) * \(2 - 4 \pmod 5\) * \(5 + 5 \pmod 6\) * \(2 * 3 \pmod 6\) * \(3 * 3 \pmod 6\)

+

Для умножения (в C++) нужно ещё учитывать следующий факт: при переполнении типа всё ломается (разве что если вы используете в качестве модуля степень двойки).

+
    +
  • int вмещает до \(2^{31} - 1 \approx 2 \cdot 10^9\).
  • +
  • long long вмещает до \(2^{63} - 1 \approx 8 \cdot 10^{18}\).
  • +
  • long long long в плюсах нет, при попытке заиспользовать выдает ошибку long long long is too long.
  • +
  • Под некоторыми компиляторами и архитектурами доступен int128, но не везде и не все функции его поддерживают (например, его нельзя вывести обычными методами).
  • +
+

Зачем нужно считать ответ по модулю

+

Очень часто в задаче нужно научиться считать число, которое в худшем случае гораздо больше, чем \(10^{18}\). Тогда, чтобы не заставлять вас писать длинную арифметику, автор задачи часто просит найти ответ по модулю большого числа, обычно \(10^9 + 7\)

+

Кстати, вместо того, чтобы писать \(1000000007\) удобно просто написать \(1e9 + 7\). \(1e9\) означает \(1 \times 10^9\)

+
int mod = 1e9 + 7; # В C++
+cout << mod;
+
1000000007
+
N = 1e9 + 7 # В питоне такое число становится float
+print(N)
+print(int(N))
+
1000000007.0
+1000000007
+

Быстрое возведение в степень

+

Задача: > Даны натуральные числа \(a, b, c < 10^9\). Найдите \(a^b\) (mod \(c\)).

+

Мы хотим научиться возводить число в большую степень быстро, не просто умножая \(a\) на себя \(b\) раз. Требование на модуль здесь дано только для того, чтобы иметь возможность проверить правильность алгоритма для чисел, которые не влезают в int и long long.

+

Сам алгоритм довольно простой и рекурсивный, постарайтесь его придумать, решая вот такие примеры (прямо решать необязательно, но можно придумать, как посчитать значение этих чисел очень быстро):

+
    +
  • \(3^2\)
  • +
  • \(3^4\)
  • +
  • \(3^8\)
  • +
  • \(3^{16}\)
  • +
  • \(3^{32}\)
  • +
  • \(3^{33}\)
  • +
  • \(3^{66}\)
  • +
  • \(3^{132}\)
  • +
  • \(3^{133}\)
  • +
  • \(3^{266}\)
  • +
  • \(3^{532}\)
  • +
  • \(3^{533}\)
  • +
  • \(3^{1066}\)
  • +
+

Да, здесь специально приведена такая последовательность, в которой каждое следующее число легко считается через предыдущее: его либо нужно умножить на \(a=3\), либо возвести в квадрат. Так и получается рекурсивный алгоритм:

+
    +
  • \(a^0 = 1\)
  • +
  • \(a^{2k}=(a^{k})^2\)
  • +
  • \(a^{2k+1}=a^{2k}\times a\)
  • +
+

Нужно только после каждой операции делать mod: * \(a^0 \pmod c = 1\) * \(a^{2k} \pmod c = (a^{k} \pmod c)^2 \pmod c\) * \(a^{2k+1} \pmod c = ((a^{2k}\pmod c) \times a) \pmod c\)

+

Этот алгоритм называется быстрое возведение в степень. Он имеет много применений: * в криптографии очень часто надо возводить число в большую степень по модулю * используется для деления по простому модулю (см. далее) * можно быстро перемножать не только числа, но еще и матрицы (используется для динамики, например)

+

Асимптотика этого алгоритма, очевидно, \(O(\log c)\) - за каждые две итерации число уменьшается хотя бы в 2 раза.

+

Задание

+

Решите задачу K из этого контеста:

+

https://informatics.msk.ru/mod/statements/view.php?id=34271

+

Задание

+

Решите как можно больше задач из практического контеста:

+

https://informatics.msk.ru/mod/statements/view.php?id=34273

+

Деление по модулю*

+

Давайте все-таки научимся не только умножать, но и делить по простому модулю. Вот только что это значит?

+

\(a / b\) = \(a \times b^{-1}\), где \(b^{-1}\) - это обратный элемент к \(b\).

+

Определение: \(b^{-1}\) - это такое число, что \(bb^{-1} = 1\)

+

Утверждение: в кольце остатков по простому модулю \(p\) у каждого остатка (кроме 0) существует ровно один обратный элемент.

+

Например, обратный к \(2\) по модулю \(5\) это \(3\) (\(2 \times 3 = 1 \pmod 5\)))

+

Задание

+

Найдите обратный элемент к: * числу \(3\) по модулю \(5\) * числу \(3\) по модулю \(7\) * числу \(1\) по модулю \(7\) * числу \(2\) по модулю \(3\) * числу \(9\) по модулю \(31\)

+

Давайте докажем это утверждение: надо заметить, что если каждый ненулевой остаток \(1, 2, \ldots, (p-1)\) умножить на ненулевой остаток \(a\), то получатся числа \(a, 2a, \ldots, (p-1)a\) - и они все разные! Они разные, потому что если \(xa = ya\), то \((x-y)a = 0\), а значит \((x - y) a\) делится на \(p\), \(a\) - ненулевой остаток, а значит \(x = y\), и это не разные числа. И из того, что все числа получились разными, это все ненулевые, и их столько же, следует, что это ровно тот же набор чисел, просто в другом порядке!

+

Из этого следует, что среди этих чисел есть \(1\), причем ровно один раз. А значит существует ровно один обратный элемент \(a^{-1}\). Доказательство закончено.

+

Это здорово, но этот обратный элемент еще хочется быстро находить. Быстрее, чем за \(O(p)\).

+

Есть несколько способов это сделать.

+

Через малую теорему Ферма

+

Малая теорема Ферма: > \(a^{p-1} = 1 \pmod p\), если \(p\) - простое, \(a \neq 0 \pmod p\)).

+

Доказательство: В предыдущем пункте мы выяснили, что множества чисел \(1, 2, \ldots, (p-1)\) и \(a, 2a, \ldots, (p-1)a\) совпадают. Из этого следует, что их произведения тоже совпадают по модулю: \((p-1)! = a^{p-1} (p-1)! \pmod p\).

+

\((p-1)!\neq 0 \pmod p\) а значит на него можно поделить (это мы кстати только в предыдущем пункте доказали, поделить на число - значит умножить на обратный к нему, который существует).

+

А значит, \(a^{p - 1} = 1 \pmod p\).

+

Как это применить Осталось заметить, что из малой теоремы Ферма сразу следует, что \(a^{p-2}\) - это обратный элемент к \(a\), а значит мы свели задачу к возведению \(a\) в степень \(p-2\), что благодаря быстрому возведению в степень мы умеем делать за \(O(\log p)\).

+

Обобщение У малой теоремы Ферма есть обобщение для составных \(p\):

+

Теорема Эйлера: > \(a^{\varphi(p)} = 1 \pmod p\), \(a\) - взаимно просто с \(p\), а \(\varphi(p)\) - это функция Эйлера (количество чисел, меньших \(p\) и взаимно простых с \(p\)).

+

Доказывается теорема очень похоже, только вместо ненулевых остатков \(1, 2, \ldots, p-1\) нужно брать остатки, взаимно простые с \(p\). Их как раз не \(p-1\), а \(\varphi(p)\).

+

Для нахождения обратного по этой теореме достаточно посчитать функцию Эйлера \(\varphi(p)\) и найти \(a^{-1} = a^{\varphi(p) - 1}\).

+

Но с этим возникают большие проблемы: посчитать функцию Эйлера сложно. Более того, на предполагаемой невозможности быстро ее посчитать построены некоторые криптографические алгоритм типа RSA. Поэтому быстро делить по составному модулю этим способом не получится.

+

Через расширенный алгоритм Евклида

+

Этим способом легко получится делить по любому модулю! Рекомендую.

+

Пусть мы хотим найти \(a^{-1} \pmod p\), \(a\) и \(p\) взаимно простые (а иначе обратного и не будет существовать).

+

Давайте найдем корни уравнения

+

\[ax + py = 1\]

+

Они есть и находятся расширенным алгоритмом Евклида за \(O(\log p)\), так как \(НОД(a, p) = 1\), ведь они взаимно простые.

+

Тогда если взять остаток по модулю \(p\):

+

\[ax = 1 \pmod p\]

+

А значит, найденный \(x\) и будет обратным элементом к \(a\).

+

То есть надо просто найти \(x\) из решения того уравнения по модулю \(p\). Можно брать по модулю прямо походу решения уравнения, чтобы случайно не переполниться.

+
+ + diff --git a/tg/object-generation.html b/tg/object-generation.html new file mode 100644 index 0000000..c17fb26 --- /dev/null +++ b/tg/object-generation.html @@ -0,0 +1,268 @@ + + + + + + + Перебор - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Перебор

+

Часто в комбинаторных задачах нужно перебрать ВСЕ возможные варианты чего-нибудь. Чаще всего это какой-нибудь комбинаторный объект типа подмножеств, k-ичных чисел, сочетаний или перестановок.

+

Учебные задачи могут звучать прямо как “переберите все перестановки”. Но перебор может помочь решить и какие-нибудь реальные задачи, встречающиеся в олимпиадах.

+

Например, задачу об укладке N предметов в рюкзак можно решить просто перебором всех подмножеств и выбором самого лучшего из них. Для случая, когда веса предметов - это действительные числа, быстрые полиномиальные решения с динамикой не работают, и надо писать именно перебор.

+

Перебор всех подмножеств

+

Подмножество \(N\)-элементного множества задается маской - строкой из \(N\) цифр 0 или 1. Задача перебора всех подмножеств заключается в том, что надо вывести их все, причем в лексикографическом порядке.

+

Это можно делать двумя способами. Первый - это заметить, что достаточно просто вывести все числа от \(0\) до \(2^N - 1\) в двоичном формате.

+

Кстати, выводить числа в двоичном формате очень просто, так как числа хранятся в компьютере уже в двоичном формате. Очень удобно воспользоваться двоичными операциями. Например, \(x << y\) возвращает число \(x\), которое в двоичном формате сдвинули вправо на \(y\) битов. То есть у него появилось справа \(y\) нулей. Можно сказать, что \(x << y = x 2^y\). Поэтому часто пишут \(1 << n\), это значит \(2^n\), и работает гораздо гораздо быстрее, чем обычное возведение в степень.

+

А как вывести двоичное представление числа \(x\)? А именно как узнать \(i\)-ый справа бит числа \(x\)? Можно воспользоваться логической операцией И (\(\&\)). Она берет два числа, и возвращает число, у которого \(i\)-ый бит равен 1, если оба \(i\)-ых бита этих чисел равны 1, а иначе 0.

+

Тогда \(i\)-ый бит числа \(x\) можно извлечь благодаря операции \(x \& (2^i)\). Если он равен 1, то эта операция вернет число \(2^i\), а иначе она вернет \(0\).

+
n = 3
+for subset in range(2 ** n):
+    for bit in range(n):
+        if subset & 1 << bit:
+            print(1, end = '')
+        else:
+            print(0, end = '')
+    print()
+
000
+100
+010
+110
+001
+101
+011
+111
+

Но есть и второй способ - более общий, и им мы будем пользоваться для всех задач перебора. Идея состоит в вызове рекурсивной функции gen.

+

У функции gen есть два аргумента * n - это финальная длина подмножества * prefix - это ссылка на список/вектор с уже сгенерированным префиксом подмножества

+
def gen(n, prefix=[]):
+  if len(prefix) == n:
+    print(''.join(prefix))
+    return
+  prefix.append('0')
+  gen(n, prefix)
+  prefix.pop()
+
+  prefix.append('1')
+  gen(n, prefix)
+  prefix.pop()
+
+gen(3)
+
000
+001
+010
+011
+100
+101
+110
+111
+
# или так
+
+def gen(n, prefix_len=0, prefix=['0'] * n):
+  if prefix_len == n:
+    print(''.join(prefix))
+    return
+  prefix[prefix_len] = '0'
+  gen(n, prefix_len + 1, prefix)
+
+  prefix[prefix_len] = '1'
+  gen(n, prefix_len + 1, prefix)
+
+gen(3)
+
000
+001
+010
+011
+100
+101
+110
+111
+

Обратите внимание, как это работает: функция gen(n, prefix) перебирает, чему равен символ, следующий после prefix - либо 0, либо 1. Оба эти варианта возможны, и для них обоих нужно сгенерировать и вывести все возможные продолжения этого префикса.

+

Почему получилось вывести все подмножества в лексикографическом порядке? Потому что на каждом шаге мы сначала рассматривали случай, когда символ равен 0, и выводили все такие подмножества, а потом уже случай, когда он равен 1. Чтобы вывести все те же множества в обратном лексикографичеспорядком порядке, надо поменять местами вызовы для 0 и 1.

+

Перебор k-ичных чисел

+

Пусть, нам требуется перебрать все \(k\)-ичные числа длины \(n\). Например, это может быть нужно в переборе, где каждый из \(n\) объектов может принимать любое из \(k\) состояний.

+

Это несложное усложнение предыдущей задачи. И ее тоже можно решать двумя способами. Первый - это просто перебрать числа от \(0\) до \(k^n - 1\) и вывести их в \(k\)-ичной форме.

+

Второй - это применить рекурсивную функцию. Будем генерировать цифры числа по одной, перебирая все возможные варианты.

+
n = 2
+k = 3
+
+def gen(n, k, prefix=[]):
+  if len(prefix) == n:
+    print(''.join(prefix))
+    return
+  for i in range(k):
+    prefix.append(str(i))
+    gen(n, k, prefix)
+    prefix.pop()
+
+gen(n, k)
+
00
+01
+02
+10
+11
+12
+20
+21
+22
+

Перебор перестановок

+

Теперь мы хотим перебрать все перестановки длины \(N\), тоже в лексикографическом порядке.

+

Первый способ с циклом по двоичным числам тут уже не сработает. Но второй способ прекрасно обобщается. Будем делать абсолютно так же, перебирать все время новую цифру. Но появляется особенность - нельзя выбирать цифру, которая уже встречалась в перестановке ранее. Для удобства будем хранить массив bool-ов used.

+
n = 4
+used = [False] * n
+
+def gen(n, used, prefix=[]):
+  if len(prefix) == n:
+    print(''.join(prefix))
+    return
+  for i in range(n):
+    if not used[i]:
+      used[i] = True
+      prefix.append(str(i))
+      gen(n, used, prefix)
+      used[i] = False
+      prefix.pop()
+
+gen(n, used)
+
0123
+0132
+0213
+0231
+0312
+0321
+1023
+1032
+1203
+1230
+1302
+1320
+2013
+2031
+2103
+2130
+2301
+2310
+3012
+3021
+3102
+3120
+3201
+3210
+

Перебор сочетаний

+

Сочетания из \(n\) по \(k\) удобно представить как отсортированный набор из \(k\) разных чисел от \(1\) до \(n\). Интересно их тоже все вывести в лексикографическом порядке. Для \(n = 5, k = 3\) это сочетания от \(1 2 3\) до \(3 4 5\).

+

Эту задачу можно решить с помощью нашего рекурсивного подхода, но надо делать это аккуратно и не перебирать ни в какой момент лишние сочетания.

+

Но давайте вместо этого рассмотрим другой, новый подход. Давайте научимся генерировать следующее сочетание в смысле лексикографического порядке на основе текущего. Это тоже делается примерно одним и тем же способом всегда.

+

Первым сочетанием, возьмем просто числа от \(1\) до \(k\). Пусть теперь мы зафиксировали текущее сочетание.

+

Надо увеличивать последний элемент, который мы еще можем увеличить, а всем последующим присвоить минимальные возможные значения. Если увеличить ничего нельзя, то полученное сочетание – максимальное. Когда элемент на позиции \(i\) (считая с 0) можно увеличить? Когда существуют хотя бы \(k-i\) чисел больше текущего, чтобы можно было поставить их после него.

+
n = 5
+k = 3
+
+# самое первое сочетание
+combination = [i+1 for i in range(k)]
+
+def next_combination(combination):
+    # изменяет массив на следующее лексикографически сочетание
+    for i in range(k - 1, -1, -1):
+        if combination[i] <= n - k + i:
+            combination[i] += 1
+            for j in range(i + 1, k):
+                combination[j] = combination[j - 1] + 1
+            return True
+    return False
+
+print(combination)
+while next_combination(combination):
+    print(combination)
+
[1, 2, 3]
+[1, 2, 4]
+[1, 2, 5]
+[1, 3, 4]
+[1, 3, 5]
+[1, 4, 5]
+[2, 3, 4]
+[2, 3, 5]
+[2, 4, 5]
+[3, 4, 5]
+

Кроме того, такой подход подойдет для генерации лексикографически следующей комбинации рассмотренных раньше типов. Так у \(k\)-ичного числа можно находить последнюю цифру, не равную \(k-1\), увеличивать ее, а все последующие заменить на 0. Аналогично с генерацией подмножеств.

+

Можно проще

+

Разумеется, многое из этого уже было написано. Так, в C++, есть функция std::next_permutation, которая делает из перестановки следующую, а в питоне есть модуль itertools, который содержит несколько полезных функций.

+
from itertools import product, combinations, permutations
+print("k-ичные числа")
+for i in product('012', repeat=2):
+    print(''.join(i))
+    
+print("Перестановки")
+for i in permutations('012', 3):
+    print(''.join(i))
+    
+print("Сочетания")
+for i in combinations([1,2,3,4], 3):
+    print(i)
+
k-ичные числа
+00
+01
+02
+10
+11
+12
+20
+21
+22
+Перестановки
+012
+021
+102
+120
+201
+210
+Сочетания
+(1, 2, 3)
+(1, 2, 4)
+(1, 3, 4)
+(2, 3, 4)
+ + diff --git a/tg/probability-theory.html b/tg/probability-theory.html new file mode 100644 index 0000000..0e32b51 --- /dev/null +++ b/tg/probability-theory.html @@ -0,0 +1,181 @@ + + + + + + + Теорвер для алгоритмистов - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Теорвер для алгоритмистов

+

Вы, наконец, узнаете, почему ДД работает за логарифм, почему быстрая сортировка быстрая, какой модуль нужно выбирать для хэшей, сколько сэмплов нужно выбирать для монте-карло и т. д.

+

Следующие строчки позволят нам генерировать распределения и рисовать графики, не обращайте внимание.

+
import numpy as np
+
+import matplotlib as plt
+%matplotlib inline
+
+import seaborn as sns
+sns.set()
+

Аксиоматика

+

В вузах теорвер очень долго аксиоматизируют перед тем, как перейти к чему-то полезному. Сейчас поясним, зачем.

+

[Парадокс Бертрана](https://ru.wikipedia.org/wiki/%D0%9F%D0%B0%D1%80%D0%B0%D0%B4%D0%BE%D0%BA%D1%81_%D0%91%D0%B5%D1%80%D1%82%D1%80%D0%B0%D0%BD%D0%B0_(%D0%B2%D0%B5%D1%80%D0%BE%D1%8F%D1%82%D0%BD%D0%BE%D1%81%D1%82%D1%8C%29):

+
+

Рассмотрим равносторонний треугольник, вписанный в окружность. Наудачу выбирается хорда окружности. Какова вероятность того, что выбранная хорда длиннее стороны треугольника?

+
+

Оказывается, можно придумать хотя бы три способа решать задачу, которые выглядят адекватно, но все дают разные ответы.

+
    +
  1. Наудачу выберем две точки на окружности и проведём через них хорду. Чтобы посчитать искомую вероятность, представим, что треугольник повёрнут так, что одна из его вершин совпадает с концом хорды. Заметим, что если другой конец хорды лежит на дуге между двумя другими вершинами треугольника, то длина хорды больше стороны треугольника. Длина рассмотренной дуги равна трети длины окружности, значит искомая вероятность равна \(\frac13\).

  2. +
  3. Зафиксируем радиус окружности, наудачу выберем точку на радиусе. Построим хорду, перпендикулярную зафиксированному радиусу, проходящую через выбранную точку. Для нахождения искомой вероятности, представим, что треугольник повёрнут так, что одна из его сторон перпендикулярна зафиксированному радиусу. Хорда длиннее стороны треугольника, если её центр ближе к центру, чем точка пересечения треугольника с зафиксированным радиусом. Сторона треугольника делит пополам радиус, следовательно вероятность выбрать хорду длиннее стороны треугольника \(\frac12\).

  4. +
  5. Выберем наудачу произвольную точку внутри круга и построим хорду с центром в выбранной точке. Хорда длиннее стороны равностороннего треугольника, если выбранная точка находится внутри круга, вписанного в треугольник. Площадь вписанного круга есть \(\frac14\) от площади большего, значит исходная вероятность равна \(\frac14\).

  6. +
+

Как мы увидели, от формального определения «случайной хорды» непосредственно зависит ответ.

+

Каждый раз, когда в истории математики появляется подобный приводящий к протеворечиям парадокс, математики паникуют и начинают всё формализовывать и аксиоматизировать. Так появилась теория вероятностей (внимание: не «ти», а «тей»).

+

Перейдем к самим определениям:

+

Функцию \(X : \Omega \to R\) будем называть случайной величиной. Она сопостав- ляет каждому элементарному исходу какое-то число.

+

Распределения

+

Геометрическое распределение.

+

Парадокс дней рождений

+

Пусть \(f(n, d)\) это вероятность того, что в группе из \(n\) человек ни у кого не совпали дни рождения. Будем считать, что дни рождения распределены независимо и равномерно в промежутке от \(1\) до \(d\).

+

\[f(n, d) = (1-\frac{1}{d}) \times (1-\frac{2}{d}) \times ... \times (1-\frac{n-1}{d})\]

+

Попытаемся оценить \(f\):

+

\[ +\begin{align} + \begin{aligned} + e^x & = 1 + x + \frac{x^2}{2!} + \ldots & \text{(ряд Тейлора для экспоненты)} \\ + & \simeq 1 + x & \text{(аппроксимация для $|x| \ll 1$)} \\ + e^{-\frac{n}{d}} & \simeq 1 - \frac{n}{d} & \text{(подставим $\frac{n}{d} \ll 1$)} \\ + f(n, d) & \simeq e^{-\frac{1}{d}} \times e^{-\frac{2}{d}} \times \ldots \times e^{-\frac{n-1}{d}} & \\ + & = e^{-\frac{n(n-1)}{2d}} & \\ + & \simeq e^{-\frac{n^2}{2d}} & \\ + \end{aligned} +\end{align} +\]

+

Матожидание

+

Математическим ожиданием случайной величины \(X\), которая принимает значения \(x_1, x_2, \ldots\) называется

+

\[ E[X] = \sum_{x \in S} p_S(x) \cdot x \]

+

Самое главное для нас свойство — ожидание линейно:

+

\[ +\begin{align*} +E[X+Y] & = \sum_{x, y} (x+y) p(x, y) +\\ & = \sum_{x, y} x p(x, y) + \sum_{x, y} y p(x, y) +\\ & = \sum_x x p(x) \sum_y p(y) + \sum_y y p(y) \sum_x p(x) +\\ & = \sum_x x p(x) + \sum_y y p(y) +\\ & = E[X] + E[Y] +\end{align*} +\]

+

Акцентируем внимание: \(X\) и \(Y\) вообще не важно какие. Возможно, они связаны как-то очень сложно, но это не важно. Как мы потом, увидем, это свойство очень упрощает вычисление матожиданий каких-то очень сложных шняг.

+

Также, в частности его можно домножать на константу.

+

Теперь можно перейти к практическим задачам.

+

Геометрическое распределение

+
+

Петя кидает монету, с вероятностью \(p\) выпадает орел и он прекращает кидать ее, с вероятностью \(1-p\) выпадает решка и он кидает ее еще раз. Пусть \(X\) - это количество бросков. Найдите \(\rho_X(x)\) и \(F_X(x)\).

+
+

Неподвижные точки в перестановке

+

Когда автор пришел на первое занятие по английскому в МФТИ, препод устроила следующую игру на знакомство: разделила всех студентов на две команды. В каждой команде про своих членов загадываются факты: кто-то мечтал стать акробатом, кто-то смотрит анимэ и всё в таком духе. Команде соперников сообщались только эти факты, и им нужно было отгадать, кому какой принадлежит. Побеждает команда, которая отгадала больше фактов о соперниках. Нас было 11 — простое число, никак не делящееся на равные команды, и поэтому в одной команде было 5 человек, а в другой 6. Автору стало интересно: если отбросить все психологические аспекты и делать все рандомно, у какой команды вероятность победить выше?

+

Следует заметить, что все-таки ожидание правильно отгаданных вопросов не очень помогает выяснить, у какой команды есть преимущество и какое.

+

Высота декартова дерева

+

Высота дерева. Высота в среднем логарифмическая. Любознательные могут ознакомиться с типа доказательством, но это не обязательно.

+

Глубина вершины — это количество родителей (прим. К. О.). Введем индикатор. $E[h] = n $.

+

Асимптотика quicksort-а

+

Тут на самом деле будет примерно так же, как с ДД. Нас интересует суммарное число сравнений. Просуммируем для каждой пары элементов вероятность, что они будут сравнены.

+

Как для пары определить эту вероятность? Посмотрим на все элементы между ними, будь они в отсортированном массиве.

+

Внезапно возникает гармонический ряд, ну а дальше мы знаем.

+

Дисперсия

+

Дисперсия — это количественная метрика. Это не что-то абстрактное, как могли рассказать в школе на теорвере, а формально определенная величина, имеющая кучу всяких полезных свойств.

+

Дисперсия определяется как средний квадрат отклонения случайной величины от ее матожидания:

+

\[ D[X] = E[(X − E[X])^2] \]

+

Её проще считать по другой формуле, разложив квадрат внутри ожидания:

+

\[ +\begin{align} +D[X] &= E[(X − E[X])^2] +\\ &= E[X^2 - 2 \cdot X \cdot E[X] + E[X]^2] +\\ &= E[X^2] - E [\underbrace{2 \cdot E[X]}_{const} \cdot X ] + E[E[X]^2] +\\ &= E[X^2] - 2 E[X]^2 + E[X]^2 +\\ &= E[X^2] - E[X]^2 +\end{align} +\]

+

Эту формулу мы будем использовать для вывода разных свойств.

+

У дисперсии очень много крутых свойств.

+

\[ D[k X] = E[k^2 X^2] - E[k X]^2 = k^2 (E[X^2] - E[X]^2) = k^2 D[X] \]

+

Предполагаем, что \(X\) и \(Y\) независимы.

+

\[ +\begin{align} +D[X + Y] &= E[(X+Y)^2] - E[X+Y]^2 +\\ &= E[X^2 + X Y + Y^2] - (E[X] + E[Y])^2 +\\ &= E[X^2] + E[Y^2] - E[X]^2 - E[Y]^2 +\\ &= D[X] + D[Y] +\end{align} +\]

+

Важное отличие от свойств матожидания: дисперсию так просто можно считать только для независимых величин.

+

Закон больших чисел

+

Закон больших чисел — принцип, который описывает результат выполнения одного и того же эксперимента много раз. Согласно закону, среднее значение конечной выборки из фиксированного распределения близко к математическому ожиданию этого распределения.

+

У матожидания (константы) стандартное обозначение \(\mu\) (мю), а у дисперсии \(\sigma\) (сигма).

+

\[ M_n = \frac{X_1 + \ldots + X_n}{n} \]

+

\[ E[M_n] = \frac1n E[X_1 + \ldots + X_n] = \mu \]

+

Это немного очевидное равенство. Теперь нас интересует, насколько точно оно в реальности достигается:

+

\[ D[M_n] = \frac{1}{n^2} D[X_1 + \ldots + X_n] = \frac{\sigma}{n} \]

+

С одной стороны, оно домножается на \(\frac{1}[n^2}\) из-за усреднения, с другой — на \(n\) из-за суммирования.

+

Нормальное распределение

+

Когда вы в каких-нибудь библиотеках для анализа данных просите показать какой-то summary, то чаще всего вы увидите два числа: матожидание и дисперсию.

+

Центральная предельная теорема названа так пафосно вполне обоснованно.

+

Она говорит, что нам достаточно про каждое слагаемое знать всего два числа — ожидание и дисперсию.

+

\[ f(x) = \frac{1}{2\sqrt{\pi}} e^{-\frac{(x-\mu)^2}{\sigma^2}} \]

+

Доказать это очень трудно. Даже со ссылками на мощные теоремы оно займёт не одну страницу. Обычно доказательство рассказывают в середине второго курса.

+

Трудно даже доказать, что это распределение, т. е. что \(\int_{-\inf}^\inf f(x) dx = 1\).

+

Метод Монте-Карло

+

Алгоритмы вида «давайте посчитаем значения в разных случайных точках и усредним» называются методами монте-карло.

+

Пусть у нас есть единичный квадрат и несколько сотен кружков. Нам нужно посчитать долю области квадрата, которая не покрывается ни одним из кругов, с точностью до 1%.

+

Можно просто делать так: тыкать в 10000 случайных точек и проверять для каждой, является ли она «хорошей», а затем вывести \(\frac{\text{хорошие}}{\text{хорошие} + \text{плохие}}\) в качестве ответа.

+

Сдать можно сюда.

+

Хэш-таблицы

+

Вообще, хэш — это такая функция, которая сопоставляет элементу какой-то другой элемент. С точки зрения криптоанализа, она должна быть трудно обратимой, а с точки зрения алгоритмиста — генерирующей равномерные игреки.

+

Можно делать так если мы планируем хранить \(n\) элементов, то нужно завести \(\Theta(n)\) ячеек, каждая из которых будет на самом деле односвязным списокм.

+

Это будет работать, потому что из-за рандома в среднем в каждой ячейке будет по одному элементу. Но константа у такого решения большая.

+

Стандартная хэш-таблица из STL по непонятным автору причинам работает очень медленно. Во многих задачах она является самой нагруженной структурой. Её можно написать в 3-5 раз быстрее.

+ + diff --git a/tg/quicksort.html b/tg/quicksort.html new file mode 100644 index 0000000..1123b38 --- /dev/null +++ b/tg/quicksort.html @@ -0,0 +1,161 @@ + + + + + + + Куча - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Куча

+

Подвешенное дерево - дерево, у которого есть корень.

+

Двоичная Куча - такое подвешенное дерево, для которого выполнены три условия:

+
    +
  1. Значение в любой вершине не меньше, чем значения её потомков.

  2. +
  3. У любой вершины не более двух сыновей.

  4. +
  5. Слои заполняются последовательно слева направо сверху вниз.

  6. +
+
+Правильная куча +
+

Давайте обозначим как h высоту кучи.

+

Куча также умеет 3 основные операции :

+
    +
  1. найти минимум за \(O(1)\)

  2. +
  3. удалить минимум за \(O(h)\)

  4. +
  5. добавление нового ключа в кучу за \(O(h)\)

  6. +
+

Так как куча всегда состоит из нескольких слоев заполненых полностью и одного частично и каждый слой содержит в два раза больше вершин, чем предыдущий, то можно понять, что высота дерева будет не больше \(O(log(N))\).

+

Теперь давайте поговорим о том, как же реализовать такую структуру.

+

Хранить кучу мы будем в виде массива, где у корня индекс равен \(1\), и у вершины \(n\) индексы ее потомков - \(2 n\) и \(2 n + 1\). Если значение измененного элемента уменьшается, то свойства кучи восстанавливаются функцией Up.

+

Запускаемся из элемента \(i\). Если элемент больше своего отца, то больше ничего делать не нужно. Иначе, мы меняем местами его с отцом и запускаемся от отца. В результате такой функции мы исправим случаи, когда первое условие кучи не соблюдается. Так как каждый раз мы только поднимаемся, то работать функция будет за количество предков вершины, а оно максимум равно высоте дерева.

+
void Up(int i) {
+    while (i > 1 && a[i] < a[i / 2]) {    // i = 1 — корень
+        swap(a[i], a[i / 2]);
+        i = i / 2;
+    }
+}
+

Если значение измененного элемента увеличивается, то свойства кучи восстанавливаются функцией Down. Запускаемя от элемента \(i\), если \(i\)-й элемент меньше, чем его сыновья, всё поддерево уже является кучей, и делать ничего не надо. В противном случае меняем местами \(i\)-й элемент с наименьшим из его сыновей, после чего выполняем Down для этого сына. Так как каждый раз мы спускаемся только в одного сына, то работать функция будет за высоту дерева.

+
void Down(int i) {
+    while (2 * i < size) {    // size — количество элементов в куче
+        left = 2 * i;             // left — левый сын
+        right = 2 * i + 1;            // right — правый сын
+        j = left;
+        if (right < size && a[right] < a[left]) {
+            j = right;
+        }
+        if (a[i] <= a[j]) {
+            break;
+        }
+        swap(a[i], a[j]);
+        i = j;
+   }
+}
+

Красивая визуализация: https://visualgo.net/en/heap

+

1 операцию мы умеем выполнять, просто спросив про корень.

+

Для 2 операции мы меняем последний элемент кучи и корень, а затем уменьшаем размер кучи и вызываем Down для корня.

+

3 операция - просто добавление элемента конец в кучи, а затем вызываем для него Up.

+

Теоретическое задание

+

Дана куча из рисунка сверху, вам даны 3 запроса, нарисуйте как выглядит куча после каждого запроса: * добавить число 125 * удалить максимальное * вывести максимальное

+

В с++ куча реализована в STL в библиотеке <queue> :

+
priority_queue<type> Q;
+Q.pop();
+Q.push();
+Q.top();
+

Практическое задание

+

В контесте на информатиксе на эту тему первые 5 задач.

+

Сортировка

+

На самом первом занятии вы ознакомились с квадратичными алгоритмами сортировки и сортировкой слияниями, а сейчас я вам расскажу гораздо более интересные сортировки.

+

Сортировка кучей

+

Только что мы с вами познакомились с кучей. Благодаря ей можно отсортировать числа по возрастанию: просто вставить все числа в кучу, а затем \(N\) раз достать минимум. Работает это за \(O(NlogN)\) (так как мы \(N\) раз вставляем и удаляем элемент, а обе эти операции работают за логарифм).

+

Практическое задание

+

(6 задача)

+

Быстрая сортировка

+

Быстрая сортировка заключается в том, что на каждом шаге мы находим опорный элемент, все элементы, которые меньше его кидаем в левую часть, остальные в правую, а затем рекурсивно спускаемся в обе части.

+

https://visualgo.net/en/sorting

+
void quicksort(int l, int r){
+    if (l < r){
+        int index = (l + r) / 2; /* index - индекс опорного элемента для 
+        начала сделаем его равным середине отрезка*/
+        index = divide(l, r, index); /* divide - функция разбивающие элементы 
+        на меньшие и больше/равные a[index], 
+        при этом функция возвращает границу разбиения*/
+        quicksort(l, index);
+        quicksort(index + 1, r);
+    }
+}
+

Давайте оценим асимптотику данной сортировки. На случайных данных она работает за \(O(NlogN)\) , так как каждый раз мы будем делить массив на две примерно равные части, то есть суммарно размер рекурсии будет около логарифма и при этом на каждом этапе рекурсии мы просмотрим не более, чем размер массива. Однако можно легко найти две проблемы, одна - одинаковые числа, а вторая - если вдруг середина - минимум или максимум.

+

Существуют несколько выходов из этой ситуации :

+
    +
  1. Давайте если быстрая сортировка работает долго, то запустим любую другую сортировку за \(NlogN\).

  2. +
  3. Давайте делить массив не на две, а на три части(меньше, равны, больше).

  4. +
  5. Чтобы избавиться от проблемы с максимумом/минимумом в середине, давайте брать случайный элемент.

  6. +
+

###Теоретическое задание

+

Вывести оптимальные числа (при которых алгоритм работает быстрее всего) на всех этапах массива (8, 3, 2, 5, 4, 6, 7, 1).

+

Поиск \(k\)-ой порядковой статистики за \(O(N)\)

+

Пусть дан массив \(A\) длиной \(N\) и пусть дано число \(K\). Задача заключается в том, чтобы найти в этом массиве \(K\)-ое по величине число, т.е. \(K\)-ую порядковую статистику.

+

Давайте поймем, что в быстрой сортировке мы можем узнать, сколько элементов меньше данного, тогда рассмотрим три случая

+
    +
  1. количество чисел, меньше данного = \(k - 1\), тогда наше число - ответ.

  2. +
  3. количество чисел, меньше данного >= \(k\), тогда спускаемся рекурсивно в левую часть и ищем там ответ.

  4. +
  5. количество чисел, меньше данного < \(k\), спускаемся в правую ищем (\(k\) - левая - 1) - ое число.

  6. +
+

За сколько же это работает, из быстрой сортировки мы имеем, что размер убывает приблизительно в 2 раза, то есть мы имеем сумму \(\sum_{k=1}^n {2 ^ k} = {2^{k+1}-1}\) что в нашем случае это максимум равно \(2 * N - 1\), то есть \(O(N)\).

+

Также в с++ эта функция уже реализована :

+
nth_element(указатель на начало, указатель на нужный элемент, указатель на конец);
+

Теоретическое задание

+

Рассказать, как работает алгоритм при k = 5 и массиве - (1, 8, 4, 6, 7, 5, 3, 2), опорный элемент - середина

+

Практическое задание

+

3 задачи на еджадже. В 2 из них у вас будет проверяться код.

+

Также нужно порешать практический контест на codeforces.

+

Ссылка на контесты

+

Куча: https://informatics.msk.ru/mod/statements/view.php?id=33379

+

Быстрая сортировка: http://ejudge.algocode.ru/cgi-bin/new-client?contest_id=5001

+

Практический контест: https://codeforces.com/group/g92L0id9Yb/contest/237751

+ + diff --git a/tg/scanline.html b/tg/scanline.html new file mode 100644 index 0000000..1825d8e --- /dev/null +++ b/tg/scanline.html @@ -0,0 +1,102 @@ + + + + + + + Сканирующая прямая - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Сканирующая прямая

+

Общая идея этого алгоритма заключается в сортировке точек и затем проходу по ним(по этому алгоритм так и называется).

+

Давайте разберем несколько задач на эту тему и лучше поймем алгоритм.

+

Точка, покрытая наибольшим количеством отрезков

+

Пусть, дан набор из \(n\) отрезков на прямой, заданных координатами начал и концов \([l_i, r_i]\). Требуется найти любую точку на прямой, покрытую наибольшим количеством отрезков.

+

Понятно, что каждую точку прямой мы проверить не можем. В каких точках прямой может происходить смена количества отрезков, которыми она покрыта? Только в началах или концах данных отрезков. Назовем такие точки интересными. Так как смена ответа может происходить только в интересной точке, максимум достигается так же в какой-то из интересных точек. Отсюда сразу следует решение за \(O(n^2)\): просто перебрать все интересные точки и проверить для них ответ.

+

Это решение можно улучшить. Отсортируем интересные точки по возрастанию координаты. Пройдем по интересным точкам слева направо, поддерживая количество отрезков \(c\), которые покрывают данную точку. Если в данной точке начинается отрезок, то надо прибавить 1 к \(c\), а если заканчивается вычесть 1 из \(c\). После этого надо попробовать обновить ответ на задачу.

+

Заметим, что если координаты двух интересных точек совпали, чтобы получить правильный ответ, сначала надо рассмотреть начала отрезков, а потом концы.

+

Как такое писать: нужно представить интересные точки в виде структур с полями “координата” и “тип” (начало/конец) и отсортировать со своим компаратором. Удобно начало отрезка обозначать +1, а конец -1, чтобы прибавлять к \(c\) именно это значение.

+
struct event{
+    int x, type;
+};
+int main(){
+    int n;
+    cin >> n;
+    vector<event> a(n);
+    for (int i = 0; i < n; ++i)
+        cin >> a[i].x >> a[i].type;
+    sort(a.begin(), a.end(), [](const event& e1, const event& e2) {
+        return e1.x == e2.x ? e1.type < e2.type : e1.x < e2.x; 
+    });
+}
+

Такое решение работает за \(O(n\log n)\) на сортировку. Этот подход называется методом сканирующей прямой.

+

Скольким отрезкам принадлежит точка

+

Пусть, теперь надо для \(q\) точек (не обязательно являющихся концами отрезков) ответить на вопрос: скольким отрезкам принадлежит данная точка?

+

Воспользуемся следующим приемом: сразу считаем все запросы и сохраним их, чтобы потом ответить на все сразу. Добавим точки запросов в массив интересных точек с новым типом 0, который будет означать, что в этой точке надо ответить на запрос. В случае равенства координат запросы должны идти после начал и до концов. Точно так же отсортируем точки и пройдем по точкам слева направо, поддерживая \(c\) и запоминая ответы на запросы. Асимптотика \(O((n+q)\log(n+q))\).

+

Количество пересекающихся отрезков

+

Пусть, дан набор из \(n\) отрезков на прямой, заданных координатами начал и концов \([l_i, r_i]\). Требуется для каждого отрезка сказать, с каким количеством отрезков он пересекается (может иметь общую точку или быть вложенным).

+

Вместо того, чтобы считать количество отрезков, с которыми отрезок пересекается, посчитаем количество отрезков, с которыми он не пересекается, и вычтем это число из \(n-1\). Отрезок \([l_1, r_1]\) может не пересекаться с другим отрезком \([l_2, r_2]\), только если \(r_2<l_1\) или \(r_1<l_2\). Количество отрезков для каждого из случаев легко подсчитать.

+

Будем считать, что изначально ответ для всех отрезков равен \(n-1\). Запишем все интересные точки, отсортируем их. Пройдем слева направо, поддерживая число отрезков, которые уже закончились (изначально 0) и число отрезков, которые еще не начались (изначально \(n-1\)). При обработке очередной точки, если это начало, вычитаем из ответа для этого отрезка число уже закончившихся, а если конец, то вычитаем количество еще не начавшихся. После этого изменяем значение соответствующего счетчика.

+

Заметим, что теперь в структуре точки необходимо хранить еще и номер отрезка, которому она принадлежала. Время работы \(O(n\log n)\).

+

Длина объединения отрезков

+

Пусть, дан набор из \(n\) отрезков на прямой, заданных координатами начал и концов \([l_i, r_i]\). Требуется нати длину их объединения.

+

Как обычно, отсортируем интересные точки и при проходе поддерживаем число отрезков, покрывающих данную точку. Если оно больше 0, то отрезок который мы прошли с прошлой рассмторенной точки принадлежит объединению. Время работы \(O(n\log n)\).

+

Сжатие координат

+

Это общая идея, которая может оказаться полезной. Пусть, есть \(n\) чисел \(a_1,\ldots,a_n\). Хотим, преобразовать \(a_i\) так, чтобы равные остались равными, разные остались разными, но все они были от 0 до \(n-1\). Для этого надо отсортировать числа, удалить повторяющиеся и заменить каждое \(a_i\) на его индекс в отсортированном массиве.

+
int a[n], all[n];
+for (int i = 0; i < n; ++i) {
+    cin >> a[i];
+    all[i] = a[i];
+}
+sort(all, all + n);
+m = unique(all, all + n) - all; // теперь m - число различных координат
+for (int i = 0; i < n; ++i)
+    a[i] = lower_bound(all, all + m, x[i]) - all;
+

Задание

+

Решите как можно больше задач из контеста https://informatics.msk.ru/mod/statements/view.php?id=33318

+ + diff --git a/tg/shortest-paths.html b/tg/shortest-paths.html new file mode 100644 index 0000000..b6f329f --- /dev/null +++ b/tg/shortest-paths.html @@ -0,0 +1,163 @@ + + + + + + + Кратчайшие пути в графе - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Кратчайшие пути в графе

+

Рассмотрим взвешенный граф, то есть у всех его ребер есть вес - некоторое число. Можно представить, что это цена, за которую мы можем по нему проехать.

+

Как его хранить? Давайте просто в списке смежности вместо номеров вершин соседа хранить пару (номер соседа, вес ребра до него).

+

Давайте решать задачу посика кратчайшего пути в графе - мы хотим за наименьшую стоимость проехать из вершины \(A\) в \(B\).

+

Обычно будем считать, что в графе нет циклов отрицательного веса - иначе кратчайшее расстояниие может быть равно минус бесконечности.

+

Для данной задачи есть несколько алгоритмов решения.

+

Алгоритм Флойда

+

Мы с вами уже знаем динамическое программирование, давайте рассмотри следующую динамику:

+

\(d_{i j}^k\) - это длина кратчайшего пути от вершины \(i\) до \(j\), используя как промежуточные только вершины из первых \(k\) = \(d_{i j}^k\) (напоминает рюкзак).

+

База динамики (\(k = 0\)) определяется только путями из одного ребра. Если есть ребро из \(i\) в \(j\) стоимостью \(c\), то \(d_{i j}^{0}\) = \(c\). Если таких ребер несколько, то, конечно, надо взять минимум.

+

Если мы хотим посчитать \(d_{i j}^{k}\), то у нас есть два варианта

+
    +
  1. Не брать на пути нигде \(k\)-ую вершину, тогда \(d_{i j}^{k}\) = \(d_{i j}^{k - 1}\)

  2. +
  3. Взять где-нибудь в пути k-ую вершину, тогда путь разбивается на две части - от \(i\) до \(k\) и от \(k\) до \(j\), раз итоговый путь кратчайший, то и и эти два пути должны быть кратчайшми, а значит формула \(d_{i j}^{k}\) = \(d_{i k}^{k - 1}\) + \(d_{k j}^{k - 1}\)

  4. +
+

В результате ответ - \(d_{A B}^{n}\),

+

Можете подумать, как по такой динамике восстановить сам путь.

+

Также заметим, что вместо трехмерной динамики в этом алгоритме можно использовать двухмерную, храня в \(dp_{ij}\) последнее известное значение из \(dp_{ij}^0\), \(dp_{ij}^1\) \(dp_{ij}^2\), \(\ldots\), \(dp_{ij}^n\).

+
for(int k = 0; k < n; k++){
+    for(int i = 0; i < n; i++){
+        for(int j = 0; j < n; j++){
+            a[i][j] = min(a[i][j], a[i][k] + a[k][j]);
+        }
+    }
+}
+

Теоретическое задание

+

Подумайте, как находить циклы отрицательного веса с помощью Флойда.

+

Плюсы алгоритма в том, что он находит расстояние сразу от всех вершин графа до остальных, а минус - алгоритм работает за \(O(N^3)\);

+

Практическое задание

+

Первые две задачи на алгоритм Флойда.

+

Алгоритм Дейкстры

+

Для этого алгоритма придется рассматривать только графы без отрицательных рёбер.

+

Алгоритм Дейкстры решает немного другую задачу: он находит расстояние от одной вершины \(A\) до каждой вершины. Давайте для каждой вершины хранить расстояние до нее из вершины \(A\) в массиве \(d\). Например: * \(d[A] = 0\) * \(d[x] = \infty\), если x не достижима из A

+

Назовем релаксацией обновление ответа для вершины \(b\) через ребро \((a, b, c)\) таким способом: \(d[b] = \min(d[b], d[a] + c)\).

+
    +
  • При таком действии ответ не может стать лучше, чем кратчайшее расстояние до \(b\), так как мы просто пользуемся путем для вершины \(a\) и продлеваем его в \(b\).
  • +
  • Если мы прорелаксировали все ребра в кратчайшем пути в правильном порядке до вершины \(b\), то мы получим кратчайший путь до \(b\).
  • +
+

Теперь давайте каждый раз доставать вершину, для которой расстояние от А сейчас минимально, и мы еще ее не смотрели и затем обновлять всех ее соседей. Допустим вершина с минимальным расстоянием - x, тогда надо прорелаксировать все ребра из нее.

+

Давайте докажем корректность алгоритма по индукции:

+

База) Первой вершиной мы всегда рассмотрим \(A\), но для нее верно, что расстояние от \(А\) до \(А\) = 0;

+

Шаг) Мы достали вершину \(i\), нам известно, что для всех ее предков ответ - корректен, но тогда, допустим, что для \(i\)-ой вершины мы нашли ответ больший, чем надо, тогда это значит, что мы должны прийти из еще не рассмотренной вершины, но так как ребер отрицательного веса в графе нет, то такое невозможно \(\Rightarrow\) мы доказали

+

https://visualgo.net/en/sssp?slide=1

+

Есть две возможные реализации алгоритма

+
    +
  1. реализация помощью нахождения минимального расстояния внутри массива за линию. Так как мы для каждого шага находим минимальную вершину, то мы сделаем не более \(N\) шагов, но при этом на каждом шаге мы находим минимум за линию, то есть алгоритм работает за \(O(N^2)\).
  2. +
+
for (int i = 0; i < n; ++i){
+    int uk = -1;
+    for (int j = 0; j < n; j++){
+        if ((mark[j] == 0) && ((uk == -1) || (d[j] < d[uk]))){
+                uk = j;
+        }
+    }
+    for (int j = 0; j < n; j++){
+        if ((j != uk) && (g[uk][j] != -1)) d[j] = min(d[j], g[uk][j] + d[uk]);
+    }
+    mark[uk] = 1;
+}
+
    +
  1. Реализация за \(O(MlognN + NlogN)\) с помощью нахождения минимального расстояния внутри кучи/сета за логарифм. Так как для каждой вершины мы сделаем не более одного извлекания из структуры + каждое ребро мы используем максимум два раза. Для этого давайте в выбранной структуре хранить пару (расстояние, вершина); Первый код реализован с set, второй с кучей. Так как в куче нет компаратора на возрастание, то надо либо сделать свой, либо домножить расстояние на -1;
  2. +
+
while (used.size()) {
+    int v = (*(used.begin())).second;
+    for (int i = 0; i < g[v].size(); i++) {
+        int to = g[v][i].first, c = g[v][i].second;
+        if (dist[v] + c < dist[to]) {
+            used.erase({dist[to], to});
+            dist[to] = dist[v] + to;
+            used.insert({dist[to], to});
+        }
+    }
+    used.erase({dist[v], v});
+}
+
while (!q.empty()) {
+        int v = q.top().second;
+        q.pop();
+    for (int j = 0; j < g[v].size(); ++j) {
+        int to = g[v][j].first, c = g[v][j].second;
+        if (d[v] + c < d[to]) {
+            d[to] = d[v] + c;
+            q.push({-d[to], to});
+        }
+    }
+}
+

Из асимптотики понятно, что при большом количестве ребер первый алгоритм писать лучше, иначе второй.

+

Теоретическое задание

+

Приведите примеры, когда каждый из алгоритмов работает лучше.

+

Недостаток алгоритма Дейкстры всего один - он не работает, если в графе есть ребра отрицательного веса.

+

Практическое задание

+

3-5 Задача на алгоритм Дейкстры.

+

Форд-Беллман

+

Этот алгоритм решает ту же задачу, что и Дейкстра, но зато может работать с отрицательными ребрами!

+

Давайте заведем массив расстояний, как и в дейкстре, для стартовой вершины расстояние = 0. Алгоритм состоит в релаксации каждого ребра в графе \(N-1\) раз.

+

Это работает потому, что в кратчайшем пути не больше, чем \(N-1\) ребро, и если мы прорелаксируем их в таком порядке, этот путь найдется. После \(N-1\) прохода по всем ребрам и их релаксации мы точно это сделаем и найдем кратчайший путь.

+

Также в этом случае удобнее хранить список ребер явно вместо списка смежности.

+
int from[m], to[m], cost[m];
+for (int i = 0; i < n - 1; ++i) {
+        for (int j = 0; j < m; ++j) {
+                d[to[j]] = min(d[to[j]], d[from[j]] + cost[j]);
+    }
+}
+

Теоретическое задание

+

Подумайте, как найти цикл отрицательного веса с помощью этого алгоритма.

+

Практическое задание

+

Решите задачи 6 и 7.

+

Ссылка на контест

+

https://informatics.msk.ru/mod/statements/view.php?id=33380#1

+ + diff --git a/tg/sorting.html b/tg/sorting.html new file mode 100644 index 0000000..31f573c --- /dev/null +++ b/tg/sorting.html @@ -0,0 +1,287 @@ + + + + + + + Сортировки — квадратичные и сортировка слиянием - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

Сортировки — квадратичные и сортировка слиянием

+
    +
  • Квадратичные сортировки: пузырьком, выбором, вставками
  • +
  • Сортировка подсчетом
  • +
  • О-нотация
  • +
  • Сортировка слиянием*
  • +
  • Применение сортировки для решения задач
  • +
+

Мы считаем, что вы уже знаете и умеете следующие вещи: * основы языков Python, C++ или Java (примеры в конспектах будут только на C++) * оператор if * циклы for и while * создание функций * концепцию рекурсии * поиск минимума в массиве

+

Квадратичные сортировки

+

Задача сортировки массива заключается в том, чтобы расставить его элементы в определённом порядке (чаще всего - по неубыванию. Это означает, что каждый элемент должен быть больше или равен всех предыдущих).

+

А именно мы хотим написать такую функцию sort_array, которая принимает массив в качестве аргумента и сортирует его элементы:

+
def sort_array(array):
+    # надо придумать, что написать здесь
+
+array = [1, -3, 7, 88, 7]
+sort_array(array)
+print(array)
+
[1, -3, 7, 88, 7]
+

Задание

+

Какие вы знаете алгоритмы сортировки?

+

Если вы не знаете ни одного алгоритма, то придумайте какой-нибудь разумный алгоритм сами.

+

Сортировка пузырьком

+

Это самый популярный алгоритм сортировки, хоть он и не самый очевидный.

+

Пусть \(N\) - длина массива. Сортировка пузырьком заключается в том, что мы просто \(N\) раз пройдемся по массиву и будем менять два соседних элемента, если первый больше второго.

+

Ссылка на красивую визуализацию: https://visualgo.net/nl/sorting

+

Заметьте, как каждую итерацию максимальный элемент “всплывает как пузырек” к концу массива.

+
def bubble_sort(array):
+    n = len(array) # длина массива
+    for i in range(n): # n раз выполняем цикл
+        for j in range(n - 1): # проходимся по всем элементам кроме последнего
+            if array[j] > array[j + 1]: # сравниваем элемент по следующим
+                a[j], a[j + 1] = a[j + 1], a[j] # меняем местами, если следующий меньше
+
+a = [1, -3, 7, 88, 7]
+bubble_sort(a)
+print(a)
+
[-3, 1, 7, 7, 88]
+

Заметьте, что после \(i\) шагов алгоритма сортировки пузырьком последние \(i + 1\) чисел всегда отсортированы. Именно поэтому алгоритм и работает.

+

И именно поэтому его можно немного ускорить.

+

Задание

+

Подумайте, какие лишние элементы мы перебираем. Как нужно изменить границы в двух циклах for, чтобы не делать никаких бесполезных действий?

+

Сортировка выбором

+

Более понятным и придумываемым способом является сортировка выбором минимума (или максимума).

+

Чтобы отсортировать массив, нужно просто \(N\) раз выбрать минимум среди еще неотсортированных чисел. То есть на \(i\)-ом шаге ищется минимум на отрезке \([i, n - 1]\), и этот минимум меняется с \(i\)-ым элементом, теперь отрезок \([0, i]\) отсортирован.

+

Красивая визуализация (вкладка SEL).

+

Сортировка вставками

+

Также существует сортировка вставками.

+

Префиксом длины \(i\) будем называть первые \(i\) элементов массива.

+

Тогда пусть на \(i\)-ом шаге у нас уже будет отсортирован префикс до \(i\)-го элемента. Чтобы этот префикс увеличить, нужно взять элемент, идущий после него, и менять с левым соседом, пока этот элемент наконец не окажется больше своего левого соседа. Если в конце он больше левого соседа, но меньше правого - это значит, что мы правильно вставили этот элемент в отсортированную часть массива.

+

Красивая визуализация (вкладка INS).

+

Задание

+

Сдайте 4 первые задачи в контесте:

+
    +
  1. Напишите сортировку пузырьком.
  2. +
  3. Напишите сортировку выбором максимума.
  4. +
  5. Напишите сортировку вставками.
  6. +
  7. Выберите любой алгоритм и примените его для сортировки пар.
  8. +
+

При сдаче задач нельзя пользоваться встроенной сортировкой!

+

Сортировка подсчетом

+

Предыдущие три алгоритма работали с массивами, в которых лежат абсолютно любые объекты, которые можно сравнивать. Любые числа, строки, пары, другие массивы, почти все что угодно.

+

Но в особых случаях, когда элементы принадлежат какому-то маленькому множеству, можно использовать другой алгоритм — сортировку подсчетом.

+

Пусть, например, нам гарантируется, что все числа натуральные и лежат в промежутке от 1 до 100.

+

Тогда есть такой простой алгоритм: создадим массив размера \(100\), в котором будем хранить на \(i\)-ом месте, сколько раз число \(i\) встретилось в этом массиве. Пройдемся по всем числам, и увеличим соответствующее значение массива на \(1\). Таким образом мы подсчитали, сколько раз какое число встретилось. Теперь можно просто пройтись по этому массиву и вывести \(1\) столько раз, сколько раз встретилась \(1\), вывести \(2\) столько раз, сколько встретилась \(2\), и так далее.

+

Красивая визуализация (вкладка COU).

+

Задание

+

Сдайте 5, 6 и 7 задачи в контесте:

+
    +
  1. Напишите сортировку подсчетом.
  2. +
  3. Придумайте, как преобразовать сортировку подсчетом, чтобы она работала быстро.
  4. +
  5. Придумайте, как использовать идею сортировки подсчетом в этой задаче.
  6. +
+

При сдаче задач нельзя пользоваться встроенной сортировкой!

+

О-нотация

+

Очень часто требуется оценить, сколько времени работают эти алгоритмы. Но тут возникают проблемы:

+
    +
  • на разных компьютерах время работы всегда будет слегка отличаться;
  • +
  • чтобы измерить время, придётся запустить сам алгоритм, но иногда приходится оценивать алгоритмы, требующие часы или даже дни работы.
  • +
+

Зачастую основной задачей программиста становится оптимизировать алгоритм, выполнение которого займёт тысячи лет, до какого-нибудь адекватного времени работы. Поэтому хотелось бы уметь предсказывать, сколько времени займёт выполнение алгоритма ещё до того, как мы его запустим.

+

Для этого давайте для начала попробуем оценить число операций в алгоритме.

+

Возникает вопрос: какие именно операции считать. Можно считать любые элементарные операции: * арифметические операции с числами: +, -, *, / * сравнение чисел: <, >, <=, >=, ==, != * присваивание: a[0] = 3

+

При этом надо учитывать, как реализованы некоторые отдельные вещи в самом языке. Например, в питоне срезы массива (array[3:10]) копируют этот массив, то есть этот срез работает за 7 элементарных действий. А swap, например, может работать за 3 присваивания.

+

Задание

+

Попробуйте посчитать точное число сравнений в сортировках пузырьком, выбором, вставками и подсчетом в худшем случае (это должна быть какая формула, зависящая от \(N\) - длины массива). Для сортировки подсчетом давайте считать, что все числа целые и лежат в промежутке от 1 до \(M\)

+

И также посчитайте точное число присваиваний в сортировках пузырьком, выбором, вставками и подсчетом в худшем случае. Давайте считать, что swap — это 3 присваивания.

+

Ниже будут ответы

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

.

+

В худшем случае алгоритмы работают за столько сравнений:

+
    +
  • Сортировка пузырьком (без улучшения): \(N(N-1)\)
  • +
  • Сортировка пузырьком (с улучшением): \((N-1) + (N-2) + \ldots + 1 = \frac{N(N-1)}{2}\)
  • +
  • Сортировка выбором: \((N-1) + (N-2) + \ldots + 1 = \frac{N(N-1)}{2}\)
  • +
  • Сортировка вставками: \(1 + 2 + \ldots + (N-1) = \frac{N(N-1)}{2}\)
  • +
  • Сортировка подсчетом: нет сравнений
  • +
+

И столько присваиваний: * Сортировка пузырьком (без улучшения): \(3\frac{N(N-1)}{2}\) * Сортировка пузырьком (с улучшением): \(3\frac{N(N-1)}{2}\) * Сортировка выбором: \(3(N-1) + \frac{N(N-1)}{2}\) * Сортировка вставками: \(3\frac{N(N-1)}{2}\) * Сортировка подсчетом: \(N + M\) (\(M\) - это создание массива)

+

Но чтобы учесть все элементарные операции, ещё надо посчитать, например, сколько раз прибавилась единичка внутри цикла for. А ещё, например, строчка n = len(array) - это тоже действие.

+

Также не сразу очевидно, какой из этих алгоритмов работает быстрее. Сравнивать формулы сложно. Хочется придумать способ упростить эти формулы так, чтобы:

+
    +
  • не нужно было учитывать много информации, не очень сильно влияющей на итоговое время;
  • +
  • легко было оценивать время работы разных алгоритмов для больших чисел;
  • +
  • легко было сравнивать алгоритмы на предмет того, какой из них лучше подходит для тех или иных входных данных.
  • +
+

Для этого придумали \(O\)-нотацию - асимптотическое время работы вместо точного (часто его ещё называют асимптотикой).

+

Пусть \(f(N)\) - это какая-то функция. Говорят, что алгоритм работает за \(O(f(N))\), если существует число \(C\), такое что алгоритм работает не более чем за \(C \cdot f(N)\) операций.

+

В таких обозначениях можно сказать, что * Сортировка пузырьком работает за \(O(N^2)\) * Сортировка выбором работает за \(O(N^2)\) * Сортировка вставками работает за \(O(N^2)\) * Сортировка подсчетом работает за \(O(N + M)\)

+

Это обозначение удобно тем, что оно короткое и понятное, а также оно не зависит от умножения на константу или прибавления константы. Если алгоритм работает за \(O(N^2)\), то это может значить, что он работает за \(N^2\), за \(N^2 + 3\), за \(\frac{N(N-1)}{2}\) или даже за \(1000 \cdot N^2 + 1\) действие. Главное, что функция ведет себя как \(N^2\), то есть при увеличении \(N\) (в данном случае это длина массива) он увеличивается как некоторая квадратичная функция. Например, если увеличить \(N\) в 10 раз, время работы программы увеличится приблизительно в 100 раз.

+

Поэтому все эти рассуждения про то, сколько операций в swap или считать ли отдельно присваивания, сравнения и циклы - отпадают. Как бы вы ни ответили на эти вопросы, они меняют ответ на константу, а значит асимптотическое время работы алгоритма никак не меняется.

+

Первые три сортировки именно поэтому называют квадратичными - они работают за \(O(N^2)\). Сортировка подсчетом работает намного быстрее - она работает за \(O(N + M)\) действий, то есть если в задаче \(M < N\), то это вообще линейная функция \(O(N)\). Линейная функция растет гораздо медленнее, чем квадратичная, так что эта сортировка гораздо лучше, чем любая другая квадратичная. У нее есть лишь один недостаток - ее можно применять только если множество значений конечное и состоит из чисел от \(1\) до \(M\).

+

Задание

+

Найдите асимптотику данных функций. Максимально упростите ответ (например, до \(O(N)\), \(O(N^2)\) и т. д.).

+
    +
  • \(\frac{N}{3}\)
  • +
  • \(\frac{N(N-1)(N-2)}{6}\)
  • +
  • \(1 + 2 + 3 + \ldots + N\)
  • +
  • \(1^2 + 2^2 + 3^2 + \ldots + N^2\)
  • +
  • \(\log{N} + 3\)
  • +
  • \(7\)
  • +
  • \(10^{100}\)
  • +
+

Задание

+

Найдите асимптотическое время работы данных функций:

+
def f(n):
+    s = 0
+    for i in range(n):
+        for j in range(n):
+            s += i * j
+    return s
+
+f(10)
+
2025
+
def g(n):
+    s = 0
+    for i in range(n):
+        s += i
+    for i in range(n):
+        s += i * i
+    return s
+
+g(10)
+
330
+
def h(n):
+    if n == 0:
+        return 1
+    return h(n - 1) * n
+
+h(10)
+
3628800
+

Задание

+

Найдите лучшее время работы алгоритмов, решающих данные задачи: * Написать числа от \(1\) до \(N\) * Написать все тройки чисел от \(1\) до \(N\) * Найти разницу между максимумом и минимумом в массиве * Найти число единиц в бинарной записи числа \(N\)

+

Сортировка слиянием* (для тех, кто всё успевает)

+

Возникает вопрос: а бывают ли сортировки, которые быстрее, чем квадратиные, и работают всегда?

+

Ответ — да. Есть несколько известных сортировок, работающих за \(O(N \log{N})\), и доказано, что асимптотически быстрее сортировок не бывает.

+

Давайте подробнее рассмотрим сортировку слиянием, она же MergeSort.

+

Ссылка на красивую визуализацию: https://visualgo.net/nl/sorting (вкладка MER)

+

Для начала определим функцию слияния (merge) двух отсортированных массивов - она возвращает отсортированный массив, состоящий из элементов обоих массивов, и работает при этом за \(O(N)\) (где \(N\) - общее число элементов).

+
def merge(a, b):
+    # надо придумать, что написать здесь
+
+merge([1, 3, 7, 10, 100], [2, 7, 7, 7, 11, 13, 18])
+
[1, 2, 3, 7, 7, 7, 7, 10, 11, 13, 18, 100]
+

В сущности слить два массива просто - это делается с помощью двух указателей \(i\) и \(j\). Изначально они равны \(0\) (то есть указывают на нулевые элементы массивов \(a\) и \(b\)). После этого достаточно смотреть на элементы \(a[i]\) и \(b[j]\) и минимальный из них класть в результирующий массив, после чего соответствующий указатель надо двигать дальше. Дальше нужно повторять этот процесс заново, пока мы не дошли до конца обоих массивов.

+

Когда функция merge уже написана, сам merge_sort писать уже легко - надо воспользоваться рекурсией. Чтобы отсортировать массив, достаточно отдельно отсортировать его левую и правую половины рекурсивно, и после этого эти половины слить.

+

Для удобства написания кода фунции можно сделать вот такими:

+
def merge(array, a, b, c):
+    # функция сливает элементы массива array [a, b) и [b, c)
+    # надо придумать, что написать здесь
+
+arr = [1, 1, 7, 10, 100, 2, 7, 40, 78, 6, 13, 100]
+merge(arr, 6, 9, 12)
+print(arr)
+merge(arr, 0, 6, 12)
+print(arr)
+
[1, 1, 7, 10, 100, 2, 6, 7, 13, 40, 78, 100]
+[1, 1, 2, 6, 7, 7, 10, 13, 40, 78, 100, 100]
+
def mergesort(array, a, c):
+    # функция сортирует элементы массива array [a, c)
+    # надо придумать, что написать здесь
+
+arr = [1, 1, 7, 10, 100, 2, 7, 40, 78, 6, 13, 100]
+mergesort(arr, 6, 12)
+print(arr)
+
+arr = [1, 1, 7, 10, 100, 2, 7, 40, 78, 6, 13, 100]
+mergesort(arr, 0, 12)
+print(arr)
+

Задание

+

Разобраться, реализовать сортировку слиянием и сдать последнюю задачу в этом контесте:

+

https://informatics.msk.ru/mod/statements/view3.php?id=33164

+

Чтобы сдать это задание, нужно писать красивый код - будет проведено ревью.

+

Применение сортировки для решения задач

+

Также сортировка очень часто применяется как часть решения олимпиадных задач. В таких случаях обычно используют встроенную сортировку sort. Она на разных языках может быть реализована по-разному, но везде она работает за \(O(N log N)\), и, обычно, неплохо оптимизирована.

+
# На питоне она пишется так
+a = [1, 5, 10, 5, -4]
+a.sort()
+print(a)
+
[-4, 1, 5, 5, 10]
+
// на C++11 она пишется так
+
+#include <vector>
+#include <algorithm>
+#include <iostream>
+
+using namespace std;
+
+int main() {
+    vector<int> v = {1, 5, 10, 5, -4};
+    sort(v.begin(), v.end());
+    for (auto x : v) {
+        cout << x << " ";
+    }
+}
+

Задание

+

Решить как можно больше задач из этого контеста:

+

https://informatics.msk.ru/mod/statements/view.php?id=33855

+

В этом контесте можно и нужно использовать встроенную сортировку sort.

+ + diff --git a/tg/stl.html b/tg/stl.html new file mode 100644 index 0000000..e607d19 --- /dev/null +++ b/tg/stl.html @@ -0,0 +1,411 @@ + + + + + + + C++ и Standard Template Library (STL) - Tinkoff Generation + + + + + + + + + + + + + + + +
Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
+

C++ и Standard Template Library (STL)

+

Первый плюс плюсов - STL, она содержит в себе множество алгоритмов и структур, чтобы ее использовать вам нужно писать

+
// либо один раз такую строчку
+using namespace std;
+
+// либо каждый раз писать перед каждой функцией и структурой из STL std::
+std::vector
+
  File "<ipython-input-1-3fdc4c3a8d74>", line 1
+    (/, либо, один, раз, такую, строчку)
+     ^
+SyntaxError: invalid syntax
+

https://en.cppreference.com/w — это сайт с документацией по языку C++. Там вы можете найти много полезной информации как о самом языке, так и о его стандартных библиотеках. Здесь же вы сможете найдите краткое изложение самого полезного из STL.

+

Поговорим об обычных сишных массивах

+
T a[x]; // инициализация массива a типа T размера x
+

Если вы пишите так, прекращайте, у этого метода много проблем, давайте напишем правильную реализацию и обговорим плюсы и минусы.

+
array<T, x> a;
+a.begin(), a.end() // указатели (итераторы) на начало и конец массива соответственно (конец массива = после последнего элемента)
+a.front(), a.back() // возвращает ссылку на первый и последний элемент соответственно
+cout << a.at(5); // метод at позволяет узнать не вышли ли мы за границы массива
+

Плюсы :

+
    +
  1. Указатели (итераторы) на начало и конец - прекрасная вещь, которую мы обсудим позже

  2. +
  3. Сишные массивы уже сейчас противоречат шаблону, в будущем их хотят вообще убрать, так что лучше от них отказаться сейчас

  4. +
  5. Быстрый переход от массива к вектору

  6. +
+

Минусы :

+
    +
  1. Тяжело отвыкнуть от старого массива.
  2. +
+

vector

+

vector — это динамический массив. Это означает, что его размер может меняться во время исполнения программы, вы можете добавлять элементы в конец и так далее. Чтобы объявить пустой vector, способный содержать в себе целые числа типа T, необходимо воспользоваться следующей конструкцией:

+
vector<T> vector_name;
+

Здесь T — это тип элементов, которые будут содержаться в vector, а vector_name — имя самого vector. Как и другие контейнеры C++, vector не может содержать элементы разных типов!

+

Чтобы добавить элемент в конец вектора, необходимо воспользоваться функцией push_back. Эта функция работает в среднем за \(O(1)\) .

+

Существуют два способа обращения к ‘vector’. Оба мы уже обсудили в массивах.

+
vector<int> a;
+cout << a[5];
+cout << a.at(5);
+

Кроме этого вам также могут потребоваться следующие методы :

+
vector<int> a;
+a.push_back(x); // вставляет x в конец a
+a.size(); // возращает размер вектора а
+a.resize(x) // сделать размер вектора = x, либо удаляются последние элементы, либо добавляются нули
+a.resize(x, y) // сделать размер вектора = x, добавляются y
+// Также можно изначально задать размер и элементы массива
+vector<int> a(10); // Размер вектора - 10 элементов, каждый из которых равен 0
+vector<int> b(5, 123); // Размер - 5 элементов, каждый из которых равен 123
+vector<int> c = {1, 2, 3} // явная инициализация
+

Как работает vector?

+

Как уже было сказано, добавление в конец вектора работает в среднем за \(O(1)\). Это означает, что, если вы сделаете \(n\) операций push_back, они будут в сумме работать за \(O(n)\). (Но при этом некоторые из них могли работать и за линейное время!)

+

У vector есть 2 важные величины: size и capacity — размер и вместимость. Размер — это то, сколько элементов сейчас находится в векторе. Вместимость — то, под сколько элементов памяти выделено. Когда size < capacity, push_back просто добавляет новый элемент в первую свободную ячейку уже выделенной памяти, поэтому работает за \(O(1)\). Когда size = capacity, так сделать не удастся. Поэтому, происходит следующее: 1. capacity увеличивается примерно в 2 раза. 2. Выделяется область памяти, вмещающая capacity элементов. 3. Элементы из старой области памяти копируются в новую. 4. Старая область памяти освобождается.

+

Поймём, почему амортизированное время работы push_back действительно равно \(O(1)\). Пусть сейчас capacity = \(n\). Тогда мы выделяли \(n + \frac{n}{2} + \frac{n}{4} + \dots < 2n\) памяти. На копирование также ушло не более \(2n\) операций. Следовательно, так как операций push_back было хотя бы \(\frac{n}{2}\), каждая операция в среднем работала за \(O(1)\).

+

Получить capacity у vector можно с помощью одноимённой функции. Рассмотрим пример того, как изменяется capacity.

+
vector<int> a;
+for (int i = 0; i < 10; i++) {
+    a.push_back(i);
+    cout << "Size: " << a.size() << ", capacity " << a.capacity() << "\n";
+}
+
+/* Будет выведено:
+   Size: 1, capacity 1
+   Size: 2, capacity 2
+   Size: 3, capacity 4
+   Size: 4, capacity 4
+   Size: 5, capacity 8
+   Size: 6, capacity 8
+   Size: 7, capacity 8
+   Size: 8, capacity 8
+   Size: 9, capacity 16
+   Size: 10, capacity 16
+*/
+

pair

+

pair — это тип, содержащий пару значений, притом значения могут быть разных типов. Объявление пары выглядит так:

+
pair<T1, T2> p;
+

Здесь T1 и T2 — это имена первого и второго типов, соответственно.

+

Первый элемент пары — это p.first; второй — p.second.

+

make_pair(a, b) — функция, которая создаёт пару \((a, b)\).

+

Рассмотрим пример работы с pair.

+
pair<int, double> p = make_pair(1, 2.0);
+pair<int, double> q = {1, 2.5}; // другой способ инициализировать пару
+cout << p.first << " " << p.second << "\n";
+

queue

+

В c++ уже реализована такая структура, как очередь, она названа queue.

+
queue<T> q; //очередь типа T
+

Очередь - структура, реализующая принцип FIFO (первый пришел - первый вышел), то есть для очереди существуют две основные функции : Вставить в конец и достать с начала.

+
q.front(); // ссылка на первый элемент
+q.back(); // ссылка на последний элемент
+q.push(x); // добавить в конец
+q.pop(); //удалить с начала
+

deque

+

deque - структура, позволяющая работать и с началом и концом одновременно, то есть вставка и удаление с двух сторон

+
deque<T> name; // дек типа T с названием name
+name.front(), name.back(); // первый и последний элемент соответственно
+name.pop_front(), name.pop_back(); // удаление первого и последнего элемента
+name.push_front(x), name.push_back(x); // вставка x в начало/конец
+

Очередь и дек будут более подробно рассмотрены на одном из следующих занятий.

+

Итераторы

+

Итератор — это объект, указывающий на элемент контейнера. Чтобы получить элемент, на который указывает итератор it, необходимо воспользоваться оператором разыменования: *it. Также если вам нужно перейти к следующему элементу надо использовать инкремент: ++it.

+

Есть несколько категорий итераторов: - InputIterator. Он поддерживает только операции разыменования и инкремента, притом после того, как был произведён инкремент, все копии его предыдущего значения могут стать невалидными. - ForwardIterator. Поддерживает то же, что и InputIterator, но итератор, указывающий на какой-то конкретный элемент, можно инкрементировать сколько угодно раз. - BidirectionalIterator. Поддерживает то же, что и ForwardIterator, но также есть возможность производить декремент (it--) — переходить к предыдущему элементу коллекции. - RandomAccessIterator. Поддерживает то же, что и BidirectionalIterator, но также есть возможность переходить к элементу коллекции, который находится от данного на каком-то расстоянии \(k\). Так, например, для итератора it возможны следующии операции: it + k, it - k, it += k, it -= k. Также можно находить расстояние между двумя позициями, на которые указывают итератора. Так, например, выражение a - b будет означать расстояние между двумя элементами коллекции, на которые указывают итераторы a и b.

+

Рассмотрим использование итераторов на примере vector. Для вектора a итератор на его первый элемент можно получить так: a.begin(). Также есть функция, которая возвращает итератор на фиктивный элемент, следующий за последним элементом вектора: a.end(). Таким образом, весь a задаётся полуинтервалом [a.begin(); a.end()) (левый конец включается, правый — нет).

+

Итераторы у vector относятся к категории RandomAccessIterator, то есть например мы можем узнать размер vector \(a\), просто взяв a.begin() - a.end(). При этом у vector типа vector<T> итератор будет иметь тип vector<T>::iterator.

+

Рассмотрим пример работы с итераторами у vector.

+
vector<int> a = {1, -2, 3, 100};
+
+vector<int>::iterator first_element = a.begin();
+cout << *first_element << "\n"; // выведет 1
+first_element++;
+cout << *first_element << "\n"; // выведет -2
+first_element--;
+cout << *first_element << "\n"; // выведет 1
+auto third_element = first_element + 2; // пользуемся типом auto, чтобы не писать длинное имя типа
+cout << *third_element << "\n"; // выведет 3
+cout << (third_element - first_element) << "\n"; // выведет 2
+
+// вывод всех элементов вектора с использованием итераторов
+for (auto it = a.begin(); it != a.end(); it++) {
+    cout << *it << " ";
+}
+cout << "\n"; // но для таких целей лучше использовать range-based for loop:
+  
+for (auto& it : a) {
+    cout << a << " ";
+}
+

list

+

list - структура, которая поддерживает быструю вставку и удаление элементов из любой позиции в контейнере. Быстрый произвольный доступ, к сожалению, не поддерживается (то есть мы не можем быстро взять \(i\)-й элемент). Он реализован в виде двусвязного списка

+
list<T> name; // создание листа типа Т с именем name
+name.insert(it, x); // вставка после итератора it переменной x
+name.erase(it); // удаляет элемент, на который указывает итератор
+name.front(), name.back(); // начало и конец name
+

set

+

set — это коллекция, которая содержит множество уникальных упорядоченных элементов.

+

Чтобы добавить элемент в set, есть функция insert. В случае, если элемент уже был в множестве, ничего не происходит.
+Чтобы удалить элемент из set, есть функция erase(в нее можно передать либо итератор на элемент, либо просто элемент). В случае, если элемента не было в множестве, ничего не происходит.
+Чтобы посмотреть, если ли элемент в set, есть функция count. Она вернёт \(0\), если элемента нет в множестве, и \(1\), если он есть. Также есть метод find,  который возвращает итератор на элемент или end, если элемента нет.

+

Все операции с элементами set (добавление, удаление, поиск) работают за \(O(\log n\)), где \(n\) — количество элементов в нём, так как он реализован с помощью сбалансированного двоичного дерева поиска.

+

Итераторы set относятся к категории BidirectionalIterator и имеют тип set<T>::iterator. Начало set можно получить с помощью функции begin, конец — с помощью функции end. Как и в случае с вектором, end указывает на конец полуинтервала. Инкремент и декремент итераторов set также работают за логарифмическое время.

+

Стоит отметить, что, так как элементы в set упорядочены, с помощью begin и end можно искать наименьший/наибольший элемент в set. Чтобы найти наименьший элемент, больший или равный заданному, есть функция lower_bound.
+Чтобы найти наименьший элемент, строго больший заданному, есть функция upper_bound.
+Каждая из этих функций возвращает итератор на искомый элемент или end(), если такого элемента не существует.

+

set может содержать только элементы тех типов, для которых определён оператор <, поскольку ему важен порядок элементов.

+

Рассмотрим пример простейших операций с set.

+
set<int> s;
+
+s.insert(3); // s = {3}
+s.insert(2); // s = {2, 3}
+cout << s.size() << "\n"; // выведет 2
+
+s.insert(3); // 3 не будет добавлено ещё раз, так как уже присутствует в множестве
+cout << s.size() << "\n"; // выведет 2
+
+s.insert(5); // s = {2, 3, 5}
+cout << s.count(3) << "\n"; // выведет 1
+cout << s.count(4) << "\n"; // выведет 0
+
+s.erase(3); // s = {2, 5}
+s.insert(6); // s = {2, 5, 6}
+
+set<int>::iterator it1 = s.find(5);
+it1++;
+cout << *it1 << "\n"; // выведет 6
+
+auto it2 = s.lower_bound(1);
+cout << *it2 << "\n"; // выведет 2, так как это первый элемент >= 1
+
+auto it3 = s.upper_bound(2);
+cout << *it3 << "\n"; // выведет 5, так как это первый элемент > 2.
+
+auto it4 = s.upper_bound(10);
+if (it4 == s.end()) {
+    cout << "No element > 10\n"; // аккуратно, если разыменуете it4, получите undefined behaviour!
+}
+
+// вывод всех элементов сета с использованием итераторов; элементы следуют в порядке возрастания
+for (auto it = s.begin(); it != s.end(); it++) {
+    cout << *it << " ";
+}
+cout << "\n"; // но для таких целей лучше использовать range-based for loop!
+

multiset

+

multiset — то же, что и set, но может содержать повторяющиеся элементы.

+

count работает за \(O(\log n + c)\), где \(c\) — количество искомых элементов. Поэтому, чтобы проверить наличие элемента \(el\) в multiset s, надо воспользоваться: s.find(el) != s.end().

+

erase удаляет все элементы с таким значением. Чтобы удалить один надо делать так: `s.erase(s.find(el)).

+

Примеры применения сета для решения задач

+

Очень часто использование сета позволяет решить задачу, которая решается и абсолютно другим способом. Иногда более сложным, а иногда и более простым. Чаще всего сет используется, если нужно сделать что-то, связанное с количеством разных элементов или с минимумом или максимум какого-то множества.

+

1) Задача Девшука или Юноша

+

Условие: http://codeforces.com/contest/236/problem/A

+

Условие вкратце: Найти чётность числа различных символов в строке.

+

Решение: вставим все символы в сет и проверим четность размера сета.

+

Заметим, что эту задачу можно легко решить и подсчетом (как в сортировке подсчетом), так как символов бывает очень мало.

+

2) A и B и ошибки компиляции

+

Условие: http://codeforces.com/contest/519/problem/B

+

Условие вкратце: Из массива убрали ровно одно число и перемешали элементы. Затем сделали так еще раз. Найдите, какие два элемента исчезли.

+

Решение: Добавим все числа в 3 различных мультисета, затем просто пройдемся по ним и найдем первый элемент, которого во втором меньше, чем в первои, но есть в первом. Мы нашли первую ошибку, вторая находится точно также.

+

Заметим, что эту задачу можно было бы легко решить и просто отсортировав все числа, что асимптотически тоже \(O(N log N)\).

+

3) Минимум на отрезке

+

Условие: https://informatics.msk.ru/mod/statements/view3.php?chapterid=756

+

Условие вкратце: Найти минимум на каждом отрезке длины \(K\) в массиве.

+

Решение: Давайте положим в мультисет первые \(K\) элементов. Далее мы будем двигать это “окно”: добавлять новый элемент справа и убирать самый левый элемент. Каждый раз будем выводить минимум в сете, который лежит в s.begin().

+

Также эта задача решается такими структурами данных как

+
    +
  1. Очередь с минимумом (за \(O(N)\)!)

  2. +
  3. Дерево отрезков

  4. +
  5. Sparse Table

  6. +
  7. Куча

  8. +
+

Но решить задачу сетом гораздо проще.

+

map

+

map — это ассоциативный контейнер: он содержит пары ключ-значение, при этом все ключи уникальны. Внутри контейнера все ключи упорядочены по возрастанию. Так же, как и в set, операции работают за логарифмическое время.

+

Объявление map выглядит так: map<T1, T2> map_name, где T1 — тип ключа, T2 — тип значения.

+

Доступ к элементам map осуществляется с помощью оператора []. map, аналогично set, поддерживает поиск по ключу с помощью find, lower_bound, upper_bound. При разыменовании итератора получается пара, первый элемент которой — ключ, второй — значение.

+

При обращении к несуществующему элементу map с помощью [], значение инициализируется значением по умолчанию для данного типа.

+

Рассмотрим работу map на примере:

+
map<int, int> a;
+a[13] = 5;
+a[2] = 7;
+cout << a[2] << "\n"; // выведет 7
+a[2]++;
+cout << a[2] << "\n"; // выведет 8
+a[100] = 42;
+
+/* Этот цикл выведет 3 строки:
+   2 8
+   13 5
+   100 42
+
+   Обратите внимание, что ключи упорядочены.
+*/
+for (auto el : a) {
+    cout << el.first << " " << el.second << "\n";
+}
+
+
+map<string, int> b;
+b["Bob"]--;
+b["Alice"] += 2;
+b["Dan"] = 123;
+
+/* Этот цикл выведет 2 строки:
+   Alice 2
+   Bob -1
+   Dan 123
+*/
+for (auto el : b) {
+    cout << el.first << " " << el.second << "\n";
+}
+
+map<string, vector<int>> c;
+c["wow"].push_back(2);
+c["abc"] = {2, -1, 17};
+cout << c["abc"].size() << "\n"; // выведет 3
+

Unordered структуры данных

+

Единственная проблема set и map - то что они работают за \(\log(n)\), что в некоторых задачах долго. Тогда возникает идея построить их не на двоичном дереве, а например на хештаблице (о ней вы узнаете на втором курсе), тогда unordered_set поддерживает вставку и удаление за \(O(1)\), единственная проблема - он содержит элементы в неотсортированном порядке, то есть мы уже не сможем искать минимум, максимум.

+

На питоне встроенные set и dict - это именно аналоги unordered_set и unordered_map. Упорядоченного сета и мэпа на питоне нет.

+
unordered_set<int> a;
+a.insert(x);
+a.erase(x);
+

Полезные функции из algorithm

+

swap

+

swap(a, b) — обменивает значения переменных a и b местами.

+
int a = 5;
+int b = 3;
+cout << a << " " << b << "\n"; // выведет 5 3
+swap(a, b);
+cout << a << " " << b << "\n"; // выведет 3 5
+

min_element и max_element

+

min_element(first, last) — возвращает итератор на минимум на полуинтервале [first; last).
+max_element(first, last) — возвращает итератор на максимум на полуинтервале [first; last).

+

Если минимумов/максимумов несколько, то возвращается первое вхождение.

+
vector<int> numbers = {5, 3, 1, 2, 1};
+auto it = min_element(numbers.begin(), numbers.end());
+cout << *it << " " << (it - numbers.begin()) << "\n"; // выведет 1 2
+

reverse

+

reverse(first, last) — переворачивает полуинтервал [first; last) (элементы идут в обратном порядке).

+
vector<int> a = {5, 2, 3, 10, 17};
+reverse(a.begin(), a.begin() + 3);
+for (int x : a) {
+    cout << x << " ";
+}
+cout << "\n";
+

В этом примере будет выведено 3 2 5 10 17.

+

sort, unique и компараторы

+

sort(first, last) — сортирует полуинтервал [first; last).

+
vector<int> a = {5, 2, 10, 11, 2, 3};
+sort(a.begin(), a.end()); // сортируем весь вектор
+for (int x : a) {
+    cout << x << " ";
+}
+cout << "\n";
+

В этом примере будет выведено 2 2 3 5 10 11.

+

Функция sort может принимать третий параметр — компаратор. Компаратор — это функция, которая принимает два объекта и возвращает true, если первый строго меньше второго, и false иначе.

+

Допустим, нам хотелось бы отсортировать числа по возрастанию их последней цифры, а при совпадении — по самому значению. Тогда мы могли бы написать следующий код:

+
bool cmp(int a, int b) {
+    return make_pair(a % 10, a) < make_pair(b % 10, b);
+}
+
+// это внутри main
+vector<int> a = {30, 32, 12, 7, 15};
+sort(a.begin(), a.end(), cmp);
+for (int x : a) {
+    cout << x << " ";
+}
+cout << "\n";
+

В данном примере, как мы и хотели, будет выведено 30 12 32 15 7.

+

unique(first, last) — принимает полуинтервал и удаляет все последовательные повторения элементов в нём. Функция возвращает итератор на конец полуинтервала, соответствующему уникализированным элементам. Значения элементов, которые следуют после этого полуинтервала, становятся неопределёнными. Поэтому рекомендуется использовать функцию unique, например, вместе с функцией resize.

+
vector<int> a = {5, 5, 5, 1, 5, 4, 4, 7, 1};
+a.resize(unique(a.begin(), a.end()) - a.begin());
+for (int x : a) {
+    cout << x << " ";
+}
+cout << "\n";
+

В данном примере будет выведено 5 1 5 4 7 1.

+

Часто требуется сначала отсортировать элементы, а потом убрать все повторения. Это делается следующей комбинацией:

+
sort(a.begin(), a.end());
+a.resize(unique(a.begin(), a.end()) - a.begin());
+

nth_element

+

Функция ставит на переданную позицию элемент, который был бы на этом месте после сортировки массива(работает за линию).

+
nth_element(begin, need, end); // need - позиция отсортированного массива, 
+//begin, end - итераторы на начало и конец места, которое надо сортировать.
+

next_permutation, prev_permutation

+

Генерирует следующую и предыдущую перестановку массива на отрезке с l по r;

+
next_permutation(l, r);//l, r - итераторы
+prev_permutation(a.begin(), a.end());
+

merge

+
merge(начало первой последовательности, конец первой последовательности, начало 
+второй последовательности, конец второй последовательности, куда вставлять);
+

Слияние двух массивов, которое используется в сортировке слиянием.

+ +

Все эти функции принимают полуинтервал [first; last) и значение value. Полуинтервал должен быть упорядочен по отношению element < value (сначала те элементы, которые удовлетворяют этому, потом остальные).

+

lower_bound — возвращает первый элемент, больший или равный value.
+upper_bound — возвращает первый элемент, строго больший value.
+binary_search — возвращает, присутствует ли value на этом полуинтервале.

+
vector<int> a = {1, 5, 5, 6, 7, 10};
+
+auto it1 = lower_bound(a.begin(), a.end(), 5);
+cout << (it1 - a.begin()) << "\n"; // выведет 1
+
+auto it2 = upper_bound(a.begin(), a.end(), 5);
+cout << *it2 << "\n"; // выведет 6
+
+if (binary_search(a.begin(), a.end(), 7)) {
+    cout << "There is an element = 7\n"; // это будет выведено
+}
+

Внимание!

+

Не используйте lower_bound, upper_bound, binary_search вместе с set/map! Они будут работать за линейное время. Используйте их собственные функции: set::lower_bound (вызывается через .) и так далее.

+

Ускорение ввода и вывода

+

Стандартные cin и cout работают очень медленно. Чтобы исправить это, в начале вашей функции main пишите следующее:

+
ios::sync_with_stdio(0);
+cin.tie(0);
+cout.tie(0);
+

Это позволяет ускорить ввод и вывод в разы!

+

Также крайне не рекомендуется использовать endl (кроме интерактивных задач). Используйте "\n". Они отличаются тем, что endl делает flush вывода, то есть сразу же выводит то, что вы хотите. Если вы будете использовать "\n", вывод будет накапливаться, а потом единожды выводиться, что гораздо быстрее.

+

Задание

+

Это просто олимпиадные задачи, но почти в любой задаче можно использовать STL, чтобы упростить себе жизнь.

+
    +
  • Решите как можно больше задач в контесте на информатиксе: https://informatics.msk.ru/mod/statements/view3.php?id=34778&chapterid=756#1
  • +
  • Решите как можно больше задач в контесте на codeforces: http://codeforces.com/group/g92L0id9Yb/contest/229989
  • +
+ + diff --git a/update.sh b/update.sh deleted file mode 100755 index 56eba1f..0000000 --- a/update.sh +++ /dev/null @@ -1,2 +0,0 @@ -# this syncs src dir with master branches of submodules -git submodule update --recursive --remote