算法笔记2——提升

346 阅读14分钟

第一课

一、哈希函数

  1. 输入无穷,输出有穷;同样的输入必定得到同样的输出;输出结果是离散均匀的,这就是哈希函数的特性,比如MD5,SHA1,都是哈希函数
  2. 由于有此性质,它的应用十分广泛
  3. 比如限定内存求数字出现次数时,就可以先用哈希算法,把原有数据集分100组(%100),再单独对每组求取
  4. 哈希表实现原理:先分16个区域(桶),新来的值经过哈希算法再模16,即可确定桶,每个桶可以认为是一个链表;随着数据集增大,进行扩容,即变为32个桶,数据量为n时,扩容次数为logn,每次扩容的时间复杂度是n。如此增删改查可以认为是常数。Java甚至是在用户不活跃时(离线),偷偷扩容,这样用户使用时无感。

二、设计RandomPool结构

【题目】设计一种结构,在该结构中有如下三个功能:
1)insert(key):将某个key加入到该结构,做到不重复加入
2)delete(key):将原本在结构中的某个key移除
3)getRandom(): 等概率随机返回结构中的任何一个key。
【要求】Insert、delete和getRandom方法的时间复杂度都是O(1)
【解答】结构(Pool)中维护一个KEY和序号的映射,和一个序号和KEY的映射,以及一个序号,即可实现O(1)

三、布隆过滤器

  1. 主要应用场景是,类似黑名单,比如用一个名单记录已经爬过的URL,防止重复爬取。当样本量 n 为大数据量时,比如100亿,那么常规的HashSet需要数百G的内存,这显然是不行的。
  2. 借助于 int[] ,实现一个长度为 m位数组。准备 k 个哈希函数。新增时,分别计算每个哈希函数,然后分别 %m ,把得到的k个数在位数组中标记为1(涂黑)。查询同理。
  3. 此方法虽然优秀,但是不能删除,且有不可避免的失误率p,有可能会把白名单的数据判断为黑名单,不过完全可以接受。
  4. 公式如下,目的是根据设定的,样本量 n期望的错误率 p,计算出,位数组长度m哈希函数个数k
  5. 单个样本的大小其实无关紧要。先后代入公式1和2(2向上取整),求出m和k。
  6. m决定了所需内存大小,内存当然会有富裕,此时,将真实的内存(折算成m)和k代入3,则得到真实的误差率。
m=nlnp(ln2)2(1)m = - \frac{n\ln{p}}{(\ln{2})^2} \tag{1}
k=mnln2(2)k = \frac{m}{n}\ln{2} \tag{2}
p(1eknm)k(3)p_真 \approx \left(1 - e^{-k_真\frac{n}{m_真}}\right) ^ {k_真} \tag{3}

四、一致性哈希

  1. 可以解决数据服务器的负载均衡问题
  2. 原始的硬模方式,比如%3,的确可以将数据均分到三台机器上,但是增加服务器,或者减少服务器时,可能需要全量数据的迁移,十分不友好。
  3. 一致性哈希的做法时,设想一个环,将哈希算法的结果集收尾相连。1 ~ 2^64 - 1 。(假设三台负载)三个服务器随机分配1000个字符串,字符串们经过同样的哈希函数计算后,得到3000个环上的点,且可以认为是均匀落在环上的,用3000个点分割三个服务器的归属权。这样再增加或删除负载节点时,就不必全量迁移了,十分强力!

五、岛问题

一个矩阵中只有0和1两种值,每个位置都可以和自己的上、下、左、右 四个位置相连,如果有一片1连在一起,这个部分叫做一个岛,求一个矩阵中有多少个岛?如下所示有三个岛

001010111010100100000000(1)\begin{matrix} 0 & 0 & 1 & 0 & 1 & 0 \\ 1 & 1 & 1 & 0 & 1 & 0 \\ 1 & 0 & 0 & 1 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 \end{matrix} \tag{1}
  1. 其实很简单......从左至右,从上至下,依次遍历。当发现是1时,开始进行感染操作,即把1变为2,再向四周递归直至边界。
  2. 时间复杂度:因为每一个点被操作的次数是有限的,无非是遍历时,以及被上下左右递归时。所以是O(n * m)
  3. 【进阶】设计多线程解决此问题

六、并查集

  1. 当面对大量若干集合之间的合并(union),**是否同集合(isSameSet)**操作时,常规的数据结构只能照顾一方,比如HashSet虽然同集查询快,但是合并需全量;链表虽然合并快,但是查询需全量。
  2. 并查集原理:为每一个元素增加一个指针,指向自己(用HashSet来管理),自己充当自己的头部;再维护一个HashSet记录每个集合的元素数量(K是头,V是数);当合并时,把较小的集合的头指向另一个;当查询是否同集时,验证头是否同一即可。
  3. 优化算法:在寻找头(findHead)的过程中,顺便可以将一路的叶子节点,都指向头,从而极大幅度减小下一次findHead的时间
  4. 经过优化后,可以认为:当findHead倒到n时,findHead的时间复杂度为O(1)

第二课

一、搜索二叉树

  1. Binary Search Tree(搜索二叉树),每个节点,都大于自己的所有左孩子,都小于自己的所有的右孩子;理论上来说,BST是没有重复节点的,因为只要每个节点维护一个自己出现的次数,或者一个列表,即可。
  2. 有序表,是用搜索二叉树来实现的;
  3. 不难想象BST的增删查操作,查询大/小于某值的最近值操作,都很容易实现。
  4. 传说中的红黑树AVL树SB树(Size balanced tree),本质上都是BST,他们仅仅是在增删的时候,加入了一些独有的,复杂规则的,左旋/右旋操作,使得树维持相对平衡。如此,查询时的时间复杂度可以控制在logn。
  5. AVL树,是严格平衡的BST,即每个节点的左右子树,高度差不会超过1。
  6. SB树,维持的是,每个节点的规模(Size)都不能小于他的两个侄子节点的规模。规模就是节点个数。
  7. 红黑树,本质上维护的是,每个节点的两个子树的最大高度差不能相差一倍以上

二、跳表

  1. SkipList(跳表),当积累了一定量的数据后,会发现他的样子和二叉树是相似的。它是利用随机函数打破了输入规律。

第三课

一、KMP算法

  1. KMP算法解决的问题:字符串str1是否包含str2,如果包含返回str2在str1中开始的位置。str1长度为n,str2长度为m。
  2. 常规的方式,时间复杂度是O(n * m),但是KMP算法可以做到O(n)
  3. 流程1:首先针对str2制作一个全新的数字数组(info[m.length] ),记录str2每一个位置之前(不包含自己这个字符)的首尾重复子串长度的最大值。比如 "aab1efaab1" 的信息数组为:【-1,0,1,0,0,0,0,1,2,3】。首个位置默认是 -1。
  4. 流程2:开始遍历str1,从第一个字母开始,看是否能完美适配str2,当遍历了L个元素 后,发现不能,则不必从str1的第二个字母继续,而是可以放心大胆的跳过 L-info[L]-1个元素 ,继续比较(这是因为倘若移动一位后,真的发现能适配str2,那只能说明流程1的信息数组求错了!info[L] 的值应该是L-1! );再者,由于str2 的 前info[L]个元素 已经在刚刚和str1的 L-info[L]+1 ~ L间接比较过,是一致的,故而接下来,只需要继续比较str1的 L+1 和 str2 的 info[L]+1 即可。
  5. 流程3:重复流程2,直至找到结果,或者遍历结束。
  6. 由于str1的指针不会回退,所以算法时间复杂度是O(n)
  7. 信息(info)数组的求法:info[0] 固定是 -1,info[1] 固定是0。然后第 i 位,由之前的答案推导出即可。(把str2转成数组)假如 str2[i-1] 和 str2[info[i-1]] 相同,那么 info[i] 就应该是 info[i-1] + 1;假如不相同,则再先前逼近(str2[info[info[i-1]]])......周而复始,这样的算法复杂度是O(m)。
public static int getIndexOf(String s, String m) {
    if (s == null || m == null || m.length() < 1 || s.length() < m.length()) {
        return -1;
    }
    char[] str1 = s.toCharArray();
    char[] str2 = m.toCharArray();
    int i1 = 0;
    int i2 = 0;
    int[] next = getNextArray(str2);
    while (i1 < str1.length && i2 < str2.length) {
        if (str1[i1] == str2[i2]) {
            i1++;
            i2++;
        } else if (next[i2] == -1) {
            i1++;
        } else {
            i2 = next[i2];
        }
    }
    return i2 == str2.length ? i1 - i2 : -1;
}

public static int[] getNextArray(char[] ms) {
    if (ms.length == 1) {
        return new int[] { -1 };
    }
    int[] next = new int[ms.length];
    next[0] = -1;
    next[1] = 0;
    int i = 2;
    int cn = 0;
    while (i < next.length) {
        if (ms[i - 1] == ms[cn]) {
            next[i++] = ++cn;
        } else if (cn > 0) {
            cn = next[cn];
        } else {
            next[i++] = 0;
        }
    }
    return next;
}

二、Manacher算法

  1. 解决的问题:字符串str中,最长回文子串的长度如何求解?
  2. 常规方式:从左至右遍历,把每个点和缝隙作为对称轴,求回文直径,取最大值即可。算法复杂度是n^2,而Manacher可以做到n
  3. 还是常规的思路,只不过进行了优化加速!维护一个最右回文有边界R,和对应中心C,每次计算 i 位置的回文半径时,通过C做点 i 的对称点 i' ,可以根据 i' 的回文半径来迅速得出 i 的回文半径。
  4. 大致可以分为四种情况:
    1)i 不在R内:暴力计算以 i 为对称轴的回文半径,然后更新RC
    2)i 在R内且 i' 半径也在R内:i 的回文半径,就等于 i' 的回文半径
    3)i 在R内且 i' 半径压线R:从R开始继续校验 i 的回文情况,然后向右拓展R的范围
    4)i 在R内且 i' 半径超过R:i 的回文半径,就等于 i' - L(R关于C的对称点)

第四课

一、滑动窗口算法

  1. 面对一个 int 数组,使用两个边界 LR(L < R)来框定一个范围,两个边界只能单向移动,随着 L 和 R 的移动,窗口范围也会变化。请提供一个算法,快速求取每时每刻窗口内的最大值(最小值)
  2. 额外维护一个双边队列,当求最大值时,我们定义这个队列从头至尾单调递减,当R每扩入一个值时,就和尾部比较,假如小于尾部值,就弹出尾部(丢掉),在进行比较,等到大于等于尾部时,从尾部入列;当L每排出一个值时,只需要看头部是不是这个值,是则头部出列(丢掉),否则不动。
  3. 周而复始,每时每刻窗口内的最大值就是双边队列的头部值
  4. 本质上:双边队列的目的是,动态维护了一个窗口内最大值依次罗列的序列

二、滑动窗口应用

【题目】
有一个整型数组arr和一个大小为w的窗口从数组的最左边滑到最右边,窗口每次向右边滑一个位置。如果数组长度为n,窗口大小为w,则一共产生n-w+1个窗口的最大值。
请实现一个函数。 输入:整型数组arr,窗口大小为w。输出:一个长度为n-w+1的数组res,res[i]表示每一种窗口状态下的最大值。
例如,数组为[4,3,5,4,3,3,6,7],窗口大小为3时,结果应该返回{5,5,5,4,6,7}:

[4 3 5]4 3 3 6 7
4 [3 5 4]3 3 6 7
4 3 [5 4 3]3 6 7
4 3 5 [4 3 3]6 7
4 3 5 4 [3 3 6]7
4 3 5 4 3 [3 6 7]

三、单调栈

  1. 在数组中,想找到每个数,左边和右边比这个数大、且离这个数最近的位置。
  2. 先假设数组中无重复值,算法流程是:额外维护一个严格单调递增的栈(从栈底到栈顶严格递增);依次遍历数组,尝试入栈(需符合单调性),能入则入;当不能入栈时,则弹出栈顶,弹出这个元素的左侧距离最近的比它大的值,就是栈中它下面压着的元素(没有元素就是它左侧没有比它大的值),弹出这个元素右侧距最近的比它大的值,就是当前遍历到的元素......周而复始,每弹出一个元素,就确定了弹出这个元素的左右信息,直到大于栈顶入栈后,继续遍历下个数组元素。如此遍历完整个数组再把栈中剩余的信息依次出栈(它们右侧都没有比自己大的值)
  3. 当数组中有重复值时,只需要把栈改为数组类型,入栈时,只要发现是等于栈顶,则和栈顶并入一个数组即可,出栈时,同时对数组中所有元素确定左右信息即可。
  4. 可以看到额外维护的这个栈是具有严格单调性的。

四、单调栈应用

定义数组中某范围内累积和与最小值的乘积,假设叫做指标A。
给定一个数组,请返回子数组中,指标A最大的值。

  1. 用上述思路求出每个值的左右比自己小的最近值,从而框定范围,并算指标A。(当然每个值自己本身就是此范围内的最小值!)再求取这些指标A的最小值即可!
  2. 虽然有了各种数据结构的算法,但是做题时还是难以想到,因此还是需要刷题......

第五课

一、Morris遍历

  1. 聪明的利用了,叶子节点空闲的指针。从而避免了递归过程中系统用到的额外栈空间,或者自己压栈申请的额外栈空间。
  2. 具体策略:
假设来到当前节点cur,开始时cur来到头节点位置
1)如果cur没有左孩子,cur向右移动(cur = cur.right)
2)如果cur有左孩子,找到左子树上最右的节点mostRight:
    a.如果mostRight的右指针指向空,让其指向cur,
    然后cur向左移动(cur = cur.left)
    b.如果mostRight的右指针指向cur,让其指向null,
    然后cur向右移动(cur = cur.right)
3)cur为空时遍历停止
  1. 大致流程:先向左遍历,到尽头后依次返回并且遍历右节点。所以每个左不为空的节点,都会被经过两次,而左为空的节点,只会经过一次
  2. 可以把Morris遍历改为前中后序遍历。简单,控制其是第一次打印还是第二次打印即可
  3. 后序遍历也可由morris遍历加工得到,但是把处理时机放在,能够达到两次的节点并且是第二次到达的时候,到达时,逆序打印左树的有边界。最后再逆序打印整棵树的右边界。
  4. 使用Morris遍历的目的,就是追求比经典递归更低的时间空间复杂度,所以逆序打印右边界时,也不能增加时间复杂度,其实很简单:就是单链表的不用额外空间逆序打印的思路,逆一遍指针、打印、再把指针逆回来即可!
  5. 经典方式遍历二叉树,时间复杂度是 n;Morris遍历也是 n。解释:虽然Morris每次都要遍历找到左子树的右边界,但是就是把逼近 n 的节点遍历2遍而已,所以Morris 就是 n。重点是Morris没有用到额外的空间!

二、树形DP

之前学习过

三、两者对比

当发现流程依赖于左右节点的处理结果时(必须要第三次的处理),此时只能使用树形DP思路;否则才可以使用Morris(降低空间复杂度)

第六课 大数据

零、总览

  1. 哈希函数可以把数据按照种类均匀分流
  2. 布隆过滤器用于集合的建立与查询,并可以节省大量空间
  3. 一致性哈希解决数据服务器的负载管理问题
  4. 利用并查集结构做岛问题的并行计算
  5. 位图解决某一范围上数字的出现情况,并可以节省大量空间
  6. 利用分段统计思想、并进一步节省大量空间
  7. 利用堆、外排序来做多个处理单元的结果合并

一、求未出现的数

32位无符号整数的范围是0~4,294,967,295,现在有一个正好包含40亿个无符号整数的文件,所以在整个范围中必然存在没出现过的数。可以使用最多1GB的内存,怎么找到所有未出现过的数?【进阶】内存限制为 10MB,但是只用找到一个没出现过的数即可

  1. 使用长度为40亿的位数组即可,需要总空间为5亿B = 50万KB = 500MB。
  2. 进阶的做法是,使用一个固定长度的int数组,每个数记录的是某范围内样本出现的次数,对40亿个样本进行遍历,这样必然能找到哪个范围内有空缺,针对那个范围再进行重新划分范围,周而复始,一定能找出

二、求高频词

有一个包含100亿个URL的大文件,假设每个URL占用64B,请找出其中所有重复的URL【拓展】某搜索公司一天的用户搜索词汇是海量的(百亿数据量),请设计一种求出每天热门Top100词汇的可行办法

  1. 哈希分流,然后单独求,最后再汇总
  2. 汇总求出前100的技巧:每个小组自己的TOP100形成一个大根堆,然后每组弹出一个元素,汇总形成总大根堆。随后总大根堆弹出一个元素,记录,同时这个元素所属的小组再弹出一个进入总根堆,循环即可。

三、题目一拓展

32位无符号整数的范围是0~4294967295,现在有40亿个无符号整数,可以使用最多1GB的内存,找出所有出现了两次的数。
【补充】可以使用最多10MB的内存,怎么找到这40亿个整数的中位数?

  1. 题目一的拓展,位图每个位置配置2位即可
  2. 求中位数,依旧采用分段统计的方式

补、限制内存排序

大小为10G的一个文件,里面是无序的一堆整数。希望利用5G内存,将其排序输出到另一个文件中。

  1. 4字节整数的范围是 -2 ^ 31 ~ 2 ^31 - 1 ,合理利用现有内存,给分段,然后针对每个分段进行排序,假如分了100份儿,要遍历10G一百次,即可。
  2. 手写一个堆,每个元素是两个整型(一个用于描述样本,另一个是描述该样本的频次),使用此堆为每段排序:把10G样本中,在当前数段内的值都加载到堆中,再依次弹出即可。
  3. 另一个思路:也不用分数值段了,当确定好堆的容量后,直接过一遍样本即可拿到最大/小的若干(堆容量个数)值,用一个变量记住本次的极值,再以此值为边界,继续过一遍样本求出若干值,周而复始即可。

四、位运算

位运算的题目,给定两个有符号32位整数a和b,返回a和b中较大的。
【要求】不能做任何比较判断。

  1. 取反函数:跟 1 异或即可
public static int flip(int n) {
	return n ^ 1;
}
  1. 获取符号位函数:整型向→移动31位,然后与1。如此负数返回1,整数返回0。根据需要在取反
public static int sign(int n) {
	return flip((n >> 31) & 1);
}
  1. 综合运用:
public static int getMax(int a, int b) {
    int c = a - b;
    int scA = sign(c);
    int scB = flip(scA);
    return a * scA + b * scB;
}

五、幂问题

判断一个32位正数是不是2的幂、4的幂

  1. 是2的幂,意味着,2进制写法中只有一个1,用 x & (x-1) 必须等于0,即可
  2. 是4的幂,首先必须是2的幂,然后与16进制的 55555555 不能等于0,即可

六、不使用加减乘除,实现加减乘除

第七课 递归&动态规划

零、动态规划

  1. 动态规划,都是用递归改出来的。仅仅是减少暴力递归中重复过程的技巧整,而已!
  2. 首先写好完整的递归,然后会发现每一次调用递归函数,都是传入2~3个值,返回1个值(我们可以有意识的把递归写成这个样子);那么其实我们可以准备一个二维数组,每次把递归函数的返回值记录一下,等到下一轮调用时先查一下,这样就避免了重复运算。
  3. 再进一步,我们可以直接用两层for循环,来针对这个二维数组进行运算。这就算是改成了动态规划。
  4. 固定思路:根据递归算法确定变量,几个变量就是几维数组;确定base case,和数组范围;标记最终答案在数组的几杠几;最后根据递归的推导顺序开始for循环,直到最终答案位置!
  5. 改好后的时间复杂度,就是多维数组的规模!比原本递归要小!
  6. 【进阶】改好的动态规划算法,可以更进一步的优化!大体分为如下方式:1)四边形不等式;2)枚举过程的状态化简;3)复杂状态用位信息代替;4)用业务反推动态规划表的初始状态
  7. 下方列举一些经典题目

题一

机器人达到指定位置方法数
【题目】
假设有排成一行的 N 个位置,记为 1 ~ N,N 一定大于或等于 2。开始时机器人在其中的 M 位置上(M 一定是 1 ~ N 中的一个),机器人可以往左走或者往右走,如果机器人来到 1 位置, 那么下一步只能往右来到 2 位置;如果机器人来到 N 位置,那么下一步只能往左来到 N-1 位置。规定机器人必须走 K 步,最终能来到 P 位置(P 也一定是 1 ~ N 中的一个)的方法有多少种。给定四个参数 N、M、K、P,返回方法数。

【举例】
N=5,M=2,K=3,P=3
上面的参数代表所有位置为 1 2 3 4 5。机器人最开始在 2 位置上,必须经过 3 步,最后到达 3 位置。走的方法只有如下 3 种: 1)从2到1,从1到2,从2到3 2)从2到3,从3到2,从2到33)从2到3,从3到4,从4到3所以返回方法数 3。

N=3,M=1,K=3,P=3
上面的参数代表所有位置为 1 2 3。机器人最开始在 1 位置上,必须经过 3 步,最后到达 3位置。怎么走也不可能,所以返回方法数 0。

题二

换钱的最少货币数
【题目】
给定数组 arr,arr 中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数 aim,代表要找的钱数,求组成 aim 的最少货币数。

【举例】
arr=[5,2,3],aim=20。4 张 5 元可以组成 20 元,其他的找钱方案都要使用更多张的货币,所以返回 4。
arr=[5,2,3],aim=0。不用任何货币就可以组成 0 元,返回 0。
arr=[3,5],aim=2。根本无法组成 2 元,钱不能找开的情况下默认返回-1。

题三、拿牌博弈

  1. 十分有趣的思路:f1含义:当A先拿时,在该范围内,返回A能收益最大值;f2含义,当B先拿时,在该范围内,返回A能收益最大值
public static void main(String[] args) {
    int[] arr = new int[]{1, 2, 100, 4, 200};
    System.out.println(f1(arr, 0, arr.length - 1));
}
static int f1(int[] arr, int l, int r) {
    if (l == r) {
        return arr[l];
    }
    int m = f2(arr, l + 1, r) + arr[l];
    int n = f2(arr, l, r - 1) + arr[r];
    return Math.max(m, n);
}
static int f2(int[] arr, int l, int r) {
    if (l == r) {
        return 0;
    }
    int m = f1(arr, l + 1, r);
    int n = f1(arr, l, r - 1);
    return Math.min(m, n);
}

题四、马走日

【题目】
请同学们自行搜索或者想象一个象棋的棋盘,然后把整个棋盘放入第一象限,棋盘的最左下角是(0,0)位置。那么整个棋盘就是横坐标上9条线、纵坐标上10条线的一个区域。给你三个参数,x,y,k,返回如果“马”从(0,0)位置出发,必须走k步,最后落在(x,y)上的方法数有多少种?

题五、Bob的生存概率

【题目】
给定五个参数n,m,i,j,k。表示在一个N * M的区域,Bob处在(i,j)点,每次Bob等概率的向上、下、左、右四个方向移动一步,Bob必须走K步。如果走完之后,Bob还停留在这个区域上,就算Bob存活,否则就算Bob死亡。请求解Bob的生存概率,返回字符串表示分数的方式。

  1. 考虑使用“最大公约数”的知识

题六

有若干面值的货币,每种面值有无数张,给定一个金额总数。求累计和为此面值,的拿钱方案有多少种

题七、矩阵的最小路径和

给定一个矩阵 m,从左上角开始每次只能向右或者向下走,最后到达右下角的位置,路径上所有的数字累加起来就是路径和,返回所有的路径中最小的路径和。