[TDD磕算法]排排队,吃果果(三)优化效率

本文是[TDD磕算法] 我为什么尝试用TDD解算法题系列的一篇。
前情提要见

题目

考虑到可能有人没看前两篇,这里把题目再描述一次。

假定一队人排成一列
每个人向前看,如果看到前面有1个比自己高、或者和自己同样高的人。就举个牌子报数1。同理如果有3个,就报3。
如果把每个人的身高和报数列出来,就形成一个二元组的队列,比如:
[(172, 0), (160, 1), (182, 0), (170, 2)]
就表示第一个人172cm,前面没有高于或等于他高度的人;第二个160cm,前面有一个;……

这道题目是,给定打乱顺序的一个序列,把他们排回应有的顺序。
比如:
输入:[ (7,2), (4,3), (8,0), (7,1), (6,0)]
输出:[ (6,0), (8,0), (7,1), (4,3), (7,2)]

性能分析

leetcode上的题目不但会测试功能是否正确,还会特意准备几个比较大的测试数据来测试性能。虽然不算是非常严谨的性能检验,但足以说明时间复杂度上的级别。
前一篇虽然解出了题目,但是那段程序的性能是很差的。差到什么程度呢?
在所有的JavaScrip提交中,击败了0%。也就是说没有比它更慢的解法了。我很惊讶竟然没有被判超时。

简单分析一下慢在哪里。
这个解用文字来描述的话,就是把这些人按照前面更高人的个数排列。先从前面有0个人的开始,加入新队列,然后是前面有1个的,然后前面2个人的,以此类推……
每次加人的时候,从头开始数队列里不低于他的人数,直到比他应有的前面人数大,然后在数到的这个人前面加入。

下面用[ (7,2),(2,0),(4,3),(3,2),(8,0),(7,1),(6,0) ]作为例子展示加入的过程,| 表示插入位置。

(2,0) => [ |]
(8,0) => [ (2,0) |]
(6,0) => [ (2,0), |(8,0) ]
(7,1) => [ (2,0), (6,0), (8,0) |]
(7,2) => [ (2,0), (6,0), (8,0), (7,1) |]
(3,2) => [ (2,0), (6,0), (8,0), |(7,1), (7,2)]
(4,3) => [ (2,0), (6,0), (8,0), (3,2), (7,1), |(7,2)]
         [ (2,0), (6,0), (8,0), (3,2), (7,1), (4,3), (7,2)]

可以看出时间耗费有两个方面:

  1. 每次向队列里加人的时候,都需要遍历队列里已有的人。这应该是O(n2)的复杂度。我算法基础不好,如果算错了还望指正。
  2. 重排之后的队列长度其实是已知的,但是这段程序不知道。不断插入新元素会造成数组容量调整,以及插入点之后所有已有元素更新位置。

有一个显而易见的优化,找插入点时不需要数到[x,n]的n+1位置,只要能保证插入的顺序,数够n个人就可以了。
经过验证,这个改变可以提高一些性能,但仍然慢于大部分的解。因为它只是让每次遍历查找变快了一些,但是遍历的次数没变,还是原来的时间复杂度。
要想有更好的性能,需要另起炉灶实现这个调整顺序的过程。

优化解求解过程

根据前面的分析,要让整个过程更快,需要避免每次遍历队列中的人。此外,最好一次就把人加入最终的位置,不要每次加人都要调整很多人的位置。
根据这两点和前两次尝试的经验。大体思路如下:

  • 首先,按照高度从低往高逐个加入。这样就可以保证已经加入队列的人比将要加入的人低。
  • 其次,根据被加入者前面更高的人的个数,调整加入队列的位置,要空出几个位置给后面更高的人。
    具体怎么做到还没有想的非常清楚,准备在做的过程中逐步搞清楚。
打磨

实现全过程见:http://cyber-dojo.org/review/show/8922CE128B?avatar=toucan&was_tag=1&now_tag=1

第一个和第二个测试与上次练习一致。单个人以及两人低的在前。这里略过。

第三个测试

由上次的经验可知,关键的区别是排在前面更高的人数。而队列总人数不是最重要的。
因此这个测试选择:三个人,最低的排在中间。引入有1个人需要向后调整1位的变化。

reconstruct_should_be([[8,0],[2,1],[3,0]], [[3,0],[2,1],[8,0]]);

仍然先用硬编码通过测试。

  if (sorted[0][1] == 1) {
    return [[3,0],[2,1],[8,0]];
  }

之后和上次练习类似,通过大段的重构(17步)建立处理[x,1]的逻辑。过程这里略过。
最终代码如下。

function reorder(sorted) {
  let result = [];
  let current = 0;
  let toBeFilled = 0;
  for (let i = 0; i < sorted.length; i ++) {
    if (toBeFilled > 0) {
      let inFrontIndex = current - toBeFilled - 1;
      result[inFrontIndex] = sorted[i];
      toBeFilled--;
    } else {
      toBeFilled = sorted[i][IN_FRONT];
      if (toBeFilled > 0) {
        current += toBeFilled;
      }
      result[current++] = sorted[i];
    }
  }
  return result;
}

大体的思路是如果是[x,0]的情况,顺序加入新的数组即可。
如果碰到[x,1]的时候,就加入当前位置的下一个位置,并记下有1个位置需要后面的人填进去。即代码中的else分支。
在加入下一个人的时候,如果发现有空位需要补充,就把他加入前面空出的位置。即if分支。

第四个测试

三个人,最高的在前,后面两人按高低排序。

reconstruct_should_be([[3,1],[8,0],[2,1]], [[8,0],[2,1],[3,1]]);

运行测试,[2,1]空出一个位置,但是后面的[3,1]错误的补充到了第一个位置。所以if分支需要补充。

实现代码
这时候忽然发现并不很容易通过测试,又一次感觉到了第一次失败尝试时代码结构与测试方向不匹配的感觉。
仔细思考了一下,发现是前一段重构时写了过多的代码。当时的测试只有[x,1]的例子,也就是说每次最多有一个位置需要留在后面补充。而代码里用了一个整数toBeFilled去记录需要补填的个数,因为猜想要处理多个位置需要补填的情况。而这个逻辑是没有经过测试检验的。
因此,通过重构改为用一个变量记录这一个预留的位置。去掉多个位置的逻辑。

  let toBeFilled = null;
  for (let p of sorted) {
    if (toBeFilled !== null && p[IN_FRONT] === 0) {
      result[toBeFilled] = p;
      toBeFilled = null;
    } else {
      if (toBeFilled === null && p[IN_FRONT] === 1 ) {
          toBeFilled = current;
          current += p[IN_FRONT];
      }
      result[current++] = p;
    }
  }
第五个测试

这一步犹豫了一下。
首先试试了[4,0],[2,1],[8,0],[5,1]的序列,也就是两个[x,1]不排在一起的情况。发现现有的逻辑已经支持。
后面本想增加[x,2]的测试,但是转念一想,觉得还是先测试两人身高相等的情况步子更小一些。

三个人,两个高度一样的人在前面,最高的一个在最后。

reconstruct_should_be([[3,0],[8,0],[3,1]], [[3,0],[3,1],[8,0]]);

实现代码
需要让[3,1][3,0]先加入队列,这样它预留的空位就会被[3,0]填补。否则按照代码逻辑就会变成后面的[8,0]填入[3,1]前面的位置了。
因此修改一开始对数组排序的比较方法。改为先按高度排序,如果高度相等按照前面人数倒序排列。

let sorted = peoples.sort(compare_in_height_and_reverse_infront);
...
function compare_in_height_and_reverse_infront(p1, p2) {
  if (p1[HEIGHT]===p2[HEIGHT]) {
    return p2[IN_FRONT] - p1[IN_FRONT];
  } else { 
    return p1[HEIGHT] - p2[HEIGHT];
  }
}
第六个测试

增加前面有两个人的例子。
三个人一队,最低的在最后,前面两人按高低排列。

reconstruct_should_be([[2,2],[8,0],[3,0]], [[3,0],[8,0],[2,2]]);

实现代码
现在可能要记录2个预留的位置,把toBeFilled改为数组。

  let toBeFilled = [];
  for (let p of sorted) {
    if (toBeFilled.length > 0 && p[IN_FRONT] === 0) {
      let fillIndex = toBeFilled.pop();
      result[fillIndex] = p;
    } else {
      if (toBeFilled.length === 0 && p[IN_FRONT] === 1 ) {
          toBeFilled = [current];
          current += p[IN_FRONT];
      }
      if (toBeFilled.length === 0 && p[IN_FRONT] === 2 ) {
          toBeFilled = [current+1, current];
          current += p[IN_FRONT];
      }
      result[current++] = p;
    }
  }

之后进行重构,统一[x,1][x,2]两个分支的代码。

...
    } else {
      if (toBeFilled.length === 0 && p[IN_FRONT] > 0 ) {
          toBeFilled = reverseRange(current, p[IN_FRONT]);
          current += p[IN_FRONT];
      }
      result[current++] = p;
    }

其中reverseRange返回以当前index为最后一个元素的倒序数组,比如reverseRange(3,1) === [3]reverseRange(3,2) === [4,3]
之所以要倒序是因为我觉得在填充预留位置时,从数组尾部删除元素开销要小一些。
这个函数的实现很简单就不贴了。

在第七个失败测试前又增加了一个测试两个[x,2]连在一起的情况,发现和前面处理的连续[x,1]的情况一样,测试直接成功。

第七个测试
reconstruct_should_be([[2,2],[8,0],[4,2],[3,0],[9,0]], 
                      [[3,0],[8,0],[2,2],[9,0],[4,2]]); 

这个测试区别于前面的特点是,当加入[2,2]时预留了2个空位。null, null, [2,2]
之后[3,0]填充了一个。[3,0],null,[2,2]
下一个是[4,2],这时除了[2,2]前面剩余的空位之外,还需要再留出一个空位。[3,0],null,[2,2],null,[4,2]

实现代码
还是先增加特殊情况分支,在加入[x,2]时如果当前预留位置只有1个了,就把当前位置补入预留位置。

      if (toBeFilled.length === 0 && p[IN_FRONT] > 0 ) {
      ... }
      if (toBeFilled.length === 1 && p[IN_FRONT] === 2) {
        toBeFilled.unshift(current);
        current += 1;   
      }

重构,统一两种加入预留位置的分支。

      let skip = p[IN_FRONT] - toBeFilled.length;
      if (skip > 0) {
        toBeFilled = reverseRange(current, skip).concat(toBeFilled);
        current += skip;
      }
      result[current++] = p;
    }
第八个测试

增加有[x,2]又有[x,1]的情况

reconstruct_should_be([[2,2],[8,0],[3,1]], [[8,0],[3,1],[2,2]]);

实现代码,先硬编码处理,[2,2]预留了1,2个位置时,[3,1]应该选择第2个而非第1个位置的逻辑。

    if (toBeFilled.length > 0 && p[IN_FRONT] === 0) {
      let fillIndex = toBeFilled.pop();
      result[fillIndex] = p;
    } else if (toBeFilled.length === 2 && p[IN_FRONT] === 1){
      let fillIndex = toBeFilled.shift();
      result[fillIndex] = p;      
    } else { ...

重构,统一两种填充预留位置的分支。

    if (toBeFilled.length > p[IN_FRONT]) {
      let skipInFill = -1 - p[IN_FRONT];
      let fillIndex = toBeFilled.splice(skipInFill, 1)[0];
      result[fillIndex] = p;
    } else { ...
重构,简化程序

我本以为在填充预留位置的时候,也需要像产生预留位置的分支一样处理更多的情况。
结果在增加了一些测试后,才确定原来已经完成了。

这时后可以推理,对于任何一个队列,比如
[8,0],[3,1],[2,2]
我都可以在它后面加一个0高度的元素,变成
[8,0],[3,1],[2,2],[0,3]
那么程序在排列的时候,除了额外增加的最小的[0,3]外,其余元素都走的是填充预留位置的逻辑,同样可以排出正确顺序。也就是说else产生预留位置的分支是冗余的。

重构后调整顺序函数变为

function reorder(sorted) {
  let result = [];
  let toBeFilled = range(sorted.length);
  for (let p of sorted) {
    result[popAt(toBeFilled, p[IN_FRONT])] = p;
  }
  return result;
}

这个重构会稍稍增加空间复杂度和操作toBeFilled数组的开销。但是比起对程序逻辑的简化来说是非常值得的。
与此类似,我也把toBeFilled数组由倒序改为了正序,性能变化不大,但是容易理解多了。

后续还可以做一个重构,把toBeFilled包装成一个类。提供popAt(n)函数。效果如下。

reserved.popAt(0) //0
reserved.popAt(0) //1
reserved.popAt(2) //4
reserved.popAt(0) //2
reserved.popAt(0) //3
reserved.popAt(0) //5

在类的内部可以不使用数组,而是一些内部状态量来产生对应序列。这样使用的数组的性能损失也可以补回来了。
不过这个已经和主线关系不大,这次练习就此打住了。

这次的解法耗时缩短为原来的1/3。在leetcode上超过了66%的提交,终于属于比较正常的解了。

体会

  • 尽管实现代码完全重写。第二次练习总结的测试顺序,在这次仍然很有帮助。
  • 硬编码快速通过虽然看起来很傻,在思路不太清楚的时候还是有效的。很容易启发后续的重构。
  • 测试顺序是否有助于程序演进。其实可以从硬编码这一步体现出来。良好的测试步骤,当前测试对问题的特殊简化会出现在硬编码中。
    • 比如先处理[x,1],然后[x,2]。1和2可能就会在硬编码里直接写出。虽然之后可能会成为变量,最终一般化。但是在增加程序逻辑的这个时刻,单一的硬编码分支表明影响局部化在程序的一小部分中。这就确保了整个问题可以逐步分解为小的问题解决
    • 而对照第一次的失败的测试过程,虽然从短到长逐一列举用例看起来也是小步进行的。但是在处理2人队列的测试时,代码里并没出现队列长度为2的信息。说明这个限制对分解出一个较简单的子问题并无帮助
  • 进展良好时还会有个感受,不怕打断。
    大家都觉得心流(Flow)状态是非常好的工作状态,程序员就应该心无杂念钻入代码中一次几个小时。比如这幅漫画。
为什么不要打断工作中的程序员

但是换个角度来看,其实漫画中的这段程序的结构是很烂的,相互耦合严重,每个点都与其它部分大量的细节相关。导致这个程序员必须把大量的细节装入脑子的“工作内存”中才能开始编码,这其实是对脑力的浪费。

在第三次练习的过程中,由于空余时间有限,周期拖的很长。常常每次做完一步就放下了,要一天或几天之后才能继续。
每次结束时我都留下一个失败的测试,下一回根据错误信息来考虑如何继续进行。结果发现,不需要特别费力就跟上了上次的进度。说明每一步可以集中在一个点进行,不需要那么大的“工作内存”了。
而分解不顺利的时候则是完全停不下来的心态。比如第一次,我做到一步卡住了很久,当天就放下了。结果第二天怎么也接不上思路了。就这么在这一步中止了。
可见,如果编写过程中感觉心急,停不下来。可能有必要留意一下是不是解决思路有需要改进的地方了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容