一、multiprocessing.Process模块简介:

class multiprocessing.Process(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None):

参数解释:

group:默认是None,这个参数是为实现ThreadGroup类时的扩展保留,所以它应是None,

target:默认是None,target的值(值是进程要执行的任务,所以值应该是一个函数名,即一个要被执行的任务)是run()方法调用的对象。

name:默认是None,设置进程的名字。

args:是target=函数名,这个函数的位置参数,元组形式。

kwargs:是target=函数名,这个函数的默认参数,字典形式。

daemon:如果是True表示创建的是守护进程。False非守护进程。

常用方法:

run():开启进程,可以在子类中重写此方法。run()方法会调用构造函数中target指定的函数,函数的位置参数和关键字参数分别取自args和kwargs参数。

start():实例化对象调用此方法开启线程,每个实例化对象只能调用一次start方法,该方法会调用run()开启线程。

join([timeout]):默认是None,阻塞状态,会等待进程结束后才会执行下面的代码。如果指定timeout(单位是秒),程序会阻塞timeout秒后在执行下面的代码。如果其进程终止或方法超时,该方法将返回None。

name:如果在实例化对象时没有设置进程名,可以使用进程对象.name=进程名,设置进程名。

is_alive():进程是否存活。如果存活返回True,否则返回False

daemon:如果为True是守护进程,如果为False非守护进程,一定要在调用start()方法前调用才生效。注意,守护进程不允许创建子进程。否则,如果守护进程在其父进程退出时终止,则它的子进程将成为孤儿。

pid:返回进程的pid。

exitcode:返回进程退出码,如果是None表示进程正在执行,如果是0表示进程正常执行完成,如果是负数进程时被信号终止了。

authkey:返回进程的身份验证密钥(字节字符串)。初始化多处理时,使用os.urandom()为主进程分配一个随机字符串。创建进程对象时,它将继承其父进程的身份验证密钥,但可以通过将authkey设置为另一个字节字符串来更改。

terminate():终止进程。在Unix上,这是使用SIGTERM信号完成的;在Windows上,使用TerminateProcess()。请注意,退出处理程序和finally子句等将不会被执行。进程的子进程不会终止,它们只会成为孤立进程。如果关联的进程使用管道或队列时使用此方法,则管道或队列可能会损坏,其他进程可能无法使用。类似地,如果进程获得了锁或信号量等,那么终止它很可能导致其他进程死锁。

kill():与terminate()相同,但是在Unix上使用SIGKILL信号。适用python3.7版本。

close():关闭进程对象,释放与之关联的所有资源。如果底层进程仍在运行,则会引发ValueError。一旦close()成功返回,进程对象将不能调用方法和属性否则将引发ValueError。适用python3.7。

参考文档:

https://docs.python.org/3/library/multiprocessing.html?highlight=multiprocessing#module-multiprocessing


二、multiprocessing.Process使用示例

不带参数:

from multiprocessing import Process
def f():
print("子进程")
if __name__ == "__main__":
p = Process(target=f) # 不带参数
p.start()
print("执行主进程内容")
# 打印内容如下
执行主进程内容
子进程

带参数:

from multiprocessing import Process
def f(name,a=10,b=20):
print(f"{a}+{b}的{name}是{a+b}")
if __name__ == "__main__":
p = Process(target=f,args=('和',),kwargs={'a':1,'b':2}) # 带参数
p.start()
print("执行主进程内容") # 打印内容如下
执行主进程内容
1+2的和是3

从打印结果我们可以看出程序先执行了主进程的print,之后才执行了子进程的print。这里主要是因为操作系统在开辟进程时需要花费一定的时间,所以程序在这段时间里,先执行了主进程的print,然后才执行子进程print。可以使用join()方法来等待子进程结束后在执行主线程代码。如下:

from multiprocessing import Process
def f(name,a=10,b=20):
print(f"{a}+{b}的{name}是{a+b}")
if __name__ == "__main__":
p = Process(target=f,args=('和',),kwargs={'a':1,'b':2})
p.start()
p.join() # 等待子进程结束后在执行下面的代码
print("执行主进程内容")
# 打印内容如下
1+2的和是3
执行主进程内容

第二种方式创建进程。很少用

from multiprocessing import Process
class MyProcess(Process): # 继承Process类
# 这里必须要调用Process中的init初始化参数
# 否则会因为无法传参导致错误
def __init__(self,buf,a=10,b=20):
self.buf = buf
self.a = a
self.b = b
super().__init__() # 必须有
def run(self): # 重写run()方法,必须自己实现
self.f(self.buf,self.a,self.b)
def f(self,buf, a=10, b=20):
print(f"{a}+{b}的{buf}是{a + b}")
if __name__ == "__main__":
p = MyProcess('和',a=1,b=2)
p.start()
p.join() # 等待子进程结束
print("我是主进程")
# 打印内容如下
1+2的和是3
我是主进程

获取进程PID号:

from multiprocessing import Process
import os
def f():
# 使用OS模块获取进程和父进程的PID
print(f"父进程PID:{os.getppid()},子进程PID:{os.getpid()}")
if __name__ == "__main__":
p = Process(target=f,args=())
p.start()
print('子进程PID:',p.pid) # 使用进程对象获取进程PID
# 打印内容如下
子进程PID: 6108
父进程PID:5320,子进程PID:6108

创建多个进程:

from multiprocessing import Process
def f(process_name):
print(f"{process_name}")
if __name__ == "__main__":
# 因为每个Process对象只能调用一次start方法
# 所以想要开几个进程,就要创建多少个Process对象
for i in range(3):
p = Process(target=f, args=("子进程-"+str(i),))
p.start()
print("主进程")
# 打印内容如下
主进程
子进程-0
子进程-2
子进程-1

我们会发现主进程比所有子进程都优先执行了,而且子进程也都不是按照顺序执行的,这主要是因为开启进程的耗时和操作系统的调度来决定的。

示例一:使用串行的方式开启多个进程

from multiprocessing import Process
import time
def f(process_name):
time.sleep(1)
print(f"{process_name}")
if __name__ == "__main__":
start_time = time.time()
for i in range(3):
p = Process(target=f, args=("子进程-"+str(i),))
p.start()
p.join() # 等待进程结束
end_time = time.time()
print(f"执行了{end_time - start_time}")
print('结束进程')
# 打印内容如下
子进程-0
子进程-1
子进程-2
执行了3.326190233230591
结束进程

从打印结果我们可以看出是所有子进程运行后才执行了主进程。但是发现会很慢,这是因为我们的join把原本应多进程并行的程序(异步),变成了串行(同步),必须等待一个子进程结束后才会执行下一个子进程。这样有可能违背了我们多进程并行的初衷,所以我们调整下join的位置。

# 下面请看示例二:

from multiprocessing import Process
import time
def f(process_name):
print(f"{process_name}")
if __name__ == "__main__":
start_time = time.time()
pro_list = [] # 存放进程对象
for i in range(3):
p = Process(target=f, args=("子进程-"+str(i),))
p.start()
pro_list.append(p) # 将进程对象添加到一个列表中
for i in pro_list: # 循环等待所有进程结束
i.join()
end_time = time.time()
print(f"执行了{end_time - start_time}")
print('进程结束')
# 打印内容如下
子进程-1
子进程-0
子进程-2
执行了0.17200994491577148
进程结束

对比示例一和示例二我们可以明显发现示例二真正实现了多个进程的并发效果。

守护进程有两个特性:

1、守护进程会在主进程代码执行结束后就终止。

2、守护进程内无法再开启子进程,否则抛出异常。

创建守护进程比较简单如下:

from multiprocessing import Process
import time
def f():
time.sleep(1)
print("守护进程")
if __name__ == "__main__":
p = Process(target=f,args=())
p.daemon=True # 开启守护进程,一定要在start前执行。
p.start()
print("主进程")
# 打印内容如下
主进程

我们发现守护进程并没有被执行,或者说还没来得及执行就结束了,我们知道操作系统在开启进程时要花费一定时间,在这个时间内主进程代码执行完了,所以守护进程还没来得及执行就结束了。可以使用join来等待守护进程执行完毕后在结束主进程

from multiprocessing import Process
import time
def f():
time.sleep(1)
print("守护进程")
if __name__ == "__main__":
p = Process(target=f,args=())
p.daemon=True # 开启守护进程,一定要在start前执行。
p.start()
p.join() # 等待守护进程结束
print("主进程")
# 打印内容如下
守护进程
主进程

进程锁:

为保证数据的安全性,在有些场合要使用进程锁,进程锁会使由原来的并行变成串行,程序效率会下降,但是却保证了数据的安全性,在数据安全性和程序效率面前,数据的安全性是大于程序的效率的。

下面以抢票为例,现在票数还有一张:

from multiprocessing import Process
import time,json
def search(name): # 查票
di = json.load(open("db"))
print(f"{name}查票,剩余票数{di['count']}") def get(name): # 购票
di = json.load(open("db"))
time.sleep(0.1)
if di["count"] > 0:
di["count"] -= 1
time.sleep(0.2)
json.dump(di,open("db","w"))
print(f"{name}购票成功")
def task(name):
search(name)
get(name)
if __name__ == "__main__":
for i in range(5): # 只模拟5个人抢一张票
p = Process(target=task,args=("游客-"+str(i),))
p.start() # 打印结果如下
游客-2查票,剩余票数1
游客-1查票,剩余票数1
游客-0查票,剩余票数1
游客-4查票,剩余票数1
游客-3查票,剩余票数1
游客-2购票成功
游客-1购票成功
游客-0购票成功
游客-4购票成功
游客-3购票成功

所有人全部购票成功,这就对数据的安全性提出了挑战。本来只有一张票,但是5个人都显示购票成功,这当然不是我们想要的结果,问题的原因在于,所有的游客在差不多同一时间都进行了购票,大家看到的票数都是1张,第一个用户购票后,将票数减1等于0还没来得及将结果写入文件,其它用户也进行了购票的操作,在余票0被写入文件的过程中,其它用户也购票成功,并将结果写入文件,造成了数据的混乱。

这里我们使用进程锁Lock也叫互斥锁,来解决问题。

from multiprocessing import Process,Lock
import time,json def search(name): # 查票
di = json.load(open("db"))
print(f"{name}查票,剩余票数{di['count']}") def get(name): # 购票
di = json.load(open("db"))
time.sleep(0.1)
if di["count"] > 0:
di["count"] -= 1
time.sleep(0.2)
json.dump(di,open("db","w"))
print(f"{name}购票成功")
def task(name,lock):
search(name) # 查票
lock.acquire() # 加锁
get(name) # 购票
lock.release() # 解锁
if __name__ == "__main__":
lock = Lock() # 获取锁
for i in range(5): # 只模拟5个人抢一张票
p = Process(target=task,args=("游客-"+str(i),lock))
p.start() # 打印内容如下
游客-0查票,剩余票数1
游客-1查票,剩余票数1
游客-2查票,剩余票数1
游客-3查票,剩余票数1
游客-4查票,剩余票数1
游客-0购票成功

在购票时加一个互斥锁,这样一个进程在购票时,其它的进程只能查看就不能进行购票的操作了,保证了数据的安全性,最终结果是正确的,这就是为什么我们明明看到有票,但是点击购买后却说没票的原因,虽然加锁后使原本并行的程序,变成了串行。但我们要知道在不能保证数据安全的情况下一切效率都是空谈。

进程间的通信IPC(Inter-Process Communication)队列

队列:Queue是多进程安全的队列,可以使用Queue实现多进程之间的数据传递。

队列的常用方法:

Queue([maxsize]):创建共享的进程队列。maxsize是队列中允许的最大数值。如果省略此参数,则无大小限制。底层队列使用管道和锁定实现。另外,还需要运行支持线程以便队列中的数据传输到底层管道中。

Queue的实例q具有以下方法:

q.get([block[,timeout]]):返回q中的一个项目。如果队列为空,此方法将阻塞,直到队列中有项目可用为止。block用于控制阻塞行为,默认为True.如果设置为False,将引发Queue.Empty异常(定义在Queue模块中)。timeout是可选超时时间,用在阻塞模式中。如果在制定的时间间隔内没有项目变为可用,将引发Queue.Empty异常。

q.get_nowait( ) 同q.get(False)方法。

q.put(item[,block[,timeout]]):将item放入队列。如果队列已满,此方法将阻塞至有空间可用为止。block控制阻塞行为,默认为True。如果设置为False,将引发Queue.Empty异常(定义在Queue库模块中)。timeout指定在阻塞模式中等待可用空间的时间长短。超时后将引发Queue.Full异常。

q.qsize():返回队列中目前项目的正确数量。此函数的结果并不可靠,因为在返回结果和在稍后程序中使用结果之间,队列中可能添加或删除了项目。在某些系统上,此方法可能引发NotImplementedError异常。

q.empty():如果调用此方法时q为空,返回True。如果其他进程或线程正在往队列中添加项目,结果是不可靠的。也就是说,在返回和使用结果之间,队列中可能已经加入新的项目。

q.full() :如果q已满,返回为True. 由于线程的存在,结果也可能是不可靠的(参考q.empty()方法)。

q.close():关闭队列,防止队列中加入更多数据。调用此方法时,后台线程将继续写入那些已入队列但尚未写入的数据,但将在此方法完成时马上关闭。如果q被垃圾收集,将自动调用此方法。关闭队列不会在队列使用者中生成任何类型的数据结束信号或异常。例如,如果某个使用者正被阻塞在get()操作上,关闭生产者中的队列不会导致get()方法返回错误。

q.cancel_join_thread():不会再进程退出时自动连接后台线程。这可以防止join_thread()方法阻塞。

q.join_thread():连接队列的后台线程。此方法用于在调用q.close()方法后,等待所有队列项被消耗。默认情况下,此方法由不是q的原始创建者的所有进程调用。调用q.cancel_join_thread()方法可以禁止这种行为。

下面我们已生产者消费者模型来进行演示:

from multiprocessing import Process,Queue
import time,random def consumer(name,q): # 消费者
while True:
task = q.get() # 从队列中取出数据
if task == None:break
print(f"{name}获取数据{task}")
time.sleep(random.random()) # 消费者效率比生产者效率高 def producer(name,q): # 生产者
for i in range(3):
q.put(i) # 向对列中添加数据
print(f"{name}生产数据{i}")
time.sleep(random.uniform(1,2)) # 模拟生产者的效率没有消费者效率高 if __name__ == "__main__":
q = Queue() # 获取一个队列
pro = []
for i in range(3): # 开启生产者进程
p = Process(target=producer,args=("生产者"+str(i),q))
p.start()
pro.append(p)
# 开启消费者进程
p1 = Process(target=consumer,args=("aaa",q))
p2 = Process(target=consumer,args=("bbb",q))
p1.start()
p2.start()
for i in pro: # 等待生产者结束
i.join() q.put(None) # 有几个消费者进程,就put几次None
q.put(None)

JoinableQueue([maxsize]) 模块

创建可连接的共享进程队列。这就像是一个Queue对象,但队列允许项目的使用者通知生产者项目已经被成功处理。通知进程是使用共享的信号和条件变量来实现的。

JoinableQueue的实例p除了与Queue对象相同的方法之外,还具有以下方法:

q.task_done():使用者使用此方法发出信号,表示q.get()返回的项目已经被处理。如果调用此方法的次数大于从队列中删除的项目数量,将引发ValueError异常。

q.join():生产者将使用此方法进行阻塞,直到队列中所有项目均被处理。阻塞将持续到为队列中的每个项目均调用q.task_done()方法为止。

下面的例子说明如何建立永远运行的进程,使用和处理队列上的项目。生产者将项目放入队列,并等待它们被处理。

我们在来实现上述的生产者消费者模型。

from multiprocessing import Process,JoinableQueue
import time,random def consumer(name,q): # 消费者
while True:
task = q.get() # 从队列中取出数据
q.task_done() # 通知生产者,我已经取完所有数据了
print(f"{name}获取数据{task}")
time.sleep(random.random()) # 消费者效率比生产者效率高 def producer(name,q): # 生产者
for i in range(1):
q.put(i) # 向对列中添加数据
print(f"{name}生产数据{i}")
time.sleep(random.uniform(1,2)) # 模拟生产者的效率没有消费者效率高
q.join() # 生产完毕,等待消费者通知数据已经获取完了 if __name__ == "__main__":
q = JoinableQueue() # 获取一个队列
pro = []
for i in range(1): # 开启生产者进程
p = Process(target=producer,args=("生产者"+str(i),q))
p.start()
pro.append(p)
# 开启消费者进程
p1 = Process(target=consumer,args=("aaa",q))
p2 = Process(target=consumer,args=("bbb",q))
p1.daemon=True # 如果不设置守护进程,这两个进程就不会结束。
p2.daemon=True # 因为他们只是通知生产者我接收到所有数据了,并没有终止循环。
p1.start()
p2.start()
for i in pro: # 等待生产者结束
i.join()

这里再次说明将消费者设置成守护进程的原因,q.task_done它只是通知生产者,我把数据已经都取完了,仅此而已,所以while循环并不会退出。如果不设置守护进程,程序会卡在while循环里。

进程池

进程池就是预先创建一个进程组,然后有任务时从池中分配一个进程去执行任务。当任务数量超过进程池的数量时,就必须等待进程池中有空闲的进程时,才能利用空闲的进程去执行任务。

进程池的优点:

1、充分利用CPU资源。

2、多个进程在同一时刻可以同时执行,达到了并行的效果。

进程池的缺点:进程的创建、销毁需要耗费CPU的时间。多进程适用于需要复杂计算少I/O阻塞的情况。如果程序不涉及复杂运算,最好是使用线程池。

关于进程池multiprocessing.Pool的一些方法:

apply(func [, args [, kwargs]]):需要注意的是:apply属于进程同步的操作,即必须等待一个进程结束后才能执行下一个进程。

apply_async(func[,args[,kwargs]]):apply_async属于进程的异步操作,所有进程可以同时执行,此方法的结果是AsyncResult类的实例,callback是可调用对象,接收输入参数。当func的结果变为可用时,将立即传递给callback。callback禁止执行任何阻塞操作,否则将接收其他异步操作中的结果。

p.close():关闭进程池,防止进一步操作。如果所有操作持续挂起,它们将在工作进程终止前完成。

P.jion():等待所有工作进程退出。此方法只能在close()或teminate()之后调用。

同步进程池的示例:

import os,time
from multiprocessing import Pool
def work(n):
print("PID:%s run" %os.getpid())
time.sleep(1)
return n ** 2 if __name__ == "__main__":
p = Pool(3) # 开启进程池
res = []
for i in range(3):
res.append(p.apply(work,args=(i,))) # 进程同步模式
print(res) # 打印返回结果 # 打印内容如下
PID:6180 run
PID:9728 run
[0, 1, 4]

因为是进程池的同步,所以进程时的执行顺序是有序的,并且必须一个进程执行后才执行下一个进程。

进程池的异步示例:

import os,time
from multiprocessing import Pool
def work(n):
print("PID:%s run" %os.getpid())
time.sleep(1)
return n ** 2 if __name__ == "__main__":
p = Pool(3) # 开启进程池
res = []
for i in range(5):
# 进程异步模式
res.append(p.apply_async(work,args=(i,)))
# 因为是异步,所以开进程会很快
# 所以我们所有进程结束后在打印结果
p.close() # 关闭进程池
p.join() # 等待进程池结束,
for i in res:
print(i.get(),end=" ") # 打印内容如下
PID:7512 run
PID:10176 run
PID:7240 run
PID:10176 run
PID:7512 run
0 1 4 9 16

关于进程池有个问题需要注意,在子进程中不能使用input函数()


下一篇:线程理论部分:https://www.cnblogs.com/caesar-id/p/10758189.html

python进程、进程池(二)代码部分的更多相关文章

  1. Python小数据池,代码块

    今日内容一些小的干货 一. id is == 二. 代码块 三. 小数据池 四. 总结 python小数据池,代码块的最详细.深入剖析   一. id is == 二. 代码块 三. 小数据池 四. ...

  2. python 小数据池,代码块, is == 深入剖析

    python小数据池,代码块的最详细.深入剖析   一. id is == 二. 代码块 三. 小数据池 四. 总结 一,id,is,== 在Python中,id是什么?id是内存地址,那就有人问了, ...

  3. Python 小数据池和代码块缓存机制

    前言 本文除"总结"外,其余均为认识过程:3.7.5: 总结: 如果在同一代码块下,则采用同一代码块下的缓存机制: 如果是不同代码块,则采用小数据池的驻留机制: 需要注意的是,交互 ...

  4. python小数据池,代码块知识

    一.什么是代码块? 根据官网提示我们可以获知: A Python program is constructed from code blocks. A block is a piece of Pyth ...

  5. 五.python小数据池,代码块的最详细、深入剖析

    一,id,is,== 在Python中,id是什么?id是内存地址,那就有人问了,什么是内存地址呢? 你只要创建一个数据(对象)那么都会在内存中开辟一个空间,将这个数据临时加在到内存中,那么这个空间是 ...

  6. python小数据池,代码块的最详细、深入剖析

    代码块: Python程序是由代码块构造的.块是 一个python程序的文本,他是作为一个单元执行的. 代码块:一个模块,一个函数,一个类,一个文件等都是一个代码块. 而作为交互方式输入的每个命令都是 ...

  7. python小数据池,代码块深入剖析

    小数据池 目的:缓存我们字符串,整数,布尔值.在使用的时候不需要创建更多的对象 缓存:int,str,bool int:缓存范围-5~256 str:    1.长度小于等于1,直接缓存 2.长度大于 ...

  8. Python之进程 3 - 进程池和multiprocess.Poll

    一.为什么要有进程池? 在程序实际处理问题过程中,忙时会有成千上万的任务需要被执行,闲时可能只有零星任务.那么在成千上万个任务需要被执行的时候,我们就需要去创建成千上万个进程么?首先,创建进程需要消耗 ...

  9. {Python之进程} 背景知识 什么是进程 进程调度 并发与并行 同步\异步\阻塞\非阻塞 进程的创建与结束 multiprocess模块 进程池和mutiprocess.Poll

    Python之进程 进程 本节目录 一 背景知识 二 什么是进程 三 进程调度 四 并发与并行 五 同步\异步\阻塞\非阻塞 六 进程的创建与结束 七 multiprocess模块 八 进程池和mut ...

  10. python 进程和线程(代码知识部分)

    二.代码知识部分 一 multiprocessing模块介绍: python中的多线程无法利用多核优势,如果想要充分地使用多核CPU的资源(os.cpu_count()查看),在python中大部分情 ...

随机推荐

  1. java 字符常量池

    一.题目: 问题:String str = new String(“hello”),“hello”在内存中是怎么分配的?    答案是:堆,字符串常量区. Java中的字符串常量池和JVM运行时数据区 ...

  2. cas 4.1.4单点登录实战

    使用工具 maven-3.3.9 cas-4.1.4 Tomcat-7.0.57-win-x64 cas-sample-Java-webapp 一.Hello cas 1.下载Tomcat,解压:修改 ...

  3. vector的内存分配问题

    vector的内存增长问题,其实无非是vector中size()和capacity()问题.vector的一个缺点就是它的内存分配是按照2的倍数分配内存的.当当前容量对插入元素不够时,分配一块新的内存 ...

  4. 【转】tomcat logs 目录下各日志文件的含义

    tomcat每次启动时,自动在logs目录下生产以下日志文件,按照日期自动备份   localhost.2016-07-05.txt   //经常用到的文件之一 ,程序异常没有被捕获的时候抛出的地方 ...

  5. Web测试——翻页功能测试用例

    参考:https://wenku.baidu.com/view/e6462707de80d4d8d15a4f1e.html?rec_flag=default&mark_pay_doc=2&am ...

  6. linux安装tomcat Neither the JAVA_HOME nor the JRE_HOME environment variable is defined

    这两天我们的开发机重启了好几次,发现每次重启后我的tomcat总是没有启动.检查java路径,配置正确,后来拿普通账号启动tomcat时报如下的错: Neither the JAVA_HOME nor ...

  7. redis 中如何切换db

    一台服务器上都快开启200个redis实例了,看着就崩溃了.这么做无非就是想让不同类型的数据属于不同的应用程序而彼此分开. 那么,redis有没有什么方法使不同的应用程序数据彼此分开同时又存储在相同的 ...

  8. AJAX初步学习

    AJAX(Asynchronous JavaScript and XML)即异步的JavaScript与XML技术,指的是一套综合了多项技术的浏览器端网页开发技术.其实就是为了解决传统页面同步刷新,消 ...

  9. bzoj3435 [Wc2014]紫荆花之恋

    如果这棵树不变的话,就是一个裸的点分树套平衡树,式子也很好推$di+dj<=ri+rj$,$ri-di>=dj-rj$ 平衡树维护$dj-rj$,然后查$ri-di$的$rank$即可. ...

  10. BZOJ_2303_[Apio2011]方格染色 _并查集

    BZOJ_2303_[Apio2011]方格染色 _并查集 Description Sam和他的妹妹Sara有一个包含n × m个方格的 表格.她们想要将其的每个方格都染成红色或蓝色. 出于个人喜好, ...