编程我只用CPP 发布的文章

一、UDP协议

UDP协议是一种简单的面向数据报的传输层协议,它不提供差错纠正、队列管理、重复消除、流量控制以及拥塞控制等功能。它只提供最小的差错检测功能,要想保证数据被可靠的投递或正确排序,应用程序必须自己实现这些功能。

因为它是无连接协议,所以它比其他传输层协议使用更少的开销。

UDP的首部如下:

UDP首部共包含8个字节,内容很简单,仅仅包含了源目端口号、报文长度以及校验和这四个元素。其中,长度字段表示的是数据长度和首部长度

二、UDP校验和

UDP校验和要用到一个伪首部,用到来自IP数据报中的部分字段,伪首部的结构如下:

伪头部的目的是让UDP层验证数据是否已经到达正确目的地(即,该IP没有接受地址错误的数据报,也没有给UDP一个其他传输协议的数据报),它不会被发送出去,只用作校验功能。

注意

  1. UDP数据报长度可以是奇数个字节,而校验算法只相加16位(2字节),因此如果UDP长度是奇数时,计算校验和会在末尾补零(但不会被发送出去)。
  2. UDP中的校验和是可选的(强烈建议使用)。因此对于使用UDP的应用层协议,在收到数据时应当先校验校验和。

三、UDP/IP分片

UDP数据报文最大可达2^16=65535个字节,除去首部还剩下65527个字节。但是受限于网络中的MTU等设置,过长的UDP数据报文段将会在IP数据报中分片。例如在一个MTU为1500的网络环境中发送一个长度为3000的UDP报文将会以如下形式发送出去:

注意:

  1. 偏移的单位是8个字节,例如第二片的偏移是185,实际上是第185*8=1480个字节。

一、常用端口号

  • 20/21: FTP端口,20端口用来传输数据,21端口用来处理连接
  • 22: ssh端口,安全shell协议
  • 23: telnet端口,telnet服务协议
  • 25/465/587: smtp端口,发邮件协议
  • 53: dns端口,域名解析协议,基于UDP
  • 80: web服务端口,http超文本传输协议
  • 110/995: pop3端口,收邮件协议
  • 115: sftp端口,安全文件传输协议
  • 123: ntp端口,网络时间协议
  • 143/993: IMAP协议端口,收邮件协议
  • 179: bgp端口,边界网络协议
  • 389: ldap端口,轻型目录存储协议
  • 443: https端口,安全超文本传输协议

二、常用网络命令

2.1 ifconfig、ifup和ifdown

启动和关闭网卡:

> ifconfig eth0 up
> ifconfig eth0 down

设置网卡IP地址:

> ifconfig eth0 192.168.10.2
> ifconfig eth0:1 192.168.20.2 # 多IP
> ifconfig eth0 192.168.10.2/24 # ip地址+掩码位数
> ifconfig eth0 192.168.10.2 netmask 255.255.255.0 # ip地址+子网地址
> ifconfig eth0 192.168.10.2/24 broadcast 192.168.10.255 # 指定广播地址

设置IPv6地址:

# 添加IPv6地址
> ifconfig eth0:1 inet6 add abcd::ff10
> ifconfig eth0:2 inet6 add abcd::ff11
# 删除IPv6地址
> ifconfig eth0 inet6 del abcd::ff10/0
> ifconfig eth0 inet6 del abcd::ff11/0

ifupifdown分别用于开启和关闭网卡:

> ifup eth0 # 开启eth0
> ifdown eth0 # 关闭eth0

2.2 route

添加、删除静态路由:

> route [add|del] -net 192.168.1.0 netmask 255.255.255.0 gw 192.168.2.254

设置默认网关:

> route add default gw 192.168.2.254

打印所有的路由:

> route -n # linux
> route print # windows

2.3 dig和nslookup

查询dns命令,dig会打印出解析过程和协议:

> dig www.baidu.com

nslookup打印所有的结果:

> nslookup -t=CNAME www.baidu.com

2.4 traceroute

查询去往一个主机所经过的路由跳数:

> traceroute www.baidu.com

2.5 telnet

一般用于探测服务或端口是否连接正常:

> telnet 129.168.10.1 443

2.6 ethtool

查看和更改网卡的配置

> ethtool -p eth0 # 测试一个网卡,网卡灯会闪烁
> ethtool -s eth0 # 统计网络信息

2.7 netstat

常用参数:

  • -t: 列出TCP套接字
  • -u: 列出UDP套接字
  • -p: 显示PID和进程名字
  • -l: 列出监听状态的socket
  • -n: 使用IP地址而不是解析的域名

查询端口占用情况:

> netstat -apn | grep 443

查询所有处于监听状态的tcp套接字

> netstat -lt

2.8 ping

探测网络是否连通:

> ping www.baidu.com -c 4 # 探测www.baidu.com,只发送四次

windows下默认是发四个包,如果要持续发包使用-t选项:

> ping www.baidu.com -t

2.9 curl和wget

curl命令用来发送get和post请求:

  • -X [POST|DELETE]: http请求类型
  • -H: 添加头部
  • -d: 数据部分
  • -I: 只显示响应头
> curl www.baidu.com
> curl -X POST -H "Content-Type: application/json" \
          -d '{"name": "maqian", "age": 22}' www.baidu.com

wget多用于下载文件:

> wget www.baidu.com/index.html -O baidu.html # -O选项表示写到文件

一、匿名管道

pipe的基本描述和用法

匿名管道是linux中的一种通信方式,为有血缘关系的进程提供数据通信。相关的api函数为:

#include <unistd.h>
int pipe(int pipefd[2]);

pipe函数传入一个长度为2的int数组,执行成功将在内核区域分配一块内存区域,并设置pipefd为管道的文件描述符。

创建子进程后,子进程也会继承这两个描述符,对于同一个文件描述符来说,父端写,子端就可以读,字段写,父端也可以读。

工作流程可以描述为:

但是由于管道内部实现是用的环形队列,因此父子不能同时对一个管道进行读操作或者写操作,队列是单向的,两端写就有问题。

#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/wait.h>

#define SEND_BUFF "HelloWorld"

#define CLOSE_FD(fd) \
    do {    \
        if (fd != -1) { \
            close(fd);  \
            fd = -1;    \
        }   \
    } while (0)

int main(){
    int fd[2], ret, n;
    char buf[1025];

    ret = pipe(fd);
    if (ret == -1)
        goto _err;

    memset(buf, 0, 1025);
    pid_t pid = fork();

    if (pid == -1) {
        goto _err;
    } else if (pid == 0) {
        // 子端关闭fd1,打开fd0读
        CLOSE_FD(fd[1]);
        n = read(fd[0], buf, 1024);
        if (-1 == n) goto _err;
        printf("child read %d byte data: %s\n", n, buf);
        CLOSE_FD(fd[0]);
    } else {
        // 父端关闭fd0,打开fd1写
        CLOSE_FD(fd[0]);
        n = write(fd[1], SEND_BUFF, sizeof(SEND_BUFF));
        if (n == -1) goto _err;
        printf("parent send %d bytes data: %s\n", 
                sizeof(SEND_BUFF), SEND_BUFF);
        // 等待子进程退出
        wait(0);
        CLOSE_FD(fd[1]);
    }
    return 0;

_err:
    perror("Error");
    CLOSE_FD(fd[0]);
    CLOSE_FD(fd[1]);
    return -1;
}

1.2 非阻塞pipe

管道还有一个函数pipe2提供了更高级的选项,可以使得管道成为非阻塞读:

#define _GNU_SOURCE             /* See feature_test_macros(7) */
#include <fcntl.h>              /* Obtain O_* constant definitions */
#include <unistd.h>

int pipe2(int pipefd[2], int flags);

其中,当flags置为O_NONBLOCK时,管道为非阻塞,从非阻塞的管道读取数据时,如果此时没有数据可读,将返回-1,并设置errno为EAGAIN

修改子进程部分代码为:

if (pid == 0){
    CLOSE_FD(fd[1]);
    while (1) {
        n = read(fd[0], buf, 1024);
        if (-1 == n) {
            if (errno == EAGAIN) {
                printf("READ AGAIN\n");
                // 没有数据可读时,休眠一秒,继续读
                sleep(1);
                continue;
            }
            goto _err;
        }
        printf("child read %d byte data: %s\n", n, buf);
        break;
    }
    CLOSE_FD(fd[0]);
}

父进程在写数据前先睡眠5秒:

else {
    CLOSE_FD(fd[0]);
    sleep(5);
    n = write(fd[1], SEND_BUFF, sizeof(SEND_BUFF));
    // ...
}

运行结果:

1.3 管道数据读取的原则

  1. 如果所有指向管道写端的文件描述符都关闭了,而仍然有进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回0,就像读到文件末尾一样。
  2. 如果有指向管道写端的文件描述符没关闭,而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次read会阻塞,直到管道中有数据可读了才读取数据并返回。
  3. 如果所有指向管道读端的文件描述符都关闭,这时有进程指向管道的写端write,那么该进程会收到信号SIGPIPE,通常会导致进程异常终止。
  4. 如果有指向管道读端的文件描述符没关闭,而持有管道写端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再write会阻塞,直到管道中有空位置了才写入数据并返回。

二、有名管道fifo

2.1 fifo的基本用法

fifo管道是linux文件系统中的一种文件类型,创建在磁盘上,任何进程都可以打开这个文件进行读写。

它可以解决无血缘关系进程的通信问题,创建管道的函数为:

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);

当管道文件被创建之后,就可像文件一样打开它进行读写:

// read.c
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<string.h>
#include<fcntl.h>
#include <errno.h>
#include<unistd.h>

#define FIFO_FILE "fifo"
#define BUFF_SIZE 1024

int main() {
    int ret, fd = -1;
    char buf[BUFF_SIZE] = { 0 };

    ret = mkfifo(FIFO_FILE, 0755);
    if (ret == -1 && errno != EEXIST)
        goto _err;

    fd = open(FIFO_FILE, O_RDONLY);
    if (fd == -1)
        goto _err;

    bzero(buf, BUFF_SIZE);
    ret = read(fd, buf, sizeof(buf) - 1);
    if (ret == -1)
        goto _err;

    printf("read: %s\n", buf);
    close(fd);
    fd = -1;
    
    return 0;
_err:
    perror("Error");
    if (fd > 0) {
        close(fd);
        fd = -1;
    }
    return -1;
}

写管道程序:

// write.c
#include<stdio.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>

#define FIFO_FILE "fifo"

int main(){
    int ret, fd;

    fd = open(FIFO_FILE, O_WRONLY);
    if (fd == -1)
        goto _err;
        
    unlink(FIFO_FILE);

    ret = write(fd, "HelloWorld", 11);
    if (ret == -1)
        goto _err;

    close(fd);
    fd = -1;

    return 0;

_err:
    perror("Error");
    if (fd > 0) {
        close(fd);
        fd = -1;
    }
    return -1;
}

此时运行./read,会创建管道文件,新开一个窗口就可以看到,管道的颜色和普通文件不一样,并且ls的第一个字符是p

默认情况下,读是阻塞的(类似于读文件),直到有数据过来为止,所以./read执行后,会阻塞,直到运行./write之后才会收到数据。

read: HelloWorld

2.2 非阻塞pipe

非阻塞pipe有一个注意事项:当打开的管道没有其他进程也打开的话(即只有当前进程在读,没有其他进程了),直接返回0,不会返回EAGAGIN

// 加上O_NONBLOCK标记
fd = open(FIFO_FILE, O_RDONLY | O_NONBLOCK);
if (fd == -1)
    goto _err;

bzero(buf, BUFF_SIZE);
printf("fd = %d\n", fd);
while (1) {
    ret = read(fd, buf, sizeof(buf) - 1);
    if (ret == -1) {
        if (errno == EAGAIN) {
            printf("READ AGAIN\n");
            sleep(1);
            continue;
        }
        goto _err;
    } else if (ret == 0) {
        printf("read end\n");
        close(fd);
        fd = -1;
        break;
    } else {
        printf("read: %s\n", buf);
    }
}

编译后如果直接执行,程序会直接退出,因为此刻没有其他进程在写,不会返回EAGAIN

一、线程同步的几种方法

多线程主要有以下几种同步方法:

  1. 互斥量
  2. 读写锁
  3. 屏障
  4. 条件变量
  5. 信号量
  6. 自旋锁

二、几种同步方式的比较

同步方式优缺点和适用范围
互斥量最简单的锁,使用临界区的方式锁住代码块,最基础的锁,适用于多线程互斥
读写锁有更高的并行性,只会锁住写锁,读锁共享,适用于读远大于写的场景
屏障适用于线程等待,直到多个线程到达同一点时再继续运行,主要用于同步
条件变量允许线程以无竞争的方式等待特定条件的发生,适用于生产者和消费者场景
信号量通过PV变量的方式,多用于生产者和消费者场景,和条件变量类似
自旋锁被锁住时线程处于忙等,适用于锁被持有的时间短,不希望线程把时间花在重新调度上

三、信号量和条件变量的区别

  1. 条件变量需要用到互斥锁,信号量不用
  2. 信号量是基于PV操作,需要多个信号量配合使用
  3. 信号量可以用于线程和进程,条件变量只能用于线程

一、平衡二叉树

1.1 什么是平衡二叉树

平衡二叉树(AVL树)是二叉搜索树的一种,它是一种高度平衡的二叉树,树中的每一个节点,两个子节点的高度差最多为1。

在最坏的情况下,BST树高度差可以达到n,查找操作时间复杂度也会达到O(n),此时树是一个链式形状,如下图所示:

对这棵树而言,它和链表几乎没有差别,反而比链表多了一些内存占用。而AVL树的出现就是为了解决这种情况,在AVL树中,这些元素将会被排布成:

此时查找一个元素的时间复杂度就是O(lgn),节省了大量的时间。

AVL树所包含的操作和BST树一样:

  • SEARCH:查找某个节点在树中的位置,实现和BST完全一致
  • MINIMUM:求以指定节点为根的树中的最小元素,实现和BST完全一致
  • MAXIMUM:求以指定节点为根的树中的最大元素,实现和BST完全一致
  • PREDECESSOR:求指定节点的前驱节点,实现和BST完全一致
  • SUCCESSOR:求指定节点的后继节点,实现和BST完全一致
  • INSERT:在树中插入节点,实现在BST基础上修改
  • DELETE:删除节点,实现在BST基础上修改
网上绝大部分AVL树的实现都是通过递归来的,为了秉持《算法导论》中的优良传统,也不打算使用递归来实现。而算法导论又没有针对AVL的章节,只在红黑树的课后习题13-3中提到了,并没有实现。除了INSERT和DELETE操作以外,其他操作都和BST树一致,下面就专门针对这两个操作对AVL树进行分析。

1.2 AVL树节点

相对BST树的节点来说,AVL树节点内部多了一个height成员,表示当前节点的高度。每当某个节点的左右子节点高度差超过1时(这个高度差被称为平衡因子,一般是右子节点的高度减去左子节点的高度),就需要对该节点进行旋转调整。

struct avl_tree_node_st {
    int key;
    int height; // 当前节点的高度
    struct avl_tree_node_st *parent;
    struct avl_tree_node_st *lchild;
    struct avl_tree_node_st *rchild;
};

关于初始时节点的高度为0还是为1的问题

大部分情况下,新建一个节点,会将它的高度设置为0,这比较符合主观的逻辑,因为它没有任何子节点。

这种情况下计算一个节点的高度的步骤为:

  1. 如果该节点是叶子节点,得到高度为0
  2. 如果该节点不是叶子节点,得到高度是左右子节点中高度更高的加1

这里要处理的一个特殊情况就是规则1:判断节点是否为叶节点,不是叶节点才能通过规则2计算。如果直接按照规则2来计算的话叶子节点高度是1,和预定条件相违背。

而如果初始化时,设置节点的高度为1,此时计算一个节点的高度就只有一个操作:

  • 它的高度等于其左右子节点中高度更大的加1

不用判断是否为叶子节点,在程序处理中更好处理一点。而实际上,高度对我们来说不重要,重要的是平衡因子

因此,AVL树节点的高度初始时设置为1更好一点。

二、左旋和右旋

AVL树最重要的操作就是左旋和右旋,当一个节点不平衡后,都需要通过这两个操作达到平衡:

对左旋和右旋的描述:

  • 对节点y右旋:y将变成其左子节点x的右子节点,原来x的右子节点β变成y的左子节点。
  • 对节点x左旋:x将变成其右子节点y的左子节点,原来的y的左子节点β变成x的右节点

两个操作是相反的,彼此旋转都可以得到对方,以下是一个左旋的例子:

左旋的代码实现:

static void _left_rotate(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y;
    // x表示左旋节点,y表示x的右子节点
    x = node;
    y = x->rchild;

    // 修改x节点的右子节点指向
    x->rchild = y->lchild;
    if (y->lchild)
        y->lchild->parent = x;

    // 修改父节点使其子节点指向新的x节点
    y->parent = x->parent;
    if (x->parent == NULL)
        t->root = y;
    else if (x->parent->lchild == x)
        x->parent->lchild = y;
    else
        x->parent->rchild = y;

    // 重新设置x和y的关系
    y->lchild = x;
    x->parent = y;

    // 修改旋转后节点的高度
    x->height = NEW_HEIGHT(x);
    y->height = NEW_HEIGHT(y);
}

右旋和左旋基本一致,只是指针方向相反了。

三、二叉树操作

3.1 计算节点高度

获得一个节点的高度是O(1)的,因为节点内部已经有一个height成员,直接拿就行了。

static inline int _node_height(struct avl_tree_node_st *node) {
    if (node == NULL)
        return 0;
    return node->height;
}

而当一个节点更新之后,从这个节点往上的根节点高度都有可能更新,需要重新设置高度,此时更新的操作就是比较左右子节点的高度,在更高的基础上加1,C语言中可以直接使用宏定义完成:

#define NEW_HEIGHT(node)    \
    max(_node_height(node->rchild), _node_height(node->lchild)) + 1

计算某个节点的平衡因子:

static inline int _get_balance(struct avl_tree_node_st *node) {
    if (node == NULL)
        return 0;
    return _node_height(node->rchild) - _node_height(node->lchild);
}

3.2 增加节点

增加节点的前置操作和BST一致,给节点找到一个合适的位置先插入。然后再判断其是否满足平衡的条件,如果不满足,对它进行调整。增加节点有会有下面四种不平衡的情况。

3.2.1 LL型

下面的树中插入元素5导致树失衡,此时元素30的平衡因子为-2

这种情况属于LL型不平衡状态,只要对不平衡的节点30执行右旋操作即可解决不平衡。

3.3.2 LR型

下面的树中插入元素15,此时元素30失衡,平衡因子为-2

这种情况属于LR型不平衡状态,要先对插入元素的父节点10进行左旋,转换成LL型再对30右旋解决平衡。

3.3.3 RR型

下面的树中插入元素55,元素30失衡,平衡因子为2

这种情况属于RR型不平衡状态,对30执行左旋即可恢复平衡。

3.3.4 RL型

在下面的树中,插入45导致30失衡,平衡因子为2

这种情况属于RL型不平衡状态,先对50右旋,转换成RR型再对30左旋,恢复平衡。

3.3.5 插入操作实现

插入操作和BST的插入操作一模一样,只是在插入完成后,新增了一步对节点的平衡操作。

void avl_tree_insert(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y;
    x = t->root;
    y = x;
    while (x != NULL) {
        y = x;
        if (node->key < x->key)
            x = x->lchild;
        else
            x = x->rchild;
    }

    node->parent = y;
    if (y == NULL)
        t->root = node;
    else if (node->key < y->key)
        y->lchild = node;
    else
        y->rchild = node;

    // 平衡节点
    if (y != NULL)
        avl_tree_insert_balance(t, node);
}

3.3.6 插入平衡

上面说过:插入节点后,有可能导致其父节点甚至祖父节点的高度变化。因此,平衡节点时,要从新增节点的父节点开始,一直到根节点,重新更新路径上每个节点的高度并计算平衡因子,一旦平衡因子不符合条件,则按照上面的四种情况旋转。

void avl_tree_insert_balance(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y;
    // is_modify用来表示是否被旋转过了,如果旋转过了,旋转操作里面已经更新了高度,无需再更新高度
    int height, balance, is_modify;
    // x是新增节点
    x = node;
    // y是新增节点的父节点,从父节点开始,重新更新节点高度
    y = node->parent;

    while (y) {
        is_modify = 0;
        balance = _get_balance(y);
        if (balance > 1) {
            // 右子树高度高于左子树高度
            x = y->rchild;
            if (x->key < node->key) {
                // RR
                _left_rotate(t, y);
            } else {
                // RL
                _right_rotate(t, x);
                _left_rotate(t, y);
            }
            is_modify = 1;
        } 

        if (balance < -1) {
            // 左子树高度高于右子树高度
            x = y->lchild;
            if (x->key > node->key) {
                // LL
                _right_rotate(t, y);
            } else {
                // LR
                _left_rotate(t, x);
                _right_rotate(t, y);
            }
            is_modify = 1;
        }

        if (is_modify == 0) {
            // 没有执行旋转,判断高度是否有改变
            height = NEW_HEIGHT(y);
            if (height != y->height)
                y->height = height;
            else
                break; // 如果节点高度没有改变的话,也不会再影响父节点的高度,直接跳出循环
        }
       
        y = y->parent;
    }
}

3.3 删除节点

删除节点的操作比插入操作要复杂,同样也是先利用BST中的删除操作,执行元素删除。然后再对删除后的节点进行平衡操作。删除一个元素也是四种失衡场景,也是LL/LR/RR/RL,恢复平衡的方式和增加一致:

3.3.1 LL型

3.3.2 LR型

3.3.3 RR型

3.3.4 RL型

3.3.5 如何确认是哪种类型的旋转

删除节点失衡后,对失衡节点的旋转和调整和新增节点一模一样。

但是和新增节点不同,新增节点通过判断父节点的高度和平衡因子很容易就能分辨出旋转类型。删除节点后,当发现失衡时当前节点已经是失衡节点了,并不能直接确定属于哪种失衡。

处理办法:假设失衡节点是z,令yz的子节点中高度更高的一个,令xy的子节点中高度更高的一个,例如:

通过比较三者的key值就可以确定旋转的类型,此处y.key > z.key,属于R型,而x.key > y.key,也是R型。两者结合起来就是RR型,元素30应该执行左旋。

特殊情况

z失衡时,左右子节点的高度必定不同。但是y的两个子节点高度有可能相同,这样就会产生两组特殊情况:

此时y的左右节点高度都是一致的,而实际上这种情况属于LL型,应该对30右旋。

相反的,下面这种情况应该属于RR型,要对30左旋:

3.3.6 删除节点后如何更新高度

增加节点后,从增加节点的父节点开始更新高度。但是删除和新增不同,删除都是通过叶子节点替代的方式来处理的,直接从删除节点的父节点开始更新高度不准确。此时可以分为以下几种情形:

删除节点只有一个节点

使用子节点L替换父节点后,L的高度不会增加,但是原来z的父节点高度减少了,要从z的父节点开始更新高度。

删除节点有两个节点且后继节点是删除节点的右儿子

在BST删除一个节点z时,如果节点有两个儿子,那么就要在右子树中找到最小的元素y替换被删除元素的位置。这个最小的元素y有两种情况:yz的右儿子和y不是z的右儿子

yz的右儿子时:

y替代z后,yx的高度都不会变化,同样只要从z的父节点开始更新高度就行了。

但是当y不是z的右儿子时,删除节点z后,x的高度不会变化,从x的新父亲r开始的节点就有可能发生变化,例如下图中的r的高度就减少了1:

此时要从y的原始父节点开始更新高度。并且还要注意的是:被删除节点z的父节点的高度也变化了,修改r的高度之后还要继续向上修改其祖先节点的高度。

3.3.7 删除代码

AVL删除和BST类似,只是多了一个x记录删除后需要开始更新高度的起始节点。

void avl_tree_delete(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y;

    // x记录节点删除后需要开始更新高度的起始节点
    x = node;
    // y是实际要进行替换删除的节点
    y = node->parent;

    if (node->lchild == NULL || node->rchild == NULL) {
        y = node->lchild ? node->lchild : node->rchild;
        _transplant(t, node, y);
        // 只有一个子节点,需要更新高度的节点是被删除节点的父节点
        x = node->parent;
    } else {
        y = tree_minimum(node->rchild);
        x = y;
        if (y != node->rchild) {
            // 不是右子节点,需要更新高度的节点是y的父节点r
            x = y->parent;
            _transplant(t, y, y->rchild);
            y->rchild = node->rchild;
            y->rchild->parent = y;
        }
        _transplant(t, node, y);
        y->lchild = node->lchild;
        y->lchild->parent = y;
    }
    // 删除完成之后,修改各节点高度,调整平衡
    if (x != NULL)
        avl_tree_delete_balance(t, x);
}

3.3.8 更新节点高度和平衡

根据章节3.3.1-3.3.5可以得到删除节点后的平衡处理代码:

void avl_tree_delete_balance(struct avl_tree_st *t, struct avl_tree_node_st *node) {
    struct avl_tree_node_st *x, *y, *z;
    int height, balance, is_modify;

    // z是被删除节点后需要开始更新高度的起始节点
    // z失衡后,y是z的子节点中高度最高的,x是y的子节点中高度较高的
    z = node;

    while (z) {
        is_modify = 0;
        balance = _get_balance(z);

        if (balance > 1) {
            // R型
            y = _node_height(z->rchild) > _node_height(z->lchild) ? z->rchild : z->lchild;
            // 注意>=符号,不是>号
            x = _node_height(y->rchild) >= _node_height(y->lchild) ? y->rchild : y->lchild;
            if (x->key > y->key) {
                // RR
                _left_rotate(t, z);
            } else {
                // RL
                _right_rotate(t, y);
                _left_rotate(t, z);
            }
            is_modify = 1;
        }

        if (balance < -1) {
            y = _node_height(z->rchild) > _node_height(z->lchild) ? z->rchild : z->lchild;
            x = _node_height(y->rchild) > _node_height(y->lchild) ? y->rchild : y->lchild;
            if (x->key < node->key) {
                // LL
                _right_rotate(t, z);
            } else {
                // LR
                _left_rotate(t, y);
                _right_rotate(t, z);
            }
            is_modify = 1;
        }

        if (is_modify == 0) {
            height = NEW_HEIGHT(z);
            z->height = height;
        }

        z = z->parent;
    }
}

四、关于节点增加或删除后更新祖先节点高度的终止条件

当插入或者删除节点的时候,会引起其祖先节点的高度修改,从而产生不平衡。因此,每次增加或者修改元素之后,需要从该节点的父节点开始,在去往根节点的路径上,逐个对每个节点的高度更新。

由于树的高度是左右子节点的高度决定的,那么可以得到一个结论:如果左右子节点的高度没有变化的话,其祖先节点的高度一定不会变化,其平衡因子也不会变化。因此,对于增加节点而言,如果在到根路径上的某个节点高度没有变化的话,就无需再继续向上更新了。例如:

增加2535元素并没有引起其父节点高度的变化,因此其祖父节点的高度也不会变化,在更新完其父节点的高度后就可以终止循环了。

在增加和删除操作中,都有一个is_modify变量来记录该节点是否失衡被旋转了。如果这个字段的值是0,表示该节点没有失衡,没有执行旋转操作,需要更新该节点的高度。如果这个字段是1,表示节点被旋转了,旋转之后的节点高度已经被重新赋值了,无需再次修改。这也就是为什么把计算平衡因子放在计算节点高度之后了,避免重复更新高度。

height的作用就是判断节点高度是否变化了,如果没有变化,根据上面的出的结论,就无需继续遍历祖先节点了,祖先节点的高度一定不会变化,跳出循环即可。

五、参考文档

BST树实现

AVL Tree | Set 1 (Insertion)

AVL Tree | Set 2 (Deletion)

一、二叉搜索树

1.1 什么是二叉搜索树

算法导论中对二叉搜索树(Binary Search Tree, 简称BST)的定义:

设x是二叉搜索树中的一个节点,如果y是x左子树中的一个节点,那么y.key<=x.key。如果y是x右子树中的一个节点,那么y.key>=x.key。

以下两棵树就是二叉搜索树:

其中a是一个包含6个节点,高度为2的二叉搜索树。b是一个包含相同关键字、高度为4的低效二叉树。

注意:树中包含了两个相同的节点5,在实际用途中,很少把相同的关键字放到二叉搜索树中。

二叉搜索树主要的操作:

  • SEARCH:查找某个节点在树中的位置
  • MINIMUM:求以指定节点为根的树中的最小元素
  • MAXIMUM:求以指定节点为根的树中的最大元素
  • PREDECESSOR:求指定节点的前驱节点
  • SUCCESSOR:求指定节点的后继节点
  • INSERT:在树中插入节点
  • DELETE:删除节点

定理:二叉搜索树中的以上各个操作都是在O(h)时间内完成。

1.2 树节点定义

二叉搜索树的节点需要包含以下元素:

  • 节点的键值,也就是key
  • 父节点和左右子节点的指针

假设key值类型为int,那么树节点的结构应该为:

struct bst_tree_node_st {
    int key;
    struct bst_tree_node_st *lchild;
    struct bst_tree_node_st *rchild;
    struct bst_tree_node_st *parent;
};

二、二叉搜索树操作

2.1 查找操作( SEARCH)

通过二叉搜索树的性质很容易得到查找算法:从树根开始查找,沿着树的路径一直向下。对每个遇到的节点x,比较关键字k和x.key,如果两个关键字相等,查找终止。如果k小于x.key,在x的左子树中继续查找。否则就在x的右子树中继续查找。

伪代码如下(来自《算法导论》):

TREE-SEARCH(x, k)
    if x == NIL or k == x.key
        return x
    if k < x.key
        return TREE-SEARCH(x.left, k)
    else return TREE-SEARCH(x.right, k)

上面采用了递归的方法来进行查找,递归最明显的特点就是简单,代码量小。但是在树高度很高时候,递归就会产生巨大的内存消耗和更多的CPU消耗,因为要频繁对函数及参数压栈,并且函数中栈是有大小限制的,不能无限递归。

大部分时候建议使用非递归查找,非递归查找的伪代码实现为:

ITERATIVE-TREE-SEARCH(x, k)
    while x ≠ NIL and k ≠ x.key
        if k < x.key
            x = x.left
        else x = x.right
    return x

非递归对应的C语言描述:

struct bst_tree_node_st *bst_tree_search(struct bst_tree_st *t, int key) {
    struct bst_tree_node_st *p = t->root;
    while (p != NULL && key != p->key) {
        if (key < p->key)
            p = p->lchild;
        else
            p = p->rchild;
    }
    return p;
}

2.2 求最大元素(MAXIMUM)和最小元素(MINIMUM)

根据二叉树的性质可知:最小的节点一定是最左边的节点,最大的节点一定是最右边的节点。

例如对下面这个二叉树而言,最小的元素是2,最大的元素是20

图2.2.1

找最小的元素只要一直在左子节点中查找,直到节点的左节点是NULL为止。相反,找最大的元素,只要在节点的右节点中查找,直到右节点是NULL为止。

/*
 * 得到某个子树中的最小元素节点
 * @root 待查找的子树根节点
 * @return 返回最小元素节点的指针,如果最小元素就是node,那么返回node
 */
struct bst_tree_node_st *bst_tree_minimum(struct bst_tree_node_st *root) {
    struct bst_tree_node_st *p = root;
    while (p != NULL && p->lchild != NULL)
        p = p->lchild;
    return p;
}

/*
 * 得到某个子树中的最大元素节点
 * @root 待查找的子树根节点
 * @return 返回最大元素节点的指针,如果最大的元素就是node,那么返回node
 */
struct bst_tree_node_st *bst_tree_maximum(struct bst_tree_node_st *root) {
    struct bst_tree_node_st *p = root;
    while (p != NULL && p->rchild != NULL)
        p = p->rchild;
    return p;
}

2.3 求前驱(PREDECESSOR)和后继(SUCCESSOR)

节点x的前驱节点是指中序遍历中顺序在x之前的元素,节点x的后继节点指中序遍历中顺序在x之后的元素。

以2.3节中的二叉树为例,其中序遍历的结果为:2 3 4 6 7 9 13 15 17 18 20。对于元素6而言,它的前驱节点是4,后继节点是7

图2.2.1

求前驱节点的过程:

  • 如果节点有左子节点,那么前驱节点一定是左子树中的最大元素。例如6的前驱是415的前驱是13
  • 如果节点没有左子节点,那么它的前驱就是以它或者以它任一祖先为右儿子的节点,例如上图中节点9的前驱是7,节点7的前驱是6

求后继节点的过程:

  • 如果节点有右子节点,那么后继节点一定是右子树中最小的元素。例如15的后继节点是1718的后继节点是20
  • 如果节点没有右子节点,那么它的后继就是以它或者以它任一祖先为左儿子的节点,例如上图中的13的后继节点是1517的后继节点是18
可以得到的一个结论:树中最小的元素没有前驱节点,树中最大的元素没有后继节点。

求前驱节点和后继节点的C语言描述:

/*
 * 求树中某个节点的后继节点
 * @node 当前节点,如果没有后继节点,返回NULL
 */
struct bst_tree_node_st *bst_tree_successor(struct bst_tree_node_st *node) {
    struct bst_tree_node_st *x = node, *p;
    if (x->rchild)
        return bst_tree_minimum(x->rchild);

    p = x->parent;
    while (p != NULL && p->rchild == x) {
        x = p;
        p = x->parent;
    }
    return p;
}

/*
 * 求树中某个节点的前驱节点
 * @node 当前节点,如果没有前驱节点,返回NULL
 */
struct bst_tree_node_st *bst_tree_predecessor(struct bst_tree_node_st *node) {
    struct bst_tree_node_st *x = node, *p;
    if (x->lchild)
        return bst_tree_maximum(x->lchild);

    p = x->parent;
    while (p != NULL && p->lchild == x) {
        x = p;
        p = p->parent;
    }
    return p;
}

2.4 插入节点(INSERT)

插入节点和搜索节点的工作方式一样,先根据元素大小找到合适的位置,然后作为子节点插入,每次插入的节点必定是叶子节点

例如在下面的树中插入元素13,首先要找到它的父节点15,然后作为它的左子节点插入:

C语言实现:

/*
 * 在树中插入节点
 * @t 树
 * @node 新节点
 */
void bst_tree_insert(struct bst_tree_st *t, struct bst_tree_node_st *node) {
    struct bst_tree_node_st *x, *y;
    y = NULL;
    x = t->root;
    // 找到合适的插入位置
    while (x != NULL) {
        y = x;
        if (node->key < x->key)
            x = x->lchild;
        else
            x = x->rchild;
    }
    node->parent = y;
    if (y == NULL)
        t->root = node;
    else if (node->key < y->key)
        y->lchild = node;
    else
        y->rchild = node;
}

2.5 删除节点(DELETE)

2.5.1 删除节点的基本操作

相对来说,删除节点要比插入节点复杂许多,它有三种不同的情况:

  1. 如果待删除节点z没有孩子节点,那么直接把它删除,修改父节点指针即可完成。
  2. 如果待删除节点z只有一个孩子,那么将这个孩子替换到z的位置,修改z的父节点指针,指向z的子节点。
  3. 如果待删除节点z有两个孩子,找到z的后继节点y,让y占据z的位置,同时还要让y的右子节点(如果有的话)占据y的位置。

前面两种情况相对简单,甚至可以被合并成一种情况。而第三种情况最为复杂,它还能继续向下分解:

  • z的后继节点y就是z的右子节点
  • z的后继节点y不是z的右子节点,这种情况下可以推断出y一定不含左子节点,但此时y的右子节点x又有两种情况:

    • x为空
    • x不为空

下面分别对以上几种情况进行讨论:

2.5.2 删除节点只有一个儿子

只有左子节点的情况

只有右子节点的情况

不管是左儿子,还是右儿子,直接替换原删除节点即可。

2.5.3 删除节点有两个儿子且后继节点是右儿子

对应情况:

只需要把z的位置替换成y就可以了。

2.5.4 删除节点有两个儿子且后继节点不是右儿子

后继节点y不是z的右儿子,那么y必定没有左儿子(如果y有左儿子的话,那么z的后继节点肯定就是y的左儿子而不会是y)。

情况对应下图:

此时要做的两步操作:

  1. y的右儿子(不管是否为NULL)替换成y
  2. y替换成z

2.5.4 删除节点的代码

添加一个辅助操作TRANSPLANT,英文意思是移植,表示把一个节点用另一个节点代替:

static void transplant(struct bst_tree_st *t,
    struct bst_tree_node_st *old_node,
    struct bst_tree_node_st *new_node) {
    if (old_node->parent == NULL)
        t->root = new_node;
    else if (old_node == old_node->parent->lchild)
        old_node->parent->lchild = new_node;
    else
        old_node->parent->rchild = new_node;
    
    // 如果替换新节点不是NULL,设置新节点的父指针指向
    if (new_node != NULL)
        new_node->parent = old_node->parent;

然后在TREE_DELETE操作中调用TRANSPLANT完成删除操作:

/*
 * 删除节点
 * @t 树
 * @node 待删除的节点
 */
void bst_tree_delete(struct bst_tree_st *t, struct bst_tree_node_st *node) {
    struct bst_tree_node_st *y;
    if (node->lchild == NULL || node->rchild == NULL)
        // 至少有一个子节点为空,直接用子节点替换
        _transplant(t, node, node->lchild ? node->lchild : node->rchild);
    else {
        // 得到右子树中的最小节点,即节点在右子树中的后继节点
        y = bst_tree_minimum(node->rchild);
        if (y->parent != node) {
            // y不是删除节点的子节点,用y的右节点替换y
            _transplant(t, y, y->rchild);
            y->rchild = node->rchild;
            y->rchild->parent = y;
        }
        // 用y替换删除节点
        _transplant(t, node, y);
        y->lchild = node->lchild;
        y->lchild->parent = y;
    }
}

一、结构体描述

结构体对齐是C/C++优化结构体内存排布的一种机制,它的出现是为了解决跨总线寻址的问题。

例如对于以下结构:

struct stu {
    char a;
    int b;
};

如果不执行结构体对齐,它在内存中的排布应该是这样的:

对于字段b而言,它占四个字节,跨了两个总线地址,如果要取出b的值就要寻址两次。而如果把结构设计成以下形式:

对b的访问就只用寻址一次了,所以,为了减少cpu消耗,编译器会对结构进行优化,会对内存进行对齐。

二、对齐原则

结构体对齐遵循的原则:

  1. 第一个成员的首地址为0,每个成员的首地址是自身大小的整数倍,不够时填充空字节
  2. 结构体总大小是结构体中最大元素的整数倍,存在结构体嵌套时,最大元素是内部的结构体成员而不是这个结构体

2.1 规则一

struct Stu {
    char name[7];
    int age;
    char sex;
    short id;
    int friends;
};
  1. name成员放在Stu成员的最开始,它占了7个字节,位于0-6
  2. age占四个字节,它本应该从第7个字节存放,但是7不能整除age的大小,它被放在第8个字节
  3. sex占一个字节,放在第12位
  4. id占两个字节,本应放在第13字节,但13无法整除2,此时会在id前面填充一个字节对齐
  5. friends放在第16个字节开始

使用vs查看内存分布可以看到,name和sex后面被填充了一个字节:

1>class Stu    size(20):
1>    +---
1> 0    | name
1>      | <alignment member> (size=1)
1> 8    | age
1>12    | sex
1>      | <alignment member> (size=1)
1>14    | id
1>16    | friends
1>    +---

2.2 规则二

struct Nginx {
    struct Stu stu;
    char c;
};

当结构体中包含结构体时,此时最大的元素是name,它对齐后占8个字节,为了维持对齐的特性,会在Nginx成员后面继续填充:

1>class Nginx    size(24):
1>    +---
1> 0    | Stu stu
1>20    | c
1>      | <alignment member> (size=3)
1>    +---

可以看到c后面填充了八个字节。

一、问题描述

搭了一个开源图床,因为备案的原因部署在海外。访问速度太慢准备上CDN(有一个已备案的域名),但是在部署CDN的途中就出现了各种问题。鼓捣了几天终于解决了,记录下踩坑记录和解决方案。

软件环境:

图床是直接用docker部署了,没有再手动去折腾lnmp环境了,别人已经准备好的docer环境拿过来就直接用了。

用一个nginx作为CDN和图床服务的代理客户端,不直接把图床暴露出来了,也是一般服务的部署方式。

平常的服务大部分都是这么干的,用起来很舒服。

这个部署在http环境下是没有问题的,能正常使用。但是把http改成https的时候就出问题了,一开始的问题就是跨域:

因为nginx是配置的http反向代理:proxy_pass http://127.0.0.1:xxxx,到docker中的也是http,所以它返回到前端的js/css资源也是http的。而此时CDN开启了强制https,浏览器中已经是https,跨域就这么产生了。

为了解决这个问题,就在docker的web环境中也加一个强制https跳转,想着apache也强制https,那么就不会返回http资源,跨域解决。

想象是美好的,现实是残酷的,这么一配置之后,CDN就挂了,原因:重定向次数过多

于是分析了一下,这么干确实有问题:

  1. nginx反向代理是用的http,对apache来说它收到了http请求,于是302重定向到https的页面。
  2. 然后CDN就访问重定向的https的页面,但是nginx收到之后又代理到http。

于是乎,CDN和apache之间就产生了某种误会,导致302死循环诞生!因此,这么干不行!得另寻它法。

那么现在的问题就产生了:如何圆润的以https的方式访问到docker内部的资源?

方案一:nginx和apache改成https通信

这个方案要做的工作:

  1. 手动生成https证书
  2. 在apache内部署https
  3. nginx和apache通过https代理

apache不熟悉,看了https部署比nginx要麻烦一些,而且内部通信也用https对性能有影响,所以就不考虑了(虽然这个方法可能比较有效,但是内心比较排斥apache,甚至想把docker中的apache换掉都不想用它配置https)。

方案二:使用nginx的sub_filter替换http为https

验证了一下,不可行,因为实际上返回到客户端的资源文件都是相对文件目录,不是http的全路径资源。

于是就崩了,百度了几天也没有办法解决。最后,在google上搜,竟然一下就找到了解决方案。

二、完美解决方案

设置http头部X-Forwarded-Proto,这个头部的作用是用于识别协议(HTTP 或 HTTPS),主要针对内部访问重定向时的协议。因此,只要在反向代理时添加以下配置就好了:

proxy_set_header X-Forwarded-Proto $scheme;

$scheme是nginx的内部变量,表明当前访问的协议,当前如果是https,那么转发到后台服务的时候就是https。这样问题就解决了。

一、归并排序

归并排序的思想是:依次把数组分成若干个小组进行排序,然后逐步把每个小组进行排序合并。

最经典的介绍归并排序的示例图为:

第一步:把8个元素分别分成4个小组排序,得到1, 2/3, 4/5, 6/7, 8四组。

第二步:把1, 23, 4合并,5, 67, 8合并,得到1, 2, 3, 45, 6, 7, 8两组。

第三部:把剩下的两组合并,得到有序的数组1 2 3 4 5 6 7 8,排序完成

归并排序的时间复杂度为O(lgn)

二、合并两个有序数组

合并两个有序数组需要用到一个辅助数组,保存排序后的元素。具体的逻辑是:分别使用两个指针,指向两个数组的首端,比较两个指针指向的元素,把小的放到辅助数组里面,指针后移,重复上面的操作。

例如上面合并1, 2, 3, 45, 6, 7, 8两组元素,设置指针i和j分别指向两个数组,此时a[i] > b[j],把b[j]放到数组里面去:

然后依次执行,直到b数组中的元素放完:

再把a中所有的元素放到有序数组中去,排序就完成了:

合并部分的代码:

// mid是i的结束位置,right是j的结束位置,tmp是临时数组
while (i <= mid && j <= right) {
    if (data[i] <= data[j])
        tmp[k++] = data[i++];
    else
        tmp[k++] = data[j++];
}

三、归并排序实现


/*
 * @data 待排序数组
 * @tmp 临时数组
 * @left 当前需要排序的区间开始
 * @right 当前需要排序的区间结束
 */
static void _merge_sort(int *data, int *tmp, uint left, uint right) {
    uint mid, i, j, k, cur;
    if (left == right)
        return;

    mid = (left + right) / 2;
    i = left;
    j = mid + 1;
    k = left;
    
    // 分组排序
    _merge_sort(data, tmp, left, mid);
    _merge_sort(data, tmp, mid + 1, right);

    // 合并有序分组
    while (i <= mid && j <= right) {
        if (data[i] <= data[j])
            tmp[k++] = data[i++];
        else
            tmp[k++] = data[j++];
    }

    // 处理两个数组多余的元素
    while (i <= mid)
        tmp[k++] = data[i++];
    while (j <= right)
        tmp[k++] = data[j++];

    // 拷贝已经排好序的到正式需要排序的数组
    for (i = left; i <= right; i++)
        data[i] = tmp[i];
}

/*
 * @data 待排序数组
 * @len 数组长度
 */
void merge_sort(int *data, const uint len) {
    // 为临时数组分配空间
    int *tmp = malloc(sizeof(int) * len);
    if (tmp == NULL)
        return;

    _merge_sort(data, tmp, 0, len - 1);

    // 删除临时数组的空间
    free(tmp);
    tmp = NULL;
}

一、计数排序

其基本思想为:假设n个输入的元素中的每一个都是在0到k之间的一个整数,对于每一个输入元素x,确定小于x的元素个数,直接把x放在它输出的数组中的位置上。例如有17个元素小于x,则x就应该在数组的第18个位置上。当有几个元素相同时,这一方案就要略作修改,不能都放在同一个位置上。

计数排序需要提供两个辅助数组用来计数,一个用来保存已经排好序的元素,一个用来保存每个元素在数组中出现的次数。计数排序是不稳定的排序算法,时间复杂度(n),空间复杂度是O(n + k),其中k是排序数组元素的范围。

例如以下数组A:

在通过计数后,得到的辅助数组C为:

对于元素1而言,它前面有两个0,因此排序后它的位置在第三个,即下标为2的位置上,但是它的数量为0,就不应该给他排序。而元素2是有的,它的位置应该是0的数量和1的数量(如果有元素1的话)之和的后面一位,在这里是第3位,下标为2。这里类似斐波那契数列:每个元素都等于前面两个的和。

因此,有必要对辅助数组C做处理,设置其所在的位置为前面所有元素数量之和,这样就不用每次都重新计算前面的数量了(参考斐波那契数列计算):

要注意的是,对于2这个元素来说,它出现了两次,如果不做特殊处理,它们处的位置都是第四个位置。要想办法把两个2分别放到自己合适的位置上去。可以采取的办法是从后往前排序,每排完一个数字,当前数字的值减一。

图例:

第一步,A的最后一个元素是3,得到C[3]的值为7,把3先放到第7个位置,并把C[3]的值减一:

第二步,现在A的最后一个元素(未排序的)是0,得到C[0]为2,放到第二个位置,C[0]减一:

第三步,重复以上步骤,直到A中所有的元素都排序完毕:

此时,整个数组就是有序的了。

二、计数排序的代码实现

// 数据可能出现的最大值
const unsigned int max_val = 100;
void count_sort(int data[], const unsigned int n) {
    // 计数数组
    int cnt[max_val] = {0};
    // 生成辅助数组
    int *tmp = new int[n];
    unsigned int i;

    // 初始化计数数组
    for (i = 0; i < n; i++) {
        cnt[data[i]]++;
    }

    // 累加所有的数量
    for (i = 1; i < max_val; i++) {
        cnt[i] += cnt[i - 1];
    }

    // 计数排序
    for (i = n - 1; i >= 0 && i < n; i--) {
        tmp[cnt[data[i]] - 1] = data[i];
        cnt[data[i]]--;
    }

    // 拷贝辅助数组到排序数组
    for (i = 0; i < n; i++) {
        data[i] = tmp[i];
    }
}