【算法基础】位运算、离散化、区间合并

算法基础

  • [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?

这涉及到计算机如何表示负数------补码

  1. 原码 :假设 x = 6,二进制是 0000 0110
  2. 反码~x1111 1001
  3. 补码(负数) :在计算机中,-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 开始)移动到最右边,然后把其他位全遮住。
  • 解释:
    1. n >> k:让整个数字向右移动k位。这时,原来的第k位就站在了**个位(第 0 位)**上。
    2. & 1:数字 1 的二进制除了最后一位是 1,前面全是 0。做 & 运算时,除了个位,其他位都会被强制变成 0。
  • 结果: 如果第k位是 1,结果就是 1;如果是 0,结果就是 0。

1.1.3 判断奇偶

x & 1x % 2 == 1 更优雅、更快速。

  • 解释:
    • 二进制中,除了最后一位代表2^0 = 1,其余位代表的都是 2 的幂(2, 4, 8),它们全是偶数。
    • 所以,一个数是奇是偶,全看最后一位
  • 结果: x & 1 == 1 为奇数,x & 1 == 0 为偶数。
十进制 二进制 拆解计算 奇偶性
1 0001 1 奇数
2 0010 2 偶数
3 0011 2 + 1 奇数
4 0100 4 偶数
5 0101 4 + 1 奇数
6 0110 4 + 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 就能交换两个数的神奇魔法。

  • 原理(异或的性质):
    1. x ⊕ x = 0 x \oplus x = 0 x⊕x=0(自己异或自己等于 0)
    2. x ⊕ 0 = x x \oplus 0 = x x⊕0=x(任何数异或 0 等于本身)
    3. 异或运算满足交换律和结合律。
  • 推导:
    1. 第一步: a = a ⊕ b a = a \oplus b a=a⊕b
    2. 第二步: 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)
    3. 第三步: 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 通常更安全且一样快,而且能避免 ab 指向同一地址时把数值变 0 的风险。


1.1.5 位移实现乘除法

**<<>>**这是算法优化中的常客。

  • 左移 << n 相当于乘以 2^n。每左移一位,低位补 0,数值翻倍。
  • 右移 >> n 相当于除以 2^n。每右移一位,相当于舍弃最右边的位,数值减半。
  • 为什么快?
    • 乘除法在 CPU 里是相对复杂的电路操作,而位移只是简单的"排排坐,往边挪",通常只需要一个指令周期。
    • 注意: 对于负数的右移,C++ 通常执行的是算术右移(高位补符号位),这符合数学上的整除。

1.2 位运算常见例题

例题1:给定一个长度为 n 的数列,请你求出数列中每个数的二进制表示中 1 的个数。

求二进制表示中 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 位的值互换,并输出互换后的结果。

二进制表示中第 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 位二进制表示形式经过循环左移若干位而得到。

  1. 核心思路:双倍字符串技巧

    这是解决所有"循环移位"或"循环同构"问题的金钥匙。

    • 原理 :假设字符串 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 的二进制串是否在其中。
  2. 字符串拼接:++=

在 C++ 中,如果 + 的两边至少有一个是 string 对象,它就代表拼接(Concatenation),即将后面的内容接到前面的内容末尾。

  • 代码应用: x = x + to_string(...) 会在原有的字符串 x 后面加上新生成的字符。
  • 代码应用: y += yy = y + y 的简写,表示将 y 复制一份并拼接到自己后面,使其长度翻倍。
  • 注意点: 拼接操作会产生新的字符串,如果是在循环中频繁使用 + 拼接长字符串,性能会比使用 .append() 或直接操作 char 数组略低,但对于处理 16 位二进制串来说,性能差异可以忽略不计。

  1. to_string() 函数

将数值类型(如 int, long, float 等)转换为对应的字符串形式。

  • 在你的代码中: a >> i & 1 的结果是一个整数 01
  • 转换结果:
    • 如果结果是 0to_string(0) 会变成字符串 "0"
    • 如果结果是 1to_string(1) 会变成字符串 "1"
  • 目的: 这样做是为了能把这个二进制位"存"进 string 类型的变量 xy 中,方便后续进行全文匹配。

  1. y.find(x) 函数

C++ 字符串查找的核心函数,用于在字符串 y 中搜索子串 x

  • 工作原理: 它会从 y 的开头开始往后找,看看有没有一段内容和 x 完全一模一样。
  • 返回值:
    • 找到了: 返回 xy 中第一次出现的起始下标(从 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 → 1
  • 50 → \rightarrow → 2
  • 1000000 → \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()) 时:

  1. unique 告诉 erase:"嘿,从下标 3 开始,后面的全是没用的重复货了!"
  2. erase 接收到这个位置,然后一路删到 alls.end()
  3. 结果 :数组被切掉了后半截,变成了真正的 {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] 时,只有两种情况:

  1. 无法合并(断开了) : 下一个区间的开头 seg.l 比我们当前的结尾 ed 还要大。
    • 动作 :说明当前的 [st, ed] 已经到头了,把它存入结果;然后把 [st, ed] 更新为这个新的区间。
  2. 可以合并(有重叠) : 下一个区间的开头 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;
}
相关推荐
专注VB编程开发20年1 小时前
C#,VB.NET GPU计算和调试
算法·gpu
*.✧屠苏隐遥(ノ◕ヮ◕)ノ*.✧1 小时前
Jsoup: 一款Java的HTML解析器
java·开发语言·前端·后端·缓存·html
*.✧屠苏隐遥(ノ◕ヮ◕)ノ*.✧1 小时前
JSP, MVC, El, JSTL, MAC
java·开发语言·mvc·mac·jsp
WZ188104638691 小时前
LeetCode第54题
算法·leetcode
黎雁·泠崖1 小时前
Java 数据结构与算法:时间空间复杂度 从入门到实战全解
java·开发语言
俩娃妈教编程2 小时前
2025 年 06 月 三级真题(1)--分糖果
c++·算法·gesp真题
Coder_Boy_2 小时前
技术交流总结:分布式、数据库、Spring及SpringBoot核心知识点梳理(实现参考)
数据库·spring boot·分布式·spring·架构
想不明白的过度思考者2 小时前
Spring Boot 实战:MyBatis 操作数据库(上)
java·数据库·spring boot·mysql·mybatis
tankeven2 小时前
HJ97 记负均正
c++·算法