Week 3 算法

欢迎!

  • 在第 0 周,我们介绍了算法这个概念:它就像一个黑盒,可以接收输入并产生输出。

  • 本周,我们将通过伪代码进一步理解算法,并逐步进入真正的代码实现。

  • 同时,我们也会思考这些算法的效率。事实上,我们会在上周讨论过的一些概念基础上,继续学习如何用它们来构建算法。

回想课程前面介绍过的这张图:

  • 进入本周时,你应该思考:算法处理问题的方式,可能会决定解决问题所需的时间!算法可以被设计得越来越高效,但这种高效也有其极限。

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

线性搜索

  • 回想上周,我们介绍过数组的概念:数组是连续的一块块内存,也就是彼此相邻、挨在一起的内存空间。

你可以把数组形象地想象成一排七个红色储物柜,如下所示:

  • 最左边的位置称为位置 0,也就是数组的开头。最右边的位置是位置 6,也就是数组的末尾

  • 可以想象,我们有一个基本问题想要回答:“数字 50 是否在数组中?”计算机必须查看每一个储物柜,才能知道数字 50 是否在里面。我们把寻找这样一个数字、字符、字符串或其他项目的过程称为搜索

我们可以把数组交给一个算法,让这个算法逐一检查这些储物柜,看看数字 50 是否藏在某扇门后,并返回 truefalse

我们可以想象给算法提供如下指令来完成这个任务:

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 right half

使用更接近代码的命名方式,我们可以进一步修改算法如下:

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

请注意,看到这段接近代码的表达时,你几乎已经能想象它在真实代码中会是什么样子。

运行时间

  • 你可以思考一个算法解决问题需要多长时间。

运行时间通常使用 big O 表示法来分析。请看下面这张图:

  • 计算机科学家讨论效率时,通常不会过分精确到每一个数学细节,而是用不同运行时间的数量级来描述效率。

  • 在上图中,第一个算法是 \(O(n)\),也就是 n 的数量级。第二个同样是 \(O(n)\)。第三个是 \(O(\log n)\)。

真正体现算法效率的是曲线的形状。我们常见的一些运行时间包括:

  \(O(n^2)\)
  • \(O(n \log n)\)

  • \(O(n)\)

  • \(O(\log n)\)

  • \(O(1)\)

  • 在上面的运行时间中,\(O(n^2)\) 通常被认为是最慢的运行时间。\(O(1)\) 是最快的。

  • 线性搜索的数量级是 \(O(n)\),因为在最坏情况下,它可能需要执行 n 步。

  • 二分搜索的数量级是 \(O(\log n)\),因为即使在最坏情况下,它运行时所需的步骤也会越来越少。

  • 程序员既关心最坏情况,也就是上界,也关心最好情况,也就是下界

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

  • \(\Theta\) 符号用于表示上界和下界相同的情况:也就是最好情况和最坏情况的运行时间相同。

渐近表示法用于衡量当输入越来越大时,算法表现得有多好。

  • 随着你继续学习计算机科学,你会在后续课程中更深入地探索这些主题。

search.c

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

// Implements linear search for integers

#include
#include

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
#include
#include

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;
}

请注意,我们不能像上一版程序那样使用 ==。相反,我们使用来自 string.h 库的 strcmp。如果两个字符串相同,strcmp 会返回 0。还要注意,字符串数组的长度 6 是硬编码的,这并不是良好的编程习惯。

  • 运行这段代码确实可以让我们遍历这个字符串数组,查看某个字符串是否在其中。不过,如果你遇到段错误,也就是程序访问了它不该访问的内存位置,请确认上面写的是 i < 6,而不是 i < 7

  • 你可以在 CS50 Manual Pages 上了解更多关于 strcmp 的信息。

phonebook.c

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

// Implements a phone book without structs

#include
#include
#include

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 创建自己的数据类型。

创建一个名为 person 的自定义数据类型,让它内部包含 namenumber,是不是会很有用?请看下面的代码:

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

请注意,这表示我们自己的数据类型 person,它包含一个名为 name 的字符串,以及另一个名为 number 的字符串。

我们可以把之前的电话簿程序修改如下,从而改进代码:

// Implements a phone book with structs

#include
#include
#include

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 的字符串,以及一个名为 numberstring。在 main 函数中,我们先创建一个名为 people 的数组,它的类型是 person,大小为 3。然后,我们更新 people 数组中几个人的姓名和电话号码。最重要的是,请注意点号表示法,例如 people[0].name,它允许我们访问第 0 个位置的 person,并为这个人指定姓名。

排序

排序是把一个未排序的值列表转换为有序列表的过程。

  • 当列表已经排好序时,搜索这个列表对计算机来说会轻松得多。回想一下,我们可以在有序列表上使用二分搜索,但不能在无序列表上这样做。

  • 事实上,排序算法有很多不同类型。

选择排序就是其中一种排序算法。

我们可以把数组表示如下:

选择排序算法的伪代码是:

For i from 0 to n1
    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^2)\)。在最坏情况或上界中,选择排序的数量级是 \(O(n^2)\)。在最好情况或下界中,选择排序的数量级是 \(\Omega(n^2)\)。

冒泡排序

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

冒泡排序的伪代码是:

Repeat n-1 times
    For i from 0 to n2
        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^2)\)。

  • 在最坏情况或上界中,冒泡排序的数量级是 \(O(n^2)\)。在最好情况或下界中,冒泡排序的数量级是 \(\Omega(n)\)。

  • 你可以通过这个可视化比较这些算法。

递归

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

递归是编程中的一个概念,指一个函数调用它自身。我们之前已经见过这种思想,比如:

If no doors left
    Return false
If number behind middle door
    Return true
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

回想第 1 周,我们想创建如下金字塔结构:

  #
  ##
  ###
  ####

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

// Draws a pyramid using iteration

#include
#include

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
#include

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)\),因为最好情况和最坏情况相同。

  • 课程中还分享了最后一个可视化

总结

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

  • 算法。

  • Big O 表示法。

  • 二分搜索和线性搜索。

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

  • 递归。

下次见!