炼金术(6): 可迭代的模型和用例

算法笔记(1):【通过测试用例的累积和回归测试导向算法的正确】

有一次,我做了一道算法题:https://leetcode.com/problems/regular-expression-matching/

最终的代码,我用JavaScript写了300多行代码,通过的时候我本地的测试用例有50几个。这个题目我做了挺久的,但是我从中获得了很多启发。

当然,算法本身所代表的DFA/NFA,以及模式匹配等知识是其中的一个方面。例如,在做完了这题之后,我去搜了下应该怎样从正则表达式直接画出对应的DFA,这个链接: drawing-minmal-dfa-for-the-given-regular-expression 教会你如何画出一个完备的状态机。

  1. 首先找到从初始状态(init state)到最终状态(final state)的最小转换路径;
  2. 其次考虑从final state遇到新的输入后,应该到哪个状态;
  3. 最后,考虑已知状态遇到其他剩余的输入后,应该转换到哪个状态。

这点其实不新鲜,你通过解决一个问题,获得了这个问题背后的知识。而另一方面,我的编程过程很有意思,一开始就几个case,然后每次我觉得可以了,跑下又又一个新case 失败,于是我就把新case加入。然后修改的代码要把旧case和新case都通过。但是解决新case的时候,我会把旧case都注释掉,先解决新case。过了后再把旧case都打开,跑下旧case会有一些被破坏,失败,我就要再修复。都过了后再提交。这样多了几十轮后,最终50几个case都过了,也AC了。

你不觉的这个过程很符合科学研究的原理么?

不断根据失败的新case修正已有的模型,但是已有的模型都属于被发现了的case集合,每次修正的模型,都不是真正正确的,但是呢,每次修正的模型可以保证cover住所有旧case集合+1个新case。新的模型只会比旧模型更正确,但是旧模型在所有旧case集合上是“绝对正确”的。最终,完全正确的模型被迭代出来。

我真正惊喜的不是最后的AC,而是在过程中不断体会这点,我在过程中一点都不着急,因为发现这种迭代方式,一定会导向最后的AC。所以当我发现这点后,就有了“信心”。

这种信心是建立这个基础之上的:“新的模型只会比旧模型更正确,但是旧模型在所有旧case集合上是“绝对正确”的。

算法笔记(2):【算法题中数组操作如何减少内存占用并加速】

做了一个中等难度的题,1次AC,但是一开始我的内存占用和速度都只有10%左右。优化了几个版本后终于上去了。我发现,算法没问题!就是一些数组的基本内存分配和拼接的地方死命的抠空间占用:

  • 最小化数组新建的地方。
  • 消灭所有实际上不需要拷贝的地方,例如通过传入start、end,而不是新建一个在子数组,这个数组实际上是只读的,只需知道当前可用的start、end即可。
  • 如果一个数组虽然会被新增元素,但是新增的元素用不到,不需要把他们删除,同样只需知道当前可用的start、end即可。end之外的是【脏】区域
  • 同上,如果数组需要在尾巴新增元素,只需判断end之后是否有【脏】区域,有的话直接覆盖写入,没有才新增。

这样,消除了内存占用,也增加了速度。这题,主要是我不相信算法有问题,抠了下内存占用,果然如此,不然我会怀疑居然别人还能有更好的算法...,我认为算法就是思路,思路如果有问题,那我就很好奇。但是如果只是优化的问题,我就放心了。

算法笔记(3):【拆开经典算法的结构,利用其性质】

做了一道Hard难度的题目,我做了三个阶段:

  1. 一开始想复杂了,试图通过O(1)的bit位内存占用,一遍遍历搞定,在最简单的有序数组的几个case下通过,提交失败,开始绞尽脑汁想太多了,怎么都绕不过去。我就去洗碗了。
  2. 洗碗的时候,突然觉的好傻,数据是无序的,可以排序啊。于是简单排序后,一遍遍历就搞定了。
  3. 但是速度上不去,内存占用也不少,开始思考:我已经只有一遍遍历了,题目要求的是O(n)的,那那里不对呢?肯定是出在JavaScript的那个排序上。我开始思考,排序算法不就超过O(n)了么?开始翻阅了下各种排序算法的时间复杂度,最多只有快排和堆排序是O(n*log(n)的。

那么,我就想,我做了两个动作:

  1. 排序
  2. 遍历

排序的过程中已经有了遍历,如果我手写排序,应该是可以把自己的遍历去掉的。从速度来说,快排和堆排是比较快的,肯定要从这两个来写。

看了下快速排序,我并不能将我的目标子结构嵌入到快速排序里。再思考堆排序,堆排序有保持堆顶是最小或者最大元素的能力。这符合题目的目标方向。

于是手写堆排序,写着写着,我发现我不需要【完整】地把堆排序的过程做完,就可以把我的目标子结构嵌入到堆排序算法里。

这样,我【利用】了【堆排序的一半过程】,达成了目标。

可见,这类算法题的设计应该就是【拆开经典排序、搜索算法】,灵活使用这些算法内部结构的【性质】,来达到具体算法问题的目标。

结果我挺满意的。拆开盒子,没有魔法。

算法笔记(4):【计算机里回答Yes/No,只需一个bit】

做了几个数独问题。我发现,数独问题的基本套路就是要确认一个单元格里可选的能填入什么数字。有这三种限制:

  • 同一行不能有重复数字
  • 同一列不能有重复数字
  • 同一个3x3子矩阵里不能有重复数字

这是一个什么问题呢?这是一个【Yes or No】的判定问题。你可以用一个很大的数组去保存同行、同列、同子矩阵里已有的数字,然后通过是否包含去做判断。但是,记住在【计算机里判断Yes/No只需一个bit】,那么判断长度为0-9的Yes/No问题。我就只需9个bit。

  • room | (1<<(n-1) 就可以把n标记为已存在
  • room & (1<<(n-1)) 就可以判断n是否已存在
  • room & ( ~ (1<<(n-1) ) ) 就可以把n清除

那么,数独问题,剩下的就是如何【递归】和【回溯】而已。

我们知道,在实际的运用中,布隆过滤器算法,就是通过N个Hash函数,把某个数据D,分别映射到bitmap里的对应pos,设置为1。那么只要同样的过程对D计算一下,如果这N个pos有一个不为1,就可以判断D不存在,如果都是1,则有概率存在,这是因为不同的数据D1,D2,经过N个Hash函数映射,有可能会碰撞,刚好N个pos,都是1。所以要选取和设计合适的Hash函数。

算法笔记(5):【手工函数栈和递归】

有一次我做了一道算法题,有一个是不用乘法、除法、以及求模来计算一个数除以另一个数的问题。我是第一次做这个题,做的并非是最高效的方式。
我发现用while循环的时候,循环执行到某一次之后,条件不满足继续循环下去。我做了一个【重置】动作,使得这个循环能继续下去直到结果算出来。这里我尝试下用文字描述下关键步骤:

let  x,y,z,….. // 一些循环里需要用到的数据
let  i,j,k… // 一些循环迭代的「游标」变量
let stack = []; // 一个用来保存结果的数据栈
while(true){ // 循环开始
	… // 一些对x,y,z的操作,每一次循环都进行某种这些变量(状态)之间的计算
       if(…){
           // 如果满足退出条件,就直接退出,程序执行完毕。
       }else{
           if(…){
		// 如果循环还可以继续进入「下一帧」,
                // 那么就对一些关键的「游标」自增,例如
               i++;
               ...
           }else{
              // 遇到某种困难,x,y,z…之类的状态变的复杂,i,j,k也变的复杂。
              // 此时,对循环进行某个「重置」在重置之前,
              // 把关键的已经计算出来的数据,放到stack里保存起来:
               stack.push(…); 

               // 由于已经保存了循环到目前为止计算的结果数据,
               // i,j,k,x,y,z …都已经不重要了,可以被「重置」:
               
               // 重新初始化「游标」变量
               reset(i,j,k…);

                // x,y,z则可以「除掉」已经计算出来的值,
               // 留下剩余还没计算的部分数据。
               update(x,y,z ….); 

               // 此时,循环会继续进行,因为还没到退出条件。
               // 但是,这个地方等价于循环好像刚开始执行一样,
               // 但是x,y,z已经被悄悄替换掉了。
               // 这跟一个函数的尾递归一样,上一次的计算结果已经被「保存」起来。
               // 在尾递归里,
               // 可以把计算结果传给函数的参数传给下一帧
               // 而这里把计算结果保存到stack里。
           }
      }
}

在这里,我想在【重置】的那个点,函数的【逻辑栈】就存在了。我在这里描述的是在循环里面存在的逻辑上的函数递归。如果while循环里有两种重要分支A/B,A里面的一次重置后循环继续执行可以执行B,B里面的另一次重置后循环继续执行可以执行A。那么有点像函数A和B的互相递归。

当然,实际上会思考这个问题,是缘于一次加入王垠在slack上开的聊天话题,里面有提到尾递归优化和栈的问题。我后面发邮件咨询了下王垠,得到的回复如下:

很像常见的一种“展开递归”的写法,也就是自己维护一个栈,然后其他地方用循环来实现。这本质上还是有一个逻辑栈,仍然是递归。这种一般出现在国内的一些 C 语言教材里,我一般不推荐大家用这种写法,而是用直接的递归。

从代码的可读性和可维护上来说,确实如此。于是我又逐渐不再这么写了,还是能用函数就用函数。不过这种【Push/Pop重置迭代】的思想,却保留在了我的思维里。

算法笔记(6):【最小子结构,递归和回溯】

在算法里,一种基本的思路就是找到一个最小子结构,处理完这个最小子结构后。问题的规模就变小了,对规模变小了的数据集继续【递归】处理就可以最终得到结果。但是这里面隐藏着一些变化点:

  1. 是否只含有一种最小子结构,如果有多种,那就要分别独立处理。对于最小子结构,应该用独立的测试用例,保证最小子结构的正确性。
  2. 对最小子结构处理完了之后,数据集D变成了D‘,那么D’是否在刚处理完最小子结构的局部,再次形成新的最小子结构,如果有,就需要在局部【尽力消除原地立刻产生的新最小子结构,直到没有】,这样问题的规模才缩小了,否则,消除了一个子结构,产生了更多个的新最小子结构,则问题的规模不是变小了,而是变大了。
  3. 是否在一遍处理过程中,产生了错误的结果,说明此遍历路径是一个错误的选择,那么一般要回到最开始路径选择的分岔点,做【回溯】。
  4. 处理边界问题,边界问题不应该是各种【补丁】,而是明确定义的边界情况,控制在最小个数内。

算法笔记(7):【可列可加性】

递归的过程中,有一个重要的假设是:

F( n[0] ) + F(n[1...]) = F(n[0....])

这里的加号,只是一个表征,代表的是一种【复合】操作。那么,上述假设就是:

  • F(a,b,c,d,e) = F(a) op F(b) op F(c)... op F(e)

类似与数学里的【可列可加性】

所以在递归中,要仔细考虑这个假设是否成立,例如一个递归求数组的平均值;

avg(nums, i){
  if(nums.length===0) return 0;
  if(i===nums.length-1) return nums[i]/2;
  return (nums[i]+avg(nums,i+1))/2;
}

就是错的,((A+B)/2 +C )/2 !== (A+B+C)/3

正确的应该是:

avg(nums, i){
  if(nums.length===0) return 0;
  if(i===nums.length-1) return nums[i]/nums.length;
  return nums[i]/nums.length+avg(nums,i+1);
}

也就是:(A+B+C)/n = (A+B)/n + C/n 递归,进去就只有两个地方需要/n:

  • 叶子节点
  • 中间处理节点

对应代码里的两个/n的地方。

--end--

posted @ 2020-03-06 23:09  ffl  阅读(401)  评论(2编辑  收藏  举报