好奇的探索者,理性的思考者,踏实的行动者。
算法
算法的分析
时间分析
渐进记号
渐近紧确界记号: Θ(big-theta)
渐近紧确上界记号:O(big-oh)
渐近紧确下界记号:Ω(big-omege)
非渐近紧确上界: o(小-oh)
非渐近紧确下界: ω(小-omege)
常见复杂度
O(1) 常量级复杂度
O(n) 线性级
O(logn) 对数级
O(log3n) = O(C * log2n) 其他底的都可以转换成以2为底的
O(nlogn) 线性对数级
O(n^2) 平方,立方,k次方级
O(2^n) 指数级
O(n!) 阶乘级, 全排列
递归的复杂度
递推公式
归并排序:T(n) = T(n/2) + T(n/2) + n T(1) = 1
T(n) = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
= 2^k * T(n/2^k) + k * n
由:T(1) = 1 n/2^k = 1 得: k=log(n)
带入上式得: T(1) = n + nlog(n)
递归树法(比较直观)
主定理法
空间分析
最好、最坏情况时间复杂度、平均情况时间复杂度(往往等于最坏情况时间复杂度)
摊还分析(平摊分析)
在n次操作中有一次耗时的操作O(n),把耗时多的那次操作均摊到接下来的 n-1
次耗时少的操作上,均摊下来,这一组**连续的操作**的均摊时间复杂度就是O(1)
典型的有:数组的扩张
算法下界的分析
决策树模型
查找
二分查找
迭代版
int binarySearch(arr, target){
int low = 0;
int high = arr.length - 1
if (arr[low] > target || arr[high] < target){
return -1
}
//核心是每次循环一次就把搜索的区间缩小一半
while(low <= high) { //注意是<=,若是<的话,会漏掉最后只剩一个的情况
int middle = low+((high-low)>>1)
if(arr[middle] > target) {
high = middle - 1 //注意缩小区间的时候要加1减1
} else if (arr[middle] < target) {
low = middle + 1
} else {
return middle
}
}
return -1;
}
递归版
private int bsearchInternally(int[] a, int low, int high, int value) {
if (low > high) return -1;
int mid = low + ((high - low) >> 1);
if (a[mid] == value) {
return mid;
} else if (a[mid] < value) {
return bsearchInternally(a, mid+1, high, value);
} else {
return bsearchInternally(a, low, mid-1, value);
}
}
布隆过滤器
主要的作用就是判断一个给定的值是否存在
bloom filter: False is always false. True is maybe true.
算法判断key在集合中时,有一定的概率key其实不在集合中
布隆过滤器非常适合这种不需要 100% 准确的、允许存在小概率误判的大规模判重场景。
布隆过滤器的误判率,主要跟哈希函数的个数、位图的大小有关。当我们往
布隆过滤器中不停地加入数据之后,位图中不是 true 的位置就越来越少了,误判率就越来
越高了。所以,对于无法事先知道要判重的数据个数的情况,我们需要支持自动扩容的功能。
排序
分析排序算法:
* 排序算法的执行效率
1.最好情况、最坏情况、平均情况时间复杂度
2.时间复杂度的系数、常数 、低阶
3.比较次数和交换(或移动)次数
* 排序算法的内存消耗
* 原地排序(Sorted in place)。
* 排序算法的稳定性
* 内存排序or外部排序
计数排序(Counting sort)
原理就是用数组来把要排序的数字大小统计数量
适用于数据范围不大的场景中
基数排序
将整数按位数切割成不同的数字,然后按每个位数分别比较(位比较的时候可以用计数排序)。
典型问题:对10万个手机号码排序
桶排序
核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序
适合无法全部加载到内存的外部排序
快速排序
target: sort A[p..r]
partition(A,p,r) // 分为三部分,【小于等于x】,【大于x】,【未处理的】
x = A[r] // 最后一个元素
i = p - 1 // 指向【小于等于x】最后一个元素
for j = p to r - 1 // j指向【未处理的】第一个元素
if A[j] <= x // 小的交换,大的直接加1
i++
exchange(A[i], A[j])
exchange(A[i+1], A[r])
return i + 1
递归版本
quick_sort(A,p,r)
if p < r
q = partition(A,p,r)
quick_sort(A,p,q-1)
quick_sort(A,q+1,r)
非递归版本
quick_sort(A,left,right)
{
PUSH(left,right);
while(STACK_IS_NOT_EMPTY)
{
POP(lo,hi);
if(lo < hi)
{
int mid = partition(A,lo,hi);
PUSH(lo,mid-1);
PUSH(mid+1,hi);
}
}
}
归并排序
merge_sort(A,p,r)
if p < r //终止条件
q = floor((p + r)/2)
merge_sort(A,p,q)
merge_sort(A,q+1,r)
merge(A,p,q,r)
merge(A,p,q,r)
L = A[p:q],R = A[q+1:r]
i = 1,j = 1
for k = p to r
if L[i] <= R[j]
A[k] = L[i]
i++
else
A[k] = R[j]
j++
堆排序(不稳定)
heap_sort(A)
build_max_heap(A)
for i = A.length downto 2
exchange(A[1], A[i])
A.heap_size = A.heap_size - 1
max_heapify(A,1)
冒泡
for(i = 0; i < n; i++){
int flag = false;
for(j = 0; j < n - i - 1; j++){
if (A[j] > A[j+1]){
flag = 1;
swap(&A[j], &A[j+1]);
}
}
if(!flag)
break;
}
插入(跟抓扑克牌原理一样)
insertion_sort(A)
for j = 2 to A.length
key = A[j] //待处理的元素
i = j - 1
while i > 0 and key < A[i]
A[i + 1] = A[i]
i--
A[i + 1] = key
选择排序
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序
每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾
选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。
顺序统计量
即寻找数组中第N个大的数字,Θ(n),以快排算法的parttion部分为模型,因为parrtion部分返回
的pivot就是它的下标的第几大的值
算法思想
递归
利:是递归代码的表达力很强,写起来非常简洁;
弊:就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用开销
关键步骤:
1. 找到如何将大问题分解为小问题的规律,比如:n规模的问题能不能通过n-1的规模(子问题)或多个n-x的规模之和来解决。
2. 并且基于此写出递推式
3. 然后再推敲终止条件,终止条件可以有好几个,这个是需要注意的,对应于上面的多个n-x的规模之和
4. 用几个小规模的问题验证一下有没有问题
5. 最后将递推公式和终止条件翻译成代码
典型问题:
求二叉树的节点数 O(log(n))
count_tree(Tree)
if(Tree = NULL)
return 0;
return 1 + count_tree(Tree.left) + count_tree(Tree.right)
归并排序,快速排序 O(nlog(n))
上台阶问题,等同于(斐波那契数列) O(2^n)
有n个台阶,每次你可以跨1个或者2个台阶,请问走这 n个台阶有多少种走法?
n的问题规模可以通过子问题来求解
递推公式:f(n) = f(n-1) + f(n-2) #递归数会指数膨胀
递归基:f(1) = 1; f(2) = 2; 因为问题规模被划分成两个,这两个都的解出才有结果
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2);
}
减治法
每次问题的规模都缩小
分治法
将原问题划分成n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后
再合并其结果,就得到原问题的解。
分治算法是一种处理问题的思想,递归是一种编程技巧
分解:将原问题分解成一系列子问题;
解决:递归地求解各个子问题,若子问题足够小,则直接求解;
合并:将子问题的结果合并成原问题。
分治能解决的问题满足的条件:
1.原问题与分解成的小问题具有相同的模式;
2.原问题分解成的子问题可以独立求解,子问题之间没有相关性
3.具有分解终止条件,也就是说,当问题足够小时,可以**直接求解**;
4.可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法
总体复杂度的效果了。
例子:
归并排序
统计一组数据中的逆序对个数
海量数据处理应用
将任务分配到分布的多台机器上并行的处理,最后再将结果合并在一起
MapReduce 框架只是一个任务调度器,底层依赖 GFS 来存储数据,依赖 Borg
管理机器。它从 GFS 中拿数据,交给 Borg 中的机器执行,并且时刻监控机器执行的进
度,一旦出现机器宕机、进度卡壳等,就重新从 Borg 中调度一台机器执行
最大子数组
find_max_crossing_subarray(A,low,mid,high)
left_sum = null
sum = 0
for i = mid downto low
sum += A[i]
if sum > left_sum or left_sum == null
left_sum = sum
max_left = i
right_sum = null
sum = 0
for j = mid + 1 to high
sum += A[j]
if sum > right_sum
right_sum = sum
max_right = j
return (max_left, max_right, left_sum + right_sum)
find_max_subarray(A,low,high)
if hight = low
return (low, high, A[low])
else
mid = floor((low + high)/2)
find_max_subarray(A,low,mid)
find_max_subarray(A,mid+1,hight)
find_max_crossing_subarray(A,low,high)
比较,然后返回最大的
回溯算法
很多时候都应用在“搜索”这类问题上
不过这里说的搜索,并不是狭义的指我们前面讲过的图的搜索算法,而是在一组可能的解中,搜索满足期望的解。
回溯算法非常适合用递归代码实现
基本思路是:寻找的问题解不满足时,回溯到上一步骤,继续问题的求解
回溯算法本质上就是穷举所有情况,然后对比得到最优解。优点在于其类似于摸着石头过河的查找策略,且可以通过剪枝
少走冤枉路。它可能适合应用于缺乏规律,或我们还不了解其规律的搜索场景中。
典型问题:
深度搜索
八皇后
ANSWER_COUNT = 0
MAXNUM = 4
# 检查当前坐标是否能放置queen
# 只需检查当前坐标的↖↑↗这三个坐标上侧方向
# 因为本行的落子不受下一行的影响
# x第几行 y第几列
def check(x,y,chess):
for i in range(x): # i [0,x) 行的值永远不会出界
# 检查竖向↑(只需检查当前行之上的行)
if(chess[i][y]==1):
return False
# 检查左上斜方向↖
if(y-1-i >= 0): #
if(chess[x-1-i][y-1-i] == 1):
return False
# 检查右上斜方向↗
if(y+1+i < MAXNUM):
if(chess[x-1-i][y+1+i] == 1):
return False
return True
# 打印棋盘
def print_chess(chess):
print('answer {}:'.format(ANSWER_COUNT))
for i in range(MAXNUM):
for j in range(MAXNUM):
print(chess[i][j],' ',end='')
print('\n')
print('\n')
# 递归求解8个queen的放置方式
def find_queen8(x,chess):
global ANSWER_COUNT,MAXNUM
# 临时棋盘,复制上一行的queen皇后落子后的棋盘
chess_temp = [[0 for col in range(MAXNUM)] for row in range(MAXNUM)]
for i in range(MAXNUM):
for j in range(MAXNUM):
chess_temp[i][j] = chess[i][j]
# x等于棋盘边界,代表已最后一行的queen已经落子,打印结果并返回
if(x == MAXNUM):
ANSWER_COUNT = ANSWER_COUNT + 1
print_chess(chess_temp)
return
# 遍历当前行的每一列
for i in range(MAXNUM):
if(check(x,i,chess_temp)):
chess_temp[x][i] = 1
#从这一行返回分两种情况:1.遍历到最后一行找到结果 2.遍历某一行后都没有通过检查
find_queen8(x+1,chess_temp)
chess_temp[x][i] = 0 #回溯到之前成功或失败的哪一步
def main():
#初始棋盘
chess = [[0 for col in range(MAXNUM)] for row in range(MAXNUM)]
#从第0行开始
find_queen8(0,chess)
#打印结果
print('求解{}皇后问题,共有{}个答案: '.format(MAXNUM, ANSWER_COUNT))
main()
动态规划
我们把问题分解为多个阶段(从第到高),每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),
然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。
适合问题
一个模型三个特征:
多阶段决策最优解模型、
最优子结构、
问题的最优解包含子问题的最优解,子问题的最优解推导父问题最优解,即后面阶段的状态可以通过前面阶段的状态推导出来
无后效性
重复子问题
方法:
1.带备忘录的自顶向下
2.自底向上
状态转移表法,通过画状态转移表来深刻理解问题所有状态的动态转移
回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码
状态转移方程法
找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码
某个问题如何通过子问题来递归求解,也就是所谓的最优子结构,比如锯钢条问题 r(n) =
状态转移方程是解决动态规划的关键。如果我们能写出状态转移方程,那
动态规划问题基本上就解决一大半了,而翻译成代码非常简单
典型问题:
钢条切割问题,
p:价格数组 n:切割总长度 r:最优解数组
cut_rod(p,n) // O(2^n)
if n == 0
return 0
q = -∞
for i = 1 to n
q = max(q,p[i] + cut_rod(p,n-i))
return q
memoized_cut_rod(p,n)
let r[0..n] be a new array
for i = 0 to n
r[i] = -∞
return memoized_cut_rod_aux(p,n,r)
memoized_cut_rod_aux(p,n,r)
if r[n] >= 0
return r[n]
if n == 0
q = 0
else
q = -∞
for i = 1 to n
q = max(q,p[i] + memoized_cut_rod_aux(p,n-i,r))
r[n] = q
return q
bottom_up_cut_rod(p,n)
let r[0..n] be a new array
r[0] = 0
for j = 1 to n
q = -∞
for i = 1 to j
q = max(q,p[i]+r[j-1])
r[j] = q
return r[n]
贪心算法(greedy algorithm)
通过局部最优的选择,能产生全局的最优选择。
找零钱问题
活动选择、区间覆盖、任务调度、教师排课,选出最大的互相兼容的活动集合
每次都选择活动结束时间最早的活动
分数背包问题
贪心算法不能解决,得用动态规划
0-1背包问题
1.容量固定,计算能装进背包的物品组合的最大重量
2.容量固定,计算能装进背包的物品组合的最大价值
霍夫曼编码(Huffman Coding)
广泛用于数据压缩中,其压缩率通常在 20%~90% 之间
霍夫曼编码不仅会考察文本中有多少个不同字符,还会考察每个字符出现的频率,
根据频率的不同,选择不同长度的编码。
对于等长的编码来说,我们解压缩起来很简单。但不等长的编码解压缩就比较麻烦
为了避免解压缩过程中的歧义,霍夫曼编码要求各个字符的编码之间,不会出现某个编码是另一个编码前缀的情况。
Prim 和 Kruskal 最小生成树算法
Dijkstra最短路径
问题:
贪心算法并不一定能得到最优解
计算几何
点和线段
线段和线段
点和多边形
碰撞检测
数学
数论
字符串
单模式串匹配算法
概念:主串,长度n 模式串,长度m
BF算法(Brute Force)
中文叫作暴力匹配算法,也叫朴素匹配算法
拿模式串与主串中是所有子串匹配,看是否有能匹配的子串。
理论上最坏情况时间复杂度是 O(n*m),但,大部分情况下,算法执行效率要比这个高很多
RK算法(Rabin-Karp算法)
RK算法是借助哈希算法对BF算法进行改造,即对每个子串分别求哈希值,然后拿子串的
哈希值与模式串的哈希值比较,减少了比较的时间。 理想情况下,RK算法的时间复杂度是 O(n)
不过这样的效率取决于哈希算法的设计方法,如果存在冲突的情况下,时间复杂度可能会退化。
BM(Boyer-Moore)算法
它是一种非常高效的字符串匹配算法,有实验统计,它的性能是著名的KMP算法的3到4倍
在实际的软件开发中,特别是一些文本编辑器中,应用比较多
核心思想:
利用模式串本身的特点,在模式串中某个字符与主串不能匹配的时候,将模式串往后多滑动几位,
以此来减少不必要的字符比较,提高匹配的效率
坏字符规则(bad character rule)
好后缀规则(good suffix shift)
好后缀规则可以独立于坏字符规则使用。
因为坏字符规则的实现比较耗内存,为了节省内存,我们可以只用好后缀规则来实现 BM 算法。
KMP
KMP算法跟BM算法的本质是一样的在模式串与主串匹配的过程中,当遇到不可匹配的字符的时候,我们希望找到一些规
律,可以将模式串往后多滑动几位,跳过那些肯定不会匹配的情况。
适用于:字符集比较小的情况
int match ( char* P, char* T ) {
int* next = buildNext ( P );
int n = ( int ) strlen ( T ), i = 0; //文本串指针
int m = ( int ) strlen ( P ), j = 0; //模式串指针
while ( j < m && i < n ) //自左向右逐个比对字符
{
if ( 0 > j || T[i] == P[j] ) //若匹配,或P已移出最左侧(两个判断的次序不可交换)
{ i ++; j ++; } //则转到下一字符
else
j = next[j]; //模式串右移(注意:文本串不用回退)
}
delete [] next; //释放next表
return i - j;
}
int* buildNext ( char* P ) {
size_t m = strlen ( P ), j = 0; //“主”串指针
int* N = new int[m]; //next表
int t = N[0] = -1; //模式串指针
while ( j < m - 1 )
{
if ( 0 > t || P[j] == P[t] ) { //匹配
j ++; t ++;
N[j] = t; //此句可改进...
} else //失配
t = N[t];
}
return N;
}
多模式串匹配算法
字符串前缀查找
见:Trie数
AC自动机
该算法通过有限自动机巧妙地将字符比较转化为状态转移,此算法的时间复杂度与关键字的数目无关,
只跟文本长度有关,其时间复杂度为O(n)
AC 自动机实际上就是在 Trie 树之上,加了类似KMP的next数组,只不过此处的next数组是构建在树上罢了
AC自动机的构建
1.将多个模式串构建成Trie树;
2.在Trie树上构建失败指针(相当于 KMP 中的失效函数next数组)。
图形学
光照模型
视频
音频
网络
网络路由
推荐算法
相似性的判断
用欧几里得距离是用来计算两个向量之间的距离的。
分布式算法
Chord 协议
Kademlia(Kad)协议
概率算法
朴素贝叶斯算法
密码学
随机算法
对称加密
非对称加密(公钥加密)
单向散列函数
安全加密:hash(密码+salt)
唯一标识
数据校验
散列函数
负载均衡
数据分片
分布式存储
消息认证码
数字签名
机器学习
神经网络
多线程算法
操作系统
线程调度
内存分配
缓存淘汰
先进先出策略 FIFO(First In,FirstOut)
最少使用策略 LFU(Least Frequently Used)
最近最少使用策略 LRU(LeastRecently Used)
链表法
可以用链表保存,头存放最新的,尾放最旧的,每次有数据来时再动态调整位置。复杂度是O(n)
链表 + 散列表法
通过散列表和双向链表的组合使用,可以实现了一个高效的、支持 LRU缓存淘汰算法的缓存系统原型。
其中查找、删除和添加都可以在O(1)的时间完成
编程语言方面
垃圾回收算法
标记清除算法(python)
在一个有限图上进行遍历,遍历后分可达和不可达,不可达的会被清理
分代收集算法(一个优化手段)
为了防止垃圾回收启动太频繁,造成程序性能低下,把要处理的对象分成几代,
刚刚创立的对象是第 0 代;经过一次垃圾回收后,依然存在的对象,便会依次从上一代挪到下一代, 而每一代启动自动垃圾回收的阈值是可以单独指定的。当垃圾回收器中新增对象减去删
除对象达到相应的阈值时,就会对这一代对象启动垃圾回收。
数据结构
位图
from typing import Optional
class Bitmap:
def __init__(self, num_bits: int):
self._num_bits = num_bits
self._bytes = bytearray(num_bits // 8 + 1)
def setbit(self, k: int) -> None:
if k > self._num_bits or k < 1: return
# byteIndex = k // 8
# bitIndex = k % 18
self._bytes[k // 8] |= (1 << k % 8)
def getbit(self, k: int) -> Optional[bool]:
if k > self._num_bits or k < 1: return
return self._bytes[k // 8] & (1 << k % 8) != 0
if __name__ == "__main__":
bitmap = Bitmap(10)
bitmap.setbit(1)
bitmap.setbit(3)
bitmap.setbit(6)
bitmap.setbit(7)
bitmap.setbit(8)
for i in range(1, 11):
print(bitmap.getbit(i))
数组,动态数组
容器能否完全替代数组
链表
单链表
单链表反转:
typedef struct list{
int item;
struct list *next;
}List;
List* reverse(List* head)
{
if (head == NULL || head->next == NULL)
return head;
List* curr = head;
List* next = head -> next;
head->next = NULL; //首指向空
while (next)
{
List *next2 = next->next;
next->next = curr;
curr = next;
next = next2;
}
return curr;
}
删除、插入节点:
删除某个节点需要遍历才能知道其前驱节点,故:遍历+删除 O(n) + O(1)
从链表中删除一个数据的两种情况:
1.删除结点中“值等于某个给定值”的结点,此时链表需要遍历才能找到。
2.删除给定指针指向的结点,单链表此时就有劣势了。
双向链表
实际中一般都使用双向链表
删除、插入节点:
因知道其前驱节点,故:删除 O(1)
循环链表
优势:从链尾到链头比较方便
约瑟夫问题
N个人围成一圈,从第一个人开始报数,数到M的人出圈;
再由下一个人重新开始报数,数到M的人出圈
跳表(Skip list)
有序链表加多级索引的结构,就是跳表.
Redis 中的有序集合(Sorted Set)就是用跳表来实现的
跳表使用空间换时间的设计思路,通过构建多级索引来提高查询的效率,实现了基于链表的“二分查找”。
跳表是一种动态数据结构,支持快速的插入、删除、查找操作,时间复杂度都是 O(logn)。
难点:跳表索引动态更新,在加节点和删除节点的同时更新索引
堆(最小堆、最大堆)
(二叉)堆是一个数组,它可以被看成一个完全二叉树,除底层该数是完全充满的。
完全二叉树比较适合用数组来存储
最大堆满足:A[parent(i)] >= A[i] 父节点大于等于子节点
max_heapify(A,i) //O(log(n))跟高度一样 i为使最大堆性质失效的节点,通过让A[i]的值在堆中逐级下降,从而维护最大堆性质
l = left(i)
r = right(i)
//选出A[i],A[left(i)],A[right(i)],并将其存在largest中
if l <= A.heap_size and A[l] > A[i]
largest = l
else
largest = i
if r <= A.heap_size and A[r] > A[largest]
largest = r
if largest != i
exchange(A[i], A[largest])
max_heapify(A, largest)
build_max_heap(A) //常量级时间 O(n)
A.heap_size = A.length
for i = floor(A.length/2) downto 1 //对于完全二叉树来说,下标从A.length/2到n的节点都是叶子节点
max_heapify(A,i)
插入:
一般插入到最后,然后从下往上的堆化
删除:
如果删除堆顶的节点,把尾部的节点移到堆顶然后再堆化
应用:
堆排序:最大堆
优先队列:最小堆常用于构建优先队列
数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队
往优先级队列中插入一个元素,就相当于往堆中插入一个元素;
从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素
1. 合并有序小文件
2. 高性能定时器
利用堆求 Top K
静态数据
维护一个大小为K的小顶堆,顺序遍历数组,从数组中取出取数据与堆顶元素比较。
大-->删除堆顶,并插入新数据 小-->不处理
动态数据
一直都维护一个 K 大小的小顶堆,当新来了数据后与堆顶元素比较
大-->删除堆顶,并插入新数据 小-->不处理
利用堆求中位数、前百分比的数据
可以利用两个堆,大顶堆(放小数据)、小顶堆(放大数据),每次来数据了1.判断放那个堆中 2.维护两个堆的比例
实现其他算法的基础:
赫夫曼编码、图的最短路径、最小生成树算法等等
问题:
一个包含10亿个搜索关键词的日志文件,如何能快速获取Top10的搜索关键词?
1.将10亿的数据用hash分片成10个文件 2.扫描文件生成key->num的散列表 3.生成一个大小为10的小顶堆 4.再比较这10个堆求top10
栈
栈既可以用数组来实现(叫作顺序栈),也可以用链表来实现(叫作链式栈)
支持动态扩容的栈,只需底层依赖一个支持动态扩容的数组就可以了
当栈满了之后,就申请一个更大的数组,将原来的数据搬移到新数组中
应用场景:
* 函数调用栈
* 表达式求值,34+13*9+44-12/3 (使用两个栈,一个保存操作数,另一个是保存运算符)
* 括号匹配
* 浏览器前进、后退功能(需要两个栈,一个放访问过的,一个是放回退时push的,用于保留前进的页面)
队列
单队列
循环队列
阻塞队列
在队列基础上增加了阻塞操作,队列为空取阻塞,队列满插入阻塞
并发队列
线程安全的队列我们叫作并发队列,最简单的是直接在enqueue()、dequeue()上加锁
散列表(hash表) 散列函数+散列冲突解决
散列函数
1.不能太复杂,以免影响性能 2.生成的值要尽可能随机并且均匀分布
散列冲突解决
开放寻址法(open addressing)
线性探测方法(Linear Probing) 线性探测每次探测的步长是 1
二次探测(Quadratic probing) 探测探测的步长就变成了原来的“二次方”
双重散列(Double hashing) 先用第一个散列函数,若冲突,再用第二个散列函数
链表法(chaining)
装载因子
装载因子 = 填入表中的元素个数 / 散列表的长度
装载因子过大、过小怎么办?
动态扩容(1.重新申请内存空间 2.重新计算哈希位置 3.并且搬移数据)、动态缩容
如何避免低效地扩容?
1.达到阈值时申请空间 2.插入新数据时插到新空间,并把一部分旧数据搬移到新空间直到旧空间中的搬完
3.查找时先从新散列表中查找,如果没有找到,再去老的散列表中查找。
散列表碰撞攻击:精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里
散列表的查找缺点:
不能进行范围区间查找
树
数的表示
于指针的的链式存储法
基于数组的顺序存储法
多叉树-->可以用左孩子右兄弟发表示为二叉树
二叉树(简洁,结构规范,描述能力强)
统计节点数
递推公式:count(tree) = 1 + count(tree.left) + count(tree.right)
递归基:count(NULL) = 0
int count(Tree tree)
{
if(tree == NULL)
{
return 0;
}
else
{
return 1 + count(tree.left) + count(tree.right);
}
}
树的遍历
前序:
preOrder(r) = print r + preOrder(r->left) + preOrder(r->right)
中序:
inOrder(r) = inOrder(r->left) + print r + inOrder(r->right)
后序:
postOrder(r) = postOrder(r->left) + postOrder(r->right) + print r
前序非递归:
def preorder(root):
stack = []
stack.append(root)
while stack:
node = stack.pop()
print node.val
if node.right: //注意顺序
stack.append(node.right)
if node.left:
stack.append(node.left)
def preorder(root): //但用回溯策略,每次左子树遍历完之后才回溯,然后遍历右子树
stack = []
while root or stack:
if root:
print root.val
stack.append(root) # push root
root = root.left # visit left
else:
root = stack.pop() # backtrack parent node
root = root.right # visit right
中序非递归:
def inorder(root):
stack = []
while root or stack:
if root:
stack.append(root)
root = root.left
else:
root = stack.pop()
print root.val #跟先序的区别在于:打印的回溯时间点不同
root = root.right
获取树的高度
递归法,height(Tree) = 1 + max(height(Tree.left),height(Tree.right))
二叉查找树
左子树中的节点比自己小,右子树中的节点比自己大
支持快速查找、插入、删除操作
删除:
情况1:删除的节点没有子节点,直接删除
情况2:要删除的节点只有一个子节点(只有左子节点或者右子节点),更新子节点的父节点为删除节点的父节点
情况3:删除的节点有两个子节点,找到这个节点的右子树中的最小节点,把它替换到要删除的节点上
取巧的方法:标记删除而不真正的删除
支持重复数据的二叉查找树
方法1.相同的key通过链表或动态数组存
方法2.每个节点仍然只存储一个数据,若碰到相同的就将这个要插入的数据放到这个节点的右子树
优势:
中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),比起散列表来要高效的多
劣势:
极度不平衡的二叉查找树再插入删除会退化成链表,高度不是logn而是n。解决:平衡二叉树
红黑树
平衡二叉树严格定义:二叉树中任意一个节点的左右子树的高度相差不能大于1,
但一般也不用严格遵循,只要树的高度不比log(n)大很多就行
平衡树的一种,也是二叉搜索树,但节点多了一个color属性
定义:
1.每个节点或是红色,或是黑色
2.根节点是黑色的;
3.每个叶子节点都是黑色的空节点(NIL) 目的:为了简化红黑树的代码实现而设置的
4.任何上下相邻的节点都不能同时为红色
5.每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
实现效果:
高度最高不超过2log(n)
推论:
1.去除红色节点-->变成全黑的四叉树-->从四叉树中取出某些节点,放到叶节点位置变成完全二叉树,高度为log(n)
2.红色比黑色小,红色高度也小于log(n)
红黑树的平衡过程
红黑树的插入、删除操作会破坏红黑树的定义
方法:就像将打乱的魔方还原好一样,遇到什么样的节点排布,就用对应的公式去调整
调整过程是一个迭代的过程。我们把正在处理的节点叫作关注节点。关注节点
会随着不停地迭代处理,而不断发生变化
最开始的关注节点就是新插入的节点,要注意当前的关注点是哪一个才能对应到具体的哪一种情况
调整的过程包含两种基础的操作:左右旋转和改变颜色
插入(插入的节点必须是红色的):
case1、case2、case3
删除:
1.针对删除节点初步调整,使其满足定义5
case1、case2、case3
2.针对关注节点进行二次调整,使其满足定义4
case1、case2、case3、case4
AVL树
最先发明的高度平衡的二叉树,查找的效率非常高
为了维持这种高度的平衡,插入、删除的消耗比红黑树大
B树
B+ 树中的节点不存储数据,只是索引,而 B 树中的节点存储数据;
B 树中的叶子节点并不需要链表来串联。
Trie树(也叫“字典树”)
比较适合的是查找前缀匹配的字符串
本质是利用字符串之间的公共前缀,将重复的前缀合并在一起
Trie还应用在自动输入补全,比如输入法自动补全功能、IDE 代码编辑器自动补全功能、浏览器网址输入的自动补全功能等等
如果用来构建 Trie 树的这一组字符串中,前缀重复的情况不是很多,那 Trie 树这种数
据结构总体上来讲是比较费内存的,是一种空间换时间的解决问题思路。
对数据集的要求
字符串的字符集不能太大
前缀重合比较多
构造Trie树
结构
class TrieNode {
char data;
TrieNode children[26]; //当然也可以换成有序数组、跳表、散列表、红黑树等
}
在Trie树中查询一个字符串
时间复杂度是 O(k),k表示要匹配的字符串的长度。
查找前缀匹配的字符串
在从trie树根节点查找输入的单词,当达到最后一个字符后,以最后一个字符为根节点的子树便为匹配的字符串,
此时需要遍历这棵树,然后去除所有的_is_ending_char = true的节点
class TrieNode:
def __init__(self, data: str):
self._data = data
self._children = [None] * 26
self._is_ending_char = False
class Trie:
def __init__(self):
self._root = TrieNode("/")
def insert(self, text: str) -> None:
node = self._root
for index, char in map(lambda x: (ord(x) - ord("a"), x), text):
if not node._children[index]:
node._children[index] = TrieNode(char)
node = node._children[index]
node._is_ending_char = True
def find(self, pattern: str) -> bool:
node = self._root
for index in map(lambda x: ord(x) - ord("a"), pattern):
if not node._children[index]: return False
node = node._children[index]
return node._is_ending_char # 如果是false,则不能完全匹配,只是前缀
if __name__ == "__main__":
strs = ["how", "hi", "her", "hello", "so", "see"]
trie = Trie()
for s in strs:
trie.insert(s)
for s in strs:
print(trie.find(s))
print(trie.find("swift"))
图
概念:顶点、边、度(degree)、入度、入度、无向图、有向图、带权图
表示方法:
邻接矩阵
底层依赖一个二维数组
缺点:对于无向图或稀疏图来说比较浪费空间
优点:查询效率高、而且方便矩阵运算
邻接表
每个顶点都对应一个链表,存储与其相连接的其他顶点
缺点:查找比较麻烦,但可以将节点的链表换成其他的,比如红黑树、有序动态数组、跳表等
广度搜索(BFS Breadth-First-Search)
def print_path(prev, s, t):
if prev[t] != None and t != s:
print_path(prev, s, prev[t]) #假设其已经完成了打印
print(t + " ")
def bfs(graph, s, t):
if s == t: return
prev = [None] * graph._num_vertices
visited = [False] * graph._num_vertices
visited[s] = True
q = deque()
q.append(s)
while q:
v = q.popleft()
for neighbour in self._adjacency[v]:
if not visited[neighbour]:
prev[neighbour] = v
if neighbour == t:
print_path(prev, s, t)
return
visited[neighbour] = True
q.append(neighbour)
深度搜索(DFS)
深度优先搜索用的是一种比较著名的算法思想,回溯思想
def dfs(graph, s, t):
found = False
visited = [False] * graph._num_vertices
prev = [None] * graph._num_vertices
def _dfs(from_vertex):
nonlocal found
if found: return
visited[from_vertex] = True
if from_vertex == t:
found = True
return
for neighbour in graph._adjacency[from_vertex]:
if not visited[neighbour]:
prev[neighbour] = from_vertex
_dfs(neighbour)
_dfs(s)
print_path(prev, s, t)
最短路径
最短路径的最优子结构:最短路径的子路径也是最短路径
Dijkstra
带权重的(非负)有向图上单源最短路径算法
G:G = (V,E)
s:start
init_single_source(G,s)
for v in G.V
v.d = ∞ //距离
v.pre = null
s.d = 0
relax(u,v,w) //从u到v是否更优,w是权重表,
if v.d > u.d + w(u,v)
v.d = u.d + w(u,v)
v.pre = u
dijkstra(G,w,s)
init_single_source(G,s)
Q = G.V //最小优先队列,以距离为准
while Q
u = extract_min(Q)
for v in Adj[u]
relax(u,v,w)
A*寻路算法
dijkstra只考虑了顶点与起点的路径长度的大小,来安排出队列顺序的
与起点越近的顶点,就会越早出队列。我们并没有考虑到这个顶点到终点的距离
A* 算法属于一种启发式搜索算法(Heuristically Search Algorithm)
f(i)的专业叫法叫做:估价函数(evaluation function)
f(i)=g(i)+h(i) g:起点到当前点的距离 h:当前点到目标点的距离
最小生成树
Prim算法
Kruskal算法
拓扑排序
拓扑排序本身就是基于有向无环图(有环的话就死循环了)的一个算法
凡是需要通过局部顺序来推导全局顺序的,一般都能用拓扑排序来解决
除此之外,拓扑排序还能检测图中环的存在。
对于Kahn 算法来说,如果最后输出出来的顶点个数,少于图中顶点个数,图中还有入度不是0
的顶点,那就说明,图中存在环。
算法:
1.Kahn算法
2.DFS算法
索引
索引的需求定义
1.功能性需求
数据是格式化数据还是非格式化数据?
数据是格式化数据还是非格式化数据?
索引存储在内存还是硬盘?
索引存储在内存还是硬盘?
单关键词查找还是多关键词组合查找?
2.非功能性需求
不管是存储在内存中还是磁盘中,索引对存储空间的消耗不能过大。
在考虑索引查询效率的同时,我们还要考虑索引的维护成本。
构建索引常用的数据结构有哪些?
红黑树
B+ 树
跳表
布隆过滤器