继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

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

慕设计6931647
关注TA
已关注
手记 54
粉丝 7190
获赞 186

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这个十来行的方法就可以了。其它方法都是不需要花太大精力去记的。

手写

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

TIM截图20180417151330.png

TIM截图20180417151357.png

多看多写

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

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

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

完。

打开App,阅读手记
2人推荐
发表评论
随时随地看视频慕课网APP