算法趣题(二)

8 篇文章 0 订阅
4 篇文章 0 订阅

1. 翻牌问题

问题:有一组写着数字 1 1 1~ 100 100 100 的纸牌,按照从小到大的顺序排列着。最开始所有的纸牌都背面朝上。接下来按照规则翻牌:第一次从第 2 2 2 张纸牌开始,隔一张牌翻牌,于是第 2 、 4 、 6 、 8 、 . . . 、 100 2、4、6、8、... 、100 2468...100 位置的牌会变成正面朝上;第二次从第 3 3 3张纸牌开始,每隔 2 2 2 张牌翻牌,于是第 3 、 6 、 9 、 . . . 、 99 3、6、9、... 、99 369...99 位置的牌中,原本正面朝上的变成背面朝上,原本背面朝上的牌变成正面朝上;依次类推,从第 n n n张牌开始,每隔 n − 1 n-1 n1 张牌翻牌,直到没有可以翻的牌为止。

求当所有的牌都不再变动的时候,所有背面朝上的纸牌数字是哪些?

分析

  • n n n张牌开始,每隔 n − 1 n-1 n1 张牌,就意味着后面的牌都是第 n n n 张牌数字的倍数,每一轮的翻牌数都是一个等差数列,公差是 n n n
  • 翻牌规则很简单,很容易就通过编程逻辑实现翻牌的规则。但是问题在于时间复杂度的效率的问题,假设一共有 n n n 张牌,那么就需要进行 n n n 轮翻牌操作;假设从第 a a a 张牌开始翻动,那么每轮需要翻动的牌的个数为 n / a n / a n/a 结果向下取整;因此算法所需时间为: n ∗ ( n / a ) n * (n / a) n(n/a) ,当 n n n 很大时,常数项 a a a 可以忽略,所有最终算法的时间复杂度为: O ( n 2 ) O(n^2) O(n2) ,效率不高。

算法

  • 初始化一个含有 100 100 100个元素的数组,规定每张牌,背面朝上为 T r u e True True,正面朝上为 F a l s e False False
  • 从数组的第二个元素开始遍历,每轮遍历都需要内部遍历一个等差数列
  • 每轮内部遍历等差数列位置上的纸牌时,若纸牌时 T r u e True True,则翻转操作,重新赋值为 F a l s e False False;若纸牌时 F a l s e False False,则翻转操作,重新赋值为 T r u e True True
  • 外部遍历完整个数组后,所有值为 T r u e True True的纸牌,就是最终背面朝上的纸牌

Python代码实现

cards = [True for i in range(100)]
for i in range(2, 101):
    for j in range(i, 101, i):
        if cards[j-1]:
            cards[j-1] = False
        else:
            cards[j-1] = True

print([i+1 for i in range(100) if cards[i] is True])

# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
算法优化

分析

  • 事实上,每轮被翻动的纸牌不但是一组等差数列,还是一组等比数列,公比为从第n张牌开始翻牌的n。这意味着,每一轮中需要翻动的纸牌都是n的倍数,反过来看,n也是每张需要翻动的纸牌数字的约数
  • 对于纸牌数字12而言,由于它的约数有 1 、 2 、 3 、 4 、 6 、 12 1、2、3、4、6、12 1234612,所以它会被从第1张牌、第2张牌、第3张牌、第4张牌、第6张牌、第12张牌开始的序列所翻动,一共会被翻动6次,偶次数翻动后依然是背面朝上;对于纸牌数字4而言,由于它的约数有 1 、 2 、 4 1、2、4 124,所以它会被从第1张牌、第2张牌、第4张牌开始的序列所翻动,一共会被翻动3次,奇次数翻动后结果是正面朝上。
  • 但由于规定不会从第一张牌开始翻动,因此对于12而言,最后会被翻动5次,奇次数翻动后结果是正面朝上;对于4而言,最后会被翻动2次,偶次数翻动后结果是背面朝上。
  • 也就是说,约数个数是偶数的数字,最终结果是正面朝上;约数个数是奇数的数字,最终结果是背面朝上。因此,只需要判断出任意一个纸牌,它所有约数个数是偶数还是奇数,就能直接知道这张纸牌最终的结果了。
  • 对于纸牌中的任何一个数字而言,它的约数总是成对且对称出现的,因此理论上,每个数字的约数个数都应该是偶数个。之所以会出现奇数个数的数字存在,是因为该数字是一个平方数,平方数的所有约数按照从小到大顺序排列,最中间那个约数的平方就是该数字本身。
  • 综上,问题就转变成了,判断求出纸牌中的所有平方数。如果某个数字是平方数,就意味着该数字的约数个数是奇数个,除去约数1,则约数个数是偶数个,偶数个约数就意味着翻动偶数次纸牌,最终的结果就是背面朝上。

算法

  • 初始化一个含有 100 100 100个元素的数组,规定每张牌,背面朝上为 T r u e True True,正面朝上为 F a l s e False False
  • 如果某个数不存在平方数,那么就无法整除该数的平方根,因此最终结果是 F a l s e False False

Python代码实现

cards = [True for i in range(100)]
for i in range(1, 101):
    # 如果除不尽有余数,则说明没有约数是奇数个
    if i % i**(1/2):
        cards[i-1] = False

print([i+1 for i in range(100) if cards[i] is True])

# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

这样代码只需要一次遍历就能够得出结果了,这样算法的时间复杂度是: O ( n ) O(n) O(n)

事实上,还可以将算法的代码进一步抽象为对数字的处理,而不是停留在纸牌的翻动处理上。这样的算法不但简洁,而且又进一步地降低了算法的时间复杂度,最终的时间复杂度为: O ( n ) O( \sqrt{n} ) O(n )

最好的算法

import math
cards = [i**2 for i in range(1, int(math.sqrt(101))+1) if i**2 <= 101]
print(cards)

# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

2. 切分木棒

问题:假设要把长度为 n 厘米的木棒切分为 1 厘米长的小段,但是 1 根木棒只能由 1 个人切分。当木棒被切成大于等于 m 段时,可以同时由 m 个人同时切分木棒。分别求当 n = 20,m = 3 时,当 n = 100,m = 5时,最少的切分次数?

分析

  • 由于能够切分木棒的人是固定的 m 个,因此效率的关键在于:在符合切分规则的前提下,尽量不能有人是空闲的,这样才能以最快的速度切分木棒。
  • 为了快速得到可用于切分的木棒(长度大于 2 厘米),所以应该从木棒中间开始切,这样可以保证每次切出来的木棒,要么是最终的 1 厘米,要么是需要继续切割的木棒。
  • 切分操作,可以分为三个阶段:
    1. 当小木棒数量小于人数时,虽然只能用到一部分人去切分,但是所有的小木棒都可以被切分,每次这样的操作会使得小木棒的数量翻倍
    2. 当小木棒数量大于等于人数时,此时所有人都去切分木棒,因此每次切分完成会导致小木棒数量增加 m 个
    3. 当最终小木棒数量大于或等于 n 时,此时说明所有的木棒都完成了切分操作
  • 之所以会出现最后的小木棒数量可能大于 n 的情况,是因为在第1、2个步骤的切分过程中,没有考虑当前小木棒是否都可以切分。因为很多时候,为了完成所有的切分,就必须会造成这种统计数量上多算。(这个算法本身导致)而且,多算的那一步只会发生在统计的最后一步中!例如,长度为7的木棒,共有5个人去分割,木棒数量的增长是:1 -> 2 -> 4 -> 8 份,算法步骤一会导致结果大于实际的 7 ;若长度是 11,共有 2 个人切分,则木棒数量增长是: 1 -> 2 -> 4 -> 6 -> 8 -> 10 -> 12,算法步骤二又会导致结果大于实际的 11 。
  • 因此,算法根本不需要关心木棒是否可以继续切分这个问题。只需要将每一根木棒都认为是再切分的就行了
  • 在整个算法的切分过程中,初始的木棒以及后面的小木棒会不断地切分下去,直到所有的没有任何一根木棒可以再切分了,这样的操作存在递归终止条件。因此,可以采用递归算法来实现。

算法

  • 当切分出的小木棒的数量大于或等于原木棒的长度时,意味着不再需要进行切分操作,记录 0 次操作
  • 当切分出的小木棒的数量小于人数时,意味着所有的木棒都可以被切分操作,小木棒数量翻倍,同时记录 1 次操作
  • 当切分出的小木棒的数量大于人数时,意味着最多对 m 根木棒切分,小木棒数量增加 m 根,同时记录 1 次操作

Python代码实现

def cutbar(n, m, current):
    """
    :param n: 木棒的长度
    :param m: 人数
    :param current: 当前小木棒的数量
    :return:切分操作的次数
    """
    if current >= n:
        return 0
    elif current < m:
        return 1 + cutbar(n, m, current * 2)
    else:
        return 1 + cutbar(n, m, current + m)

print(cutbar(20, 3, 1), cutbar(100, 5, 1))

# 8 22
另一种算法

按照顺序的思维来解决这道题,会难以发现算法无需担心木棒是否可再分割的关键,这是上面算法的难点所在。

其实对于本题,问题的关键就在于:木棒的原长度与小木棒的数量之间的关系

分析

  • 既然原木棒最终都能切分成长度为 1 的小木棒,反过来看,长度为 1 的小木棒也能拼接成原木棒长度。
  • 因此,切分原木棒的次数,与小木棒拼接成原木棒的次数,两者相等
  • 问题就转变成了:m 个人,用单位长度为 1 的小木棒,拼接出长度为 n 的木棒,需要多少次步骤。

算法

  • 若只有小木棒数量大于或等于原木棒长度,才停止循环
  • 若当前小木棒数量小于人数,则小木棒数量增加翻倍
  • 若当前小木棒数量大于人数,则小木棒数量增加 m 个
def cutbar(n, m):
    """
    :param n:原木棒长度 
    :param m: 人数
    :return:拼接的次数 
    """
    count = 0
    current = 1
    while current < n:
        # 实现三目运算符
        current += current if current < m else m
        count += 1
    return count

print(cutbar(20, 3), cutbar(100, 5))

# 8 22

同样的,这个算法也是无法避免小木棒数量的增多的问题。但通过转换思路的角度考虑,不再显得很难懂了。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值