[TDD磕算法] 排排队,吃果果(二)先实现再优化

本文是[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)]

对于失败的反思

在第一次尝试,按部就班却失败了的经历后,我反思了一下这个题目究竟应该怎么解决。是不是真的不适合用TDD的方式做?
感觉在以下方面是可以改进的。

  • 在做的过程中,其实心里一直有种“这道题肯定有很漂亮的解法,说不定试试就试到了”的心态。有这样的心态,有意无意的会避免一些“傻”的写法,而且会在还没有足够用例支持的时候就急于重构出较为一般化的代码结构。然而并不真正匹配问题,反倒给后续演进造成了障碍。
  • 因此首先应该向着逻辑上而言“绝对可以解决”的方案进行,在完全解决前,不要被性能的担忧或者设计偏好所干扰。
  • 另一个问题是,在第一次做的时候其实没有仔细想清楚测试顺序。虽然按一个人、二个人、三个人这样的顺序递增穷尽用例看起来很合情合理,但是由此产生的测试却未必与程序需要处理的子问题同步的。
步骤也许是曲折的,但是一定要通向目标

第二次尝试

全过程见 http://cyber-dojo.org/review/show/8922CE128B?avatar=rabbit&was_tag=1&now_tag=1
第一个测试仍然是以一个人开始,和第一次完全一样,就不详细说了。

第二个测试

两个人一队,低的在前。
这次没有像上次一样选择高的在前,因为这样一次引入了两个变化:队列人数和排在前面人的逻辑。

reconstruct_should_be([[4,0],[3,0]], [[3,0],[4,0]]);

实现代码,按高度排序

return people.sort( (p1,p2) => p1[HEIGHT] - p2[HEIGHT] );
第三个测试

两人,高的在前。引入了前面有一个人的逻辑。

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

实现代码
这次学乖了,刻意先老老实实的写hardcode通过。

  let sortedByHeight = people.sort( (p1,p2) => p1[HEIGHT] - p2[HEIGHT] );
  if (deepEqual([[3,1],[4,0]], sortedByHeight)) {
    return [[4,0],[3,1]];
  }
第三个测试的重构

然后开始考虑这个4在3前面的逻辑是怎么来的,可以假定有个函数能给出每个人应有的位置,那么就按它返回的index插入数组即可。

for(let p of sortedByHeight) {
  result[peoplesBefore(p, sortedByHeight)] = p;
}

这样,就把硬编码挪入了新的函数里。[3,1],[4,0]的时候特殊处理,否则返回在按高度排序数组里的index。

function peoplesBefore(people, peoples) {
  if (deepEqual([[3,1],[4,0]], peoples)) {
    if (deepEqual([4,0], people)) {
      return 0;
    }
    if (deepEqual([3,1], people)) {
      return 1;
    }
  }

  for (let i in peoples) {
    if (deepEqual(peoples[i], people)) {
      return i;
    }
  }
}

进一步分解硬编码,把[3,1]的情况留在硬编码里特殊处理。先想办法统一前面没有更高的人的情况。
再次假定一个函数,它可以数出前面的人数,比如对于[4,0]而言,在[3,0],[4,0]中返回1,而在[3,1],[4,0]中时返回0。

  if (deepEqual([[3,1],[4,0]], peoples)) {
    if (deepEqual([3,1], people)) {
      return 1;
    }
  }
  if (people[PEOPLE_BEFORE] === 0) {
    return shorterPeoplesWhoHasNoTallerBefore(people, peoples);
  }
...
function shorterPeoplesWhoHasNoTallerBefore(people, peoples) {
  let result = 0;
  for (let p of peoples) {
    if (p[HEIGHT] < people[HEIGHT] && p[PEOPLE_BEFORE] === 0) {
      result += 1;
    }
  }
  return result;
}

这时候发现[3,1]的硬编码可以简化为前面更高人数为1。同时,既然已经每次数一遍更矮的人数了,前面的排序就没有必要了。

function reconstructQueue(peoples) {
  let result = [];
  for(let p of peoples) {
    result[peoplesBefore(p, peoples)] = p;
  }
  return result;
}
 
function peoplesBefore(people, peoples) {
  if (people[PEOPLE_BEFORE] === 0) {
    return shorterPeoplesWhoHasNoTallerBefore(people, peoples);
  }
  if (people[PEOPLE_BEFORE] === 1) {
    return 1;
  }
}

经过一大段重构到这个点时,感觉硬编码基本上消除了。添加后续测试。

第四个测试

与第一次不同,我没有按照人数递增穷举所有可能的思路,而是根据当前代码逻辑。进一步扩充有PEOPLE_BEFORE === 1的逻辑分支。

三个人,中等高度的人排在最后。这样因为他的位置不再是1所以会失败。

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

实现代码
发现可以稍作调整就快速通过了。
把原来硬编码的位置1,改为如果没有更高人在前时应在的位置加1。
也就是说本来3排在2后面,现在再后挪一位。

  if (people[PEOPLE_BEFORE] === 1) {
    return shorterPeoplesWhoHasNoTallerBefore(people, peoples) + 1;
  }
第五个测试

继续扩充前面有一个人的逻辑分支。

三个人最低的在中间。

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

这个用例初看起来和第三个测试类似,但是失败了。原因是数位置的时候是只算[x,0]的个数的。这样8和3都会认为是在位置1的,结果排出来的数组只有两个元素。
实现代码
发现实际上是要把[x,1]这样的元素插入已经排好的[x,0]队列里合适的位置。改为如下实现。
先用原有来的逻辑处理前面没有更高的人,也就是[x,0]的情况。再把[x,1]插入结果队列

function reconstructQueue(peoples) {
  let result = [];
  for(let p of peoples) {
    if (p[PEOPLE_BEFORE] === 0) {
      result[shorterPeoplesWhoHasNoTallerBefore(p, peoples)] = p;
    }
  }
  for (let p of peoples) {
    if (p[PEOPLE_BEFORE] === 1) {
      insertAfterFirstNoShorter(p, result);
    }
  }
  return result;
}

insertAfterFirstNoShorter的实现是找到第一个不低于当前需要插入高度的人,在他后面加入新的人。
比如[5,0],[8,0],需要加入[3,1],数到5是高于3的,把3加入5之后的位置。

function insertAfterFirstNoShorter(people, peoples) {
  for (let i in peoples) {
    if (peoples[i][HEIGHT] >= people[HEIGHT]) {
      peoples.splice(i+1,0,people);
      return;
    }
  }
}
第六个测试

[x,1]的逻辑还没有完备,增加用例有两个[x,1]的情况。

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

结果不是预期的8,3,5,而是8,5,3。因为首先3加在8后面,之后5也是加入8随后的位置。顺序反了。
实现代码,修改insertAfterFirstNoShorter,把加入第一个更高的人之后改为第二个更高的人之前或者队尾。名字也相应的修改为insertBefore2ndNoShorter

function insertBefore2ndNoShorter(people, peoples) {
  let afterFirst = false;
  for (let i in peoples) {
    if (peoples[i][HEIGHT] >= people[HEIGHT]) {
      if (afterFirst) {
        peoples.splice(i,0,people);
        return;
      }
      afterFirst = true;
    }
  }
  peoples.push(people);
}
第六个测试的重构

又补充了几个[x,1]测试都成功了,我确信代码已经完全处理了有一个人在前的逻辑。开始重构。重构的出发点是觉得[x,0]和[x,1]的两段逻辑是可以统一的。
首先修改插入[x,0]的为函数为insertBefore1stNoShorter,找到第一个更高的人,加在他前面。

  peoples = peoples.sort( (p1, p2) => p1[PEOPLE_BEFORE] - p2[PEOPLE_BEFORE]);
  ...
  for(let p of peoples) {
    if (p[PEOPLE_BEFORE] === 0) {
      insertBefore1stNoShorter(p, result);
    }
    if (p[PEOPLE_BEFORE] === 1) {
      insertBefore2ndNoShorter(p, result);
    }
  }

显而易见两个函数的区别只是数人头的个数。
进一步重构,把人数作为参数传入。

  for(let p of peoples) {
    insertBeforeNTimesNoShorter(p, result, p[PEOPLE_BEFORE]+1);
  }
第七个测试

增加两个人高度相等的用例

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

直接通过了,因为前面写代码的时候其实已经加入了相等的判断。可以说这个测试加的太晚了,或者说是实现代码写的过多。
这个测试算是补救,确保提前写的实现是正确的。

第八个测试

引入有两个人在前面的用例。

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

出人意料的是直接过了,原因是[x,0],[x,1]或[x,2]已经在前面重构中一般化了。
仔细想想,并增加了几个测试后,发现原来已经做完了
成功好像比上次的失败来的更加突然嘛。

体会

  • 这次成功做法的最大不同,是抛开了对性能的担心。最终做出的结果性能也确实是相当的差。但是至少做完了。
  • 另一个重大区别是测试顺序。很明显每一步引起的修改都集中在程序的一部分中,这表明问题被分解开了。
  • 写这篇回顾时才发现的这次练习一个不寻常的地方,在于大段的重构。一般大的重构是前面欠债引起的。而这次却不是这个情况。很多代码结构是在消除硬编码的时候经过很多步骤产生出来的,同时外部行为并无变化。这究竟是小步带来的自然演进,还是因为我经过上次失败后脑子里已经预想了一些设计。目前我还不是特别确定。

如前面所言,这个解法是非常低效的,实在不能算是个算法解答。
下一篇将会回顾我探索更好的解法的过程。

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

推荐阅读更多精彩内容