如何将一个算法的熟练度从“了解”提升到“手写”?

  2016年秋,为了准备毕业找工作的我,专门花了一段时间复习了一遍算法与数据结构的教材。

  过了一遍基本的数据结构和算法的原理,书上的示例代码也都看得懂。于是自信满满地去参加互联网大厂的笔试。

  。。。

  然后就跪了!一遇到需要白板编程或手写的时候,要么干脆就写不出来,要么花很长时间写出来还不知道对不对。

  至此我明白了一个道理:知道一个算法的原理以及看得懂示例代码,并不意味着就掌握了它。Too young too simple!

  毕业后,我开始认真学习算法和数据结构。买了《算法导论》和《算法第四版》来慢慢研究。第一本并没有看多少,打算以后看。后一本基本看完了。

  前不久我找工作的时候,遇到一个手写堆排序的面试题。还好最近复习过相关的知识,也写过Demo,才勉勉强强写出来了。但是自己并不满意,一是速度不够快,二是仍然不能百分百自信它就是正确的,无BUG的。

  所以打算系统研究一下如何将一个算法从“了解”到“手写”。

  

  为什么要手写

  有的朋友(包括我自己以前也是)可能认为,明明如今各大主流语音都有内置的库或者开源的库,实现了各种基本的数据结构和算法,为什么我们还需要白板编程或手写实现?

  很现实的一个解答是:大厂面试要考。

  那么他们为什么要考这个呢?因为已有库的灵活性不够。

  怎么解释?比如你用Java写了一个排序的工具类。你需要满足通用性,那就得用泛型。而如果遇到一个业务场景,需要排序的数据就是简单的int类型,这个时候再用泛型就会进行自动装箱和拆箱,造成性能浪费。

  再比如“图”这种数据结构,经常需要根据业务需求来自定义一些字段和方法。

  再者,如果经历过从了解原理,看懂代码,用IDE写代码,手写代码这个过程,就会明白:只有快速手写出一个数据结构和算法,才可以说是真正掌握了它

  

  过程与技巧

  下面以快速排序为例,谈谈如何从“了解”到“手写”的过程与技巧。

  

  1了解

  最初学习这个算法,肯定是要了解它的原理的。如果这个时候别人让我介绍它,我不一定能说得很清楚,逻辑也不一定严密。大概会这样回答:

  每次从数组中选取一个元素作为“支点”,然后将比这个“支点”小的放到左边,比这个“支点”大的放到右边。然后分别对左边和右边重复这个步骤,直到这个步骤的范围为1。

  这个时候你已经基本了解这个算法的原理了。下一步需要做的是用更精确的语言,更严密的逻辑去描述它:

  

  2描述

  有些书上可能会有这个算法的严密逻辑描述,有些可能没有,这个时候就需要自己去总结了。

  怎么总结?打草稿,思考周全,反复修改。草稿大概像这样:

  一趟快速排序的过程是:

  1.以第一个元素作为支点p;

  2.从右边往左边遍历,如果遇到比p小,就把这个数交换到左边;

  3.从左边往右边遍历,如果遇到比p大,就把这个数交换到右边;

  4.重复步骤2和步骤3,直到两边遍历相遇。

  

  5.将支点放到中间正确的位置,返回支点的位置。

  很明显,这个草稿版的逻辑并不严密和清晰。经过修改后的版本:

  输入数组arr,长度为n。对于arr[lo..hi]范围的一趟快速排序的过程是:

  1.声明i = lo, j = hi;

  2.取支点prev = arr[lo];

  3.从右边开始遍历arr[j],如果遇到arr[j] p, 就交换arr[i]与arr[j],否则j--;

  4.从左边开始遍历,如果遇到arr[i] p, 就交换arr[i]与arr[j],否则i++;

  5.循环步骤3和步骤4,直到i = j;

  6.将支点放到合适的位置,交换arr[i]与arr[lo];

  7.返回支点最终的位置i;

  取得一趟快速排序的返回值作为mid,再分别对 arr[lo..mid - 1] 与 arr[mid + 1..hi] 这两个范围进行一趟快速排序,直到 lo = hi

  这样大概就清晰多了。下一步就是利用IDE编写代码

  

  IDE编写

  先上代码(草稿版):

  public class QuickSort {

  public void sort(double[] arr) {

  quickSort(arr, 0, arr.length - 1);

  }

  private void quickSort(double[] arr, int lo, int hi) {

  if (lo = hi) {

  int mid = partition(arr, lo, hi);

  quickSort(arr, lo, mid - 1);

  quickSort(arr, mid + 1, hi);

  }

  }

  private int partition(double[] arr, int lo, int hi) {

  double p = arr[lo];

  int i = lo;

  int j = hi;

  while (i j) {

  while (arr[j] = p)

  j--;

  exchange(arr, i, j);

  while (arr[i] = p)

  i++;

  exchange(arr, i, j);

  }

  exchange(arr, lo, i);

  return i;

  }

  private void exchange(double[] arr, int i, int j) {

  double temp = arr[i];

  arr[i] = arr[j];

  arr[j] = temp;

  }

  }

  

  1要不要用泛型?

  我的答案的不需要。学习算法时用泛型会增加复杂性,等你真正掌握了这个算法,需要写一个较为通用的库时,再用泛型不迟。

  所以我这里使用double类型来作为示例。你也可以根据自己的喜好使用int或其它任意类型。

  

  2要不要写私有的辅助方法?

  可以写。一方面,写一些私有的辅助方法能让整个代码更加优雅和清晰。另一方面,也方便自己记忆,以便更准确更快地“手写”。所以我这里写了一个exchange方法作为辅助。

  

  3测试代码

  上述代码是未经过测试的。尤其是partition方法的核心部分,其实是根据上面的文字步骤来写的。

  单元测试是必须的,有些人(em...大概是说的我自己?)就是“迷之自信”,认为自己写的代码不需要测试,肯定无bug。。。

  So,讲个故事,我在学习图的最小生成树Prim算法的时候,用到了索引堆这个数据结构。结果测试Prim算法的时候发现出了Bug,Debug很久才发现,是我以前写最小索引堆这个数据结构出了问题。当时没有写单元测试!

  所以,单元测试是必须的...

  下面对上述代码进行测试和修改。

  

  测试和修改

  先上测试代码:

  public class QuickSortTest {

  @Test

  public void sort() {

  double[] arr = new double[]{0.1, 0.3, 0.2, 0.5, 0.4};

  QuickSort sort = new QuickSort();

  sort.sort(arr);

  double[] res = new double[]{0.1, 0.2, 0.3, 0.4, 0.5};

  assertTrue(Arrays.equals(arr, res));

  }

  }

  果不其然,测试未能通过。(T-T)...

  先冷静一分钟...

  然后进行紧张的Debug工作...

  然后优化代码...

  最终代码(快速切分部分):

  private int partition(double[] arr, int lo, int hi) {

  double p = arr[lo];

  int i = lo + 1; // 注意初始比较下标

  int j = hi;

  while (i j) {

  // 注意下标越界问题

  while (i hi arr[i] = p)

  i++;

  while (j lo arr[j] = p)

  j--;

  // 这里注意要判断一下

  if (i j)

  exchange(arr, i, j);

  }

  // 注意这里是j不是i,因为最终j = i

  if (lo j)

  exchange(arr, lo, j);

  return j;

  }

  通过测试。Perfect!

  

  简化代码与记忆

  要想“手写”,就必须记住关键代码。可以通过简化代码,来让代码更简洁好记。比如上述代码,我们可以把arr抽离出来作为一个属性。这样就可以节省一些代码量,使得手写更快。

  简化后的代码:

  public class QuickSort {

  private double[] arr; // 待排序的数组

  public QuickSort(double[] arr) {

  this.arr = arr;

  quickSort(0, arr.length - 1);

  }

  // 递归调用多趟快速切分

  private void quickSort(int lo, int hi) {

  if (lo = hi) {

  int mid = partition(lo, hi);

  quickSort(lo, mid - 1);

  quickSort(mid + 1, hi);

  }

  }

  // 一趟快速切分

  private int partition(int lo, int hi) {

  double p = arr[lo];

  int i = lo + 1, j = hi;

  while (i j) {

  while (i hi arr[i] = p)

  i++;

  while (j lo arr[j] = p)

  j--;

  if (i j) exchange(i, j);

  }

  if (lo j) exchange(lo, j);

  return j;

  }

  // 交换两个元素

  private void exchange(int i, int j) {

  double temp = arr[i];

  arr[i] = arr[j];

  arr[j] = temp;

  }

  }

  

  重点记忆

  每个算法和数据结构都有需要重点记忆。比如这个快速排序,其实只需要重点记住partition这个十来行的方法就可以了。其它方法都是不需要花太大精力去记的。

  

  手写

  有点瑕疵,献丑了。哈哈...

  

  

  

  多看多写

  有句话叫孰能生巧。多看多写自然就熟了。不然就算现在会写,可能过一段时间就忘了。

  如果时间不那么充裕就偶尔看看代码,尝试在心里背一背。如果时间充裕就写一些。

  如果看代码的时间都没有?那我也爱莫能助咯~

  完。

猜你喜欢

转载自www.cnblogs.com/qfdsj/p/8986116.html
今日推荐