異或雜湊

Yaosicheng124發表於2024-09-02

理論基礎

異或雜湊是個很神奇的演算法,利用了異或操作的特殊性和雜湊降低衝突的原理,可以用於快速找到一個組合是否出現、序列中的數是否出現了k次

https://blog.csdn.net/notonlysuccess/article/details/130959107

https://codeforces.com/blog/entry/85900

CF1175F

https://codeforces.com/contest/1175/problem/F

那麼,最經典的求組合出現問題

理論基礎中提到了這個問題,並給出了 \(O(n^2)\) 的暴力解法。

std::mt19937_64 rnd(time(0));
using hash = uint64_t;

void solve()
{
    int n; std::cin >> n;
    std::vector<int> a(n); for (auto& x : a) {std::cin >> x;}
    std::vector<hash> code(n + 1), pre_chk(n + 1), pre_xor(n + 1);
    // code:(a[i] -> uint64), pre_chk : 1 ~ n 的 字首異或和, pre_xor : a[i] 的 字首異或和
    for (int i = 1; i <= n; i++) {
        code[i] = rnd();
        pre_chk[i] = pre_chk[i - 1] ^ code[i];
    }
    for (int i = 1; i <= n; i++) {pre_xor[i] = pre_xor[i - 1] ^ code[a[i]];}

    std::map<int, int> cnt; cnt[0] = 1;

    int res{}; for (int l = 1; l <= n; l++) {
        for (int r = l; r <= n; r++) {
            if (pre_xor[r] ^ pre_xor[l - 1] == pre_chk[r - l + 1]) {
                res += 1;
            }
        }
    }
}

根據問題進一步提取性質:

  • 滿足條件的區間肯定有 \(1\)等於區間長度的最大值 \(mx\)
  • 分類 \(mx\)\(1\) 的左邊或者右邊處理即可。
int ans{}, one{};//特殊記錄長度為1的個數,因為會統計兩邊

auto calc = [&](auto a) {//正反跑一遍,處理兩個方向的方案數
    std::vector<hash> pre_xor(n + 1);//a[i] 的 字首異或和和
    for (int i = 0; i < n; i++) {pre_xor[i + 1] = pre_xor[i] ^ code[a[i]];}
    int lst_one{-1}, mx = 0; for (int r = 0; r < n; r++) {
        if (a[r] == 1) {//進行新一段的處理
            one += 1; lst_one = r; mx = 1;    
        } else if (lst_one != -1) {
            mx = std::max(mx, a[r]);
            if (mx >= (r - lst_one + 1)) {//如果當前最大值大於等於當前段長度,則可以操作
                int l{lst_one - (mx - (r - lst_one + 1))};//找到符合當前最大值長度的段的左端點
                if (l >= 0) {ans += ((pre_xor[r + 1] ^ pre_xor[l]) == pre_chk[mx]);}
            }
        }
    }

};
calc(decltype(a)(a.begin(), a.end())); calc(decltype(a)(a.rbegin(), a.rend()));

std::cout << ans + one / 2 << '\n';

CF1418G

https://www.luogu.com.cn/problem/CF1418G

那麼,出現次數問題

這裡要求出現三次,所以不用二進位制異或而是新定義一個三進位制異或:

\[0 \oplus 0 = 0\\ 0 \oplus 1 = 1\\ 0 \oplus 2 = 2\\ 1 \oplus 2 = 0\\ \]

constexpr int N{60};
using ternary = std::array<int, N>;

ternary operator ^(const ternary &a, const ternary &b){
    ternary c;
    for (int i = 0; i < N; i++) {
        c[i] = a[i] + b[i];
        if (c[i] >= 3) c[i] -= 3;
    }
    return c;
}

首先我們知道一個思想,證明充要條件就要證明它既充分又必要;同樣,要證明一個數等於某個值,必須讓它既小於等於又大於等於這個值。
這個思想運用到這道題上就十分方便。我們讓所有數的出現個數 \(cnt = 3\),便是要去滿足 \(cnt \geq 3 \land cnt \leq 3\) 這倆約束。 |

第一個約束十分好想,可以規約到 \(cnt \equiv 0 \pmod 3\) 上去(三的倍數必然大於等於三),然後顯然用 XOR-Hash 搞一下就行。

然後考慮第二個約束。我們考慮使用類似於雙指標的演算法,具體來說:考慮對於一個滿足約束二的 \([l, r]\) 區間,右指標每次往右移動一次,都可能會破壞原本“滿足約束二”的性質。那麼為了讓其重新滿足,我們需要讓左指標一直向右移動,即:從左到右刪去數字使得區間再次滿足約束二。(只需讓新加入的右指標的值 \(a_r\) 出現的次數小於等於三即可;因為這樣刪除必然不會導致“因為其他數字出現次數減少而導致不能滿足約束二”這種情況,理由顯然)

\(pre_r\)\([1, r]\) 區間的異或和(也就是到 \(r\) 為止的字首異或和)。當刪除完畢之後,我們統計滿足 \(pre_r = pre_{pos}\)\(pos \in [l, r]\)\(pos\) 數量,這一點可以使用 map 或者雜湊表完成。那麼這道題就完成了,複雜度 \(\mathcal{O}(N \log_2 N)\) 或者純線性。

void solve()
{
    int n; std::cin >> n; std::vector<int> a(n); for (auto& x : a) {std::cin >> x; --x;}
    std::vector<ternary> nums(n); for (int i = 0; i < n; i++) for (int j = 0; j < N; j++) {nums[i][j] = rnd() % 3;}

    std::vector pos(n, std::vector<int>());//(數字,出現位置)
    std::vector<ternary> pre_xor(1);//(字首異或和)
    std::map<ternary, int> cnt;//(統計字首異或和值的出現次數)
    cnt[pre_xor[0]] = 1;
    int p{}; i64 ans{};
    for (int i = 0; i < n; i++) {
        pre_xor.push_back(pre_xor.back() ^ nums[a[i]]);//當前目標的字首異或和
        pos[a[i]].push_back(i);
        if (std::size(pos[a[i]]) > 3) {//如果該數字的出現次數已經大於三次了
            while (p <= pos[a[i]][std::ssize(pos[a[i]]) - 4]) {//去掉直到最左邊的該數字的位置所有出現的數字, 並更新每個字首異或和出現的次數
                cnt[pre_xor[p]] -= 1; p++;
            }
        }
        ans += cnt[pre_xor.back()];
        cnt[pre_xor.back()] += 1;
    }
    std::cout << ans << '\n';
}

CF1996G

https://www.luogu.com.cn/problem/CF1996G

很神奇的雜湊做法

我們設 \(n=6,m=2\),且 \((1,3),(4,6)\) 是朋友,用紫線的連結表示朋友關係

img

對於每對朋友,要麼是透過優弧聯通,要麼是透過劣弧聯通,所以我們乾脆直接對優弧劣弧都染色一下

img

其中綠色/橙色是 \((4,6)\) 的劣弧/優弧,藍色/黃色是 \((1,3)\) 的劣弧/優弧

要維護最少的路,就是透過我們對於每隊朋友都選擇他們的劣弧/優弧後使得沒有被染色的道路最多(我們選擇某隊朋友的劣弧後,就使得優弧不存在圖上了)

一個很經典的思路:保留最少相當於刪除最多

為了方便寫部落格,我們分別對上面顏色的曲線進行編號:綠色是1,黃色是2,橙色是3,藍色是4

那麼我們能選擇的弧的集合其實是 \((1,3),(2,3),(1,4),(2,4)\)

其實就是我們要對每對朋友都選擇一個弧,使得僅被這些弧染色的道路儘可能多,然後刪除這些道路

我們改怎麼實現這個想法呢?

我們定義\(edge_i\)\(i\rightarrow i+1\) 的這條邊,例如 \(edge_1\) 就是 \(1\) 連向 \(2\) 的道路

我們對每對朋友的兩個端點都 \(\oplus rand\),其中 \(rand\) 是一個六十四位隨機數,即對於 \((1,3)\)\(edge_1\oplus rand,edge_3⊕rand\),其中 \(rand\) 僅在這裡是相同的,即每對朋友在異或時的 \(rand\) 都互不相同。

然後我們維護一個字首和就可以得到 \(i\rightarrow i+1\) 這條路的染色情況了

而這是非常抽象的,我們是怎麼得到染色情況的呢?並且我們不是隻染了一個弧嗎,另一個難道直接不管了?

首先我們先簡化模型,假設只有 \((1,3)\) 這一對朋友,並且我們恰好得到 \(rand=1\),那麼有

img

然後又加上了 \((4,6)\) 這對朋友,並且 \(rand\) 恰好是 \(2\)

img

可以發現神奇的每個數值剛好都對應著一種弧的集和

我們對兩端都異或同一個隨機數是透過差分的思想來 \(O(1)\) 染色,這樣可以透過字首和得知當前的染色情況

可以透過字首和得知染色情況是因為,我們透過六十四位的隨機數異或值實現了雜湊的思想,對於每種弧都有特定的雜湊值,而弧集的雜湊值是可以透過異或得到,這個比較抽象,所以建議可以理解為狀壓差不多的思想

還有一個問題:為什麼只對一個弧染色就相當於對兩個弧都染色了呢

因為是異或的隨機值,我們對優弧染上了 \(x\) ,對劣弧染上了 \(y\) ,然後整個圈都同時異或 \(y\),相當於優弧染上了 \(x⊕y\),劣弧染了 \(0\),因為是隨機的異或值,所以 \(x⊕y\) 可以直接相當於 \(x\)

然後用統計下字首和出現最多的數值,刪除這個數就是答案

std::mt19937_64 rng {std::chrono::steady_clock::now().time_since_epoch().count()};

void solve()
{
#define tests
	int n, m; std::cin >> n >> m;
	std::vector<u64> f(n);
	for (int i = 0, u, v; i < m; i++) {
		std::cin >> u >> v; --u; --v; u64 rnd{rng()};
		f[u] ^= rnd; f[v] ^= rnd;//相當於差分對優弧劣弧染色
	}
	// 要維護最少的路,就是透過我們對於每隊朋友都選擇他們的劣弧/優弧後使得沒有被染色的道路最多
	// 我們選擇某隊朋友的劣弧後,就使得優弧不存在圖上了
	// 因為是異或的隨機值,我們對優弧染上了 x ,對劣弧染上了 y ,然後整個圈都同時異或 y
	// 相當於優弧染上了 x⊕y,劣弧染了 0,因為是隨機的異或值,所以 x⊕y 可以直接相當於 x。
	// 所以字首和隨便選一個分界點都能代表一種全染色情況,要麼優弧,沒值的久全是劣弧
	std::map<u64, int> cnt;
	u64 pre{};
	int mx{};
	for (int i = 0; i < n; i++) {
		pre ^= f[i]; cnt[pre] += 1; mx = std::max(mx, cnt[pre]);
	}
	std::cout << n - mx << '\n';
}