点选下方白字标为隆哥蒙
这首诗会囊括以后的大部份文本,因此会举许多标识符的示例,聊聊怎样采用架构观念,因此给对演算法难上加难的好友给一点儿具体文本可继续执行的刷题提议。
具体来说,这儿讲的都是一般的数据结构和演算法,咱并非搞体育竞技的,野路子长大,只化解常规性的难题,以复试为终极目标。
除此之外,下列是我对个人的实战经验的归纳,没哪本演算法复制件写那些小东西,因此请听众打声认知我的视角,别苦恼于技术细节难题,即使这首诗是对计算机程序和演算法创建两个架构性的重新认识。
从总体到技术细节,Kosaraju,从抽象化到具体文本的架构观念是通用型的,不而已自学计算机程序和演算法,自学其它任何人科学知识都是高效率的。
先说计算机程序,接着反正演算法。
一、计算机程序的储存形式
数据结构的储存形式多于三种:字符串(顺序储存)和二叉树(拉艾储存)。
这句话怎么认知,并非还有散列表、栈、队列、堆、树、图等等各种计算机程序吗?
我们分析难题,一定要有递归的思想,Kosaraju,从抽象化到具体文本。你上来就列出这么多,那些都属于「上层建筑」,而字符串和二叉树才是「结构基础」。即使那些多样化的计算机程序,究其源头,都是在二叉树或者字符串上的特殊操作,API 不同而已。
比如说「队列」、「栈」这三种计算机程序既可以采用二叉树也可以采用字符串实现。用字符串实现,就要处理扩容缩容的难题;用二叉树实现,没这个难题,但需要更多的内存空间储存节点指针。
「图」的三种表示方法,邻接表是二叉树,邻接矩阵是二维字符串。邻接矩阵判断连通性迅速,并可以进行矩阵运算化解一些难题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但是许多操作的效率上肯定比不过邻接矩阵。
「散列表」是通过散列函数把键映射到两个大字符串里。而且对化解散列冲突的方法,拉链法需要二叉树特性,操作简单,但需要额外的空间储存指针;线性探查法就需要字符串特性,以便连续寻址,不需要指针的储存空间,但操作稍微复杂些。
「树」,用字符串实现是「堆」,即使「堆」是两个完全二叉树,用字符串储存不需要节点指针,操作也比较简单;用二叉树实现是很常见的那种「树」,即使不一定是完全二叉树,因此不适合用字符串储存。为此,在这种二叉树「树」结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL 树、红黑树、区间树、B 树等等,以应对不同的难题。
了解 Redis 数据库的好友可能也知道,Redis 提供列表、字符串、集合等等几种常用计算机程序,但是对每种计算机程序,底层的储存形式都至少有三种,以便于根据储存数据的实际情况采用合适的储存形式。
综上,计算机程序种类许多,甚至你也可以发明自己的计算机程序,但是底层储存无非字符串或者二叉树,二者的优缺点如下:
字符串由于是紧凑连续储存,可以随机访问,通过索引快速找到对应元素,而且相对节约储存空间。但正即使连续储存,内存空间必须一次性分配够,因此说字符串如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);而且你如果想在字符串中间进行插入和删除,每次必须搬移后面的大部份数据以保持连续,时间复杂度 O(N)。
二叉树即使元素不连续,而是靠指针指向下两个元素的位置,因此不存在字符串的扩容难题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正即使储存空间不连续,你无法根据两个索引算出对应元素的地址,因此不能随机访问;而且由于每个元素必须储存指向前后元素位置的指针,会消耗相对更多的储存空间。
二、计算机程序的基本操作
对任何人计算机程序,其基本操作无非遍历 + 访问,再具体文本一点儿是:增删查改。
计算机程序种类许多,但它们存在的目的都是在不同的应用场景,尽可能高效率地增删查改。话说这不是计算机程序的使命么?
怎样遍历 + 访问?我们仍然从最高层来看,各种计算机程序的遍历 + 访问无非三种形式:线性的和非线性的。
线性是 for/while 迭代为代表,非线性是递归为代表。再具体文本一步,无非下列几种架构:
字符串遍历架构,典型的线性迭代结构:
void traverse(int[] arr) { for (int i = 0; i < arr.length; i++) { // 迭代访问 arr[i] }}二叉树遍历架构,兼具迭代和递归结构:
/* 基本的单二叉树节点 */class ListNode { int val; ListNode next;}void traverse(ListNode head) { for(ListNode p = head; p !=null; p = p.next) { // 迭代访问 p.val }}void traverse(ListNode head) { // 递归访问 head.valtraverse(head.next)}二叉树遍历架构,典型的非线性递归遍历结构:
/* 基本的二叉树节点 */class TreeNode { int val; TreeNode left, right;}void traverse(TreeNode root) { traverse(root.left) traverse(root.right)}你看二叉树的递归遍历形式和二叉树的递归遍历形式,相似不?再看看二叉树结构和单二叉树结构,相似不?如果再多几条叉,N 叉树你会不会遍历?
二叉树架构可以扩展为 N 叉树的遍历架构:
/* 基本的 N 叉树节点 */class TreeNode { int val; TreeNode[] children;}void traverse(TreeNode root) { for (TreeNode child : root.children) traverse(child)}N 叉树的遍历又可以扩展为图的遍历,即使图是好几 N 叉棵树的结合体。你说图是可能出现环的?这个很好办,用个布尔字符串 visited 做标记就行了,这儿就不写标识符了。
所谓架构,是套路。
不管增删查改,那些标识符都是永远无法脱离的结构,你可以把这个结构作为大纲,根据具体文本难题在架构上添加标识符就行了,上面会具体文本举例。
三、演算法刷题指南
具体来说要明确的是,计算机程序是工具,演算法是通过合适的工具化解特定难题的方法。也是说,自学演算法以后,最起码得了解那些常用的计算机程序,了解它们的特性和缺陷。
那么该怎样在 LeetCode 刷题呢?以后的文章 演算法自学之路 写过一些,什么按标签刷,坚持下去云云。
现在距那首诗已经过去将近一年了,我不想说那些不痛不痒的话了,直接说具体文本的提议:
先刷二叉树,先刷二叉树,先刷二叉树!
先刷二叉树,先刷二叉树,先刷二叉树!
先刷二叉树,先刷二叉树,先刷二叉树!
这是我这刷题的亲身体会,下图是我从 2018/10 到 2019/10 这一年的心路历程:
为什么要先刷二叉树呢?
即使二叉树是最容易培养架构观念的,而且大部分演算法技巧,本质上都是树的遍历难题。
刷二叉树看到题目没思路?
根据许多听众的难题,其实大家并非没思路,而已没认知我们说的「架构」是什么。
不要小看这几行破标识符,几乎大部份二叉树的题目都是一套这个架构就出来了。
void traverse(TreeNode root) { // 前序遍历 traverse(root.left) // 中序遍历 traverse(root.right) // 后序遍历}比如说我随便拿几道题的解法出来,不用管具体文本的标识符逻辑,只要看看架构在其中是怎样发挥作用的就行。
LeetCode 124 题,难度 Hard,让你求二叉树中最大路径和,主要标识符如下:
看出来了吗,这是个后序遍历嘛。
LeetCode 105 题,难度 Medium,让你根据前序遍历和中序遍历的结果还原一棵二叉树,很经典的难题吧,主要标识符如下:
不要看这个函数的参数许多,而已为了控制字符串索引而已,本质上该算法也是两个前序遍历。
LeetCode 99 题,难度 Hard,恢复一棵 BST,主要标识符如下:
这不是个中序遍历嘛,对一棵 BST 中序遍历意味着什么,应该不需要解释了吧。
你看,Hard 难度的题目不过如此,而且还这么有规律可循,只要把架构写出来,接着往相应的位置加小东西就行了,这不是思路吗。
刚已经开始刷二叉树的题目,前 10 道也许有点难受;结合架构再做 20 道,也许你就有点自己的认知了;刷完整个专题,再去做什么回溯动规分治专题,你就会发现只要涉及递归的难题,都是树的难题。
直接举例吧,说几道我们以后文章写过的难题。
动态规划详解 说过凑零钱难题,暴力解法是遍历一棵 N 叉树:
def coinChange(coins: List[int], amount: int): def dp(n): if n == 0: return 0 if n < 0: return -1 res = float(INF) forcoinin coins: subproblem = dp(n – coin) # 子难题无解,跳过 if subproblem == -1: continue res = min(res, 1 + subproblem) return res if res != float(INF) else -1 return dp(amount)这么多标识符看不懂咋办?直接提取出架构,就能看出核心思路了:
# 不过是两个 N 叉树的遍历难题而已def dp(n): for coin in coins: dp(n – coin)其实许多动态规划难题是在遍历一棵树,你如果对树的遍历操作烂熟于心,起码知道怎么把思路转化成标识符,也知道怎样提取别人解法的核心思路。
再看看回溯演算法,前文 回溯演算法详解 干脆直接说了,回溯演算法是个 N 叉树的前后序遍历难题,没例外。
比如 N 皇后难题吧,主要标识符如下:
void backtrack(int[] nums, LinkedList<Integer> track) { if (track.size() == nums.length) { res.add(new LinkedList(track)); return; } for (int i = 0; i < nums.length; i++) { if (track.contains(nums[i])) continue; track.add(nums[i]); // 进入下一层决策树backtrack(nums, track); track.removeLast(); }/* 提取出 N 叉树遍历架构 */void backtrack(int[] nums, LinkedList<Integer> track) { for (int i = 0; i < nums.length; i++) { backtrack(nums, track);}N 叉树的遍历架构,找出来了吧。你说,树这种结构重不重要?
综上,对演算法难上加难的好友来说,可以先刷树的相关题目,打声从架构上看难题,而不要苦恼于技术细节难题。
苦恼技术细节难题,就比如苦恼 i 到底应该加到 n 还是加到 n – 1,这个字符串的大小到底应该开 n 还是 n + 1 ?
从架构上看难题,是像我们这样基于架构进行抽取和扩展,既可以在看别人解法时快速认知核心逻辑,也有助于找到我们自己写解法时的思路方向。
当然,如果技术细节出错,你得不到正确的答案,但是只要有架构,你再错也错不到哪去,即使你的方向是对的。
但是,你要是心中没架构,那么你根本无法解题,给了你答案,你也不会发现这是个树的遍历难题。
这种观念是很重要的,动态规划详解 中归纳的找状态转移方程的几步流程,有时候按照流程写出解法,说实话我自己都不知道为啥是对的,反正它是对了。。。
这是架构的力量,能够保证你在快睡着的时候,依然能写出正确的程序;就算你啥都不会,都能比别人高两个级别。
四、最后归纳
计算机程序的基本储存形式是拉艾和顺序三种,基本操作是增删查改,遍历形式无非迭代和递归。
刷演算法题提议从「树」分类已经开始刷,结合架构观念,把这几十道题刷完,对树结构的认知应该就到位了。
这时候去看回溯、动规、分治等演算法专题,对思路的认知可能会更加深刻一些。
最后,除了树的难题,还有一些其它类型难题的架构套路,我都有归纳过,在上面简单列一下:
一网打尽!二分查找解题模版与题型全面解析复试官,你再问我滑动窗口难题试试?我有解题模板,不怕!有了这套模板,女好友再也不用担心我刷不动 LeetCode 了有了四步解题法模板,再也不害怕动态规划!双指针的魅力!四行标识符求解「盛最多水的容器」一文学会「回溯搜索演算法」解题技巧END
● 答应我,别再if/else走天下了可以吗
● 关于计算机读研的小提议点“在看”你懂得