草庐IT

[OpenJudge 186/洛谷 P1949/NOI 2001] 聪明的打字员〔搜索〕

OSpect 2023-03-28 原文

题目链接:OpenJudge - 1184:聪明的打字员

题目

总时间限制: 5000ms 内存限制: 65536kB
描述
阿兰是某机密部门的打字员,她现在接到一个任务:需要在一天之内输入几百个长度固定为6的密码。当然,她希望输入的过程中敲击键盘的总次数越少越好。
不幸的是,出于保密的需要,该部门用于输入密码的键盘是特殊设计的,键盘上没有数字键,而只有以下六个键:Swap0, Swap1, Up, Down, Left, Right,为了说明这6个键的作用,我们先定义录入区的6个位置的编号,从左至右依次为1,2,3,4,5,6。下面列出每个键的作用:
Swap0:按Swap0,光标位置不变,将光标所在位置的数字与录入区的1号位置的数字(左起第一个数字)交换。如果光标已经处在录入区的1号位置,则按Swap0键之后,录入区的数字不变;
Swap1:按Swap1,光标位置不变,将光标所在位置的数字与录入区的6号位置的数字(左起第六个数字)交换。如果光标已经处在录入区的6号位置,则按Swap1键之后,录入区的数字不变;
Up:按Up,光标位置不变,将光标所在位置的数字加1(除非该数字是9)。例如,如果光标所在位置的数字为2,按Up之后,该处的数字变为3;如果该处数字为9,则按Up之后,数字不变,光标位置也不变;
Down:按Down,光标位置不变,将光标所在位置的数字减1(除非该数字是0),如果该处数字为0,则按Down之后,数字不变,光标位置也不变;
Left:按Left,光标左移一个位置,如果光标已经在录入区的1号位置(左起第一个位置)上,则光标不动;
Right:按Right,光标右移一个位置,如果光标已经在录入区的6号位置(左起第六个位置)上,则光标不动。
当然,为了使这样的键盘发挥作用,每次录入密码之前,录入区总会随机出现一个长度为6的初始密码,而且光标固定出现在1号位置上。当巧妙地使用上述六个特殊键之后,可以得到目标密码,这时光标允许停在任何一个位置。
现在,阿兰需要你的帮助,编写一个程序,求出录入一个密码需要的最少的击键次数。
输入
仅一行,含有两个长度为6的数,前者为初始密码,后者为目标密码,两个密码之间用一个空格隔开。
输出
仅一行,含有一个正整数,为最少需要的击键次数。
样例输入

123456 654321

样例输出

11

来源
Noi 01



思路

第一眼:不像是太难的问题,但在 OpenJudge 有半数的五星难度评分,洛谷上还是蓝色 tag.

开始着手:有点像动规,看看样例找思路吧——这是什么鬼样例输入?我怎么无论如何也没法 步搞出 呢.

怎么办呢——难以偷懒解决的小数据量问题,只好枚举吧.


这是一道搜索好题,也很有难度.

我的解法是一种优化了多次的、有点玄的 IDA* 做法(“玄”是因为它的正确性论述有一部分较琐碎,没想到严格的形式化证明).

此题的可行状态数太多,搜索树会非常深,我因而考虑用 IDA*.

什么是 IDA* 呢?是一种启发式搜索算法——用估值函数剪枝的迭代加深搜索算法.

什么是迭代加深搜索呢?这是一种设置了递归深度上限的回溯搜索,采用了逐个试验的逻辑寻找最优解(最小的可能深度). 比如假设最小可能深度为 ,那么递归时任意分支只要深度超过 就剪掉;如果这样没有找到合法解,说明 不对,再假定 为答案并重新搜,直到找到解.

什么是估值函数呢?这是指在每个状态都估算一下当前状态离目标状态最少还需要几层递归的函数. 很明显,如果 当前深度+估算的值 超过了设定的上限,那么当前的分支也可以直接剪掉. 稍后我们可以发现,估值函数可以起到巨大的优化效果.

因此,我们要做以下两件事:设计搜索算法的逻辑、设计估值函数.


先看看怎么搜索.

主干逻辑是明显的. 此题的一个状态就是当前的 位密码以及光标位置,状态间的转移就是一次操作.

但可以想到,每次递归中可以做的剪枝很多,有以下两类:

  1. 明显的浪费:如光标在最左边时左移、值为 时增加值等.
  2. 不必要的修改:可以想象一个位置的值如果此时和目标值一样,就不必做改值/交换操作.

(网络上有剪枝 BFS 题解称左右移操作也可以剪枝,但我测试得到错误答案,没搞懂此题 BFS 的优化逻辑.)


这道题最好玩的是估值函数的设计. 对这个函数的不断优化加深了我对 IDA* 算法的理解.

本题中,估值函数的定义是从一个 位密码 变化到另一个密码 需要的操作数的一个下界. 此外,这个估值效果必须很紧,不然估不出 这种 层递归就直接爆炸.

我们把上述的操作数叫做成本,然后把光标移动耗费的叫做移动成本,其它的叫修改成本. 很明显如果 位不匹配,那么 是移动成本的下界. 关键在于修改成本,我先后使用了三个方式来估计它.

方法一:计算 数字和 的数字和之差的绝对值 .

其道理是显然的:一次改值操作最多让这个差减小 ,而交换操作不影响这个差,而我们希望差最终为 ,所以 是正确的下界.

但第一种估计显然是不够用的. 如 的数字和之差为 但修改成本显然比 大得多. 稍加观察,我引入第二种估值:

方法二:计算两个密码的直径,其定义为密码的最大三位之和减去最小三位之和,并求出直径之差的绝对值 .

这个方法完美解决了 / 这种情况,因为它反映了两个状态间数据分布的区别.

可以类似第一种估值证明第二种估值是正确的.

验证发现,这两种估计仍然不够用,例如 这种情况下它会炸掉:修改成本应该在 左右,但是最好的第一种方案估出来也是 ,并不理想.

我发挥了点奇思妙想,使用了比较别致的第三种估计:

方法三:先把两个密码升序排序,然后对于 里每一位,如果它 里出现,就求它和排序后 对应项的差的绝对值,结果相加得 .

我的动机是,/ 之所以出状况,就是因为 没有被正确处理,它应该跟 差的绝对值,而不是像第一种方案那样直接减掉 本身.

即:这种没有出现在 里的数应该拿来求差的绝对值而不是差;而出现在 里的数,由于可能有交换操作,交换后可能不需要任何其它修改,故不纳入考虑.

但另一方面,我们也不能直接对应地计算 ,因为此题存在神奇的交换操作, 如果能和 交换就会出现更优的 .

这就是我想出这种折衷方案的理由:让比较大的数和比较大的数求差的绝对值,那么一种不错的路子就是对排序后的两个密码里的对应项求差的绝对值.

这样得到的 是不是正确的下界呢?不敢说一定是. 但做了一些数学推理后发现,这个估计已经很精了,我有把握说这几乎就是下界. 就算存在 Hack 数据,我们算法的近似度应该也很高.

于是,如果 都是修改成本的下界,我们直接取 再加上移动成本的下界 就完成了估值函数的计算.


就这样,我们的 IDA* 算法设计完了. 代码如下.

#include <bits/stdc++.h>

#define IOS_SPEED std::ios::sync_with_stdio(false)

using std::cin, std::cout;
using std::array;
using std::swap, std::sort, std::abs, std::lower_bound, std::max;

constexpr int len = 6, upper = 9;
array<int, len> code, target, target_o; // xxx_o := 排序后的 xxx
int pos = 0;
int sum_target = 0, diff_target = 0; // sum_xxx := xxx 的数字和, diff_xxx := xxx 的直径

inline int rest_lowerbound(){ // 估值函数
    int answer = 0;
    for(int i=0; i<len; ++i){
        if(code[i]!=target[i]) ++ answer;
    }
    int sum_code = 0, diff_code = 0, dist_sum = 0;
    auto code_o = code;
    sort(code_o.begin(), code_o.end());
    for(int i=0; i<len; ++i){
        sum_code += code_o[i]; diff_code += (i<len/2)? (-code_o[i]): code_o[i];
        auto loc = lower_bound(target_o.begin(), target_o.end(), code_o[i]);
        if(loc==target_o.end()||*loc!=code_o[i])
            dist_sum += abs(code_o[i]-target_o[i]);
    }
    answer += max(max(abs(sum_target-sum_code), abs(diff_target-diff_code)), dist_sum);
    -- answer; if(answer<0) answer = 0;
    return answer;
} // abs(sum_target-sum_code) 即 C_1, abs(diff_target-diff_code) 即 C_2, dist_sum 即 C_3

bool enumerate(int depth, int stop){
    if(depth+rest_lowerbound()>stop) return false;
    if(code==target) return true;
    if(pos>0){
        -- pos; if(enumerate(depth+1, stop)) return true; ++ pos;
    }
    if(pos<len-1){
        ++ pos; if(enumerate(depth+1, stop)) return true; -- pos;
    }
    if(code[pos]>0&&code[pos]!=target[pos]){ // 剪枝
        -- code[pos]; if(enumerate(depth+1, stop)) return true; ++ code[pos];
    }
    if(code[pos]<upper&&code[pos]!=target[pos]){ // 剪枝
        ++ code[pos]; if(enumerate(depth+1, stop)) return true; -- code[pos];
    }
    if(pos>0&&code[pos]!=target[pos]){ // 剪枝
        swap(code[pos], code[0]); if(enumerate(depth+1, stop)) return true; swap(code[pos], code[0]);
    }
    if(pos<len-1&&code[pos]!=target[pos]){ // 剪枝
        swap(code[pos], code[len-1]); if(enumerate(depth+1, stop)) return true; swap(code[pos], code[len-1]);
    }
    return false;
}

int min_presses(){
    target_o = target;
    sort(target_o.begin(), target_o.end());
    for(int i=0; i<len; ++i){
        sum_target += target_o[i]; diff_target += (i<len/2)? (-target_o[i]): target_o[i];
    }
    int attempt = rest_lowerbound();
    while(!enumerate(0, attempt)) ++ attempt;
    return attempt;
}

void interface(){
    IOS_SPEED;
    char new_bit;
    for(int i=0; i<len; ++i){cin >> new_bit; code[i] = new_bit-'0';}
    for(int i=0; i<len; ++i){cin >> new_bit; target[i] = new_bit-'0';}
    cout << min_presses() << "\n"; 
}

int main()
{
    interface();
    return 0;
}

效果拔群!OpenJudge 上用时 4ms(原先没有引入 估计方式时超过 2500 ms);洛谷上以 61ms 成为最快解.

这归功于 rest_lowerbound() 已经对答案给出了很好的估计.


从这个故事中,我们有什么 takeaway 呢?

  1. 对大小关系的敏感对于做优化问题极有帮助.
  2. 搜索是一类很有技术性的算法.
  3. 同一个问题值得反复打磨.
  4. 要敢于尝试一些怪点子.
  5. 这个打字员确实很聪明.

有关[OpenJudge 186/洛谷 P1949/NOI 2001] 聪明的打字员〔搜索〕的更多相关文章

  1. ruby - 为什么 split (' ' ) 试图变得(太)聪明? - 2

    我刚刚发现String#split有以下奇怪的行为:"a\tbc\nd".split=>["a","b","c","d"]"a\tbc\nd".split('')=>["a","b","c","d"]"a\tbc\nd".split(//)=>["a\tb","c\nd"]Thesource(来自2.0.0的string.c)超过200行,包含这样一段话:/*L5909*/elseif(rb_enc_asciicompat(enc2)==1){if(RSTRING_LEN(spat)==1&&RSTRING_PTR(spat)[0]==''){split_type=awk;}}后来,在

  2. ruby - 如何使用 Ruby Duck 打字 - 2

    我正在学习Ruby,我遇到了一个关于打字的主要概念问题。请允许我详细说明为什么我不理解范式。假设我像您在Ruby中一样为简洁的代码进行方法链接。我必须准确地知道链中每个方法调用的返回类型是什么,否则我无法知道下一个链接上有哪些方法可用。难道我每次都要检查方法文档吗??我遇到了这个不断运行的教程练习。似乎我陷入了引用、推断、运行、失败、修复、重复以使代码运行的过程,而不是准确地知道我在编码过程中使用的是什么。这与Ruby的直观性promise背道而驰。假设我正在使用第三方库,我再次需要知道允许哪些类型传递参数,否则我会失败。我可以查看代码,但可能有也可能没有任何关于该方法期望的类型的注释

  3. javascript - 在 Protractor 中模拟慢打字 - 2

    sendKeys()方法将一次发送所有key(实际上,一次一个,但速度非常快):varelm=element(by.id("myinput"));elm.sendKeys("test");有没有办法放慢输入速度,以便Protractor一次发送一个字符,每个字符之间有一个小的延迟?我们可以slowdownProtractorentirely,但这不会改变sendKeys()的工作方式,而且它还会减慢一切,而我们只需要“发送key”部分并且仅在特定情况下。 最佳答案 想法是使用browser.actions()并构建一系列“发送键”命

  4. Javascript 打字效果 - 2

    该问题与lasttime出自同一问题.我的网站运行静态域,因此我希望能够在每个网站上使用此脚本而无需制作重复副本。它用作键入文本效果,我希望能够定义它从网页本身而不是脚本打印出的文本。Javascriptvarindex=0;vartext='Text';functiontype(){document.getElementById('screen').innerHTML+=text.charAt(index);index+=1;vart=setTimeout('type()',100);}我试过摆弄代码并使用与我之前的帖子相同的方法,但我似乎无法让它工作。

  5. javascript - 如何在考虑 html 标签规则的 JavaScript 中创建打字机效果? - 2

    假设我有一些来自div标签的文本,如下所示:Thisissomecoolcontent...现在,如果我愿意,我可以创建一个JavaScript函数,一次打印一个字符,它会工作得很好。示例如下。functionprintSentence(inner,outer,index,speed){varinput=document.getElementById(inner).innerHTML;vartimer=setInterval(function(){document.getElementById(outer).innerHTML+=input.charAt(index);index++;

  6. javascript - 在javascript中模拟打字的外观,而不是实际的按键 - 2

    我正在尝试编写一个简单的函数,让它看起来好像有人在textarea中输入--这是我的函数(如果它很糟糕,请原谅我,但我通常不使用javascript)---console.log()部分工作正常,但出于某种原因,我无法让此脚本按照我期望的方式更新dom...functiontype(string){value="";el=document.getElementById("typeArea");for(vari=0;itextarea").val(value);el.textContent=value;console.log(value);sleep(160);}sleep(2000);

  7. javascript - 基于上下文的 getElementById 比原生 getElementById 慢 1000 倍。像 sizzle 这样的选择器引擎使用更聪明的策略吗? - 2

    在将htmlblock插入dom之前,我对在dom外构建htmlblock很感兴趣,因此我使用dynatrace进行了一些测试。我使用了bobince的方法:IsthereanywaytofindanelementinadocumentFragment?我发现它慢了将近1000倍(在IE7中),这让我很惊讶。由于功能非常基础,我想知道sizzle等引擎使用的策略。我想知道是否有一些更有效的方法来进行基于上下文的节点选择? 最佳答案 框架选择器引擎通常是右手优先评估的,所以我希望上下文ID选择器document.getElementB

  8. javascript - 如何使用 Javascript 或 jQuery 库显示打字速度? - 2

    我想在联系表单中使用的文本区域正下方添加一个打字速度指示器。这只是为了好玩,并让用户在填写表单时与页面进行一些互动。它应该在打字时显示平均速度,并在击键空闲时保持最后的平均速度。当他们离开文本区域时,最后的平均值应该保持不变。理想情况下,我希望有一个jQuery插件(如果可用的话)。[编辑]这最初只是为了我的几个网站。但是在我发布问题之后,我突然想到这对于SO来说是一个很好的功能。如果您同意votehere 最佳答案 这是一个经过测试的实现,看起来不错,但我不保证数学。演示:http://jsfiddle.net/iaezzy/pL

  9. javascript - d3.scale.category20 对我来说太聪明了 - 2

    任何人都可以向我解释为什么这两个表达式返回不同的值...log1.text(c20(1));//"#aec7e8"log2.text(d3.scale.category20()(1));//"#1f77b4"...在以下上下文中工作示例...varc20=d3.scale.category20(),col=d3.range(20).map(function(c){returnc20(c).replace("#","0x")}),log1=d3.select("#log1"),log2=d3.select("#log2");log1.text(c20(1));//"#aec7e8"log

  10. javascript - (不是这样)聪明的 key 导致 Node JS 中的 SHA512 Hmac 出现问题 - 2

    这是一个古怪的问题,但我已经为此工作了几个小时,但没有取得太大进展。我希望这里有人可以提供建议...我正在将脚本从php移植到Node。php脚本使用了这个函数:hash_hmac('sha512',$text,$key);我已经使用加密模块在Node中复制了这个:varhash=crypto.createHmac("sha512",key);hash.update(text);returnhash.digest("hex");我已经验证,在给定相同的文本和key时,这些函数会产生相同的哈希值。除了...在php中用作键的字符串看起来类似于:(不要问)define("SITE_KEY"

随机推荐