编程基础:编程的工作流

先规划,后编程

许多新手程序员往往会直接上手编写代码,把编写代码当做第一步。然而,编写代码实际上是整个过程中较后的一个步骤。一个好的程序员会先进行规划,然后再开始编写代码,有时还会将一个大型的编程任务拆分成几个较小的任务。即使被告诫要先规划后编写,很多编程学生也会忽略这个建议;毕竟,如果时间很紧,为什么要 “浪费” 30分钟来规划呢?然而,这种权衡会造成一种虚假的高效。实际上规划30分钟可以节省许多时间来确保代码的正确性(或至少更接近正确),而且还更容易理解和修复。

为了更好地理解在编写代码前进行规划的重要性,我们先想象自己在建造摩天大楼。如果你的任务是建造一座摩天大楼,你会立即开工,边建边想这座建筑的设计吗?希望不是这样。相反,你(或建筑师)会首先设计建筑蓝图。这些蓝图会不断完善,直到满足每个投资人的规格要求,并且具有建成的可行性。即使完成了蓝图,也必须再得到当地政府的批准。只有在计划完全完成并且经过审查后才开始实际建造。编程也应该以类似的方式进行:首先制定完整的计划(算法),然后再开始建造(实现代码)。

我们说编程的核心是要解决一类问题,而不仅仅是一个具体的问题。为了更好地理解这一点,我们可以举一个例子。考虑判断一个特定的数字(比如 77 )是否为质数。如果我们有足够的数学知识(即质数的定义和除法规则),就可以解决这个问题,确定 77 确实是质数。然而,编程问题通常涉及到一个更普遍的问题类。我们通常不会编写一个程序来确定 77 是否为质数,而是编写一个程序,给定一个数字 NN ,判断 NN 是否为质数。一旦我们有了这类问题的算法,就可以让计算机为我们解决该类问题的任何具体实例。

当我们考虑一类问题时,我们有参数(argument)告诉我们:我们正在解决该类中的哪个特定问题。在前面的例子中,问题类是由 NN 参数化的,我们要测试它是否为质数。为了为这类问题开发一个算法,我们必须考虑参数的所有可能的合法值。正如大部分编程语言所设计的,我们可以限制参数的合法数据类型,以将输入值限制为在问题的上下文中有意义的值。对于质数测试,我们希望我们的参数 NN 被限制为只能储存整数,因为检查字母、单词或文件是否为质数是没有意义的。

为了编写一个程序,它可以取任何数字 NN 并确定 NN 是否为质数,我们必须首先找出这类问题的算法。正如我们之前所说,如果我们盲目地编写代码来解决问题,最终会一团糟,就像没有计划地建造一座摩天大楼一样。为一类问题想出普适的算法是一个具有挑战性的任务,这通常需要大量的工作和思考。


七步曲

这张图展示了编程过程的高层次概述。程序员首先制定解决问题的算法。我们将这个规划阶段分为四个步骤,稍后我们将详细讨论。在完成这四个步骤后,程序员应该拥有一个完整的任务计划,并确信这个计划是可行的。

在确定适当的算法之后,他才准备进行编程过程的第五步:将计划翻译成他正在使用的编程语言中的代码。最初,把算法转换成代码会很慢,因为你可能不熟悉语法,需要经常查找特定的细节。但是,即使很慢,这也应该是能够被准确完成的。既然你已经设定了计划,那么你应该已经考虑所有要实际解决的问题。你的算法中可能有一些复杂的步骤,但这没有关系。正如我们稍后将看到的,每当你的算法中有一个过于复杂,无法被简单地转换为几行代码的步骤时,你应该将该步骤视作一个独立的编程任务,并不断地将同样复杂的转化为独立任务来完成。注意,完成这些独立任务也需要进行 “七步曲”。

一旦算法在代码中实现,程序员必须测试他的代码,这是编程过程的第六步。,程序员通过测试程序尽可能地发现他的算法或实现中的错误。如果程序员发现了程序中的错误,他就会进行程序调试(第七步):找出错误的原因并进行修复。程序员可能需要重新设计算法(如果错误在算法中)或修改已有的代码(如果错误在实现中)来纠正错误。然后程序员会再次重复一遍所有后续步骤。

等到程序员完成了足够多的测试用例,没有发现错误,他就确信他的程序是正确的。请注意,我们说的是程序员确信他的程序是正确的。没有任何测试可以保证程序的正确性[1]。不过,更多的测试可以增加程序员对代码正确性的信心。当程序员确信他的代码是正确的时,他就成功完成了手头的任务。


算法

正如我们之前讨论的那样,算法是一组明确的步骤,用于解决特定类别的问题。通常,算法至少有一个参数;不过,也存在没有参数的算法,它们仅仅限于一个特定的问题,而不是一类问题。我们可以在不了解计算机的任何特定知识的情况下讨论和思考算法。一个好的算法不仅可以被转化为代码,也可以被没有特定问题知识的人执行[2]

计算机所执行的算法是为了处理数字。事实上,我们将在类型(types)这个话题讨论“一切都是数字”的概念,这是编程中的一个关键原则。虽然计算机只能对数字进行计算,但是,字母、单词、图像、视频、声音等都可以被表示为数字,让计算机可以对它们进行计算。这里有一个处理数字的简单算法示例(它需要一个参数 NN ,一个非负整数):

1
2
3
4
5
6
Given a non-negative integer N:
Make a variable called x, set it equal to (N + 2)
Count from 0 to N (include both ends), and for each number (call it "i")
that you count:
Write down the value of (x * i) Update x to be equal to (x + i * N)
When vou finish counting, write down the value of x.

对于任何非负整数 NN ,我给你这些步骤,你应该能够执行它们。如果你按照 N=2N = 2 的步骤执行,你应该得到数字序列 {0, 4, 12, 10}\{0,\ 4,\ 12,\ 10\} 。这些步骤非常明确,很容易知道应该发生什么。如果你误解了指令或者算术计算错误,可能会得到错误的答案,但是,对于特定的 NN 值,正确执行这些步骤的所有人应该得到相同的答案。我们还应该注意到,这个算法可以被很容易地转换成任何一种编程语言,只需知道那种编程语言的基本语法即可。

你可能会想,为什么我们需要一个生成这个特定数字序列的算法。其实,这只是一个假想的算法,作为一个简单的入门例子展示。在现实中,我们将设计一些能解决某个特定问题的算法。不过,设计一个算法需要一些很重要的工作,这将是将是本段落讨论的重点。

虽然计算机只能处理数字,但我们可以设想一些像人类一样,能在物理世界处理各种各样事物的算法。例如,我们可以编写操作 LEGO 积木或食物等物理对象的算法。虽然这些事物在计算机上很难实现(我们需要计算机控制机器人与物理世界进行交互),但它们仍然很有指导意义,因为基本的算法设计原则是相通的。

在一些编程入门课程的开始阶段,通常会有一个练习,让学生写下制作花生酱和果酱三明治的步骤。随后,教师会执行这些算法,这些算法通常不太精确并且含糊不清。教师会最搞笑地执行这些指令的解释,以强调学生所写的并没有真正描述他们的意思。

这个练习强调了一个重要的观点:你必须准确地指定你想让计算机做什么。当你写一些模糊的东西时,计算机不会“知道你的意思”,也不会想出“等等”中还可能有什么东西。相反,你必须能够以一步一步的方式精确地描述你想要做的事情。精确地描述执行特定任务的确切步骤有些棘手,因为我们习惯于人们隐含地理解我们省略的细节。计算机不会为你做这件事(在任何编程语言中都是如此)。

虽然“三明治算法”练习强调了精确描述你想要计算机执行什么的重要性,但它在说明算法设计中真正最难的部分这方面还存在缺陷。这个算法没有参数,所以它只描述了如何解决一个特定的问题(制作花生酱和果酱三明治)。真正的编程问题(通常)涉及需要参数的算法。一个更适合的练习可能是“编写一个算法,该算法接受一个你想要的三明治的材料列表,并描述如何制作三明治”。

这是一个更为复杂的问题,但真正涉及到算法设计的许多概念。首先,我们的算法不能将任何东西都放入三明治中:它只能使用特定类型的东西,也就是食物。我们不会期望我们的算法能够制作出“汽车、摩天大楼、飞机”三明治,因为它们都是错误的类型(types)。

我们的算法也可能需要处理错误情况。即使我们指定了正确类型的输入,但拥有正确类型的某些特定值可能依然无法被正确处理。例如,“鸡胸肉”是食物,但如果鸡胸肉尚未煮熟,我们就不应该试图将其做成三明治。在制作三明治的算法中,另一个错误情况可能是我们指定了太多的食物放入三明治中(怎么做出含有一整只火鸡、40磅胡萝卜和3加仑冰淇淋的三明治呢?)。当然,如果我们为人类编写这个三明治算法,我们可以忽略这种荒谬的情况,因为人类具有“常识”,然而,计算机没有。

即使我们忽略所有错误情况,我们的一般算法也不是简单地将所有配料按照输入中出现的顺序堆叠在面包上。例如,我们可能会得到一个输入“鸡肉、芥末、菠菜、番茄”。在这种情况下,我们可能希望先将芥末涂在面包上,然后再将其他配料放在上面(希望它们的顺序使三明治最为稳定)。

似乎从任意食材列表中编写一个制作三明治的正确算法是一个相当复杂的任务。哪怕我们不想将该算法实现为代码,而是希望让一个没有常识的人(或一个对常识漫不经心的教授)能够正确执行,这项任务也是非常具有挑战性的。那么我们该如何着手完成这个任务并期望得到一个好的算法呢?

编写算法的错误方法是先把一些东西扔到纸上,然后稍后再来整理它。想象一下,如果我们只写下一些步骤,这些步骤杂乱无章,并让一个没有常识的人来尝试它们,来实现我们的三明治例子。当厨房着火时,我们尝试进去弄清楚出了什么问题。然后我们调整步骤并重试。这一次,厨房爆炸了。我们重复这个过程,直到最终得到一个类似三明治的东西,并且房子没有烧毁。

上段文字听起来可能有些傻,但这正是许多初学者(和中级)程序员处理编程任务的方式。他们直接开始编写代码(没有计划的时间!繁忙的日程!),结果自然是行不通。然后,他们花费大量时间来试图修复代码,尽管他们没有明确的计划来描述它应该做什么。随着他们“修复”代码,它变得越来越混乱。最终,程序勉强工作了,他们就这样满意了。

相反,你应该按照有原则的方式去设计算法。上图展示了你设计算法的步骤。我们接下来将花费几个章节来详细讨论每个步骤。请注意,在你通过人肉测试你的算法之后,“Translate to Code” 才会进行,只有当你拥有一个经过测试的算法时,才会让你对你的计划很有信心。

如果你计划得足够好并且翻译正确,你的代码第一次就可以运行。如果它第一次不能运行,你至少有一个可靠的计划来指导你的调试。


算法设计四步曲

Step 1. 人工解决一些实例

设计算法的第一步是自己人工至少解决一个具体参数的问题实例。通常情况下,这一步需要绘制问题图表以便精确解决地问题。你解决问题的精度越高(包括在适用情况下绘制问题图表的精度越高),后续步骤就越容易。你可能需要绘制的图表种类可以参考许多科学课程(特别是物理课程)中所绘制的图表。如图所示,第一步和第二步有多层重叠在一起,这些层可能需要全部执行,以便正确推广算法。

本章早期提到的算法示例之一是确定一个数字是否为质数。如果你想编写一个确定数字是否为质数的函数,你的第一步将是选择一个数字并确定它是否为质数。仅仅说“好的,我知道7是质数”是没有什么用的——你只是使用了你已知的一个事实,并没有真正想出解决问题地办法。对于这样一个具有“是”或“否”答案的问题,我们可能希望至少处理一个能得出答案“是”的示例和一个能得出答案“否”的示例。

另一个例子是,如果我们想编写一个计算 xxyy 次幂的程序。为了完成第一步,我们为 xxyy 选了两个特定的值,并手动计算它们。我们可以尝试 x=3x = 3y=4y = 4,得出答案为 34=813^4 = 81

如果你在这一步卡住了,通常意味着有两种可能性。第一种情况是问题不明确:你不清楚应该做什么。在这种情况下,你必须在继续之前,解决 “问题该如何解决” 的问题。在校园中,这个解决方案可能需要向你的教授或 TA 询问更多细节。在工业界中,可能需要询问技术主管或客户。如果你正在解决自己创建的问题,则可能需要更加努力地思考正确答案应该是什么,并改进你对问题的定义和理解。

在编程过程中,存在两种情况会让第一步变得困难。一种情况是你缺乏相关领域的知识。在我们的之前和质数有关的示例中,如果你不记得质数的定义,那就是缺乏相关领域知识的例子(问题领域是数学),也就是缺乏数学知识。没有任何的编程知识或 ”努力“ 能够克服这种领域知识的缺乏。相反,你必须请教领域专家,比如数学教科书、网站或专家。一旦你掌握了正确的领域知识,就可以解决问题实例。需要注意的是,领域知识可能来自不同于数学的领域,编程可以用于处理任何类型的信息。

有时,领域知识可能来自于计算机科学或工程学的特定领域。例如,如果你想编写一个程序来确定英文文本的含义,则相关领域领域实际上是计算机科学的一个子领域,称为自然语言处理。这里的领域知识将是编写处理自然语言的程序所使用的特定技术。一位英语教授或教科书等英语领域专家不太可能包含此类信息。

Step 2. 记录下你的做法

在这一步骤中,你必须回忆并思考你是如何解决问题的,并写下解决这个特定实例的步骤。另一种思考这一步骤的方式是写下一组清晰的指令,任何人都可以按照这些指令重现你刚刚解决的特定问题实例的答案。如果在第一步中有多个实例,则在第二步中也要重复多次,每个实例对应一次。如果某个指令有点复杂,那没关系,只要这个指令有清晰的含义,我们就可以把这些复杂的步骤转化为单独的编程问题,并分别解决它们。

第二步的难点在于思考你是如何完成问题的。困难之处在于很容易忽略一些小细节、简单的步骤或你隐含地执行了某些操作。这种困难可以通过我们之前提到的花生酱和三明治练习来说明。关于该做什么的隐含假设或依赖于常识会导致不精确或遗漏步骤。计算机不会自动补上你遗漏的任何步骤,因此你必须小心地思考所有细节。

回到计算 xxyy 次幂的示例,我们可以为 x=3x = 3y=4y = 4 的特定实例写下以下步骤:

1
2
3
4
5
6
7
8
Multiply 3 by 3 
You get 9
Multiply 3 by 9
You get 27
Multiply 3 by 27
You get 81

81 is your answer.

这些步骤非常精确,没有任何猜测的空间。任何能进行基本算术运算的人都可以按照这些步骤得出正确的答案。计算机非常擅长算术运算,因此这些步骤中没有一个复杂到需要拆分为子问题的程度。

Step 3. 泛化你的步骤

在我们解决了这类问题中我们感兴趣的一个或多个特定实例,并记录了我们执行的特定步骤后,我们要将这些步骤概括为一个算法。在第二步中,我们解决了特定的实例,但现在我们需要找到解决这类问题的一个完整模式。这种概括通常需要两个步骤。首先,我们必须取出我们使用的具体值,并用参数的数学表达式代替它们。例如,对于计算 343^4 的步骤,我们会看到在每个步骤中都要将 3 乘以某个数。在更一般的情况下,我们并不总是使用 33 ,而是使用 33 具体地来说是因为我们选择了 xx 的值。我们可以通过将 33 的出现替换为 xx 来稍微概括这一点:

1
2
3
4
5
6
7
8
Multiply 3 by 3 
You get 9
Multiply 3 by 9
You get 27
Multiply 3 by 27
You get 81

81 is your answer.

第二种通用的泛化步骤的方法是找到重复的步骤:同样的步骤一遍又一遍地重复。通常情况下,步骤重复的次数将取决于参数。我们必须泛化执行这些步骤的次数,以及步骤本身。有时候,我们可能会发现某些步骤,几乎但不完全是重复的,这种情况下,我们可能需要调整这些步骤,使它们完全重复。在我们的 343^4 的例子中,我们的乘法步骤几乎是重复的——都是将 xx 乘以“某个数”,但“某个数”会变化(分别是 33992727 )。仔细查看这些步骤,我们会发现我们要乘的“某个数”是上一步的答案。因此,我们可以给它一个名称(和一个初始值)来使所有这些步骤相同:

1
2
3
4
5
6
7
Start with n = 3 

n = Multiply x by n
n = Multiply x by n
n = Multiply x by n

n is your answer.

现在,我们有完全相同的步骤重复了三次。我们现在可以考虑这个步骤重复的次数是 xx 和/或 yy 的函数。我们必须小心,不要草率地得出结论,它重复了 xx 次,因为 x=3x = 3 ,这只是这种情况下的一个巧合。在以下的情况下,它会重复 y1y - 1 次。原因是我们需要将 4433 相乘在一起,而在 nn 开始时已经有一个,因此我们需要 y1y - 1 个。这导致我们需要以下的通用步骤:

1
2
3
4
5
6
Start with n = 3

Count up from 1 to y - 1 (inclusive), for each number you count,
n = Multiply x by n

n is your answer.

我们需要将一个特定值的通用性进一步推广为参数的函数。我们从 n=3n = 3 开始;然而,并不总是想要从 33 开始。在一般情况下,我们希望从 n=xn = x 开始:

1
2
3
4
5
6
Start with n = x

Count up from 1 to y - 1 (inclusive), for each number you count,
n = Multiply x by n

n is your answer.

有时,你会发现很难看出其中的规律,这会使得推广步骤变得困难。当出现这种情况时,回到步骤 1 和步骤 2 可能会有所帮助。对问题进行更多实例也可以提供更多信息供你考虑,让你洞察算法的模式。这个过程通常被称为编写“伪代码”(pseudo code),因为你正在努力设计一种算法,但并没有特定的目标语言。几乎所有程序员都使用这种方法来确保他们的算法在编写任何实际代码之前是正确的。

Step 4. 测试你的程序

在第三步之后,我们有了一个我们认为是正确的算法。然而,我们可能在途中出现了错误。第四步的主要目的是在继续前进之前确保我们的步骤是正确的。为了达到这个目的,我们用不同于我们用来设计算法的参数值来测试我们的算法。我们手动执行算法,并将其得到的答案与正确答案进行比较。如果它们不同,那么我们就知道我们的算法是错误的。我们使用的测试案例(参数值)越多,我们就越有信心我们的算法是正确的。不幸的是,通过测试来确保我们的算法是正确的是不可能的。要完全确定你的算法是正确的唯一方法是通过正式证明其正确性(使用数学证明),这超出了本专业的范围。

一种常见的错误类型是在第三步中误将特殊情况普遍化。正如我们刚刚讨论的,有人可能认为这些步骤重复了 xx 次,因为 x=3x = 3 并且这些步骤重复了 33 次。如果我们在第三步中写下了这个,那么我们的算法只能在 x=y1x = y - 1 时工作,否则我们会计算错误的次数并得到错误的答案。如果是这种情况,我们希望通过在第四步手动测试算法来检测问题。当我们发现这样的问题时,我们必须回到第三步重新审视我们所做的概括。通常,最好的方法是回到第一步和第二步,针对暴露问题的任何测试案例,重新执行这些步骤,以获得不同的具体步骤来进行概括。然后,你可以找到你之前提出的概括有哪些错误,并进行相应的修改。

另一种常见的错误类型是在设计算法时没有考虑到某些情况。事实上,在我们的 xyx^y 示例中,我们没有考虑当 y=0y=0 时会发生什么,因此我们的算法会错误地处理这种情况。如果你手动执行 x=2, y=0x=2,\ y=0 的算法,则应该得到 20=12^0=1 的答案;然而,你将得到2的答案。具体而言,你将从 n=x=2n=x=2 开始。然后我们会试图从 11 计数到 01=10-1=-1,其中没有数字,因此我们会立即停止计数。然后我们会返回 nn(即 22 )作为我们的答案。

要完善我们的算法,我们需要回到第1步和第2步,重新考虑失败的情况( x=2, y=0x=2,\ y=0 )。这种情况有点棘手,因为我们只知道答案是1而不需要任何计算(任何 xxx0=1x^0=1 )。答案不需要任何计算的事实使第 2 步有所不同,我们只需要给出1作为答案。虽然这种简单性似乎很好,但它实际上使将其纳入我们的通用步骤变得更加困难。我们可能会尝试编写这样的通用步骤:

1
2
3
4
5
6
7
8
9
If y is 0 then
1 is your answer
Otherwise:
Start with n = x
Count up from 1 to y - 1 (inclusive), for each number you count,
n = Multiply x by n

n is your answer.

这些步骤明确地检查了导致问题的情况( y=0y = 0 ),为该情况提供正确的答案,然后执行更通用的算法。对于某些问题,可能存在需要特别关注的边缘情况。然而,对于这个问题,我们可以做得更好。请注意,如果想出更好的解决方案并采取上述方法,这并不是完全错误的,只不过不是最佳解决方案。

更好的方法是,你意识到如果我们可以一次数也不计,我们需要一个答案为 11 ,因此我们应该从 11 开始计数,而不是从 xx 开始。这样做,我们需要比之前总共多计数 11 次(到 yy 而不是到 y1y-1 ),也就是再多乘以 xx 一次:

1
2
3
4
5
6
Start with n = 1

Count up from 1 to y (inclusive), for each number you count,
n = Multiply x by n

n is your answer.

每当在第四步检测到算法问题时,我们通常希望返回第一步和第二步,以获取更多可概括的信息。有时,我们可能会立刻看到问题(例如,如果我们犯了一个微不足道的算术错误,或者如果手动执行有问题的测试用例可以给我们正确的概括见解)。如果我们知道如何修复问题,可以立即修复它,而无需重做第一步和第二步,但如果卡住了,则应该重做这些步骤,直到找到解决方案。无论你采取何种方法来完善算法,都应该使用你已经使用过的所有测试用例以及一些新测试用例来重新测试它。

设计好的测试用例是一项随着实践而提高的重要技能。对于第四步的测试,你将需要使用至少产生几个不同答案的案例进行测试(例如,如果你的算法具有答案 “是” 或 “否” ,应该使用产生 “是” 和 “否” 两种参数进行测试)。你还应该测试任何边缘实例,即行为可能与一般案例不同的案例。每当你有条件决策(包括计数范围的限制)[3]时,应该在这些条件的边界附近测试潜在的边缘实例。比如,如果你的算法基于 x<3x < 3 是否成立做出决策,你可能需要测试 x=2x = 2x=3x = 3x=4x = 4 。你不必在“纸笔”测试中进行大量测试,因为在编写实际代码后,你能更高效地进行更多的测试。

总结

现在,你应该已经基本掌握了设计简单算法的流程。这项技能是你在阅读这套编程基础知识的其余部分时将要练习的,因为每个编程问题的关键都是找出正确的算法。我们想再次强调逐步解决问题的重要性。许多新手程序员尝试跳过前几个步骤,直接开始编写代码。结果往往是灾难性的,他们最终要花费比正确规划多几个数量级的时间来弥补。

新手程序员跳过第三步(编写通用算法)的原因各不相同,但一个常见的原因是“第三步(编写通用算法)似乎太难了”。这可能是跳过第三步最糟糕的原因——如果制定正确的计划很困难,那么你又怎么可能在没有计划的情况下编写正确的代码呢?最好在更多的示例上重复步骤一和二,直到找到模式并写出算法。新手程序员跳过前几步的另一个常见原因是“节省时间”,但他们经常会花费无数小时来调试结果代码。为了避免花费多个小时来调试混乱的代码,花费 10 或 30 分钟来计划是完全值得的!

随着你逐渐熟练这个过程,你可能会发现从第一步到第四步会被你本能地完成。你可以在脑海中完成它们,就像基本的数学技能一样。随着你的编程技术不断提高,在脑中完成简单的步骤完全没有问题,只要你确定你正确无误。然而,当遇到超出或非常接近你编程能力边界的问题时,你仍需要遵循这些步骤,所以记住这整个工作流是非常重要的。

接下来,我们将首先学习一些关于在 C 语言中阅读代码的知识,然后再继续了解编写代码的更多内容。阅读代码是指能够理解一段代码确切地做了什么,并通过人工[4]逐步执行代码来执行它。这项技能非常重要:首先,会读,才会写。阅读代码需要绘制和更新反映程序状态的图表,而编写代码则需要按照算法中规定的方式编写语法,以实现适当的程序状态转换。其次,能够阅读你的代码对于调试代码至关重要。第三,你可能会在各种场合(例如小组课程项目、工业编码团队)中,遇到需要阅读并理解其他人代码,以便于你自己能够继续开发的情况。


  1. 1.对这句话感兴趣的可以查阅图灵的 “停机问题” 相关资料
  2. 2.例如让一个没有数论知识的人按照写好的算法来进行 RSA 加密的计算
  3. 3.比如你确定了某个重要判断的条件,或者某个循环的循环次数
  4. 4.用你的脑子