0%

转载自王争老师数据结构与算法之美字符串匹配基础下https://time.geekbang.org/column/article/71845

概述

字符串匹配算法,可以分为单模式串匹配算法,和多模式串匹配算法,单模式串匹配算法包括了BF(暴力匹配O(m*n),但因为可以及早停止,实际感觉并没有很差)、RK(O(n),n为主串长度,利用哈希加速,哈希函数要取好,不要超过int的最大值,遍历一遍主串就可以算出全部n-m+1个哈希值)、BM(Boyer-Moore,性能最好的算法,使用好后缀、坏字符,从最后一个字符开始匹配)、KMP(好前缀、坏字符)四种算法。

这里介绍不是那么复杂的KMP算法。

(PS. 其实个人觉得,要学好算法,语文非常地重要,不亚于数学,要严谨准确而清晰易懂)

思路与算法

KMP 算法基本原理

在模式串和主串匹配的过程中,把不能匹配的那个字符叫作坏字符,把已经匹配的那段字符串叫作好前缀

当遇到坏字符的时候,我们就要把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于拿好前缀的后缀子串,跟模式串的前缀子串在比较。这个比较的过程能否更高效了呢?可以不用一个字符一个字符的比较了吗?

KMP 算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位?

我们只需要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。假设最长的可匹配的那部分前缀子串是{v},长度是k。我们把模式串一次性往后滑动 j - k 位,相当于,每次遇到坏字符,我们就把j更新为k,i不变,然后继续比较。

为了表述起来方便,我把好前缀的所有后缀子串中,最长的可匹配前缀子串的那个后缀子串,叫做最长可匹配后缀子串;对应的前缀子串,叫做最长可匹配前缀子串

如何来求好前缀的最长可匹配前缀和后缀子串呢?我们发现,这个问题其实不涉及主串,只需要通过模式串本身就能求解。所以,能不能事先预处理计算好,在模式串和主串匹配的过程中,直接拿过来就用呢?

其实和BM算法(后面会讲到)类似,KMP算法可以提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。我们把这个数组定义为next数组(或者叫longest prefix suffix,最长的前缀的后缀的下标,如果到了结尾就用-1表示不存在),很多书中还给这个数组起了一个名字,叫失效函数(failure function)。

数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可以匹配前缀子串的结尾字符下标。这句话有点拗口,举个例子就懂了。

有个next数组,我们很容易就可以实现KMP算法了。我先假设next数组已经计算好了,先给出KMP算法的框架代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def KMPSearch(p, s):
m = len(p)
n = len(s)

# create lps[] that will hold the longest prefix suffix
# values for pattern
lps = get_lps(p, m)

j = 0 # index for p[]
for i in range(n):
while j > 0 and s[i] != p[j]:
j = lps[j - 1] + 1
if s[i] == p[j]:
j += 1
if j == m:
# print("Found pattern at index " + str(i - j + 1))
# j = lps[j - 1] + 1
return i - m + 1
return -1

失效函数计算方法

基本原理剪完了,现在来看最复杂的部分,也就是next数组是如何计算的。

当然,我们可以用非常笨的方法,比如要计算下面这个模式串 b 的 next[4],我们就把 b[0, 4]的所有后缀子串,从长到短找出来,依次看看,是否能跟模式串的前缀子串匹配。很显然,这个方法也可以计算得到 next 数组,但是效率非常低。有没有更加高效的方法呢?

我们按照下标从小到大,依次计算 next 数组的值。当我们要计算 next[i]的时候,前面的 next[0],next[1],……,next[i-1]应该已经计算出来了。利用已经计算出来的 next 值,我们是否可以快速推导出 next[i]的值呢?如果 next[i-1]=k-1,也就是说,子串 b[0, k-1]是 b[0, i-1]的最长可匹配前缀子串。如果子串 b[0, k-1]的下一个字符 b[k],与 b[0, i-1]的下一个字符 b[i]匹配,那子串 b[0, k]就是 b[0, i]的最长可匹配前缀子串。所以,next[i]等于 k。但是,如果 b[0, k-1]的下一字符 b[k]跟 b[0, i-1]的下一个字符 b[i]不相等呢?这个时候就不能简单地通过 next[i-1]得到 next[i]了。这个时候该怎么办呢?

我们假设 b[0, i]的最长可匹配后缀子串是 b[r, i]。如果我们把最后一个字符去掉,那 b[r, i-1]肯定是 b[0, i-1]的可匹配后缀子串,但不一定是最长可匹配后缀子串。所以,既然 b[0, i-1]最长可匹配后缀子串对应的模式串的前缀子串的下一个字符并不等于 b[i],那么我们就可以考察 b[0, i-1]的次长可匹配后缀子串 b[x, i-1]对应的可匹配前缀子串 b[0, i-1-x]的下一个字符 b[i-x]是否等于 b[i]。如果等于,那 b[x, i]就是 b[0, i]的最长可匹配后缀子串。

可是,如何求得b[0, i-1]的次长可匹配后缀子串呢?次长可匹配后缀子串肯定被包含在最长可匹配后缀子串中,而最长可匹配后缀子串又对应最长可匹配前缀子串又对应最长可匹配前缀子串b[0, y]。于是,查找b[0, i-1]的次长可匹配后缀子串,这个问题就变成,查找b[0, y]的最长匹配后缀子串,其中y = next[i-1]

按照这个思路,我们可以考察完所有的 b[0, i-1]的可匹配后缀子串 b[y, i-1],直到找到一个可匹配的后缀子串,它对应的前缀子串的下一个字符等于 b[i],那这个 b[y, i]就是 b[0, i]的最长可匹配后缀子串。

前面已经给出 KMP 算法的框架代码了,现在把这部分的代码也写出来了。这两部分代码合在一起,就是整个 KMP 算法的代码实现。

1
2
3
4
5
6
7
8
9
10
def get_lps(p, m):
lps = [-1] * m
k = -1
for i in range(1, m):
while k != -1 and p[k + 1] != p[i]:
k = lps[k]
if p[k + 1] == p[i]:
k += 1
lps[i] = k
return lps

最后整体的实现

Python3版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution:
def KMPSearch(self, p, s):
# 特判
if not p:
return 0
m, n = len(p), len(s)
lps = self.get_lps(p, m)
j = 0
for i in range(n):
while j != 0 and s[i] != p[j]:
j = lps[j - 1] + 1
if s[i] == p[j]:
j += 1
if j == m:
# return i - m + 1
print("find at {}".format(i - m + 1))
j = lps[j - 1] + 1
return -1

def get_lps(self, p, m):
lps = [-1] * m
k = -1
for i in range(1, m):
while k != -1 and p[i] != p[k + 1]:
k = lps[k]
if p[i] == p[k + 1]:
k += 1
lps[i] = k
return lps


def main():
sol = Solution()

s = "abcruizheuhuruizheaasdasd"
p = "ruizhe"
res = sol.KMPSearch(p, s)
print(res)


if __name__ == '__main__':
main()

转载:

https://zhuanlan.zhihu.com/p/77383599

一、索引的本质

MySQL官方对索引的定义为:索引(Index)是帮助MySQL高效获取数据的数据结构。提取句子主干,就可以得到索引的本质:索引是数据结构。

我们知道,数据库查询是数据库的最主要功能之一。我们都希望查询数据的速度能尽可能的快,因此数据库系统的设计者会从查询算法的角度进行优化。最基本的查询算法当然是顺序查找(linear search),这种复杂度为O(n)的算法在数据量很大时显然是糟糕的,好在计算机科学的发展提供了很多更优秀的查找算法,例如二分查找(binary search)、二叉树查找(binary tree search)等。如果稍微分析一下会发现,每种查找算法都只能应用于特定的数据结构之上,例如二分查找要求被检索数据有序,而二叉树查找只能应用于二叉查找树上,但是数据本身的组织结构不可能完全满足各种数据结构(例如,理论上不可能同时将两列都按顺序进行组织),所以,在数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构上实现高级查找算法。这种数据结构,就是索引。

看一个例子:

上图展示了一种可能的索引方式。左边是数据表,一共有两列七条记录,最左边的是数据记录的物理地址(注意逻辑上相邻的记录在磁盘上也并不是一定物理相邻的)。为了加快Col2的查找,可以维护一个右边所示的二叉查找树,每个节点分别包含索引键值和一个指向对应数据记录物理地址的指针,这样就可以运用二叉查找在O(log2n)的复杂度内获取到相应数据。

虽然这是一个货真价实的索引,但是实际的数据库系统几乎没有使用二叉查找树或其进化品种红黑树(red-black tree)实现的,原因会在下文介绍。

二、二叉排序树

在介绍B树之前,先来看另一颗神奇的树——二叉排序树(Binary Sort Tree)。关于这棵树大家已经很熟悉了,我不多说了,看原文吧。

  • 若左子树不空,则左子树上所有节点的值均小于它的根节点的值
  • 若右子树不空,则右字数上所有节点的值均大于它的根节点的值
  • 它的左、右子树也分别为二叉排序数(递归定义)

从图中可以看出,二叉排序树组织数据时,用于查找是比较方便的,因为每次经过一次节点时,最多可以减少一半的可能,不过极端情况会出现所有节点都位于同一侧,直观上看就是一条直线,那么这种查询的效率就比较低了,因此需要对二叉树左右子树的高度进行平衡化处理,于是就有了平衡二叉树(Balenced Binary Tree)。

所谓“平衡”,说的是这棵树的各个分支的高度是均匀的,它的左子树和右子树的高度之差绝对值小于1,这样就不会出现一条支路特别长的情况。于是,在这样的平衡树中进行查找时,总共比较节点的次数不超过树的高度,这就确保了查询的效率(时间复杂度为O(logn))

三、B树

还是直接看图比较清楚,图中所示,B树事实上是一种平衡的多叉查找树,也就是说最多可以开m个叉(m>=2),我们称之为m阶B树,为了体现本博客的良心之处,不同于其他地方都能看到2阶B树,这里特意画了一棵5阶B树 。博主棒。

总的来说,m阶B树满足以下条件:

  • 每个节点至多可以拥有m棵子树
  • 根节点,至少有2个节点(要么极端情况,就是一棵树就一个根节点,单细胞生物,既是根,也是叶,也是树)。
  • 非根非叶的节点至少有Ceil(m/2)个子树(Ceil表示向上取整,图中5阶B树,每个节点至少有3个子树,也就是至少有3个叉)。
  • 非叶子节点中的信息包括[n, A0, K1, A1, K2, A2, ..., Kn, An],其中n表示该节点中保存的关键字个数,K为关键字且Ki<Ki+1,A为指向子树根节点的指针。
  • 从根到叶子的每一条路径都有相同的长度,也就是说,叶子节点在相同的层,并且这些节点不带信息,实际上这些节点就表示找不到指定的值,也就是指向这些节点的指针为空。
  • B树的查询过程和二查排序树比较类似,从根节点依次比较每个节点,因为每个节点中的关键字和左右子树都是有序的,所以只要比较节点中的关键字,或者沿着指针就能很快的找到指定的关键字,如果查找失败,则会返回叶子节点,即空指针。

例如查询图中字母表中的K

  • 从根节点P开始,K的位置在P之前,进入左侧指针
  • 左子树中,依次比较C、F、J、M,发现K在J和M之间。
  • 沿着J和M之间的指针,继续访问子树,并依次进行比较,发现第一个关键字K即为指定查找的值。

B树搜索的简单伪代码如下:

1
2
3
4
5
6
7
8
9
10
BTree_Search(node, key) {
if(node == null) return null;
foreach(node.key)
{
if(node.key[i] == key) return node.data[i];
if(node.key[i] > key) return BTree_Search(point[i]->node, key);
}
return BTree_Search(point[i+1]->node, key);
}
data = BTree_Search(root, my_key);

B树的特点可以总结为如下:

  • 关键字集合分布在整颗树中
  • 任何一个关键字出现且只出现在一个节点中
  • 搜索有可能在非叶子节点结束
  • 其搜索性能等价于在关键字集合内做一次二分查找
  • B树的插入删除新的数据记录会破坏B-Tree的性质,因为在插入删除时,需要对树进行分裂、合并、转移等操作以保持B-Tree的性质。

四、Plus版-B+树

作为B树的加强版,B+树与B树的差异在于

有n棵子树的节点含有n个关键字(也有人认为是n-1个关键字)。

所有的关键字全部存储在叶子节点上,且叶子节点本身根据关键字自小而大顺序连接。

非叶子节点可以看成索引部分,节点中仅包含有其子树(根节点)中的最大(最小)关键字。

B+树的查找过程,与B树类似,只不过查找时,如果在非叶子节点上的关键字等于给定值,并不终止,而是继续沿着指针直到叶子节点位置。因此在B+树中,不管查找成功与否,每次查找都是走了一条从根到叶子节点的路径。

B+树的特性如下:

  • 所有关键字都存储在叶子节点上,且链表中的关键字恰好是有序的
  • 不可能非叶子节点命中返回
  • 非叶子节点相当于叶子节点的索引,叶子节点相当于是存储(关键字)数据的数据层。
  • 更适合文件索引系统

五、带有顺序访问指针的B+Tree

一般在数据库系统或文件系统中使用的B+Tree结构都在进店B+Tree的基础上进行了优化,增加了顺序访问指针,

如上图所示,在B+Tree的每个叶子节点增加一个指向相邻子节点的指针,就形成了带有顺序访问指针的B+Tree。做这个优化的目的是为了提高区间访问的性能,例如图4中如果要查询key为从18到49的所有数据记录,当找到18后,只需顺着节点和指针顺序遍历就可以一次性访问到所有数据节点,极大提高了取件查找效率。

六、MySQL为什么使用B树(B+树)

红黑树等数据结构也可以用来实现索引,但是文件系统以及数据库系统普遍采用B树或者B+树,这一节将结合计算机组成原理相关知识讨论B-/+Tree作为索引的理论基础。

一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储在磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。下面先介绍内存和磁盘存取原理,然后再结合这些原理分析B-/+Tree作为索引的效率。

太长了,后面我总结一下

  1. 预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
  2. B-Tree巧妙地利用磁盘预读原理,将一个节点的大小设为等于一个页。每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了一个node只需一次I/O。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# 解决博弈问题动态规划通用思路.py


# 我们把石头游戏改得更具有一般性
# 你和你的朋友面前有一排石头堆,用一个数组piles表示
# piles[i]表示第i堆石子有多少个。
# 你们轮流拿石头,一次拿一堆,但是只能拿走最左边或者最右边的石头堆。
# 所有石头被拿完后,谁拥有的石头多,谁获胜
# 假设两人都很聪明,请你设计一个算法,返回先手和后手最后得分(石头总数)之差
# 比如:
# piles = [1, 100, 3]
# 先手能得4分,后手能得100分,返回-96


# 思路
# 子问题
# 在第i到第j堆石头,先手能获得最大石头数是多少,后手是多少
# 定义状态数组
# f(i, j, 0) f(i, j, 1) 表示第i到第j个石头,包括第i和第j个,0表示先手,1表示后手
# 因为两人都极聪明,先手选完就变后手,来回交替的
# 递推方程
# f(i, j, 0) = max 拿左边 f(i+1, j, 1) + a[i]
# = 拿右边 f(i, j-1, 1) + a[j]
# 知道了拿左边还是右边后
# f(i, j, 1) = 先手拿左边 f(i+1, j, 0)
# = 先手拿右边 f(i, j-1, 0)
# 初始化
# f(i, i, 0) = a[i] 只有一堆石子的时候,先手为该堆石子个数,后手就为0
# f(i, i, 1) = 0
# 返回值
# f(0, n-1, 0) 先手最终石子数 - f(0, n-1, 1)后手最终石子数
# 优化空间复杂度
# 是对角线的递推,这种情况最好不要优化空间,还可以利用计算机的缓存
from typing import List


class Solution:
def game(self, piles: List[int]) -> int:
if not piles:
return 0
n = len(piles)
dp = [[[0] * 2 for _ in range(n)] for _ in range(n)]
for i in range(n):
dp[i][i][0] = piles[i]
for gap in range(1, n):
for i in range(n - gap):
j = i + gap
left = dp[i + 1][j][1] + piles[i]
right = dp[i][j - 1][1] + piles[j]
# 先手选左边
if left > right:
dp[i][j][0] = left
dp[i][j][1] = dp[i + 1][j][0]
# 先手选右边
else:
dp[i][j][0] = right
dp[i][j][1] = dp[i][j - 1][0]
return dp[0][n - 1][0] - dp[0][n - 1][1]


def main():
sol = Solution()

piles = [1, 100, 3]
res = sol.game(piles)
print(res)


if __name__ == '__main__':
main()
代表题目:
leetcode 877
1
2
3
4
5
6
7
# 也可以使用第2种解法
# 数学规律
#
# 先拿者可以拿到序号为 1,3,5...n-1 的石子堆,
# 也可以拿到序号为 2,4,6...n 的石子堆。
# 因为总石子数为奇数,所以这两种方式中,其中一种拿到的石子数大于另一种。
# 所以不按照拿尽量多的石子数,按照这种纯奇数序号或者纯偶数序号的方式拿,先拿者总可以赢。

dp解法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
lass Solution:
def stoneGame(self, piles: List[int]) -> bool:
if not piles:
return True
n = len(piles)
dp = [[[0] * 2 for _ in range(n)] for _ in range(n)]
for i in range(n):
dp[i][i][0] = piles[i]
for gap in range(1, n):
for i in range(n - gap):
j = i + gap
left = dp[i + 1][j][1] + piles[i]
right = dp[i][j - 1][1] + piles[j]
# 先手选左边
if left > right:
dp[i][j][0] = left
dp[i][j][1] = dp[i + 1][j][0]
# 先手选右边
else:
dp[i][j][0] = right
dp[i][j][1] = dp[i][j - 1][0]
return bool(dp[0][n - 1][0] - dp[0][n - 1][1])

一 OSI与TCP/IP各层的结构与功能,都有哪些协议

五层协议的体系结构

学习计算机网络时我们一般采取折中的办法,也就是中和OSI和TCP/IP的优点,采用一种只有五层协议的体系结构,这样既简洁又能将概念阐述清楚。

结合互联网的情况,自上而下的,非常简要的介绍一下各层的作用。

1 应用层

应用层(application layer)的任务是通过应用进程间的交互来完成特定网络应用。应用层协议定义的是应用进程(进程:主机中正在运行的程序)间的通信和交互的规则。对于不同的网络应用需要不同的应用层协议。在互联网中应用层协议很多,如域名系统DNS,支持万维网应用的HTTP协议,支持电子邮件的SMTP协议等等。我们把应用层交互的数据单元称为报文。

域名系统

域名系统(Domain name System缩写DNS,Domain Name被译为域名)是因特网的一项核心服务,它作为可以将域名和IP地址相互映射的一个分布式数据库,能够使人更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串。例如:一个公司的web网站可看做是它在网上的门户,而域名就相当于其门牌地址,通常域名都使用该公司的名称或简称。例如上面提到的微软公司的域名,类似的还有:IBM 公司的域名是 www.ibm.com、Oracle 公司的域名是 www.oracle.com、Cisco公司的域名是 www.cisco.com 等。

HTTP协议

超文本传输协议(HTTP,Hypertext Transfer Protocol)是互联网上应用最广泛的一种网络协议。所有的WWW(万维网)文件都必须遵守这个标准。设计HTTP最初的目的是为了提供一种发布和接收HTML页面的方法。

2 运输层

运输层(transport layer)的主要任务就是负责向两台主机进程之间的通信提供通用的数据传输服务。应用进程利用该服务传送应用层报文。“通用的”是指并不针对某一个特定的网络应用,而是多种应用可以使用同一个运输层服务。由于一台主机可同时运行多个线程,因此运输层有复用和分用的功能。所谓复用就是指多个应用层进程可同时使用下面运输层的服务,分用和复用相反,是运输层把收到的信息分别交付上面应用层中的相应进程。

运输层主要使用一下两种协议

  1. 传输控制协议TCP(Transmisson Control Protocol)--提供面向连接的,可靠的数据传输服务。
  2. 用户数据报协议UDP(User Datagram Protocol)--提供无连接的,尽最大努力的数据传输服务(不保证数据传输的可靠性

UDP的主要特点

  1. UDP是无连接的。
  2. UDP使用尽最大努力交付,即不保证可靠交付,因此主机不需要维持复杂的连接状态(这里面有许多参数);
  3. UDP是面向报文的;
  4. UDP没有拥塞控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如直播,实时视频会议等);
  5. UDP支持一对一、一对多、多对一和多对多的交互通信;
  6. UDP的首部开销小,只有8个字节,比TCP的20个字节的首部要短。

TCP的主要特点

  1. TCP是面向连接的。(就好像打电话一样,通话前需要先拨号建立连接,通话结束后要挂机释放连接);
  2. 每一条TCP连接只能有两个端点,每一条TCP连接只能是点对点的(一对一);
  3. TCP提供可靠交付的服务。通过TCP连接传送的数据,无差错、不丢失、不重复、并且按序到达;
  4. TCP提供全双工通信。TCP允许通信双方的应用进程在任何时候都能发送数据。TCP连接的两端都设有发送缓存和接收缓存,用来临时存放双方通信的数据;
  5. 面向字节流。TCP中的“流”(Stream)指的是流入进程或从进程流出的字节序列。“面向字节流”的含义是:虽然应用程序和TCP的交互是一次一个数据块(大小不等),但TCP把应用程序交下来的数据仅仅看成是一连串的无结构的字节流。

3 网络层

网络层(network layer)负责为分组交换网上的不同主机提供服务。在发送数据时,网络层把运输层产生的报文段或用户数据封装成分组和包进行传送。在TCP/IP体系结构中,由于网络层使用IP协议,因此分组也叫IP数据报,简称数据报

这里要注意:不要把运输层的“用户数据报UDP”和网络层的“IP数据报”弄混。另外,无论是哪一层的数据单元,都可笼统地用“分组”来表示。

网络层的另一个任务就是选择合适的路由,使源主机运输层所传下来的分组,能通过网络层中的路由器找到目的主机。

这里强调指出,网络层中的“网络”二字已经不是我们通常谈到的具体网络,而是指计算机网络体系结构模型中第三层的名称。

互联网是由大量的异构(heterogeneous)网络通过路由器(router)相互连接起来的。互联网使用的网络层协议是无连接的网际协议(Internet Protocol)和许多路由选择协议,因此互联网的网络层也叫做网际层IP层

4 数据链路层

数据链路层(data link layer)通常简称为链路层。两台主机之间的数据传输,总是在一段一段的链路上传送的,这就需要使用专门的链路层的协议。在两个相邻节点之间传送数据时,数据链路层将网络层交下来的IP数据报组装成帧,在两个相邻节点间的链路上传送帧。每一帧包括数据和必要的控制信息(如同步信息,地址信息,差错控制等)。

在接收数据时,控制信息使接收端能够知道一个帧从哪个比特开始和到哪个比特结束。这样,数据链路层在收到一个帧后,就可从中提取数据部分,上交给网络层。控制信息还使接收端能够检测到所收到的帧中有无差错。如果发现差错,数据链路层就简单的丢弃这个出了差错的帧,以避免继续在网络中传送下去白白浪费网络资源。如果需要改正数据在链路层传输时出现的差错(这就是说,数据链路层不仅要检错,而且还要纠错),那么就要采用可靠性传输协议来纠正出现的差错。这种方法会使链路层的协议复杂些。

5 物理层

在物理层所传送的数据单位是比特。物理层(physical layer)的作用是实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异。使其上面的数据链路层不必考虑网络的具体传输介质是什么。“透明传送比特流”表示经实际电路传送后的比特流没有发生变化,对传送的比特流来说,这个电路好像是看不见的。

在互联网使用的各种协议中最重要和最著名的就是TCP/IP两个协议。现在人们经常提到的TCP/IP并不一定单指TCP和IP这两个具体的协议,而往往表示互联网所使用的的整个TCP/IP协议簇。

总结一下

上面我们对计算机网络的五层体系结构有了初步的了解,下面附送一张七层体系结构图总结一下。

图片来源:blog.csdn.net/yaopeng_200…

二 TCP三次握手和四次挥手(面试常客)

为了准确无误地把数据送达目标处,TCP协议采用了三次握手策略。

漫画图解:

图片来源:《图解HTTP》

简单示意图:

  • 客户端-发送带有SYN标志的数据-一次握手-服务端
  • 服务端-发送带有SYN/ACK标志的数据包-二次握手-客户端
  • 客户端-发送带有ACK标志的数据包-三次握手-服务端

为什么要三次握手

三次握手的目的是建立可靠的通信通道,说道通讯,简单来说就是数据的发送与接收,而三次握手最主要的目的就是双方确认自己与对方的发送与接收是正常的。

第一次握手:Client什么都不能确认;Server确认了对方发送正常

第二次握手:Client确认了:自己发送、接收正常,对方发送、接收正常;Server确认了:自己接收正常,对方发送正常

第三次握手:Client确认了:自己发送、接收正常,对方发送、接收正常;Server确认了:自己发送、接收正常,对方发送、接收正常

所以三次握手就能确认双方收发功能都正常,缺一不可。

为什么要传回SYN

接收端传回发送端所发送的SYN是为了告诉发送端,我接收到的信息确实就是你发送的信号。

传了SYN,为啥还要传ACK

双方通信无误必须是两者互相发送信息都无误。传了SYN,证明发送方到接收方的通道没有问题,但是接收方到发送方的通道(即Server发送正常,Client接收正常)还需要ACK信号来进行验证。

SYN是TCP/IP建立连接时使用的握手信号。在客户机和服务器之间建立正常的TCP网络连接时,客户机首先发出一个SYN消息,服务器使用SYN-ACK应答表示接收到了这个消息,最后客户机再以ACK(Acknowledge,确认字符,在数据通信传输中,接收站发送给发送站的一种传输控制字符。它表示确认发来的数据已经接收无误。)消息响应。这样在客户机和服务器之间才能建立起可靠的TCP连接,数据才可以在客户机和服务器之间传递。

断开一个TCP连接则需要“四次挥手”:

  • 客户端-发送一个FIN,用来关闭客户端到服务器的数据传送
  • 服务器-收到这个FIN,它返回一个ACK,确认序号为收到的序号加1。和SYN一样,一个FIN将占用一个序号
  • 服务器-关闭与客户端的连接,发送一个FIN给客户端
  • 客户端-发回ACK报文确认,并将确认序号设置为收到序号加1

为什么要四次挥手

任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了TCP连接。

举个例子:A和B打电话,通话即将结束后,A说“我没啥要说的了”,B回答说“我知道了”,但是B可能还有要说的话,A不能要求B跟着自己的节奏结束通话,于是B可能又巴拉巴拉说了一通,最后B说”我说完了“,A回答”知道了“,这样通话才算结束。

上面讲的比较概括,推荐一篇讲得比较细致的文章:blog.csdn.net/qzcsu/artic…

三 TCP、UDP协议的区别

UDP在传送数据之前不需要先建立连接,远地主机在收到UDP报文之后,不需要给出任何确认。虽然UDP不提供可靠交付,但在某些情况下UDP确是一种最有效的工作方式(一般用于即时通讯),比如:QQ语音、QQ视频、直播等等。

TCP提供面向连接的服务。在传送数据之前必须先建立连接,数据传送结束后要释放连接。TCP不提供广播或多播服务。由于TCP要提供可靠的,面向连接的服务(TCP的可靠体现在TCP在传递数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制,在数据传完后,还会断开连接用来节约系统资源),这些难以避免增加了许多开销,如确认,流量控制,计时器以及连接管理等。这不仅使协议数据单元的首部增大很多,还要占用许多处理机资源。TCP一般用于文件传输、发送和接收邮件、远程登录等场景。

四 TCP协议如何保证可靠运输

  1. 应用数据被分割成TCP认为最合适发送的数据块。
  2. TCP给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。
  3. 校验和:TCP将保持它首部和数据的校验和。这是一个端到端的校验和,目的是检测数据在传输过程中的任何变化,如果收到段的检验和有差错,TCP将丢弃这个报文段和不确认收到此报文段。
  4. TCP的接收端会丢弃重复的数据。
  5. 流量控制:TCP连接的每一方都有固定大小的缓冲空间,TCP的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP使用的流量控制协议是一个可变大小的滑动窗口协议。(TCP利用滑动窗口实现流量控制)
  6. 拥塞控制:当网络拥塞时,减少数据的发送。
  7. 停止等待协议:也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。超时重传:当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。

停止等待协议

  • 停止等待协议是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组;
  • 在停止等待协议中,若接收方收到重复分组,就丢弃该分组,但同时还要发送确认;
  1. 无差错情况:

发送方发送分组,接收方在规定时间内收到,并且回复确认,发送方再次发送。

  1. 出现差错情况(超时重传)

停止等待协议中超时重传是指只要超过一段时间仍然没有收到确认,就重传前面发送过的分组(认为刚才发送过的分组丢失了)。因此每发送完一个分组需要设置一个超时计时器,其重传时间应比数据在分组传输的平均往返时间更长一些。这种自动重传方式常称为自动重传请求 ARQ。另外在停止等待协议中若收到重复分组,就丢弃该分组,但同时还要发送确认。连续ARQ协议可提高信道利用率。发送维持一个发送窗口,凡位于发送窗口内的分组可连续发送出去,而不需要等待对方确认。接收方一般采用累积确认,对按序到达的最后一个分组发送确认,表明这个分组位置的所有分组都已经正确收到了。

  1. 确认丢失和确认迟到

    • 确认丢失:确认消息在传输过程丢失

      当A发送M1消息时,B收到后,B向A发送了一个M1确认消息,但却在传输过程中丢失。而A并不知道,在超时计时过后,A重传M1消息,B再次收到该消息后采取以下两点措施:

      1. 丢弃这个重复的M1消息,不向上层交付。
      2. 向A发送确认消息。(不会认为已经发送过了,就不再发送。A能重传,就证明B的确认消息丢失)。
    • 确认迟到:确认消息在传输过程中迟到

      A发送M1消息,B收到并发送确认。在超时时间内没有收到确认消息,A重传M1消息,B仍然收到并继续发送确认消息(B收到了2份M1)。此时A收到了B第二次发送的确认消息。接着发送其他数据。过了一会,A收到了B第一次发送的对M1的确认消息(A也收到额2份确认消息)。处理如下:

      1. A收到重复的确认后,直接丢弃。
      2. B收到重复的M1后,也直接丢弃重复的M1。

自动重传请求ARQ协议

停止等待协议中超时重传是指只要过一段时间仍然没有收到确认,就重传前面发送过的分组(认为刚才发送过的分组丢失了)。因此没发送完一个分组需要设置一个超时计时器,其重传时间应比数据在分组传输的平均往返时间更长一些。这种自动重传方式常称为自动重传请求ARQ。

优点:简单

缺点:信道利用率低

连续ARQ协议

连续ARQ协议可提高信道利用率。发送方维持一个发送窗口,凡位于发送窗口内的分组可以连续发送出去,而不需要等待对方确认,接收方一般采用累积确认,对按序到达的最后一个分组发送确认,表明这个分组为止的所有分组都已经正确收到了。

优点:信道利用率高,容易实现,即使确认丢失,也不必重传。

缺点:不能向发送方反映出接收方已经正确收到的所有分组的信息。比如:发送方发送了5条消息,中间第三条丢失(3号),这是接收方只能对前两个发送确认。发送方无法知道后三个分组的下落,而只好把后三个全部重传一次。这也叫Go-Back-N(回退N),表示需要退回来重传已经发送过的N个消息。

滑动窗口

  • TCP利用滑动窗口实现流控的机制。
  • 滑动窗口(Sliding window)是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞情况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题。
  • TCP 中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为 0 时,发送方一般不能再发送数据报,但有两种情况除外,一种情况是可以发送紧急数据,例如,允许用户终止在远端机上的运行进程。另一种情况是发送方可以发送一个 1 字节的数据报来通知接收方重新声明它希望接收的下一字节及发送方的滑动窗口大小。

流量控制

  • TCP 利用滑动窗口实现流量控制。
  • 流量控制是为了控制发送方发送速率,保证接收方来得及接收。
  • 接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。

拥塞控制

在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫拥塞。拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。相反,流量控制往往是点对点通信量的控制,是个端到端的问题。流量控制所要做到的就是抑制发送端发送数据的速率,以便使接收端来得及接收。

为了进行拥塞控制,TCP 发送方要维持一个 拥塞窗口(cwnd) 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。

TCP的拥塞控制采用了四种算法,即 慢开始拥塞避免快重传快恢复。在网络层也可以使路由器采用适当的分组丢弃策略(如主动队列管理 AQM),以减少网络拥塞的发生。

  • 慢开始: 慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的负荷情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd初始值为1,每经过一个传播轮次,cwnd加倍。
  • 拥塞避免: 拥塞避免算法的思路是让拥塞窗口cwnd缓慢增大,即每经过一个往返时间RTT就把发送方的cwnd加1.

  • 快重传与快恢复: 在 TCP/IP 中,快速重传和恢复(fast retransmit and recovery,FRR)是一种拥塞控制算法,它能快速恢复丢失的数据包。没有 FRR,如果数据包丢失了,TCP 将会使用定时器来要求传输暂停。在暂停的这段时间内,没有新的或复制的数据包被发送。有了 FRR,如果接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段。有了 FRR,就不会因为重传时要求的暂停被耽误。  当有单独的数据包丢失时,快速重传和恢复(FRR)能最有效地工作。当有多个数据信息包在某一段很短的时间内丢失时,它则不能很有效地工作。

五 在浏览器中输入url地址 ->> 显示主页的过程(面试常客)

百度好像最喜欢问这个问题。

打开一个网页,整个过程会使用哪些协议

图片来源:《图解HTTP》

六 状态码

七 各种协议与HTTP协议之间的关系

一般面试官会通过这样的问题来考察你对计算机网络知识体系的理解。

图片来源:《图解HTTP》

八 HTTP长连接、短连接

在HTTP/1.0中默认使用短连接。也就是说,客户端和服务器每进行一次HTTP操作,就建立一次连接,任务结束就中断连接。当客户端浏览器访问的某个HTML或其他类型的Web页中包含有其他的Web资源时(如JavaScript文件、图像文件、CSS文件等),每遇到这样一个Web资源,浏览器就会重新建立一个HTTP会话。

而从HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头加入这行代码:

1
Connection:keep-alive

在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭,客户端再次访问这个服务器时,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。实现长连接需要客户端和服务端都支持长连接。

HTTP协议的长连接和短连接,实质上是TCP协议的长连接和短连接。

—— 《HTTP长连接、短连接究竟是什么?》

转载(感谢原作者):

https://juejin.im/post/5b7be0b2e51d4538db34a51e#heading-17

人生的真谛要用自己一辈子去理解

甲午年,正月初九。我儿子十八了,正所谓成人长大。学名奇临,取奇妙降临之意。后下海,艺名麒麟,乃仿周大师之遗韵。

人生一世,极不容易。登天难,求人更难。黄连苦,无钱更苦。江湖险,人心更险。春冰薄,人情更薄。过去有句话:既落江湖内,便是薄命人。我本不愿儿从艺,奈何人自有志无法横栏。但有几句话要说清楚。

艺人分几类,相隔种种。一是普通艺人,有一技之长,凭能耐吃饭。一是名演员,知名度高,但不代表艺术高,此类要两说。一是角儿,何为角儿?舞台上的顶梁柱、剧团班社的灵魂。贴出你的名字要保证卖得出票,全团老少指着你吃饭。角儿是有责任的艺人。

郭麒麟刚十八,我不希望儿子大红。人红麻烦多,一大三大,名气大后,开销大压力大是非大。红起来容易,难的是接住自己。年三十吃顿饺子容易,之后呢?得天天吃饺子才行啊,可你有那么多的面和馅儿吗?所以,要多下功夫,并且要保持一个好心态。很多人不成功的原因,主要是太尊重自己了。

一个人刚出道,不狂是没有出息的。但如果一直狂,是肯定没出息的。你眼中的你,和别人眼中的你,不是一回事。无限赞美自己,只是一种胆怯。我一直认为,恐惧到头就是愤怒。发挥无敌想象,给自己描绘了一个童话世界,白衣如雪来去如风。剑影刀光闪过后,你满脸冷酷地立于珠峰顶端,傲视苍生无比英武。此时我要问的是:孩子,你怎么下去?不让古人,是谓有志;不让今人,是谓无良。记住了,小俗便雅,大雅则俗。有人夸你,别信。有人骂你,别听。周围人随意捧骂,不可与之交,因其无志兴也。记住,言语多反复,当防欺诈。忘恩思小过,定会反戈。开口说大义,临大难必变节。逢人称兄弟,即深交也平常。

另外,凡事要慎重。江湖子弟,拿得起来放得下。身边人很重要。一根稻草,扔街上就是垃圾,捆上白菜就是白菜价,捆上大闸蟹就是大闸蟹价。包括脚下的平台,也极重要。同样是一个人,步行一小时能走多远?骑车呢?开车呢?坐飞机呢?平台会决定你的速度,且记且记。

此外,钱财要珍惜,但不可看得太重。财乃天地至公之物,假手于人罢了,雨打残花风卷流云,轮番更转而已。穷转富,富转穷,哪有百世富家翁?至于交友,吃点亏也无妨。人每所谓穷通寿夭为命所系,岂不知造物之报施,全视人之自取。芸芸众生富贵贫寒,不是谁都可以傲视乾坤。其中有命有运,要知因果懂善恶,我儿且记,但行好事,莫问前程。

1. 问题描述

给出只包含int类型的数组,所有值出现k(k>1)次,除了一个值,这个值出现了p次(p>1, p%k!=0)。找到这个值。

2. 从只有1bit的特殊情况开始

为了应用位运算,我们应该重新思考integers是如何在计算机中被表示的--通过位。让我们先考虑1位。假如我们有一数组的一bit数(除了0就是1),我们要统计数组中的1,使得当统计1的计数器到达k时,计数器回到0并且重新统计(k和我们问题中提到的k一样)。为了跟踪我们已经遇到多少个1,我们需要一个计数器。假设计数器二进制表示有m位:xm, ..., x1(最重要的位到最不重要的位)。我们至少可以总结出计数器的以下四个特性。

  1. 计数器有一个初始状态,简单来讲为0
  2. 对于数组中的每一个输入,如果我们击中0,计数器应该保持不变
  3. 对于数组中的每一个输入,如果我们击中1,计数器应该增加1
  4. 为了覆盖k个计数,我们要求2^m>=k,这意味着m > logk

这里是关键部分:当我们扫描数组时,计数器(从x1到xm)中的每个位如何变化。注意,我们被提示使用位运算。为了满足第2个属性,回想一下,如果另一个操作数为0,有哪些位操作不会改变操作数?是的,有位与x=x|0和位异或x=x^0。

好了,我们现在有一个表达式:x = x | i或者x = x^i,其中i是数组中的扫描元素。哪一个更好呢?我们还不知道。所以,让我们开始实际的计算。

开始的时候,计数器的所有位都初始化为0,即xm=0, ..., x1=0。由于我们要选择的位操作,保证了计数器的所有位在遇到0时都保持不变,所以直到我们遇到数组中第一个1,计数器将是0。当我们遇到第一个1的时候,我们得到:xm = 0, ... , x2=0, x1=1。让我们继续下去,直到打出第二个1,之后我们得到:xm=0, ... , x2=1, x1=0。注意,x1从1变成了0。对于x1 = x1 | i,在第二次计数之后,x1仍然会是1。所以很明显,我们应该使用x1 = x1 ^ i,那么x2, ..., xm呢?我们的想法是找到x2, ... , xm将改变其值的条件。以x2为例。如果我们打了一个1,需要改变x2的值,那么在我们进行改变之前,x1的值一定是多少?答案是:x1必须是1,否则我们不应该改变x2,因为把x1从0改成1就可以了。因此,只有当x1和i都是1时,x2才会改变值,或者数学上说,x2 = x2 ^ (x1 & i)。同理,xm只有在,xm-1, ... , x1和i都是1的时候才会改变数值:xm = xm ^ (xm-1 & ... & x1 & i)。ok,我们找到了这个位运算。

但是,你可能会注意到,上面发现的位运算将从0开始计数,直到2^m-1,而不是k。如果k < 2^m - 1,我们需要一些”切割“机制,以在计数达到k时将计数器重新初始化为0。为此,我们对xm, ... , x1应用位AND,并使用一些称为mask的变量,即xm = xm & mask, ... , x1 = x1 & mask。如果我们能保证只有当计数达到k时,mask才为0,而在其他所有计数情况下为1,那么我们就完成了。我们如何实现这一点呢?试着想一想,计数为k的情况与其他所有情况的区别是什么?是的,是1的计数!对于每一个计数,我们对计数器的每一个位都有唯一的值,这可以看作是它的状态。如果我们把k写成二进制形式:km, ... ,k1, 我们就可以构造mask如下。

mask = ~(y1 & y2 & .. & ym),其中如果kj = 1, yj = xj, 如果kj = 0, yj = -xj ( j = 1 to m )

简单来讲,就是只有当x1..xm和k的所有位都相等的时候,mask才会为0

我们来举一些例子:

k = 3: k1 = 1, k2 = 1, mask = ~(x1 & x2);

k = 5: k1 = 1, k2 = 1, k3 = 1, mask = ~(x1 & ~x2 & x3);

综上所述,我们的算法将是这样的(nums是输入数组):

1
2
3
4
5
6
7
8
9
10
11
12
for (int i : nums) {
xm ^= (xm-1 & ... & x1 & i);
xm-1 ^= (xm-2 & ... & x1 & i);
.....
x1 ^= i;

mask = ~(y1 & y2 & ... & ym) where yj = xj if kj = 1, and yj = ~xj if kj = 0 (j = 1 to m).

xm &= mask;
......
x1 &= mask;
}

3. 32位数的一般情况

现在是时候把我们的结果从1位数的情况推广到32位整数了。一个直接的方法是为整数中的每个位创建32个计数器。但是,如果我们利用位操作的优势,我们也许可以”集体“管理所有的32个计数器,其中m是满足m>=logk的最小整数。原因是位操作只适用于每个位,所以对不同位的操作是相互独立的(有点明显,对吧?)。这使得我们可以将32个计数器的对应位归为一个32位整数。下面是一个示意图,展示了如何做到这一点。

最上面的一行是32位的整数,其中每一个位,我们都有一个对应的m位计数器(由向上箭头下面的那一列所示)。由于对32位中每一个位的操作都是相互独立的,所以我们可以将比如说所有计数器的第m位,归为一个32位数(由橙色框所示)。这个32位数中的所有位(表示为xm)将遵循相同的位操作。由于每个计数器有m个位,我们最终得到m个32位数,对应于第二部分中定义的x1, ... , xm,但现在它们是32位数而不是1位数。因此,在上面开发的算法中,我们只需要将x1到xm视为32位整数而不是1位数。其他一切都将是相同的,我们就完成了。很简单,嗯?

4. 返回什么

最后就是我们应该返回什么值,或者等价于x1到xm中哪一个会等于单一元素。为了得到正确的答案,我们需要了解m个32位整数x1到xm代表什么。以x1为例,x1有32位,我们把它们标注为r(r=1到32)。当我们扫描完输入数组后,x1的r-th位的值将由数组中所有元素的r-th为决定(更具体的说,假设数组中所有元素的r-th位1的总计数为q,那么最终r-th位就是q'=q%k,其二进制形式为:q'm, ... , q'1,那么根据定义,x1的r-th位将等于 q'1)。现在你可以问自己这个问题:如果x1的r-th位是1,这意味着什么?

答案是要找到能对这个1做出贡献的东西,一个出现了k次的元素会有贡献吗?不会,为什么?因为一个元素要做出贡献,至少要同时满足两个条件:这个元素的r-th位是1,这个1的出现次数不是k的整数倍,第一个条件是微不足道的。第二个条件来自于每当1的命中次数为k时,计数器就会回零,也就是x1中的对应位会被重置为0,对于一个出现了k次的元素,不可能同时满足这两个条件(违反第二条),所以它不会有贡献。所以,只有出现p(p%k!=0)次的单个元素才会做出贡献。如果p>k,那么前k*[p/k] ([p/k]表示p/k的整数部分)的元素也不会做出贡献。所以我们总是可以设置p' = p % k,并说这个单元素有效地出现了p'次。

我们把p‘写成二进制形式:p'm, ... , p'1(注意p' < k, 所以它将适合m位)。这里提出一个声明,xj等于这个单个元素的条件是p'j = 1(j =1 到 m),下面给出一个快速证明:

如果xj的r-th位是1,我们可以放心的说这个单一元素的r-th位也是1(否则没有任何东西可以使xj的r-th位是1)。我们还要证明,如果xj的r-th位是0,那么单元素的r-th位只能是0,我们就假设在这种情况下,单元素的r-th位是1,我们看看会发生什么。在扫描结束时,这个1将被计算p'次。根据定义,xj的r-th位将等于p'j,也就是1,这与xj的r-th位为0的假设相矛盾,因此我们得出结论,只要p'j=1,xj的r-th位将始终与单一元素的r-th位相同。由于这对xj中的所有位都是真的(即对若r=1到32来说是真的),所以我们得出结论,只要p'j=1,xj将等于这个单一元素的值。

所以现在我们应该返回什么就很清楚了。只要用其二进制形式表达p'=p%k,只要p'j = 1,就可以返回对应的xj中的任何一个。总的来说,该算法将在O(n*logk)时间和O(logk)的空间内运行。

快速例子几个

Here is a list of few quick examples to show how the algorithm works (you can easily come up with other examples):

  1. k = 2, p = 1 k is 2, then m = 1, we need only one 32-bit integer (x1) as the counter. And 2^m = k so we do not even need a mask! A complete java program will look like:
1
2
3
4
5
6
7
8
9
public int singleNumber(int[] nums) {
int x1 = 0;

for (int i : nums) {
x1 ^= i;
}

return x1;
}
  1. k = 3, p = 1 k is 3, then m = 2, we need two 32-bit integers(x2, x1) as the counter. And 2^m > k so we do need a mask. Write k in its binary form: k = '11', then k1 = 1, k2 = 1, so we have mask = ~(x1 & x2). A complete java program will look like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public int singleNumber(int[] nums) {
int x1 = 0, x2 = 0, mask = 0;

for (int i : nums) {
x2 ^= x1 & i;
x1 ^= i;
mask = ~(x1 & x2);
x2 &= mask;
x1 &= mask;
}

return x1; // Since p = 1, in binary form p = '01', then p1 = 1, so we should return x1.
// If p = 2, in binary form p = '10', then p2 = 1, and we should return x2.
// Or alternatively we can simply return (x1 | x2).
}
  1. k = 5, p = 3 k is 5, then m = 3, we need three 32-bit integers(x3, x2, x1) as the counter. And 2^m > k so we need a mask. Write k in its binary form: k = '101', then k1 = 1, k2 = 0, k3 = 1, so we have mask = ~(x1 & ~x2 & x3). A complete java program will look like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public int singleNumber(int[] nums) {
int x1 = 0, x2 = 0, x3 = 0, mask = 0;

for (int i : nums) {
x3 ^= x2 & x1 & i;
x2 ^= x1 & i;
x1 ^= i;
mask = ~(x1 & ~x2 & x3);
x3 &= mask;
x2 &= mask;
x1 &= mask;
}

return x1; // Since p = 3, in binary form p = '011', then p1 = p2 = 1, so we can return either x1 or x2.
// If p = 4, in binary form p = '100', only p3 = 1, which implies we can only return x3.
// Or alternatively we can simply return (x1 | x2 | x3).
}

Lastly I would like to thank those for providing feedbacks to make this post better. Hope it helps and happy coding!

相关力扣题目

136 https://leetcode-cn.com/problems/single-number/

137 https://leetcode-cn.com/problems/single-number-ii/

260 https://leetcode-cn.com/problems/single-number-iii/

概念

工作中对外提供的API接口设计都要考虑限流,如果不考虑限流,会造成系统的连锁反应,轻者响应缓慢,重者系统宕机,整个业务线崩溃,如何应对这种情况呢,我们可以对请求进行引流或者直接拒绝等操作,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机。

在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流

缓存:缓存的目的是提升系统访问速度和增大系统处理容量

降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行

限流:限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理

限流算法

常用的限流算法有令牌桶漏桶,而Google开源项目Guava中的RateLimiter使用的就是令牌桶控制算法。

漏桶算法

把请求比作是水,水来了都先放进桶里,并以限定的速度出水,当水来得过猛而水不够快时就会导致水直接溢出,即拒绝服务。

漏斗有一个进水口和一个出水口,出水口以一定速率出水,并且有一个最大出水率:

在漏斗中没有水的时候,

  • 如果进水速率小于等于最大出水速率,那么,出水速率等于进水速率,此时,不会积水
  • 如果进水速率大于最大出水速率,那么,漏斗以最大速率出水,此时,多于的水会积在漏斗中

在漏斗中有水的时候,

  • 出水口以最大速率出水
  • 如果漏斗未满,且有进水的话,那么这些水会积在漏斗中
  • 如果漏斗已满,且有进水的话,那么这些水会溢出到漏斗之外

令牌桶算法

对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。

令牌桶算法的原理是系统以恒定的速率产生令牌,然后把令牌放到令牌桶中,令牌桶有一个容量,当令牌桶满了的时候,再向其中放令牌,那么多余的令牌会被丢弃;当想要处理一个请求的时候,需要从令牌桶中取出一个令牌,如果此时令牌桶没有令牌,那么则拒绝该请求。

google guava实现了令牌桶限流算法:https://github.com/google/guava

令牌桶算法 vs 漏桶算法

漏桶:

漏桶的出水速度是恒定的,那么意味着如果瞬时大流量的话,将有大部分请求被丢弃掉(也就是所谓的溢出)。

令牌桶:

生成令牌的速度时恒定的,而请求去哪令牌是没有速度限制的。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。

最后

不论是对于令牌桶拿不到令牌被拒绝,还是漏桶的水满了溢出,都是为了保证大部分流量的正常使用,而牺牲掉了少部分流量,这是合理的,如果因为极少部分流量需要保证的话,那么就可能导致系统达到极限而挂掉,得不偿失。

本文讲的单机的限流,是JVM级别的限流,所有的令牌生成都是在内存中,在分布式环境下不能直接这么用,可以使用,可以使用redis限流

出处

https://www.ymq.io/2018/08/11/RateLimiter/

平衡二叉搜索树

概念:

平衡二叉搜索是基于二分法的策略提高数据的查找速度的二叉树的数据结构;

特点:

平衡二叉搜索树是采用二分法思维把数据按规则组装成一个树形结构的数据,用这个树形结构的数据减少物管数据的检索,大大提升了数据检索的速度;平衡二叉树的数据结构组转过程有以下规则。

  1. 非叶子节点只能允许最多两个子节点存在;
  2. 每一个非叶子节点的左子树上所有结点都小于当前节点的值,右子树上所有结点都大于当前节点的值;
  3. 树左右两边的层级数相差不会大于1;
  4. 没有值相等重复的结点;
  5. 左右子树也是平衡二查搜索树。

B树

概念:

B树和二叉树稍有不同的是B树属于多叉树又名平衡多路搜索树(查找路径不只两个)。

规则:

  1. 排序方式:所有结点关键字是按递增次序排列,并遵循左小右大原则;
  2. 子节点数:非叶子节点的子节点数>1,且<=M,且M>=2,空树除外(注:M阶代表一个树节点最多有多少个查找路径,M=M路,当M=2是时二叉树,M=3时是三叉);
  3. 关键字数:枝结点的关键字数量大于等于ceil(M/2)-1 且小于等于M-1个(ceil是向正无穷方向取整的函数,如ceil(1.1) = 2);
  4. 所有叶子节点都在同一层、叶子节点除了包含了关键字和关键字记录的指针外,也有指向其子节点的指针,只不过其指针地址都为null,对应下图最后一层节点的空格子。

最后我们用一个图和一个实际的例子来理解B树(这里为了理解方便我就直接用实际字母的大小来排列C>B>A)

  • B树的查找流程

    如上图如果要从上图中找到E字母,查找流程如下

    1. 获取根节点的关键字进行比较,当前根节点关键字为M,E<M(26个字母顺序),所以往左找到左边的子节点
    2. 拿到关键字D和G,D<E<G所以直接找到D和G中间的节点。
    3. 拿到E和F,因为E=E所以直接返回关键字和指针信息(如果树结构立面没有包含所要查找的节点则返回null);
  • B树的插入节点流程

    定义一个5阶树(平衡五路搜索树),现在我们要把3,8,31,11,23,29,50,28这些数字构建一个5阶树出来;

    遵循规则:

    1. 节点拆分规则:单签是要组成一个5路搜索树,那么此时m=5,关键字数必须<=5-1(这里关键字数>4就要进行节点拆分);
    2. 排序规则:满足节点本身比左子树左右节点大,比右子树所有节点小的排序规则。

    先插入3、8、31、11

    再插入23、29

    再插入50、28

  • B树节点的删除

    规则:

    1. 节点合并规则:当前是要组成一个5路查找树,那么此时m=5,关键字数必须大于等于ceil(5/2)-1(这里就是关键字数<2就要进行节点合并)
    2. 满足节点本身比左子树所有结点大,比右子树所有结点小的排序规则;
    3. 关键字数小于二时先从子节点取,子节点没有符合条件时就向父节点取,取中间值往父节点放;

    特点:

    B树相对于平衡二叉树的不同是,每个节点包含的关键字增多了,特别是在B树应用到数据库中的时候,数据库充分利用了磁盘块的原理(磁盘数据存储是采用块的形式存储的,每个块的大小为4K,每次IO进行数据读取时,同一个磁盘块的数据可以一次性读取出来)把节点大小限制和充分使用在磁盘快大小范围;把树的节点关键字增多后树的层级比原来的二叉树少了,减少数据查找的次数和复杂度;

B+树

概念:

B+树是B树的一个升级版,相对于B树来说B+树更充分的利用了节点的空间,让查询速度更加稳定,其速度完全接近于二分法查找。

规则:

  1. B+跟B树不同B+树的非叶子节点不保存关键字记录的指针,只进行数据索引,这样使得B+树每个非叶子节点所能保存的关键字大大增加;
  2. B+树叶子节点保存了父节点的所有关键字记录的指针,所有数据地址必须要到叶子节点才能获取到。所以每次数据查询的次数都一样;
  3. B+树叶子节点的关键字从小到大有序排列,左边结尾数据都会保存右边节点开始数据的指针。
  4. 非叶子节点的子节点数=关键字数(来源百度百科)(根据各种资料,这里有两种算法的实现方式,另一种为非叶节点的关键字数=子节点数-1(来源维基百科),虽然他们数据排列结构不一样,但其原理还是一样的。Mysql 的B+树是用第一种方式实现);

特点:

  1. B+树的层级更少:相较于B树B+每个非叶子节点存储的关键字数更多,树的层级更少所以查询数据更快;
  2. B+树查询速度更稳定:B+所有关键字数据地址都存在叶子节点上,所以每次查找的次数都相同所以查询速度要比B树更稳定;
  3. B+树天然具备排序功能:B+树所有的叶子节点数据构成了一个有序链表,在查询大小区间的数据时候更方便,数据紧密性很高,缓存的命中率也会比B树高。
  4. B+树全节点遍历更快:B+树遍历整棵树只需要遍历所有的叶子节点即可,,而不需要像B树一样需要对每一层进行遍历,这有利于数据库做全表扫描。

B树相对于B+树的优点是,如果经常访问的数据离根节点很近,而B树非叶子节点本身存有关键字其数据的地址,所以这种数据检索的时候会要比B+树快。

B*树

规则:

B*树是B+树的变种,相对于B+树他们的不同之处如下:

(1)首先是关键字个数限制问题,B+树初始化的关键字初始化个数是ceil(m/2),b树的初始化个数为(ceil(2/3*m)

(2)B+树节点满时就会分裂,而B*树节点满时会检查兄弟节点是否满(因为每个节点都有指向兄弟的指针),如果兄弟节点未满则向兄弟节点转移关键字,如果兄弟节点已满,则从当前节点和兄弟节点各拿出1/3的数据创建一个新的节点出来;

特点:

在B+树的基础上因其初始化的容量变大,使得节点空间使用率更高,而又存有兄弟节点的指针,可以向兄弟节点转移关键字的特性使得B*树额外分解次数变得更少;

总结

1、相同思想和策略

从平衡二叉树、B树、B+树、B*树总体来看它们的贯彻的思想是相同的,都是采用二分法和数据平衡策略来提升查找数据的速度;

2、不同的方式的磁盘空间利用

不同点是他们一个一个在演变的过程中通过IO从磁盘读取数据的原理进行一步步的演变,每一次演变都是为了让节点的空间更合理的运用起来,从而使树的层级减少达到快速查找数据的目的;

蓄水池采样算法(Reservoir Sampling)

蓄水池采样算法是非常常用的一种流式数据处理算法

问题

大致描述:

给出一个数据流,这个数据流的长度很大或未知,并且对该数据流中的数据只能访问一次。请写出一个随机选择算法,使得数据流中所有数据被选中的概率相等。

一些实际问题

  1. 从 100,000 分调查报告中抽取1000份进行统计。
  2. 从一本很厚的电话簿中抽取1000人进行姓氏统计。
  3. 从google搜索"Ken Thompson",从中抽取100个结果查看哪些是今年的。

这些都是很基本的采样问题。

既然说的采样问题,最重要的就是做到公平,也就是保证每个元素被采样到的概率是相同的。

对于第一个问题,我们已经知道数据的规模,通过算法生成[0, 100,000-1]间的随机数1000个,并且保证不重复即可。再取出对应的元素即可。

但是对于第二和第三个问题,我们不知道数据的整体规模是多大。可能有人会想到,可以先对数据进行一次遍历,计算出数据的规模N,然后按照第一题的方法采样即可。这当然可以,但是并不好。因为这可能需要遍历两次,需要花两次的时间。也可以尝试估算数据的规模,但是这样得到的采样数据可能并不平均。

问题严格定义

给你一个长度为N的链表。N很大,但你不知道N有多大。你的任务是从这N个元素中随机抽取k个元素。你只能遍历这个链表一次。你的算法必须保证取出的元素恰好有k个,且它们是完全随机的(出现概率相等的)。

解法

蓄水池算法:

蓄水池算法是针对从一个序列中随机抽取不重复的K个数,保证每个数被抽取到的概率都为K/N这个问题构建的。

做法:

首先构造一个可以容纳k个元素的蓄水池(数组),将序列前k个元素直接放入蓄水池数组中。

然后从第i = k+1个数据开始,以k/i(k<i<=n)的概率决定它是否进入到蓄水池中。蓄水池中的k个元素被替换出去的概率是相同的。

当遍历完所有元素之后,数组中剩下的元素即为所需采取的样本。

证明

对于第i个数(i <= k)。在k步之前,被选中的概率为1。当走到第k+1步时,被k+1个元素替换的概率=第k+1个元素被选中的概率i被替换的概率,即为 k/(k+1) 1/k = 1/(k+1)。则不被第k+1个元素替换的概率为1 - 1/(k+1) = k/(k+1)。依次类推,不被K+2个元素替换的概率为1-k/(k+2) * 1/k = (k+1)/(k+2)。则运行到第n步时,第i个数仍保留的概率=被选中的概率不被替换的概率,即: \[ 1 \times \frac {k}{k+1}\times \frac {k+1}{k+2}\times \frac {k+2}{k+3}\times ...\times \frac {n-1}{n} = \frac {k}{n} \] 对于第j个数(j>k)。我们知道,在第j步被选中的概率为k/j。不被j+1个元素替换的概率为1 - k/(j+1) 1/k = j/(j+1)。则运行到第n步时,第i个数仍保留的概率=被选中的概率*不被替换的概率,即: \[ \frac {k}{j}\times \frac {j}{j+1}\times \frac {j+1}{j+2}\times \frac {j+2}{j+3}\times ... \times \frac {n-1}{n} = \frac {k}{n} \] 所以对于中每个元素,被保留的概率都为k/n。

实现

python3版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# 蓄水池算法实现.py
import random


class ReservoirSampling(object):

def sample(self, node, k):
data = []
# 计数器
counter = 0
while node:
counter += 1
# 前k个元素直接放入
if counter <= k:
data.append(node)
else:
# 判断第j个元素是否留下
if random.randint(1, counter) <= k:
# 判断替换掉哪个元素
removed_idx = random.randint(0, k - 1)
# 替换该元素,放入新元素
data[removed_idx] = node
# 如果不留下,就继续
# 访问下一个node
node = next(node)
return data


def main():
class ListNode(object):
def __init__(self, val):
self.val = val
self.next = None

def __next__(self):
return self.next

head = ListNode(0)
cur = head
for i in range(1, 11):
cur.next = ListNode(i)
cur = cur.next
rs = ReservoirSampling()
res = rs.sample(head, 10)
for node in res:
print(node.val)


if __name__ == '__main__':
main()

实战题目

382. 链表随机节点

使用函数randa()来实现函数randb()

原文引自:https://blog.csdn.net/wangruitao1991/article/details/51678815

我们由浅入深,首先来看:

  1. 给你一个能成1到7随机数的函数,用它写一个生成1到5的随机数。即使用rand7来实现rand5

    rand7可以随机生成1,2,3,4,5,6,7,是等概率的,这里直观的想法是不断地电泳rand7,直到它生成1到5之间的数,然后返回。代码如下:

    1
    2
    3
    4
    5
    6
    7
    int rand5() {
    inx x = ~(1<<31); // max int
    while (x > 5){
    x = rand7();
    }
    return x;
    }

    这个函数可以等概率的产生1到5的数码?首先,它确确实实只会返回1到5这几个数,其次,对于这些数,都是由rand7等概率的产生的1/7,没有对任何一个数有偏袒,直觉告诉我们,rand5就是等概率的产生1到5的。事实呢?让我们来计算一下,产生1到5中的数是不是1/5就OK了。

    产生1的概率:等于第一次产生1的概率,加上第一次生成6,7第二次产生1的概率,加上... $$ p(x=1) = 1/7 + 2/7 * 1/7 + (2/7)^2 * 1/7 + ... \

    = 1/7 * (1 + 2/7 + (2/7)^2+...)\

    = 1/7 * 1 / (1-2/7)\

    = 1/5\ $$ 其他同理,所以从上面的分析,我们可以得到一个一般的结论,如果a > b,那么一定可以用randa实现randb。其中,randa表示等概率生成1到a的函数,randb表示等概率生成1到b的函数。代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    ;// a > b
    int randb() {
    inx x = ~(1 << 31); // max int
    while (x > b) {
    x = randa();
    }
    return x;
    }

    这里还有没有优化的空间呢。我们想如果a大于b很多,那么这个循环大多数是无法退出的。这是我们可以找到一个最接近a的b的整数倍b * (A/b),大于这个整数倍就继续循环,否则就返回 randa()%b + 1 。代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    // a > b
    int randa() {
    int x = ~(1 << 31); // max int
    while (x > b * (a / b)) {
    x = randa();
    }
    return x % b + 1;
    }

    好了,a大于b时这个问题得到完美的解决了。那么a小于b的时候呢。

    比如,如何用rand5实现rand7。

    我们只需要将rand5映射到一个能产生更大随机数的randa,a > 7,这个问题就可以解决了。这里要注意,映射之后的randa也应该是等概率生成1到a的。

    如何映射呢。其实可以将rand5想象成一个五进制数。2个rand5就可以表示25种情况。

    5 * (rand5() - 1) + rand5(),可以等概率的产生1-25之间的数字。

    根据上面的模板我们可以得到以下的代码:

    1
    2
    3
    4
    5
    6
    7
    int rand7() {
    int x = ~(1 << 31); // max int
    while (x > 7) {
    x = 5 * (rand5() - 1) + rand5();
    }
    return x;
    }

    在根据上面的模板简化:

    1
    2
    3
    4
    5
    6
    7
    int rand7() {
    int x = ~(1 << 31); // max int
    while (x > 21) {
    x = 5 * (rand5() - 1) + rand5();
    }
    return x % 7 +1;
    }

    通过上文分析,我们可以得到步骤如下:

    1. 如果a > b,进入步骤2;否则构造Randa2 = a * (Randa – 1) + Randa, 表示生成1到a2 随机数的函数。如果a2 仍小于b,继教构造 Randa3 = a * (Randa2 - 1) + Randa…直到ak > b,这时我们得到Randak , 我们记为RandA。
    2. 步骤1中我们得到了RandA(可能是Randa或Randak ),其中A > b, 我们用下述代码构造Randb:
    1
    2
    3
    4
    5
    6
    7
    // A > b
    int Randb(){
    int x = ~(1<<31); // max int
    while(x > b*(A/b)) // b*(A/b)表示最接近A且小于A的b的倍数
    x = RandA();
    return x%b + 1;
    }

    从上面一系列的分析可以发现,如果给你两个生成随机数的函数Randa和Randb, 你可以通过以下方式轻松构造Randab,生成1到a*b的随机数。

    1
    2
    Randab = b * (Randa - 1) + Randb
    Randab = a * (Randb - 1) + Randa