2016年秋,为了准备毕业找工作的我,专门花了一段时间复习了一遍算法与数据结构的教材。
过了一遍基本的数据结构和算法的原理,书上的示例代码也都看得懂。于是自信满满地去参加互联网大厂的笔试。
。。。
然后就跪了!一遇到需要白板编程或手写的时候,要么干脆就写不出来,要么花很长时间写出来还不知道对不对。
至此我明白了一个道理:知道一个算法的原理以及看得懂示例代码,并不意味着就掌握了它。 Too young too simple!
毕业后,我开始认真学习算法和数据结构。买了《算法导论》和《算法第四版》来慢慢研究。第一本并没有看多少,打算以后看。后一本基本看完了。
前不久我找工作的时候,遇到一个手写堆排序的面试题。还好最近复习过相关的知识,也写过Demo,才勉勉强强写出来了。但是自己并不满意,一是速度不够快,二是仍然不能百分百自信它就是正确的,无BUG的。
所以打算系统研究一下如何将一个算法从“了解”到“手写”。
为什么要手写有的朋友(包括我自己以前也是)可能认为,明明如今各大主流语音都有内置的库或者开源的库,实现了各种基本的数据结构和算法,为什么我们还需要白板编程或手写实现?
很现实的一个解答是:大厂面试要考。
那么他们为什么要考这个呢?因为已有库的灵活性不够。
怎么解释?比如你用Java写了一个排序的工具类。你需要满足通用性,那就得用泛型。而如果遇到一个业务场景,需要排序的数据就是简单的int
类型,这个时候再用泛型就会进行自动装箱和拆箱,造成性能浪费。
再比如“图”这种数据结构,经常需要根据业务需求来自定义一些字段和方法。
再者,如果经历过从了解原理,看懂代码,用IDE写代码,手写代码这个过程,就会明白:只有快速手写出一个数据结构和算法,才可以说是真正掌握了它
过程与技巧下面以快速排序为例,谈谈如何从“了解”到“手写”的过程与技巧。
1 了解最初学习这个算法,肯定是要了解它的原理的。如果这个时候别人让我介绍它,我不一定能说得很清楚,逻辑也不一定严密。大概会这样回答:
每次从数组中选取一个元素作为“支点”,然后将比这个“支点”小的放到左边,比这个“支点”大的放到右边。然后分别对左边和右边重复这个步骤,直到这个步骤的范围为1。
这个时候你已经基本了解这个算法的原理了。下一步需要做的是用更精确的语言,更严密的逻辑去描述它:
2 描述有些书上可能会有这个算法的严密逻辑描述,有些可能没有,这个时候就需要自己去总结了。
怎么总结?打草稿,思考周全,反复修改。草稿大概像这样:
一趟快速排序的过程是:
- 以第一个元素作为支点p;
- 从右边往左边遍历,如果遇到比p小,就把这个数交换到左边;
- 从左边往右边遍历,如果遇到比p大,就把这个数交换到右边;
- 重复步骤2和步骤3,直到两边遍历相遇。
- 将支点放到中间正确的位置,返回支点的位置。
很明显,这个草稿版的逻辑并不严密和清晰。经过修改后的版本:
输入数组arr,长度为n。对于arr[lo..hi]范围的一趟快速排序的过程是:
- 声明i = lo, j = hi;
- 取支点prev = arr[lo];
- 从右边开始遍历arr[j],如果遇到arr[j] < p, 就交换arr[i]与arr[j],否则j--;
- 从左边开始遍历,如果遇到arr[i] > p, 就交换arr[i]与arr[j],否则i++;
- 循环步骤3和步骤4,直到i >= j;
- 将支点放到合适的位置,交换arr[i]与arr[lo];
- 返回支点最终的位置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
或其它任意类型。
可以写。一方面,写一些私有的辅助方法能让整个代码更加优雅和清晰。另一方面,也方便自己记忆,以便更准确更快地“手写”。所以我这里写了一个exchange
方法作为辅助。
上述代码是未经过测试的。尤其是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
这个十来行的方法就可以了。其它方法都是不需要花太大精力去记的。
有点瑕疵,献丑了。哈哈...
多看多写有句话叫孰能生巧。多看多写自然就熟了。不然就算现在会写,可能过一段时间就忘了。
如果时间不那么充裕就偶尔看看代码,尝试在心里背一背。如果时间充裕就写一些。
如果看代码的时间都没有?那我也爱莫能助咯~
完。