You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

12 KiB

做好闭环(四):二分答案算法的代码统一结构

你好,我是胡光。

不知不觉,我们已经讲完了“算法数据结构篇”的全部内容。说是“讲完”,其实更意味着你的算法数据结构的学习之路才刚刚开始,因为编程的核心与灵魂就是算法和数据结构。但这毕竟是一个入门课,所以,整个这部分的内容,我更多是侧重说说那些你可能比较陌生的,且有趣的思维与结构。

我希望通过这个过程,能够激起你对于算法数据结构的学习热情。时至今日,我相信你应该更能深刻地理解我在开篇词里说到的,“学编程,不等于学语言“这句话的含义。

我也非常高兴,看到很多同学都在紧跟着专栏更新节奏,坚持学习。经常在专栏上线的第一时间,这些同学就给我留言,提出自己的疑惑。大部分留言,我都在相对应的课程中回复过了,而对于每节课中的思考题呢,由于要给你充足的思考时间,所以我选择在今天这样一节课中,给你进行一一的解答。

看一看我的参考答案,和你的思考结果之间,有什么不同吧。也欢迎你在留言区中,给出一些你感兴趣的题目的思考结果,我希望我们能在这个过程中,碰撞出更多智慧的火花。

重新认识数据结构(上):初识链表结构

在这一节里,我们学习了基本的链表结构,并且演示了链表结构的插入操作。最后呢,给你留了一个题目,就是实现链表的删除操作。留言区中很多人实现的代码,我也都看过了,总的来说,很多用户对“虚拟头结点”的掌握还是很不错的,下面是我给出的参考代码:

struct Node *erase(strcut Node *head, int ind) {
    struct Node ret, *p = &ret, *q;
    ret.next = head;
    while (ind--) p = p->next;
    q = p->next;
    p->next = p->next->next;
    return ret.next;
}

由于删除操作,有可能删除的是 head 所指向链表的头结点,所以代码中使用了虚拟头结点的技巧来实现。其中,细心的你可能会发现一个致命的问题:删除节点的操作中,我们只是改变了链表节点的指向关系,跳过了那个待删除节点的位置,那原先那个待删除节点呢?这个节点的空间呢?

这就涉及到操作系统中的内存管理相关的知识了由于这里不影响编程逻辑的理解所以我们就不展开说了。如果你感兴趣可以自行搜索内存泄漏、malloc、free 等关键字进行学习。

重新认识数据结构(下):有趣的 “链表” 思维

这一节是上一节链表知识的升华,我们将一个快乐数序列,在思维层面映射成了链表结构,之后就将快乐数的判定问题,很自然的转换成了链表判环问题,算是彻彻底底的体验了一把链表思维。最后呢,我留了两个思考题,下面我给你一一解答。

1. 计算环的长度

第一个问题,如果链表中有环的话,那么这个环的长度是多少?这个问题比较简单,我看到留言区中很多用户都能自行想出来,在这里我就简单说一说。

我们可以肯定,如果链表中有环,那么采用快慢指针的方法,两个指针一定会在环中相遇。此时,可以让其中一个指针不动,另外一个指针再沿着环走一圈,直到两个指针再次相遇,这样,就能得到环的长度了。

2. 找到环的起始位置

第二个问题,如果链表中有环,请求出环的起始点。如下图所示,环的起始点为 3 号点。


这里呢,我将用图跟你感性地解答这个问题,请你注意,以下我所要讲的不是一个严格的证明过程,如果想要更准确地理解这个问题,你可以试着借助“同余式”来理解。下面,就开始我们的非严谨图例演示。

首先,假设从链表起始点到环的起点距离为 x那么当快慢指针中的慢指针 p 刚刚走到环的起始点位置的时候q 指针应该在环内部距离环起始点 x 的位置上,如图所示:


图中q 指针距离环起始点 x 步q 指针沿着链表向前走 y 步,就又可以到达环的起始点位置,如图所示 x + y 等于环长。也就是说q 指针想要遇到 p 指针,就必须要追上 y 步的距离,又因为 p 指针每次走 1 步q 指针每轮走 2 步,所以 q 指针每轮追上1步也就是说从此刻开始当 q 指针追上 p 指针的时候p 指针正好向前走了y 步,如图所示:


此时,你会发现 p 点在环中走了 y 步以后p 和 q 相遇了,也就意味着 p 点再走 x 步就到环的起始点了。而恰巧,从链表头结点开始到环的起始点也是 x 步,所以此时只需要让 p 站在相遇点q 指针回到链表的起始点,然后两个指针以相同的速度,一起往后走,直到二者再次相遇的时候,相遇点就是环的起始点了。

至此,我们就看完了求解环起始点位置的方法,至于代码么,就不是难题了,你可以自行发挥了。

二分查找:提升程序的查找效率

这一节中呢,我们学习了简单的二分查找算法,由此我们引申出了二分答案的相关算法。二分答案算法的应用场景呢,就是有一个函数 f(x) = y如果它具有单调性并且通过 x 求 y 很好求,而通过 y 确定 x 就很麻烦,这时,二分答案算法就该登场了。

最后的思考题中呢,是一道通过税后工资,计算税前工资的题目。我们可以发现,根据个人所得税缴纳的规则,肯定是税前工资越高,税后工资就越高,所以我们把税前工资 x 和税后工资 y 之间,看成是一个映射关系 f 的话,那么 f(x) = y 的函数,就是单调函数。

而这个 f 函数呢,我们也可以看到,通过税前工资 x 确定税后工资 y 的过程很简单,而通过税后工资 y 计算税前工资 x 的过程就不那么容易了。因此,我们当然要搬出二分答案算法,来解决这个问题了。下面是我给出的示例代码:

#define EPS 1e-7
#define min(a, b) ((a) < (b) ? (a) : (b))

double f(double x) {
    double xx = min(3000, x) * 0.03;
    if (x > 3000) {
        xx += (min(12000, x) - 3000) * 0.1;
    }   
    if (x > 12000) {
        xx += (min(25000, x) - 12000) * 0.2;
    }
    if (xx > 25000) {
        xx += (min(35000, x) - 25000) * 0.25;
    }
    return x - xx;
}

double binary_search(double l, double r, double y) {
    if (r - l <= EPS) return l;
    double mid = (l + r) / 2.0;
    if (f(mid) < y) return binary_search(mid, r, y);
    return binary_search(l, mid, y);
}

你会发现,代码中的 binary_search 函数,和我们那一讲中所给的切绳子问题的代码几乎一模一样,唯一不同的就是 f 函数换了样子。

其实对于二分答案的算法实现代码真的不是什么难点难点在于发现问题可以采用二分算法的过程。也就是看到那两条性质判断f(x)=y是不是具有单调性是不是通过x求y比较容易通过y求x比较困难。

栈与单调栈:最大矩形面积

本节呢,我们学习了栈和单调栈的基本知识,并且知道了单调栈是用来维护最近大于或小于关系的数据结构。最后的思考题呢,是判断一个括号序列是否是合法的,所谓合法的括号序列,也就是括号之间要么是完全包含,要么是并列无关。

根据栈的基础知识,如果我们把一个元素入栈动作看成是左括号,出栈看成是对应的右括号,那么一组元素的入栈及出栈操作,就可以唯一对应到一个合法的括号序列。例如,如下操作序列:

  1    2   3   4    5    6   7    8   9  10
push push pop pop push push pop push pop pop 

其中 push 是入栈操作pop 是出栈操作。显然3号的 pop 操作,弹出的应该是 2 号push 进去的元素,也就是 2 号和 3 号操作是一对操作。那么把 push 写成左括号pop 写成右括号,如上操作序列,就可以对应如下的括号序列:

【()】{【】【】}

你会发现,相对应的左右括号,就对应了相匹配的 push 和 pop 操作。那么判断一个括号序列是否合法,就可以把这个括号序列看成是一组入栈和出栈操作。

我们依次处理括号序列中的每一位,碰到左括号就入栈;碰到右括号,我们就弹出栈顶的一个左括号,看看是否和当前右括号是匹配的,如果不匹配就说明括号序列非法,如果匹配,就继续处理下一个括号序列中的字符。直到最后,如果栈中为空,就说明原括号序列合法。

好了,至此我们就讲完了这道题目的解题思路,接下来就是请你把我说的解题思路,转换成代码了,加油!如果实在想不出来,也可以参考用户 @胖胖胖 、@Hunter Liu 在留言区中的代码和解题思路。

动态规划(下):动态规划之背包问题与优化

在这一节课我们认识了背包类动态规划算法讲了0/1背包问题以及多重背包问题转0/1背包问题的转换技巧。其中我们提到了用二进制拆分法对多重背包拆分过程进行优化这样不但可以大大减少拆分出来的物品数量并且还不影响转换成等价的 0/1背包问题。

关于动态规划状态定义的相关理解,这里给用户 @徐洲更 点赞,大家可以在《动态规划(上):只需四步,搞定动态规划算法设计》当中看到他的留言。

下面呢,我就给出多重背包转 0/1背包的示例代码

#define MAX_N 100
#define MAX_W 10000
int v[MAX_N + 5], w[MAX_N + 5], c[MAX_N + 5];
int v1[MAX_N + 5], w1[MAX_N + 5], n2 = 0;
int dp[MAX_N + 5][MAX_W + 5];

// 添加一个0/1背包中的物品
void add_item(int v_value, int w_value) {
    n2++;
    v1[n2] = v_value;
    w1[n2] = w_value;
    return ;
}

int get_dp(int n, int W) {
    // 对多重背包中的每一种物品进行拆分
    for (int i = 1; i <= n; i++) {
        // 采用二进制拆分法
        for (int k = 1; k <= c[i]; c[i] -= k, k <<= 1) {
            add_item(k * v[i], k * w[i]);
        }
        if (c[i]) add_item(c[i] * v[i], c[i] * w[i]);
    }
    // 按照0/1背包的方式进行求解
    for (int i = 1; i <= n2; i++) {
        for (int j = 1; j <= W; j++) {
            dp[i][j] = dp[i - 1][j];          
            if (j < w1[i]) continue;
            if (dp[i - 1][j - w1[i]] + v1[i] < dp[i][j]) continue;
            dp[i][j] = dp[i - 1][j - w1[i]] + v1[i];
        }
    }
    return 0;
}    

代码中v、w、c 数组存储的是多重背包中第 i 种物品的价值、重量和数量v1、w1 数组用来存储拆分出来的0/1背包中的物品价值和重量信息get_dp 函数就是求解多重背包问题的过程。

其中分成两步进行求解首先对多重背包中的每种物品按照二进制拆分法打包拆分成0/1背包中的若干个物品。拆分完成后再按照0/1背包的算法流程进行求解需要注意的是代码中的循环变量 k枚举的就是拆分的每一堆的物品数量从数量 1 开始,每次扩大一倍。

对于多重背包中的每种物品,经过二进制拆分以后,最后剩下的几个,要单独算作一个物品,这就是代码第 22 行的含义。理解了二进制拆分的过程以后后面的0/1背包的求解过程就不需要我来解释了都是老生常谈了。

好了,今天的思考题答疑就结束了,如果你还有什么不清楚,或者有更好的想法,欢迎告诉我,我们留言区见!