Zajęcia 1: maski bitowe

Operacje bitowe w C++

Liczby całkowite w C++ można interpretować jako maski bitowe. Mamy do dyspozycji następujące operacje bitowe: & to and (koniunkcja), | to or (alternatywa), ^ to xor (różnica symetryczna), ~ to not (negacja), << to shl (przesunięcie bitowe w lewo o podaną liczbę bitów), >> to shr (przesunięcie bitowe w prawo). Do tego mamy jeszcze operatory traktujące liczby niezerowe jako prawdę, a zero jako fałsz: && to koniunkcja logiczna, || to alternatywa logiczna, ! to negacja logiczna.

Znajomość priorytetów operatorów logicznych nie jest powszechna, a błędy w tym zakresie są bardzo trudne do wychwycenia (np. jaka jest kolejność operacji w a | b + c czy a & b && c?). Dlatego polecam nawiasować wszystko z operatorami bitowymi jak się da, a pomijać nawiasowanie tylko wtedy, kiedy jest się absolutnie pewnym, że tak jest dobrze.

Operacje bitowe a operacje na zbiorach

Liczbę całkowitą 32-bitową (int) możemy teraz traktować jako podzbiór zbioru {0, 1, ..., 31}. Oto przykłady operacji na takich zbiorach:

Zadanie 1: Max-Cut (usprawnianie algorytmu wykładniczego)

Zadanie: Mamy dany graf nieskierowany G = (V, E), |V| = n. Chcemy podzielić zbiór V na dwie rozłączne części V1 i V2, tak aby liczba krawędzi prowadzących z jednego z tych zbiorów do drugiego była jak największa. Założenie: n ≤ 25.

To jest tzw. problem NP-trudny, czyli nie należy się spodziewać istnienia jakiegoś algorytmu wielomianowego rozwiązującego ten problem. Dzięki maskom bitowym możemy zaimplementować narzucający się algorytm wykładniczy efektywnie. W tym celu musimy poczynić kilka spostrzeżeń:

  1. Podział zbioru V będziemy reprezentować jako maskę bitową. Zapalony bit oznacza, że element należy do zbioru V1. Wtedy maski możemy rozważać w pętli od 0 do (1 << n) - 1.
  2. Graf możemy reprezentować jako prostą tablicę intów: int g[n];. Kolejne elementy tablicy to maski bitowe reprezentujące wiersze macierzy sąsiedztwa grafu.
Jesteśmy już gotowi zaimplementować prawie całe rozwiązanie:

int wynik = 0;
for (int mask = 0; mask < (1 << n); ++mask) {
  int akt = 0;
  for (int i = 0; i < n; ++i)
    if (mask & (1 << i)) {
      akt += zapalone_bity(g[i] & (~mask));
    }
  wynik = max(wynik, akt);
}
return wynik;

Pozostaje kwestia, jak zliczać zapalone bity w liczbie. Jeśli używamy kompilatora z rodziny GCC, możemy posłużyć się zbudowaną instrukcją __builtin_popcount, która robi dokładnie to, co chcemy (na SIO kompilujemy w GCC). (Przy okazji warto wspomnieć o istnieniu pokrewnej instrukcji __builtin_ctz zliczającej najmniej znaczące zera liczby, czyli podającej indeks pierwszego zapalonego bitu). Jeśli nie mamy GCC, możemy posłużyć się tzw. tablicowaniem. Dla wszystkich liczb, powiedzmy, 16-bitowych wyznaczamy liczby zapalonych bitów, spamiętujemy je w tablicy, a wówczas przy obliczaniu liczby zapalonych bitów dzielimy naszego inta na połówki. Kod tablicowania to:

int zapalone[1 << 16]; // wyzerowane
for (int mask = 0; mask < (1 << 16); ++mask) {
  zapalone[mask] = zapalone[mask / 2] + mask % 2;
}

a kod funckji zapalone_bity to:

int zapalone_bity(int a) {
  return zapalone[a >> 16] + zapalone[a & ((1 << 16) - 1)];
}

Łącznie w rozwiązaniu wykonujemy rzędu O(2n*n) operacji.

Zadanie 2: Trójkąty (usprawnianie algorytmu wielomianowego)

W grafie nieskierowanym chcemy zliczyć trójkąty, czyli trójki wierzchołków połączone każdy z każdym. Tutaj n ≤ 1500, a rozwiązanie wielomianowe o złożoności O(n3) jest oczywiste. (To zadanie da się też rozwiązać w czasie O(m3/2), gdzie m to liczba krawędzi grafu, ale nie będziemy się tym rozwiązaniem przejmować).

Tutaj również można wykorzystać maski bitowe. Tylko tym razem graf nie zmieści się w tablicy intów, więc musimy go upakować w tablicę dwuwymiarową mask bitowych: int g[n][(n + 31) / 32]. Teraz już implementacja jest zupełnie jasna:

long long wynik = 0;
for (int i = 0; i < n; ++i)
  for (int j = 0; j < n; ++j)
    if (g[i][j / 32] & (1 << (j % 32))) { // jest krawędź z i do j
      for (int k = 0; k < (n + 31) / 32; ++k)
        wynik += zapalone_bity(g[i][k] & g[j][k]);    
return wynik / 6;

Koszt algorytmu to O(n3/B), przy czym B oznacza liczbę bitów, jaką mamy do dyspozycji (B=32, B=64...).

Patrz także mój artykuł z Delty na ten temat (PDF).

Zadanie 3: Domknięcie przechodnie (do samodzielnego przemyślenia, dla miłośników algorytmów)

Mamy dany graf skierowany G. Domknięciem zwrotno-przechodnim grafu G nazywamy graf G*, w którym dwa wierzchołki są połączone krawędzią, jeśli w oryginalnym grafie były połączone ścieżką (być może jednowierzchołkową). Rozwiązanie O(n3) to tzw. algorytm Floyda-Warshalla. Pokaż rozwiązanie działające w czasie O(n3/B).

Zadanie 4: Skojarzenie (programowanie dynamiczne po maskach bitowych)

Znów dany jest graf nieskierowany o n wierzchołkach. Chcemy znaleźć w nim doskonałe skojarzenie, czyli taki podział wierzchołków na pary, że wierzchołki w każdej parze są połączone krawędzią. Wystarczy nam informacja, czy takie skojarzenie istnieje. Mamy n ≤ 25.

Uwagi rozszerzające: Znane są algorytmy wielomianowe rozwiązujące ten problem, działające w czasie O(n4) lub nawet O(n3). Są one jednak dość trudne. W grafie dwudzielnym mamy algorytm przez ścieżki naprzemienne, działający w czasie O(n3) (tak naprawdę O(n * m)). Na upartego można by dzielić wierzchołki grafu na dwa podzbiory na wszystkie możliwe sposoby (patrz zad. 1), a potem wyrzucać krawędzie mieszczące się w ramach tych podzbiorów i uruchamiać algorytm skojarzenia w grafie dwudzielnym. Otrzymalibyśmy w ten sposób algorytm O(2n * n3), i do tego trochę skomplikowany. A da się prościej.

Pokażemy, jak to zadanie rozwiązać prościej, w koszcie czasowym wykładniczym. Zastosujemy programowanie dynamiczne: dla każdego możliwego podzbioru zbioru wierzchołków chcemy stwierdzić, czy da się skojarzyć wszystkie wierzchołki w ramach tego podzbioru (nieważne jakie jest to skojarzenie, ważne czy jest). Skoro to programowanie dynamiczne, to przy obliczaniu wyników dla większcyh zbiorów będziemy korzystali z wyników dla mniejszych. Mamy zatem tablicę bool czy[1 << 25], zainicjowaną fałszem poza czy[0] = 1, chcemy ją wypełnić i zwrócić czy[(1 << n) - 1]. Obliczywszy czy[mask], możemy na wszystkie możliwe sposoby dodać do tego częściowego skojarzenia jedną krawędź łączącą wierzchołki spoza zbioru mask, przechodząc w ten sposób do dalszego stanu w programowaniu dynamicznym. Oto kod:

for (int mask = 0; mask < (1 << n); ++mask)
  if (czy[mask])
    for (int i = 0; i < n; ++i)
      if (!(mask & (1 << i)))
        for (int j = 0; j < n; ++j)
          if ((g[i] & (1 << j)) // jest krawędź z i do j
              && !(mask & (1 << j)))
            czy[mask | (1 << i) | (1 << j)] = 1;
return czy[(1 << n) - 1];

Mamy tu ewidentnie O(2n * n2) operacji. A da się lepiej, jeśli tylko sprytniej obsłużymy dwie wewnętrzne pętle. Otóż wystarczy wziąć dowolne i znajdujące się poza aktualną maską i tylko dla niego szukać odpowiednich j. Faktycznie, jeśli istnieje doskonałe skojarzenie, to wierzchołek i musi z czymś być połączony. Dzięki tej drobnej modyfikacji otrzymujemy rozwiązanie o koszcie czasowym O(2n * n). Chyba nie muszę nikogo przekonywać, że rozwiązanie działające 25 razy szybciej jest ewidentnie lepsze.

for (int mask = 0; mask < (1 << n); ++mask)
  if (czy[mask]) {
    int i0 = -1;
    for (int i = 0; i < n; ++i)
      if (!(mask & (1 << i))) {
        i0 = i;
        break;
      }
    if (i0 != -1) {
      for (int j = 0; j < n; ++j)
        if ((g[i] & (1 << j)) && !(mask & (1 << j)))
          czy[mask | (1 << i0) | (1 << j)] = 1;
    }
  }
return czy[(1 << n) - 1];

Pytanie rozszerzające: Jak na podstawie tablicy czy odtworzyć doskonałe skojarzenie? W czasie O(n2) i bez dodatkowej pamięci!

Zadanie domowe

Zadanie domowe: Trójkąty na ASD-SIO. Warto dokładnie przeczytać treść zadania.


Jakub Radoszewski