算法基础
- [1. 位运算](#1. 位运算)
-
- [1.1 常见二进制用法](#1.1 常见二进制用法)
-
- [1.1.1 lowbit:提取二进制的最后一位1](#1.1.1 lowbit:提取二进制的最后一位1)
- [1.1.2 获取第k位](#1.1.2 获取第k位)
- [1.1.3 判断奇偶](#1.1.3 判断奇偶)
- [1.1.4 无缓存交换](#1.1.4 无缓存交换)
- [1.1.5 位移实现乘除法](#1.1.5 位移实现乘除法)
- [1.2 位运算常见例题](#1.2 位运算常见例题)
- [2. 离散化](#2. 离散化)
-
- [2.1 离散化知识点](#2.1 离散化知识点)
- [2.2 例题](#2.2 例题)
- [3. 区间合并](#3. 区间合并)
-
- [3.1 区间合并模板](#3.1 区间合并模板)
- [3.2 例题](#3.2 例题)
1. 位运算
在计算机中,数字是以**二进制(补码)**形式存储的。常见的位运算符包括:
| 运算符 | 名称 | 规则 | 二进制 |
|---|---|---|---|
& |
与 (AND) | 两个都为 1,结果才为 1 | 1011 & 0110 = 0010 |
| ` | ` | 或 (OR) | 只要有一个为 1,结果就为 1 |
^ |
异或 (XOR) | 相同为 0,不同为 1("不进位加法") | 1011 ^ 0110 = 1101 |
~ |
取反 (NOT) | 0 变 1,1 变 0 | ~1011 = 0100 (简略表示) |
<< |
左移 | 所有位向左移动,右侧补 0(相当于乘 2) | 0011 << 1 = 0110 |
>> |
右移 | 所有位向右移动(相当于除 2) | 0110 >> 1 = 0011 |
1.1 常见二进制用法
1.1.1 lowbit:提取二进制的最后一位1
i & -i 被称为 lowbit 操作。它的作用是:返回 x 的二进制表示中,最低位(最右边)的 1 及其后面的 0 所构成的数值。
为什么 x & -x 能得到最后一位 1?
这涉及到计算机如何表示负数------补码。
- 原码 :假设 x = 6,二进制是
0000 0110。 - 反码 :
~x是1111 1001。 - 补码(负数) :在计算机中,
-x等于反码加 1,即-x = ~x + 1。-6的补码:1111 1001 + 1 = 1111 1010。
神奇的事情发生了:
-
x:
0000 0110 -
-x:
1111 1010 -
进行
&运算:c++0000 0110 (6) & 1111 1010 (-6) ----------- 0000 0010 (结果为 2,即二进制中的最后那个 1)
1.1.2 获取第k位
n >> k & 1这是处理二进制最基础的操作,就像是在看数组的第k个元素。
- 操作: 把想看的第 k位(从右数,从 0 开始)移动到最右边,然后把其他位全遮住。
- 解释:
n >> k:让整个数字向右移动k位。这时,原来的第k位就站在了**个位(第 0 位)**上。& 1:数字1的二进制除了最后一位是 1,前面全是 0。做&运算时,除了个位,其他位都会被强制变成 0。
- 结果: 如果第k位是 1,结果就是 1;如果是 0,结果就是 0。
1.1.3 判断奇偶
x & 1 比 x % 2 == 1 更优雅、更快速。
- 解释:
- 二进制中,除了最后一位代表2^0 = 1,其余位代表的都是 2 的幂(2, 4, 8),它们全是偶数。
- 所以,一个数是奇是偶,全看最后一位。
- 结果:
x & 1 == 1为奇数,x & 1 == 0为偶数。
十进制 二进制 拆解计算 奇偶性 1 00011 奇数 2 00102 偶数 3 00112 + 1 奇数 4 01004 偶数 5 01014 + 1 奇数 6 01104 + 2 偶数 发现规律了吗?
- 偶数 的二进制最后一位永远是 0。
- 奇数 的二进制最后一位永远是 1。
为什么?
因为二进制的位权分别是 ...、8、4、2、1。前面的 ...、8、4、2 全是偶数,无论它们怎么相加,结果还是偶数。唯一能决定这个数是奇还是偶的,只有最后那个 1 1 1(即 2 0 2^0 20 位)。
1.1.4 无缓存交换
**a ^= b; b ^= a; a ^= b;**这是一个不需要额外变量 temp 就能交换两个数的神奇魔法。
- 原理(异或的性质):
- x ⊕ x = 0 x \oplus x = 0 x⊕x=0(自己异或自己等于 0)
- x ⊕ 0 = x x \oplus 0 = x x⊕0=x(任何数异或 0 等于本身)
- 异或运算满足交换律和结合律。
- 推导:
- 第一步: a = a ⊕ b a = a \oplus b a=a⊕b
- 第二步: b = b ⊕ ( a ⊕ b ) = ( b ⊕ b ) ⊕ a = 0 ⊕ a = a b = b \oplus (a \oplus b) = (b \oplus b) \oplus a = 0 \oplus a = a b=b⊕(a⊕b)=(b⊕b)⊕a=0⊕a=a (此时 b b b 拿到了原来的 a a a)
- 第三步: a = ( a ⊕ b ) ⊕ a = ( a ⊕ a ) ⊕ b = 0 ⊕ b = b a = (a \oplus b) \oplus a = (a \oplus a) \oplus b = 0 \oplus b = b a=(a⊕b)⊕a=(a⊕a)⊕b=0⊕b=b (此时 a a a 拿到了原来的 b b b)
注意: 虽然这个写法很酷,但在现代编译器中,使用
std::swap通常更安全且一样快,而且能避免a和b指向同一地址时把数值变 0 的风险。
1.1.5 位移实现乘除法
**<< 与 >>**这是算法优化中的常客。
- 左移
<< n: 相当于乘以 2^n。每左移一位,低位补 0,数值翻倍。 - 右移
>> n: 相当于除以 2^n。每右移一位,相当于舍弃最右边的位,数值减半。 - 为什么快?
- 乘除法在 CPU 里是相对复杂的电路操作,而位移只是简单的"排排坐,往边挪",通常只需要一个指令周期。
- 注意: 对于负数的右移,C++ 通常执行的是算术右移(高位补符号位),这符合数学上的整除。
1.2 位运算常见例题
例题1:给定一个长度为 n 的数列,请你求出数列中每个数的二进制表示中 1 的个数。
c++
#include <iostream>
using namespace std;
int main()
{
int n;
scanf("%d", &n);
while (n -- )
{
int x, s = 0;
scanf("%d", &x);i != 0
for (int i = x; i; i -= i & -i) // 中间的 i 实际上等价于i != 0
s ++ ;
printf("%d ", s);
}
return 0;
}
例题2:给定一个正整数 x,请你将 x 的二进制表示中第 i 位和第 j 位的值互换,并输出互换后的结果。
代码思路:
第一步:拆解(Extract)
利用 "获取第 k k k 位" 的知识点,把整数 x x x 的每一位都取出来,存入一个数组中。
- 代码实现:
q[k] = x >> k & 1; - 原理: 把 x x x 右移 k k k 位,让目标位落在最低位,然后通过
& 1把它"抠"出来。
第二步:交换(Swap)
直接在数组中交换下标为 i i i 和 j j j 的两个元素。
- 代码实现:
swap(q[i], q[j]); - 原理: 此时位值已经变成了普通的数组元素(0 或 1),直接交换数值即可。
第三步:重组(Reconstruct)
在前面的代码逻辑中,你已经把整数 x x x 拆成了一个个的 0 和 1 放在数组 q 里,并且完成了位置交换。现在,你需要把这些"零件"按原位装回去,重新变回一个十进制整数。利用 "位移实现乘除法" 的知识点,将数组中的 0 和 1 重新放回对应的二进制权重位上。
- 代码实现:
x += q[k] << k; - 原理:
q[k]是 0 或 1。如果q[k]是 1,通过<< k把它左移 k k k 位,就得到了 2 k 2^k 2k 的值,累加进 x x x 即可。
举个例子: 如果 q[3] = 1(代表第 3 位是个 1),那么 1 << 3 就是把 1 变成二进制的 1000。 在十进制中,1000 二进制对应的数值就是 2 3 = 8 2^3 = 8 23=8。
补充:
| 类型 | 占用字节 | 位数 (bits) | 近似范围 | 精确公式 |
|---|---|---|---|---|
int |
4 字节 | 32 位 | ± 2 × 10 9 \pm 2 \times 10^9 ±2×109 | − 2 31 -2^{31} −231 ~ 2 31 − 1 2^{31}-1 231−1 |
long long |
8 字节 | 64 位 | ± 9 × 10 18 \pm 9 \times 10^{18} ±9×1018 | − 2 63 -2^{63} −263 ~ 2 63 − 1 2^{63}-1 263−1 |
c++
#include <iostream>
#include <vector>
using namespace std;
int main()
{
long long x;
int i, j;
cin >> x >> i >> j;
vector<int> q(40); // 默认40个0
for (int k = 0; k < 32; k++)
q[k] = (x >> k) & 1;
swap(q[i], q[j]);
// 4 -> 100
// 复原为十进制数
long long res = 0;
for (int k = 0; k < 32; k++) // q[k]只可能是0或1,每次左移1位 然后转化为十进制数累加到res中
{
if(q[k] == 1)
{
res += (q[k] << k); // q[k]表示位于原本的第几位 即原本的2的k次方
}
}
cout << res << endl;
return 0;
}
例题3:判断其中一个的 16 位二进制表示形式,是否能由另一个的 16 位二进制表示形式经过循环左移若干位而得到。
判断其中一个的 16 位二进制表示形式,是否能由另一个的 16 位二进制表示形式经过循环左移若干位而得到。
-
核心思路:双倍字符串技巧
这是解决所有"循环移位"或"循环同构"问题的金钥匙。
- 原理 :假设字符串 A A A 是
1011。它循环移位可能产生的结果有:1011,0111,1110,1101。 - 技巧 :如果我们将 A A A 拼接在自己后面,得到 A + A A+A A+A(即
10111011),你会发现所有可能的循环移位结果 都作为子串 出现在了 A + A A+A A+A 中。 - 代码应用 :代码中通过
y += y;将 b b b 的二进制串加倍,然后用y.find(x)检查 a a a 的二进制串是否在其中。
- 原理 :假设字符串 A A A 是
-
字符串拼接:
+与+=
在 C++ 中,如果 + 的两边至少有一个是 string 对象,它就代表拼接(Concatenation),即将后面的内容接到前面的内容末尾。
- 代码应用:
x = x + to_string(...)会在原有的字符串x后面加上新生成的字符。 - 代码应用:
y += y是y = y + y的简写,表示将y复制一份并拼接到自己后面,使其长度翻倍。 - 注意点: 拼接操作会产生新的字符串,如果是在循环中频繁使用
+拼接长字符串,性能会比使用.append()或直接操作char数组略低,但对于处理 16 位二进制串来说,性能差异可以忽略不计。
to_string()函数
将数值类型(如 int, long, float 等)转换为对应的字符串形式。
- 在你的代码中:
a >> i & 1的结果是一个整数0或1。 - 转换结果:
- 如果结果是
0,to_string(0)会变成字符串"0"。 - 如果结果是
1,to_string(1)会变成字符串"1"。
- 如果结果是
- 目的: 这样做是为了能把这个二进制位"存"进
string类型的变量x或y中,方便后续进行全文匹配。
y.find(x)函数
C++ 字符串查找的核心函数,用于在字符串 y 中搜索子串 x。
- 工作原理: 它会从
y的开头开始往后找,看看有没有一段内容和x完全一模一样。 - 返回值:
- 找到了: 返回
x在y中第一次出现的起始下标(从 0 开始)。 - 没找到: 返回一个特殊常量
string::npos。在大多数编译器和你的代码判断逻辑中,这个值被转成了-1。
- 找到了: 返回
- 代码逻辑:
if(y.find(x) != -1)表示"只要在y+y的大串里能找到x这个小串,就说明匹配成功"。
c++
#include <iostream>
#include <string>
#include <iostream>
using namespace std;
int main()
{
int a, b;
while (cin >> a >> b)
{
string s1, s2;
for (int i = 0; i < 16; i++){
s1 += to_string((a >> i) & 1); // 提取a的每一位并进行拼接
s2 += to_string((b >> i) & 1);
}
string s = s2 + s2; // 拼接在一块 这样 s2 + s2 就包含了 s2 所有可能的循环移位结果
if (s.find(s1) != -1)
{
printf("YES\n");
}
else
{
printf("NO\n");
}
}
return 0;
}
2. 离散化
2.1 离散化知识点
什么是离散化?
离散化的核心思想是:化大为小,保持相对顺序。
为什么要离散化?
比如坐标 x x x 的范围是 [ − 10 9 , 10 9 ] [-10^9, 10^9] [−109,109]。如果你想开一个数组 a[2000000000],内存绝对会爆掉。
但是,我们实际用到的坐标点最多只有 3 × 10 5 3 \times 10^5 3×105 个( n n n 次增加坐标 + m m m 次询问的 l l l 和 r r r)。
离散化就像是给这组稀疏的坐标点"排座位":
比如有坐标:{-100, 50, 1000000}
我们把它们映射成:
-100→ \rightarrow →150→ \rightarrow →21000000→ \rightarrow →3
这样,原本巨大的坐标就变成了紧凑的下标 1, 2, 3。
2.2 例题
c++
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 定义 PII 方便存储坐标和加数的键值对
typedef pair<int, int> PII;
// 为什么是 300010?
// n 次插入用到 n 个坐标,m 次询问用到 2m 个坐标 (l 和 r)
// 总计 n + 2m = 300,000。开 300010 防止越界
const int N = 300010;
int n, m;
int a[N]; // 存储离散化后的坐标对应的加数 即离散化(压缩)后的新数轴
int s[N]; // 存储前缀和
vector<int> alls; // 存储所有"用到的"原始坐标,用于排序去重
vector<PII> add, query; // 存储所有的操作指令
// 二分查找:输入原始坐标 x,返回它在离散化数组中的"排名"(下标)
int find(int x) { // x是原始的坐标 非常大
int l = 0, r = alls.size() - 1;
while (l < r) {
int mid = l + r >> 1;
if (alls[mid] >= x) r = mid; // 此时alls已经排序去重了,存储的是所有的大坐标
else l = mid + 1;
}
return r + 1; // 返回从 1 开始的下标,方便前缀和计算
}
int main() {
// 1. 读入数据并收集坐标
cin >> n >> m;
for (int i = 0; i < n; i++) {
int x, c;
cin >> x >> c;
add.push_back({x, c});
alls.push_back(x); // 记录插入操作的坐标
}
for (int i = 0; i < m; i++) {
int l, r;
cin >> l >> r;
query.push_back({l, r});
alls.push_back(l); // 记录询问左边界
alls.push_back(r); // 记录询问右边界
}
// 2. 离散化核心:排序 + 去重
sort(alls.begin(), alls.end());
alls.erase(unique(alls.begin(), alls.end()), alls.end());
// 3. 执行"加值"操作
for (auto item : add) {
int x = find(item.first); // 把大坐标转换成小下标
a[x] += item.second; // 在映射后的位置加上 c
/*
第一条指令:在坐标100加3。第二条指令:在坐标100再加5。映射过程:假设通过 find 函数,原始坐标100被映射成了离散化后的门牌号 1。执行结果:第一次执行:a[1] 变成3。第二次执行:a[1] 变成3+5=8。
*/
}
// 4. 计算前缀和 -> 可以直接算区间[l, r]的和
// 此时 a 数组里已经填好了值,坐标已经变得非常紧凑
for (int i = 1; i <= alls.size(); i++) s[i] = s[i - 1] + a[i];
// 5. 处理询问
for (auto item : query) {
int l = find(item.first), r = find(item.second); // 坐标映射
cout << s[r] - s[l - 1] << endl; // 利用前缀和 O(1) 算出区间和
}
return 0;
}
离散化模板:
c++// 阶段一:收集所有坐标(读入数据) vector<int> alls; // 存储所有待离散化的值 // 阶段二:制作"坐标查找字典"(核心步骤) sort(alls.begin(), alls.end()); // 将所有值排序 alls.erase(unique(alls.begin(), alls.end()), alls.end()); // 去掉重复元素 /* 去重:unique 把重复的坐标删掉。 现在 alls 变成了一个从小到大排好序的字典。 比如 alls = {10, 50, 100, 1000}。 坐标 10 就是字典里的第 1 个。 坐标 50 就是字典里的第 2 个。 */ // 二分求出x对应的离散化的值 int find(int x) // 找到第一个大于等于x的位置 { int l = 0, r = alls.size() - 1; while (l < r) { int mid = l + r >> 1; if (alls[mid] >= x) r = mid; else l = mid + 1; } return r + 1; // 映射到1, 2, ...n } /* 这时候,我们翻开加法备忘录 (add)。 备忘录说:"在坐标 100 加 5。" 我们去查字典(find 函数):100 在字典里排第 3。 于是,我们在缩小的数组里操作:a[3] += 5。 */unique()去重函数:
假设我们有一个排好序的数组
alls:{10, 10, 20, 30, 30}。当我们调用
unique(alls.begin(), alls.end())时,它会把数组改造成这样:
下标 0 1 2 3 4 内容 10 20 30 [10] [30] 状态 有效 有效 有效 垃圾数据 垃圾数据
unique返回的"分界线"就指向下标3。
- 有效区域 :下标
0, 1, 2(共 3 个不重复元素)。- 垃圾区域 :从下标
3开始直到最后。- 返回值 :指向下标
3的迭代器。- auto it = unique(alls.begin(), alls.end());
- int num = it - alls.begin(); // 这就是不重复元素的个数
erase()删除函数:
C++ 的容器操作通常遵循 "左闭右开" 的原则,即
[开始位置, 结束位置)。当你执行
alls.erase(unique(...), alls.end())时:
unique告诉erase:"嘿,从下标 3 开始,后面的全是没用的重复货了!"erase接收到这个位置,然后一路删到alls.end()。- 结果 :数组被切掉了后半截,变成了真正的
{10, 20, 30},长度也从 5 变成了 3。区间for循环:
for (auto item : add){}我们可以把它翻译成大白话:"对于
add容器里的每一个元素,我都给它起个临时名字叫item,然后拿着这个item进到大括号里干活。"
add:这是你定义的vector<PII>。它就像一个"备忘录清单",里面存了一组组的{坐标 x, 加数 c}。item:这是循环变量。在循环每执行一次时,它都会自动代表add里的某一个pair(一对数) 。
- 第一圈:
item是第一条加数指令。- 第二圈:
item是第二条加数指令。auto:这是 C++ 的自动类型推导关键字。编译器看到add里面存的是PII(即pair<int, int>),就会自动把item也设为PII类型。
3. 区间合并
3.1 区间合并模板
核心思路:贪心算法
要合并区间,最怕的是区间忽左忽右、乱七八糟。所以第一步永远是排序。
第一步:按左端点排序
我们按照每个区间的左端点 (l)从小到大排序。
- 排序后,我们处理区间的顺序就是从数轴左边往右边走的。
- 这样,我们只需要关注当前处理的区间 和下一个区间是否有重叠即可。
第二步:扫描与合并
我们维护一个"当前正在合并的区间",暂且叫它 [st, ed](start 和 end)。 当我们遇到一个新的区间 [seg.l, seg.r] 时,只有两种情况:
- 无法合并(断开了) : 下一个区间的开头
seg.l比我们当前的结尾ed还要大。- 动作 :说明当前的
[st, ed]已经到头了,把它存入结果;然后把[st, ed]更新为这个新的区间。
- 动作 :说明当前的
- 可以合并(有重叠) : 下一个区间的开头
seg.l小于或等于当前的结尾ed。- 动作 :把当前区间的结尾
ed延长。延长的长度取决于新区间能达到的最远距离,即ed = max(ed, seg.r)。
- 动作 :把当前区间的结尾
c++
// 将所有存在交集的区间合并
void merge(vector<PII> &segs) // PII -> segs 存一个区间的 [左端点, 右端点]
{
vector<PII> res; // 存储合并完成后的新区间。
sort(segs.begin(), segs.end());
/*
sort:
排序前:{[1, 3], [7, 9], [2, 6]}
排序后:{[1, 3], [2, 6], [7, 9]} (按左端点排好了)
*/
int st = -2e9, ed = -2e9;
/*
扫描第 1 个区间 seg = [1, 3]
判断:ed < seg.first => -2e9 < 1? 成立!
动作:if (st != -2e9) 不成立(因为现在还是初始值)。
更新当前维护区间:st = 1, ed = 3。
当前活跃区间:[1, 3]
扫描第 2 个区间 seg = [2, 6]
判断:ed < seg.first => 3 < 2? 不成立!
合并
更新 ed = max(3, 6) = 6。
当前活跃区间:[1, 6]
[st, ed]每次进行扫描变动 看是否能合并
*/
for (auto seg : segs) // // 遍历排序后的每一个区间
if (ed < seg.first) // 情况 1:当前维护的区间与新区间"断开了" (无交集)
{
//说明当前区间 [st, ed] 的结尾够不着新区间 seg 的开头。
if (st != -2e9) res.push_back({st, ed});
st = seg.first, ed = seg.second;
}
else ed = max(ed, seg.second); // 情况 2:有交集,更新 ed 为更远的那个端点
if (st != -2e9) res.push_back({st, ed});
segs = res;
}
3.2 例题
c++
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
typedef pair<int, int> PII;
void merge(vector<PII> &segs)
{
vector<PII> res;
sort(segs.begin(), segs.end());
int st = -2e9, ed = -2e9;
for (auto seg : segs)
if (ed < seg.first)
{
if (st != -2e9) res.push_back({st, ed});
st = seg.first, ed = seg.second;
}
else ed = max(ed, seg.second);
if (st != -2e9) res.push_back({st, ed});
segs = res;
/*
清空旧数据:segs 会把原本存储的所有区间(旧数据)全部销毁掉。
拷贝新数据:把 res 中的所有区间完整地复制一份到 segs 中。
同步长度:segs 的大小(size)会自动变为和 res 一模一样。
*/
}
int main()
{
int n;
scanf("%d", &n);
vector<PII> segs;
for (int i = 0; i < n; i ++ )
{
int l, r;
scanf("%d%d", &l, &r);
segs.push_back({l, r});
}
merge(segs);
cout << segs.size() << endl;
return 0;
}