Python解题 - CSDN周赛第23期 - 树形背包与优化

以问哥目前的水平来看,本期的四道题的整体难度还是中等偏上的,而且从结果上来看, 也达到了竞赛的标准(只有三名选手拿到满分)。也许在某些大佬看来还是太简单了,毕竟都是模板题,直接套模板就能过,但对于这些考察点掌握得还不是很熟悉的童鞋(比如我),临阵磨枪加赛后总结才勉强梳理清楚。至少以赛代练的效果是达到了,而且这种get到了的感觉非常棒,希望以后再接再厉。

==========================

由于问哥的题解基本是面向初学者,而本期的题目又大都有代表性,每道题都可以展开来说很多,所以问哥尽可能会介绍地详细一些,把思考过程写出来,所以一方面本期内容会比较长,应该只有感兴趣的朋友才读得下去,另一方面本期内容也相当于是我自己的阶段性知识总结,不一定符合某些大佬的胃口,为了避免骚扰这部分朋友,设置了关注可见,敬请谅解。。。


第一题:排查网络故障

A地跟B地的网络中间有n个节点(不包括A地和B地),相邻的两个节点是通过网线连接。正常的情况下,A地和B地是可以连通的,有一天,A地和B地突然不连通了,已知只有一段网线出问题(两个相邻的节点)小明需要排查哪段网线出问题。他的排查步骤是: 1. 选择某个中间节点;2. 在这个节点上判断跟A地B地是否连通,用来判断那一边出问题。请问小明最少要排查多少次,才能保证一定可以找到故障网线。

分析

题目说得那么明显,所以第一眼想到的就是二分搜索,于是直接套了模板。最少要排查多少次,才能保证一定可以找到。说明每次二分的时候,都要继续迭代搜索“情况更差”的那部分,才能保证在多少次数内一定可以找到。而就本题而言,这种“更差”的情况,就是二分后,某部分的节点数更长一些。所以我们使用二分进行迭代判断的标准就是,如果左边的节点比右边多,则右边界向左移,反之,左边界向右移,如果相等的话就无所谓哪一边了(我这里选择的是右边界向左移)。这样,当搜索的左、右边界重合后,就一定可以找到故障网线,而我们只要记录下迭代了多少次即可。

参考代码1

n = int(input().strip())
left, right = 1, n
res = 0
while left <= right:
    mid = (left+right)//2
    if mid-left >= right-mid:
        right = mid-1
    else:
        left = mid+1
    res += 1
print(res)

二分模板已经在脑子里固化,所以无脑过了,但是实际上,赛后想一想,由于每次都是检查中间,然后比较左右两部分的大小,结果只能是要么相等,要么一边比另一边多一个节点,所以其实没必要用二分模板,直接把n除以2,每次向上取整(因为选取节点多的那一边),直到 n 等于1,然后计算除操作的次数即可。 

参考代码2

n = int(input().strip()) + 1 # 边界问题
res = 0
while n > 1:
    n = n//2 + bool(n%2)
    res += 1
print(res)

第二题:零钱兑换

给定数组arr,arr中所有的值都为正整数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个aim,代表要找的钱数,求组成aim的最少货币张数。 如果无解,请返回-1。

数据范围:数组大小满足 0 <= n <=10000 , 数组中每个数字都满足 0 < val <=10000,0 <= aim <=100000

要求:时间复杂度 O(n×aim) ,空 间复杂度 O(aim)。

示例:

示例
输入

[5,2,3],20

输出4

分析

完全背包的模板题。和01背包限定了每种物品只有一件(要么选要么不选)不同,完全背包中的每种物品可选择的数量无限。其实早在第九期竞赛的时候就考过完全背包,而且类似地,都是使用某几种货币组成多少钱,不过那次是询问有多少种可能,而这次是最少使用多少张货币。 

之前第18期周赛的时候考过01背包,那时候问哥也通过画图试图讲解清楚背包问题的这一基本类型。虽然问哥自己也并没有完全掌握所有背包类型(比如第三题树形背包就没有拿到满分),但通过这次比赛,也让自己更深入地了解了这部分知识。

拿示例举例,我们有三种货币,要拼出20元,这里我们把要拼出的aim值当做背包,那么参考01背包,我们首先建立一个大小为4行21列的背包(三种货币每种一行,加上第一行初始行,金额按照1元的颗粒度建立列,第一列为0元初始列),而除了第一列的每一个格子的初始值都是正无穷(代表无解,类似地,如果要查找最多需要多少张货币,则需要改成负无穷)。由于我们要统计的是使用多少张货币,所以如果用 dp[i][j] 表示使用第 i 行以上的某几种货币组成金额 j 至少需要多少张的话,可得背包状态转移方程如下,其中 k 表示当前货币的面值:

dp[i][j] = min(dp[i-1][j], dp[i-1][j-k]+1, dp[i-1][j-2*k]+2,..., dp[i-1][j-n*k]+n)

下面对这个转移方程做一下解释。

比如我们按照示例的数据建立了二维表格如下,很显然,当只有第一种货币时,只有该货币的倍数金额可以使用该货币组成。其他格子维持无解状态(正无穷)。

而因为只有5元货币,所以可组成的金额及所用到的张数也就是5的倍数,组成5元需要1张,组成10元需要2张,组成15元需要3张,组成20元需要4张。

而当我们加入第二种货币:2元货币的时候,需要比较每种组成的金额,来使用上面的转移公式进行状态转移:

  • 当金额为2的时候,dp[i-1][2-1*2]+1 = 1,而 dp[i-1][2] = inf,两者比较取小,结果为1;
  • 当金额为3的时候,dp[i-1][3-1*2] +1 和,而 dp[i-1][3] 都是 inf,两者取小还是 inf;
  • 当金额为4的时候,dp[i-1][4]dp[i-1][4-1*2]+1 都是 inf,只有df[i-1][i-2*2]+2=2,所以结果为2;
  • 当金额为5的时候,dp[i-1][5-1*2]+1dp[i-1][5-2*2] 都是inf,只有 dp[i-1][5]=1,所以结果为1;
  • 当金额为6的时候,比较 dp[i-1][6]dp[i-1][6-1*2]+1dp[i-1][6-2*2]+2,和 dp[i-1][6-3*2]+3,前三个都是inf,只有最后一个为3,所以结果为3;
  • 当金额为7的时候,比较 dp[i-1][7]dp[i-1][7-1*2]+1dp[i-1][7-2*2]+2,和 dp[i-1][7-3*2]+3,只有第二个结果最小,所以取结果为2;
  • 剩下金额略去……

由此可见,每一行转移的时候都要去比较分别取1~n张当前货币时,最小的张数。

计算最后一种货币的时候也是如此:

但是,不难发现,每次都要循环比较 1~n 种情况,这样做是很低效的,而仔细看看就会发现,其实我们每次比较的结果,都已经保存在了 dp[i][j-k] 里。

所以,上面的转移过程,等价于下面:

于是,我们可以得到优化后的转移方程:

dp[i][j] = min(dp[i-1][j], dp[i][j-k]+1)

这样,时间复杂度上就得到了优化,我们再看空间复杂度。实际上,我们并不需要二维数组,因为上面的转移方程里,dp[i][j-k]dp[i][j] 是同维度的,而 dp[i-1][j] 可以当做数组还没有更新的当前值,所以我们其实只需要一维数组即可,这样空间复杂度也得到了优化。最终的转移方程如下:

dp[i][j] = min(dp[i][j],dp[i][j-k])

另外,本题还有个小坑,就是题目给的输入是一个包含了列表和数字的字符串,而不是像其他题目那样给出“空格分隔的数字”,所以给输入带来很大麻烦,比如要把示例中的“[5,2,3],20”转换成列表[5,2,3]和数字20。问哥在线试了不少次才把答题所需的货币列表和目标金额数字截取出来,感觉又像是从哪里抄来的题目。。。

最终实现代码如下:

参考代码

str1 = input().strip() # 题目输入的是字符串
arr = ""
for i, j in enumerate(str1):
    arr += j
    if j == "]": break
arr = eval(arr) # 把货币数组截取出来转换为列表
aim = int(str1[i+2:]) # 把目标值截取出来转换为整数
n = len(arr)
### 上面全是输入字符串的转化操作 ###
### 下面是完全背包的模板部分 ###
dp = [float("inf")]*(aim+1)
dp[0] = 0 # 初始值,0元不需要任何货币
for i in range(n):
    for j in range(arr[i], aim+1):
        dp[j] = min(dp[j], dp[j-arr[i]]+1)
if dp[aim] < float("inf"):
    print(dp[aim])
else:
    print(-1)

第三题:清理磁盘空间

小明电脑空间满了,决定清空间。为了简化问题,小明列了他个人文件夹(/data)中所有末级文件路径和大小,挑选出总大小为 m 的删除方案,求所有删除方案中,删除操作次数最小是多少。 一次删除操作:删除文件或者删除文件夹。如果删除文件夹,那么该文件夹包含的文件都将被删除。文件夹的大小:文件夹中所有末级文件大小之和。

示例:

示例一示例二
输入

6 10

/data/movie/a.mp4 5

/data/movie/b.mp4 3

/data/movie/c.mp4 2

/data/movie/d.mp4 4

/data/picture/a.jpg 4

/data/picture/b.jpg 1

6 10
/data/movie/a.mp4 1
/data/movie/b.mp4 1
/data/movie/c.mp4 1
/data/movie/d.mp4 1
/data/picture/a.jpg 4
/data/picture/b.jpg 11
输出2-1

分析

又是一道背包题。可能是因为上一题太基础了,本题又祭出了背包问题的一个特殊类型——树形背包。

树形背包,也叫依赖型背包,可放入背包的物品存在相互依赖或制约关系,比如如果选择 A,就必须选择 B;或者如果选择 A,就不能选择 B。

很明显,本题就是这样一道依赖型背包问题。把要删除的总容量大小 m 看做是“背包”的总体积,而要删除的文件或文件夹就存在着制约关系:如果选择(删除)文件但是保留同文件夹下的其它文件,就不能选择(删除)该文件所在的文件夹;而相应地,如果选择(删除)某文件夹,就不能二次选择(删除)它所包含的文件或文件夹。

我们在做01背包和完全背包的时候,可以发现其实背包状态都从上一个物品转移过来,而不需要关心之前的物品,所以空间上只需要一维数组(滚动数组)。但是在树形背包的时候,因为存在依赖关系,背包状态无法单单只从上一个物品的状态转移过来,因为有可能上一个物品选了,当前物品就选不了,或者选了当前物品,上一个物品就不能选,所以就不能只参考它的背包状态了。

树形背包的基本思想,就是通过递归,从树根节点到叶子节点,再把叶子结点的背包状态合并到它的父节点,然后层层合并,回到树根节点。问题的关键点就在于这个“合并”,它并不仅仅是其他背包类型的“转移”。

 实际上,“合并”是一个三维数组的滚动计算过程。以本题为例,我们要合并父文件夹的背包状态,与其中一个子文件夹/文件的背包状态,我们就要按照背包颗粒度,比较当父文件夹的容量分别剩下 1~m 时(第一维),选择子文件夹/文件(第二维)的话,分别选择子文件夹/文件中 1~n 容量(第三维)的结果。

通过类似于01背包的滚动数组的空间优化办法,可以压缩掉一个维度的空间,所以我们只需要倒序遍历,两个嵌套循环即可完成父与子背包状态合并。时间复杂度是 O(n*m^{2}) ,因为在每一个容量状态下,子背包的容量 m 遍历了两次,总共遍历了 n 个父背包的容量状态。

下面是以本题为例,以上述思想写成的代码。由于本题的文件夹结构,是一个天然的树形结构,所以我们可以根据已知的信息先构造出一棵树。然后定义一个递归函数dfs,将叶子节点的背包状态计算出,再回到父节点进行合并。

参考代码1

n, m = map(int, input().strip().split())
volume = {} # 用于存放每个文件夹/文件的容量大小
tree = {} # 用于建立文件夹与文件的树形结构
for _ in range(m):
    i, j = map(input().strip().split())
    temp = i.split("/")[1:]
    for k in range(len(temp)):
        key = "/".join(temp[:k+1])
        volume[key] = volume.get(key,0)+int(j) # 统计每个父文件夹的总容量
        if k < len(temp)-1: # 建树
            tree.setdefault(key, set()).add("/".join(temp[:k+2]))
if volume["data"] < m: 
    print(-1)
else:
### 下面是树形背包部分 ###
    def dfs(root):
        dp = [0]+[float("inf")]*volume[root]
        dp[-1] = 1 # 删除当前文件夹/文件只需一次
        if root in tree:
            for child in tree[root]:
                childdp = dfs(child)
                for i in range(volume[root], -1, -1):
                    for j in range(1, min(i+1, len(childdp))):
                        # 合并每一棵子树的背包
                        dp[i] = min(dp[i], dp[i-j]+childdp[j])
        return dp
    res = dfs("data")
    # 输出结果
    if res[m] < float("inf"):
        print(res[m])
    else:
        print(-1)

优化

但是,如前所述,这种基本树形背包的方式时间复杂度太高,O(n*m^{2}),而本题文件的数量最大到1000,上面的代码无法通过本题。所以必须考虑优化。

考虑到普通背包的时间复杂度为 O(n*m),每个物品的每个容量状态都检查一次,那么树形背包可不可以呢?答案是可以的。

如果要使时间复杂度降低,我们就不能使用合并操作,取而代之的是转移,如果我们使背包状态转移的方式从“多个子-->父”的多点对单点的模式(必须进行合并才能从父节点再转移出去),改变成“兄弟-->长子-->父”的单点对单点的后序遍历的链式,这样每次转移都只需要检查一次背包状态。时间复杂度和普通背包相同,为 O(n*m)

但是这样的转移仍然不是纯粹的一条单链,因为父节点也可能会有兄弟节点,那它的背包状态是从长子节点转移过来,还是从兄弟节点转移过来呢?

这样的二分抉择因题而异,就本题而言,假如我们当前的物品是一个父文件夹,如果我们考虑选择删除当前文件夹的话,很显然,它的子文件夹及子文件的状态都无效了,我们需要从同层级的兄弟文件夹/文件转移状态,如果我们不删除当前文件夹,那么他的子文件夹/文件的状态有效,我们选择从子文件夹/文件的背包状态转移。也就是说,我们需要根据删或不删当前文件夹,来综合考虑兄弟文件夹和子文件夹的背包状态,从而选择更优的那个。

如果用 v 表示当前(父)文件夹的容量,上述过程写成状态转移方程可表示如下:

dp[father][j] = min(dp[son][j], dp[sibling][j-v]+1)

相信您也看出来了,这种情况下,在空间上我们做不到一维数组,我们必须为每个节点维护一张动态转移表,以防兄弟节点要从很远的地方转移过来。

接下来的问题就是怎么样实现子节点以及兄弟节点到父节点的转移了,简单说就是如何让他们找到父节点的位置?这就是数据结构要考虑的事了。

使用字典或数组来标记文件夹/文件的位置都可以。其实因为数组支持随机访问,和字典的读取速度相同,都是 O(1),所以选择这两种数据结构差别不大。但是在操作上,如果使用字典的话,我们还需要额外维护另外两张表,用来记录文件夹的兄弟和子文件夹分别是谁,而使用数组的话,因为父、子都按照统一的顺序(树的先序或后序)排列,父节点的下一个节点就是子节点(如果没有子节点,就是兄弟节点),只要知道每个文件夹下的文件数量(size),就可以快速定位。于是,我们只需要再增加一个字典,用来记录每个文件夹下的文件或文件夹的数量,然后通过size来快速指向兄弟节点。将这种思想结合到状态转移方程如下:

dp[current][j] = min(dp[current+1][j], dp[current+size[current]][j-v]+1)

如此,我们便成功地把时间复杂度降为 O(n*m)。实现代码如下:

参考代码2

n, m = map(int, input().strip().split())
volume = {} # 用于存放每个文件夹/文件的容量大小
tree = {} # 用于建立文件夹与文件的树形结构
for _ in range(m):
    i, j = map(input().strip().split())
    temp = i.split("/")[1:]
    for k in range(len(temp)):
        key = "/".join(temp[:k+1])
        volume[key] = volume.get(key,0)+int(j) # 统计每个父文件夹的总容量
        if k < len(temp)-1: # 建树
            tree.setdefault(key, set()).add("/".join(temp[:k+2]))
if volume["data"] < m: 
    print(-1)
else:
    size = {} # 用于存放每个文件夹包含的子文件夹/文件的数量
    files = [] # 用于存放先序遍历的文件夹/文件顺序
    def dfs(root):
        files.append(root) # 将文件及文件夹按先序遍历排列
        size[root] = 0
        if root in tree:
            for t in tree[root]:
                size[root] += dfs(t)+1 # 每个父文件夹里含有子文件夹及子文件的数量
        return size[root]
    dfs("data")
    files.append(0) # 添加一个0用作初始化的背包
    dp = {0:[0]+[float("inf")]*m} # 添加0的初始背包
    for i in range(len(files)-2, -1, -1): # 从倒数第一个文件开始背包,相当于后序遍历
        dp[files[i]] = [0]+[float("inf")]*m # 每个文件/文件夹都有一个初始背包状态
        for j in range(1, m+1):
            if j >= volume[files[i]]:
                # 如果当前文件/文件夹容量放得下,比较子文件的背包(不删当前文件夹)和兄弟文件夹的背包(删了当前文件夹)
                dp[files[i]][j] = min(dp[files[i+1]][j], dp[files[i+size[files[i]]+1]][j-volume[files[i]]]+1)
            else:
                dp[files[i]][j] = dp[files[i+1]][j] # 如果当前文件/文件夹的容量放不下,直接从子文件背包转移过来
    # 输出结果
    if dp["data"][m] < float("inf"):
        print(dp["data"][m])
    else:
        print(-1)

第四题:交际圈

小明参加了个大型聚会。聚会上有 n 个人参加,我们将他们编号为1..n,有些人已经互相认识了,有些人还不认识。聚会开始后,假设A跟B认识,A会给所有他认识的人介绍B,原先跟A认识,但不认识B的人,都会在此时,跟B互相认识。当所有人都把自己认识的人介绍一遍后,此时n个人就会形成k个交际圈,同一个交际圈中,两两互相认识,不同的交际圈之间,互相不认识。

问题:当所有人都把自己认识的人介绍一遍后,形成了多少个交际圈?

示例:

示例
输入

3 3

1 2

1 3

2 3

输出1

分析

本题与第12期的蚂蚁家族那题不能说一模一样,也差不多95%相似了。可以说就是把蚂蚁换成人,节点数量扩大到100000,然后问了个不一样的问题:最终有多少交际圈(集合)。也就是这个问题,让人更容易想到并查集算法。

问哥最开始就是用的蚂蚁那道题的代码,但是因为数据量太大,那种自己的“原创”思路老是超时,不得已花了点时间仔细思考了一下。

很显然,本题还是关于的问题。而关于图的数据结构,我们最常用的就是深度优先搜索(DFS)和广度优先搜索(BFS),此题也是一样,可以使用DFS来解。具体做法是:

为输入的人际关系建立一张字典(图的邻接表),每个人代表的数字作为键,值就是他所认识的人的集合。很显然,如果不在这张字典里,就是没有人际关系的人,他们每个人自己就是一个交际圈,所以也要计算进来。

接下来,遍历这张字典,当遍历到某个键(代表某个人)的时候,将它的值取出来(他所认识的其他人),在DFS深度搜索这些人自己分别认识的人。当然,为了避免重复搜索形成环路,我们还要定义一个集合checked,用来记录那些我们已经统计过的人。最终,我们就可以得到有多少人是联系在一起的(互相认识),然后输出答案即可。

参考代码1

n, m = map(int, input().strip().split())
relation = dict()
for _ in range(m):
    a, b = map(int, input().strip().split())
    if a != b:
        relation.setdefault(a, set()).add(b)
        relation.setdefault(b, set()).add(a)
result = n - len(relation) # 孑然一身的人的数量
# 下面是没有使用递归的深搜
checked = set() # 保存检查过的人
for k in relation:
    if k not in checked:
        ppl = relation[k]
        result += 1 # 如果当前这个人不在检查列表里,就把他当做“祖先”
        while ppl:
            p = ppl.pop()
            checked.add(p)
            for i in relation[p]:
                if i not in checked:
                    ppl.add(i)
# 输出“祖先”数量
print(result)

前面也说了,本题在提示使用并查集算法来解,但是问哥在比赛的时候却不太记得并查集的模板了,相比之下,深搜更加熟悉一些,于是当然选择更加顺手的方法了。这里其实也不用纠结太多,对于图的数据结构,深搜还是并查集,两种方法的时间复杂度区别不大,都是 O(n^{2}),因为每个关系对都要检查到。

下面简单介绍一下并查集算法。

并查集主要的操作就是(合)并查(找),当然这两种操作的基础还是数组这种数据结构。上面我们也提到,数组支持随机查找,它的下标从0开始,也是连续的数字,所以当问题中出现类似1到n连续的数字,我们都可以考虑使用数组的数据结构,类似于用数字做键的字典。

首先在准备阶段,我们假定每个节点的“祖先”都是自己。在本题里,也就是说每个人都只认识自己(废话)。而用代码表示的话,我们使用下标数字表示人,数组在该下标保存的数字表示他的“祖先”。(很显然,0的存在只是用来方便数字的顺序,并不会参与计算,可以忽略)

每当我们接收到一条 a 与 b 相连的信息,我们执行查找工作,找到两人各自的“祖先”,然后把其中一人的“祖先”指向另一人的“祖先”,是为合并。比如,我们收到消息,1和2相识,于是我们查找上面的数组,发现两人的“祖先”都是自己,则我们把1的“祖先”修改成2的“祖先”,也就是2,而2的“祖先”不变,还是自己。

然后我们又收到1和3也相识,执行同样的查找操作,我们找到1的“祖先”是2,而3的“祖先”是自己,所以我们把2的“祖先”修改为3。

而在这个递归查询的过程中,很显然,下标1和2的“祖先”都是3,于是我们可以在递归查询的同时使用“路径压缩”,将1的“祖先”也顺便修改为3,这样后面再查找1的“祖先”的时候,就不需要再通过2来递归了。

最终,当得到所有人的关系图后,我们的数组可能长下面这样:

于是,我们只要数一下,最后的数组里有多少不同的“祖先”,也就是有多少不同的数字(0除外)就可以了。实现代码如下:

参考代码2

n, m = map(int, input().strip().split())
ppl = list(range(n+1)) # 将所有节点放入数组,初始每个人的“祖先”都是自己
# 压缩路径的查找函数
def find(p):
    if ppl[p] == p:
        return p
    ppl[p] = find(ppl[p]) # 压缩路径,以后同样的“祖先”不用再递归
    return ppl[p]
for _ in range(m):
    a, b = map(int, input().strip().split())
    # 合并“祖先”
    ppl[find(a)] = find(b)
# 统计“祖先”的数量并输出
res = set()
for i in range(1, n+1):
    res.add(find(ppl[i]))
print(len(res))
  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

请叫我问哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值