基础数据结构
目录
• 线性结构
• 二叉堆
• 并查集
• 哈希表
• 应用举例 一、线性结构 基础知识
• 数组
• 带头结点的双链表
-- He a d 结点 : 虚拟头结点
-- Fir s t 结点 : 第一个有实际内容的结点
• 队列 : 循环队列与 Open-Close 表 例 1. 最小值
• 实现一个 n 个元素的线性表 A 。每次可以修
改其中一个元素,也可以询问闭区间 [p, q]
中元素的最小值。
• 1<=n,m<=100000 分析
• 利用二级检索的思想
-- 设块长为 L, 则一共有 n/L 个块
-- 维护数组 B, 保存每个块的最小值
• Modify(x, y)
-- A[x] = y O(1)
-- 重算 x 所在块的最小值 ( 更新 B) O(L)
9 10 11 12 13 14 15 16
8
7
6
5
4
3
1 2
13~16
9~12
5~8
1~4 Min 操作
• Min(a, b)
-- 把区间 [a, b] 分成若干部分
-- 完整块 : 一共最多 n/L 个块
O(n/L)
-- 非完整块 : 首尾各最多 L-1 个元素
O(L)
• 每次操作时间复杂度 : O(n/L+L)
-- 设 L=O(n 1/2 ) 则渐进时间复杂度为 O(n 1/2 )
√ √ √
√
√
√ 例 2. 最接近的值
• 给一个 n 个元素的线性表 A ,对于每个数 A i ,
找到它之前的数中,和它最接近的数。即
对于每个 i ,计算
C i =min{| A i - A j | | 1<= j < i }
• 规定 C 1 =0 。 分析
• 问题的关键 : 你只需要解决 离线 问题
-- 在线问题 : 每输入一个 A i , 立刻输出 C i
-- 离线问题 : 在给出任何输出之前得到所有 A i
• 预处理 : 用 O(nlogn) 时间给所有 A i 排序
-- 很快就会学到了 ☺
• 主算法
-- 根据从小到大的顺序构造双向链表
-- 依次计算 C n , C n-1 , ..., C 1 在线问题可能么 ? 主算法
• A={2, 6, 5, 10, 4, 7}, 依次计算 C 6 , C 5 , C 4
-- 每次计算 C i 时 , 链表中恰好是 计算 C i 需要的元素
-- 计算 C i 只需比较两个元素,然后删除 A i
O(1)
2 2
4 4
5 5
6 6
7 7
10 10
2 2
4 4
5 5
6 6
10 10
2 2
5 5
6 6
10 10 例 3. 移动窗口
• 有一个长度为 n 的数组,还有一个长度为
k<=n 的窗口。窗口一开始在最左边的位
置,能看到元素 1, 2, 3, ...k 。每次把窗口往
右移动一个元素的位置,直到窗口的右边
界到达数组的元素 n 。 分析
• 考虑最小值。假设窗口从左往右移动
• 保存 5 是不明智的 , 因为从 现在 开始一直到 5
离开窗口 , 5 始终被 4" 压制 " ,永远都不可能
成为最小值。删除 5 不会影响 结果
• 启发 :算最小值的 有用 元素形成一个递增
序列,最小值就是队首元素
• 关键: 窗口右移操作
...
4
5
3
... 2 分析
• 窗口右移: 队首出队,新元素入队,然后
在队列中删除它前面比它大的元素
• 实现
-- 用链表储存窗口内 有用 元素
-- 则窗口右移的时间和删除元素的 总 次数成正比
• 元素被删除后不会再次被插入,因此每个
元素最多被删除一次,总次数为 O(n), 即:
算法时间总复杂度为 O(n)
...
4
12
11
9
7
5
3
3
... 例 4. 程序复杂度
• 给出一段程序,计算它的时间复杂度。这段程序
只有三种语句:
-- OP <x > :执行一条时间耗费为 x 的指令。这里的 x 为不
超过 100 的正整数。
-- LOOP <x> :与 END 配套,中间的语句循环 x 次,其中
x 可以为规模 n ,也可以是不超过 100 的正整数。
-- EN D :与 LOOP 配套,循环终止。
• 注意: OP 语句的参数不能为变量 n ,而 LOOP 语
句的参数可以为变量 n ,但不允许有其他名字的变
量。这样,整个程序的时间复杂度应为 n 的多项
式。你的任务就是计算并显示出这个多项式。 例 4. 程序复杂度
• 输出仅包含一行,即时间复杂度的多项
式。这个多项式应该按通常的方式显示处
理,即不要输出 0n 这样的项, n 项也不要输
出为 n^1 ,等等。
OP 1
LOOP n
LOOP 10
LOOP n OP 7 END
OP 1
END
END
70n^2+10n+1 分析
• 考虑特殊情况:没有变量 n 只有常数的情况
• 两种思路
-- 递归求解:不直接,附加开销大 /
-- 基于栈的扫描算法:实现简单明了,效率高 ☺
• 扫描过程(基本想法)
-- LO O P x 和 OP x, 把语句入栈
-- END, 不断从栈里弹出语句,直到弹出 LOOP
• 弹出过程中累加操作数 x ,然后乘以循环次数 y
• 把 OP x*y 压入栈中,相当于用一条 OP 等效一个 LOOP 分析
• 栈扫描算法的直接推广
-- 栈里的每个操作数不是数,而是多项式
-- 多项式加法,多项式与整数的乘积
-- 所有通过至少一个数据的同学都采用此法
• 问题
-- 多项式如何表示
-- 多项式操作的时间复杂度是怎样的? 复杂性
• 大部分数据涉及到高精度整数运算
• 高精度运算的复杂性
-- 写起来相对麻烦
-- 时间复杂度: O(n 2 ) (nlogn is possible, but...)
-- 空间复杂度
• 实际情况:所有使用了高精度运算的同学
在时间 " 爆 " 掉之前空间先 " 爆 " 掉
-- 每个数 10000 位 , n 次数可达 10000 (或更多)
-- 栈里面可以有 10000 个多项式, 10 12 =1T !!!!! 空间 ... 空间 ... 空间 ...
• 是否可以找到一个空间需求比较小的算
法? 哪怕时间效率略一点都可以
• 输出文件已经不小了,需要尽量少的保存
中间结果,因此最好不要栈!
-- 只要有栈,最坏情况中间结果的空间大小就是
输出大小乘以栈的最大深度,而输出 ... " 把括号展开 "
• 先想办法去掉所有 LOOP/END
• 借助 当前乘数 确定这次 OP 的 最终 效果
OP 1
LOOP n
LOOP 10
LOOP n
OP 7
END
OP 1
END
END
OP 1 * 1
OP (n*10*n) * 7
OP (n*10) * 1 基本算法
• 设当前乘数为 m, 结果多项式为 ans
-- 初始化 m= 1, ans = 0
• 主循环:一条一条语句处理
-- 遇到 LOOP x , m = m * x
-- 遇到 END , m = m / x
-- 遇到 OP x, ans = ans + m * x
• 数据结构:设 m = an b ,则用数对 (a, b) 表示
m , a 是高精度整数, b 是 int 类型
• 除了结果多项式 ans 和 a , 其他空间可忽略 二、二叉堆 堆
• 堆 (heap) 经常被用来实现优先队列 (priority
queue): 可以把元素加入到优先队列中 , 也
可以从队列中取出 优先级最高 的元素
-- Insert(T, x): 把 x 加入优先队列中
-- DeleteMin(T, x): 获取优先级最高的元素 x, 并
把它从优先队列中删除 堆的操作
• 除了实现优先队列 , 堆还有其他用途 , 因此
操作比优先队列多
-- Getmin(T, x): 获得最小值
-- Delete(T, x): 删除任意已知结点
-- DecreaseKey(T, x, p): 把 x 的优先级降为 p
-- Build(T, x): 把数组 x 建立成最小堆
• 显然 , 用堆很容易实现优先队列 堆的定义
• 堆是一个完全二叉树
-- 所有叶子在同一层或者两个连续层
-- 最后一层的结点占据尽量左的位置
• 堆性质
-- 为空 , 或者最小元素在根上
-- 两棵子树也是堆 存储方式
• 最小堆的元素保存在 heap[1..hs] 内
-- 根在 heap[1]
-- K 的左儿子是 2k, K 的右儿子是 2k+1,
-- K 的父亲是 [k/2]
1 1
2 2
3 3
4 4
5 5
6 6
7 7
8 8
9 9
10 10
11 11 12 12
13 13 14 14 删除最小值元素
• 三步法
-- 直接删除根
-- 用最后一个元素代替根上元素
-- 向下调整
13 13
2 2
3 3
4 4
5 5
6 6
7 7
8 8
9 9 10 10
11 11 12 12
1 1
2 2
3 3
4 4
5 5
6 6
7 7
8 8
9 9 10 10
11 11 12 12
13 13 向下调整
• 首先选取当前结点 p 的较大儿子 . 如果比 p 大 , 调整
停止 , 否则交换 p 和儿子 , 继续调整
13 13
2 2
3 3
4 4
5 5
6 6
7 7
8 8
9 9 10 10
11 11 12 12
2 2
13 13
3 3
4 4
5 5
6 6
7 7
8 8
9 9 10 10
11 11 12 12
void swim(int p){
int q = p>>1, a = heap[p];
while(q && a<heap[q]){ heap[p]=heap[q]; p=q; q=p>>1; }
heap[p] = a;
} 插入元素和向上调整
• 插入元素是先添加到末尾 , 再 向上调整
• 向上调整 : 比较当前结点 p 和父亲 , 如果父亲比 p
小,停止 ; 否则交换父亲和 p, 继续调整
void sink(int p){
int q=p<<1, a = heap[p];
while(q<=hs){
if(q<hs&&heap[q+1]<heap[q])q++;
if(heap[q]>=a) break;
heap[p]=heap[q]; p=q; q=p<<1;
}
heap[p] = a;
} 堆的建立
• 从 下往上逐层 向下调整 . 所有的叶子无需调整 ,
因此从 hs/2 开始 . 可用数学归纳法证明循环变量
为 i 时 , 第 i+1, i+2, ...n 均为最小堆的根
void insert(int a)
{ heap[++hs]=a; swim(hs); }
int getmin()
{ int r=heap[1]; heap[1]=heap=[hs--];
sink(1); return r; }
int decreaseKey(int p, int a )
{ heap[p]=a; swim(p); }
void build()
{ for(int i=hs/2;i>0;i--) sink(i); } 时间复杂度分析
• 向上调整 / 向下调整
-- 每层是常数级别 , 共 logn 层 , 因此 O(logn)
• 插入 / 删除
-- 只调用一次向上或向下调整 , 因此都是 O(logn)
• 建堆
-- 高度为 h 的结点有 n/2 h+1 个 , 总时间为
lo
g
l o g
1
0 0
( )
2 2
n n
h h
h h
n h
O h O
n
⎢ ⎥
⎢ ⎥
⎣ ⎦
⎣ ⎦
+
= =
⎛ ⎞
⎡ ⎤
× =
⎜ ⎟
⎢ ⎥
⎜ ⎟
⎢ ⎥
⎝ ⎠
∑ ∑ 例 1. k 路归并问题
• 把 k 个有序表合并成一个有序表 .
• 元素共有 n 个 . 分析
• 每个表的元素都是从左到右移入新表
• 把每个表的当前元素放入二叉堆中 , 每次删
除最小值并放入新表中 , 然后加入此序列的
下一个元素
• 每次操作需要 logk 时间 , 因此总共需要 nlogk
的时间 例 2. 序列和的前 n 小元素
• 给出两个长度为 n 的有序表 A 和 B, 在 A 和 B 中
各任取一个 , 可以得到 n 2 个和 . 求这些和最
小的 n 个 分析
• 可以把这些和看成 n 个有序表 :
-- A[1]+B[1] <= A[1]+B[2] <= A[1]+B[3] <=...
-- A[2]+B[1] <= A[2]+B[2] <= A[2]+B[3] <=...
-- ...
-- A[n]+B[1] <= A[n]+B[2] <= A[n]+B[3] <=...
• 类似刚才的算法 , 每次 O(logn), 共取 n 次最
小元素 , 共 O(nlogn) 例 3. 轮廓线
• 每一个建筑物用一个三元组表示 (L, H, R), 表示
左边界 , 高度和右边界
• 轮廓线用 X, Y, X, Y... 这样的交替式表示
• 右图的轮廓线为 : (1, 11, 3, 13, 9, 0, 12, 7, 16,
3, 19, 18, 22, 3, 23, 13, 29, 0)
• 给 N 个建筑,求轮廓线 分析
• 算法一 : 用数组记录每一个元线段的高度
-- 离散化 , 有 n 个元线段
-- 每次插入可能影响 n 个元线段 , O(n), 共 O(n 2 )
-- 从左到右扫描元线段高度 , 得轮廓线
• 算法二:每个建筑的左右边界为事件点
-- 把事件点排序 , 从左到右扫描
-- 维护建筑物集合 , 事件点为线段的插入删除
-- 需要求最高建筑物 , 用堆 , 共 O(nlogn) 例 4. 丑数
• 素因子都在集合 {2, 3, 5, 7} 的数称为 ugly
number
• 求第 n 大的丑数 分析
• 初始:把 1 放入优先队列中
• 每次从优先队列中取出一个元素 k ,把 2k,
3k, 5k, 7k 放入优先队列中
• 从 2 开始算,取出的第 n 个元素就是第 n 大的
丑数
• 每取出一个数,插入 4 个数,因此任何堆里
的元素是 O(n) 的,时间复杂度为 O(nlogn) 例 5. 赛车
• 有 n 辆赛车从各不相同的地方以各种的速度 ( 速度
0<v i <100) 开始往右行驶,不断有超车现象发生。
• 给出 n 辆赛车的描述(位置 x i ,速度 v i ),赛车已
按照位置排序(
x 1 <x 2 <...<x n )
• 输出超车总数以及按时间顺序的前 m 个超车事件
V 3
V 4
V 1
V 2
X 2 X 3
X 4
X 1 分析
• 事件个数 O(n 2 ), 因此只能一个一个求
• 给定两辆车,超越时刻预先可算出
• 第一次 超车 可能 在哪些辆车之间?
-- 维护所有车的前方相邻车和追上时刻
• 局部:此时刻不一定是该车下个超车时刻!
• 全局:所有时刻的 最小值 就是下次真实超车时刻
• 维护:超车以后有什么变化?
-- 相对顺序变化 ... 改变三个车的前方相邻车
-- 重新算追上时刻,调整三个权
-- 简单的处理方法:删除三个再插入三个 例 6. 可怜的奶牛
• 农夫 John 有 n (
n ≤ 100 000 )头奶牛,可是由于
它们产的奶太少,农夫对它们很不满意,决定每
天把产奶最少的一头做成牛肉干吃掉。但还是有
一点舍不得, John 打算如果不止有一头奶牛产奶
最少,当天就大发慈悲,放过所有的牛。
• 由于 John 的奶牛产奶是周期性的, John 在一开始
就能可以了解所有牛的最终命运,不过他的数学
很差,所以请你帮帮忙,算算最后有多少头奶牛
可以幸免于难。每头奶牛的产奶周期 T i 可能不
同,但不会超过 10 。在每个周期中,奶牛每天产
奶量不超过 200 。 分析
• 如果采用最笨的方法,每次先求出每头牛的产奶
量,再求最小值,则每天的复杂度为 O(n) ,总复
杂度为 O(Tn) ,其中 T 是模拟的总天数。由于周期
不超过 10 ,如果有的牛永远也不会被吃掉,那么
我们需要多模拟 2520 天( 1 , 2 , 3 , ... , 10 的最
小公倍数)才能确定
• 周期同为 t 的奶牛在没有都被吃掉之前,每天的最
小产奶量也是以 t 为周期的。因此如果把周期相同
的奶牛合并起来,每天只需要比较 10 类奶牛中每
类牛的 最小产奶量就可以了,每天的复杂度为
O(k) ,其中 k 为最长周期 分析
• 假设周期为 6 的牛有 4 头,每次只需要比较 k
组牛的 " 代表 " 就可以了,每天模拟的时间复
杂度为 O ( k ) 。
项
目
第 6 n + 1 天 第 6 n + 2 天 第 6 n + 3 天 第 6 n + 4 天 第 6 n + 5 天 第 6 n + 6 天
牛 1
2
5
3
5
7
4
牛 2
3
1
6
7
5
4
牛 3
5
3
3
5
3
9
牛 4
4
4
3
8
8
2
合 并 结 果
2 ( 牛 1 )
1 ( 牛 2 )
3 ( 多 )
5 ( 多 )
3 ( 牛 3 )
2 ( 牛 4 ) 分析
• 只要周期为 6 的牛都不被吃掉, 这个表一直是有效
的 。但是在吃掉一头奶牛后,我们需要修改这个
表,使它仍然记录着每天的最小产奶量
-- 方法一 : 重新计算,时间 O(h) ,其中 h 是该组的牛数
-- 方法二 : 把 一个周期中每天 的最小产奶量组织成堆,每
次删除操作的复杂度是 O(klogh)
• 由于每头奶牛最多被吃掉一次,因此用在维护 " 最
小产奶量结构 " 的总复杂度不超过 O(nklogn) 。每
天复杂度为 O(k) ,总复杂度为 O(Tk+nklogn) 例 7. 黑匣子
• 我们使用黑匣子的一个简单模型。它能存
放一个整数序列和一个特别的变量 i 。在初
始时刻,黑匣子为空且 i 等于 0 。这个黑匣子
执行一序列的命令。有两类命令:
• AD D ( x ) :把元素 x 放入黑匣子;
• GE T : i 增 1 的同时,输出黑匣子内所有整数
中第 i 小的数。牢记第 i 小的数是当黑匣子中
的元素以非降序排序后位于第 i 位的元素 例 7. 黑匣子
编 号
命 令
i
黑 匣 子 内 容
输 出
1
A D D ( 3 )
0
3
2
G E T
1
3
3
3
A D D ( 1 )
1
1, 3
4
G E T
2
1, 3
3
5
A D D ( - 4 )
2
- 4, 1, 3
6
A D D ( 2 )
2 - 4, 1, 2, 3
7
A D D ( 8 )
2 - 4, 1, 2, 3, 8
8
A D D ( - 1 0 0 0 )
2 - 1 0 0 0, - 4, 1, 2, 3, 8
9
G E T
3 - 1 0 0 0, - 4, 1 , 2, 3, 8
1
1 0
G E T
4 - 1 0 0 0, - 4, 1, 2 , 3, 8
2
1 1
A D D ( 2 )
4 - 1 0 0 0, - 4, 1, 2, 2, 3, 8 分析
• 降序堆 H ≥ 和升序堆 H ≤ 如图放置
• H ≥ 根节点的值 H ≥ [1] 在堆 H ≥ 中最大,
H ≤ 根节点的值 H ≤ [1] 在堆 H ≤ 中最小 ,
并满足
-- H ≥ [1] ≤ H ≤ [1]
-- siz e [ H ≥ ]= i
• ADD( x ): 比较 x 与 H ≥ [1] ,若 x ≥
H ≥ [1] ,则将 x 插入 H ≤ ,否则从 H ≥ 中
取出 H ≥ [1] 插入 H ≤ ,再将 x 插入 H ≥
• GE T: H ≤ [1] 就是待获取的对象。输
出 H ≤ [1] ,同时从 H ≤ 中取出 H ≤ [1] 插入
H ≥ ,以维护条件 (2) 三、并查集 并查集
• 并查集维护一些不相交集合 S={S 1 , S 2 , ...,
S r }, 每个集合 S r 都有一个特殊元素 rep[S i ],
称为集合代表 . 并查集支持三种操作
-- Make-Set(x): 加入一个集合 {x} 到 S, 且 rep[{x}]
= x. 注意 , x 不能被包含在任何一个 S i 中 , 因为 S
里任何两个集合应是不相交的
-- Union(x, y): 把 x 和 y 所在的两个不同集合合并 .
相当于从 S 中删除 S x 和 S y 并加入 S x US y
-- Find-Set(x): 返回 x 所在集合 S x 的代表 rep[S x ] 链结构
• 每个集合用双向链表表示 , rep[S i ] 在链表首部
• Make-Set(x): 显然是 O(1) 的
• Find-Set(x): 需要不断往左移 , 直到移动到首部 .
最坏情况下是 O(n) 的
• Union(x, y): 把 S y 接在 S x 的尾部 , 代表仍是
rep[S x ]. 为了查找链表尾部 , 需要 O(n) 增强型链结构
• 给每个结点增加一个指回 rep 的指针
• Make-Set(x): 仍为常数
• Find-Set(x): 降为常数 ( 直接读 rep)
• Union(x, y): 变得复杂 : 需要把 S y 里所有元素的 rep
指针设为 rep[Sx]! 增强型链结构的合并
• 可以把 x 合并到 y 中,也可以把 y 合并在 x 中 技巧 1: 小的合并到大的中
• 显然 , 把小的合并到大的中 , 这一次 Union 操
作会比较节省时间 , 更精确的分析 ?
• 用 n, m, f 分别表示 Make-Set 的次数 , 总操作
次数和 Find-Set 的次数 , 则有
• 定理 : 所有 Union 的总时间为 O(nlogn)
• 推论 : 所有时间为 O(m + nlogn)
• 证明 : 单独考虑每个元素 x, 设所在集合为 S x ,
则修改 rep[x] 时 , S x 至少加倍 . 由于 S x 不超过
n, 因此修改次数不超过 log 2 n, 总 nlogn 树结构
• 每个集合用一棵树表示 , 根为集合代表 树结构的合并
• 和链结构类似 , 小的合并到大的中 技巧 2: 路径压缩
• 查找结束后顺便把父亲
设置为根 , 相当于有选择
的设置 rep 指针而不像链
结构中强制更新 所有 rep 路径压缩的分析
• 设 w[x] 为 x 的子树的结点数 , 定义势能函数
• Union(x i , x j ) 增加势能 . 最多会让 w[rep[xi]] 增加
w[rep[xj]]<=n, 因此势能增加不超过 logn
• Find-Set(x) 减少势能 . 把路径压缩看作是从根到
结点 x 的向下走过程 , 则除了第一次外的其他向下
走的步骤 p Æ c 会让 c 的子树从 p 的子树中移出 , 即
w[p] 减少 w[c], 而其他点的 w 值保持不变 路径压缩的分析
• Find-Set 除了第一次外的其他向下走的步骤
p Æ c 会让 c 的子树从 p 的子树中移出
-- 情况一 : w[c]>=w[p]/2, 则势能将至少减少 1
-- 情况二 : w[c]<w[p]/2, 这种情况最多出现 logn 次 ,
因为 w[p] 最多进行 logn 次除 2 操作就会得到 1
• Union 操作积累起来的 mlogn 的势能将被
Find-Set 消耗 , 情况一最多消耗 mlogn 次 , 情
况二本身不超过 mlogn 次 , 因此
• 定理 : Find-Set 的总时间为 O(mlogn) 路径压缩的分析
• 定理 : 如果所有 Union 发生在 Find-Set 之前 ,
则所有操作的时间复杂度为 O(m)
• 证明 : 每次 Find-Set 将会让路径上除了根的
所有结点为根的儿子 . 所有结点只会有一次
改变,因此总时间复杂度为 O(m)
• 也就是说
-- 只使用技巧 1( 启发式合并 ): O(m+nlogn)
-- 只使用技巧 2( 路径压缩 ): O(mlogn)
• 同时使用呢 ? Ackermann 函数及其反函数 树结构的完整结论
• 定理 : m 个操作的总时间复杂度为 ( (
) )
O m
α n
void makeset(int x){ rank[x] = 0; p[x]=x; }
int findset(int x){
int i, px = x;
while (px != p[px]) px = p[px];
while (x != px) { i = p[x]; p[x] = px; x = i; }
return px;
}
void unionset (int x , int y){
x = findset(x); y = findset(y);
if(rank[x] > rank[y]) p[y] = x;
else { p[x] = y; if(rank[x] == rank[y]) rank[y]++; }
} 例 1. 亲戚
• 或许你并不知道,你的某个朋友是你的亲戚。他
可能是你的曾祖父的外公的女婿的外甥女的表姐
的孙子。如果能得到完整的家谱,判断两个人是
否亲戚应该是可行的,但如果两个人的最近公共
祖先与他们像个好几代,使得家谱十分庞大,那
么检验亲戚关系实非人力所能及。在这种情况
下,最好的帮手就是计算机。
• 为了将问题简化,你将得到一些亲戚关系的信
息,如同 Marry 和 Tom 是亲戚, Tom 和 Ben 是亲
戚,等等。从这些信息中,你可以推出 Marry 和
Ben 是亲戚。请写一个程序,对于我们的关于亲
戚关系的提问,以最快的速度给出答案。 分析
• 本质 : 是否在图的同一个连通块
• 问题 : 图太庞大 , 每次还需要遍历
• 解决 : 用并查集 例 2. 矩形
• 在平面上画了 N 个长方形,每个长方形的边平行
于坐标轴并且顶点坐标为整数。我们用以下方式
定义印版:
-- 每个长方形是一个印版;
-- 如果两个印版有公共的边或内部,那么它们组成新的
印版,否则这些印版是分离的
• 数出印版的个数 . 左图有两个,右图只有一个 分析
• 把矩形看作点,有公共边的矩形连边,问
题转化为求连通分量的个数
• 判断方法:
Y
( X 2 , Y 2 )
)
,
(
2
2
′
′ Y
X
( X 1 , Y 1 )
)
,
(
1
1
′
′ Y
X
X 例 3. 代码等式
• 由元素 0 和 1 组成的非空的序列称为一个二进制代
码。一个代码等式就是形如 x 1 x 2 .. x l = y 1 y 2 .. y r , 这里 x i
和 y j 是二进制的数字(
0 或 1 )或者是一个变量(如
英语中的小写字母)
• 每一个变量都是一个有固定长度的二进制代码,
它可以在代码等式中取代变量的位置。我们称这
个长度为变量的长度
• 对于每一个给出的等式,计算一共有多少组解。
• 例 : a,b,c,d,e 的长度分别是 4,2,4,4,2, 则 1bad1 = acbe
有 16 组解 分析
• 长度为 k 的变量拆成 k 个长度为 1 的变量
• 每位得到一个等式
-- 1=1 或者 0=0 :冗余等式
-- 1=0 或者 0=1 :无解
-- a=b : a 和 b 相等(
a 为变量 b 可以为常数)
• 相等关系用并查集处理,最后统计集合数
为 n ,答案为 2 n 。 例 4. 围墙
• 按顺序给出 M 个整点组成的线段,找到最小
的 k ,使得前 k 条线段构成了封闭图形。
(任意两条线段只可能在端点相交) 分析
• 将所有出现过的坐标用整数表示,初始时
候每个独立成树。读入连接 A 和 B 的线段
后,将 A 、 B 所在的树和并。如果 A 、 B 在同
一棵树,那么就出现了封闭图形(因为 x 个
点 x 条边的图必定出现圈)
• 把坐标转换成编号的步骤,可以通过对坐
标进行排序,再删除重复。
• 时间 : O(MlogM) 例 5. 可爱的猴子
• 树上挂着 n 只可爱的猴子(
n ≤ 2*10 5 )。
• 猴子 1 的尾巴挂在树上
• 每只猴子有两只手,每只手可以抓住最多一只猴
子的尾巴,也可以不抓。猴子想抓谁一定抓得到
• 所有猴子都是悬空的,因此如果一旦脱离了树,
猴子会立刻掉到地上。
• 第 0 , 1 , ... , m ( 1 ≤ m ≤ 400 000 )秒中每一秒
都有某个猴子把他的某只手松开,因此常有猴子
掉在地上
• 请计算出每个猴子掉到地上的时间 分析
• 并查集?
• " 时光倒流 "
• 如何标记每只猴子的时间?
-- 枚举并查集的元素
-- 需要访问兄弟 / 儿子?
-- 链表即可
-- 不用指针 例 6. 奇数偶数
• 你的朋友写下一个由 0 和 1 组成的字符串,并
告诉你一些信息,即某个连续的子串中 1 的个
数是奇数还是偶数。你的目标是找到尽量小
的 i ,使得前 i+1 条不可能同时满足
-- 例如,序列长度为 10 ,信息条数为 5
-- 5 条信息分别为 1 2 even , 3 4 odd , 5 6 even , 1
6 even , 7 10 odd
• 正确答案是 3 ,因为存在序列 (0,0,1,0,1,1) 满
足前 3 条信息,但是不存在满足前 4 条的序列 分析
• 部分序列
-- 可以从 s 的奇偶性恢复整个序列
-- a b even 等价于 s[b], s[a-1] 同奇偶
-- a b odd 等价于 s[b], s[a-1] 不同奇偶
• 一开始每个 s[i] 自成一集合
-- a b even Æ 合并 s[b], s[a-1]
-- a b odd Æ ???
• 矛盾的标志? 例 7. 团伙
• 如果两个认识 , 那么他们要么是朋友要么是
敌人 , 规则如下
-- 朋友的朋友是朋友
-- 敌人的敌人是敌人
• 给出一些人的关系 ( 朋友、敌人 ), 判断一共
最多可能有多少个团伙 例 8. 船
• 给一个 01 矩阵 , 黑色代
表船 . 右图有一个 29 吨
的 , 3 个 7 吨的 , 两个 4 吨
的和三个一吨的 .
• 输入行数 N(<30000) 和
每行的黑色格子 ( 区间
数和每个区间 )
• 输出每种重量的个数
• 一共不超过 1000 个船 ,
每个的重量不超过 1000 例 9. 离线最大值
• 设计一个集合 , 初始为空,每次可以插入一
个 1~n 的数 (1~n 各恰好被插入一次 ), 也可以
删除最大值 , 要求 m 次操作的总时间尽量小 . 分析
• 在最后加入 n-m 次虚拟的 MAX 操作 , 并记第 i
个 MAX 操作为 M i , 记 M 1 之前的插入序列为 S 1,
M i-1 (1<i<=m) 和 M i 之间的插入序列为 S i
• 如果 n 在 S j 中被插入,则 M j 的输出一定是 n.
然后删除 M j ,即 把 S j 合并到 S j+1 中 ,然后再
查找 n-1 所在的序列 S k ,则 M k 的输出为 n-
1... 如此下去,从 n 到 1 依次查找每个数所在
序列,就可以得到它后面的 MAX 操作的结
果 , 并把它和紧随其后的序列合并 例 10. 合并队列
• 初始时 n 个数 1~n 各在单独的一列中 , 需要执
行两个操作
-- Move(i, j): 把 i 所在列接到 j 所在列的尾部
-- Check(i, j): 询问 i 和 j 是否在同一列 , 如果是 , 输
出二者之间的元素个数 分析
• 每个数 i 的 p[i] 表示 i 和 p[i] 在同一队列 , 且 p[i]
是 i 之前的第 d[i] 个元素
• 对于队首 x, 有 p[x]=x, 附加变量 tot[x] 表示以
x 为首的队列一共有多少个元素
-- Mo v e 需要进行两次查找和一次合并
-- Ch e c k 需要两次查找
• FIN D: 修改 p[i] 时要修改 d[i]
• ME R G E: 可以启发式合并么 ??? 四、哈希表 哈希表
• 哈希表 (Hash table) 经常被用来做字典 (dictionary),
或称符号表 (symbol-table) 直接存取表
• 直接存取表 (Direct-access table) 的基本思
想是 : 如果 key 的范围为 0~m-1 而且所有 key
都不相同 , 那么可以设计一个数组 T[0..m-1],
让 T[k] 存放 key 为 k 的元素 , 否则为空 (NIL)
• 显然 , 所有操作都是 O(1) 的
• 问题 : key 的范围可能很大 ! 64 位整数有
18,446,744,073,709,551,616 种可能 , 而字
符串的种类将会更多 ! 解决方案 : 哈希函数 哈希函数的选取
• 严格均匀分布的哈希函数很难寻找 , 但一般
说来有三种方法在实际使用中效果不错
-- 取余法 : 取 h(k) = k mod m
-- 乘积法 : 取 h(k) = (A*k mod 2 w ) rsh (w-r)
-- 点积法 : 随机向量 a 和 k 的 m 进制向量做点积
• 实践中经常采用取余法 取余法
• 一般来说不要取 m=2 r , 因为它只取决于 k 的后 r 位
• 一般取 m 为不太接近 2 或 10 的幂 乘积法
• 取 m=2 r , 计算机字长为 w, 则
H(k) = (A*k mod 2 w ) rsh (w-r)
• 其中 rsh 表示右移位 , A 是满足 2 w-1 <A<2 w 的
奇数 . 注意 : A 不应该太接近于 2 w . 由于乘法
并对 2 w 取余是很快的 ( 自然就会丢弃高位 ),
而算术右移也很快,因此函数计算开销小 冲突解决
• 不同的 key 映射到同一个数 , 称为冲突
(collision), 冲突的两个元素显然不可能放在
哈希表的同一个位置
• 通常有两种冲突解决方案 (resolving
collisions)
-- 链方法 (chaining): 把 key 相同的串成链表
-- 开放地址法 (open addressing): 自己的位置被
占了 , 就去占别人的 链地址法
• Ke y 相同的元素形成一个链表 链地址法的查找效率
• 链地址法的时间效率取决于 hash 函数的分布 . 我
们假设每个 k 将等可能的被映射到任意一个 slot,
不管其他 key 被映射到什么地方
• 设 n 为 key 的数目 , m 为不同的 slot 数 , 装载因子α
= n/m, 即每个 slot 平均的 key 数 , 则
• n=O(m) 时期望搜索时间是 O(1) 的 开放地址法
• 开发地址法只使用表内的空间 . 如果冲突产生 , 计
算出第二个可能的位置 , 如果那里也有其他元素 ,
再看第三个可能的位置 ... 即按一个 探测序列 查找
• 位置应该是 key 和探测的次数 (probe number) 的函
数 , 第 i 次探测位置为 slot = h(k,i)
• 每个 slot 都应能被探测到 , 因此每个 k 的探测序列
<h(k,0), h(k,1), h(k,2), ...,h(k,m-1)>
• 都应是 {0,1,...,m-1} 的排列
• 注意 : 开放地址法不容易 删除 元素 开放地址法示例 探测方法
• 线性探测 :
• 虽然很简单 , 但是容易形成元素堆积
• 二次哈希 : 组合两个哈希函数
• 一般效果会好很多,但应保证 h 2 (k) 和 m 互素 , 比如
取 m 为 2 的幂而让 h 2 (k) 只产生奇数 开放地址法的查找效率
• 假设每个 key 等概率的把 m! 个排列中的任何
一个作为它的探测序列 , 则有
• 定理 : 装载因子α <1 时 , 不成功查找的期望
探测次数为 1/ (1- α)
• 证明 : 第 i 次探测到非空 slot 的概率为
(n-i)/(m-i) < n/m = α
• 下面各种情况按概率加权计算期望 开放地址法的查找效率
• 各种情况按概率加权 , 得期望的探测次数为 查找效率比较
• 把开放地址法说通俗一点,就是
-- 装载因子为常数时 , 期望查找次数为常数
-- 一半装满时 , 期望查找次数为 1/(1-0.5)=2
-- 装满 90% 时 , 期望查找次数为 1/(1-0.9)=10
• 装得比较满 ( 尤其是 n>m) 时 , 使用链地址法
-- 在哈希表里保存每个 key 的第一个元素
first[0..m-1],
-- 在一个数组 data[1..n] 里装着所有元素和下一个
元素 next[1..n] 链地址法参考代码
• 在实际应用中,推荐使用链地址法
-- first[i] 表示 哈希函数值为 i 的第一个数据下标
-- ke y [i] 和 next[i] 表示 第 i 个 数据的 key 和下一个
int find(int k){
int h = hash(k);
int p = first[h];
while (p){ if(key[p] == k) return p; p = next [p]; }
return 0;
}
void insert(int x){
int h = hash(key[x]); next[x] = first[h]; first[h] = x;
}