目录
1. 前言
上一篇中给出的初始方案是暴力破解(或者说全量搜索)的方式,总共需要搜索10!=3628800种情况,因此非常耗时。
本篇给出基于动态规划思想的题解。
2. 递推关系
由于10个数每个只用计算一次,考虑已经执行了若干个数的运算(它们构成visited集合),此时算盘上的计算结果为curSum,完成之后剩余的运算所需要的最少算珠移动数与之前若干个数的运算顺序(以及相应的算珠移动数)是无关的,仅与curSum有关。由此可以得到本问题的子问题分解结构,如下所述。
令f(unvisited)表示(在当前已经求得的和—即visited中的数的和—的基础上)执行剩余的unvisited的数的运算所需要的最少算珠移动数。注意,unvisited与visited是互补的,或者说一一对应的。则有以下递推关系成立:
其中,unvisited表示一个集合,“unvisited-k”则表示从unvisited中删除一个元素k的操作。
基于以上递推关系,可以以正向的标准的动态规划的方式实现,也可以以递归加Memoization的方式实现,鉴于后者代码显得更加简洁,以下代码采用后者。
因为10个数每个只能用一次,所以实际上visited或者unvisited(以下代码中是用visited)可以用10比特的二进制数来表示。
3. 代码及测试
# -*- coding: utf-8 -*-
"""
Created on Tue Oct 12 07:43:10 2021
@author: chenxy
"""
# import sys
import time
# import datetime
# import math
# import random
# from typing import List
# from queue import Queue
# from collections import deque
import itertools as it
import numpy as np
def move(x: int, y: int)->int:
'''
当前算盘上值为cursm时,再加上y需要移动算珠的个数
Parameters
----------
x : int
The current number in abacus.
y : int
The number to be added.
Returns
-------
int
The number of abacus-stone being moved in the above operation.
'''
# The representation of x
a1 = 1 if x>=50 else 0
a2 = (x%50) // 10
a3 = 1 if (x%10)>=5 else 0
a4 = x%5
# The representation of x+y
z = x + y
b1 = 1 if z>=50 else 0
b2 = (z%50) // 10
b3 = 1 if (z%10)>=5 else 0
b4 = z%5
return z, abs(a1-b1)+abs(a2-b2)+abs(a3-b3)+abs(a4-b4)
#3. Recursion + Memoization
memo = dict()
minMoves = 100
def search1(bit10, moves)->int:
'''
Parameters
----------
bit10 : int
10 bits to represent whether each one of [1,2,...,10] is used.
bit[0]:1; bit[1]:2; ...; bit[9]:10;
moves : number of moves up to now.
Returns
-------
The minimum number of moves needed for the current condition.
'''
# print(bit10,moves)
global minMoves
if bit10 == 0b11_1111_1111:
if minMoves > moves:
minMoves = moves
return
if (bit10, moves) in memo:
# Already visited, no need to continue.
return
cur_sum = 0
for k in range(10):
if bit10>>k & 0x1 == 1:
cur_sum = cur_sum + (k+1)
for k in range(10):
if bit10>>k & 0x1 == 0:
_,cur_move = move(cur_sum, k+1)
search1(bit10|(0x1<<k),cur_move+moves)
memo[(bit10, moves)] = minMoves
tStart = time.perf_counter()
search1(0,0)
tCost = time.perf_counter() - tStart
print('moves={0}, tCost = {1:6.3f}(sec)'.format(minMoves,tCost))
#4. Recursion + Memoization
memo = dict()
def search2(bit10)->int:
'''
Parameters
----------
bit10 : int
10 bits to represent whether each one of [1,2,...,10] is used.
bit[0]:1; bit[1]:2; ...; bit[9]:10;
Returns
-------
The minimum number of moves needed for the current condition.
'''
# print(bit10)
if bit10 == 0b11_1111_1111:
return 0
if bit10 in memo:
# Already visited, no need to continue.
return memo[bit10]
cur_sum = 0
for k in range(10):
if bit10>>k & 0x1 == 1:
cur_sum = cur_sum + (k+1)
minMoves = 100
for k in range(10):
if bit10>>k & 0x1 == 0:
_,cur_move = move(cur_sum, k+1)
moves = search2(bit10|(0x1<<k))
if minMoves > (moves + cur_move):
minMoves = (moves + cur_move)
memo[bit10] = minMoves
return minMoves
tStart = time.perf_counter()
minMoves = search2(0)
tCost = time.perf_counter() - tStart
print('moves={0}, tCost = {1:6.3f}(sec)'.format(minMoves,tCost))
运行结果:
moves=26, tCost = 0.028(sec)
moves=26, tCost = 0.007(sec)
4. 后记
以上包含两种同为“Recursion+Memoization”策略的实现方式,后一种又比前一种还要再快4倍。相比原始的暴力破解方案则快了三个数量级。
search1()与search2()为什么会有4倍的速度差异呢?
search1()将到目前visited为止的算珠移动次数通过函数接口传递,然后在到达目标(即所有数都运算完的状态)进行最小算珠移动次数判断,并且以{visited, moves}作为状态表示用于memo记忆。而Search2()则只计算从visited往后的最小算珠移动次数,因此只需要基于visited进行memo记忆。
很显然,search1()所需要考虑的{visited, moves}所表示的状态数会远远大于search2()的仅由visited所表示的状态数。或许这就是两者运算速度相差4倍的原因吧。
本系列总目录参见:程序员的算法趣题:详细分析和Python全解