0%

无线网络和移动网络

概述

构成

无线网络和移动网络的构成大致相同,都是有三部分组成

  • 无线主机:就是我们所用的终端,如手机,电脑等.
  • **基站:**负责协调无线主机和网络基础设施之间的通讯,比如交换机,信号塔
  • 无线链路:主机通过无线通信链路连接到一个基站,与链路访问相匹配的多址访问协议

无线局域网可分为两大类:有固定基础设施和自组网络(ad hoc 网络) 。n无线局域网可分为两大类:有固定基础设施和自组网络(ad hoc 网络) 。

基础设施模式

基础设施模式是指预先建立起来的、能够覆盖一定地理范围的一批固定基站。

移动主机通过基站接入有线网络;

切换:移动主机的移动可能会改变与之相关联的基站。

ad hoc网络

自组织网络(ad hoc network)中,主机不与基站相连,主机本身提供这些服务。

  • 无基站

  • 节点(移动主机)仅仅能够在其覆盖范围内向其他节点传送数据;

  • 节点之间相互通信组成的临时网络:在它们内部进行选路和地址分配。

  • 自组织网络(ad hoc network)中,主机不与基站相连,主机本身提供这些服务。

无线网络类型

标准:无线网分组跨越的无线跳、是否有基础设施。
1、单跳、基于基础设施。802.11、3G蜂窝网等。
2、单跳、无基础设施。蓝牙、自组织模式的802.11
3、多跳、基于基础设施。结点通过无线结点中继通信,连接到基站,如无线网状网络。
4、多跳、无基础设施。移动自组织网络,包括车载自组织网络。

无线链路

  • 无线网络比特差错率较高,不仅需要CRC,还可能需要链路层ARQ重传受损的帧。
  • 两台主机不足以相互检测到对方的传输,由于
    • 由于物理阻挡存在隐藏终端问题(hidden terminal problem)。
    • 信号衰减(fading)。

无线网络特征

  1. 信号强度递减。有路径耗损。
  2. 其他源的干扰。如2.4GHz的802.11b受到对应无线电话和环境电子噪音影响。
  3. 多径传播。由于地面发射通过多条路径传播,接收的信号模糊。
  4. 物理层特征
    对于给定的调制方案,SNR(信噪比,Signal-to-Noise Ratio)越高,BER(比特差错率)越低。
    对于给定的SNR,较高比特传输速率的调制技术具有较高的BER。
    物理层可以根据信道条件动态选择调制技术。

CDMA

码多分址(Code Divison Multiple Access, CDMA),是一种信道划分协议 。每个要发送的比特乘一个信号比特编码,这个信号变化速率(码片速率, chipping rate)比初始数据快。接收方再乘以相同的编码获得原始数据。

每个用户被指派一个惟一的m bit码片序列。

所有用户共享相同的频道, 但每个用户用自己的“码片”序列对数据编码。

例如,发送方使用的CDMA码:(1,1,1,-1,1,-1,-1,-1)发送01比特,0表示为-1:

SKkilat

例:设m=8且码片为00011011,则为便于运算与表示,通常将其表示为:

S=(-1,-1,-1,+1,+1,-1,+1,+1)

若发送bit 1则发送序列:

​ (-1,-1,-1,+1,+1,-1,+1,+1)

若发送bit 0则发送序列:

​ (+1,+1,+1,-1,-1,+1,-1,-1)

当站点发送比特“1”时,就发送指定给该站点的mbit码片序列;发送比特“0”时,发送此mbit码片序列的二进制反码(当用-1表示0时,发送的就是-1 * mbit码片序列)

习惯上,将码片序列中的“0”写成“-1”,“1”写成“+1”

两种无线网络

wifi

  • IEEE 802.11 无线LAN,也称WiFi,是一种较小范围的接入网技术。

都是使用CSMA/CA 协议实现多路访问

都可以用于有固定基础设施模式和自组网络模式

wifi体系结构

802.11基本构建模块为基本服务集(Basic Service Set, BSS),一个BSS包括多个无线站点和一个接入点(Access Point, AP, 中央基站)。所有站在本BSS以内都可以直接通信。但在和本 BSS 以外的站通信时都要通过本 BSS 的基站。

802.11站点可以自己组合成一个自组织网络。

  1. 802.11频段2.4~2.4835GHz,分为11个部分重叠的信道,其中只有3个非重叠信道(两个信道仅当中间相隔4个及以上的信道时,无重叠),在同一个物理网络中可以连接3个AP,然后连接到一个交换机上。意味着 可以在一个无线 局域网里安装3个AP,分别分配信道 1,6,11。每个AP接到 一个交换机,这样无线LAN的最大传输速率可以到33M
  2. 无线站点关联AP
    • 每个AP有一个SSID(服务集标识符,Service Set Identifier),每个AP被分配一个信道号
    • WiFi Jungle:主机如何从多个AP中关联其中一个?
    • 每个AP周期性发送信标帧,包括AP的SSID和MAC
    • 主机对11个信道进行扫描,获取所有可用的AP的信标帧
    • 主机选择其中一个AP进行关联,加入其所属子网
    • 主机向关联AP发送DHCP发现报文,获取IP地址
    • 可能需要身份鉴别

一个基本服务集可以是孤立的,也可通过接入点 AP连接到一个主干分配系统 DS (Distribution System),然后再接入到另一个基本服务集,构成扩展的服务集ESS (Extended Service Set)。

ESS 还可通过叫做门桥(portal)为无线用户提供到非 802.11 无线局域网(例如,到有线连接的因特网)的接入。门桥的作用就相当于一个网桥。

自组网络(ad hoc network)

自组网络没有上述基本服务集中的接入点 AP 而是由一些处于平等状态的移动站之间相互通信组成的临时网络。

802.11的MAC协议–CSMA/CA

WiFi所使用的MAC协议叫做CSMA/CA**,CSMA即是载波侦听**,其原理同以前讲述的一样,他会检测其他无线站点是否正在发送数据,如果是则停止传输,知道信道空闲.但是和以前不同的是,CA是碰撞避免,而不是CD碰转检测,因为CD完全不适用于无线链路,其一,两个无线站点之间如果需要互相检测到对方的发送信号所需的成本太大,其二,即使可以互相检测到信号,也会因为隐藏终端问题和衰减的问题无法检测到所有的碰撞。

802.11采取碰撞避免而非碰撞检测

发送帧的步骤是:

  1. 在发送信号之前,即侦听到信道空闲时,会在一个分布式帧间间隔DIFS的短时间后发送数据帧.(无冲突检测)
  2. 若信道繁忙,会选取一个随机回退值(相等于一个定时器),每当侦听到信道空闲时此回退值就会减小,信道繁忙则会冻结回退值,当回退值为0时,发送数据帧。
  3. 发送数据帧并等待确认,目的地则会在等待一个被称为短帧间间隔SIFS的短时间后发送确认帧.
  4. 如果源收到确认帧,表示被正确接收了,需要发送其他帧会从第二步开始.如果未收到确认,进入第二部的回退阶段,并从更大的范围选取回退值,如果发送多次,放弃发送该帧.

处理终端隐藏问题:

思路: 允许发送方“预约”信道而非随机访问 :避免长的数据帧冲突

  • 发送方使用请求发送(Request to Send, RTS)控制帧,AP允许发送(Clear to Send, CTS)控制帧预约对信道的访问。
  • 当帧长超过门限值时使用两个控制帧,减少消耗的信道资源。
  • RTS ,CTS被所有节点侦听到:发送方发送数据帧,其他站点推迟发送。

RTS也可能仍会相互冲突 (但时间很短)

802.11MAC帧格式

  • 帧控制:包含许多子字段,类型和子类型用于区分管理,RTS,CTS,ACK和数据帧,WEP用于知识是否加密,to,from定义不同地址 字段的含义等等,不做具体描述
  • 持续期:表示预约信道的时间,即在上文中描述的如何处理隐藏终端问题的预约时间
  • 地址一:源MAC地址
  • 地址二:接收该帧的设备的MAC地址
  • 地址三:AP所连的交换机的MAC地址,是基本服务集BSS和因特网连接的关键
  • 序号控制:用于区分帧的重传和新的发送
  • 地址四:用于自组织间的MAC地址
  • 有效载荷:存放数据
  • CRC:循环冗余检测字段
帧地址的变化

1、R1到H1
路由器R1将数据报封装在以太网帧中,源地址为R1的MAC地址,目的地址为H1的MAC地址。
帧到AP后,由802.3以太网帧转换为802.11无线网帧。地址1为接收方H1的MAC地址,地址2为发送方AP的MAC地址,地址3为R1的MAC地址。
2、H1到R1
H1生成一个802.11帧,地址1为AP的MAC地址,地址2为H1的MAC地址,地址3为R1的MAC地址。
AP收到该帧后,转为以太网帧,源地址为H1的MAC地址,目的地址为R1的MAC地址。
地址3允许AP构建以太网帧时确定目的MAC地址,在BSS和有线局域网互联中起作用。

其他性质

速率适应。条件较好(收到ACK),增加传输速率,条件变差(没有收到ACK),减小传输速率。类似TCP拥塞机制。
功率管理。一个结点可以明显的在睡眠和唤醒状态之间交替。
结点向AP指示打算睡眠,并设置定时器,在AP发送信标帧(100ms一次)前唤醒(约250us)结点。
结点睡眠时,AP先缓存帧,待以后传输。
短暂的唤醒时间、接收信标帧时间、检查缓存帧的时间,能大大节约能源。

蜂窝网

WiFi热区范围较小,蜂窝网通过基站控制器(Base Station Controller, BSC)和收发基站(Base Transceiver Station, BTS)组成GSM基站系统,并将它们连接,可以进行大范围的无线覆盖。一个基站覆盖的区域类似蜂窝。
蜂窝电话进行扩展,不光可以传播语音,还可以传输IP数据报。

移动管理

原理
归属网络(home network):一个移动结点的永久居所。
归属代理(home agent):归属网络中代表移动结点执行移动管理功能的实体
永久地址:归属网络中的地址,用它一定可以找到移动用户

外部网络(foreign network):移动结点当前所处网络,也称为被访网络(visited network)。
外部代理(foreing agent):外部网络中代表结点移动管理的实体。
通信者(correspondent):希望与移动结点通信的实体。

寻址

  • 外部代理为移动节点创建转交地址(Care-Of Address, COA),与外部网络匹配。一个移动结点可以和两个地址相关联,永久地址(permanent address)和COA。
  • 外部代理可以告诉归属代理,该移动结点在他的外部网络和他的COA

间接路由选择

步骤:

  1. 数据报导向移动结点的归属网络。
  2. 归属代理将原始数据报封装(encapsulate)成大数据报,交付到移动结点的COA。
  3. 拥有该COA的外部代理接收并拆封大数据报,向移动结点转发原始数据报。
  4. 移动结点使用永久地址为源地址,通信者地址为目的地址,直接发送数据报到通信者。

  • 用户移动到新的网络时,注册新的COA,更新原本归属代理中的COA。
  • 存在三角路由选择问题,即使通信者和移动结点之间有更有效的路径,也会先发给归属代理,再发到外部网络。

直接路由选择

步骤:

  1. 通信者代理向归属代理查询移动结点的COA。
  2. 通信者代理使用隧道技术经过外部代理发往移动结点。

问题:对通信者来说是非透明的: 通信者必须从归属代理那里得到转交地址COA

如果移动用户从一个被访问网络移动到另一个网络会怎样呢?

由于直接选路时,归属代理只在会话开始时被询问过一次,那么在会话过程中移动用户又切换了网络怎么办?

移动结点移动到另一个外部网络时

  1. 移动结点向新的外部代理注册。
  2. 新的外部代理向锚外部代理(会话开始时首次发现移动结点时对应的外部代理)提供移动结点新的COA
  3. 锚外部代理收到发往该移动结点的数据报后,使用新的COA重新封装数据报,并且发向移动结点。
  4. 如果移动结点又移动到新的外部网络,对应的外部代理需要随后建立和锚代理的联系。

移动性管理

移动IP

支持移动性的因特网体系结构与协议合起来被称为移动IP.有三部分组成

  1. 代理发现。代理通告服务或者移动结点请求服务。
    1、代理通告。外部代理和归属代理周期性的广播类型9的ICMP报文,包括COA等信息。
    2、代理请求。移动结点广播类型10的ICMP报文,收到请求的代理单播一个代理通告。

  2. 向归属代理注册。移动结点收到COA后,该地址必须向归属代理注册。

    1、收到外部代理通告之后,移动结点向外部代理发送注册报文,放入端口号434的UDP数据报中,包括:通告的COA、归属代理地址(HA)、移动结点永久地址(MA)、请求注册寿命、注册标识。
    2、外部代理收到注册后记录永久IP(封装的数据报的目的地址为该永久IP,则该数据包需要解封)。向归属代理发送注册报文。
    3、归属代理接收后验证,将移动结点的永久IP和COA绑定(到达永久IP的数据报被封装,隧道方式经外部代理发给COA)。归属代理发送注册回答。
    4、外部代理接收注册回答,转发个移动结点。
    数据报的间接路由选择。

  3. **代理通告:**外部代理/归属代理会周期性的通告其服务,通过发送一个类型字段为9的ICMP报文实现,报文结构如下图:

网络层

第一部分:网络层概述

网络层是整个协议栈中最复杂的层次。它能够被分为两个相互作用的部分:数据平面和控制平面

1、网络层的目标:

是实现主机到主机的通信。

2、网络层在计算机网络的作用是:

  • 为运输层提供支持。
  • 为实现从源主机到目标主机成功的移动数据分组,整个路径上的每一台分组交换机上均需实现网络层。

网络层属于网络核心的功能,从运输层到网络层就从网络边缘进入到了网络核心。

3、网络层的核心功能:选路转发

转发是指在路由器内部将输入端口的分组转移到正确的输出端口;而路由是指路由器决定从源到目的地的路径

在全局范畴为主机之间的通信进行选路,选路的结果反映为分组交换机上的转发表

分组交换机上的网络层根据转发表以及分组头部信息,将分组向适当链路进行转发

对于面向连接的网络层服务,提供连接建立的功能

分组交换机的分类:

根据链路层首部信息进行转发的——链路层节点交换机

根据网络层首部信息进行转发的——路由器

4、网路层可以提供的服务:

  • 确保交付
  • 具有时延上界的确保交付
  • 有序分组交付
  • 确保最小带宽
  • 确保最大时延抖动

面向连接的服务——虚电路,需事先握手

面向无连接的服务——数据报,无需握手

5、几种实际使用的网络层服务模型:

6、网络层和运输层相应服务的区别

网络层只向运输层提供主机到主机的服务;运输层是向应用层提供进程到进程的服务。

值得一提的是运输层同时提供上述两种服务。

此外,运输层的服务是在网络边缘的端系统实现的(端到端系统)。而网络层的服务则是在整个网络中实现的,包含路由器。

第二部分:路由器的工作原理

路由器的结构

整个路由器结构可划分为两大部分:

1、路由选择部分。

路由选择部分:也叫做控制部分,其核心构件是路由选择处理机。 路由选择处理机的任务是根据所选定的路由选择协议构 造出路由表,同时经常或定期地和相邻路由器交换路由信息而不断地更新和维护路由表。

2、分组转发部分。

分组转发部分由三部分组成:

​ 1、交换结构(switching fabric):又称为交换组织,其作用是根据转发表(forwarding table) 对分组进行处理。

​ 2、一组输入端口

​ 3、一组输出端口 (请注意:这里的端口就是硬件接口)

  • 输入端口:线路端接→数据链路处理(协议、拆封)→查找、转发、排队(→交换结构)

分散式交换:

按照给出的目的地址,使用输入端口的内存中存储的路由选择表,查找输出端口(注意每个输入端口都有一份转发表的拷贝),

目标:以“线路速度”完成输入端口的处理

排队:如果数据报到达的速度超过了输入端口将数据报转交给交换结构的速度,则后到的分组会暂时阻塞

​ 输入端口排队:输入的分组太多,就会对其进行缓存。会产生线头阻塞现象(大家都想吃麦当劳,即使肯德基没人排队)。输入缓冲区溢出可导致排队时延和丢包。

  • 输出端口:排队(缓存管理)→数据链路处理(协议、封装)→线路端接→

​ 输出端口里面装有物理层、数据链路层和网络层的处理模块。输出端口从交换结构接收分组,然后把它们发送 到路由器外面的线路上。

​ 在网络层的处理模块中设有一个缓冲区(队列)。 当交换结构传送过来的分组的速率超过输出链路的发送速率 时,来不及发送的分组就必须暂时存放在这个队列中。

​ 数据链路层处理模块将分组加上链路层的首部和尾部,交给物理层后发送到外部线路。

​ 输出端口排队:来不及输出的分组就对其进行缓存,输出缓冲区溢出会导致分组的排队和丢失。

  • 交换结构:三种交换技术(经内存交换、经总线交换、经互联网交换)

    通过内存:当路由器的某个输入端口收到一个分组时,就用中断方式通知路由选择处理机。然后分组就从输入端口复制到内存中。 路由器处理机从分组首部提取目的地址,查找路由表, 再将分组复制到合适的输出端口的缓存中。

    问题:若存储器的带宽(读或写)为每秒M个分组,那么路由器的交换速率(即分组从输入端口传送到输出端口的速 率)一定小于M/2。【转发速度受限于内存的带宽(每个分组走两次总线)】

    通过总线 :数据报从输入端口通过共享的总线直接传送到合适的输出端口,而不需要路由选择处理机的干预。

    问题:因为每一个要转发的分组都要通过这一条总线,因此路由器的转发带宽就受总线速率的限制。

    现代的技术已经可以将总线的带宽提高到每秒吉比特的速率,因此许多的路由器产品都采用这种通过总线的交换方式。

    通过纵横交换结构(crossbar switch fabric):这种交换结构常称为互连网络(interconnection network)。 它有2N条总线,可以使N个输入端口和N个输出端口相连接。当输入端口收到一个分组时,就将它发送到与该输入端口相连的水平总线上。

    若通向所要转发的输出端口的垂直总线是空闲的,则在这个结点将垂直总线与水平总线接通,然后将该分组转发到这个输出 端口。但若该垂直总线已被占用(有另一个分组正在转发到同一个输出端口),则后到达的分组就被阻塞,必须在输入端口排队。

问题:缓冲区设置多少合适呢?

a:$B=RTT x R$

输出端口分组调度策略

1、先来先服务FCFS/FIFO

2、加权公平排队WFQ。类似与OS中的优先级优先策略。

第三部分:网际协议:因特网中的转发和编址

因特网网络层的内部视图:

网络层主体是什么,它又是如何提供上述服务呢?

网络层具有三个主要组件:IP协议、因特网控制报文协议、因特网路由选择协议。通过这些
组件,网络层可以复杂的网络网中寻找到最合适的路径,将分组从源主机移动到目的主机。
下面将通过具体介绍这三大组件,来介绍网络层。

路由器转发表只有目标地址和链路接口。

IP数据报格式

目前正在使用的IP协议有两个版本,一个是广泛部署的IPv4,另一个是被提议用来代替IPv4
IPv6。对于不同版本的IP协议,对应着不同的数据报格式

一个IP数据报由首部和数据两部分组成。首部的前一部分长度是20字节,后一部分为可变长度。

(1)IP数据报首部固定部分中的各字段

版本:4bit,指IP协议的版本。通信双方使用的版本必须保持一致,目前使用的是IPV4。

首部长度:4bit。以4字节为一个单位。

服务类型:8bit,用来获取更好的服务。前3bit表示优先级,第4bit表示要求更低时延,第5比特表示要求更高吞吐量,第6比特表示要求更高可靠性,第7比特表示要求选择费用更低廉的路由,第8比特未用。

总长度:这是整个IP数据报的长度,即首部加数据使用字节计算。该字段长为16比特,因
此,IPv4数据报的理论最大长度为65535字节。

标识、标志位、片偏移:它们与IP分片有关,标识号用于确定哪些数据报其实是同一个较大
数据报的片,最后一个片的标志位被设为0, 而其他片的标志位被设为1, 偏移字用于指定
该片应该存放在数据报的哪个位置。以8个字节为偏移单位。

TTL: 用于确保数据报不会长时间在网络中循环,每当数据报由一台路由器处理时,该字段
的值减一,当TTL为0时,数据报将会被丢弃。

协议:该字段标识数据报的数据部分将会交给哪个特定的运输层协议

首部校验和:用于帮助路由器检测收到的IP数据报中的比特错误,路由器一般会丢弃检测出
错误的数据报,

源和目的IP地址:顾名思义,就是发出此数据报和接收此数据报的主机地址。

(2)IP首部的可变部分:

选项:选项允许IP首部被扩展,但很少使用。选项使得数据报首部长度可变,故无法预先确
定数据字段从何开始。而且使得处理每个数据报的时间不定、也增加了开销

数据:数据报的有效载荷,被用来交给上一层

IPv6数据报中的字段:

版本:用于标识IP协议的版本号

流量类型:与IPv4中的TOS相似

流标签:该20比特用于标识一条数据报的流

有效载荷长度:该16比特值给出了在IPv6数据报的定长的40字节的数据报首部后的字节数量

下一个首部:标识数据报中的数据字段被交给哪个运输层协议

源和目的地地址

数据:数据报的有效载荷

IP分片和重组

为什么IP要分片?

因为网络链路具有MTU(最大传输单元)属性—–是由链路层最大帧的限制决定的。不同类型的链路具有不同的MTU。

于是大的IP数据报就会在网络中被分成小的分片:

  • 一个数据报变成了几个数据报,每片开头都要带上IP报头。
  • 重组只在目的主机中进行
  • 数据报头部的标识、标志以及片偏移字段用于目的主机对接收的分片进行分组。

e.g.:

IP地址结构

点分十进制(dotted decimal)

一个IP地址可以划分为两个部分:网络号(network numbers)和主机号(host identifier)。

网络号也称为网络前缀(nerwork prefix)、网络标识(network ID)。它是用于确定拥有该IP地址的主机位于哪个网络。

主机号用于确定属于该网络的哪台主机。

问题:

. 下面哪些关于两个IP地址的说法是正确的?
A.如果它们在不同的网络,则它们的网络号必须不同.
B.如果它们在不同的网络,则它们的主机号必须不同.
C.如果它们在相同的网络,则它们的主机号必须相同.
D.如果它们在相同的网络,则它们的网络号必须相同.
E.如果它们在相同的网络,则它们的主机号必须不同.
答案:ADE

IP地址的分类

A类网、B类网、C类网个数是主机号的数量-2

问题:

下面的IP地址中A类、B类、C类地址分别有几个?
92.168.1.100
129.32.123.54
223.89.201.145
220.18.255.254
124.254.200.254
191.64.220.8
66.254.1.100
192.1.100.1

202.15.200.12

答案:

A类:第一位确定为0,范围为:0~127,所以:92.168.1.100 124.254.200.254 66.254.1.100三个属于A类网

B类:前两位确定为10,范围为:128~191,所以:129.32.123.54 191.64.220.8两个属于B类网

C类:前三位确定为110,范围为:192~223,所以:223.89.201.145 220.18.255.254 192.1.100.1 202.15.200.12四个属于C类网

问题:有类地址191.168.1.2的网络号和主机号分别是什么?

答:因为为191开头,所以为B类地址,前16位为网络号,后16位为主机号,所以网络号为191.168.0.0,主机号为0.0.1.2

问题;一个C类网可用的IP地址有多少个?
答:根据问题,可知是一个确定的C类网,IP地址为可用主机个数,2^8-2=254个。

子网划分

一个有类网可以划分为多个相同大小的子网(subnet)。

子网的意义:在实际的运用中,我们不可能直接将一个有类网络管理,需要将其分割成不同的块来细分管理,这些块就是子网。

子网划分的方法:从主机号中借用一部分位数作为子网号

子网掩码

上述的子网划分有一个问题:我们怎么知道子网到底借了主机号多少位作为子网号?

A:通过子网掩码我们就能得知。

子网掩码的记法:223.1.1.0/24中的/24就是子网掩码的记法,表示的是该网络号223.1.1(c类网络)的子网掩码的位数为24位。


首先,我们弄清楚子网掩码位数、IP位数、网络位数、主机位数的关系。

IP地址位数=网络位数+主机位数=32位。子网掩码就是网络地址的位数子网掩码的1的个数表示网络位的个数,子网掩码中0的个数表示主机号的位数】。简单地来说,网络位是不属于你控制的,是上级主管给你的,给你多少就是多少。但是主机位是你可以控制的,你可以把它缩短,把缩短出来的位数加到网络位中,这样网络位就长了,子网数就多了,相应地每个子网的IP数量就少了(相当于IP认了山头)。

A类网络的网络位数是8位,默认子网掩码就是11111111.00000000.00000000.00000000,换算成二进制表示为255.0.0.0。即255.0.0.0的子网掩码表示A类网络中的子网没有借用主机号,就一个子网。

B类网络的网络位数是16位,默认子网掩码就是11111111.11111111.00000000.00000000,换算成十进制表示为255.255.0.0。

C类网络的网络位数是24位,默认子网掩码就是11111111.11111111.11111111.00000000,换算成十进制表示为255.255.255.0。

A类网络加长子网掩码到16位就把一个A类网络划分为256个B类网络同样大小的网络,再加长到24位就又把每个B类大小的子网划分为256个C类网络大小的子网。就是这个道理。一个大的网络,通过把子网掩码加长,使网络位多了,也就是网络数目多了,子网就多了。

一个B类网络的默认子网掩码为255.255.0.0,你如果想把它划分为2个子网,网络位数就成立17位,也就是说子网掩码就变成了255.255.128.0;想划分为16个子网,因为16是2的4次方,所以网络位数加4变成了20位,也就是说子网掩码加长,成了20位,就是255.255.240.0。依此类推。

一个C类网络的默认子网掩码为24位的,那么主机位=32-24=8位,2的8次方等于256,所以一个C类网络的IP地址数量(包括网络地址和广播地址)为256个。

总结:

  • IP地址位数=32
  • 网络位+主机位=32
  • 子网掩码加长n位,则在当前子网基础上划分为2的n次方个子网。每个子网的IP地址数量=2^(32-划分前子网掩码位数-n)

第二个问题:如何根据子网划分的目标计算子网掩码?

A:核心抓住子网掩码的位数等于该有类网现在网络号的位数。知道了这个道理,计算子网掩码的方法就是:已知子网内IP数的多少,求出主机位的位数,用32减去主机位数就等于网络位数,也就是子网掩码。

同一个网段的中的计算机子网掩码相同,计算机的网关就就是到其他网段的出口,也就是路由器接口地址。路由器接口使用的地址可以是本网段中任何一个地址,不过通常使用该网段的第一个可用的地址或最后一个可用的地址,这是为了尽可能避免和网络中的计算机地址冲突。(参见下文的层次路由部分)

网关实质上是一个网络通向其他网络的IP地址(此处也说明了IP地址是路由器的接口地址)。比如有网络A和网络B,网络A的IP地址范围为“192.168.1.1192. 168.1.254”,子网掩码为255.255.255.0;网络B的IP地址范围为“192.168.2.1192.168.2.254”,子网掩码为255.255.255.0。在没有路由器的情况下,两个网络之间是不能进行TCP/IP通信的,即使是两个网络连接在同一台交换机(或集线器)上,TCP/IP协议也会根据子网掩码(255.255.255.0)判定两个网络中的主机处在不同的网络里。而要实现这两个网络之间的通信,则必须通过网关。如果网络A中的主机发现数据包的目的主机不在本地网络中,就把数据包转发给它自己的网关,再由网关转发给网络B的网关,网络B的网关再转发给网络B的某个主机(如附图所示)。网络B向网络A转发数据包的过程。

对默认网关,其意思是一台主机如果找不到可用的网关,就把数据包发给默认指定的网关,由这个网关来处理数据包。现在主机使用的网关,一般指的是默认网关。

CIDR

即上述说的子网掩码的记法:地址格式: a.b.c.d/x, 这里的 x表示地址中网络部分的位数 #

转发

最长前缀匹配:

使用 CIDR 时,路由表中的每个项目由“网络前缀”和“下一跳地址”组成。在查找路由表时可能会得到不止一个匹配结果。

应当从匹配结果中选择具有最长网络前缀的路由:最长前缀匹配(longest-prefix matching)。

网络前缀越长,其地址块就越小,因而路由就越具体。

最长前缀匹配又称为最长匹配或最佳匹配。

e.g

C4.5E.0.0/12的网络前缀为11000100 0101
C4.5E.10.0/20的网络前缀为11000100 01011110 0000
C4.60.0.0/12的网络前缀为11000100 0110
C4.68.0.0/14的网络前缀为11000100 011010
80.0.0.0/1的网络前缀为1
40.0.0.0/2的网络前缀为01
0.0.0.0/2的网络前缀为00

a中为11000100 01011110 00001101 87符合B;b中为11000100 01011110 0001xxxx 09和B不符合,退而求其次选择A。其他同理。

获取网络地址和IP地址(主机部分)

Q:一个网络如何获取一个地址?
A:从ISP的地址空间中获得。(找上级组织)

Q:ISP如何获得整块地址?
A: ICANN: Internet Corporation for Assigned Names and Numbers(因特网名字与号码分配团体)

分配IP地址

管理 DNS

分配域名, 解决域名纠纷

Q:主机如何获得IP地址?(主机部分)
1.A:手动配置。DHCP 动态主机配置协议:从服务器上动态获取IP地址

DHCP协议

用于主机在加入网络时动态租用IP地址。

问:在一个以太网中,哪个DHCP数据包可以让该网络中的每个DHCP服务器知道DHCP客户端是否选用了它提供的IP地址?
A.DHCP discover
B.DHCP offer
C.DHCP request
D.DHCP ack
解析;如上图,DHCP request可以告知每个DHCP服务器这个客户端选择的是否是服务器提供的IP地址。

DHCP协议的步骤:

主机广播 “DHCP discover” 报文

DHCP服务器响应“DHCP offer” 报文

主机请求IP地址: “DHCP request” 报文

DHCP 服务器确认: “DHCP ack” 报文

从移动性角度看,DHCP存在问题:当一个节点连接到新的子网中时,要从DHCP中获取新的IP地址。所以在移动的过程中就不能维持TCP的连接。

网络地址转换NAT(Network Address Translation)

NAT能使路由器对于外部世界看起来像一个单一IP的单一设备, 使路由器对外界隐藏内部网络的细节所有离开此内部网络的报文与进入此网络的报文都有一个相同的源地址与目的地址,NAT路由器通过使用一张NAT转换表来区分内部网络中的各个主机,转换表包含端口号与其IP地址。NAT转换表与某台主机中端口号与进程ID号对照表类似。(NAT就好似一个班级,本地IP地址就是班级的同学,其他人访问这个班级的一个IP地址时,只需要访问到班级,由班级自己转达到该IP地址。此外,为了保护班级同学的隐私,班级对外展示的都是班级同学的外号,其他人找人也只能通过班级+外号来找人。)

把原本的本地IP地址映射成为源端口号

例如外网需要远程连接内网的192.168.1.9的主机,在NAT中把远程访问的端口3389影射为内网的192.168.1.9:3389,当外网输入外网Ip做远程访问时,路由检测到3389的端口号,就自动转到内网192.168.1.9,这时候外网就直接可以和内网的192.168.1.9通过端口影射直接访问了

NAT转换表

(相当于班级外号表,将班级内的同学的姓名转化成外号传输给外人)

记录的是源IP地址,端口号 -> NAT IP 地址,新的端口的转换对。

假设一个私有地址为10.1.0.2的主机想访问互联网服务器162.105.192.12,那么首先它首先把消息发出给NAT路由器。路由器记录了它的内网地址和端口,并且给它分配一个全局地址和全局端口。这个地址关系记录在NAT路由表中。之后按照目的地址发给服务器。一段时间之后,服务器回应了请求给NAT路由器,那么路由器根据目的地址和端口(此时是全局的,就是班级号+外号)按照NAT路由表转换为对应的主机地址,再发送给主机,这样主机就收到了服务器的回应。

NAT的优点:

1.不需要从 ISP处获得大批IP地址: 所有设备可以使用同一个 IP地址
2.可以在不通知外部网络的情况下改变内网主机的IP地址
3.即使改变了ISP也无须改变内网主机的IP地址
4.内网主机对外网主机而言是不可见的、不可寻址的。
(这也算是一项安全措施).
5.由上述的分析可知NAT可以扩展IP的数量(每一个路由器都可以分配很多私有地址,并且不同路由器的私有地址可以重复。不同班级的二狗子表示不同的人,但是二狗子)

NAT虽然得到广泛应用,但很多人反对NAT,原因如下:
1.端口号是用于进程编址,而不是主机编址
2.路由器通常仅应当处理高达第三层的分组
3.主机应彼此直接对话,结点不应该介入修改IP地址与端口号
4.应该使用IPv6来解决IP地址不足的问题

NAT的另一个问题是妨碍了P2P应用程序, 使得在某个NAT下的主机无法与另一个主机建立对等方发起的一条TCP连接。

UPnP:允许外部主机使用TCP或UDP向NAT化的主机发起通信会话。(告诉对方我的真实IP地址和应用的端口号)

内部主机通过IGD( Internet Gateway Device )协议

  • 了解公共IP地址
  • 向路由器注册/移除映射记录
    (内部IP地址,内部端口号)–>(公共IP地址,公共端口号)

内部主机通过某种渠道向外部应用程序公开(公共IP地址,公共端口号)

适用于P2P应用

ICMP:因特网控制报文协议

因特网控制消息协议(Internet Control Message Protocol)用于主机或路由器发布网络级别的控制消息
【错误报告: 如主机、网络、端口、协议不可达等。
回声请求/回答 (用于ping应用程序)】

1、从体系结构而言,位于IP层之上 :

  • ICMP 报文封装在IP分组中

2、ICMP 消息: 包括一个类型字段和一个编码字段

3、ICMP 报文的种类有两种,即 ICMP 差错报告报文和 ICMP 询问报文

4、ICMP消息的一般格式:

ICMP消息的常见类型:

IPv6

IPv6的数据报格式

·版本号。这 4 比特字段用于标识 IP 版本号。 IPv6 将该字段值设为 6。

·流量类型。与IPv4的TOS相似

·流标签。 该 20 比特字段用于标识一个数据报的流。

·有效载荷长度。 给出了 IPv6 数据报中跟在定长的 40 字节数据报首部后面的字节数 量。

·下一个首部。该字段标识该数据报中的内容(数据字段)需要交付给哪个协议(如 TCP 或 UDP)该字段使用与 IPv4 首部中协议字段相同的值。

·跳限制。转发数据报的每台路由器将对该字段内容减 1。如果跳限制计数到达 0,则 该数据报将被丢弃。

·源和目的地址。

·数据。 几个字段在 IPv6 数据报中已废弃:

·分片相关字段。IPv6 不允许在中间路由器上进行分片与重新组装。

·首部校验和。

·选项字段。

几个字段在 IPv6 数据报中已废弃:

·分片相关字段。IPv6 不允许在中间路由器上进行分片与重新组装。

·首部校验和。

·选项字段。

ipv4到ipv6的迁移

双栈技术:

1、如果源和目标都支持IPv6的话,就使用Ipv6进行通信。

2、如果任何一方不支持IPv6的话,则使用IPv4进行通信。

第四部分:选路算法

目的是:给定一组路由器以及连接路由器的链路,从中找到一条从源路由器到目标路由器“好的”路径。

  • 根据信息是全局性还是分散式的进行分类:

全局选路算法

要求:

1、所有路由器都知道整个网络拓扑图以及链路的费用信息
2、链路状态算法

分散式选路算法

要求:

1、每个路由器仅有与其相连链路的费用信息
2、通过迭代计算过程与相邻节点交换信息
3、距离向量算法

  • 根据信息是静态还是动态的进行分类

静态选路算法

要求:

1、随着时间的流逝,路由的变化非常缓慢

动态选路算法

要求:

1、路由信息可以更快地发生变化
2、周期性的跟新
3、可以响应拓扑或链路费用的变化

  • 根据是否对负载敏感进行分类

负载敏感算法

要求:

1、链路费用会动态地变化以反映出链路的当前状况

负载迟钝算法

要求:

1、链路费用不明显地反映链路的当前状况

链路状态选路算法LS

选择的是迪克斯拉特算法(SPF)—–最低费用算法。具体算法实现不介绍。(Dijkstra算法可参考如下:点击打开链接 点击打开链接

利用这些最短路径上的下一个节点作为下一跳得到源节点的转发表(路由表)。所以路由器的下一跳地址是直接得出的,不需要修改。

可能会遇到的问题:

距离向量选路算法DV

是基于迭代的算法。每个结点当DV变化时将自身DV估计通告给邻居;邻居在必要时(其DV更新后发生改变)才通告它们的邻居

当结点检测到本地链路费用改变或者接收到来自邻居的新DV估计更新时,即依据B-F方程更新自身的距离向量估计Dx(y)将最终收敛于实际的最小费用dx(y)

思想

  1. $B-F公式:dx(y)=minv{c(x,v)+dv(y)}$

    • dx(y):节点x到y的当前最低费用
  • v是对于x的所有直接邻居
    • 我到目的地的最小距离,等于 我到邻居距离与邻居到目的地最小距离之和(存在一个或多个邻居) 的最小值。
  1. 每个路由器中都有一张路由表,包含三个内容:目的网络号、经过的邻居路由器、到目的网络的距离(<目的网络 N,跳数,下一跳地址>)

  2. 路由器定期向其邻居路由器传送路由表的拷贝

算法执行步骤

参考文章

  1. 从相邻的 X 路由器接收发送过来的 RIP(Routing Information Protocol) 报文
  2. 将该 RIP 报文中的下一跳地址修改为 X,且跳数增加 1
  3. 对每个项目执行如下步骤
    a.若原路由表没有 RIP 中的目的网络 N,直接添加到原路由表中
    b.若原路由表中有 RIP 中的目的网络 N,但下一跳地址不是 X ,选择跳数少的替换。如果两者跳数一样,则保留原路由表的项。
    c.若原路由表中有 RIP 中的目的网络 N,且下一跳地址是 X,使用收到的项替换
  4. 若超过 180s (RIP 默认 180s)还没有收到相邻路由器的更新路由表,则相邻路由器置为不可达,跳数为 16
    路由器的下一跳的地址是不断修改的

链路状态改变时的特点

1、好消息(链路费用变小)传的快:起到收缩的作用,让DV趋于最小的值

t0 时刻, y 检测到链路费用变化, 更新自己的距离向量, 同时将这个变化通知给它的邻居
t1时刻, z 收到来自 y 的更新报文并更新了自己的距离向量表,计算出到x的新的最低费用,并向邻居发送它的新距离向量
t2时刻,y 收到自z的更新并更新其距离向量表,Y的最低费用未变,因此y不发送任何报文给z

2、坏消息传播的慢

解决方案:

针对只有3个节点的环路可以采用毒性逆转

毒性逆转:如果一个结点Z到达某目的X的最小费用路径是通过某个邻居Y,则Z告知邻居结点Y到达该目的X的距离为无穷大。

针对有多个节点的环路没有很好的办法,但是可以定义最大度量(maximum metric):一个最大的有效费用值,如16跳步表示费用为∞

LS算法和DS算法的比较:

  • 报文复杂性

LS 算法要求每个结点都知道网络中每条链路的费用,要发送O(|N|*|E|)个报文。而且无论何时一条链路的费用改变时,必须向所有结点发送新的链路费用。

DV算法要求在每次迭代时,在两个直接相连邻居之间交换报文。当链路费用改变时,DV 算法仅当在新的链路费用导致与该链路相连结点的最低费用路径发生改变时,才传播已改变的链路费用。

  • 收敛速度

LS算法的实现是一个要求O(|N|*|E|)个报文的O(|N|^2)算法。但是可能会有震荡的情况发生。

DV 算法收敛较慢,且在收敛时会遇到路由选择环路,还会遭遇无穷计数的问题。

  • 健壮性。

如果一台路由器发生故障、行为错乱或受到破坏时

LS算法:路由器能够向其连接的一条链路广播不正确费用。作为LS广播的一部分,一个结点也可损坏或丢弃它收到的任何LS广播分组。但是每个LS结点都仅计算自己的转发表。因此路由计算在某种程度上是分离的,提供了一定程度的健壮性。

DV算法:一个结点可向任意或所有目的结点通告其不正确的最低费用路径。因此一个不正确的结点计算值会扩散到整个网络。

路由信息协议RIP(内部网关协议)

RIP协议是一种采用距离向量算法的路由协议

  • 到目的网络的距离以跳为单位,最大距离为15,距离16表示无穷大,即目的网络不可达。(这一规定限制了RIP协议只能适用于中小网络,网络规模太大的话路由信息就无法到达远端路由器了)
  • 初始时每个RIP路由器只有到直连网的路由,距离为1;
  • 每30秒RIP路由器把它的整个路由表发给邻居(具体实现时每个邻居会错开发送,30秒的时间也会随机变化一点)

简述RIP协议的工作原理:路由器每30秒把自己的路由表发给邻居。路由器用邻居发来的路由表根据距离向量算法修改自己的路由表。初始时每个路由器只有到直连网距离为1的路由。

利用邻居的路由表建立自己的路由表:当收到邻居发来的路由表时,路由器将更新它的路由表<目的网络,开销,下一跳>:

首先将收到的路由的距离全加1(即一跳的距离);

再利用收到的路由表修改自己的路由表:

  • 将收到的路由表中不存在的路由表项加入到自己的路由表;
  • 如果收到的路由表中某一项的距离比该路由器原路由表对应项的距离更小,则更新该路由表项,并将对应路径的下一跳设置为邻居
  • 如果路由项存在,就要重置失效定时器;
  • 如果收到的路由表中存在某一项的目的网络也是该路由器的路由表中某一项的目的网络,且下一跳为发送路由表的路由,那无论如何该路由器都要更新对应的表项,将距离改为收到的表项中的距离+1;

因特网中的链路状态选路——OSPF协议(内部网关协议)

是分布式的链路状态协议。由于现在的网络的规模越来越大,自治就很重要,要让每个ISP管理自己的路由器。故我们最多的使用的是OSPF协议。使用的是洪泛链路和Dijkstra最低开销路径算法

协议的内容:

第一要点:洪泛法广播自己邻居的链路状态。

  1. 向本自治系统中所有路由器发送信息,使用的方法是洪泛法

  2. 发送的信息就是与本路由器相邻的所有路由器的链路状态(例如拥堵度)

    【每个链路获得与之相连的链路的标识和开销是由链路状态广播算法实现的。】

  3. 只要当链路状态发生变化时,路由器就用洪泛法向所有路由器发送此信息

  4. 即使链路状态没变化,也要周期性地发送链路状态信息

第二要点:所有路由器的链路状态库可及时更新。

  1. 由于各路由器之间频繁地交换链路状态信息,因此所有的路由器最终都能建立一个链路状态数据库
  2. 这个数据库实际上就是全网的拓扑结构图,它在全网范围内是一致的
  3. OSPF 的链路状态数据库能较快地进行更新,使各个路由器能及时更新其路由表。OSPF 的更新过程收敛得快是其重要优点

OSPF协议的特点

  1. 不强制如何设置链路权值的策略,但提供对给定链路权值集合确定最低费用路径的机制
  2. 即使链路状态未发生变化,每30分钟广播一次链路状态
  3. 链路状态以OSPF通告的形式封装在OSPF报文中,由IP分组承载(协议号:89)
  4. OSPF路由器之间的交换都是经过鉴别的(简单的、MD5的),以确认OSPF通告的真实性,防止伪造和篡改
  5. OSPF通告都是有序列号的,以防止重放攻击
  6. OSPF中支持多条具有相同费用的路径
  7. OSPF支持多播选路和层次路由
  8. OSPF使用IP数据报传输报文,是一个网络层协议

层次路由

上述讨论的情况都是基于路由器是相似的。但是实际中的网络是具有层次的。

层次路由解决了当前网络规模大导致路由器无法储存全部的路由器信息的问题。

让ISP自己管理自己的路由器。实现自治系统(AS)。

1、在相同AS内的路由器可全部运行同样的选路算法

  • 自治系统内部选路协议

内部网关协议 IGP (Interior Gateway Protocol) 目前这类路由选择协议使用得最多,如 RIP 和 OSPF 协议。】

2、在不同AS内的路由器可以运行不同的自治系统内部选路协议

3、不同AS之间的选路:外部网关协议EGP

边界网关协议BGP(包括了外部BGP—eBGP,内部BGP—iBGP)

对于每个AS,每台路由器要么是一台网关路由器,要么是一台内部路由器。

网关路由器:(同上文中的网关一样)

通常位于AS的边缘

具有连接到其它AS的链路

BGP会话

分为内部会话和外部会话。

内部会话参见上文。外部会话如下:

因特网上的AS间路由—BGP4

因为因特网的规模太大,AS间的选择有很多,想要选择最佳路由往往不可实现或者代价很大。

所以,退而求其次。
BGP为每一个AS从相邻AS获取子网可达性信息,然后向内部的所有路由器传播这些消息。
基于该可达性信息和AS策略,决定到达子网的“好”路由
边界网关协议 BGP 只能是力求寻找一条能够到达目的网络且比较好的路由(不能兜圈子),而并非要寻找一条最佳路由

外部会话中,BGP边界路由器采取的是半永久的TCP连接交换路由选择的信息。

路径属性

前缀+属性(包含在前缀中)=路由 前缀表示一个子网或一个子网的集合(IP地址)。例如AS2向AS1通告它的前缀含义就是告诉AS1:你向这个前缀表示的子网或子网集合发送数据报,我一定帮你转发。

两个重要的属性:

1、AS-PATH(AS路径)包含前缀通告所经过的AS序列(如上述AS2告诉了AS1,那么AS2的AS-PATH中包含了AS1,只能在EBGP邻居之间传路由时进行改动,对IBGP邻居传来的路由改不了

AS-PATH还可以防止环路的出现。当一个AS收到的路径列表中看到自己就可以拒绝接受该通告

2、NEXT-HOP(下一跳)开始一个AS-PATH的路由器节后,指向一个跳AS。(因为可能从当前AS到下一跳AS之间可能有多条链路)

特征:

①从EBGP邻居学习到的路由会传递给我的EBGP邻居,下一跳改变,为自己

②从EBGP邻居学习到的路由会传递给我的IBGP邻居,下一跳不变,还是EBGP邻居,需要使用next-hop-self改变

③从IBGP邻居学习到的路由不会再传递给我的IBGP邻居(水平分割)

④从IBGP邻居学习到的路由会传递给我的EBGP邻居,下一跳改变,为自己

作用:为BGP发言者指示了去往目的地的下一跳。

【BGP发言者往往是BGP边界路由器。两个BGP发言人通过一个共享网络连接在一起】

BGP 的下一跳属性和IGP 的有所不同,不一定就是邻居路由器的IP 地址。下一跳属性取值情况分为4种,如 图所示。

img

  • BGP发言者把自己产生的路由发给所有邻居时

将把该路由信息的下一跳属性设置为自己与对端连接的接口地址

  • BGP发言者把从EBGP邻居得到的路由发给IBGP邻居时

并不改变该路由信息的下一跳属性。将从EBGP得到的路由的NEXT_HOP直接传递给IBGP对等体。

上图中,RTA通过IBGP向RTF通告路由8.0.0.0/24时,NEXT_HOP为10.3.1.1。

  • BGP 发言者把接收到的路由发送给 EBGP 对等体

将把该路由信息的下一跳属性设置为自己与对端连接的接口地址;

上图中,RTB通过EBGP向RTA通告路由8.0.0.0/24时,NEXT_HOP为10.3.1.1。

  • 对于可以多路访问的网络(如以太网或帧中继)

如果通告路由器和源路由器的接口处于同一网段,则BGP会向邻居路由通告路由的实际的来源。

上图中,RTC向EBGP对等体RTB通告路由8.0.0.0/24时,则使用该路由的实际来源地址10.2.1.3作为NEXT_HOP。

如果配置了负载分担,等价路由被发给IBGP邻居时则会修改下一跳属性。关于“负载分担”的概念请参见“BGP的选路规则”。

当一台网关路由器接收到一个路由器通告时,它使用输入策略决定是否接收或过滤该路由

Q:路由器怎么确定自己下一跳的目的地呢?
A:转发表。路由器的转发表中储存下一跳信息。实现参见上述。

BGP路由选择

Q:从源到目标仅有一条路可选或者多条路的时候怎么办?
A:只有一条路的时候,就只在AS内广播寻找最低费用路径,反正AS间的路径只有一条。
有多条路径的时候,采取如下消除规则:

​ 1、本地偏好值:策略决定(由AS的网络管理员决定的决策)具有最高本地偏好值的路由将被选择。

​ 2、最短AS-PATH:在余下的路由中,具有最短AS-PATH的路由将被选择。(起码感受上这个就比其他的短)

​ 3、热土豆选路:从余下的路由中,选择具有最靠近NEXT-HOP路由器的路由

热土豆选路:选择具有最小的最低费用(AS内部的)的网关。意思就是希望尽快将分组送出其AS,而不担心其AS外部到目的地的余下部分的开销

(使用AS内部协议的选路信息决定每个网关的最低费用路径的费用)不同网关的费用已经经过BGP的外部会话传到了源路由器。

一个简单的BGP图例:

子网x通过AS3和AS2均可到达

因特网上的AS内层次路由——层次OSPF

将自治系统再划分为若干小的范围。

划分区域的好处就是将利用洪泛法交换链路状态信息的范围局限于每一个区域而不是整个的自治系统,这就减少了整个网络上的通信量。

OSPF 使用层次结构的区域划分。在上层的区域叫作主干区域(backbone area)。主干区域的标识符规定为0.0.0.0。主干区域的作用是用来连通其他在下层的区域。

补充路由选择策略

图示:

桩网路:所有进入该网络的流量必定以该网络为目的地;所有离开该网络的流量必定源于该网络

路由协议小结

单源最短路径

最短路径问题:一个带权重的有向图G = (V, E)和权重函数w: E->R,该权重函数将每条边映射到实数值的权重上。一条路径p的权重w(p)是构成该路径的所有边的权重之和,定义从节点u到结点v的最短路径权重δ (u, v)如下:

单目的地最短路径问题:找出从每个顶点v到指定终点t的最短路径。把图中的每条边反向,就可以把这一问题变为单源最短路径问题。

一些概念:

如果一条路径中包含两个相同的结点,则该路径包含环路

不包含环路的路径称为简单路径

最短路应为简单路径,不包含环路。

  • 对任何简单路径最多包含|V|-1条边和|V|个结点。
  • 不失一般性,假设后续算法寻找的最短路径都不包含环路。

负权重边

边的权重可以为负值,即使边的权重为负值,对于所有节点v,最短路径权重 (u, v)都可以有精确定义。但是,如果图G=(V, E)包含从源节点s可以到达的,权重为负值的环路,则最短路径权重无定义,从s到该环路上的任意节点的路径都不可能是最短路径。如果从节点s到节点v的某条路径上存在权重为负值的环路,则(u, v) = -∞。

示例:

从节点s到节点c有无数条路径;<s,c>, <s,c,d,c>,<s,c,d,c,d,c>等,因为环路<c, d, c>的权重为3>0。所以从s到c的最短路径为<s,c>,δ(s, c)=5。

从节点s到节点e也有无数条路径:<s,e>, <s,e,f,e>, <s,e, f,e, f,e >等,因为环路<e, f, e>的权重为-3,所以,从节点s到节点e没有最短路径,因此δ(s,e)=-∞。类似的, δ(s,f)=-∞。因为g可以从节点f到达,所以, δ(s, g)=-∞。(因为s到e可以在e、f上转圈圈,就会使得路径趋于负的无穷小)

最短路径的表示

一个结点的前驱结点记为:v.π
(前驱结点或者为NIL或者为另一个结点)

利用v.π的记录可以搜索出最短路径上的所有结点。

一个源点s所诱导的前驱子图定义为Gπ=(Vπ,Eπ),其中,

结点集合Vπ={v∈V:v.π≠NIL}∪{s}

  • 即Vπ是源点s和图G中的前驱结点不为NIL的所有结点的集合

边集合Eπ={(v.π,v)∈E:v∈Vπ-{s}}

  • 即Eπ是由Vπ中的结点v的π值所“诱导”(induced)的边的集合。

则,算法终止时,Gπ是一棵最短路径树。

该树包含了从源点s到每个可以从s到达的结点的最短路径。

另外,最短路径不是唯一的,最短路径树也不一定是唯一的。

松弛操作

松弛:原来用一根橡皮筋连接p和w两点,现在有一点v到w的路径更短,现在把橡皮筋w点的另一端p换成v点,这样缓解橡皮筋紧绷的压力,让其变得松弛。

简单的说就是不断更新最短路径。

1)松弛边: w -> v 意味着先检查从s到v的最短路径是否是先从s 到 w,再由w -> v, 如果是,则更新v.d和v.π的数据。

1
2
3
4
5
6
7
8
9
10
11
//对于每个顶点v∈V,都设置一个属性d[v],用来描述从源点s到v的最短路径上权值的上界,称为**最短路径估计(shortest-path estimate)**。π[v]代表S到v的当前最短路径中v点之前的一个点的编号
private void relax(DirectedEdge e)
    {
        int v = e.from(), w = e.to();
        if (v.d > w.d + e.Weight())
        {
            v.d = w.d + e.Weight();
            v.π = e;
        }
    }
//RELAX 的时间:O(1)

最短路径和松弛操作的性质

1。三角不等式性质:对于任何边(u,v)∈E,有δ(s,v)≤δ(s,u)+ω(u,v)。

证明:

假定p是从源结点s到结点v的一条最短路径,则p的权重不会比任何从s到v的其它路径的权重大,因此路径p的权重也不会比这样的一条路径的权重更大:从源结点s到结点u的一条最短路径,再加上边(u,v)而到达结点v的这条路径。

如果s到v没有最短路径,则不可能存在s到v的路径。

2。上界性质:对于所有结点v∈V,我们有v.d≥δ(s,v)。一旦v.d的取值达到δ(s,v),其值将不再变化。

证明:

使用数学归纳法

3。非路径性质:若从结点s到结点v之间不存在路径,有v.d=δ(s,v)=∞。

证明:

因为从源点s到给定点v之间不存在路径,所以δ(s,v)=∞。而根据上界性质,总有v.d≥δ(s,v),所以,v.d≥δ(s,v)=∞。

4。收敛性质:设结点u,v∈V,如果s⇝u→v是图G中的一条最短路径,且在对边(u,v)进行松弛前的任意时间有u.d=δ(s,u),则在之后的所有时间有v.d=δ(s,v)。

5。路径松弛性质:若p=<v0,v1,…,vk>是从源结点s=v0到结点vk的一条路径,且我们对p中所进行松弛的次序为(v0,v1),(v1,v2),…,(vk−1,vk),则vk.d=δ(s,vk)。

该性质的成立与其他边的松弛操作及次序无关,即使这些松弛操作是与对p上的边所进行的松弛操作穿插进行的。

数学归纳法证明。

6。前驱子图性质:对于所有结点v∈V,一旦v.d=δ(s,v),则前驱子图是一棵根结点为s的最短路径树。

最短路径的最优子结构

最短路径具有最优子结构性质:两个结点之间的一条最短路径包含其他的最短路径

具有==贪心思想==

证明(剪枝-复制法+反证法)

假设路径p分为poi 、pij、pjk ,并且p是vo到vk的最短路径。如果poi 、pij、pjk不是最短路径的话,假设存在poi2

是vo到vi 的最短路径。将poi 减去复制上poi2 就反证出p不是最短路径,矛盾。

Bellman-ford算法

适用条件&范围

  • 单源最短路径(从源点s到其它所有顶点v);——可以有负权重的边,但不能有负权重的环
  • 有向图&无向图(无向图可以看作(u,v),(v,u)同属于边集E的有向图);
  • 边权可正可负(如有负权回路输出错误提示);
  • 差分约束系统;

步骤

第一,初始化所有点。每一个点保存一个值,表示从原点到达这个点的距离,将原点的值设为0,其它的点的值设为无穷大(表示不可达)。

第二,进行循环,循环下标为从1到n-1(n等于图中点的个数)。在循环内部,遍历所有的边,进行松弛计算。(对于N个点,进行N次循环,每次循环依次更新路径)

第三,遍历途中所有的边(edge(u,v)),判断是否存在这样情况:
d(v) > d (u) + w(u,v)(三角不等式性质)
则返回false,表示途中存在从源点可达的权为负的回路。

松弛迭代(核心)

参考文章

大致意思就是,每次对所有边进行松弛,就算一次松弛迭代:

性质一:一般性的,当图中已经存在一个或多个已确认顶点时,即图处于任意一种状态,若图中尚存在未确认顶点,则执行一次迭代后,会增加至少一个已确认顶点。

证明:

性质二:若图中存在由起点不可达的顶点,则这些顶点的初始化状态即为最短路径状态,即初始化时就处于已确认状态。

证明:

所以Bellman-ford算法的循环次数:

  • 松弛边按照最坏情况下进行,即一次只增加一个已确认顶点,则需要执行的迭代次数为 |V|-1次
  • 松弛边按照最好情况下进行,则需要执行的迭代次数为 1 次。

检测带权有向图中是否存在负权回路

根据前面对松弛函数执行次数的分析可知,若图中不存在负权回路,那么即使在最坏情况下,也只需要执行 |V|-1 次迭代松弛,即可获得从起点到各顶点的最短路径。

若图中存在负权回路,当回路较小时,例如顶点自身或者两个顶点之间的负权回路,则在 |V|-1 次迭代过程中,可能多次通过了该负权回路;若回路较大,例如从起点出发,串联所有顶点最后回到起点,即通过 |V|-1 条边构成一个圆形,如下图所示。则 |V|-1 次迭代过程中,可能一次也不会通过该负权回路,但是当再执行一次迭代松弛,即可将 ds 值更新为负值,所以可以多执行一次迭代,通过判断是否更新从起点到某个顶点的最短路径权值,来判断图中是否存在负权回路

算法描述

BELLMAN-FORD(G,w, s)

          INITIALIZE-SINGLE-SOURCE(G, s)
          for i = 1 to |G.V|-1
                 for  each edge (u, v)∈ E
                        RELAX(u, v, w)
                for each edge (u, v) ∈ E
                 if  v.d > u.d + w(u, v)
                        return  FALSE
                        
                             return TRUE

算法证明

根据最优子结构性和松弛迭代就可以证明:

  • 如果图G中不包含从源结点s可以到达的权重为负值的环路,则算法将返回TRUE,且对于所有结点v∈V,前驱子图Gπ是一个根结点为s的最短路径树。

    如果结点v是从s可以到达的,则论断可以从松弛迭代和最优子结构性得到证明。

    如果结点v不能从s可达,则论断可以从非路径性质获得。

    综合前驱子图性质和本论断,可以推导出Gπ是一棵最短路径树

  • 如果图G中包含一条从源结点s可以到达的权重为负值的环路,则算法将返回FALSE。

    三角不等式性质。可以使用反证法说明

时间性能

初始化:Θ(V)

松弛处理:for循环执行|V|-1次,每次的时间是Θ(E)

Bellman-ford算法总的运行时间是O(VE)。

Dijkstra算法(贪心算法)

Dijkstra算法解决带权重的有向图上单源最短路径问题

**==该算法要求所有边的权重为非负值==**,如果采用合适的实现方式,Dijkstra算法的运行时间要比Bellman-Ford算法快。

算法思路

  • 保存两个集合S和U,S用于存放已经找到和起点之间最短距离的点,U用于存放尚未找到最短距离的点,起初S中只有起点,其余所有的点存放在U中,并根据和起点是否相邻,修改了距离起点的距离,不相邻则默认为无穷远。
  • 循环遍历U中的点,找出离起点距离最近的点,该点到起点的距离就是该点到起点的最短距离,将该点从U中移除并放置到S中去,并重新计算U中的点距离起点的距离。这样每次选取的点都是剩余点中距离起点最近的点。
  • 循环上述操作,直到找到终点到起点的最短路径或则循环结束。

Dijkstra算法是一个贪心算法:每次总是选择V-S集合中最短路径估计值最小的结点加入S中。

算法描述

1
2
3
4
5
6
7
8
9
DIJKSTRA(G, w, s)
              INITIALIZE-SINGLE-SOURCE(G, s)//对所有节点的d值和派值初始化
              S = ∅
              Q =G.V
              while Q != ∅
                     u = EXTRACT-MIN(Q)
                     S= S ∪  {u}
                     for  each vertex v ∈ G.Adj[u]//对所有从u出发的边进行松弛
                            RELAX(u, v, w)

算法证明

利用循环不变式证明循环不变式:

算法在while语句的每次循环开始前,对于每个结点u∈S,有u.d=δ(s,u)

只 需 证 明: 对 于 每 个 结 点u∈V,当u被 加 入 到S时,有u.d=δ(s,u)。注:一旦u加入S,就不会再修正u.d。且根据上界性质,该等式将一直保持。

证明过程:

(1)初始化:初始时,S=Ø,因此循环不变式直接成立。

(2)保持:在每次循环中,对于加入到集合S中的结点u而言,u.d=δ(s,u)。

用反证法证明:设结点u是第一个在加入到集合S时u.d≠δ(s,u)的结点。

由于s是第一个加入到集合S中的结点,并且s.d=δ(s,s)=0,所以u≠s,并且在u即将加入S时,S≠Ø,因为S中至少包含了s。故,此时必存在至少一条从s到u的路径(否则,根据非路径性质将有u.d=δ(s,u)=∞,与假设的u.d≠δ(s,u)相矛盾,故这样路径一定存在),这样也必存在一条从s到u的最短路径,记为p。

取p路径上的一个点x(该点是s到u的中间点)在结点u加入到集合S时,有x.d=δ(x,y)【因为u是第一个不满足关系的点,并且每次S取的点都是剩下点中具有最短路径的点,x.d<=u.d。它肯定被取入S中了】

然后取p路径上的y点,该点的前驱节点为x。则有:在结点u加入到集合S时,应有y.d=δ(s,y)。这是因为x∈S,u是第一个u.d≠δ(s,u)的结点,在将x加入到集合S时,有x.d=δ(s,x),y是x的邻接点,所以此时边(x,y)将被松弛。由于y是最短路径p上的结点,根据最短路径的最优子结构性和收敛性质,此时应有y.d=δ(s,y)。

因为结点y是从结点s到结点u的一条最短路径上位于u前面的一个结点,所以应有δ(s,y)≤δ(s,u),即y.d<=u.d。

但是我们注意到,此时取进S集合的是u不是y,所以u.d应该小于等于y.d。矛盾

时间性能

根据算法的处理规则,每个结点u仅被加入集合S一次,邻接链表Adj[u]中的每条边在整个运行期间也只被检查一次。因此算法第7-8行的for循环执行次数总共为|E|次(即松弛判定总次数)

Dijkstra算法的总运行时间依赖于最小优先队列Q的实现。

  • 如果用线性数组(无序或者按序插入)实现,每次找d最小的结点u需要O(V)的时间,所以算法的总运行时间为O(V^2^+E)=O(V^2^)。
  • 如果用二叉堆实现,每次找d最小的结点u需要O(lgV)的时间,所以算法的总运行时间为O((V+E)lgV)。
  • 如果用斐波那契堆实现,算法的总运行时间可以改善至O(VlgV+E)。

差分约束和最短路径

对于一组不等式:
$$
\begin{cases}
x_1-x_2≤0\
x_1-x_5≤1\
x_2-x_5≤1\
x_3-x_1≤5\
x_4-x_1≤4\
x_4-x_3≤-1\
x_5-x_3≤-3\
x_5-x_4≤-3\
\end{cases}
$$
特点是全都是两个未知数的差小于等于某个常数(大于等于也可以,因为左右乘 −1-1−1 就可以化成小于等于的形式),这样的不等式组称作差分约束系统。

这个不等式组要么无解,要么就有无限组解。因为如果存在一组解(x1,·····xn)的话,那么对于任何一个常数 k 有

(x1+k,·····xn+k)也肯定是一组解,因为任何两个数加上一个数以后,它们之间的关系(差)是不变的,这个差分约束系统中的所有不等式都不会被破坏。

约束图

每个未知数 xi对应图中的一个顶点vi ,把所有的不等式都化成图中的一条边,对于不等式
$$
x_i−x_j≤ y
$$
化成三角形不等式
$$
x_i≤ x_j+y
$$
就可以化成边 <vj,vi>权值为 y 。最后在这张图上求一遍单源最短路,这些三角不等式就全部满足了,因为它是最短路问题的基本性质。

说明:

(1)结点集合:约束图中引入一个额外的结点v0,从其出发可以达到其他所有结点。因此结点集合V由代表每个变量xi的结点vi和**额外的结点v0**组成。

(2)边集合:边集合E包含代表每个差分约束的边,同时包含v0到其他所有结点的边(v0,vi),i=1,2,…,n。

(3)边的权重:如果xj-xi≤bk是一个差分约束条件,则边(vi,vj)的权重记为ω(vi,vj)=bk,而从v0出发到其他结点的边的权重ω(v0,vj)=0

图中每一条边都代表差分约束系统的一个不等式。现在以 v0为源点,求单源最短路,最终得到的v0到 vi的最短路径长度就是 xi的一个解。如图中 v0到其他各个顶点的最短距离分别是 {−5,−3,0,−1,−4}{-5,-3,0,-1,-4}{−5,−3,0,−1,−4} ,因此满足上述不等式的一组解就是 {x1,x2,x3,x4,x5}={−5,−3,0,−1,−4} 。当然把每个数都加上 101010 也是一组解 {5,7,10,9,6}{5,7,10,9,6}{5,7,10,9,6} ,但是这组解只满足不等式组 (1) ,也就是原先的差分约束系统,而不满足不等式组 (2) ,也是我们后来加上的那些不等式。当然这是无关紧要的,因为 x0本来就是个局外人,并不在乎它。

对于上面例子而言,它代表的解 x0值也在其中也就是 x0=0。但是 x0的值是无可争议的,既然是以它作为源点求最短路径,那么源点到它的最短路径当然是 0了,因此,我们解这个差分约束系统无形中存在一个条件x0=0那么它有什么用呢?可以限制所有的未知数的解都不大于0 。

解的个数:

(1) 如果图G不包含权重为负值的回路,则是该系统的一个可行解。

(2) 如果图G包含权重为负值的回路,则该系统没有可行解

一个有趣的结论:当我们一开始就把 v0的解死定为 A 的时候,所有未知数的解都不会大于 A (一开始把 dis[0]=A)

运输层

第一部分:概述

在学习完应用层的时候,对运输层就有了一定的认识。需要指出的是,运输层是在端系统中的而不是在路由器中的。在发送端系统中,运输层将从发送应用程序进程接收到的报文转换成运输层分组。实现的方法是将报文(Message)划分成为较小的块,并为每块加上一个运输层首部以生成运输层报文段(segment)。然后运输层将这些报文段传递给网络层,网络层将其封装成网络层分组·······在接受端,网络层从数据段中提取运输层报文段,并将该报文段向上交给运输层。接收方:把报文段重组成应用数据,交付给应用层

运输层和网络层的关系

网络层: 不同主机之间的逻辑通信

运输层: 应用进程之间的逻辑通信

网络层负责ip数据报的产生以及ip数据包在逻辑网络上的路由转发。

网络层只是根据网络地址将源结点发出的数据包传送到目的结点(点到点),其主要任务是:通过路由选择算法,为报文或分组通过通信子网选择最适当的路径。该层控制数据链路层与传输层之间的信息转发,建立、维持和终止网络的连接。具体地说,数据链路层的数据在这一层被转换为数据包,然后通过路径选择、分段组合、顺序、进/出路由等控制,将信息从一个网络设备传送到另一个网络设备。

传输层提供端到端通信服务层次,提供可靠及非可靠连接。

传输层则负责将数据可靠地传送到相应的端口(端到端),传输层提供了主机应用程序进程之间的端到端的服务。传输层利用网络层提供的服务,并通过传输层地址提供给高层用户传输数据的通信端口,使高层用户看到的只是在两个传输实体间的一条端到端的、可由用户控制和设定的、可靠的数据通路。

类似于家庭间通信:

12个孩子要与另一个家庭的12个孩子相互通信

  • 进程 = 孩子们
  • 进程间报文 = 信封中的信笺
  • 主机 = 家庭的房子
  • 运输协议 = 张三 和 李四(站在大门口的管家)
  • 网络层协议 = 邮局提供的服务

上例中的几种特殊场景

1、张三和李四生病了,无法工作,换成张五和李六

【不同的运输层协议可能提供不一样的服务】

2、邮局不承诺信件送抵的最长时间

【运输层协议能够提供的服务受到底层网络协议的服务模型的限制】

3、邮局不承诺平信一定安全可靠的送达,可能在路上丢失,但张三、李四可在较长时间内没有受到对方的回信时,再次誊写信件,寄出

【在网络层不提供某些服务的情况下,运输层自己提供】

ps:

网络层为主机间提供逻辑通信, 而运输层为应用进程间提供端到端的通信逻辑.

网络层提供IP数据报首部中的检验和字段, 只检验首部是否出现差错; 而运输层要检验收到的报文是否有差错.

【邮局只负责送到家,东西坏没坏由管家去查看。管家觉得没问题再给主人(应用)】

第二部分:多路复用和多路分解

TCP和UDP的最基本任务

  1. 主机到主机之间数据的交付(由IP层提供)扩展为运行在二个主机上的进程间的数据交付。

  2. 一台主机可以同时运行多个网络进程

    • 发送方:多个进程通过各自端口将数据交付给运输层,共同使用运输层的服务。这叫运输层的多路复用。

    • 接收方:当运输层收到从下层网络层传递上来的数据后,通过端口号就数据向上交付给各自的应用进程。这叫运输层的多路分解。

多路分解和多路复用

可以说多路分解和多路复用是运输层的核心作用。

端口

端口的作用就是让应用层的各种应用进程都能将其数据通过端口向下交付给运输层,以及让运输层知道应当将其报文段中的数据向上通过端口交付给应用层相应的进程(或者线程)

从这个意义上讲,端口是用来标志应用层的进程(或者线程)

端口用一个 16 bit 端口号进行标志

套接字

TCP 使用“连接”(而不仅仅是“端口”)作为最基本的抽象,同时将 TCP 连接的端点称为套接字(socket) 。

套接字和端口、IP 地址的关系是:

套接字

报文段(数据段)的投送过程

  1. 主机收到IP包

    • 每个数据包都有源IP地址和目的IP地址

    • 每个数据包都携带一个传输层的数据报文段

    • 每个数据报文段都有源、目的端口号

  2. 主机根据“IP地址+端口号”将报文段定向到相应的套接字(这里注意的是,TCP和UDP的socket的内容有差异)

TCP/UDP报文格式

面向连接的复用和分用

  1. TCP 套接字由一个四元组来标识

    (源IP地址,源端口号,目的IP地址,目的端口号)

    【提供源IP地址和源端口号是为了TCP连接时的握手】

  2. 接收方主机根据这四个值将报文段定向到相应的套接字(网络层只管送套接字)

  3. 服务器主机同时支持多个并发的TCP套接字:

    【每一个套接字都由其四元组来标识】

  4. Web服务器为每一个客户连接都产生不同的套接字

    非持久HTTP对每一个请求都建立不同的套接字(由应用层可知会影响性能)

无连接的复用和分用

  1. UDP 套接字由一个二元组来标识

    (目的IP地址,目的端口号)

    【无握手,故称为无连接】

  2. 接收方根据目的端口号将报文段定向到相应的套接字

  3. 具有不同源IP地址和/或源端口的UDP报文如果具有相同的目的IP地址和目的端口号,则定向到相同的套接字

第三部分:无连接的UDP

一个最简单的运输层协议必须提供

  1. 多路复用/多路分解服务

  2. 差错检查(虽然进行差错检测,但不进行差错恢复。只是丢弃出错的UDP报文或交给应用程序但发出警告)

    请思考下为什么UDP提供的是无连接的不可靠的传输服务,却还要提供差错检测?

    【这是为了防止一直出错而导致数据根本传递不了。不进行差错恢复不意味着可以让数据一直丢失】

  3. 几乎没有对IP增加什么东西。如果程序开发人员选择基于UDP的Socket,则应用程序几乎是直接与IP打交道。

UDP处理数据的流程

发送方

  • 从应用进程得到数据
  • 附加上为多路复用/多路分解所需的源和目的端口号及差错检测信息,形成报文段(数据报
  • 递交给网络层,尽力而为的交付给接收主机

接收方

  • 从网络层接收报文段(数据报
  • 根据目的端口号,将数据交付给相应的应用进程

UDP的优势

  • 无需建立连接——建立连接会增加时延
  • 简单——发送方和接收方无需维护连接状态
  • 段首部开销小——TCP:20Byte vs UDP:8Byte
  • 无拥塞控制——UDP 可按需要随时发送

UDP大量应用可能导致的严重后果

路由器中大量的分组溢出

显著减小TCP通信的速率,甚至挤垮TCP会话

使用UDP的可靠数据传输

在应用层实现数据的可靠传输

增加了应用进程的实现难度

这点说明我们不能单纯的凭借主观感受来说UDP是一定不能可靠数据传输的。

UDP报文段(数据报datagram)

UDP报文段

UDP报文段格式

UDP的首部格式

首部格式

源端口号以及目的端口号
用于唯一标识套接字,指示该报文段所要交付的套接字。端口号一般是16个bit,范围[0,65535]。其中0-1023是周知端口号,比如HTTP是80端口,FTP是21端口
长度
UDP用户数据报的长度(首部字段和数据字段),其最小值是8,也即是只有首部。

1
首部只有8个字节
  1. 源端口 : 需要对方回信时选用, 不选可全为0.
  2. 目的端口 : 在终点交付报文时使用.
  3. 长度 : UDP用户数据报的长度.
  4. 检验和 : 检验UDP在传输中是否有错.

校验和
检测UDP用户数据报在传输的过程中是不是有错,有错就丢弃。

UDP的检查和

目标:检测收到的报文段的“差错” (例如, 出现突变的比特)

发送方

  • 把报文段看作是16比特字的序列
  • 检查和:对报文段的所有16比特字的和进行1的补运算
  • 发送方将计算校验和的结果写入UDP校验和字段中

接收方

  • 计算接收到的报文段的校验和

  • 检查计算结果是否与收到报文段的校验和字段中的值相同

    【不同 — 检测到错误

    ​ 相同 — 没有检测到错误(但仍可能存在错误)】

UDP特点

  1. 无连接 : 发送数据之前不需要连接.
  2. 尽最大努力交付 : 不保证可靠交付.
  3. 面向报文 : 对应用层下发的报文, 添加d首部后就下发到IP层, 对下发的报文不合并也不c拆分, 仅仅保留报文的边界. 一次交付一个报文.
  4. 无拥塞控制 : 发送后就不在管理.
  5. 多种通信 : 支持一对一, 一对多, 多对多通信.
  6. 首部开销小 : 首部仅有8个字节.

第四部分:可靠数据传输(十分重要)

本节只讨论单向数据传输:数据是从发送端到接受端的。

在实际的网络传输中,信道是不可靠的,在其上传输的分组可能会损坏或丢失,甚至相对次序都不能保证。

在这种情况下,应用层的程序迫切需要运输层提供一个可靠的数据传输服务,可以保证无论在实际的物理传输中发生了什么,数据都可以无损按序地交付给接收端。这就是可靠数据传输协议的作用,也是 TCP 向调用它的应用所提供的服务模型。

信道 (channel):信道一般指连接信号发送方和接收方的传输线路,包括双绞铜线、同轴电缆、光纤、陆地无线电或者卫星无线电等物理媒体。

可靠信道上的可靠传输—— rdt 1.0

考虑的情况:底层信道完全可靠。即不会产生比特错误、不会丢失分组。而且也假定接收方接收数据的速率与发送端发送数据的速率一样快。

可以看到,rdt 的发送端只通过 rdt_send(data) 事件接收来自较高层的数据发送请求。在完成一次数据发送请求中需要两个动作:

  • 产生一个包含该数据的分组(经由 make_pkt(data) 产生)
  • 然后将该分组通过 udt_send(packet) 发送到信道中

完成这两个动作后,重新返回原始状态,继续等待来自较高层的数据发送请求。

而在接收端,rdt 通过 rdt_rcv(packet) 事件从底层信道接收一个分组。在一次数据接收过程中同样需要两个动作:

  • 从分组中取出数据(经由 extract(packet, data) 产生)
  • 然后将数据上传给较高层(通过 deliver_data(data) 动作)

和发送端一样,接收端完成这两个动作后也重新返回原始状态,继续等待从底层信道接收分组。

需要注意的是,在发送端,引起状态变迁的事件是由较高层应用的过程调用产生的;而在接收端,引起状态变迁的事件是由较低层协议的过程调用产生的。

现在我们就构造出了适用于可靠信道的可靠数据传输协议 rdt 1.0 ,因为信道可靠,接收方也不需要提供任何反馈信息给发送方,不必担心出现差错。而且因为假定了接收方接收数据的速率能够与发送方发送数据的速率一样快,所以接收方也没有必要请求发送方慢一点发送。

经具有比特差错信道的可靠数据传输协议 rdt 2.0(解决分组出错问题,通过ACK/NAK告知发送方)

考虑的情况:分组比特可能受损,所有传输的分组都将按序被接收,不会丢失

首先需要明确的一点是:如果发送方知道了哪些分组发送出去后接收方并没有收到,那么发送方就需要重传这些分组。基于这样的重传机制的可靠数据传输协议称为自动重传请求(Automatic Repeat Request, ARQ)协议

ARQ处理机制

  1. 如何判断分组受损——差错检测

    加校验和checksum

  2. 如何通知发送方分组是否受损——接收方反馈(ACK和NAK)

    • 确认——acknowledgements (ACKs): 接收方明确告诉发送方正确收到分组

    • 否认——negative acknowledgements (NAKs): 接收方明确告诉发送方分组有错

  3. 在得知分组受损后,发送方如何处理——出错重传

    当发送方收到了接收方发送的NAKS时,选择重发

下面来看一下 rdt 2.0 的有限状态机描述图,现在该数据传输协议(自动重传请求协议)采用了差错检测、肯定确认与否定确认。

rdt 2.0 的发送端有两个状态。在最左边的初始状态中,发送端协议正等待来自较高层传下来的数据。当触发 rdt_send(data) 事件时:

  • 通过 sndpkt = make_pkt(data, checksum) 产生一个包含待发送数据且带有校验和的分组
  • 然后将该分组通过 udt_send(sndpkt) 发送到信道中

执行完上述的两个动作后,发送端的状态变迁为“等待接收接收端的 ACK 或 NAK 分组”。接下来根据接收端的响应不同会有不同的变迁方案:

  • 如果收到了一个 ACK 分组(rdt_rcv(rcvpkt) && isACK(rcvpkt)),那么发送端知道接收端已经成功接收到了刚才发送出去的分组,发送端状态回到初始状态,继续等待下一次由较高层传下来的数据发送请求
  • 如果收到了一个 NAK 分组(rdt_rcv(rcvpkt) && isNAK(rcvpkt)),那么发送端知道接收端接收到的分组是受损的,所以调用 udt_send(sndpkt) 重新发送该分组,然后状态不变,继续等待接收接收端的 ACK 或 NAK 分组

由于 rdt 2.0 的发送端拥有这个特性,所以 rdt 2.0 这样的协议被称为停等(stop-and-wait)协议

rdt 2.0 的接收端仍然只有一个状态。状态变迁取决于收到的分组是否受损,有两种方式:

  • 如果收到的分组受损,即 rdt_rcv(rcvpkt) && corrupt(rcvpkt),则返回 NAK 分组
  • 如果收到的分组完好,即 rdt_rcv(rcvpkt) && notcorrupt(rcvpkt),则返回 ACK 分组

处理完后仍然返回自身这个状态,继续等待下一次从底层接收分组并处理。

接收方发送ACK。发送方接收到ACK后无异样操作。

接收方收到的报文是有错误的,于是其发送NAK给发送方。发送方收到NAK后选择重传。

现在我们得到了一个似乎是可以在有比特差错信道上正常工作的可靠数据传输协议了,但仔细想想,我们没有考虑 ACK 或 NAK 分组受损的情况。如果 ACK 或 NAK 分组受损的时候,我们应该怎么做?

经具有比特差错信道的可靠数据传输协议 rdt 2.1 (解决 ACK 或 NAK 分组受损问题和重传问题)

1、解决ACK 或 NAK 分组受损的问题比较简单的一个方法ACK和NAK加校验和

Q:我们怎么知道重传的分组是我们需要的分组呢?

2、解决只考虑重传可能会出现大量重复分组问题的方式是:是在数据分组中添加一个新的字段,然后让发送端对其数据分组编号,将发送数据分组的序号放在该字段中。于是,接收端只需要检查序号就可以确定收到的分组是否是一次重新传送的分组。因为 rdt 2.0 是一个简单的停等协议【发送方发出一个分组,然后等待接收方的应答】,1 比特序号就足够了。

在这里再次提醒一下我们在 rdt 2.0 开始的地方所做的假设:假设信道不丢分组,而且不会存在分组乱序的情况。所以发送端知道所接收到的 ACK 和 NAK 分组(无论是否受损)都是为响应其最近发送的数据分组而生成的。

完善了对 ACK 和 NAK 分组受损的情况的处理机制后,我们把完善后的协议称为 rdt 2.1,下面是 rdt 2.1 发送端的有限状态机描述图:

现在的状态数是以前的两倍,是因为协议的状态必须反映出目前(由发送端)正发送的分组或(在接收端)希望接受的分组序号是 0 还是 1。看起来这个描述图很复杂,其实发送或期望接收 0 号分组的状态中的动作与发送或期望接收 1 号分组的状态中的动作是相似的,唯一不同的是序号处理的方法不同。

这里我按照上图来描述一下 rdt 2.1 协议发送端的状态变迁过程:

  • 首先由较高层触发 rdt_send(data) 事件,通过 sndpkt = make_pkt(0, data, checksum) 产生一个序号为 0,包含待发送数据且带有校验和的分组,接着通过 udt_send(sndpkt) 将其发送到信道中,然后状态变迁为“等待接收接收端的 ACK 或 NAK 0”

  • 当发送端收到了一个来自接收端的分组数据:

    • 如果该分组数据受损,或者接收到的是 NAK 分组,那么通过 udt_send(sndpkt) 重新传送刚才的序号为 0 的分组到信道中
    • 如果该分组完好且收到的是 ACK 分组,那么发送端知道接收端已经成功接收了刚才发送的序号为 0 的分组,此时发送端状态变迁到等待较高层传下来的数据发送请求
  • 接着再次由较高层触发 rdt_send(data) 事件,通过 sndpkt = make_pkt(1, data, checksum) 产生一个序号为 1,包含待发送数据且带有校验和的分组,接着通过 udt_send(sndpkt) 将其发送到信道中,然后状态变迁为“等待接收接收端的 ACK 或 NAK 1”

  • 当发送端再次收到了一个来自接收端的分组数据:

    • 如果该分组数据受损,或者接收到的是 NAK 分组,那么通过 udt_send(sndpkt) 重新传送刚才的序号为 1 的分组到信道中
    • 如果该分组完好且收到的是 ACK 分组,那么发送端知道接收端已经成功接收了刚才发送的序号为 1 的分组,此时发送端状态变迁到等待较高层传下来的数据发送请求(即回到本状态机的初始状态)

只要收到NAK就重发,收到ACK就发下一个。

接着再来描述一下 rdt 2.1 协议接收端的状态变迁过程:

  • 首先在初始状态上,接收端等待着接收由发送端发来的序号为 0 的分组数据
  • 接着由rdt_rcv(rcvpkt)从底层信道接收了一个分组数据:
    • 如果该分组受损(即 rdt_crv(rcvpkt) && corrupt(rcvpkt)),那么由 sndpkt = make_pkt(NAK, checksum) 产生一个附带校验和的 NAK 分组,接着由 udt_send(sndpkt) 发送回发送端
    • 如果该分组失序(即 rdt_rcv(rcvpkt) && notcorrupt(rcvpkt) && has_seq1(rcvpkt)),那么由 sndpkt = make_pkt(ACK, checksum) 产生一个附带校验和的 ACK 分组,接着由 udt_send(sndpkt) 发送回发送端
    • 如果该分组完好且顺序正确(即 rdt_rcv(rcvpkt) && notcorrupt(rcvpkt) && has_seq0(rcvpkt)),那么通过 extract(rcvpkt, data)deliver_data(data) 将分组数据上传给较高层程序。接着,由 sndpkt = make_pkt(ACK, checksum) 产生一个附带校验和的 ACK 分组,由 udt_send(sndpkt) 发送回发送端
  • 接下来等待序号为 1 的分组的处理过程与上面类似,不再赘述

只要分组有错就发NAK,只要分组没错(失序或没有失序)就发ACK。

总结一下:

发送方在发送的分组是0或者1时都需要确认是否发送的分组被正确的接受。如果没有就重发。所以有2、4两个相同的状态。

接受端收到失序分组的原因:

这里顺便解释一下接收端接收到失序分组的原因:假设发送端发送序号为 0 的分组,接收端收到并回复 ACK,接着接收端就开始等待接收序号为 1 的分组,但是这个接收端返回的 ACK 分组由于在传输过程中受损,发送端并不知道序号为 0 的分组已经发送成功,所以仍然重复发送序号为 0 的分组,这样,就造成了接收端在等待接收序号为 1 的分组的时候,却接收到了序号为 0 的失序分组。

为什么当接收端接收到分组失序时要返回 ACK 分组呢?因为按照上面的假设,信道不会丢失分组,也不会乱序,所以收到失序的分组的唯一原因就是上面解释的这种,那么在这种情况下,只需要告诉发送端:我确实已经收到了你刚才一直重发的分组,可以发新的了。所以接收端回应 ACK 分组即可。

经具有比特差错信道的可靠数据传输协议 rdt 2.2 (无 NAK 分组)

其实上面的 rdt 2.1 协议在上述假设的底层信道模型中已经工作的不错了,但是我们还可以再简化一下,实现一个无 NAK 的可靠数据传输协议,我们称它为 rdt 2.2。

  • 只使用ACK

  • 取消NAK,接收方对最后一个正确收到的分组发送 ACK

    【接收方必须明确指出被确认的分组的序号】

  • 发送方收到的重复的ACK将按照NAK来进行处理

    【重传正确的分组】

rdt 2.1 和 rdt 2.2 之间的细微变化在于,接收端此时必须包括由一个 ACK 报文所确认的分组序号(可以通过在接收端有限状态机中,在 make_pkt() 中包括参数 ACK 0 或 ACK 1 来实现),发送端此时必须检查接收到的 ACK 报文中被确认的分组序号(可通过在发送端有限状态机中,在 isACK() 中包括参数 0 或 1 来实现)。

下图是 rdt 2.2 协议发送端的有限状态机描述图:

下图是接收端的有限状态机描述图:

考虑在 rdt 2.1 协议中,如果接收端收到了一个受损的分组则会返回 NAK 分组。但是**==如果不发送 NAK,而是对上次正确接收的分组发送一个 ACK,也能实现与发送 NAK 一样的效果。==**(失序分组的话,就告诉发送方发新的,是新的分组错误的话,发之前收到的ACK也告诉发送方发新的。) 发送端接收到对同一个分组的两个 ACK(即接收冗余ACK)后,就知道接收端没有正确接收到跟在被确认两次的分组后面的分组。这就是 rdt 2.2 可以取消 NAK 分组的原因。

具体 rdt 2.2 的流程因为和 rdt 2.1 基本类似,故不赘述。

经具有比特差错的丢包信道的可靠数据传输协议 rdt 3.0(解决数据或者ACK会丢失问题)

新的假设:底层信道会丢包 (数据或 ACK)

Q:怎么检测丢包和丢包后怎么做?
A:发送方检测丢包和恢复丢包。

解决方法:发送方对ACK等待“适当的”时间

  • 如果在这个时间内没有收到ACK则重传

  • 如果分组或ACK仅仅是延迟到达(而非丢失):

  • 重传将造成重复,但序号可以解决这个问题

  • 接收方必须指出确认的分组序号

解释:发送端负责检测和回复丢包工作。假定发送端传输一个数据分组,该分组或者接收端对该分组的 ACK 发生了丢失。在这两种情况下,发送端都收不到应当到来的接收端的响应。所以,如果发送端愿意等待足够长的时间以确定该分组缺失已丢失,则它只需要重传该数据分组即可。

但是等待多长时间合适呢?很明显发送端至少需要等待:发送端与接收端之间的往返时延(可能会包括在中间路由器的缓冲时延)加上接收端处理一个分组所需的时间。但这个时间是很难估算的。在 RFC 1323 中,这个时间被假定为 3 分钟。

在实践中,发送端明智地选择一个时间值,以判定可能发生了丢包(尽管不能确定)。如果在这个时间内没有收到 ACK,则重传该分组。注意到如果一个分组经历了特别大的时延,发送端可能会重传该分组,即使该数据分组及其 ACK 都没有丢失。这就在发送端到接收端的信道中引入了冗余数据分组的可能性。不过上面的 rdt 2.2 协议已经有足够的功能(即序号)来处理冗余分组情况。

从发送端的观点来看,重传是万灵药。发送端不知道是一个数据分组丢失,还是一个 ACK 丢失,或者只是该分组或 ACK 过低延时。在所有这些情况下,发送端执行的动作都是重传

为了实现基于时间的重传机制,需要一个倒计时计时器,在一个给定的时间量过期后,中断发送端。因此发送端需要能做到:

  • 每次发送一个分组(包括第一次分组和重传分组)时,便启动一个定时器
  • 响应定时器中断(采取适当的动作)
  • 终止定时器

rdt3.0举例:

rdt3.0收到错误序号的ack,并不是立马重传,而是超时重传。

rdt3.0性能分析:

rdt3.0 可以工作, 但是性能很差

解释:正常情况下L/R(分组离开发送端)的时间后发送方就要发送新的数据。但是由于rdt3.0需要接受ACK。其中发送方接受ACK的时间为RTT+L/R(忽略ACK分组的传输时间,RTT是传播时间)。这段时间内发送方是在等待的。

结果:

发送方只有万分之2.7 的时间是忙的

每30ms(1个RTT)内只能发送1KB : 1 Gbps 的链路只有33kB/sec(1KB/30ms) 的吞吐量

网络协议限制了物理资源的利用率

图示:

提高性能的一种可行方法:流水线技术

流水线可靠数据传输协议(提高效率)

允许发送方发送多个分组而无需等待确认

  • 必须增大序号范围(不再局限于3.0中的一个ACK了。正是3.0如此,故采取流水线作业)ACK(1-XXX)
  • 协议的发送方和接收方必须对分组进行缓存

例如:

现在有一个问题:当流水线技术中丢失了一个分组,怎么进行重传?

  • Go-Back-N(GBN,回退N步)协议:其后分组全部重传
  • 选择重传(SR)协议:仅重传该分组

go-Back-N(回退N重传协议):

1.发送者在流水线中最多有 N 个未确认的数据报。

2.接收者仅发送累计的确认 ,如果中间有数据报缺失,就不予以确认

3.发送者对最久未确认的数据报进行计时,如果计时器到点, 重传所有未确认的数据报

4.发送窗口大于1 ≤ 2k-1,接受窗口等于1(也就意味着如果某一个报文段出现错误,那么接受窗口会停留再次,之后收到的数据将会被丢弃)

selective repeat(选择重传协议):

1.发送者在流水线中最多有 N 个未确认的数据报。

2.接收者对单个数据报进行确认。

3.发送者对每一个未确认的数据报进行计时,如果计时器到点, 仅重传该个未确认的数据报。

4.发送窗口大于1,接受窗口大于1(意味着可以缓存出错位置之后的报文段),最好是两者相同,

Go-Back-N:(累计确认)

允许发送方发送多个分组而不需等待确认,但已发送但未确认的分组数不能超过N。

限制滑动窗口大小:为了流量控制

ACK-only: 对正确按序到达的分组发送ACK

  • 可能会产生重复的ACK
  • 需要记住期待序号 expectedseqnum

失序分组或损坏分组:

  • 丢弃 (不缓存) -> 接收方无缓存!
  • 重发正确按序到达的最高序号分组的ACK(告诉发送方,该分组的后序分组(ack大于此ack)有问题,要重发)
  • 每次发送的ACK一定是对正确按序到达的最高序号分组的确认

Go-Back-N协议特点

  1. ACK(n): 接收方对序号n之前包括n在内的所有分组进行确认 - “累积 ACK”(一段ACK都确认后或者没收到后再进行重传)

  2. 对所有已发送但未确认的分组统一设置一个定时器

  3. 超时(n):重传分组n和窗口中所有序号大于n的分组

  4. 接收方收到失序分组:

    • 丢弃 (不缓存) -> 接收方无缓存!

    • 重发按序到达的最高序号分组的ACK

选择重传(超时重传)

解决GBN大量重传分组的问题

  1. 接收方逐个对所有正确收到(即使失序)的分组进行确认(不是累积确认)

    对接收到的(失序)分组进行缓存(GBN不缓存), 以便最后对上层进行有序递交

  2. 发送方只重发怀疑丢失或损坏的分组

    ​ 发送方为每一个没有收到ACK的分组设置定时器

  3. 发送窗口

    • 大小为N,范围[sendbase, sendbase + N - 1]

    • 限制已发送但未被确认的分组数最多为N

    • sendbase以前的分组都被确认

  4. 接受窗口窗口

    • 大小为N,范围[recvbase, recvbase + N - 1]

    • 落在窗口内的序号都是期待收到的分组序号

    • recvbase前都是按序到达,已发出确认,且已递交给上层

图示:

补充:为什么发送方会收到比sendbase还小的分组确认?

假设窗口位于sendbase-1时,序号sendbase-1的分组定时器时间还没收到ACK(sendbase-1),发送方重发该分组。这时又收到了ACK(sendbase-1),窗口前移。移动后,发送方又收到了ACK(sendbase-1)

前移的标志:确认收到rcvbase前的分组。

为什么接受方会收[recvbase-N,recvbase - 1]范围内的分组?并且必须给出确认?

  • 因为确认可能会丢失。假设接受方按序收到N个分组,向发送方发送确认后接受窗口向前移动N位。
  • 假设确认分组全部丢失,导致发送方重发。发送方最多只能发N个,因此接受方会收到[recvbase-N,recvbase - 1]范围内的分组
  • 接收方这时必须给出确认,否则发送方窗口无法向前移动

为什么接受方收到比recvbase-N更早的分组后不用发确认了?

  • 因为比recvbase-N更早的分组(如recvbase-N-1),发送方一定收到确认了。
  • 当接受窗口位于recvbase时,意味着接受方一定按序收到了从[recvbase-N, recvbase- 1]的分组
  • 这意味着发送方窗口一定到了recvbase-N。因此发送方一定收到了比recvbase-N更早的确认

总之,接受方窗口移动前一定要考虑发送方的窗口移动。要清楚一点:接受方是发出ACK的一方,它的窗口移动是在发送方的前面的。

当发送窗口和接受窗口不同步会产生严重后果。

第一种情况:

第二种情况:

这是接受方在窗口太大时的两难:最后收到的分组0是新的分组还是重传的?(接受方无法判断是情况一还是二)

结论:N≤2^k-1^(窗口长度必须小于等于序列号空间大小的一半)

第五部分:面向连接的传输 : TCP

TCP的特点:

基于字节流
面向连接
可靠传输
缓冲传输
全双工
流量控制

TCP报文段的首部格式

TCP虽然是面向字节流的,但TCP传送的数据单元却是报文段.
TCP报文段首部前20个字节是固定的, 后4n个字节是不定的

源端口和目的端口 : 各占两个字节.

**序号 : 占4个字节, 在一个TCP连接中传送的字节流中的每一个字节都按顺序编号. 整个要传输的字节流的起始序号必须在连接建立时设置. ** 序号字段的值则指的是本报文段所发送的数据的第一个字节在整个报文字节流中的序号

确认号 : 占4个字节, 期望收到对方下一个报文段的第一个数据字节的序号.

TCP是全双工的,主机A在向主机B传输数据的同时,也从主机B中接受数据。确认号仅当ACK标志为1时有效。确认号表示期望收到的下一个字节的序号

数据偏移 : 占4位, TCP报文段的数据起始处距离TCP报文段的起始处有多远.

保留 : 占6位,保留为今后使用,但目前应置为 0

紧急URG : URG = 1时, 紧急指针字段有效, 优先发送, 比如终止指令.

确认ACK : 当ACK = 1时, 才有效. TCP规定, 建立连接后所有传达的报文段ACK必须置为1.

推送PSH : 接收 TCP 收到推送比特置 1 的报文段,就尽快地交付给接收应用进程,而不再等到整个缓存都填满了后再向上交付。

复位RST :当 RST = 1 时,表明 TCP 连接中出现严重差错(如由于主机崩溃或其他原因),必须释放连接,然后再重新建立运输连接。

同步SYN : 在建立连接时同步序号. 当SYN = 1, 而ACK = 0时, 表明是一个连接请求报文. 如果对方同意建立连接, 则应在相应的报文段中使用SYN = 1和ACK = 1.

终止FIN : 当FIN = 1, 表示报文发送完毕, 可以释放连接.

窗口 : 占2字节, 发送报文一方的接收窗口. 窗口值得意义: .作为接收方让发送方设置其发送窗口的依据. 窗口字段明确指出了现在允许对方发送的数据量. 窗口值是动态变化的. 因为接收方的数据缓存空间是有限的.

校验和 : 占2字节, 检验范围包括首部和数据这两部分. 和UDP的检验和类似.

紧急指针 : 占2字节, URG = 1才有意义.

选项字段 : 长度可变, TCP 只规定了一种选项,即最大报文段长度 MSS (Maximum Segment Size)。MSS 告诉对方 TCP:“我的缓存所能接收的报文段的数据字段的最大长度是 MSS 个字节。”

填充字段 :这是为了使整个首部长度是 4 字节的整数倍。

序列号和确认号是整个报文段首部最重要的两个字段

TCP如何保证可靠性

UDP传输数据不可靠,具体表现:
- 发送方不知道UDP数据段传达到接收方了没有,无反馈信息
- 发送方有多少就发多少,不会理会接收方实际可接收的数据大小

因此可靠性传输需要考虑两个方面:

  1. 接收到消息后的反馈机制
  2. 接收方对于数据的承载能力

有以下几个点:

1)应用数据被分割成TCP认为最合适发送的数据块。称为段(Segment)传递给IP层

流水线方式发送报文段】

2)当TCP发出一个段后,它会启动一个定时器,等待目的端确认收到这个报文段。若没有及时收到确认,将重新发送这个报文段(最早未确认的报文段)见“超时重传”

3)当TCP收到发自TCP连接另一端的数据,它将发送一个确认。这个确认不是立即发送的,通常将推迟几分之一秒。

【累积确认:只确认最后一个正确按序到达的报文段,见“反馈机制”】

4)TCP将保持它首部和数据的校验和,这是一个端到端的校验和,目的是检测数据在传输过程中的任何变化。如果收到段的校验和有差错,TCP将丢弃这个报文也不进行确认(超时重传导致对方就会重复发送了)。

5)TCP承载与IP数据报来传输,而IP数据报可能会失序,所以TCP的报文段到达时也可能会失序。但是TCP收到数据后会重新排序到正确的顺序(通过序号)。

6)IP数据报会发生重复,TCP的接收端必须丢弃重复是数据

7)TCP还能提供流量控制,TCP连接的每一方都有一定大小的缓冲空间

反馈机制

链路层也使用了可靠数据的传输,通过停等协议、滑动窗口协议等,但由于网络层的IP协议是不可靠的,即使在链路层上不会发生丢失和错序,但是在路由器(网络层)上可能发生丢包。
TCP传输使用选择性重传(selective repeat)的传输方式。不选用停等协议是因为传输层中端到端的延迟很大。选择性重传用到了序列号和确认号
确认号+序号

客户端发送第一个报文段,序号为x;第一个确认号字段为y-1。
然后服务端成功接收报文段后,提供一个确认,确认号为x+1,表示已经收到x以及之前的数据,期待接收x+1以及之后的数据。这里的报文段序号设置为y.
接着第三个报文段由客户发往服务器。确认已经收到服务器发来的数据。确认号为y+1,并且序号为x+1.

超时重传

在发送一个数据之后,就开启一个定时器,若是在这个时间内没有收到发送数据的ACK确认报文,则对该报文进行重传,在达到一定次数还没有成功时放弃并发送一个复位信号。
  这里比较重要的是重传超时时间,怎样设置这个定时器的时间(RTO),从而保证对网络资源最小的浪费。因为若RTO太小,可能有些报文只是遇到拥堵或网络不好延迟较大而已,这样就会造成不必要的重传。太大的话,使发送端需要等待过长的时间才能发现数据丢失,影响网络传输效率。
  由于不同的网络情况不一样,不可能设置一样的RTO,实际中RTO是根据网络中的RTT(传输往返时间)来自适应调整的。具体关系参考相关算法。
  通过图来了解重传机制:

超时间隔加倍

  • 每一次TCP超时重传均将下一次超时间隔设为先前值的两倍

  • 超时间隔由EstimatedRTT和DevRTT决定,每当发生下列事件之一是重新计算超时间隔

    • 收到上层应用的数据

    • 收到对未确认数据的ACK

快速重传(缩短了超时的时间)

TCP采取的是累计确认机制(不是说TCP要收完全部窗口中的ACK再发下一窗口),即当接收端收到比期望序号大的报文段时,便会重复发送最近一次确认的报文段的确认信号,我们称之为冗余ACK(duplicate ACK)。这个和收到错误分组,接收方不发ACK是没有关联的。换句话说,累计确认机制让TCP具备了快速重传的特点。

  1. 【增加重发丢失分组的延时】

  2. 通过重复的ACK检测丢失报文段

    发送方常要连续发送大量报文段

    如果一个报文段丢失,会引起很多连续的重复ACK.

  3. 如果发送收到一个数据的3个重复ACK,它会认为确认数据之后的报文段丢失

    快速重传: 在超时到来之前重传报文段

TCP重传机制和GBN/SR的关系

注意:这三者的发送窗口是收到之前的ACK就滑动到下一个分组

TCP重传机制更多的像一种GBN和SR的混合机制

TCP积累是确认式的,只用一个定时器,很像GBN

但有区别:

  • 很多TCP实现缓存失序的报文段。

  • GBN在报文段n超时时,会重发从n开始所有未确认的报文段。而TCP只会重传报文段n。甚至如果在报文段n超时收到了对报文段n+1的确认,TCP连报文段n都不会重传。

  • TCP还有快速重传机制。

流量控制

流量控制的原因:

  • TCP接收方有一个缓存,所有上交的数据全部缓存在里面
  • 应用进程从缓冲区中读取数据可能很慢

所以TCP支持根据接收端能力来决定发送端的发送速度。这个机制叫做流量控制。

流量控制的目标:

发送方不会由于传得太多太快而使得接收方缓存溢出

流量控制的手段:

接收方在反馈时,将缓冲区剩余空间的大小填充在报文段首部的窗口字段中,通知发送方

窗口大小

(接收端向发送端主机通知自己可以接受数据的大小,这个大小限制就叫做窗口大小)

窗口扩大因子M

接收端如何把窗口大小告诉发送端呢? 回忆我们的TCP首部中, 有一个16位窗口字段, 就是存放了窗口大小信息;那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么?实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M,
实际窗口大小是 窗口字段的值左移 M 位;

机理

接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过ACK端通知发送端;窗口大小字段越大, 说明网络的吞吐量越高;
接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;发送端接受到这个窗口之后, 就会减慢自己的发送速度;

如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端

当接收端收到从3001号开始的数据段后其缓冲区挤满。不得不暂时停止发送数据,之后窗口收到更新通知后才得以继续进行。如果这个通知在途中丢失了,可能导致无法继续通信。所以发送方会是不是发送一个窗口探测的数据段。此数据端仅含一个字节来获取最新的窗口大小。

TCP连接的建立

就是我们常说的通过三次握手建立TCP连接。

第一步:客户端的TCP想服务器端的TCP发送一个特殊的TCP报文段,称为SYN报文段(syn设置为1),不包含应用层数据,随机选择一个初始序号client_isn。该报文段会被封装到一个IP数据报中,并发送给服务器。(告诉服务器,我来了)
第二步:服务器收到ip数据报后,提取出TCP SYN报文段,为该TCP分配TCP缓存和变量并向该客户TCP发送允许连接的报文段。这个报文段也不包含应用层数据。首部包含三个重要信息:SYN比特设置为1,确认号设置为client_isn+1;服务器选择自己的随机序号server_isn。该报文也被称为SYNACK segment(服务器告诉客户端你可以连接,我准备好了)
第三步:客户机收到SYNACK报文段后,给该连接分配缓存和变量。客户机向服务器发送另外一个报文段,对服务器的允许连接的报文段进行确认(确认字段设置为server_isn+1)。因为连接已经建立了,因此SYN比特设置为0。这一阶段可以在报文段负载中携带客户到服务器的数据。(客户告诉服务器我不会鸽你的,我马上就要来了)

为什么要三次握手?

第三次握手是为了防止已经失效的连接请求报文段突然又传到服务端,因而产生错误。

假设只需要二次握手,考虑这种情况:

1:主机A发出的请求报文段在某些网络节点滞留时间太长,主机A由于超时重发连接请求,B收到重发的连接请求后给出同意连接的确认,主机A收到B的确认建立连接。数据传输完毕释放连接。

2:这时第一个请求才到达B,主机B收到该失效的请求后,误以为A又发出请求,于是向主机A发出确认,同意建立连接。主机A则不会理睬该确认。主机B则苦等A的数据。三次握手就可以防止这种情况的发生。(主机A不会对主机B的确认发出确认,连接就建立不起来)

SYN洪泛攻击

TCP服务器为了响应一个SYN连接请求报文,需要初始化连接变量及分配缓存(需要消耗服务器的资源),然后发送SYNACK报文进行响应,并等待客户端的ACK报文段。

如果客户机不给出第三次ACK响应来完成第三次握手的最后一步,服务器在等待一段时间后(通常为一分钟)服务器将终止该半开连接并释放资源。

第六部分:拥塞控制原理

在过多的源发送了过多的数据,超出了网络的处理能力。

不同于流量控制!

流量控制是为了匹配发送方和接收方的速度,只发生在发送方和接收方之间

拥塞控制:网络拥塞时,所有发送方都要抑制发送速度

拥塞的现象:

丢包 (路由器缓冲区溢出)

延时长 (在路由器缓冲区排队)

拥塞控制的方法(TCP选择端系统自己判断是否拥塞)

网络辅助的拥塞控制

直接网络反馈:路由器以阻塞分组的形式通知发送方“网络拥塞了”

经由接收方的网络反馈:路由器标识从发送方流向接收方分组中的某个字段以指示拥塞的产生,由接收方通知发送方“网络拥塞了”

端到端拥塞控制

网络层不为拥塞控制提供任何帮助和支持

端系统通过对网络行为(丢包或时延增加)的观测判断网络是否发生拥塞

目前TCP采用该种方法

TCP拥塞控制

由于发送方到接收方之间的信道是公用的,因此如果发送方不考虑中间信道的容量随意发送就可能出现拥塞。拥塞会导致延迟严重,甚至大量丢包

TCP必须使用端到端拥塞控制。(区别于网络辅助手段)
方法:让每一个发送方根据所感知到的网络拥塞程度来限制其能向链接发送流量的速率。
不拥塞——加快发送流量的速率
拥塞——减慢发送流量的速率

发送端由一个接收缓存、一个发送缓存和几个变量组成。
拥塞控制机制需要跟踪一个额外的变量,拥塞窗口,cwnd。它对于一个TCP发送方能向网络中发送流量的速率进行了限制。
$LastByteSent−LastByteAcked<=min(cwnd,rwnd)$

这里我们假设接收窗口无限大,发送方未被确认的数据量仅受限于cwnd
$ 发送速率 = cwnd/RTT (字节/秒)$

可以通过调节cwnd来调整发送方向连接发送数据的速率
① 限制发送流量
运行在发送方的TCP拥塞控制机制跟踪一个额外的变量,即拥塞窗口(congestion window。cwnd)
调节cwnd值以控制发送速率
② 发送方感知出现拥塞
过度拥塞的时候,路由器的缓存会溢出,引起一个数据报被丢弃,引起发送方的丢包事件
丢包事件:出现超时或者收到来自接收方的三个冗余ACK
③ 发送方在感知拥塞后确定应当发送的速率
这里需要用到TCP拥塞控制算法:慢启动;拥塞避免;快速恢复

阶段 阶段行为 结束的方式
慢启动 cwnd的值以一个MSS开始并且每当传输的报文段首次被确认,就指数增加MSS,1,2,4…每经过一个RTT,发送的速率翻番。 第一次结束:(出现由超时指示的丢包时间)将cwnd设置为1,然后重新开始慢启动过程,并且设置ssthresh(慢启动阈值)设置为cwnd的一半。 第二次结束:(当cwnd达到或者超过ssthresh),TCP转移到拥塞避免阶段。 第三次结束(检测到3个冗余ACK):执行快速重传并进入快速恢复状态
拥塞避免 此时cwnd的值大约是上次拥塞的值的一半,每次收到确认,将cwnd增加一个MSS 出现超时时,cwnd的值被设置为1个MSS,当丢包发生时,ssthresh更新为cwnd值的一半;当收到3个冗余的ACK时,将ssthresh的值记录为cwnd的值的一半,然后进入快速恢复状态
快速恢复 每收到一个冗余的ACK,cwnd将增加一个MSS 当对丢失报文段的一个ACK到达时,TCP在降低cwnd后进入拥塞避免状态。如果出现超时事件,cwnd设置为1个MSS,并且ssthresh的值设置为cwnd值的一半,然后迁移到慢启动状态

MSS,最大报文段长度

此处引入一个概念程为拥塞窗口,拥塞窗口发送开始的时候, 定义拥塞窗口大小为1;每次收到一个ACK应答, 拥塞窗口加1;

每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快.为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.此处引入一个叫做慢启动的阈值。
当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长

**当TCP开始启动的时候, 慢启动阈值等于窗口最大值;在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1;**少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;

补充:

1、初始速率很低但是速率的增长速度很快。

2、对收到3个重复ACK的反应——快速重传

  • 门限值设为当前CongWin的一半(门限值初始值65kB)
  • 将CongWin减为新的门限值+3MSS
  • 线性增大拥塞窗口

3、对超时事件的反应:设置门限(Threshold)

  • 门限值设为当前CongWin的一半(门限值初始值65kB)
  • 将CongWin设为1个 MSS大小;
  • 窗口以指数速度增大
  • 窗口增大到门限值之后,再以线性速度增大

在执行慢开始算法时,拥塞窗口 cwnd 的初始值为 1,发送第一个报文段 M0。

发送端收到 ACK1 (确认 M0,期望收到 M1)后,将 cwnd 从 1 增大到 2,于是发送端可以接着发送 M1 和 M2 两个报文段。

接收端发回 ACK2 和 ACK3。发送端每收到一个对新报文段的确认 ACK,就把发送端的拥塞窗口+1MSS。现在发送端的 cwnd 从 2 增大到 4,并可发送 M3 ~ M6共 4个报文段。

发送端每收到一个对新报文段的确认 ACK,就把发送端的拥塞窗口+1MSS,因此拥塞窗口 cwnd 随着传输次数按指数规律增长。

当拥塞窗口 cwnd 增长到慢开始门限值 ssthresh 时(即当 cwnd = 16 时),就改为执行拥塞避免算法,拥塞窗口按线性规律增长。

假定拥塞窗口的数值增长到 24 时,网络出现超时(表明网络拥塞了)。

更新后的 ssthresh 值变为 12(即发送窗口数值 24 的一半),拥塞窗口再重新设置为 1,并执行慢开始算法。

当 cwnd = 12 时改为执行拥塞避免算法,拥塞窗口按按线性规律增长,每经过一个往返时延就增加一个 MSS 的大小。

假定拥塞窗口的数值增长到 24 时,网络出现冗余ACK

TCP拥塞控制算法(Reno)总结

  1. 当 拥塞窗口CongWin小于门限值Threshold时,发送方处于 慢启动 阶段,窗口以指数速度增大。
  2. 当 拥塞窗口CongWin大于门限值Threshold时,发送方处于 拥塞避免 阶段,窗口线性增大。
  3. 当收到 3个重复的ACK 时,门限值Threshold设为拥塞窗口的1/2,而拥塞窗口CongWin设为门限值Threshold+3个MSS。
  4. 当 超时 事件发生时,门限值Threshold设为拥塞窗口的1/2,而拥塞窗口CongWin设为1个 MSS。

TCP吞吐量

忽略慢启动。

假定当丢包事件发生时,窗口大小为 W.

​ 此时 吞吐量为$W/RTT$

丢包事件发生后,窗口大小减为W/2, 吞吐量为W/2RTT.

​ 因此平均吞吐量为: $0.75 W/RTT$

TCP拥塞控制的公平性

由于TCP是具有拥塞机制的,所以TCP在带宽的处理上是公平的,但是UDP是没有拥塞控制的,所以UDP是不公平的。它会以恒定的速率传输数据。

在并行TCP连接中,由于TCP是公平的。当一个应用使用了多个并行的TCP连接,它就会占有更多的带宽。从应用的角度来开就不是公平的。

应用层

应用层直接面向用户,是OSI中的最高层。它的主要任务是为用户提供应用的接口,即提供不同计算机间的文件传送、访问与管理,电子邮件的内容处理,不同计算机通过网络交互访问的虚拟终端功能等。

阅读全文 »

动态规划算法

**动态规划(dynamic programming)**是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。这个方法很类似于数学上的数学归纳法。

定义

动态规划实际上是一类题目的总称,并不是指某个固定的算法。动态规划的意义就是通过采用递推(或者分而治之)的策略,通过解决大问题的子问题从而解决整体的做法。

动态规划的核心思想是巧妙的将问题拆分成多个子问题,通过计算子问题而得到整体问题的解。而子问题又可以拆分成更多的子问题,从而用类似递推迭代的方法解决要求的问题。

动态规划的本质,是对问题状态的定义状态转移方程的定义

拆分问题,靠的就是状态的定义状态转移方程的定义

参考资料:动态规划

动态规划实质

动态规划算法通常基于一个递推公式及一个或多个初始状态。当前子问题的解将由上一次子问题的解推出。使用动态规划来解题只需要多项式时间复杂度,因此它比回溯法、暴力法等要快许多。

每个阶段只有一个状态->递推;
每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心;
每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索;
每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划。

每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到

这个性质叫做最优子结构;

而不管之前这个状态是如何得到的

这个性质叫做无后效性。

动态规划和分治的区别

动态规划也是一种分治思想(比如其状态转移方程就是一种分治),但与分治算法不同的是,分治算法是把原问题分解为若干个子问题,自顶向下求解子问题,合并子问题的解,从而得到原问题的解动态规划也是把原始问题分解为若干个子问题,然后自底向上,先求解最小的子问题,把结果存在表格中,在求解大的子问题时,直接从表格中查询小的子问题的解,避免重复计算,从而提高算法效率。(因为分治策略会反复求解公共子问题)

可以采用动态规划求解的问题的一般要具有3个性质

1、最优化子结构原理

如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。最有子结构是使用动态规划的最基本的条件,如果不具有最优子结构性质,就不可以使用动态规划解决。证明的例子

如何证明问题的最优解满足最优子结构性呢?

即证明:作为构成原问题最优解的组成部分,对应每个子问题的部分应是该子问题的最优解。

“剪切-粘贴”(cut-and-paste)技术:

本质上是反证法证明:假定原问题最优解中对应于某个子问题的部分解不是该子问题的最优解,而存在“更优的子解”,那么我们可以从原问题的解中“剪切”掉这一部分,而将“更优的子解”粘贴进去,从而得到一个比最优解“更优”的解,这与最初的解是原问题的最优解的前提假设相矛盾。因此,不可能存在“更优的子解”。——所以,原问题的子问题的解必须是其最优解,最优子结构性成立。

2、子问题重叠

子问题重叠不是使用动态规划的必要条件,但是问题存在子问题重叠的特性更能够充分彰显动态规划的优势。

举个例子:求斐波那契数列时fib(8)=fib(7)+fib(6),其中fib(8)的最优解就包含了子问题fib(7)的最优解和子问题fib(6)的最优解,反过来讲只要我们求解出子问题的最优解,那么就可以构造出问题的最优解,这也就是为什么最优子结构是求解动态规划的必要条件。fib(7)和fib(6)中有相同的子问题fib(5),这就是子问题重叠。

3、无后效性

即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。

动态规划的维数

斐波那契数列问题中的递归方程中只涉及一个变量i,所以是一维的动态规划,最长公共子序列问题中,递归方程中涉及两个变量,是二维的动态规划。一个很直观的理解就是,动态规划在从子问题的最优解来得到问题的最优解的过程中所填写的那张表格是几维的,就是几维的动态规划。

最长公共子串(Longest Common Substring)与最长公共子序列(Longest Common Subsequence)的区别: 子串要求在原字符串中是连续的,而子序列则只需保持相对顺序一致,并不要求连续。

如何验证最优子结构特性

先写出问题的最优解和子问题的最优解的关系,然后假定问题的最优解成立(上面关系的左边成立),看能否得到子问题的最优解就是关系式右边的子问题最优解的形式,如果是就验证了最优子结构(一般用反证法)。

解题核心

动态规划的解题核心主要分为两步:

  1. 第一步:状态的定义
  2. 第二步:状态转移方程的定义

在这里,我们为了避免混淆用“状态”这个词来替代“问题”这个词。“问题”表示的含义类似:题目、要求解的内容、题干中的疑问句这样的概念。状态表示我们在求解问题之中对问题的分析转化。

第一步:状态的定义

—————-刻画了一个最优解的结构特征(最优子结构性)

有的问题过于抽象,或者过于啰嗦干扰我们解题的思路,我们要做的就是将题干中的问题进行转化(换一种说法,含义不变)。转化成一系列同类问题的某个解的情况,比如说:

题目:求一个数列中最大连续子序列的和。

我们要将这个原问题转化为:

状态定义:Fk是第k项前的最大序列和,求F1~FN中最大值。

通过换一种表述方式,我们清晰的发现了解决问题的思路,如何求出F1~FN中的最大值是解决原问题的关键部分。上述将原问题转化成另一种表述方式的过程叫做:状态的定义。这样的状态定义给出了一种类似通解的思路,把一个原来毫无头绪的问题转换成了可以求解的问题。

第二步:状态转移方程的定义

—————-递归地定义最优解的值(一个递推关系式)

在进行了状态的定义后,自然而然的想到去求解F1~FN中最大值。这也是状态定义的作用,让我们把一个总体的问题转化成一系列问题,而第二步:状态转移方程的定义则告诉我们如何去求解一个问题,对于上述已经转换成一系列问题我们要关注的点就在于:如何能够用前一项或者前几项的信息得到下一项,这种从最优子状态转换为下一个最优状态的思路就是动态规划的核心。
对于上面的例子题目来说,状态转移方程的定义应该是:

Fk=max{Fk-1+Ak,Ak}
Fk是前k项的和,Ak是第k项的值

仔细思考一番,我们能够得到这样的结论,对于前k个项的最大子序列和是前k-1项的最大子序列和Fk与第k项的和、或者第k项两者中较大的。如果大家还是不能理解这个原理建议用演算纸自己计算一番,这里就不过多赘述了。这种状态转移的思路就是DP的核心。

第三步:计算最优解的值(目标函数的最大/小值)

动态规划是一种策略,不是一个具体的算法。

因此,动态规划求解不象搜索或数值计算,具有一个标准的数学表达式和明确清晰的解题方法。动态规划针对的最优化问题,由于问题性质的不同,确定最优解的条件的不同,动态规划的设计对不同的问题,有各具特色的解题方法。

改进一:若问题的决策序列由n次决策构成,而每次决策有p种选择,若采用枚举法,则可能的决策序列将有p^n^个。而利用动态规划策略的求解过程中仅保存了所有子问题的最优解,而舍去了所有不能导致问题最优解的次优决策序列,因此可能有多项式的计算复杂度。

改进二:重叠子问题性:动态规划与分治法也不同,分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则有些子问题被重复计算了很多次。动态规划保存了已解决的子问题的答案,在需要时找出已求得的答案,避免了大量的重复计算,节省了时间。

无论是采取自底向上还是自顶向下方法,动态规划分技术要点是:

  • 用一个表(备忘)来记录所有已解的子问题的答案。
  • 不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。

利用查表,避免对重复子问题的重复求解,动态规划可以将原来具有指数级复杂度的搜索算法改进成了具有多项式时间的算法,这是动态规划算法的根本目的。

关于动态规划求解代价的说明

在动态规划方法中,我们通常自底向上地使用最优子结构,即首先求子问题的最优解,然后求原问题的最优解。在求解原问题的过程中,需要在涉及的子问题中做出选择,选出能得到原问题最优解的子问题。这样,求解原问题的代价通常就是求子问题最优解的代价加上此次选择直接产生的代价

第四步:利用计算出的信息,构造一个最优解(求解向量)

动态规划的应用场景

看过了如何去使用动态规划,那么随之而来的问题就在于:既然动态规划不是某个固定的算法,而是一种策略思路,那么什么时候应该去使用什么时候不能用呢?答案很简短:

对于一个可拆分问题中存在可以由前若干项计算当前项的问题可以由动态规划计算。

例题1: 数塔取数问题

一个高度为N的由正整数组成的三角形,从上走到下,求经过的数字和的最大值。
每次只能走到下一层相邻的数上,例如从第3层的6向下走,只能走到第4层的2或9上。

该三角形第n层有n个数字,例如:

第一层有一个数字: 5

第二层有两个数字: 8 4

第三层有三个数字: 3 6 9

第四层有四个数字:7 2 9 5

最优方案是:5 + 8 + 6 + 9 = 28

状态定义:
$$
F(i,j)是第i行j列项最大取数和,求第n行F(n,m)(0 < m < n)中最大值。
$$
状态转移方程:
$$
F(i,j) = max{(F(i-1,j-1),F(i-1,j))}+A(i,jt)
$$
分析:

(1)第一步:有底层向上层算起,因为这是一个金字塔的形状,底层向上算起,就可以最终到一个值,这个值就是最大值,

img

(2)每一层相加,然后比较取最大数。即:

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.Scanner;

public class Dp01 {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
long max = 0;
int[][] dp = new int[n][n];
dp[0][0] = scan.nextInt();

for(int i=1;i<n;i++){
for(int j=0;j<=i;j++){
int num = scan.nextInt();
if(j==0){
dp[i][j] = dp[i-1][j] + num;
}else {
dp[i][j] = Math.max(dp[i-1][j-1],dp[i - 1][j])+num;
}
max = Math.max(dp[i][j],max);
}
}
System.out.println(max);
}
}

例题2:编辑距离

编辑距离,又称Levenshtein距离(也叫做Edit Distance),是指两个字串之间,由一个转成另一个所需的最少编辑操作次数。许可的编辑操作包括将一个字符替换成另一个字符,插入一个字符,删除一个字符。
例如将kitten一字转成sitting:
sitten (k->s)
sittin (e->i)
sitting (->g)
所以kitten和sitting的编辑距离是3。俄罗斯科学家Vladimir Levenshtein在1965年提出这个概念。
给出两个字符串a,b,求a和b的编辑距离。

状态定义:Fi,j表示第一个字符串的前i个字母和第二个字符串的前j个字母需要编辑的次数,求Fn,m,n和m分别是两个字符串的长度。

状态转移方程:
$$
当F_{i,j-1}!=F_{i-1,j}时,F_{i,j}=min(F_{i-1,j-1},F_{i,j-1},F_{i-1,j})+1
$$

.
$$
当F_{i,j-1}=F_{i-1,j}时,F_{i,j}=F_{i,j-1};
$$

例题3:矩阵取数问题

一个N*N矩阵中有不同的正整数,经过这个格子,就能获得相应价值的奖励,从左上走到右下,只能向下向右走,求能够获得的最大价值。例如:3 * 3的方格。

1 3 3
2 1 3
2 2 1

能够获得的最大价值为:11。

我们必须注意到的一点是,到达一个格子的方式最多只有两种:从左边来的(除了第一列)和从上边来的(除了第一行)。因此为了求出到达当前格子的最短路径,我们就要先去考察那些能到达当前这个格子的格子,到达它们的最短路径。经过上面的分析,很容易可以得出问题的状态和状态转移方程。状态
$$
S[i][j]
$$
表示我们走到(i, j)这个格子时,最短的路径。那么,状态转移方程如下:
$$
S[i][j]=A[i][j] + min(S[i-1][j], if i>0 ; S[i][j-1], if j>0)
$$
其中i代表行,j代表列,下标均从0开始。

其中i代表行,j代表列,下标均从0开始。

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
public class MinPath {
public static int getMin(int[][] map, int n, int m) {
int[][] dp = new int[n][m];
for(int i=0;i<n;i++){ //初始化第一列的值
for(int j=0;j<=i;j++){
dp[i][0]+=map[j][0];
}
}
for(int i=0;i<m;i++){ //初始化第一行的值
for(int j=0;j<=i;j++){
dp[0][i]+=map[0][j];
}
}
for(int i=1;i<n;i++){
for(int j=1;j<m;j++){
dp[i][j] = min(dp[i][j-1]+map[i][j],dp[i-1][j]+map[i][j]);
}
}
return dp[n-1][m-1];
}
public static int min(int a,int b){
if(a>b){
return b;
}else{
return a;
}
}
public static void main(String[] args) {
Scanner sc=new Scanner(System.in);
int m=sc.nextInt();
int n=sc.nextInt();
int map[][]=new int[m][n];
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
map[i][j]=sc.nextInt();
}
} System.out.println(getMin(map,m,n));
}

例题4:钢条切割问题

给定一段长度为n英寸的长钢条和一个价格表P,求切割为短钢条的方案,使得销售收益rn最大。

长度i 1 2 3 4 5 6 7 8 9 10
价格pi 1 5 8 9 10 17 17 20 24 30

按价格从高到低分段出售,即先卖pi最大的段,然后再卖pj较小的段。是否可以获得最好的收益?(贪心策略)
显然是不可能获得收益最大的。详细参见上述贪心和动态规划之间的区别。

对最优切割,设某次切割在位置i,将钢条分成长度为i和n-i的两段,令ri和rn-i分别是这两段的最优子切割收益,则有:rn=ri+ rn-i

钢条切割问题的朴素递归求解过程

基本思路:我们将钢条从左边切割下长度为i的一段,只对右边剩下的长度为n-i的一段继续进行切割(递归求解),对左边的一段则不再进行切割。则可得公式:
$$
r_n=\max \limits_{1<=i<=n}(P_i+r_{n-i})
$$
其中pi为左边长度为i的钢条的收益,rn-i为右边长度为n-i的钢条继续切割后得到的最优收益,rn为长度为n的钢条切割后得到的最优收益。

但是此时,原问题的最优解只包含一个相关子问题(右端剩余部分)的解,而不是两个。就是分治的策略,会有很多重复计算在里面,所以效率不高。时间复杂度为O(2^n^)。

钢条切割问题的动态规划求解

带备忘录的自顶向下方法(top-down with memorization)

此方法仍然按照自然的递归形式编写过程,但过程中会保存每个子问题的解(通常是保存在数组或者散列表中),当需要一个子问题的解时,会先判断是否保存过此解。如果是,则直接返回保存的值,从而节省了计算时间。否则,按照常规方式进行计算。

因此,动态规划方法需要付出额外的空间保存子问题的解,是一种典型的时空权衡(time-memory trade-off)

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
public static int memorizedCutRod(int[] p, int n){
int result = 0;
//保存已计算过的子问题的解的数组
int res[] = new int[n+1];
for (int i = 0; i < res.length; i++) {
res[i] = -1;
}
result = memorizedCutRodAux(p, n, res);
return result;
}

public static int memorizedCutRodAux(int[] p, int n, int[] r){
//如果已经计算过该子问题的解,直接返回
if(r[n]>=0){
return r[n];
}

int q = -1;
if(n==0){
q = 0;
} else{
//r(n)=max(p[i]+r(n-i))
//p[i]表示切割成长度为i的钢条的收益
//r(n-i)剩余钢条的最大收益值
for(int i = 1;i<=n;i++){
q = Math.max(q, p[i]+memorizedCutRodAux(p, n-i, r));
}
}
r[n] = q;
return q;
}

自底向上的方法(bottom-up method)

这种方法一般需要恰当定义子问题的规模的概念,使得任何子问题的求解都只依赖于更小的子问题的求解。因而,我们可以将子问题按照规模排序,按照由小到大的顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已经求解完毕,结果已经保存。每个子问题只需要求解一次,当我们求解它时,它的所有前提子问题都已经求解完成。

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
public static void extendedBottomUpCutRod(int[] p, int n){
//保存已计算过的子问题的解的数组
int[] res = new int[n+1];
//用来保存最优解的切割的钢条的长度
int[] solve = new int[n+1];
//从规模为0的最小子问题开始依次求解
res[0] = 0;
for (int i = 1; i <= n; i++) {
int q = -1;
for(int j = 1; j<=i;j++){
if(q<p[j]+res[i-j]){
q = p[j]+res[i-j];
solve[i] = j;//对于长度为i的钢条,求出其最优收益,并记录下获得最优收益时左侧第一个切割点的位置
}
}
res[i] = q;
}

print(res[n], solve, n);
}

public static void print(int maxValue, int[] solve, int n) {
System.out.println(maxValue);
while(n>0){
System.out.print(solve[n]+", ");
n = n - solve[n];
}
}

具有相同的渐近运行时间:Θ(n^2^)

但由于自底向上法没有频繁的递归函数调用的开销,所以自底向上法的时间复杂性函数通常具有更小的系数。

而在某些特殊情况下,自顶向下法可能没有递归处理所有可能的子问题(剪枝)而减少工作量。

子问题图

在自底向上的动态规划方法中,对一个给定的子问题x,在求解它之前应先求解它所依赖的子问题。仅当它依赖的所有子问题都求解完成了,才会求解它。——这在子问题图中,对应顶点的一个逆拓扑序,可以按照深度优先原则进行处理。

基于子问题图“估算”算法的运行时间:

算法的运行时间等于所有子问题求解的时间之和。子问题图中,子问题对应顶点,子问题的数目等于顶点数。一个子问题的求解时间与子问题图中对应顶点的“出度”成正比。因此,一般情况下,动态规划算法的运行时间与顶点和边的数量至少呈线性关系。

问题五:矩阵链乘法

n个要连续相乘的矩阵构成一个矩阵链<A1,A2,…,An>,要计算这n个矩阵的连乘乘积:A1A2…An,称为矩阵链乘问题。

  • 矩阵链乘不满足交换律:A1A2A3 ≠ A3A2A1

  • 矩阵链乘满足结合律,所以矩阵链乘相当于在矩阵之间加括号。不同的加括号方案导出不同的矩阵链乘计算模式。

如,已知四个矩阵A1,A2,A3,A4,根据不同的加括号方式,乘积A1A2A3A4有五种不同的计算模式:(A1(A2(A3A4))) (A1((A2A3)A4)) ((A1A2)(A3A4)) ((A1(A2A3))A4)) ((A1A2)A3)A4)

两个矩阵A和B只有相容(compatible),即A的列数等于B的行数时,才能相乘。如果A是p×q的矩阵,B是q×r的矩阵,那么乘积C是p×r的矩阵。计算C所需要时间由第8行的标量乘法的次数决定的,即pqr。

以矩阵链<A1,A2,A3>为例,来说明不同的加括号方式会导致不同的计算代价。假设三个矩阵的规模分别为10×100、100×5和5×50。
如果按照((A1A2)A3)的顺序计算,为计算A1A2(规模10×5),需要做101005=5000次标量乘法,再与A3相乘又需要做10550=2500次标量乘法,共需7500次标量乘法。
如果按照(A1(A2A3))的顺序计算,为计算A2A3(规模100×50),需100550=25000次标量乘法,再与A1相乘又需1010050=50000次标量乘法,共需75000次标量乘法。因此第一种顺序计算要比第二种顺序计算快10倍。

矩阵链乘法问题(matrix-chain multiplication problem)可描述如下:给定n个矩阵的链<A1,A2,…,An>,矩阵Ai的规模为p(i-1)×p(i) (1<=i<=n),求完全括号化方案,使得计算乘积A1A2…An所需标量乘法次数最少。

因为括号方案的数量与n呈指数关系,所以通过暴力搜索穷尽所有可能的括号化方案来寻找最优方案是一个糟糕策略。

步骤一:最优括号化方案的结构特征

动态规划的第一步是寻找最优子结构,然后就可以利用这种子结构从子问题的最优解构造出原问题的最优解。在矩阵链乘法问题中,我们假设A(i)A(i+1)…A(j)的最优括号方案的分割点在A(k)和A(k+1)之间。那么,继续对“前缀”子链A(i)A(i+1)..A(k),(记为Ai,k)进行括号化时,我们应该直接采用独立求解它时所得的最优方案。
我们已经看到,一个非平凡(i≠j)的矩阵链乘法问题实例的任何解都需要划分链,而任何最优解都是由子问题实例的最优解构成的。为了构造一个矩阵链乘法问题实例的最优解,我们可以将问题划分为两个子问题(A(i)A(i+1)…A(k)和A(k+1)A(k+2)..A(j)(记为Ak+1,j)的最优括号化问题),求出子问题实例的最优解,然后将子问题的最优解组合起来。我们必须保证在确定分割点时,已经考察了所有可能的划分点,这样就可以保证不会遗漏最优解。

步骤2:一个递归求解方案

对于矩阵链乘法问题,我们可以将对所有1<=i<=j<=n确定A(i)A(i+1)…A(j)的最小代价括号化方案作为子问题。令m[i,j]表示计算矩阵A(i..j)所需标量乘法次数的最小值,那么,原问题的最优解—**计算A(1..n)所需的最低代价就是m[1,n]**。

我们可以递归定义m[i,j]如下:
对于i=j时的平凡问题,矩阵链只包含唯一的矩阵A(i..j)=A(i),因此不需要做任何标量乘法运算。所以,对所有i=1,2,…,n,m[i,i]=0。
若i<j,我们利用步骤1中得到的最优子结构来计算m[i,j]。我们假设A(i)A(i+1)…A(j)的最优括号化方案的分割点在矩阵A(k)和A(k+1)之间,其中i<=k<j。那么,m[i,j]就等于计算A(i..k)和A(k+1..j)的代价加上两者相乘的代价的最小值。Ai,k是一个pi-1×pk的矩阵,Ak+1,j是一个pk×pj的矩阵。结果矩阵Ai,j是Ai,k和Ak+1,j最终相乘的结果。。因此,我们得到
m[i,j]=m[i,k]+m[k+1,j]+ p(i-1)p(k)p(j)
此递归公式假定最优分割点k是已知的,但实际上我们是不知道。不过,k只有j-i种可能的取值,即k=i,i+1,…,j-1。由于最优分割点必在其中,我们只需检查所有可能情况,找到最优者即可。
因此,A(i)A(i+1)…A(j)的最小代价括号化方案的递归求解公式变为:

$$
①如果i=j,m[i,j]=0
$$

$$
②如果i<j,m[i,j]=\min\limits_{i<=k<j} (m[i,k]+m[k+1,j]+p_{i-1}p_{k}p_{j})
$$

m[i,j]的值给出了子问题最优解的代价,但它并未提供足够的信息来构造最优解。为此,我们用s[i,j]保存最优括号化方案的分割点位置k,即使得 m[i,j]=m[i,k]+m[k+1,j]+ p(i-1)p(k)p(j)成立的k值。

步骤3:计算最优代价

我们采用自底向上表格法代替递归算法来计算最优代价。此过程假定矩阵Ai的规模为p(i-1)×pi(i=1,2,…,n)。它的输入是一个序列p=<p0,p1,…,pn>,其长度为p.length=n+1。过程用一个辅助表m[1..n,1..n]来保存代价m[i,j],用另一个辅助表s[1..n-1,2..n]记录最优值m[i,j]对应的分割点k。我们就可以利用表s构造最优解。
对于矩阵A(i)A(i+1)…A(j)最优括号化的子问题,我们认为其规模为链的长度j-i+1。因为j-i+1个矩阵链相乘的最优计算代价m[i,j]只依赖于那么少于j-i+1个矩阵链相乘的最优计算代价。因此,算法应该按长度递增的顺序求解矩阵链括号化问题,并按对应的顺序填写表m。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void PRINT_OPTIMAL_PARENS(int p[],int m[][M],int s[][M])
{
int q,n=p.length()-1;
for(int i=1;i<=n;i++)m[i][i]=0;
for(int l=2;l<=n;l++)/* 矩阵链的长度 */
{
for(int i=1;i<=n-l+1;i++)
{
int j=i+l-1; /* 等价于 l=j-i+1 */
m[i][j]=100000;
for(int k=i;k<=j-1;k++)
{
q=m[i][k]+m[k+1][j]+p[i-1]*p[k]*p[j];
if(q<m[i][j])
{
m[i][j]=q;
s[i][j]=k;
}
}
}
}

}

步骤4:构造最优解

s[i,j]记录了AiAi+1…Aj的最优括号化方案中“首个”加括号的位置点k。

  • 基于s[i,j],对AiAi+1…Aj的括号化方案是:(AiAi+1…As[i,j])(As[i,j]+1…Aj)

A1…n的最优方案中最后一次矩阵乘运算是:(A1…s[1,n])(As[1,n]+1…n

1
2
3
4
5
6
7
8
9
10
11
12
//递归输出
public static void PRINT_OPTIMAL_PARENS(int s[][M],int i,int j)
{
if(i == j) System.out.println("A");
else
{
System.out.println("(");
PRINT_OPTIMAL_PARENS(s,i,s[i][j]);
PRINT_OPTIMAL_PARENS(s,s[i][j]+1,j);
System.out.println(")");
}
}

问题六:最长公共子序列LCS

两个字符串的最长公共非连续子串,称为最长公共子序列。

步骤一:LCS问题的最优子结构性

设A=“a0,a1,…,am”,B=“b0,b1,…,bn”,且Z=“z0,z1,…,zk”为它们的最长公共子序列。不难证明有以下性质:
如果am=bn,则zk=am=bn,且“z0,z1,…,z(k-1)”是“a0,a1,…,a(m-1)”和“b0,b1,…,b(n-1)”的一个最长公共子序列;
如果am!=bn,则若zk!=am,蕴涵“z0,z1,…,zk”是“a0,a1,…,a(m-1)”和“b0,b1,…,bn”的一个最长公共子序列;
如果am!=bn,则若zk!=bn,蕴涵“z0,z1,…,zk”是“a0,a1,…,am”和“b0,b1,…,b(n-1)”的一个最长公共子序列。

步骤二:递推关系式

涵盖了步骤一中的所有情况。

步骤三:LCS的求解

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
public static int[][] longestCommonSubsequence(String str1, String str2) {
int[][] c = new int[str1.length() + 1][str2.length() + 1];//建立二维矩阵

//数组b只是为了方便理解,可以去掉
char[][] b = new char[str1.length() + 1][str2.length() + 1];//建立二维矩阵

// 初始化边界条件
for (int i = 0; i <= str1.length(); i++) {
c[i][0] = 0;//每行第一列置零
}
for (int j = 0; j <= str2.length(); j++) {
c[0][j] = 0;//每列第一行置零
}
// 填充矩阵
for (int i = 1; i <= str1.length(); i++) {
for (int j = 1; j <= str2.length(); j++) {
if (str1.charAt(i - 1) == str2.charAt(j - 1)) {
c[i][j] = c[i - 1][j - 1] + 1;
b[i][j]="↖";
}
else if(c[i-1,j]>=c[i,j-1])
{
c[i][j] = c[i - 1][j];
b[i][j]="↑";
}
else if(c[i-1,j]<=c[i,j-1])
{
c[i][j] = c[i][j-1];
b[i][j]="←";
}
}
}
return c;
}
}

说明:

c[i] [j]的含义是:x序列的前i个元素形成的子序列和y序列的前j个元素形成的子序列的最大公共子序列的值。

步骤四:构造出该LCS

由步骤三我们只是得出了LCS的长度,没有得出具体的LCS中的元素。

但是步骤三中得到了c这个二维数组,依照其元素的值:

反序,从b[m,n]处开始,沿箭头在表格中向上追踪。每当在表项b[i,j]中:

  • 遇到一个“↖”时,意味着xi=yj是LCS的一个元素,下一步继续在b[i-1,j-1]中寻找上一个元素;
  • 遇到“←”时,下一步到b[i,j-1]中寻找上一个元素;
  • 遇到“↑”时,下一步到b[i-1,j]中寻找上一个元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//根据矩阵输出LCS
public static void print(int[][] opt, String X, String Y, int i, int j) {
if (i == 0 || j == 0) {
return;
}
if (X.charAt(i - 1) == Y.charAt(j - 1)) {
print(opt, X, Y, i - 1, j - 1);
System.out.print(X.charAt(i - 1));
} else if (opt[i - 1][j] >= opt[i][j]) {
print(opt, X, Y, i - 1, j);
} else {
print(opt, X, Y, i, j - 1);
}
}

问题七:最优二叉搜索树(有点类似于树塔取数问题)

二叉搜索树(二分检索树):

二叉搜索树T是一棵二元树,它或者为空,或者其每个结点含有一个可以比较大小的数据元素,且有:

  • T的左子树的所有元素比根结点中的元素小;
  • T的右子树的所有元素比根结点中的元素大;
  • T的左子树和右子树也是二叉搜索树。

不失一般性,这里假设所有元素互异

最优二叉搜索树的定义:

给定一个n个关键字的已排序的序列K=<k1,k2,…,kn>(不失一般性,设k1<k2<…<kn),对每个关键字ki,都有一个概率pi表示其被搜索的频率。根据ki和pi构建一个二叉搜索树T,每个ki对应树中的一个结点。对搜索对象x,在T中可能找到、也可能找不到:

  • 若x等于某个ki,则一定可以在T中找到结点ki,称为成功搜索。
  • 成功搜索的情况一共有n种,分别是x恰好等于某个ki
  • 若x<k1 、或x>kn、或ki<x<ki+1 (1≤i<n), 则在T中搜索x将失败,称为失败搜索。
    • 为此引入外部结点d0,d1,…,dn,用来表示不在K中的值,称为伪关键字。
    • 伪关键字在T中对应外部结点,共有n+1个。——扩展二叉树:内结点表示关键字ki,外结点(叶子结点)表示di
    • 这里每个di代表一个区间。(d0表示所有小于k1的值,dn表示所有大于kn的值,对于i=1,…,n-1,di表示所有在ki和ki+1之间的值。)
    • **每个di也有一个概率qi**,表示搜索对象x恰好落入区间di的频率。

二叉搜索树的期望搜索代价:

​ 对于特定搜索对象,搜索过程是从根开始到某个结点的检索过程。成功搜索结束于内结点,不成功搜索结束于外部结点。

记depthT(i)为结点i在T中的深度(根到i的路径上的边数)。则从根结点开始访问结点i的数量等于depthT(i) +1;
$$
E(二叉搜索树T的期望代价)= \sum_{i=1}^n(depth_T(K_i)+1)*p_i+\sum_{i=0}^n(depth_T(d_i)+1)*q_i
$$

$$
=1+\sum_{i=1}^ndepth_T(K_i)*p_i+\sum_{i=0}^ndepth_T(d_i)*q_i
$$

即:加权平均代价,包括所有成功搜索的结点和失败搜索的结点。

对于给定的关键字及其概率集合,期望搜索代价最小的二叉搜索树称为其最优二叉搜索树。

问题:如何构造最优二叉搜索树?

步骤一:最优子结构

证明最优二叉搜索树的最优子结构:

如果T是一棵相对于关键字k1,…,kn和伪关键字d0,…,dn的最优二叉搜索树,则T中一棵包含关键字ki,…,kj的子树T’必然是相对于关键字ki,…,kj(和伪关键字di-1,…,dj)的最优二叉搜索子树。

我们可以利用最优二叉搜索树的最优子结构性来构造最优二叉搜索树。

步骤二:递归算法的构建

对给定的关键字ki,…,kj,若其最优二叉搜索(子)树的根结点是kr(i≤r≤j),则kr的左子树中包含关键字ki,…,kr-1及伪关键字di-1,…,dr-1,右子树中将含关键字ki+1,…,kj及伪关键字dr,…,dj

策略:在i≤l≤j的范围内检查所有可能的结点kl

(ki,ki+1,…,kj)的最优二叉搜索树,其中i>=1,j<=n且j>=i−1(当j=i−1时,子树不包含实际关键字,只包含伪关键字di−1).定义e[i,j]为包含关键字(ki,ki+1,…,kj)的二叉搜索树进行一次搜索的期望代价,最后我们希望求解得到e[1,n]。

  • 当j = i - 1时,子树只有伪关键字di-1 ,所以,e[i,j]=qi-1
  • 当j > = i时,从(ki,ki+1,…,kj)中选择根节点Kr
    • 其左子树包含关键字(ki,ki+1,…,kr-1)且是最优二叉搜索子树
    • 其右子树包含关键字(kr+1,kr+2,…,kj)且是最优二叉搜索子树

当一棵树成为另一个节点的子树时:

  • 子树的所有结点的深度+1

  • 根据搜索代价期望值计算公式,子树对根为kr的树的期望搜索代价的贡献是其期望搜索代价+其所含所有结点的概率之和。

    所以,期望搜索代价增加量为:
    $$
    w[i,j]=\sum_{l=i}^jp_l+\sum_{l=i-1}^jq_l
    $$

原因:子树的每个结点的深度增加1。

所以若kr为包含关键字ki,ki+1,…,kj)的最优二叉搜索树的根,则其期望搜索代价e[i,j]与左、右子树的期望搜索代价e[i,r-1]和e[r+1,j]的递推关系为:
$$
e[i,j]=p_r+(e[i,r-1]+w[i,r-1])+(e[r+1,j]+w[r+1,j])
$$
其中,w(i,r-1)和w(r+1,j)是左右子树所有结点的概率之和。且有:
$$
w[i,j]=w[i,r-1]+w[r+1,j]+p_r
$$
所以:
$$
e[i,j]=\begin{cases} q_{i-1}, j=i-1 \\min\limits_{i<=r<=j}(e[i,r-1]+e[r+1,j]+w[i,j]), i<=j \end{cases}
$$

步骤三:计算二叉搜索树的期望搜素代价

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
//p、q为概率列表

private static int[][] Optimal_BST(double[] p, double[] q, int n) {
double[][] e = new double[n+2][n+2];//记录二叉搜索树的期望代价
double[][] w = new double[n+2][n+2];//记录期望搜索代价增加量
int[][] root = new int[n+2][n+2];

//初始化叶子结点的值
for(int i=1;i<=n+1;i++){
e[i][i-1]=q[i-1];
w[i][i-1]=q[i-1];
}
//自底向上的迭代计算
for(int l=1 ; l<=n ; l++){//最外层循环是逐渐的将关键字个数从一个扩展到n个
for(int i=1;i<=n-l+1;i++){
int j=i+l-1;
e[i][j]=Double.MAX_VALUE;
w[i][j]=w[i][j-1]+p[j]+q[j];
for(int r=i;r<=j;r++){
double t = e[i][r-1]+e[r+1][j]+w[i][j];
if(t<e[i][j]){
e[i][j]=t;
root[i][j]=r;///存储根节点的位置
}
}
}
}
return root;
}

步骤四:构建最优解输出

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
CONSTRUCTOR-OPTIMAL-BST(root[][],i,j,r){
int rootChild = root[i][j];//子树根节点
if (rootChild == root[1][n])
{
//输出整棵树的根
print("K"+rootChild+"是根");
CONSTRUCTOR-OPTIMAL-BST(root,i,rootChild - 1,rootChild);
CONSTRUCTOR-OPTIMAL-BST(root,rootChild + 1,j,rootChild);
return;
}
if (j < i - 1)
{
return;
}
else if (j == i - 1)//遇到虚拟键
{
if (j < r)
{
print( "d" + j + "是" + "k" + r + "的左孩子" );
}
else {
print( "d" + j + "是" + "k" + r + "的右孩子" );
}
return;
}
else//遇到内部结点
{
if (rootChild < r)
{
print ("k" + rootChild + "是" + "k" + r + "的左孩子" );
}
else{
print ("k" + rootChild + "是" + "k" + r + "的右孩子" );
}

}

CONSTRUCTOR-OPTIMAL-BST(root[],i,rootChild - 1,rootChild);
CONSTRUCTOR-OPTIMAL-BST(root[],rootChild + 1,j,rootChild);
}

问题八:01背包问题

https://blog.csdn.net/mu399/article/details/7722810?depth_1-utm_source=distribute.pc_relevant.none-task&utm_source=distribute.pc_relevant.none-task

动态规划的具体实现

动态规划和递归

一个人爬楼梯,每次只能爬 1 个或 2 个台阶,假设有 n 个台阶,那么这个人有多少种不同的爬楼梯方法?

746. 使用最小花费爬楼梯 是这道题的换皮题, GrowingIO 前端工程师岗位考察过这个题目。

由于上第 n 级台阶一定是从 n - 1 或者 n - 2 来的,因此 上第 n 级台阶的数目就是 上 (n - 1) 级台阶的数目「加」上 (n - 2) 级台阶的数目

递归代码:

1
2
3
4
5
function climbStairs(n) {
if (n === 1) return 1;
if (n === 2) return 2;
return climbStairs(n - 1) + climbStairs(n - 2);
}

我们继续用一个递归树来直观感受以下:

dynamic-programming-2

红色表示重复的计算

可以看出这里面有很多重复计算,我们可以使用一个 hashtable 去缓存中间计算结果,从而省去不必要的计算。

那么动态规划是怎么解决这个问题呢? 答案也是“查表”,不过区别于递归使用函数调用栈,动态规划通常使用的是 dp 数组,数组的索引通常是问题规模,值通常是递归函数的返回值。递归是从问题的结果倒推,直到问题的规模缩小到寻常。 动态规划是从寻常入手, 逐步扩大规模到最优子结构。

如果上面的爬楼梯问题,使用动态规划,代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
function climbStairs(n) {
if (n == 1) return 1;
const dp = new Array(n);
dp[0] = 1;
dp[1] = 2;

for (let i = 2; i < n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[dp.length - 1];
}

不会也没关系,我们将递归的代码稍微改造一下。其实就是将函数的名字改一下:

1
2
3
4
5
function dp(n) {
if (n === 1) return 1;
if (n === 2) return 2;
return dp(n - 1) + dp(n - 2);
}

dp[n] 和 dp(n) 对比看,这样是不是有点理解了呢? 只不过递归用调用栈枚举状态, 而动态规划使用迭代枚举状态。

动态规划的查表过程如果画成图,就是这样的:

dynamic-programming-3

虚线代表的是查表过程

这道题目是动态规划中最简单的问题了,因为只涉及到单个因素的变化,如果涉及到多个因素,就比较复杂了,比如著名的背包问题,挖金矿问题等。

对于单个因素的,我们最多只需要一个一维数组即可,对于如背包问题我们需要二维甚至更高维度的数组。

爬楼梯我们并没有必要使用一维数组,而是借助两个变量来实现的,空间复杂度是 O(1)。代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function climbStairs(n) {
if (n === 1) return 1;
if (n === 2) return 2;

let a = 1;
let b = 2;
let temp;

for (let i = 3; i <= n; i++) {
temp = a + b;
a = b;
b = temp;
}

return temp;
}

之所以能这么做,是因为爬楼梯问题的状态转移方程中当前状态只和前两个状态有关,因此只需要存储这两个即可。 动态规划问题有很多这种讨巧的方式,这个技巧叫做滚动数组。

再次强调一下:

  • 如果说递归是从问题的结果倒推,直到问题的规模缩小到寻常。 那么动态规划就是从寻常入手, 逐步扩大规模到最优子结构。
  • 记忆化递归和动态规划没有本质不同。都是枚举状态,并根据状态直接的联系逐步推导求解。
  • 动态规划性能通常更好。 一方面是递归的栈开销,一方面是滚动数组的技巧。

动态规划的三个要素

  1. 状态转移方程
  2. 临界条件
  3. 枚举状态

可以看出,用递归解决也是一样的思路

在上面讲解的爬楼梯问题中,如果我们用 f(n) 表示爬 n 级台阶有多少种方法的话,那么:

1
2
f(1)f(2) 就是【边界】
f(n) = f(n-1) + f(n-2) 就是【状态转移公式】

我用动态规划的形式表示一下:

1
2
dp[0] 与 dp[1] 就是【边界】
dp[n] = dp[n - 1] + dp[n - 2] 就是【状态转移方程】

可以看出两者是多么的相似。

实际上临界条件相对简单,大家只有多刷几道题,里面就有感觉。困难的是找到状态转移方程和枚举状态。这两个核心点的都建立在已经抽象好了状态的基础上。比如爬楼梯的问题,如果我们用 f(n) 表示爬 n 级台阶有多少种方法的话,那么 f(1), f(2), … 就是各个独立的状态

不过状态的定义都有特点的套路。 比如一个字符串的状态,通常是 dp[i] 表示字符串 s 以 i 结尾的 ….。 比如两个字符串的状态,通常是 dp[i][j] 表示字符串 s1 以 i 结尾,s2 以 j 结尾的 ….。

当然状态转移方程可能不止一个, 不同的转移方程对应的效率也可能大相径庭,这个就是比较玄学的话题了,需要大家在做题的过程中领悟。

搞定了状态的定义,那么我们来看下状态转移方程。

状态转移方程

爬楼梯问题由于上第 n 级台阶一定是从 n - 1 或者 n - 2 来的,因此 上第 n 级台阶的数目就是 上 (n - 1) 级台阶的数目「加」上 (n - 2) 级台阶的数目

上面的这个理解是核心, 它就是我们的状态转移方程,用代码表示就是 f(n) = f(n - 1) + f(n - 2)

实际操作的过程,有可能题目和爬楼梯一样直观,我们不难想到。也可能隐藏很深或者维度过高。 如果你实在想不到,可以尝试画图打开思路,这也是我刚学习动态规划时候的方法。当你做题量上去了,你的题感就会来,那个时候就可以不用画图了。

状态转移方程实在是没有什么灵丹妙药,不同的题目有不同的解法。状态转移方程同时也是解决动态规划问题中最最困难和关键的点,大家一定要多多练习,提高题感。接下来,我们来看下不那么困难,但是新手疑问比较多的问题 - 如何枚举状态

如何枚举状态

前面说了如何枚举状态,才能不重不漏是枚举状态的关键所在。

  • 如果是一维状态,那么我们使用一层循环可以搞定。
  • 如果是两维状态,那么我们使用两层循环可以搞定。
  • 。。。

这样可以保证不重不漏。

但是实际操作的过程有很多细节比如:

  • 一维状态我是先枚举左边的还是右边的?(从左到右遍历还是从右到左遍历)
  • 二维状态我是先枚举左上边的还是右上的,还是左下的还是右下的?
  • 里层循环和外层循环的位置关系(可以互换么)
  • 。。。

其实这个东西和很多因素有关,很难总结出一个规律,而且我认为也完全没有必要去总结规律。不过这里我还是总结了一个关键点,那就是:

  • 如果你没有使用滚动数组的技巧,那么遍历顺序取决于状态转移方程。比如:
1
2
for i in range(1, n + 1):
dp[i] = dp[i - 1] + 1;

那么我们就需要从左到右遍历,原因很简单,因为 dp[i] 依赖于 dp[i - 1],因此计算 dp[i] 的时候, dp[i - 1] 需要已经计算好了。

二维的也是一样的,大家可以试试。

  • 如果你使用了滚动数组的技巧,则怎么遍历都可以,但是不同的遍历意义通常不不同的。比如我将二维的压缩到了一维:
1
2
3
for i in range(1, n + 1):
for j in range(1, n + 1):
dp[j] = dp[j - 1] + 1;

这样是可以的。 dp[j - 1] 实际上指的是压缩前的 dp[i][j - 1]

而:

1
2
3
4
for i in range(1, n + 1):
# 倒着遍历
for j in range(n, 0, -1):
dp[j] = dp[j - 1] + 1;

这样也是可以的。 但是 dp[j - 1] 实际上指的是压缩前的 dp[i - 1][j - 1]。因此实际中采用怎么样的遍历手段取决于题目。我特意写了一个 【完全背包问题】套路题(1449. 数位成本和为目标值的最大数字 文章,通过一个具体的例子告诉大家不同的遍历有什么实际不同,强烈建议大家看看,并顺手给个三连。

  • 关于里外循环的问题,其实和上面原理类似。

这个比较微妙,大家可以参考这篇文章理解一下 0518.coin-change-2

小结

关于如何确定临界条件通常是比较简单的,多做几个题就可以快速掌握。

关于如何确定状态转移方程,这个其实比较困难。 不过所幸的是,这些套路性比较强, 比如一个字符串的状态,通常是 dp[i] 表示字符串 s 以 i 结尾的 ….。 比如两个字符串的状态,通常是 dp[i][j] 表示字符串 s1 以 i 结尾,s2 以 j 结尾的 ….。 这样遇到新的题目可以往上套, 实在套不出那就先老实画图,不断观察,提高题感。

关于如何枚举状态,如果没有滚动数组, 那么根据转移方程决定如何枚举即可。 如果用了滚动数组,那么要注意压缩后和压缩前的 dp 对应关系即可。

动态规划为什么要画表格

动态规划问题要画表格,但是有的人不知道为什么要画,就觉得这个是必然的,必要要画表格才是动态规划。

其实动态规划本质上是将大问题转化为小问题,然后大问题的解是和小问题有关联的,换句话说大问题可以由小问题进行计算得到。这一点是和用递归解决一样的, 但是动态规划是一种类似查表的方法来缩短时间复杂度和空间复杂度。

画表格的目的就是去不断推导,完成状态转移, 表格中的每一个 cell 都是一个小问题, 我们填表的过程其实就是在解决问题的过程,

我们先解决规模为寻常的情况,然后根据这个结果逐步推导,通常情况下,表格的右下角是问题的最大的规模,也就是我们想要求解的规模。

比如我们用动态规划解决背包问题, 其实就是在不断根据之前的小问题A[i - 1][j] A[i -1][w - wj]来询问:

  • 应该选择它
  • 还是不选择它

至于判断的标准很简单,就是价值最大,因此我们要做的就是对于选择和不选择两种情况分别求价值,然后取最大,最后更新 cell 即可。

其实大部分的动态规划问题套路都是“选择”或者“不选择”,也就是说是一种“选择题”。 并且大多数动态规划题目还伴随着空间的优化(滚动数组),这是动态规划相对于传统的记忆化递归优势的地方。除了这点优势,就是上文提到的使用动态规划可以减少递归产生的函数调用栈,因此性能上更好。

Leetcode 5.最长回文子串

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
class Solution {
public String longestPalindrome(String s) {
int len = s.length();
if(len<=1)return s;
char[]chars = s.toCharArray();
int maxLen = 1;
int begin = 0;
// dp[i][j] 表示 s[i, j] 是否是回文串
boolean[][] dp = new boolean[len][len];
for(int i=0;i<len;i++)
{
if(i+1<len)dp[i][i+1]=true;
dp[i][i]=true;
}
for(int j=1;j<len;j++)
for(int i=0;i<j;i++)
{
if(chars[i]!=chars[j])//一头一尾不相等,所以肯定不是回文子串
dp[i][j]=false;
else
{
if(j-i<3)//当子串只有两个或者三个元素的时候,肯定是回文子串
dp[i][j]=true;
else
dp[i][j]=dp[i+1][j-1];
}
if(dp[i][j]==true&&maxLen<j-i+1)//最长子串进行迭代
{
maxLen=j-i+1;begin=i;
}
}
return s.substring(begin,begin+maxLen);
}
}

0 理解Socket

什么是Socket呢?
我们经常把Socket翻译为套接字,Socket是在应用层和传输层之间的一个抽象层,它把TCP/IP层复杂的操作抽象为几个简单的接口供应用层调用已实现进程在网络中通信。
socket起源于UNIX,在Unix一切皆文件哲学的思想下,socket是一种”打开—读/写—关闭”模式的实现,服务器和客户端各自维护一个”文件”,在建立连接打开后,可以向自己文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
套接字是通信的基石,可看作不同主机的进程进行双向通信的端点。
套接字有两种不同的类型:流式套接字和数据报套接字。
套接字可处于阻塞模式或非阻塞模式。

img

img

1 WinSock API

什么是WinSock呢?
WinSock是一套开放的、支持多种协议的Windows下网络编程的接口,是Windows网络编程实时上的标准。
Winsock版本:目前Winsock有两个版本,分别是WinSock1.1和WinSock2.0,使用方法如下:
WinSock1.1:
#include <winsock.h>
#pragma comment(lib, “wsock32.lib”)
WinSock2.0:
#include <winsock2.h>
#pragma comment(lib, “ws2_32.lib”)
WinSock服务是以动态链接库Winsock DLL形式实现的。

通用API函数列表

WinSock API      描述
WSAStartup      Winsock启动
WSACleanup     Winsock停止
WSASetLastError   错误的检查和控制

针对WinSock1.1存在的某些局限,WinSock2提供了许多方面的扩展(如支持多个传输协议的原始套接字、重叠IO模型、服务质量控制等)以支持功能更强大的应用,考虑兼容性,WinSock1.1的API都在WinSock2中保留了下来。

2 阻塞socket

基于TCP的套接字

// 创建套接字
SOCKET socket(int af, int type, int protocol);
// 绑定地址端口
int bind(SOCKET s, const struct sockaddr *addr, socklen_t addrlen);
// 监听客户端
int listen(SOCKET s, int backlog);
// 接收客户端套接字请求
int accept(SOCKET s, struct sockaddr *addr, socklen_t *addrlen);
// 连接服务端套接字
int connect(SOCKET s, const struct sockaddr addr, socklen_t addrlen);
// 发送数据
int send(SOCKET s, const char FAR * buf, int len, int flags);
// 接收数据(阻塞)
int recv(SOCKET s, char
buf, int len, int flags);
// 关闭套接字
int closesocket(SOCKET s);

img

基于UDP的套接字

// 创建套接字
SOCKET socket(int af, int type, int protocol);
// 绑定地址端口
int bind(SOCKET s, const struct sockaddr addr, socklen_t addrlen);
// 发送数据
int sendto (SOCKET s, const char
buf, int len, int flags, const struct sockaddr* to, int tolen);
// 接收数据(阻塞)
int recvfrom(SOCKET s, char* buf, int len, int flags, struct sockaddr* from, int* fromlen);
// 关闭套接字
int closesocket(SOCKET s);

img

总结

在阻塞模式下,在IO操作完成之前,执行操作的WinSock函数会一直等待下去,不会立即返回,这就意味着任意一个线程在某一时刻只能进行一个IO操作,而且应用程序很难同时通过多个建好连接的套接字进行通信。
可见,在默认情况下套接字为阻塞模式。
这种情况下一般采用多线程方式,在不同的线程中进行不同的连接处理来避免阻塞,但是多线程会增加系统开销,而且线程同步会增加复杂度。

3 非阻塞Socket

WinSock API默认为阻塞模式,但是其提供了非阻塞模式套接字,非阻塞模式套接字使用上不如阻塞模式套接字简单,存在一点的难度,但是只要排除了这些困难,它在功能上还是很强大的。
可以使用ioctlsocket将套接字设置为非阻塞模式套接字:
int PASCAL FAR ioctlsocket (
  IN SOCKET s,
  IN long cmd,
  IN OUT u_long FAR *argp);
  // If *argp = 0, blocking is enabled;
  // If *argp != 0, non-blocking mode is enabled.

代码片段(基于TCP)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (INVALID_SOCKET == sock)
{
AfxMessageBox(_T("Create socket failed"));
WSACleanup();
return false;
}

unsigned long lMode = 1;
int retMode = ioctlsocket(sock, FIONBIO, (unsigned long *)& lMode);
if (retMode == SOCKET_ERROR)
{
return false;
}

将一个套接字设置为非阻塞模式后,WinSock API调用会立即返回。大多数情况下,这些调用都会”失败”,并返回一个WSAEWOULDBLOCK错误表示请求的操作在调用期间没有时间完成。由于会不断地返回这个错误,所以程序员需要通过不断地检查函数返回码以判断一个套接字何时可供读写。

C++ Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
While(true)
{
nRec = recv(sock, (char *)pbuf, 1000, 0);
if (nRec == SOCKET_ERROR)
{
int r = WSAGetLastError();
if (r == WSAEWOULDBLOCK)
{
continue;
}
else if (nRec == WSAETIMEDOUT || nRec == WSAENETDOWN)
{
printf("recv failed!\n");
closesocket(sServer);
closesocket(sClient);
WSACleanup();
return -1;
}
}
}

4 套接字IO模型

套接字的阻塞模式和非阻塞模式都存在一定的缺点,会给编程带来一定的麻烦。为了免去这样的麻烦,WinSock提供了集中不同的套接字IO模型对IO进行管理,它们包括:

  • select(选择)
  • WSAAsyncSelect(异步选择)
  • WSAEventSelect(事件选择)
  • Overlaped(重叠)
  • Completion port(完成端口)

4.1 套接字IO模型:select(选择)

select模式是WinSock中最常见的IO模型。通过调用select函数可以确定一个或多个套接字的状态,判断套接字上是否存在数据,或者能否向一个套接字写入数据。有如下好处:
1)、防止应用程序在套接字处于阻塞模式时,在一次IO操作后被阻塞;
2)、防止在套接字处于非阻塞模式中时产生WSAEWOULDBLOCK错误。

函数原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
The **select** function determines the status of one or more sockets, waiting if necessary, to perform synchronous I/O.

int PASCAL FAR select (
IN int nfds,
IN OUT fd_set FAR *readfds,
IN OUT fd_set FAR *writefds,
IN OUT fd_set FAR *exceptfds,
IN const struct timeval FAR *timeout);
nfds - Ignored. The nfds parameter is included only for compatibility with Berkeley sockets.
readfds -Optional pointer to a set of sockets to be checked for readability.
writefds -Optional pointer to a set of sockets to be checked for writability.
exceptfds -Optional pointer to a set of sockets to be checked for errors.
timeout -Maximum time for select to wait, provided in the form of a TIMEVAL structure. Set the timeout parameter to null for blocking operations.


使用该模型时,在服务端(select主要用在服务端处理多个客户请求上)我们可以开辟两个线程,一个线程用来监听客户端的连接请求,另一个用来处理客户端的请求。就这样不需要一个一个客户请求对应一个服务器处理线程,减少了线程的开销。
select允许进程指示内核等待多个事件中的任何一个发生,并仅在有一个或多个时间发生或经历一段指定时间后才唤醒它。select告诉内核对哪些描述子感兴趣以及等待多长时间。这就是所谓的非阻塞模型,就是进程或线程执行此函数时不必非要等待事件的发生,一旦执行肯定返回,以返回值的不同来反映函数的执行情况,如果事件发生则与阻塞方式相同,若事件没有发生则返回一个代码来告知事件未发生,而进程或线程继续执行,所以效率较高。
select本身是会阻塞的,我们可以使用select实现阻塞式套接字(如上),也可以实现异步套接字。我个人对实现异步套接字的理解是:你可以单独使用一个线程来进行你select,也就是说select阻塞你单独的线程,说白了就是让线程来完成异步。
该模型有个最大的缺点就是,它需要一个死循环不停的去遍历所有的客户端套接字集合,询问是否有数据到来,这样,如果连接的客户端很多,势必会影响处理客户端请求的效率,但它的优点就是解决了每一个客户端都去开辟新的线程与其通信的问题。

4.2 套接字IO模型:WSAAsyncSelect(异步选择)

如果有一个模型,可以不用去轮询客户端套接字集合,而是等待系统通知,当有客户端数据到来时,系统自动的通知我们的程序,这就解决了select模型带来的问题了。
于是WSAAsyncSelect模型登场了,WSAAsyncSelect模型就是这样一个解决了普通select模型问题的socket编程模型。它是在有客户端数据到来时,系统发送消息给我们的程序,我们的程序只要定义好消息的处理方法就可以了,用到的函数只要是WSAAsyncSelect。

1
2
3
4
5
6
7
8
9
10
11
The WSAAsyncSelect function requests Windows message-based notification of network events for a socket.
int WSAAsyncSelect(
__in SOCKET s,
__in HWND hWnd,
__in unsigned int wMsg,
__in long lEvent
);
s -A descriptor that identifies the socket for which event notification is required.
hWnd -A handle that identifies the window that will receive a message when a network event occurs.
wMsg -A message to be received when a network event occurs.
lEvent -A bitmask that specifies a combination of network events in which the application is interested.

WSAAsyncSelect模型将套接字和Windows消息机制很好地粘合在一起,为用户异步SOCKET应用提供了一种较优雅的解决方案。
WSAAsyncSelect模型是非常简单的模型,它解决了普通select模型的问题,但是它最大的缺点就是它只能用在Windows程序上,因为它需要一个接收系统消息的窗口句柄,那么有没有一个模型既可以解决select模型的问题,又不限定只能是Windows程序才能用呢?请看下节。

4.3 套接字IO模型:WSAEventSelect(事件选择)

WSAEventSelect模型是一个不用主动去轮询所有客户端套接字是否有数据到来的模型,它也是在客户端有数据到来时,系统发送通知给我们的程序,但是,它不是发送消息,而是通过事件的方式来通知我们的程序,这就解决了WSAEventSelect模型只能用在Windows程序的问题。
该模型的实现,我们也可以开辟两个线程来进行处理,一个用来接收客户端的连接请求,一个用来与客户端进行通信,用到的主要函数有:WSAEventSelect,WSAWaitForMultipleEvents,WSAEnumNetworkEvents。

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
The WSACreateEvent function creates a new event object.
WSAEVENT WSACreateEvent(void);
The WSAEventSelect function specifies an event object to be associated with the specified set of FD_XXX network events.
int WSAEventSelect(
__in SOCKET s,

__in WSAEVENT hEventObject,
__in long lNetworkEvents
);

The WSAWaitForMultipleEvents function returns when one or all of the specified event objects are in the signaled state, when the time-out interval expires, or when an I/O completion routine has executed.
DWORD WSAWaitForMultipleEvents(
__in DWORD cEvents,

__in const WSAEVENT* lphEvents,
__in BOOL fWaitAll,

__in DWORD dwTimeout,
__in BOOL fAlertable
);

The WSAEnumNetworkEvents function discovers occurrences of network events for the indicated socket, clear internal network event records, and reset event objects (optional).
int WSAEnumNetworkEvents(
__in SOCKET s,

__in WSAEVENT hEventObject,
__out LPWSANETWORKEVENTS lpNetworkEvents
);



The WSACloseEvent function closes an open event object handle.
BOOL WSACloseEvent(
__in WSAEVENT hEvent
);

代码片段(接受客户端请求并注册事件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 全局变量
int g_nTotalConn = 0;
SOCKET g_ClientSocket[MAXIMUM_WAIT_OBJECTS];
WSAEVENT g_ClientEvent[MAXIMUM_WAIT_OBJECTS];
//……
while (TRUE)
{
// Accept a connection
sClient = accept(sListen, (struct sockaddr *)&client, &nAddrSize);
printf("Accepted client:%s:%d\n", inet_ntoa(client.sin_addr), ntohs(client.sin_port));
// Associate socket with network event
g_ClientSocket[g_nTotalConn] = sClient;
g_ClientEvent[g_nTotalConn] = WSACreateEvent();
WSAEventSelect(g_ClientSocket[g_nTotalConn],
g_ClientEvent[g_nTotalConn],
FD_READ | FD_CLOSE);
g_nTotalConn++;
}

代码片段(接受到事件并处理)

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
DWORD WINAPI WorkerThread(LPVOID lpParam)
{
int nRet = 0;
int nIndex = 0;
WSANETWORKEVENTS NetworkEvents;
char szMessage[MSG_SIZE] = {0};
while(TRUE)
{
nRet = WSAWaitForMultipleEvents(g_nTotalConn, g_ClientEvent, FALSE, 1000, FALSE);
//注意这里应该有相应的修正的地方,WSAWaitForMultipleEvents函数在fWaitAll设置成FALSE
//的时候只能指定一个事件对象受信,解决方法使用for循环进行循环检测
if (nRet == WSA_WAIT_FAILED || nRet == WSA_WAIT_TIMEOUT)
{
continue;
}
nIndex = nRet - WSA_WAIT_EVENT_0;
//查看发生了什么网络事件
WSAEnumNetworkEvents(g_ClientSocket[nIndex],
g_ClientEvent[nIndex],
&NetworkEvents);
if (NetworkEvents.lNetworkEvents & FD_READ)
{
// Receive message from client
nRet = recv(g_ClientSocket[nIndex], szMessage, MSG_SIZE, 0);
if (nRet == 0 || (nRet == SOCKET_ERROR && WSAGetLastError() == WSAECONNRESET))
{
Cleanup(nIndex);
}
else
{
szMessage[nRet] = '\0';
send(g_ClientSocket[nIndex], szMessage, strlen(szMessage), 0);
}
}
if (NetworkEvents.lNetworkEvents & FD_CLOSE)
{
Cleanup(nIndex);
}
}
return 0;
}

该模型通过一个死循环里面调用WSAWaitForMultipleEvents函数来等待客户端套接字对应的Event的到来,一旦事件通知到达,就通过该套接字去接收数据。虽然WsaEventSelect模型的实现较前两种方法复杂,但它在效率和兼容性方面是最好的。

4.4 套接字IO模型:Overlaped(重叠)

以上三种模型虽然在效率方面有了不少的提升,但它们都存在一个问题,就是都预设了只能接收64个客户端连接,虽然我们在实现时可以不受这个限制,但是那样,它们所带来的效率提升又将打折扣,那又有没有什么模型可以解决这个问题呢?
当然有,它就是Overlaped模型。
优点:
1、可以运行在支持Winsock2的所有Windows平台 ,而不像完成端口只是支持NT系统。
2、比起阻塞、非阻塞、select、WSAAsyncSelect以及WSAEventSelect等模型,Overlapped I/O模型使应用程序能达到更佳的系统性能。
因为它和这5种模型不同的是:使用重叠模型的应用程序通知缓冲区收发系统直接使用数据,也就是说,如果应用程序投递了一个10KB大小的缓冲区来接收数据,且数据已经到达套接字,则该数据将直接被拷贝到投递的缓冲区。
而这5种模型种,数据到达并拷贝到单套接字接收缓冲区中,此时应用程序会被告知可以读入的容量。当应用程序调用接收函数之后,数据才从单套接字缓冲区拷贝到应用程序的缓冲区,差别就体现出来了。
3、从《Windows网络编程》中提供的试验结果中可以看到,在使用了P4 1.7G Xero处理器(CPU很强啊)以及768MB的回应服务器中,最大可以处理4万多个SOCKET连接,在处理1万2千个连接的时候CPU占用率才40% 左右(非常好的性能,已经直逼完成端口了^_^),再也不被限制在64个客户端连接数了,而且性能杠杠的!
原理:
概括一点说,重叠模型是让应用程序使用重叠数据结构(WSAOVERLAPPED),一次投递一个或多个Winsock I/O请求。针对这些提交的请求,在它们完成之后,应用程序会收到通知,于是就可以通过自己另外的代码来处理这些数据了。
需要注意的是,有两个方法可以用来管理重叠IO请求的完成情况(就是说接到重叠操作完成的通知):
1、事件对象通知(event object notification)
2、完成例程(completion routines),注意,这里并不是完成端口
我们知道WinSock2扩展中支持重叠IO模型,既然要使用重叠结构,我们常用的send、sendto、recv、recvfrom也都要被WSASend、WSASendto、WSARecv、WSARecvFrom替换掉了,这里只需要注意一点,它们的参数中都有一个Overlapped参数,我们可以假设是把我们的WSARecv这样的操作操作”绑定”到这个重叠结构上,提交一个请求,其他的事情就交给重叠结构去操心,而其中重叠结构又要与Windows的事件对象”绑定”在一起,这样我们调用完WSARecv以后就可以”坐享其成”,等到重叠操作完成以后,自然会有与之对应的事件来通知我们操作完成,然后我们就可以来根据重叠操作的结果取得我们想要德数据了。
WinSock重叠IO的基础是Windows的重叠IO机制。

1
2
3
4
5
6
7
8
9
10
BOOL WINAPI ReadFile(
__in HANDLE hFile,

__out LPVOID lpBuffer,
__in DWORD nNumberOfBytesToRead,

__out LPDWORD lpNumberOfBytesRead,
__in LPOVERLAPPED lpOverlapped

);

如果我们在CreateFile的时候没有使用FILE_FLAG_OVERLAPPED标志,同时在调用ReadFile的时候把lpOverlapped这个参数设置的是null,那么ReadFile这个函数的调用一直要到读取完数据指定的数据后才会返回,如果没读取完,就会阻塞在这里。同样 ,writefile和ReadFile都是这样的。这样在读写大文件的时候,我们很多时间都浪费在等待ReadFile和writefile的返回上面。如果ReadFile和WriteFile是往管道里读写数据,那么有可能阻塞得更久,导致程序性能下降。为了解决这个问题,windows引进了重叠io的概念,同样是上面的ReadFile和WriteFile,如果在CreateFile的时候设置了file_flag_overlapped ,那么在调用ReadFile和WriteFile的时候就可以给他们最后一个参数传递一个overlapped结构。这样ReadFile或者WriteFile的调用马上就会返回,这时候你可以去做你要做的事,系统会自动替你完成ReadFile或者WriteFile,在你调用了ReadFile或者WriteFile后,你继续做你的事,系统同时也帮你完成ReadFile或WriteFile的操作,这就是所谓的重叠。使用重叠io还有一个好处,就是你可以同时发出几个ReadFile或者WriteFile的调用,然后用WaitForSingleObject或者WaitForMultipleObjects来等待操作系统的操作完成通知,在得到通知信号后,就可以用GetOverlappedResult来查询IO调用的结果。

如果我们在CreateFile的时候没有使用FILE_FLAG_OVERLAPPED标志,同时在调用ReadFile的时候把lpOverlapped这个参数设置的是null,那么ReadFile这个函数的调用一直要到读取完数据指定的数据后才会返回,如果没读取完,就会阻塞在这里。同样 ,writefile和ReadFile都是这样的。这样在读写大文件的时候,我们很多时间都浪费在等待ReadFile和writefile的返回上面。如果ReadFile和WriteFile是往管道里读写数据,那么有可能阻塞得更久,导致程序性能下降。为了解决这个问题,windows引进了重叠io的概念,同样是上面的ReadFile和WriteFile,如果在CreateFile的时候设置了file_flag_overlapped ,那么在调用ReadFile和WriteFile的时候就可以给他们最后一个参数传递一个overlapped结构。这样ReadFile或者WriteFile的调用马上就会返回,这时候你可以去做你要做的事,系统会自动替你完成ReadFile或者WriteFile,在你调用了ReadFile或者WriteFile后,你继续做你的事,系统同时也帮你完成ReadFile或WriteFile的操作,这就是所谓的重叠。使用重叠io还有一个好处,就是你可以同时发出几个ReadFile或者WriteFile的调用,然后用WaitForSingleObject或者WaitForMultipleObjects来等待操作系统的操作完成通知,在得到通知信号后,就可以用GetOverlappedResult来查询IO调用的结果。

举个例子:
你想当你有这样一个请求,就是
readfile(…) //1
writefile(…) //2
readfile(…) //3
你在程序中如果使用同步的话,那只有当你完成1以后2才会继续执行,2执行完以后3才会继续执行,这就是同步。
当如果使用异步的话,当系统遇到1时,ok,开一线程给它去完成该io请求,然后系统继续运行2,3,分别开两线程。 1-2-3如果是比较耗时的操作,尤其是运用在网络上,那么1-2-3这三个io请求是并行的,也就是重叠的。

4.4.1 基于事件通知的重叠I/O模型

The WSARecv function receives data from a connected socket.
int WSARecv(
__in SOCKET s,
__in_out LPWSABUF lpBuffers,
__in DWORD dwBufferCount,
__out LPDWORD lpNumberOfBytesRecvd,
__in_out LPDWORD lpFlags,
__in LPWSAOVERLAPPED lpOverlapped,
__in LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

C++ Code

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
while(1)
{
Flags = 0;
rc = WSARecv(ConnSocket, &DataBuf, 1, &RecvBytes, &Flags, &RecvOverlapped, NULL);
if ( (rc == SOCKET_ERROR) && (WSA_IO_PENDING != (err = WSAGetLastError())))
{
fprintf(stderr, "WSARecv failed: %d\n", err);
break;
}

rc = WSAWaitForMultipleEvents(1, &RecvOverlapped.hEvent, TRUE, INFINITE, TRUE);
if (rc == WSA_WAIT_FAILED)
{
fprintf(stderr, "WSAWaitForMultipleEvents failed: %d\n", WSAGetLastError());
break;
}

rc = WSAGetOverlappedResult(ConnSocket, &RecvOverlapped, &RecvBytes, FALSE, &Flags);
if (rc == FALSE)
{
fprintf(stderr, "WSARecv operation failed: %d\n", WSAGetLastError());
break;
}

printf("Read %d bytes\n", RecvBytes);
WSAResetEvent(RecvOverlapped.hEvent);
// If 0 bytes are received, the connection was closed
if (RecvBytes == 0 )
break;
}

4.4.2 基于完成例程的重叠I/O模型

完成例程(Completion Routine)并非是大家所常听到的”完成端口”(Completion Port),而是另外一种管理重叠I/O请求的方式。
如果你想要使用重叠I/O机制带来的高性能模型,又懊恼于基于事件通知的重叠模型要收到64个等待事件的限制,还有点畏惧完成端口稍显复杂的初始化过程,那么”完成例程”无疑是你最好的选择!^_^因为完成例程摆脱了事件通知的限制,可以连入任意数量客户端而不用另开线程,也就是说只用很简单的一些代码就可以利用Windows内部的I/O机制来获得网络服务器的高性能。
而且个人感觉”完成例程”的方式比重叠I/O更好理解,因为就和我们传统的”回调函数”是一样的,也更容易使用一些,推荐!
基于事件通知的重叠I/O模型,在你投递了一个请求以后(比如WSARecv),系统在完成以后是用事件来通知你的,而在完成例程中,系统在网络操作完成以后会自动调用你提供的回调函数,区别仅此而已,是不是很简单呢?
采用完成例程的服务端,通信流程是这样的:

img

从图中可以看到,服务器端存在一个明显的异步过程,也就是说我们把客户端连入的SOCKET与一个重叠结构绑定之后,便可以将通讯过程全权交给系统内部自己去帮我们调度处理了(该过程见途中灰色部分),我们在主线程中就可以去做其他的事情,边等候系统完成的通知(调用事前注册的完成例程回调函数)就OK,这也就是完成例程高性能的原因所在。
有趣的比方:完成例程的处理过程,也就像我们告诉系统,说”我想要在网络上接收网络数据,你去帮我办一下”(投递WSARecv操作),”不过我并不知道网络数据合适到达,总之在接收到网络数据之后,你直接就调用我给你的这个函数(比如_CompletionProess),把他们保存到内存中或是显示到界面中等等,全权交给你处理了”,于是乎,系统在接收到网络数据之后,一方面系统会给我们一个通知,另外同时系统也会自动调用我们事先准备好的回调函数,就不需要我们自己操心了。
完成例程回调函数原型及传递方式:

1
2
3
4
5
6
Void CALLBACK _CompletionRoutineFunc(
DWORD dwError,             // 标志咱们投递的重叠操作,比如WSARecv,完成的状态是什么
DWORD cbTransferred,         // 指明了在重叠操作期间,实际传输的字节量是多大
LPWSAOVERLAPPED lpOverlapped,   // 参数指明传递到最初的IO调用内的一个重叠结构
DWORD dwFlags            // 返回操作结束时可能用的标志(一般没用)
);

因为我们需要给系统提供一个如上面定义的那样的回调函数,以便系统在完成了网络操作后自动调用,这里就需要提一下究竟是如何把这个函数与系统内部绑定的呢?如下所示,在WSARecv函数中是这样绑定的:最后一个参数

因为我们需要给系统提供一个如上面定义的那样的回调函数,以便系统在完成了网络操作后自动调用,这里就需要提一下究竟是如何把这个函数与系统内部绑定的呢?如下所示,在WSARecv函数中是这样绑定的:最后一个参数

1
2
3
4
5
6
7
8
9
10
The WSARecv function receives data from a connected socket.
int WSARecv(
__in SOCKET s,
__in_out LPWSABUF lpBuffers,
__in DWORD dwBufferCount,
__out LPDWORD lpNumberOfBytesRecvd,
__in_out LPDWORD lpFlags,
__in LPWSAOVERLAPPED lpOverlapped,
__in LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

小结

重叠模型的缺点:它为每一个IO请求都开了一个线程,当同时有1000个请求发生,那么系统处理线程上下文[context]切换也是非常耗时的,所以这也就引发了完成端口模型iocp,用线程池来解决这个问题,这是下节要学习的内容。

4.5 套接字IO模型:Completion port(完成端口)

IOCP(I/O Completion Port,I/O完成端口)是性能最好的一种I/O模型。
它是应用程序使用线程池处理异步I/O请求的一种机制。在处理多个并发的异步I/O请求时,以往的模型都是在接收请求是创建一个线程来应答请求。这样就有很多的线程并行地运行在系统中。而这些线程都是可运行的,Windows内核花费大量的时间在进行线程的上下文切换,并没有多少时间花在线程运行上。再加上创建新线程的开销比较大,所以造成了效率的低下。
Windows Sockets应用程序在调用WSARecv()函数后立即返回,线程继续运行。当系统接收数据完成后,向完成端口发送通知包(这个过程对应用程序不可见)。
应用程序在发起接收数据操作后,在完成端口上等待操作结果。当接收到I/O操作完成的通知后,应用程序对数据进行处理。
完成端口其实就是上面两项的联合使用基础上进行了一定的改进。
一个完成端口其实就是一个通知队列,由操作系统把已经完成的重叠I/O请求的通知放入其中。当某项I/O操作一旦完成,某个可以对该操作结果进行处理的工作者线程就会收到一则通知。而套接字在被创建后,可以在任何时候与某个完成端口进行关联。
众所皆知,完成端口是在Windows平台下效率最高,扩展性最好的IO模型,特别针对于WinSock的海量连接时,更能显示出其威力。其实建立一个完成端口的服务器也很简单,只要注意几个函数,了解一下关键的步骤也就行了。
从本质上说,完成端口模型要求我们创建一个Win32完成端口对象(内核对象),通过指定数量的线程对重叠I/O请求进行管理,以便为已经完成的重叠I/O请求提供服务。要注意的是,所谓”完成端口”,实际是Win32、Windows NT以及Windows 2000采用的一种I/O构造机制,除套接字句柄之外,实际上还可接受其他东西。然而,本文只打算讲述如何使用套接字句柄,来发挥完成端口模型的巨大威力。使用这种模型之前,首先要创建一个I/O完成端口对象,用它面向任意数量的套接字句柄。管理多个I/O请求。要做到这—点,需要调用CreateIoCompletionPort函数。该函数定义如下:

1
2
3
4
5
6
HANDLE CreateIoCompletionPort(
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
DWORD CompletionKey,
DWORD NumberOfConcurrentThreads
);

5 原始套接字

一般情况下程序设计人员主要接触以下两类套接字:
流式套接字(SOCK_STREAM): 面向连接的套接字,对应于 TCP 应用程序。
数据包套接字(SOCK_DGRAM): 无连接的套接字,对应于UDP 应用程序。
这一类套接字为标准套接字。此外,还有一类原始套接字,它是一种对原始网络报文进行处理的套接字。原始套接字的用途主要有:
发送自定义的IP 数据报
发送ICMP 数据报
网卡的侦听模式,监听网络上的数据包。
伪装IP地址。
自定义协议的实现。
原始套接字主要应用在底层网络编程上,同时也是网络黑客的必备手段。eg:sniffer、拒绝服务(DoS)、IP 地址欺骗等都需要在原始套接字的基础上实现。
原始套接字与标准套接字之间的关系如下图所示。标准套接字与网络协议栈的TCP、UDP 层打交道,而原始套接字则与IP层级网络协议栈核心打交道。
网络监听技术很大程度上依赖于SOCKET_RAW。

img

要使用原始套接字,必须经过创建原始套接字、设置套接字选项和创建并填充相应协议头这三个步骤,然后用send、WSASend函数将组装好的数据发送出去。接收的过程也很相似,只是需要用recv或WSARecv函数接收数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SOCKET sock;
Sock=socket (AF_INET, SOCK_RAW, IPPROTO_UDP);
int setsocketopt (SOCKET s, int level, int optname, const char FAR *optval, int optlen);
struct TCP
{
unsigned short tcp_sport;
unsigned short tcp_dport;
unsigned int tcp_seq;
unsigned int tcp_ack;
unsigned char tcp_lenres;
unsigned char tcp_flag;
unsigned short tcp_win;
unsigned short tcp_sum;
unsigned short tcp_urp;
};

raw socket(原始套接字)工作原理与规则
https://blog.csdn.net/bcbobo21cn/article/details/51330174

raw socket(原始套接字)工作原理与规则
https://blog.csdn.net/bcbobo21cn/article/details/51330174