hav-cs50-merge-01

哈佛 CS50 中文官方笔记(二)

第三讲

原文:cs50.harvard.edu/x/notes/3/

  • 欢迎光临!

  • 线性查找

  • 二分查找

  • 运行时间

  • search.c

  • phonebook.c

  • 结构体

  • 排序

  • 冒泡排序

  • 递归

  • 归并排序

  • 总结

欢迎光临!

  • 在第零周,我们介绍了算法的概念:一个可能接受输入并产生输出的黑盒。

  • 本周,我们将通过伪代码和实际代码来扩展我们对算法的理解。

  • 此外,我们还将考虑这些算法的效率。实际上,我们将基于我们对如何使用上周讨论的一些概念来构建算法的理解。

  • 回想一下课程早期我们介绍过的以下图表:

    图表:以“问题规模”为 x 轴;“解决问题所需时间”为 y 轴;红色,从原点到图表顶部的陡峭直线接近黄色,从原点到图表顶部的较平缓直线,两者均标记为“n”;绿色,从原点到图表右侧逐渐变平缓的曲线,标记为“log n”

  • 随着我们步入本周,你应该考虑一个算法如何与问题协同工作可能会决定解决问题所需的时间!算法可以被设计得越来越高效,直至极限。

  • 今天,我们将关注算法的设计以及如何衡量它们的效率。

线性查找

  • 回想一下,上周你被介绍到数组的概念,即连续的内存块:彼此并排。

  • 你可以比喻性地想象一个数组就像一系列七个红锁,如下所示:

    七排并排的红锁

  • 最左侧的位置称为位置 0数组的开始。最右侧的位置是位置 6数组的结束

  • 我们可以想象,我们有一个基本问题,即想知道,“数字 50 是否在数组中?”计算机必须查看每个锁,以便能够看到数字 50 是否在里面。我们将寻找此类数字、字符、字符串或其他项的过程称为搜索

  • 我们可以将我们的数组交给一个算法,然后我们的算法将搜索我们的锁,看看数字 50 是否在某个门后面,返回值truefalse

    七个红锁指向一个空盒子。从空盒子中出来一个 bool 类型的输出

  • 我们可以想象我们可能提供给算法的各种指令以执行此任务,如下所示:

    For each door from left to right
        If 50 is behind door
            Return true
    Return false 
    

    注意,上述指令被称为伪代码:我们可以提供给计算机的指令的人类可读版本。

  • 计算机科学家可以将伪代码翻译如下:

    For i from 0 to n-1
        If 50 is behind doors[i]
            Return true
    Return false 
    

    注意,上面的内容仍然不是代码,但它是对最终代码可能的样子一个非常接近的近似。

二分搜索

  • 二分搜索是另一种可以用于我们寻找 50 的任务中的搜索算法

  • 假设锁内的值已经从小到大排列,二分搜索的伪代码如下所示:

    If no doors left
        Return false
    If 50 is behind middle door
        Return true
    Else if 50 < middle door
        Search left half
    Else if 50 > middle door
        Search right half 
    
  • 使用代码的命名法,我们可以进一步修改我们的算法如下:

    If no doors left
        Return false
    If 50 is behind doors[middle]
        Return true
    Else if 50 < doors[middle]
        Search doors[0] through doors[middle - 1]
    Else if 50 > doors[middle]
        Search doors[middle + 1] through doors[n - 1] 
    

    注意,通过查看这个代码的近似,你几乎可以想象出这在实际代码中可能的样子。

运行时间

  • 你可以考虑算法解决问题所需的时间。

  • 运行时间涉及到使用大 O符号的分析。看看下面的图表:

    图表:以“问题规模”为 x 轴;“解决问题所需时间”为 y 轴;红色,从原点到图表顶部的陡峭直线接近黄色,从原点到图表顶部的较平缓直线;绿色,从原点到图表右侧逐渐变平缓的曲线,均标注为“O(n)”;绿色,从原点到图表右侧逐渐变平缓的曲线,标注为“O(log n"

  • 计算机科学家在讨论算法的效率时,不是对算法的数学效率进行超具体分析,而是用各种运行时间的顺序来讨论效率。

  • 在上面的图表中,第一个算法是 (O(n)) 或 n 的阶数。第二个也是 (O(n))。第三个是 (O(\log n))。

  • 它是曲线的形状,显示了算法的效率。我们可能会看到一些常见的运行时间:

    • (O(n²))

    • (O(n \log n))

    • (O(n))

    • (O(\log n))

    • (O(1))

  • 在上述运行时间中,(O(n²))被认为是运行时间最慢的。(O(1))是最快的。

  • 线性搜索的阶数为 (O(n)),因为它在最坏情况下可能需要 n 步才能运行。

  • 二分搜索的阶数为 (O(\log n)),因为它在运行时将越来越少,即使在最坏情况下也是如此。

  • 程序员对最坏情况,或上界,和最佳情况,或下界都感兴趣。

  • (\Omega) 符号用来表示算法的最佳情况,例如 (\Omega(\log n))。

  • (\Theta) 符号用来表示上界和下界相同的地方:最佳情况和最坏情况的运行时间相同的地方。

  • 渐近符号是用来衡量算法在输入越来越大时表现如何的度量。

  • 随着你继续在计算机科学领域发展你的知识,你将在未来的课程中更详细地探索这些主题。

search.c

  • 你可以通过在终端窗口中输入code search.c并编写如下代码来实现线性搜索:

    // Implements linear search for integers
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        // An array of integers
        int numbers[] = {20, 500, 10, 5, 100, 1, 50};
    
        // Search for number
        int n = get_int("Number: ");
        for (int i = 0; i < 7; i++)
        {
            if (numbers[i] == n)
            {
                printf("Found\n");
                return 0;
            }
        }
        printf("Not found\n");
        return 1;
    } 
    

    注意,以int numbers[]开头的行允许我们在创建数组时定义每个元素的值。然后,在for循环中,我们有线性搜索的实现。return 0用于指示成功并退出程序。return 1用于带错误(失败)退出程序。

  • 我们现在已经在 C 中自己实现了线性搜索!

  • 如果我们想在数组中搜索一个字符串呢?修改你的代码如下:

    // Implements linear search for strings
    
    #include <cs50.h> #include <stdio.h> #include <string.h>  
    int main(void)
    {
        // An array of strings
        string strings[] = {"battleship", "boot", "cannon", "iron", "thimble", "top hat"};
    
        // Search for string
        string s = get_string("String: ");
        for (int i = 0; i < 6; i++)
        {
            if (strcmp(strings[i], s) == 0)
            {
                printf("Found\n");
                return 0;
            }
        }
        printf("Not found\n");
        return 1;
    } 
    

    注意,我们无法像之前这个程序的迭代版本中那样使用==。相反,我们使用strcmp,它来自string.h库。如果字符串相同,strcmp将返回0。另外,请注意,字符串长度6是硬编码的,这不是好的编程实践。

  • 事实上,运行这段代码允许我们遍历这个字符串数组,查看是否包含某个特定的字符串。然而,如果你看到段错误,即程序访问了它不应访问的内存部分,请确保你有i < 6而不是i < 7

  • 你可以在CS50 手册页面上了解更多关于strcmp的信息。

phonebook.c

  • 我们可以将数字和字符串的这些想法结合到一个程序中。在终端窗口中输入code phonebook.c并编写如下代码:

    // Implements a phone book without structs
    
    #include <cs50.h> #include <stdio.h> #include <string.h>  
    int main(void)
    {
        // Arrays of strings
        string names[] = {"Yuliia", "David", "John"};
        string numbers[] = {"+1-617-495-1000", "+1-617-495-1000", "+1-949-468-2750"};
    
        // Search for name
        string name = get_string("Name: ");
        for (int i = 0; i < 3; i++)
        {
            if (strcmp(names[i], name) == 0)
            {
                printf("Found %s\n", numbers[i]);
                return 0;
            }
        }
        printf("Not found\n");
        return 1;
    } 
    

    注意,Yuliia 的电话号码以+1-617开头,David 的电话号码以+1-617开头,John 的电话号码以+1-949开头。因此,names[0]是 Yuliia,numbers[0]是 Yuliia 的电话号码。这段代码将允许我们在电话簿中搜索特定号码的人。

  • 虽然这段代码能工作,但存在许多低效之处。实际上,存在一种可能性,即姓名和电话号码可能不匹配。如果我们可以创建自己的数据类型,将一个人与电话号码关联起来,那岂不是很好?

结构体

  • 结果表明,C 语言允许我们通过struct创建自己的数据类型。

  • 创建一个包含namenumber的名为person的自定义数据类型不是很有用吗?考虑以下内容:

    typedef struct
    {
        string name;
        string number;
    } person; 
    

    注意,这代表了我们自己的数据类型person,它有一个名为name的字符串和一个名为number的字符串。

  • 我们可以通过修改我们的电话簿程序来改进我们之前的代码:

    // Implements a phone book with structs
    
    #include <cs50.h> #include <stdio.h> #include <string.h>  
    typedef struct
    {
        string name;
        string number;
    } person;
    
    int main(void)
    {
        person people[3];
    
        people[0].name = "Yuliia";
        people[0].number = "+1-617-495-1000";
    
        people[1].name = "David";
        people[1].number = "+1-617-495-1000";
    
        people[2].name = "John";
        people[2].number = "+1-949-468-2750";
    
        // Search for name
        string name = get_string("Name: ");
        for (int i = 0; i < 3; i++)
        {
            if (strcmp(people[i].name, name) == 0)
            {
                printf("Found %s\n", people[i].number);
                return 0;
            }
        }
        printf("Not found\n");
        return 1;
    } 
    

    注意,代码以typedef struct开始,其中定义了一个新的数据类型person。在person内部有一个名为name的字符串和一个名为number的字符串。在main函数中,首先创建一个名为people的数组,其类型为person,大小为 3。然后,我们更新people数组中两个人的姓名和电话号码。最重要的是,注意如何使用点表示法,例如people[0].name,允许我们访问第 0 个位置的person并为其分配一个姓名。

排序

  • 排序是将未排序的值列表转换为排序列表的行为。

  • 当一个列表排序后,在该列表中搜索要远比在未排序的列表中搜索要耗费计算机更少的资源。回想一下,我们可以在有序列表上使用二分搜索,但不能在未排序的列表上使用。

  • 结果表明,有许多不同的排序算法。

  • 选择排序 是这样的排序算法之一。

  • 我们可以这样表示一个数组:

    七个并排的红锁,最后一个标记为 n-1

  • 选择排序的伪代码如下:

    For i from 0 to n–1
        Find smallest number between numbers[i] and numbers[n-1]
        Swap smallest number with numbers[i] 
    
  • 总结这些步骤,第一次遍历列表需要 n - 1 步。第二次,它需要 n - 2 步。继续这个逻辑,所需的步骤可以表示如下:

    (n - 1) + (n - 2) + (n - 3) + ... + 1 
    
  • 这可以简化为 n(n-1)/2 或更简单地说,(O(n²))。在最坏情况或上界,选择排序的顺序为 (O(n²))。在最好情况或下界,选择排序的顺序为 (\Omega(n²))。

冒泡排序

  • 冒泡排序 是另一种排序算法,它通过重复交换元素来“冒泡”较大的元素到末尾。

  • 冒泡排序的伪代码如下:

    Repeat n-1 times
        For i from 0 to n–2
            If numbers[i] and numbers[i+1] out of order
                Swap them
        If no swaps
            Quit 
    
  • 随着我们进一步排序数组,我们知道越来越多的部分变得有序,所以我们只需要查看尚未排序的数字对。

  • 冒泡排序可以分析如下:

     (n – 1) × (n – 1)
      n2 – 1n – 1n + 1
      n2 – 2n + 1 
    

    或者,更简单地说 (O(n²))。

  • 在最坏情况下,或上界,冒泡排序的顺序为 (O(n²))。在最好情况下,或下界,冒泡排序的顺序为 (\Omega(n))。

  • 您可以 可视化 这些算法的比较。

递归

  • 我们如何提高我们的排序效率?

  • 递归 是编程中的一个概念,其中函数调用自身。我们之前在看到……时看到了这一点。

    If no doors left
        Return false
    If number behind middle door
        Return true
    Else if number < middle door
        Search left half
    Else if number > middle door
        Search right half 
    

    注意,我们正在对这个问题越来越小的迭代调用 search

  • 类似地,在我们的第 0 周伪代码中,您可以看到递归是如何实现的:

    1  Pick up phone book
    2  Open to middle of phone book
    3  Look at page
    4  If person is on page
    5      Call person
    6  Else if person is earlier in book
    7      Open to middle of left half of book
    8      Go back to line 3
    9  Else if person is later in book
    10     Open to middle of right half of book
    11     Go back to line 3
    12 Else
    13     Quit 
    
  • 此代码可以简化以突出其递归特性,如下所示:

    1  Pick up phone book
    2  Open to middle of phone book
    3  Look at page
    4  If person is on page
    5      Call person
    6  Else if person is earlier in book
    7      Search left half of book
    9  Else if person is later in book
    10     Search right half of book
    12 Else
    13     Quit 
    
  • 考虑一下在第一周我们想要创建以下这样的金字塔结构:

     #
      ##
      ###
      #### 
    
  • 在您的终端窗口中输入 code iteration.c 并编写如下代码:

    // Draws a pyramid using iteration
    
    #include <cs50.h> #include <stdio.h>  
    void draw(int n);
    
    int main(void)
    {
        // Get height of pyramid
        int height = get_int("Height: ");
    
        // Draw pyramid
        draw(height);
    }
    
    void draw(int n)
    {
        // Draw pyramid of height n
        for (int i = 0; i < n; i++)
        {
            for (int j = 0; j < i + 1; j++)
            {
                printf("#");
            }
            printf("\n");
        }
    } 
    

    注意,此代码通过循环构建金字塔。

  • 要使用递归实现此功能,请在您的终端窗口中输入 code iteration.c 并编写如下代码:

    // Draws a pyramid using recursion
    
    #include <cs50.h> #include <stdio.h>  
    void draw(int n);
    
    int main(void)
    {
        // Get height of pyramid
        int height = get_int("Height: ");
    
        // Draw pyramid
        draw(height);
    }
    
    void draw(int n)
    {
        // If nothing to draw
        if (n <= 0)
        {
            return;
        }
    
        // Draw pyramid of height n - 1
        draw(n - 1);
    
        // Draw one more row of width n
        for (int i = 0; i < n; i++)
        {
            printf("#");
        }
        printf("\n");
    } 
    

    注意到 基准情况 将确保代码不会无限运行。当 if (n <= 0) 时终止递归,因为问题已经解决。每次 draw 函数调用自身时,它都会通过 n-1 来调用自身。在某一点上,n-1 将等于 0,导致 draw 函数返回,程序结束。

归并排序

  • 我们现在可以利用递归来寻求更有效的排序算法,并实现所谓的 归并排序,这是一种非常有效的排序算法。

  • 归并排序的伪代码相当简短:

    If only one number
        Quit
    Else
        Sort left half of number
        Sort right half of number
        Merge sorted halves 
    
  • 考虑以下数字列表:

     6341 
    
  • 首先,归并排序会问,“这是一个数字吗?”答案是“不是”,所以算法继续。

     6341 
    
  • 第二,归并排序现在将数字从中间分开(或者尽可能接近中间)并排序数字的左半部分。

     63|41 
    
  • 第三,归并排序将查看左边的这些数字并询问,“这是一个数字吗?”由于答案是“不是”,然后它会将左边的数字从中间分开。

     6|3 
    
  • 第四,归并排序将再次询问,“这是一个数字吗?”这次答案是肯定的!因此,它将退出这个任务,并返回到此时正在运行的最后任务:

     63|41 
    
  • 第五,归并排序将排序左边的数字。

     36|41 
    
  • 现在,我们回到伪代码中我们之前中断的地方,因为左边的数字已经排序了。步骤 3-5 的类似过程将发生在右边的数字上。这将导致:

     36|14 
    
  • 两个半部分现在都已排序。最后,算法将合并两边。它会查看左边的第一个数字和右边的第一个数字。它会将较小的数字放在前面,然后是第二小的数字。算法将对所有数字重复此操作,结果如下:

     1346 
    
  • 归并排序已完成,程序退出。

  • 归并排序是一个非常高效的排序算法,最坏情况下的时间复杂度为 (O(n \log n))。最佳情况仍然是 (\Omega(n \log n)),因为算法仍然必须访问列表中的每个位置。因此,归并排序的时间复杂度也是 (\Theta(n \log n)),因为最佳情况和最坏情况是相同的。

  • 最后,可视化被分享。

总结

在本课中,你学习了算法思维和构建自己的数据类型。具体来说,你学习了…

  • 算法。

  • 大 O 表示法。

  • 二分查找和线性查找。

  • 各种排序算法,包括冒泡排序、选择排序和归并排序。

  • 递归。

欢迎下次再来!

第四讲

原文:cs50.harvard.edu/x/notes/4/

  • 欢迎!

  • 像素艺术

  • 十六进制

  • 内存

  • 指针

  • 字符串

  • 指针算术

  • 字符串比较

  • 复制和 malloc

  • Valgrind

  • 垃圾值

  • 与 Binky 一起玩指针

  • 交换

  • 溢出

  • scanf

  • 文件输入输出

  • 总结

欢迎!

  • 在前几周,我们讨论了图像是由称为像素的更小的构建块组成的。

  • 今天,我们将更深入地探讨构成这些图像的零和一。特别是,我们将深入研究构成文件(包括图像)的基本构建块。

  • 此外,我们还将讨论如何访问存储在计算机内存中的底层数据。

  • 在今天开始之前,要知道本讲座中涵盖的概念可能需要一些时间才能完全 理解

像素艺术

  • 像素是方格,单个的点,颜色排列在上下的左右网格中。

  • 你可以想象一个图像是一个位图,其中零代表黑色,一代表白色。

    零和一转换为黑白笑脸

十六进制

  • RGB,或 红色、绿色、蓝色,是代表每种颜色数量的数字。在 Adobe Photoshop 中,你可以看到这些设置如下:

    带有 RGB 值和十六进制输入的 Photoshop 面板

    注意红色、蓝色和绿色数量的变化如何改变所选颜色。

  • 从上面的图片中你可以看到,颜色不仅仅由三个值来表示。在窗口底部,有一个由数字和字符组成的特殊值。255 表示为 FF。这可能是为什么?

  • 十六进制 是一个有 16 个计数值的计数系统。它们如下:

     0 1 2 3 4 5 6 7 8 9 A B C D E F 
    

    注意 F 代表 15

  • 十六进制也被称为 十六进制

  • 在十六进制计数时,每一列都是 16 的幂。

  • 数字 0 表示为 00

  • 数字 1 表示为 01

  • 数字 9 表示为 09

  • 数字 10 表示为 0A

  • 数字 15 表示为 0F

  • 数字 16 表示为 10

  • 数字 255 表示为 FF,因为 16 x 15(或 F)等于 240。再加上 15 得到 255。这是使用两位十六进制系统可以计数的最大数字。

  • 十六进制之所以有用,是因为它可以用更少的数字来表示。十六进制允许我们更简洁地表示信息。

内存

  • 在过去的几周里,你可能还记得我们关于并发内存块的艺术家渲染。将这些内存块应用十六进制编号,你可以这样可视化:

    以 0x 编号的内存块

  • 你可以想象,关于上面的 10 块是否表示内存中的位置或值 10 可能会有混淆。因此,按照惯例,所有十六进制数通常都带有 0x 前缀,如下所示:

    以 0x 编号的内存块

  • 在你的终端窗口中,键入 code addresses.c 并按照以下方式编写你的代码:

    // Prints an integer
    
    #include <stdio.h>  
    int main(void)
    {
        int n = 50;
        printf("%i\n", n);
    } 
    

    注意到 n 在内存中以值 50 存储的方式。

  • 你可以这样可视化程序存储这个值的方式:

    存储在内存位置中的值 50,以十六进制表示

指针

  • C 语言有两个与内存相关的强大运算符:

     & Provides the address of something stored in memory.
      * Instructs the compiler to go to a location in memory. 
    
  • 我们可以通过以下方式修改我们的代码来利用这一知识:

    // Prints an integer's address
    
    #include <stdio.h>  
    int main(void)
    {
        int n = 50;
        printf("%p\n", &n);
    } 
    

    注意到 %p,它允许我们查看内存位置的地址。&n 可以直译为“n 的地址。”执行此代码将返回以 0x 开头的内存地址。

  • 指针 是一个存储某个地址的变量。最简洁地说,指针是计算机内存中的一个地址。

  • 考虑以下代码:

    int n = 50;
    int *p = &n; 
    

    注意到 p 是一个指针,它包含一个整数 n 的地址。

  • 按照以下方式修改你的代码:

    // Stores and prints an integer's address
    
    #include <stdio.h>  
    int main(void)
    {
        int n = 50;
        int *p = &n;
        printf("%p\n", p);
    } 
    

    注意到这段代码与我们的前一段代码具有相同的效果。我们只是利用了我们对 &* 运算符的新知识。

  • 为了说明 * 运算符的使用,考虑以下:

    // Stores and prints an integer via its address
    
    #include <stdio.h>  
    int main(void)
    {
        int n = 50;
        int *p = &n;
        printf("%i\n", *p);
    } 
    

    注意到 printf 行打印了 p 位置的整数。int *p 创建了一个指针,其任务是存储一个整数的内存地址。

  • 你可以这样可视化我们的代码:

    具有指针值存储在其他地方的内存位置中的相同值 50

    注意到指针似乎相当大。事实上,指针通常存储为 8 字节值。p 存储的是 50 的地址。

  • 你可以更准确地将指针想象为一个指向另一个地址的地址:

    指针作为箭头,从一个内存位置指向另一个内存位置

字符串

  • 现在我们已经对指针有了心理模型,我们可以剥去之前在这门课程中提供的简化层次。

  • 按照以下方式修改你的代码:

    // Prints a string
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        string s = "HI!";
        printf("%s\n", s);
    } 
    

    注意到打印了一个字符串 s

  • 回想一下,字符串只是一个字符数组。例如,string s = "HI!" 可以表示如下:

    存储在内存中的带有感叹号的字符串 HI

  • 然而,s 究竟是什么?s 在内存中的位置在哪里?正如你可以想象的那样,s 需要存储在某个地方。你可以这样可视化 s 与字符串的关系:

    指向它的指针的相同字符串 HI

    注意到名为s的指针告诉编译器字符串的第一个字节在内存中的位置。

  • 按如下方式修改你的代码:

    // Prints a string's address as well the addresses of its chars
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        string s = "HI!";
        printf("%p\n", s);
        printf("%p\n", &s[0]);
        printf("%p\n", &s[1]);
        printf("%p\n", &s[2]);
        printf("%p\n", &s[3]);
    } 
    

    注意到上面的代码打印了字符串s中每个字符的内存位置。&符号用于显示字符串中每个元素的地址。当运行此代码时,注意元素0123在内存中是相邻的。

  • 同样,你可以按如下方式修改你的代码:

    // Declares a string with CS50 Library
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        string s = "HI!";
        printf("%s\n", s);
    } 
    

    注意到这段代码将展示从s位置开始的字符串。这段代码实际上移除了cs50.h提供的string数据类型的训练轮。这是原始的 C 代码,没有 cs50 库的框架。

  • 取下训练轮,你可以再次修改你的代码:

    // Declares a string without CS50 Library
    
    #include <stdio.h>  
    int main(void)
    {
        char *s = "HI!";
        printf("%s\n", s);
    } 
    

    注意到cs50.h已被移除。字符串被实现为char *

  • 你可以想象字符串作为数据类型是如何创建的。

  • 上周,我们学习了如何创建自己的数据类型作为结构体。

  • cs50 库包括以下结构体:typedef char *string

  • 当使用 cs50 库时,这个结构体允许使用一个自定义的数据类型,称为string

指针算术

  • 指针算术是进行内存位置数学运算的能力。

  • 你可以修改你的代码以打印字符串中的每个内存位置,如下所示:

    // Prints a string's chars
    
    #include <stdio.h>  
    int main(void)
    {
        char *s = "HI!";
        printf("%c\n", s[0]);
        printf("%c\n", s[1]);
        printf("%c\n", s[2]);
    } 
    

    注意到我们正在打印s位置处的每个字符。

  • 此外,你可以按如下方式修改你的代码:

    // Prints a string's chars via pointer arithmetic
    
    #include <stdio.h>  
    int main(void)
    {
        char *s = "HI!";
        printf("%c\n", *s);
        printf("%c\n", *(s + 1));
        printf("%c\n", *(s + 2));
    } 
    

    注意到s位置处的第一个字符被打印出来。然后,打印s + 1位置处的字符,以此类推。

  • 同样,考虑以下内容:

    // Prints substrings via pointer arithmetic
    
    #include <stdio.h>  
    int main(void)
    {
        char *s = "HI!";
        printf("%s\n", s);
        printf("%s\n", s + 1);
        printf("%s\n", s + 2);
    } 
    

    注意到这段代码从s开始打印存储在各个内存位置上的值。

字符串比较

  • 字符串本质上是一个字符数组,通过其第一个字节的位置来标识。

  • 在课程早期,我们考虑了整数的比较。我们可以在终端窗口中通过输入code compare.c来在代码中表示这一点,如下所示:

    // Compares two integers
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        // Get two integers
        int i = get_int("i: ");
        int j = get_int("j: ");
    
        // Compare integers
        if (i == j)
        {
            printf("Same\n");
        }
        else
        {
            printf("Different\n");
        }
    } 
    

    注意到这段代码从用户那里获取两个整数并比较它们。

  • 然而,在字符串的情况下,不能使用==运算符来比较两个字符串。

  • 利用==运算符尝试比较字符串将尝试比较字符串的内存位置,而不是其中的字符。因此,我们建议使用strcmp

  • 为了说明这一点,按如下方式修改你的代码:

    // Compares two strings' addresses
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        // Get two strings
        char *s = get_string("s: ");
        char *t = get_string("t: ");
    
        // Compare strings' addresses
        if (s == t)
        {
            printf("Same\n");
        }
        else
        {
            printf("Different\n");
        }
    } 
    

    注意到,对于两个字符串都输入HI!,仍然会输出Different

  • 为什么这些字符串看起来是不同的?你可以使用以下内容来可视化原因:

    两个字符串分别存储在内存中

  • 因此,上面compare.c的代码实际上是在尝试查看内存地址是否不同,而不是字符串本身。

  • 使用strcmp,我们可以修正我们的代码:

    // Compares two strings using strcmp
    
    #include <cs50.h> #include <stdio.h> #include <string.h>  
    int main(void)
    {
        // Get two strings
        char *s = get_string("s: ");
        char *t = get_string("t: ");
    
        // Compare strings
        if (strcmp(s, t) == 0)
        {
            printf("Same\n");
        }
        else
        {
            printf("Different\n");
        }
    } 
    

    注意到strcmp可以在字符串相同的情况下返回0

  • 为了进一步说明这两个字符串是如何生活在两个位置上的,按如下方式修改你的代码:

    // Prints two strings
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        // Get two strings
        char *s = get_string("s: ");
        char *t = get_string("t: ");
    
        // Print strings
        printf("%s\n", s);
        printf("%s\n", t);
    } 
    

    注意我们现在有两个独立的字符串被存储,可能位于两个不同的位置。

  • 你可以通过以下小修改看到这两个存储的字符串的位置:

    // Prints two strings' addresses
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        // Get two strings
        char *s = get_string("s: ");
        char *t = get_string("t: ");
    
        // Print strings' addresses
        printf("%p\n", s);
        printf("%p\n", t);
    } 
    

    注意在打印语句中 %s 已经被改为 %p

复制和 malloc

  • 编程中一个常见的需求是将一个字符串复制到另一个字符串。

  • 在你的终端窗口中,键入 code copy.c 并编写以下代码:

    // Capitalizes a string
    
    #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h>  
    int main(void)
    {
        // Get a string
        string s = get_string("s: ");
    
        // Copy string's address
        string t = s;
    
        // Capitalize first letter in string
        t[0] = toupper(t[0]);
    
        // Print string twice
        printf("s: %s\n", s);
        printf("t: %s\n", t);
    } 
    

    注意,string t = ss 的地址复制到 t 中。这并没有完成我们想要做的事情。字符串没有被复制——只有地址被复制了。此外,请注意 ctype.h 的包含。

  • 你可以将上述代码可视化如下:

    两个指针指向同一内存位置并带有字符串

    注意 st 仍然指向相同的内存块。这不是字符串的真正副本。相反,这两个指针指向同一个字符串。

  • 在我们解决这个挑战之前,确保我们的代码不会因为尝试将 string s 复制到不存在的 string t 而导致 段错误 是很重要的。我们可以使用 strlen 函数如下来帮助实现这一点:

    // Capitalizes a string, checking length first
    
    #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h>  
    int main(void)
    {
        // Get a string
        string s = get_string("s: ");
    
        // Copy string's address
        string t = s;
    
        // Capitalize first letter in string
        if (strlen(t) > 0)
        {
            t[0] = toupper(t[0]);
        }
    
        // Print string twice
        printf("s: %s\n", s);
        printf("t: %s\n", t);
    } 
    

    注意 strlen 用于确保 string t 存在。如果它不存在,则不会复制任何内容。

  • 为了能够创建字符串的真正副本,我们需要引入两个新的构建块。首先,malloc 允许你,程序员,分配一块特定大小的内存。其次,free 允许你告诉编译器释放你之前分配的那块内存。

  • 我们可以修改我们的代码来创建我们字符串的真正副本如下:

    // Capitalizes a copy of a string
    
    #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h>  
    int main(void)
    {
        // Get a string
        char *s = get_string("s: ");
    
        // Allocate memory for another string
        char *t = malloc(strlen(s) + 1);
    
        // Copy string into memory, including '\0'
        for (int i = 0; i <= strlen(s); i++)
        {
            t[i] = s[i];
        }
    
        // Capitalize copy
        t[0] = toupper(t[0]);
    
        // Print strings
        printf("s: %s\n", s);
        printf("t: %s\n", t);
    } 
    

    注意 malloc(strlen(s) + 1) 创建了一个长度为字符串 s 加一的内存块。这允许在最终的复制字符串中包含 \0 字符。然后,for 循环遍历字符串 s 并将每个值赋给字符串 t 的相同位置。

  • 结果表明,我们的代码效率不高。按照以下方式修改你的代码:

    // Capitalizes a copy of a string, defining n in loop too
    
    #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h>  
    int main(void)
    {
        // Get a string
        char *s = get_string("s: ");
    
        // Allocate memory for another string
        char *t = malloc(strlen(s) + 1);
    
        // Copy string into memory, including '\0'
        for (int i = 0, n = strlen(s); i <= n; i++)
        {
            t[i] = s[i];
        }
    
        // Capitalize copy
        t[0] = toupper(t[0]);
    
        // Print strings
        printf("s: %s\n", s);
        printf("t: %s\n", t);
    } 
    

    注意现在 n = strlen(s)for 循环的左侧被定义。在 for 循环的中间条件中最好不调用不必要的函数,因为它会反复运行。当将 n = strlen(s) 移到左侧时,函数 strlen 只运行一次。

  • C 语言有一个内置的函数用于复制字符串,称为 strcpy。它可以如下实现:

    // Capitalizes a copy of a string using strcpy
    
    #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h>  
    int main(void)
    {
        // Get a string
        char *s = get_string("s: ");
    
        // Allocate memory for another string
        char *t = malloc(strlen(s) + 1);
    
        // Copy string into memory
        strcpy(t, s);
    
        // Capitalize copy
        t[0] = toupper(t[0]);
    
        // Print strings
        printf("s: %s\n", s);
        printf("t: %s\n", t);
    } 
    

    注意 strcpy 做了之前我们的 for 循环所做的工作。

  • 在出现错误的情况下,get_stringmalloc 都会返回 NULL,这是内存中的一个特殊值。你可以编写代码来检查这个 NULL 条件,如下所示:

    // Capitalizes a copy of a string without memory errors
    
    #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h>  
    int main(void)
    {
        // Get a string
        char *s = get_string("s: ");
        if (s == NULL)
        {
            return 1;
        }
    
        // Allocate memory for another string
        char *t = malloc(strlen(s) + 1);
        if (t == NULL)
        {
            return 1;
        }
    
        // Copy string into memory
        strcpy(t, s);
    
        // Capitalize copy
        if (strlen(t) > 0)
        {
            t[0] = toupper(t[0]);
        }
    
        // Print strings
        printf("s: %s\n", s);
        printf("t: %s\n", t);
    
        // Free memory
        free(t);
        return 0;
    } 
    

    注意如果获取的字符串长度为 0malloc 失败,则返回 NULL。此外,注意 free 让计算机知道你已完成通过 malloc 创建的这块内存。

Valgrind

  • Valgrind 是一个工具,可以检查你的程序中是否有与 malloc 相关的内存问题。具体来说,它检查你是否释放了所有分配的内存。

  • 考虑以下 memory.c 代码:

    // Demonstrates memory errors via valgrind
    
    #include <stdio.h> #include <stdlib.h>  
    int main(void)
    {
        int *x = malloc(3 * sizeof(int));
        x[1] = 72;
        x[2] = 73;
        x[3] = 33;
    } 
    

    注意,运行此程序不会导致任何错误。虽然 malloc 用于分配足够内存的数组,但代码未能释放分配的内存。

  • 如果你输入 make memory 然后跟 valgrind ./memory,你将得到一个 valgrind 报告,该报告将报告由于你的程序导致丢失的内存位置。valgrind 揭示的一个错误是我们试图将 33 的值赋给数组的第 4 个位置,而我们只分配了一个大小为 3 的数组。另一个错误是我们从未释放 x

  • 你可以修改你的代码来释放 x 的内存,如下所示:

    // Demonstrates memory errors via valgrind
    
    #include <stdio.h> #include <stdlib.h>  
    int main(void)
    {
        int *x = malloc(3 * sizeof(int));
        x[1] = 72;
        x[2] = 73;
        x[3] = 33;
        free(x);
    } 
    

    注意,现在再次运行 valgrind 不会出现内存泄漏。

垃圾值

  • 当你向编译器请求一块内存时,没有保证这块内存是空的。

  • 很可能你分配的内存之前已被计算机使用。因此,你可能会看到 垃圾垃圾值。这是由于你获得了一块内存但没有初始化它。例如,考虑以下 garbage.c 代码:

    #include <stdio.h> #include <stdlib.h>  
    int main(void)
    {
        int scores[1024];
        for (int i = 0; i < 1024; i++)
        {
            printf("%i\n", scores[i]);
        }
    } 
    

    注意,运行此代码将在内存中为你的数组分配 1024 个位置,但 for 循环可能会显示其中并非所有值都是 0。始终注意,当你没有将内存块初始化为零或其他值时,垃圾值的可能性。

Pointer Fun with Binky

  • 我们观看了一个来自斯坦福大学的 视频,该视频帮助我们可视化和理解指针。

交换

  • 在现实世界中,编程中一个常见的需要是交换两个值。自然地,没有临时存储空间交换两个变量是困难的。在实践中,你可以输入 code swap.c 并编写如下代码来观察这一行为:

    // Fails to swap two integers
    
    #include <stdio.h>  
    void swap(int a, int b);
    
    int main(void)
    {
        int x = 1;
        int y = 2;
    
        printf("x is %i, y is %i\n", x, y);
        swap(x, y);
        printf("x is %i, y is %i\n", x, y);
    }
    
    void swap(int a, int b)
    {
        int tmp = a;
        a = b;
        b = tmp;
    } 
    

    注意,尽管这段代码正在运行,但它不起作用。值,甚至在发送到 swap 函数之后,都没有交换。为什么?

  • 当你向函数传递值时,你只提供了副本。xy作用域 限制在当前代码编写的主函数中。也就是说,在 main 函数的花括号 {} 中创建的 xy 的值只有 main 函数的作用域。在我们的上述代码中,xy 是通过 传递的。

  • 考虑以下图像:

    一个矩形,顶部是机器代码,然后是全局堆和栈

    注意,全局 变量,我们在这个课程中没有使用,在内存中只有一个位置。各种函数存储在内存的另一个区域 stack 中。

  • 现在,考虑以下图像:

    一个矩形,底部是主函数,上面直接是交换函数

    注意,mainswap 有两个独立的或内存区域。因此,我们不能简单地从一个函数传递值到另一个函数来改变它们。

  • 按如下修改你的代码:

    // Swaps two integers using pointers
    
    #include <stdio.h>  
    void swap(int *a, int *b);
    
    int main(void)
    {
        int x = 1;
        int y = 2;
    
        printf("x is %i, y is %i\n", x, y);
        swap(&x, &y);
        printf("x is %i, y is %i\n", x, y);
    }
    
    void swap(int *a, int *b)
    {
        int tmp = *a;
        *a = *b;
        *b = tmp;
    } 
    

    注意,变量不是通过传递,而是通过引用传递。也就是说,ab 的地址被提供给函数。因此,swap 函数可以知道从主函数中如何对实际的 ab 进行更改。

  • 你可以这样可视化:

    在主函数中存储的 a 和 b 通过引用传递给交换函数

溢出

  • 堆溢出 是当你溢出堆,触及你不应该触及的内存区域时。

  • 栈溢出 是当调用太多函数时,超出可用内存量。

  • 这两种情况都被认为是缓冲区溢出

scanf

  • 在 CS50 中,我们创建了像 get_int 这样的函数来简化从用户获取输入的行为。

  • scanf 是一个内置函数,可以获取用户输入。

  • 我们可以使用 scanf 很容易地重新实现 get_int,如下所示:

    // Gets an int from user using scanf
    
    #include <stdio.h>  
    int main(void)
    {
        int n;
        printf("n: ");
        scanf("%i", &n);
        printf("n: %i\n", n);
    } 
    

    注意,n 的值存储在 scanf("%i", &n) 这一行中 n 的位置。

  • 然而,尝试重新实现 get_string 并不容易。考虑以下:

    // Dangerously gets a string from user using scanf with array
    
    #include <stdio.h>  
    int main(void)
    {
        char s[4];
        printf("s: ");
        scanf("%s", s);
        printf("s: %s\n", s);
    } 
    

    注意,由于字符串是特殊的,不需要使用 &。然而,这个程序并不总是在每次运行时都能正确运行。在这个程序中,我们没有为我们的字符串分配所需的内存量。实际上,我们不知道用户可能输入多长的字符串!进一步地,我们也不知道内存位置可能存在的垃圾值。

  • 此外,你的代码可以修改如下。但是,我们必须为字符串预分配一定量的内存:

    // Using malloc
    
    #include <stdio.h> #include <stdlib.h>  
    int main(void)
    {
        char *s = malloc(4);
        if (s == NULL)
        {
            return 1;
        }
        printf("s: ");
        scanf("%s", s);
        printf("s: %s\n", s);
        free(s);
        return 0;
    } 
    

    注意,如果提供了一个四字节的字符串,你可能会得到一个错误。

  • 如下简化我们的代码,我们可以进一步理解这个预分配的基本问题:

    #include <stdio.h>  
    int main(void)
    {
        char s[4];
        printf("s: ");
        scanf("%s", s);
        printf("s: %s\n", s);
    } 
    

    注意,如果我们预先分配一个大小为 4 的数组,我们可以输入 cat 并使程序运行。然而,大于这个大小的字符串可能会创建一个错误。

  • 有时,编译器或运行它的系统可能会分配比我们指示的更多内存。然而,从根本上说,上面的代码是不安全的。我们不能相信用户会输入一个适合我们预分配内存的字符串。

文件输入/输出

  • 你可以读取和操作文件。虽然这个主题将在未来的某个星期进一步讨论,但考虑以下 phonebook.c 的代码:

    // Saves names and numbers to a CSV file
    
    #include <cs50.h> #include <stdio.h> #include <string.h>  
    int main(void)
    {
        // Open CSV file
        FILE *file = fopen("phonebook.csv", "a");
    
        // Get name and number
        char *name = get_string("Name: ");
        char *number = get_string("Number: ");
    
        // Print to file
        fprintf(file, "%s,%s\n", name, number);
    
        // Close file
        fclose(file);
    } 
    

    注意,这段代码使用指针来访问文件。

  • 你可以在运行上述代码之前创建一个名为phonebook.csv的文件,或者下载phonebook.csv。运行上述程序并输入姓名和电话号码后,你会发现这些数据在你的 CSV 文件中持久保存。

  • 如果我们想在运行程序之前确保phonebook.csv文件存在,我们可以按照以下方式修改我们的代码:

    // Saves names and numbers to a CSV file
    
    #include <cs50.h> #include <stdio.h> #include <string.h>  
    int main(void)
    {
        // Open CSV file
        FILE *file = fopen("phonebook.csv", "a");
        if (!file)
        {
            return 1;
        }
    
        // Get name and number
        char *name = get_string("Name: ");
        char *number = get_string("Number: ");
    
        // Print to file
        fprintf(file, "%s,%s\n", name, number);
    
        // Close file
        fclose(file);
    } 
    

    注意,这个程序通过调用return 1来防止NULL指针。

  • 我们可以通过输入code cp.c并编写如下代码来实现自己的复制程序:

    // Copies a file
    
    #include <stdio.h> #include <stdint.h>  
    typedef uint8_t BYTE;
    
    int main(int argc, char *argv[])
    {
        FILE *src = fopen(argv[1], "rb");
        FILE *dst = fopen(argv[2], "wb");
    
        BYTE b;
    
        while (fread(&b, sizeof(b), 1, src) != 0)
        {
            fwrite(&b, sizeof(b), 1, dst);
        }
    
        fclose(dst);
        fclose(src);
    } 
    

    注意,这个文件创建了我们自己的数据类型,称为 BYTE,它的大小与 uint8_t 相同。然后,文件读取一个BYTE并将其写入文件。

  • BMPs 也是我们可以检查和操作的数据集合。这周,你将在你的问题集中做这件事!

总结

在本课中,你学习了指针,它使你能够访问和操作特定内存位置的数据。具体来说,我们深入探讨了……

  • 像素艺术

  • 十六进制

  • 内存

  • 指针

  • 字符串

  • 指针算术

  • 字符串比较

  • 复制

  • malloc 和 Valgrind

  • 垃圾值

  • 交换

  • 溢出

  • scanf

  • 文件输入/输出

欢迎下次再来!

第五讲

原文:cs50.harvard.edu/x/notes/5/

  • 欢迎!

  • 数据结构

  • 队列

  • 杰克学习事实

  • 调整数组大小

  • 数组

  • 链表

  • 字典

  • 哈希和哈希表

  • 字典树

  • 总结

欢迎光临!

  • 前几周已经向你介绍了编程的基本构建块。

  • 你在 C 语言中学到的所有知识都将使你能够在 Python 等高级编程语言中实现这些构建块。

  • 每周,概念变得越来越具有挑战性,就像一座山变得越来越陡峭。本周,随着我们探索数据结构,挑战变得平缓。

  • 迄今为止,你已经学习了如何使用数组在内存中组织数据。

  • 今天,我们将讨论如何在内存中组织数据以及从你不断增长的知识中出现的可能性。

数据结构

  • 数据结构 实质上是内存中的组织形式。

  • 在内存中组织数据有许多方法。

  • 抽象数据类型 是我们可以概念上想象的数据类型。在了解计算机科学时,通常从这些概念数据结构开始学习是有用的。学习这些将使以后理解如何实现更具体的数据结构变得更加容易。

队列

  • 队列 是一种抽象数据结构的形式。

  • 队列具有特定的属性。具体来说,它们是 FIFO 或“先进先出”。你可以想象自己在游乐园排队等待游乐设施。第一个排队的会先玩,最后一个排队的会后玩。

  • 队列与特定的动作相关联。例如,一个项目可以被 enqueued;也就是说,项目可以加入队伍或队列。此外,一个项目可以被 dequeued 或者在到达队伍前端时离开队列。

  • 在代码中,你可以这样想象一个队列:

    const int CAPACITY = 50;
    
    typedef struct
    {
        person people[CAPACITY];
        int size;
    }
    queue; 
    

    注意,名为 people 的数组是 person 类型。CAPACITY 表示栈可能达到的高度。整数 size 表示队列实际填充的程度,无论它可以容纳多少。

  • 队列与栈相对。从根本上讲,栈的性质与队列的性质不同。具体来说,它是 LIFO 或“后进先出”。就像在餐厅里堆叠盘子一样,最后放入堆叠中的盘子可能是第一个被取走的。

  • 栈与特定的动作相关联。例如,push 将某物放置在栈顶。Pop 是从栈顶移除某物。

  • 在代码中,你可能可以这样想象一个栈:

    const int CAPACITY = 50;
    
    typedef struct
    {
        person people[CAPACITY];
        int size;
    }
    stack; 
    

    注意,名为 people 的数组是 person 类型。CAPACITY 表示栈可能达到的高度。整数 size 表示栈实际填充的程度,无论它可以容纳多少。注意,这段代码与队列中的代码相同。

  • 您可能会想象,上面的代码有一个限制。因为在这个代码中,数组的容量总是预先确定的。因此,栈可能总是过大。您可能会想象只使用栈中的 5000 个位置中的一个。

  • 如果我们的栈是动态的——能够随着添加到其中的项目而增长,那就太好了。

Jack Learns the Facts

  • 我们观看了由 Elon 大学的 Shannon Duvall 教授制作的名为Jack Learns the Facts的视频。

调整数组大小

  • 回顾到第 2 周,我们向您介绍了您的第一个数据结构。

  • 数组是一块连续的内存。

  • 您可能会想象数组如下所示:

    三个带有 1 2 3 的箱子

  • 在内存中,还有其他程序、函数和变量存储的值。其中许多可能是曾经被使用但现在可供使用的未使用垃圾值。

    三个带有 1 2 3 的箱子以及其他许多内存元素

  • 假设您想在我们的数组中存储第四个值4。需要做的是分配一个新的内存区域并将旧数组移动到新区域?最初,这个新的内存区域将填充垃圾值。

    三个带有 1 2 3 的箱子在四个带有垃圾值的箱子上方

  • 当向这个新的内存区域添加值时,旧的垃圾值会被覆盖。

    三个带有 1 2 3 的箱子在四个带有 1 2 3 和一个垃圾值的箱子上方

  • 最终,所有旧的垃圾值都会被新的数据覆盖。

    三个带有 1 2 3 的箱子在四个带有 1 2 3 4 的箱子上方

  • 这种方法的缺点之一是设计不佳:每次我们添加一个数字,我们都必须逐个复制数组项。

数组

  • 如果我们能够将4存储在内存的另一个地方会怎么样?根据定义,这将不再是一个数组,因为4将不再在连续的内存中。我们如何连接内存中的不同位置?

  • 在您的终端中,键入code list.c并编写以下代码:

    // Implements a list of numbers with an array of fixed size
    
    #include <stdio.h>  
    int main(void)
    {
        // List of size 3
        int list[3];
    
        // Initialize list with numbers
        list[0] = 1;
        list[1] = 2;
        list[2] = 3;
    
        // Print list
        for (int i = 0; i < 3; i++)
        {
            printf("%i\n", list[i]);
        }
    } 
    

    注意,上面的代码与我们在本课程中早期学到的非常相似。内存为三个项目预先分配。

  • 建立在最近获得的知识基础上,我们可以利用我们对指针的理解来改进这段代码的设计。按照以下方式修改您的代码:

    // Implements a list of numbers with an array of dynamic size
    
    #include <stdio.h> #include <stdlib.h>  
    int main(void)
    {
        // List of size 3
        int *list = malloc(3 * sizeof(int));
        if (list == NULL)
        {
            return 1;
        }
    
        // Initialize list of size 3 with numbers
        list[0] = 1;
        list[1] = 2;
        list[2] = 3;
    
        // List of size 4
        int *tmp = malloc(4 * sizeof(int));
        if (tmp == NULL)
        {
            free(list);
            return 1;
        }
    
        // Copy list of size 3 into list of size 4
        for (int i = 0; i < 3; i++)
        {
            tmp[i] = list[i];
        }
    
        // Add number to list of size 4
        tmp[3] = 4;
    
        // Free list of size 3
        free(list);
    
        // Remember list of size 4
        list = tmp;
    
        // Print list
        for (int i = 0; i < 4; i++)
        {
            printf("%i\n", list[i]);
        }
    
        // Free list
        free(list);
        return 0;
    } 
    

    注意,创建了一个包含三个整数的列表。然后,可以将三个内存地址分配给值 123。接着,创建了一个大小为四的列表。接下来,列表从第一个复制到第二个。将值 4 添加到 tmp 列表中。由于 list 指向的内存块不再使用,使用命令 free(list) 释放它。最后,编译器被指示将 list 指针现在指向 tmp 指向的内存块。打印 list 的内容,然后释放。此外,请注意包含了 stdlib.h

  • 有用的是将 listtmp 都视为指向一块内存的指针。正如上面的例子所示,list 在某个时刻 指向 一个大小为 3 的数组。到结束时,list 被指示指向一个大小为 4 的内存块。技术上讲,在上述代码结束时,tmplist 都指向了同一块内存。

  • 一种不使用 for 循环复制数组的方法是使用 realloc

    // Implements a list of numbers with an array of dynamic size using realloc
    
    #include <stdio.h> #include <stdlib.h>  
    int main(void)
    {
        // List of size 3
        int *list = malloc(3 * sizeof(int));
        if (list == NULL)
        {
            return 1;
        }
    
        // Initialize list of size 3 with numbers
        list[0] = 1;
        list[1] = 2;
        list[2] = 3;
    
        // Resize list to be of size 4
        int *tmp = realloc(list, 4 * sizeof(int));
        if (tmp == NULL)
        {
            free(list);
            return 1;
        }
        list = tmp;
    
        // Add number to list
        list[3] = 4;
    
        // Print list
        for (int i = 0; i < 4; i++)
        {
            printf("%i\n", list[i]);
        }
    
        // Free list
        free(list);
        return 0;
    } 
    

    注意,列表通过 realloc 调整大小到新的数组。

  • 可能会有人想为列表分配比所需更多的内存,比如 30 项而不是所需的 3 或 4 项。然而,这并不是一个好的设计,因为它在不需要时也会消耗系统资源。此外,几乎没有保证最终需要超过 30 项内存。

链表

  • 在最近几周,你学习了三个有用的原语。struct 是你可以自己定义的数据类型。点号(.)在点表示法中允许你访问该结构体内部的变量。* 操作符用于声明指针或取消引用变量。

  • 今天,你将介绍 -> 操作符。它是一个箭头。此操作符指向一个地址并在结构体内部查找。

  • 链表是 C 中最强大的数据结构之一。链表允许你包含位于不同内存区域的值。此外,它们允许你根据需要动态地扩展和缩小列表。

  • 你可能会想象三个值存储在三个不同的内存区域,如下所示:

    三个内存中分别有 1 2 3 的三个盒子

  • 如何将这些值在列表中拼接起来?

  • 我们可以想象上面的数据如下所示:

    三个内存中分别有 1 2 3 的三个盒子,每个盒子上附有较小的盒子

  • 我们可以利用更多的内存来跟踪下一个项目使用指针的位置。

    三个内存中分别有 1 2 3 的三个盒子,每个盒子上附有较小的盒子,其中包含内存地址

    注意,NULL 被用来表示列表中没有其他内容。

  • 按照惯例,我们会在内存中保留一个额外的元素,一个指针,它跟踪列表中的第一个项目,称为列表的

    三个分别位于内存不同区域的盒子,其中较小的盒子附着在内存地址上,现在有一个最终盒子,其中包含第一个盒子的内存地址

  • 抽象掉内存地址,列表将如下所示:

    三个分别位于内存不同区域的盒子,其中较小的盒子指向一个最终盒子,其中一个盒子指向另一个盒子,直到盒子的末端

  • 这些盒子被称为节点。一个节点包含一个和一个称为next的指针。在代码中,你可以想象一个节点如下:

    typedef struct node
    {
        int number;
        struct node *next;
    }
    node; 
    

    注意,这个节点包含的项是一个名为number的整数。其次,包含一个指向节点next的指针,它将指向内存中的另一个节点。

  • 我们可以重新创建list.c以利用链表:

    // Start to build a linked list by prepending nodes
    
    #include <cs50.h> #include <stdio.h> #include <stdlib.h>  
    typedef struct node
    {
        int number;
        struct node *next;
    } node;
    
    int main(void)
    {
        // Memory for numbers
        node *list = NULL;
    
        // Build list
        for (int i = 0; i < 3; i++)
        {
            // Allocate node for number
            node *n = malloc(sizeof(node));
            if (n == NULL)
            {
                return 1;
            }
            n->number = get_int("Number: ");
            n->next = NULL;
    
            // Prepend node to list
            n->next = list;
            list = n;
        }
        return 0;
    } 
    

    首先,将node定义为struct。对于列表的每个元素,通过malloc为节点分配内存,大小为一个节点的大小。将n->number(或n的数字字段)赋值为一个整数。将n->next(或nnext字段)赋值为null。然后,将节点放置在列表的起始位置,内存位置为list

  • 从概念上讲,我们可以想象创建链表的过程。首先,声明node *list,但它的值是垃圾值。

    一个垃圾值

  • 接下来,在内存中分配一个名为n的节点。

    一个名为 n 的垃圾值和一个名为 list 的指针

  • 接下来,将节点的number赋值为1

    n 指向一个数字为 1 且 next 值为垃圾值的节点

  • 接下来,将节点的next字段赋值为NULL

    n 指向一个数字为 1 且 next 值为 null 的节点

  • 接下来,将list指向n指向的内存位置。现在nlist指向同一个地方。

    n 和 list 都指向一个数字为 1 且 next 值为 null 的节点

  • 然后创建一个新的节点。numbernext字段都填充了垃圾值。

    list 指向一个数字为 1 且 next 值为 null 的节点,n 指向一个具有垃圾值的新的节点

  • n的节点(新节点)的number值更新为2

    指向编号为 1 的节点,下一个的值为 null,n 指向一个编号为 2 的新节点,下一个为垃圾值

  • 此外,next字段也被更新了。

    指向编号为 1 的节点,下一个的值为 null,n 指向一个编号为 2 的新节点,下一个为 null

  • 最重要的是,我们不想失去与这些节点的任何连接,以免它们永远丢失。因此,nnext字段指向与list相同的内存位置。

    指向编号为 1 的节点,下一个的值为 null,n 指向一个编号为 2 的新节点,下一个为 null

  • 最后,list被更新为指向n。我们现在有一个包含两个项目的链表。

    指向编号为 1 的节点,下一个指向编号为 n 的节点,该节点指向编号为 2 的节点,下一个为 null 的链表

  • 观察我们的列表图,我们可以看到最后添加的数字是列表中第一个出现的数字。因此,如果我们按顺序打印列表,从第一个节点开始,列表将看起来是乱序的。

  • 我们可以按正确顺序打印列表如下:

    // Print nodes in a linked list with a while loop
    
    #include <cs50.h> #include <stdio.h> #include <stdlib.h>  
    typedef struct node
    {
        int number;
        struct node *next;
    } node;
    
    int main(void)
    {
        // Memory for numbers
        node *list = NULL;
    
        // Build list
        for (int i = 0; i < 3; i++)
        {
            // Allocate node for number
            node *n = malloc(sizeof(node));
            if (n == NULL)
            {
                return 1;
            }
            n->number = get_int("Number: ");
            n->next = NULL;
    
            // Prepend node to list
            n->next = list;
            list = n;
        }
    
        // Print numbers
        node *ptr = list;
        while (ptr != NULL)
        {
            printf("%i\n", ptr->number);
            ptr = ptr->next;
        }
        return 0;
    } 
    

    注意,node *ptr = list创建了一个临时变量,它指向与list指向的相同位置。while循环打印ptr指向的节点内容,然后更新ptr以指向列表中的下一个节点。

  • 在这个例子中,向列表中插入总是按(O(1))的顺序进行,因为只需非常少的步骤就可以在列表的前端插入。

  • 考虑到搜索这个列表所需的时间,它按(O(n))的顺序,因为在最坏的情况下,必须搜索整个列表以找到项目。向列表添加新元素的时间复杂度将取决于该元素添加的位置。这在下述示例中得到了说明。

  • 链表不是存储在连续的内存块中。只要系统资源足够,它们可以增长到你想要的任何大小。然而,缺点是,与数组相比,需要更多的内存来跟踪列表。对于每个元素,你必须存储不仅元素的值,还要存储指向下一个节点的指针。此外,链表不能像数组那样索引,因为我们需要通过前(n - 1)个元素来找到第(n)个元素的位置。因此,上图所示的列表必须进行线性搜索。因此,在上述构建的列表中不可能进行二分搜索。

  • 此外,你可以在列表的末尾放置数字,如图中所示代码:

    // Appends numbers to a linked list
    
    #include <cs50.h> #include <stdio.h> #include <stdlib.h>  
    typedef struct node
    {
        int number;
        struct node *next;
    } node;
    
    int main(void)
    {
        // Memory for numbers
        node *list = NULL;
    
        // Build list
        for (int i = 0; i < 3; i++)
        {
            // Allocate node for number
            node *n = malloc(sizeof(node));
            if (n == NULL)
            {
                return 1;
            }
            n->number = get_int("Number: ");
            n->next = NULL;
    
            // If list is empty
            if (list == NULL)
            {
                // This node is the whole list
                list = n;
            }
    
            // If list has numbers already
            else
            {
                // Iterate over nodes in list
                for (node *ptr = list; ptr != NULL; ptr = ptr->next)
                {
                    // If at end of list
                    if (ptr->next == NULL)
                    {
                        // Append node
                        ptr->next = n;
                        break;
                    }
                }
            }
        }
    
        // Print numbers
        for (node *ptr = list; ptr != NULL; ptr = ptr->next)
        {
            printf("%i\n", ptr->number);
        }
    
        // Free memory
        node *ptr = list;
        while (ptr != NULL)
        {
            node *next = ptr->next;
            free(ptr);
            ptr = next;
        }
        return 0;
    } 
    

    注意代码是如何 遍历 这个列表来找到末尾的。当追加一个元素(添加到列表的末尾)时,我们的代码将以 (O(n)) 的时间复杂度运行,因为我们必须遍历整个列表才能添加最后一个元素。此外,注意使用了一个名为 next 的临时变量来跟踪 ptr->next

  • 此外,你可以在添加项目时对列表进行排序:

    // Implements a sorted linked list of numbers
    
    #include <cs50.h> #include <stdio.h> #include <stdlib.h>  
    typedef struct node
    {
        int number;
        struct node *next;
    } node;
    
    int main(void)
    {
        // Memory for numbers
        node *list = NULL;
    
        // Build list
        for (int i = 0; i < 3; i++)
        {
            // Allocate node for number
            node *n = malloc(sizeof(node));
            if (n == NULL)
            {
                return 1;
            }
            n->number = get_int("Number: ");
            n->next = NULL;
    
            // If list is empty
            if (list == NULL)
            {
                list = n;
            }
    
            // If number belongs at beginning of list
            else if (n->number < list->number)
            {
                n->next = list;
                list = n; 
            }
    
            // If number belongs later in list
            else
            {
                // Iterate over nodes in list
                for (node *ptr = list; ptr != NULL; ptr = ptr->next)
                {
                    // If at end of list
                    if (ptr->next == NULL)
                    {
                        // Append node
                        ptr->next = n;
                        break;
                    }
    
                    // If in middle of list
                    if (n->number < ptr->next->number)
                    {
                        n->next = ptr->next;
                        ptr->next = n;
                        break;
                    }
                }
            }
        }
    
        // Print numbers
        for (node *ptr = list; ptr != NULL; ptr = ptr->next)
        {
            printf("%i\n", ptr->number);
        }
    
        // Free memory
        node *ptr = list;
        while (ptr != NULL)
        {
            node *next = ptr->next;
            free(ptr);
            ptr = next;
        }
        return 0;
    } 
    

    注意这个列表是如何在构建过程中排序的。为了以这种特定顺序插入元素,我们的代码在每次插入时仍将以 (O(n)) 的时间复杂度运行,因为在最坏的情况下,我们可能需要查看所有当前元素。

  • 这段代码可能看起来很复杂。然而,请注意,使用指针和上面的语法,我们可以在内存的不同位置拼接数据。

  • 数组提供连续的内存,可以快速搜索。数组还提供了进行二分搜索的机会。

  • 我们能否结合数组和链表的最佳之处?

  • 二叉搜索树 是另一种数据结构,可以更有效地存储数据,以便进行搜索和检索。

  • 你可以想象一个有序的数字序列。

    1 2 3 4 5 6 7 在相邻的框中

  • 想象一下,中心值成为树的顶部。那些小于这个值的放在左边。那些大于这个值的放在右边。

    1 2 3 4 5 6 7 在按层次排列的框中,4 在顶部,3 和 5 在其下方,1、2、6 和 7 在这些箭头下方

  • 然后可以使用指针指向每个内存区域的正确位置,这样每个节点都可以连接起来。

    1 2 3 4 5 6 7 在按层次排列的框中,4 在顶部,3 和 5 在其下方,1、2、6 和 7 在这些箭头下方,它们以树状结构连接

  • 在代码中,可以这样实现。

    // Implements a list of numbers as a binary search tree
    
    #include <stdio.h> #include <stdlib.h>  
    // Represents a node
    typedef struct node
    {
        int number;
        struct node *left;
        struct node *right;
    }
    node;
    
    void free_tree(node *root);
    void print_tree(node *root);
    
    int main(void)
    {
        // Tree of size 0
        node *tree = NULL;
    
        // Add number to list
        node *n = malloc(sizeof(node));
        if (n == NULL)
        {
            return 1;
        }
        n->number = 2;
        n->left = NULL;
        n->right = NULL;
        tree = n;
    
        // Add number to list
        n = malloc(sizeof(node));
        if (n == NULL)
        {
            free_tree(tree);
            return 1;
        }
        n->number = 1;
        n->left = NULL;
        n->right = NULL;
        tree->left = n;
    
        // Add number to list
        n = malloc(sizeof(node));
        if (n == NULL)
        {
            free_tree(tree);
            return 1;
        }
        n->number = 3;
        n->left = NULL;
        n->right = NULL;
        tree->right = n;
    
        // Print tree
        print_tree(tree);
    
        // Free tree
        free_tree(tree);
        return 0;
    }
    
    void free_tree(node *root)
    {
        if (root == NULL)
        {
            return;
        }
        free_tree(root->left);
        free_tree(root->right);
        free(root);
    }
    
    void print_tree(node *root)
    {
        if (root == NULL)
        {
            return;
        }
        print_tree(root->left);
        printf("%i\n", root->number);
        print_tree(root->right);
    } 
    

    注意这个搜索功能首先会去 tree 的位置。然后,它使用递归来搜索 numberfree_tree 函数递归地释放树。print_tree 函数递归地打印树。

  • 如上所示的树提供了一种数组不具备的动态性。它可以按我们的意愿增长和缩小。

  • 此外,当树平衡时,这个结构提供 (O(log n)) 的搜索时间。

词典

  • 词典 是另一种数据结构。

  • 词典,就像实际的书本形式的词典,有单词和定义,有

  • 算法时间复杂度的 圣杯 是 (O(1)) 或 常数时间。也就是说,最终目标是访问能够瞬间完成。

    各种时间复杂性的图表,其中 O(log n) 是次优,O(1) 是最佳

  • 词典可以通过散列提供这种访问速度。

散列和散列表

  • 散列的想法是取一个值并能够输出一个值,这个值可以成为以后访问它的快捷方式。

  • 例如,散列苹果可能散列为一个值为1,而浆果可能散列为2。因此,找到苹果就像询问哈希算法苹果存储在哪里一样简单。虽然在设计上不是理想的,但最终,将所有a放在一个桶中,将b放在另一个桶中,这种桶化散列值的理念说明了你可以如何使用这个概念:散列值可以用来简化查找这样的值。

  • 散列函数是一种将较大值减少到较小且可预测的值的算法。通常,这个函数接收一个你希望添加到你的哈希表中的项目,并返回一个表示该项目应放置的数组索引的整数。

  • 哈希表是数组和链表的绝佳组合。在代码实现中,哈希表是一个指向节点指针数组。

  • 可以这样想象哈希表:

    一个垂直的 26 个盒子组成的列,每个盒子代表字母表中的一个字母

    注意这是一个分配给字母表每个值的数组。

  • 然后,在数组的每个位置,使用链表来跟踪存储在该位置的每个值:

    一个垂直的 26 个盒子组成的列,每个盒子代表字母表中的一个字母,来自马里奥宇宙的各种名称从右边出现,路易吉与 l 一起,马里奥与 m 一起

  • 冲突是在你向哈希表中添加值时,已经存在散列位置上的值。在上面的例子中,冲突只是简单地附加到列表的末尾。

  • 通过更好地编程你的哈希表和哈希算法可以减少冲突。你可以想象对上面的改进如下:

    由 L A K 和 L I N 安排的各种盒子组成的垂直列,Lakitu 从 L A K 中出现,链接从 L I N 中出现

  • 考虑以下哈希算法的示例:

    路易吉被输入到一个哈希算法中,输出为 11

  • 这可以在代码中如下实现:

    #include <ctype.h>  
    unsigned int hash(const char *word)
    {
        return toupper(word[0]) - 'A';
    } 
    

    注意哈希函数返回toupper(word[0]) - 'A'的值。

  • 作为程序员,你必须决定使用更多内存以拥有大哈希表并可能减少搜索时间,还是使用更少的内存并可能增加搜索时间的好处。

  • 这种结构提供了 (O(n)) 的搜索时间。

Trie

  • Trie是另一种数据结构。Trie 是数组的树。

  • Trie总是可以在常数时间内进行搜索。

  • Trie的一个缺点是它们往往需要占用大量的内存。注意,我们只需要 (26 \times 4 = 104) 个节点来存储青蛙

  • 青蛙将如下存储:

    逐个字母拼写青蛙,每个字母与一个列表 T 从另一个列表 O 中关联,依此类推

  • 汤姆 将按以下方式存储:

    逐个字母拼写青蛙,每个字母与一个列表 T 从另一个列表 O 中关联,依此类推,以及类似地拼写汤姆,其中青蛙和汤姆共享两个共同字母 T 和 O

  • 这种结构提供了 (O(1)) 的搜索时间。

  • 这种结构的缺点在于使用它需要多少资源。

总结

在本课中,你学习了如何使用指针构建新的数据结构。具体来说,我们深入探讨了...

  • 数据结构

  • 栈和队列

  • 调整数组大小

  • 链表

  • 字典

  • Tries

次次见!

第六讲

原文:cs50.harvard.edu/x/notes/6/

  • 欢迎!

  • 嗨,Python!

  • Speller

  • 过滤器

  • 函数

  • 库、模块和包

  • 字符串

  • 位置参数和命名参数

  • 变量

  • 类型

  • 计算器

  • 条件语句

  • 面向对象编程

  • 循环

  • 抽象

  • 截断和浮点数不精确

  • 异常

  • 马里奥

  • 列表

  • 搜索和字典

  • 命令行参数

  • 退出状态

  • CSV 文件

  • 第三方库

  • 总结

欢迎光临!

  • 在之前的几周里,你被介绍了编程的基本构建块。

  • 你已经学习了一种名为 C 的低级编程语言中的编程。

  • 今天,我们将使用一种名为 Python 的高级编程语言。

  • 随着你学习这门新语言,你会发现你将更有能力自学新的编程语言。

嗨,Python!

  • 几十年来,人类已经看到在之前的编程语言中做出的设计决策如何得到改进。

  • Python 是一种编程语言,它建立在你在 C 语言中学到的知识之上。

  • Python 还可以访问大量的用户创建的库。

  • 与 C 语言不同,C 是一种 编译语言,Python 是一种 解释语言,你不需要单独编译你的程序。相反,你在 Python 解释器 中运行你的程序。

  • 到目前为止,代码看起来是这样的:

    // A program that says hello to the world
    
    #include <stdio.h>  
    int main(void)
    {
        printf("hello, world\n");
    } 
    
  • 今天,你会发现编写和编译代码的过程已经简化了。

  • 例如,上面的代码在 Python 中将被渲染为:

    # A program that says hello to the world 
    print("hello, world") 
    

    注意,分号已经消失,并且不需要任何库。你可以通过在终端中键入 python hello.py 来运行这个程序。

  • Python 可以相对简单地实现 C 语言中相当复杂的功能。

Speller

  • 为了说明这种简单性,让我们在终端窗口中键入‘code dictionary.py’并编写如下代码:

    # Words in dictionary words = set()
    
    def check(word):
        """Return true if word is in dictionary else false"""
        return word.lower() in words
    
    def load(dictionary):
        """Load dictionary into memory, returning true if successful else false"""
        with open(dictionary) as file:
            words.update(file.read().splitlines())
        return True
    
    def size():
        """Returns number of words in dictionary if loaded else 0 if not yet loaded"""
        return len(words)
    
    def unload():
        """Unloads dictionary from memory, returning true if successful else false"""
        return True 
    

    注意,上面有四个函数。在 check 函数中,如果 wordwords 中,它返回 True。这比 C 语言中的实现简单得多!同样,在 load 函数中,字典文件被打开。对于该文件中的每一行,我们将该行添加到 words 中。使用 rstrip,从添加的单词中移除尾随的新行。size 仅返回 wordslen 或长度。unload 只需要返回 True,因为 Python 自己处理内存管理。

  • 上述代码说明了为什么存在高级语言:为了简化并允许你更容易地编写代码。

  • 然而,速度是一个权衡。因为 C 允许程序员做出关于内存管理的决策,它可能比 Python 运行得更快——这取决于你的代码。当调用 Python 的内置函数时,C 只运行你的代码行,而 Python 运行所有在引擎盖下运行的代码。

  • 你可以在Python 文档中了解更多关于函数的信息。

过滤器

  • 为了进一步说明这种简单性,请在你的终端窗口中键入code blur.py来创建一个新文件,并编写如下代码:

    # Blurs an image 
    from PIL import Image, ImageFilter
    
    # Blur image before = Image.open("bridge.bmp")
    after = before.filter(ImageFilter.BoxBlur(1))
    after.save("out.bmp") 
    

    注意,此程序从名为PIL的库中导入模块ImageImageFilter。它接受一个输入文件并创建一个输出文件。

  • 此外,你可以创建一个名为edges.py的新文件,如下所示:

    # Finds edges in an image 
    from PIL import Image, ImageFilter
    
    # Find edges before = Image.open("bridge.bmp")
    after = before.filter(ImageFilter.FIND_EDGES)
    after.save("out.bmp") 
    

    注意,此代码是对你的blur代码的微小调整,但产生了截然不同的结果。

  • Python 允许你将 C 和其他低级编程语言中更为复杂的编程抽象出来。

函数

  • 在 C 中,你可能见过如下函数:

    printf("hello, world\n"); 
    
  • 在 Python 中,你会看到如下函数:

    print("hello, world") 
    

库、模块和包

  • 与 C 一样,CS50 库可以在 Python 中使用。

  • 以下函数将特别有用:

     get_float
      get_int
      get_string 
    
  • 你可以如下导入 cs50 库:

    import cs50 
    
  • 你也可以选择仅导入 CS50 库中的特定函数,如下所示:

    from cs50 import get_float, get_int, get_string 
    

字符串

  • 在 C 中,你可能记得如下代码:

    // get_string and printf with %s
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        string answer = get_string("What's your name? ");
        printf("hello, %s\n", answer);
    } 
    
  • 在 Python 中,此代码将转换为:

    # get_string and print, with concatenation 
    from cs50 import get_string
    
    answer = get_string("What's your name? ")
    print("hello, " + answer) 
    

    你可以通过在终端窗口中执行code hello.py来编写此代码。然后,你可以通过运行python hello.py来执行此代码。注意+符号如何连接"hello, "answer

  • 同样,这也可以在不连接的情况下完成:

    # get_string and print, without concatenation 
    from cs50 import get_string
    
    answer = get_string("What's your name? ")
    print("hello,", answer) 
    

    注意,打印语句自动在hello语句和answer之间创建一个空格。

  • 同样,你可以将上述代码实现为:

    # get_string and print, with format strings 
    from cs50 import get_string
    
    answer  = get_string("What's your name? ")
    print(f"hello, {answer}") 
    

    注意大括号如何允许print函数将answer进行插值,使得answer出现在其中。f是必须的,以便正确地格式化包含answer

位置参数和命名参数

  • C 语言中的函数,如freadfwriteprintf使用位置参数,其中你通过逗号分隔符提供参数。作为程序员,你必须记住哪个参数在哪个位置。这些被称为位置参数

  • 在 Python 中,命名参数允许你提供参数而不考虑位置性。

  • 你可以在文档中了解更多关于print函数的参数。

  • 访问该文档,你可能看到如下内容:

    print(*objects, sep='  ', end='\n', file=None, flush=False) 
    

    注意可以提供各种对象来打印。当提供多个对象给 print 时,会显示一个空格分隔符。同样,在 print 语句的末尾提供一个新行。

变量

  • 变量声明也得到了简化。在 C 语言中,你可能会有 int counter = 0; 这样的代码。在 Python 中,同样的行会写成 counter = 0。你不需要声明变量的类型。

  • Python 更倾向于使用 counter += 1 来实现加一,失去了 C 语言中 counter++ 的能力。

类型

  • Python 中的数据类型不需要显式声明。例如,你上面看到 answer 是一个字符串,但我们不必告诉解释器这一点:它自己就知道。

  • 在 Python 中,常用的类型包括:

     bool
      float
      int
      str 
    

    注意到 longdouble 类型不见了。Python 会处理更大或更小的数字应该使用哪种数据类型。

  • Python 中还有一些其他的数据类型:

    range   sequence of numbers
    list    sequence of mutable values
    tuple   sequence of immutable values
    dict    collection of key-value pairs
    set     collection of unique values 
    
  • 这些数据类型在 C 语言中都可以实现,但在 Python 中可以更简单地实现。

计算器

  • 你可能还记得课程早期提到的 calculator.c

    // Addition with int
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        // Prompt user for x
        int x = get_int("x: ");
    
        // Prompt user for y
        int y = get_int("y: ");
    
        // Perform addition
        printf("%i\n", x + y);
    } 
    
  • 我们可以像在 C 语言中一样实现一个简单的计算器。在终端窗口中输入 code calculator.py 并编写以下代码:

    # Addition with int [using get_int] 
    from cs50 import get_int
    
    # Prompt user for x x = get_int("x: ")
    
    # Prompt user for y y = get_int("y: ")
    
    # Perform addition print(x + y) 
    

    注意 CS50 库是如何导入的。然后,xy 从用户那里收集。最后,打印出结果。注意,在 C 程序中通常会看到的 main 函数在这里完全消失了!虽然可以使用 main 函数,但不是必需的。

  • 有可能移除 CS50 库的训练轮。按照以下方式修改你的代码:

    # Addition with int [using input] 
    # Prompt user for x x = input("x: ")
    
    # Prompt user for y y = input("y: ")
    
    # Perform addition print(x + y) 
    

    注意执行上述代码会导致程序出现奇怪的行为。这可能是为什么?

  • 你可能已经猜到解释器将 xy 理解为字符串。你可以通过以下方式使用 int 函数来修复你的代码:

    # Addition with int [using input] 
    # Prompt user for x x = int(input("x: "))
    
    # Prompt user for y y = int(input("y: "))
    
    # Perform addition print(x + y) 
    

    注意 xy 的输入是如何传递给 int 函数的,该函数将其转换为整数。如果不将 xy 转换为整数,字符将进行连接。

条件语句

  • 在 C 语言中,你可能记得这样的程序:

    // Conditionals, Boolean expressions, relational operators
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        // Prompt user for integers
        int x = get_int("What's x? ");
        int y = get_int("What's y? ");
    
        // Compare integers
        if (x < y)
        {
            printf("x is less than y\n");
        }
        else if (x > y)
        {
            printf("x is greater than y\n");
        }
        else
        {
            printf("x is equal to y\n");
        }
    } 
    
  • 在 Python 中,它将如下所示:

    # Conditionals, Boolean expressions, relational operators 
    from cs50 import get_int
    
    # Prompt user for integers x = get_int("What's x? ")
    y = get_int("What's y? ")
    
    # Compare integers if x < y:
        print("x is less than y")
    elif x > y:
        print("x is greater than y")
    else:
        print("x is equal to y") 
    

    注意到没有更多的花括号。相反,使用缩进来表示。其次,在 if 语句中使用冒号。此外,elif 替换了 else if。在 ifelif 语句中也不再需要括号。

  • 进一步查看比较,考虑以下 C 语言的代码:

    // Logical operators
    
    #include <cs50.h> #include <stdio.h>  
    int main(void)
    {
        // Prompt user to agree
        char c = get_char("Do you agree? ");
    
        // Check whether agreed
        if (c == 'Y' || c == 'y')
        {
            printf("Agreed.\n");
        }
        else if (c == 'N' || c == 'n')
        {
            printf("Not agreed.\n");
        }
    } 
    
  • 这可以按照以下方式实现:

    # Logical operators 
    from cs50 import get_string
    
    # Prompt user to agree s = get_string("Do you agree? ")
    
    # Check whether agreed if s == "Y" or s == "y":
        print("Agreed.")
    elif s == "N" or s == "n":
        print("Not agreed.") 
    

    注意到 C 语言中使用的两个竖线被 or 替换了。确实,人们通常喜欢 Python,因为它对人类来说更易读。此外,注意 Python 中不存在 char 类型。相反,使用 str 类型。

  • 对这段代码的另一种实现方式可以是使用 列表

    # Logical operators, using lists 
    from cs50 import get_string
    
    # Prompt user to agree s = get_string("Do you agree? ")
    
    # Check whether agreed if s in ["y", "yes"]:
        print("Agreed.")
    elif s in ["n", "no"]:
        print("Not agreed.") 
    

    注意我们能够在一个 list 中表达多个关键字,如 yyes

面向对象编程

  • 有可能某些类型的值不仅具有属性或属性,还具有函数。在 Python 中,这些值被称为对象

  • 在 C 语言中,我们可以创建一个struct,在其中可以关联多个变量,形成一个单独的自定义数据类型。在 Python 中,我们也可以这样做,并且还可以在自定义数据类型中包含函数。当一个函数属于特定的对象时,它被称为方法

  • 例如,Python 中的strs有内置的方法。因此,你可以按照以下方式修改你的代码:

    # Logical operators, using lists 
    # Prompt user to agree s = input("Do you agree? ").lower()
    
    # Check whether agreed if s in ["y", "yes"]:
        print("Agreed.")
    elif s in ["n", "no"]:
        print("Not agreed.") 
    

    注意旧的s值被strs的内置方法s.lower()的结果覆盖。

  • 同样,你可能还记得我们在 C 语言中是如何复制字符串的:

    // Capitalizes a copy of a string without memory errors
    
    #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h>  
    int main(void)
    {
        // Get a string
        char *s = get_string("s: ");
        if (s == NULL)
        {
            return 1;
        }
    
        // Allocate memory for another string
        char *t = malloc(strlen(s) + 1);
        if (t == NULL)
        {
            return 1;
        }
    
        // Copy string into memory
        strcpy(t, s);
    
        // Capitalize copy
        if (strlen(t) > 0)
        {
            t[0] = toupper(t[0]);
        }
    
        // Print strings
        printf("s: %s\n", s);
        printf("t: %s\n", t);
    
        // Free memory
        free(t);
        return 0;
    } 
    

    注意代码的行数。

  • 我们可以将上述内容用 Python 实现如下:

    # Capitalizes a copy of a string 
    # Get a string s = input("s: ")
    
    # Capitalize copy of string t = s.capitalize()
    
    # Print strings print(f"s: {s}")
    print(f"t: {t}") 
    

    注意这个程序与 C 语言中的对应程序相比要短得多。

  • 在这个课程中,我们将只触及 Python 的皮毛。因此,随着你继续学习,Python 文档将特别重要。

  • 你可以在Python 文档中了解更多关于字符串方法的信息。

循环

  • Python 中的循环与 C 语言非常相似。你可能还记得以下 C 语言的代码:

    // Demonstrates for loop
    
    #include <stdio.h>  
    int main(void)
    {
        for (int i = 0; i < 3; i++)
        {
            printf("meow\n");
        }
    } 
    
  • for循环在 Python 中可以这样实现:

    # Better design 
    for i in range(3):
        print("meow") 
    

    注意,i从未被明确使用。然而,Python 会自动增加i的值。

  • 此外,while循环可以这样实现:

    # Demonstrates while loop 
    i = 0
    while i < 3:
        print("meow")
        i += 1 
    
  • 为了进一步加深我们对 Python 中循环和迭代的理解,让我们创建一个新的文件,命名为uppercase.py,如下所示:

    # Uppercases string one character at a time 
    before = input("Before: ")
    print("After: ", end="")
    for c in before:
        print(c.upper(), end="")
    print() 
    

    注意end=是如何用于传递参数给print函数,以继续行而不添加换行符。此代码一次传递一个字符串。

  • 阅读文档后,我们发现 Python 有一些方法可以应用于整个字符串,如下所示:

    # Uppercases string all at once 
    before = input("Before: ")
    after = before.upper()
    print(f"After: {after}") 
    

    注意.upper是如何应用于整个字符串的。

抽象

  • 正如我们今天早些时候暗示的,你可以通过使用函数和将各种代码抽象到函数中来进一步改进我们的代码。修改你之前创建的meow.py代码如下:

    # Abstraction 
    def main():
        for i in range(3):
            meow()
    
    # Meow once def meow():
        print("meow")
    
    main() 
    

    注意,meow函数抽象掉了print语句。此外,注意main函数位于文件顶部。在文件底部,调用main函数。按照惯例,在 Python 中,你期望创建一个main函数。

  • 的确,我们可以在函数之间传递变量,如下所示:

    # Abstraction with parameterization 
    def main():
        meow(3)
    
    # Meow some number of times def meow(n):
        for i in range(n):
            print("meow")
    
    main() 
    

    注意meow现在接受一个变量n。在main函数中,你可以调用meow并向它传递一个值,比如3。然后,meowfor循环中使用n的值。

  • 阅读上述代码,注意作为 C 程序员,你如何能够相当容易地理解上述代码。虽然某些约定不同,但你之前学到的构建块在这个新的编程语言中非常明显。

截断和浮点数不精确

  • 回想一下,在 C 语言中,我们遇到了截断,其中一个整数除以另一个整数可能会得到一个不精确的结果。

  • 你可以通过修改 calculator.py 代码来看到 Python 如何处理这种除法:

    # Division with integers, demonstration lack of truncation 
    # Prompt user for x x = int(input("x: "))
    
    # Prompt user for y y = int(input("y: "))
    
    # Divide x by y z = x / y
    print(z) 
    

    注意,执行此代码会产生一个值,但如果你在 .333333 后看到更多数字,你会看到我们面临的是 浮点数不精确性。不会发生截断。

  • 我们可以通过稍微修改我们的代码来揭示这种不精确性:

    # Floating-point imprecision 
    # Prompt user for x x = int(input("x: "))
    
    # Prompt user for y y = int(input("y: "))
    
    # Divide x by y z = x / y
    print(f"{z:.50f}") 
    

    注意,这段代码揭示了不精确性。Python 仍然面临这个问题,就像 C 语言一样。

异常

  • 让我们探索在运行 Python 代码时可能发生的更多异常。

  • 按照以下方式修改 calculator.py

    # Doesn't handle exception 
    # Prompt user for an integer n = int(input("Input: "))
    print("Integer") 
    

    注意,输入错误的数据可能会导致错误。

  • 我们可以通过修改以下代码来尝试处理和捕获潜在的异常:

    # Handles exception 
    # Prompt user for an integer try:
        n = int(input("Input: "))
        print("Integer.")
    except ValueError:
        print("Not integer.") 
    

    注意,上述代码会反复尝试获取正确的数据类型,并在需要时提供额外的提示。

马里奥

  • 回想一下几周前我们的挑战,即在马里奥游戏中堆叠三个砖块。

    三个垂直砖块

  • 在 Python 中,我们可以按照以下方式实现类似的功能:

    # Prints a column of 3 bricks with a loop 
    for i in range(3):
        print("#") 
    

    这会打印出一列三个砖块。

  • 在 C 语言中,我们有一个 do-while 循环的优势。然而,在 Python 中,传统上使用 while 循环,因为 Python 没有内置的 do-while 循环。你可以在名为 mario.py 的文件中按照以下方式编写代码:

    # Prints a column of n bricks with a loop 
    from cs50 import get_int
    
    while True:
        n = get_int("Height: ")
        if n > 0:
            break
    
    for i in range(n):
        print("#") 
    

    注意,while 循环是如何用来获取高度的。一旦输入的高度大于零,循环就会中断。

  • 考虑以下图像:

    四个水平问号砖块

  • 在 Python 中,我们可以通过修改以下代码来实现:

    # Prints a row of 4 question marks with a loop 
    for i in range(4):
        print("?", end="")
    print() 
    

    注意,你可以覆盖 print 函数的行为,使其保持在上一行打印的位置。

  • 与之前的迭代类似,我们可以进一步简化这个程序:

    # Prints a row of 4 question marks without a loop 
    print("?" * 4) 
    

    注意,我们可以使用 * 来重复打印语句,使其重复 4 次。

  • 那么一大块砖块怎么办?

    三乘三的马里奥砖块

  • 要实现上述功能,你可以按照以下方式修改你的代码:

    # Prints a 3-by-3 grid of bricks with loops 
    for i in range(3):
        for j in range(3):
            print("#", end="")
        print() 
    

    注意一个 for 循环是如何嵌套在另一个 for 循环中的。print 语句在每个砖块行的末尾添加一个新行。

  • 你可以在 Python 文档 中了解更多关于 print 函数的信息。

列表

  • list 是 Python 中的一个数据结构。

  • list 中有内置的方法或函数。

  • 例如,考虑以下代码:

    # Averages three numbers using a list 
    # Scores scores = [72, 73, 33]
    
    # Print average average = sum(scores) / len(scores)
    print(f"Average: {average}") 
    

    注意,你可以使用内置的 sum 方法来计算平均值。

  • 你甚至可以利用以下语法从用户那里获取值:

    # Averages three numbers using a list and a loop 
    from cs50 import get_int
    
    # Get scores scores = []
    for i in range(3):
        score = get_int("Score: ")
        scores.append(score)
    
    # Print average average = sum(scores) / len(scores)
    print(f"Average: {average}") 
    

    注意,这段代码使用了内置的 append 方法来处理列表。

  • 你可以在 Python 文档 中了解更多关于列表的信息。

  • 你也可以在 Python 文档 中了解更多关于 len 的信息。

搜索和字典

  • 我们还可以在数据结构内进行搜索。

  • 考虑一个名为 phonebook.py 的程序如下:

    # Implements linear search for names using loop 
    # A list of names names = ["Yuliia", "David", "John"]
    
    # Ask for name name = input("Name: ")
    
    # Search for name for n in names:
        if name == n:
            print("Found")
            break
    else:
        print("Not found") 
    

    注意这是如何为每个名字实现线性搜索的。

  • 然而,我们不需要遍历列表。在 Python 中,我们可以如下执行线性搜索:

    # Implements linear search for names using `in` 
    # A list of names names = ["Yuliia", "David", "John"]
    
    # Ask for name name = input("Name: ")
    
    # Search for name if name in names:
        print("Found")
    else:
        print("Not found") 
    

    注意 in 如何用于实现线性搜索。

  • 然而,此代码仍有改进空间。

  • 回想一下,字典dict 是键值对的集合。

  • 你可以在 Python 中如下实现字典:

    # Implements a phone book as a list of dictionaries, without a variable 
    from cs50 import get_string
    
    people = [
        {"name": "Yuliia", "number": "+1-617-495-1000"},
        {"name": "David", "number": "+1-617-495-1000"},
        {"name": "John", "number": "+1-949-468-2750"},
    ]
    
    # Search for name name = get_string("Name: ")
    for person in people:
        if person["name"] == name:
            print(f"Found {person['number']}")
            break
    else:
        print("Not found") 
    

    注意,每个条目都实现了 namenumber 的字典。

  • 更好的是,严格来说,我们不需要同时使用 namenumber。我们可以将此代码简化如下:

    # Implements a phone book using a dictionary 
    from cs50 import get_string
    
    people = {
        "Yuliia": "+1-617-495-1000",
        "David": "+1-617-495-1000",
        "John": "+1-949-468-2750",
    }
    
    # Search for name name = get_string("Name: ")
    if name in people:
        print(f"Number: {people[name]}")
    else:
        print("Not found") 
    

    注意,字典是用花括号实现的。然后,if name in people 这个语句会搜索 name 是否在 people 字典中。此外,注意在 print 语句中,我们可以使用 name 的值来索引 people 字典。非常实用!

  • Python 尽力使用其内置搜索实现 常数时间

  • 你可以在 Python 文档 中了解更多关于字典的信息。

命令行参数

  • 与 C 语言一样,你还可以利用命令行参数。考虑以下代码:

    # Prints a command-line argument 
    from sys import argv
    
    if len(argv) == 2:
        print(f"hello, {argv[1]}")
    else:
        print("hello, world") 
    

    注意 argv[1] 是使用 格式化字符串 打印的,print 语句中的 f 表示格式化字符串。

  • 你可以在 Python 文档 中了解更多关于 sys 库的信息。

退出状态

  • sys 库也有内置的方法。我们可以使用 sys.exit(i) 来使用特定的退出码退出程序:

    # Exits with explicit value, importing sys 
    import sys
    
    if len(sys.argv) != 2:
        print("Missing command-line argument")
        sys.exit(1)
    
    print(f"hello, {sys.argv[1]}")
    sys.exit(0) 
    

    注意使用了点符号来利用 sys 的内置函数。

CSV 文件

  • Python 也内置了对 CSV 文件的支持。

  • 按照以下方式修改你的 phonebook.py 代码:

    import csv
    
    file = open("phonebook.csv", "a")
    
    name = input("Name: ")
    number = input("Number: ")
    
    writer = csv.writer(file)
    writer.writerow([name,number])
    
    file.close() 
    

    注意 writerow 会为我们添加 CSV 文件中的逗号。

  • 虽然 file.closefile = open 是 Python 中常用且可用的语法,但此代码可以如下改进:

    import csv
    
    name = input("Name: ")
    number = input("Number: ")
    
    with open("phonebook.csv", "a") as file:
    
        writer = csv.writer(file)
        writer.writerow([name,number]) 
    

    注意,代码在 with 语句下缩进。这会在完成后自动关闭文件。

  • 类似地,我们可以在 CSV 文件中如下写入字典:

    import csv
    
    name = input("Name: ")
    number = input("Number: ")
    
    with open("phonebook.csv", "a") as file:
    
        writer = csv.DictWriter(file, fieldnames=["name", "number"])
        writer.writerow({"name": name, "number": number}) 
    

    注意此代码与之前的迭代相当相似,但使用了 csv.DictWriter

第三方库

  • Python 的一个优点是其庞大的用户基础和同样庞大的第三方库数量。

  • 如果你已经安装了 Python,你可以通过输入 pip install cs50 在你的电脑上安装 CS50 库。

  • 考虑其他库,David 展示了 cowsayqrcode 的使用。

总结

在本节课中,你学习了如何将之前课程中编程的基本构建块在 Python 中实现。此外,你还了解了 Python 如何使代码更加简化。同时,你学习了如何利用各种 Python 库。最后,你了解到作为一名程序员,你的技能并不仅限于单一编程语言。你已经看到,通过这门课程,你正在发现一种新的学习方法,这可以在任何编程语言中为你服务——也许在几乎任何学习领域中都能!具体来说,我们讨论了……

  • Python

  • 变量

  • 条件语句

  • 循环

  • 数据类型

  • 面向对象编程

  • 截断和浮点数不精确

  • 异常

  • 字典

  • 命令行参数

  • 第三方库

次次见!

posted @ 2025-11-08 11:25  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报