程序员江湖有个传言:懂了《算法导论》的90%,就超越了90%的程序员。
全局变量在部分题目中,有很大的用处。能直接降低算法的理解程度!例如题目285!
为了降低空间复杂度,对于输入参数是数组的题目,可以考虑使用“交换元素”的思想,来达到输入参数的复用。比如题目41“全排列二”。
1)对于典型套路类算法题目,比如滑动窗口类题目,位运算类题目。解题关键:根据题意,分析数据,找到数据背后的有助于解题的规律。这些规律往往是一些数学上的递推式子。比如题目201、题目713.
初步找到规律后,可以在深一步想想这个规律可以如何更高效的利用。这能更一步的提高算法效率。比如《剑指offer》专项突破版,面试题3“前n个数字二进制形式中1的个数”。一开始想到利用“i & (i-1)”这个规律可以求出单个数字中1的个数;如果再深一步,想到 i中1的个数 恰好是 “i & (i-1) ”中1的个数 加1,那能更一步降低算法时间复杂度。
2)Recursion相关的题目,如果Debug的时候,实在想不通哪里错了,可以画图。以一个例子画出递归树,然后纠正代码。比如,Backtrack Algorithm相关的题目可以画出“回溯树”来帮助写代码。
就这么说吧,所有递归算法,无论它是干什么的,本质上都是在遍历一棵(递归)树,然后在节点(前中后序位置)上执行代码。你要写递归算法,本质上就是要告诉每个节点需要做什么。
3)学到新算法时,立即写代码实践,才能熟练。后期,就通过刷题,不断归纳总结,才能掌握,举一反三。
新算法的学习,首先应该考虑实现上,哪些是重要的卡点,应该用什么方法来实现。更进一步,能够用怎样的巧妙数据结构来优化时间效率。这需要长期训练,形成条件反射。
4)在使用指针时,一定要注意区分“指针变量”与“指针所代表的地址”这两个不同的含义。在binary tree中,对于已知的节点指针 TreeNode* root," root=nullptr "的操作,表示的是 root 这个指针变量变为了NULL,而不是对应的变量地址被置为NULL。这点需要尤其注意,我因此吃亏!!!!
同时,对节点的指针进行赋值时,注意是否对原始节点的左右关系做了修改。这点我也吃过亏。
5)注意区别:Longest Common Substring 和 Longest Common Subsequence。Longest Common Subsequence 应该使用DP来解决,dp[i][j]表示字符串str1[i....]和字符串str2[j....]的最长公共子序列;而Longest Common Substring属于字符串类算法,使用双指针和哈希表可以轻松解题。
6)遇到一个算法题,如果想不出来简单方法,那可以先写出暴力解,即BF Version。然后,再基于此,思考如何优化。
7)“连续子数组”的求plus(minus或multiplies)的最值问题,可以考虑SlidingWindowMethod。目前遇到相关题目分为两种类型:一、数组全为正整数,或已排序,解法SlidingWindowMethod(剑指Offer II 008),或者双指针(剑指Offer II 006、007);二、数组元素为有符号整数,解法使用unordered_map,具体是记录前n项的前缀,和出现的次数。剑指Offer II 010。
单纯的数组内题目(往往形参只有一个数组),unorder_map往往涉及前缀和,它有很多妙用,用好了可以极大的提升算法性能。
8)有时候题目会限制我们算法的时间复杂度,这种信息其实也暗示着一些东西。
比如,要求我们的算法复杂度是 O(N logN),你想想怎么才能搞出一个对数级别的复杂度呢?肯定得用到“二分搜索” 或者 二叉树相关的数据结构,比如TreeMap,PriorityQueue之类的对吧。
再比如,有时候题目要求你的算法时间复杂度是 O(M N),这可以联想到什么?可以大胆猜测,常规解法是用 “回溯算法” 暴力穷举,但是更好的解法是动态规划,而且是一个二维动态规划,需要一个 M*N 的二维 dp数组,所以产生了这样一个时间复杂度。
概念:当我们需要枚举数组中的两个元素时,如果我们发现随着第一个元素的递增,第二个元素是递减的,那么就可以使用双指针的方法,将枚举的时间复杂度从O(N²)减少至O(N)。这是因为在枚举的过程每一步中,「左指针」会向右移动一个位置,而「右指针」会向左移动若干个位置,这个与数组的元素有关,但我们知道它一共会移动的位置数为O(N)。均摊下来,每次也向左移动一个位置,因此时间复杂度为O(N)。例如,[题目167]、[题目15]。
15分钟内,如果自己没有解出来,就直接看官方解答。但要注意的是,一边看答案一边套用labuladong关于动态规划的解题技巧来理解。这样做能加深对动态规划类题目的一般套路理解。
还要一点尤其注意的是,一定要在对题目给出的数据做了分析的情况下,再来使用DP算法。不然,盲目的套用DP,不会写出理想的代码。
如果DP类题目涉及到多个table来寻找最值情况,即一个表对应一个状态转移方程,多个表就对应多个状态转移方程。这种分类的动态规划,难度就瞬间上去了。做这类题的关键是,一定要把这多个表都找到,把每个位置的各个状态都准确找到。例如题目1567.
解题策略:你自己设计一种寻找最值的思路,通过遍历来搜索理想的最值。这种思路就是greedy策略的选择,这也是该类题目的难点所在。相关的题目有乐扣题目11(盛最多水的容器)、图中的最短路径问题。
Greedy策略在Graph Theory的search类算法中有很多的实践。比如,DFS中,每次都使用与当前节点相连的首节点进行搜索;BFS中,每次都等遍历完当前节点的所有直接相连节点之后,在搜索下一层的节点;MST之prim算法中,每次都选取cross edges中代价最小的边;MST之kruskal算法中,每次都选取所有未访问边中的代价最小者进行判断;shortest path中,每访问到一个新的节点都更新一次每个节点到目标节点的最小代价值。
事实上,DFS、BFS、MST、shortPath都可以归为一类算法:PFS——priority first search。
1、可以直接视作int[26]进行hash映射了。都可以不用Map了。这牺牲了小部分空间,但是极大地节省了时间复杂度。
2、看到字符串类题目,应该首先想到哈希表。它在字符串比较类题目中,是解题最基本的数据结构。比如字符串的变位词类题目,比如题目567(变位词);含有相同字符的子字符串类题目。
2、快速比较“两个字符串是否有相同字符”的一种方法:通过将字符串的字符转换到二进制位1,实现将字符串转换成一个整数。实现方式,左移运算 + ‘或’运算。然后,利用‘与’运算,可以快速判断“是否有相同字符”。 (这个思路充分体现了“位运算”的高效性,毕竟这是二进制运算)
3、“两个字符串判断是否变位词的问题”,难点在字符串中可能出现相同字符,因此,不能使用“2”中的技巧。可以用HashTable来存储字符串中不同字符出现的次数,然后比较两个HashTable中的值是否全等。对于全是小写字母的字符串,可以利用字符的整数表示特性,使用动态数组替换HashTable。例如题目567(变位词),题目438。使用了“SlidingWindows”或“Two-Pointer”来求解问题。
4、回文串类问题:直接使用原始字符串上的双指针最高效。比如题目125、题目680。难点在“回文串”相关的动态规划。
相关的题目在解题时,应该建立一种基于子树操作的recursion来解决问题的思想,即通过写一个对当前子树的操作函数,基于它的recursion来实现对所有子树的操作,从而解决问题。最终会发现,BT类问题的代码始终没有逃出“前/中/后序的遍历框架”。(这实际上是一种“递归的结构化”思维,一开始可能难以理解,一旦理解,会发现解题思路明确,代码相对也简单!但对“穷举”类题目不适用,“穷举类”题目属于深度优先搜索框架下)。
BT类问题的难点是,如何把题目的要求细化成每个节点需要做的事情。然后剩下的事情交给“基于子树的递归函数”就可以了。
其次,需要注意的一点是,对节点进行“增删查改”时,不要忘记维护节点之间的前后继承关系,这是我在做 BinarySearchTree 相关题目时经常容易出错的点。同时,也要注意区分“指针变量”与“变量存储的地址”。
1)平衡二叉搜索树(AVL树)具有的性质:
图相关的算法,可以在脑海中抽象出一个矩阵阵列。基于它来思考解答。比如union-find set中,以一个树中最小序号的点来表示root节点。
其中,会经常使用遍历算法:BFS和DFS。这两者中,DFS更加常用。DFS的实现主要是基于iteration。
1)很重要的一个操作:判断图中是否有环Loop!!!
2)最小生成树Minimum Spanning Tree相关的两个解法Prim和kruskal解法实现的关键:
Prim算法中的核心操作是两个:cut的表示,cross edge的表示。这里尤其是要注意crossEdge的实时刷新(自己写的prim算法卡在了这里,当时刷新了cut之后,我只增加了跨边,没有删除消失的跨边)。 可以使用
1)lowcost[i]:表示以i为终点的跨边的最小权值,当lowcost[i]=0说明以i为终点的边的最小权值为0,也就是表示i点加入了MST;
2)mst[i]:表示对应lowcost[i]的起点,即说明边< mst[i], i>是MST的一条边,当mst[i]=0表示起点i加入MST。
实在写不出,就百度找博客。
Kruskal算法的核心操作是两个:判断某一个节点对应的树与另一个节点对应的树是否是同一颗树,以及两个树的合并操作。(都可以通过Union-find set实现)
3)union-find set实现的关键:
统一规定一个set中代表元素的规则。这个规则应该根据不同情景来确定。这一步在union操作中,通常有两种策略,一种是较小高度的树合并到较大高度的树;另一种是,较少元素的树合并到较高元素的树。其中,路径压缩应该在find函数中实现。并查集代码实现
4)Shortest Path 问题
Dijkstra算法是典型的单源最短路径算法,即从图中给定的一个起点(源)出发,求出其与所有顶点的最短路径。它能被用于计算一个节点到其他所有节点的最短路径。主要特点是以起始点为中心向外层层层拓展,直到扩展到终点为止。注意,该算法要求图中不存在负权边。
计算思路:各个节点设置一个变量来存储从起始点到该点的最短距离。通过BFS,遍历图中的各个定点和边。遍历过程中,不断更新各个节点的距离变量。完成BFS,即可求解问题。
ShortestPath问题还可以从多源起点出发,这是就需要把这些源点都同时入队,进行BFS。
即Trie树,是哈希树的变种,和hash效率有一拼。是一种用于快速检索的多叉树结构。其核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
注意和“前缀和”(hashtable)的区别!!!!
前缀树和后缀树
典型应用:用于统计和排序大量的字符串(但不仅限于字符串),所以常常被搜索引擎系统用于文本词频统计。
优点:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
缺点是:Trie树的内存消耗非常大。因为当前节点的下一层节点要开辟足够大的空间用于存储可能的所有字符。
1)树中的每一条边上都标识有一个字符,这些字符可以是任意一个字符集中的字符。比如,对于都是小写字母的字符串,字符集就是 'a' - 'z';对于都是数字的字符串,字符集就是 ‘0‘ - ’9’;对于二进制字符串,字符集就是0和1。2)每个节点,设置一个布尔值,用于表示是否字符串尾字符。字典树(前缀树)的实现
创建原理
对于给定的字符串集合{W1 W2 W3, ... Wn},如何创建对应的Trie树。事实上,Trie树的创建是从只有根节点开始,通过依次将W1,W2 W3,... Wn插入Trie中实现的。所以关键就是之前提到的Trie的插入操作。底层数据结构采取“二维数组”即可,其中,一维长度为节点数量,二维长度为所采用的字符集。
具体来说,Trie一般支持两个操作:
1)Trie.insert(W):第一个操作是插入操作,就是将一个字符串W加入到集合中。
2)Trie.search(S):第二个操作是查询操作,就是查询一个字符串S是不是在集合中。
代码实现
字典树Or前缀树的实现
一般技巧:
1)快慢指针:适合用于链表是否有环的判断。一般是快指针比慢指针每次多走一步,即Speed(fast)=Speed(slow)+1。如果在节点为NULL前,两个指针相等,则环存在。进阶题目,判断环的起点节点,这就需要简单的数学推导了。 注意,对于个别题目,快指针不一定只比慢指针快一步。
还可以用来找“链表的中间节点”。
2)操作“链表倒数第n个节点”类题目,比如删除操作,解题思路是设置大小刚好为n的固定窗口。通过窗口的移动,在线性扫描的时间,就能快速找到倒数第n个节点。
3)链表节点的重排列问题,这类题目涉及链表节点前后关系的操作。通常易错的操作是在重排列节点关系的过程中,原始节点关系就已经被打乱,从而导致问题求解错误。这类题目应该画出节点关系模型,理清节点关系变化前后的各节点状态。例如题目206反转链表。只要画出了相邻三节点的模型,解题思路就出来了。
这类题目往往出现在数组这种数据结构中出现。使用HashTable能解决大部分问题。难点在如何运用HashTable。
注意和“前缀树”的区别!!!
摩尔投票法的核心思想为对拼消耗。定义关键变量,动态记录现有重复元素的个数。适用于“找出一组数字中重复出现超过指定比例的数字”类的题目。比如,找出一组数字序列中出现次数大于1/2的数字;或者找出所有出现次数大于1/3的数字(题目229)。
两种套路:一是已排序数组。利用“双指针法”可以轻松解决问题[题目167];二是无序数组,最高效的方法是使用 unordered_map,可以通过一次线性扫描,边搜索,边维护数据结构;因为它检索的高效性,可以快速的判断是否答案。[题目001]
直接上代码。代码是十进制加法的模拟,其中,st1和st2是两个长度相同的栈。
stack st3;
int add1=0;
while(!st1.empty()||!st2.empty())
{
int cur=st1.top()+st2.top();
st1.pop();
st2.pop();
if(add1>0)
cur++;
if(cur>9)
{
add1=1;
cur%=10;
}else
{
add1=0;
}
st3.push(cur);
}
if(add1>0)
st3.push(add1);
“单调栈”是属于典型的以空间换时间的策略,比较抽象,有点难以理解。“单调栈”顾名思义就是栈中的数据是有序的。例如,题目0739(每日温度),维护了一个从栈底到栈顶值递减的栈
“栈”数据结构中,最难的一类题就是它了。
LRU:Least Recent Used,基于哈希表和双向链表可以轻松实现它。建议双向链表自己写。对应题目146.LRU 缓存机制。
机制有两种操作:
int get(int key):如果关键字key存在于缓存中,则返回关键字的值,否则返回-1;
void put(int key, int value):如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组【关键字-值】。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
难点:“最近未使用的数据值”指近期既没有被put,也没有被get的值。
这类题目往往是求数据流中前K大/小的数据的值,或者第K大的数值。因为求解窗口有固定大小,可以考虑使用一个固定长度的容器来求解。再结合问题是求解最值情况,因此考虑使用 大/小顶堆来解决问题,即queue头文件中的priority_queue。这种容器适配器能很好的满足需求,能控制住时间复杂度和空间复杂度。例如,题目703、题目347、题目373。
要注意的是,priority_queue模板类的使用。它是一个容器适配器,声明如下,其中,可以使用的底层容器Container为vector和queue;比较器Compare是一个二元谓词 (a binary predicate)。谓词是一个函数对象(或仿函数),它定义了返回值为bool的“()”运算符函数;二元谓词即“()”运算符函数中有两个形参。
template < class T, class Container=vector, class Compare=less >
class priority_queue;
1)利用“或”操作 和 空格 将英文字符转换为小写
('a'|' ')='a'
('A'|' ')='a'
2)利用“与”操作 和 下划线 将英文字符转换为大写
('b' & '_')='B'
('B' & '_')='B'
3)利用 “异或”操作 和 空格 进行英文字符大小写互换
('d' ^ ' ')='D'
('D' ^ ' ')='d'
4)判断两个数是否异号
这个技巧很实用。读者可能想利用乘积或者商来判断两个数是否异号,但是这种处理方式可能造成溢出,从而出现错误。
这种方法利用了补码编码的符号位。
int x=-1, y=2;
bool f=((x^y)<0); //true
int x=3, y=2;
bool f=((x^y)<0); //false
8、水塘抽样
一种能从由相等元素组成的集合(“水塘”)中等概率抽出其中一个的方法。
设nums中有k个值为target的元素,该算法会保证这k个元素的下标成为最终返回值的概率均为(1/k),证明如下:
例题:LeetCode398.随机数索引。题目:给定一个可能含有重复元素的整数数组,要求随机输出给定的数字的索引。您可以假设给定的数字一定存在于数组中。
代码:例题中的官方解答。看完代码,就基本能理解这个method了。
9、斜着遍历数组
// 斜着遍历数组
for (int l = 2; l <= n; l++) {
for (int i = 0; i <= n - l; i++) {
int j = l + i - 1;
// 计算 dp[i][j]
}
}