WHCSRL 技术网

[FreeCodeCamp笔记] Python 数据结构和算法1 二分搜索 Binary Search

我以前学过数据结构和算法(data structure and algorithms. 现在普遍简称DSA),当时用的Robert Sedgewick的coursera课程。这位大神写的《算法(第四版)》,是算法的经典教材,可惜这本书900页,我直接被吓跑了。而coursera课程用的是java,我又不会java,所以课后习题做的异常艰苦。

这几天,我又想学一下数据结构和算法,但是我决定用python学。其实我也会c++和c#,但是python显然是最简单易用的语言,使用python可以节省很多时间。于是,我在网上找免费的资源,最后,我找到了一个12个小时的视频。这个视频是FreeCodeCamp.org提供的。FreeCodeCamp是一个免费提供程序员教学视频的网站,很多人通过它实现了转行。它的视频的特点是时间都很长,最短的可能也有2小时。比起其他的编程视频,它是一个比较系统的教材。这边的python数据结构和算法,一共有6课,分别是Binary Search, Binary Search Trees, Hash和字典,递归和动态规划,图,面试技巧。

我看了第一课,虽然Binary Search比较简单,也许很多人觉得不用再看了。不过这一课里面,有比较规范的解题过程。从单元测试,到通用算法,这些都能和python的语言特性相结合,而且这些都是容易被我们忽略的。

问题

爱丽丝有几张扑克牌。她将扑克牌按降序排列,然后将它们按顺序面朝下放在桌子上。她挑战鲍勃,让他翻出尽可能少的扑克牌,从中选出包含给定数字的扑克牌。编写一个函数来帮助 Bob 定位卡片。

你为什么要学习数据结构和算法

无论您是从事软件开发还是数据科学职业,几乎可以肯定的是,您会被要求解决编程问题,例如在技术面试或编码评估中反转链表或平衡二叉树。

然而,众所周知,作为软件开发人员,您在工作中几乎永远不会遇到这些问题。所以有理由想知道为什么在面试和编码评估中会问这样的问题。解决编程问题表现出以下特点:

  1. 你可以系统地思考一个问题,然后一步一步地系统地解决它。
  2. 您可以为您编写的程序设想不同的输入、输出和边缘情况。
  3. 您可以向同事清楚地传达您的想法并采纳他们的建议。
  4. 最重要的是,您可以将您的想法和想法转化为可读的工作代码。

在面试中测试的不是您对特定数据结构或算法的了解,而是您解决问题的方法。您可能无法解决问题并仍然通过面试,反之亦然。在本课程中,您将学习成功解决问题和清晰面试的技能。

方法

阅读问题后,您可能会对如何解决它有一些想法,您的第一直觉可能是开始编写代码。这不是最佳策略,由于编码错误,您最终可能会花费更长的时间来尝试解决问题,或者根本无法解决问题。

这是我们将用于解决问题的系统策略:

  1. 把问题说清楚。识别输入和输出格式。
  2. 提出一些示例输入和输出。尝试覆盖所有边缘情况。
  3. 提出问题的正确解决方案。用简单的英语说出来。
  4. 实施解决方案并使用示例输入对其进行测试。修复错误,如果有的话。
  5. 分析算法的复杂性并找出效率低下的地方(如果有)。
  6. 应用正确的技术来克服低效率。重复步骤 3 到 6。

*“应用正确的技术”*是常用数据结构和算法知识派上用场的地方。

解决方案

1. 把问题说清楚。识别输入和输出格式。

在编码挑战和面试中,您经常会遇到详细的单词问题。第一步是用抽象的术语清楚而准确地陈述问题。

例如,在这种情况下,我们可以将扑克牌序列表示为数字列表。翻出一张特定的卡片,就相当于访问了列表对应位置的数字的值。

现在可以将问题表述如下:

问题

我们需要编写一个程序来查找给定数字在按降序排列的数字列表中的位置。我们还需要尽量减少访问列表中元素的次数。

输入

  1. cards:按降序排列的数字列表。例如[13, 11, 10, 7, 4, 3, 1, 0]
  2. query: 一个数字,要确定其在数组中的位置。例如7

输出

  1. position:query在列表中的位置cards。例如3在上述情况下(从 开始计数0

基于以上内容,我们现在可以创建函数的签名:

def locate_card(cards, query):
    pass
  • 1
  • 2

2. 单元测试

我们的函数应该能够处理我们传递给它的任何有效输入集。以下是我们可能会遇到的一些可能变化的列表:

  1. 该数字query出现在列表中间的某个位置cards
  2. query是 中的第一个元素cards
  3. query是 中的最后一个元素cards
  4. 该列表cards仅包含一个元素,即query
  5. 该列表cards不包含 number query
  6. 该列表cards为空。
  7. 该列表cards包含重复数字。
  8. 该数字query出现在 中的多个位置cards
  9. (你能想到更多的变化吗?)
tests = []

# query occurs in the middle
# 在中间的情况
tests.append({
    'input': {
        'cards': [13, 11, 10, 7, 4, 3, 1, 0],
        'query': 1
    },
    'output': 6
})

# query is the first element
# 第一张扑克牌就是我们要找的
tests.append({
    'input': {
        'cards': [4, 2, 1, -1],
        'query': 4
    },
    'output': 0
})

# query is the last element
# 最后一张扑克牌
tests.append({
    'input': {
        'cards': [3, -1, -9, -127],
        'query': -127
    },
    'output': 3
})

# cards contains just one element, query
# 只有一张扑克牌
tests.append({
    'input': {
        'cards': [6],
        'query': 6
    },
    'output': 0 
})

# cards does not contain query 
# 找不到
tests.append({
    'input': {
        'cards': [9, 7, 5, 2, -9],
        'query': 4
    },
    'output': -1
})

# cards is empty
# 扑克牌队列是空的
tests.append({
    'input': {
        'cards': [],
        'query': 7
    },
    'output': -1
})

# numbers can repeat in cards
# 有重复的数字
tests.append({
    'input': {
        'cards': [8, 8, 6, 6, 6, 6, 6, 3, 2, 2, 2, 0, 0, 0],
        'query': 3
    },
    'output': 7
})

# query occurs multiple times
# 被查找的扑克牌出现很多次
tests.append({
    'input': {
        'cards': [8, 8, 6, 6, 6, 6, 6, 6, 3, 2, 2, 2, 0, 0, 0],
        'query': 6
    },
    'output': 2
})
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81

让我们看一下我们的单元测试。

>>>tests
[{'input': {'cards': [13, 11, 10, 7, 4, 3, 1, 0], 'query': 7}, 'output': 3},
 {'input': {'cards': [13, 11, 10, 7, 4, 3, 1, 0], 'query': 1}, 'output': 6},
 {'input': {'cards': [4, 2, 1, -1], 'query': 4}, 'output': 0},
 {'input': {'cards': [3, -1, -9, -127], 'query': -127}, 'output': 3},
 {'input': {'cards': [6], 'query': 6}, 'output': 0},
 {'input': {'cards': [9, 7, 5, 2, -9], 'query': 4}, 'output': -1},
 {'input': {'cards': [], 'query': 7}, 'output': -1},
 {'input': {'cards': [8, 8, 6, 6, 6, 6, 6, 3, 2, 2, 2, 0, 0, 0], 'query': 3},
  'output': 7},
 {'input': {'cards': [8, 8, 6, 6, 6, 6, 6, 6, 3, 2, 2, 2, 0, 0, 0],
   'query': 6},
  'output': 2}]
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

3. 提出问题的正确解决方案。用简单的语言表达出来。

我们的首要目标应该始终是为问题提出正确的解决方案,这可能是最有效的解决方案。一个问题的最简单或最明显的解决方案,通常涉及检查所有可能的答案,称为蛮力解决方案。

在这个问题中,想出一个正确的解决方案是很容易的:Bob 可以简单地将卡片一张一张地翻过来,直到他找到一张上面有给定数字的卡片。下面是我们如何实现它:

  1. 创建一个position值为 0的变量。
  2. 检查是否在索引数量positioncard平等query
  3. 如果是,position就是答案并且可以从函数返回
  4. 如果不是,则将 的值增加position1,并重复步骤 2 到 5,直到我们到达最后一个位置。
  5. 如果未找到该号码,则返回-1

线性搜索算法:恭喜,我们刚刚编写了我们的第一个算法!算法只是一个语句列表,可以将其转换为代码并由计算机在不同的输入集上执行。这种特定的算法称为线性搜索,因为它涉及以线性方式搜索列表,即元素一个元素。

**提示:**在开始编码之前,始终尝试用自己的语言表达(说或写)算法。它可以根据您的需要简短或详细。写作是清晰思考的好工具。您可能会发现解决方案的某些部分难以表达,这表明您可能无法清楚地思考它。你越能清楚地表达你的想法,你就越容易转化为代码。

4. 实施解决方案并使用示例输入对其进行测试,修复错误。

呼!我们终于准备好实施我们的解决方案了。到目前为止我们所做的所有工作肯定会派上用场,因为我们现在正是我们想要我们的函数做的事情,并且我们有一种简单的方法可以在各种输入上测试它。

这是实现该功能的第一次尝试。

def locate_card(cards, query):
    # Create a variable position with the value 0
    position = 0
    
    # Set up a loop for repetition
    while True:
        
        # Check if element at the current position matche the query
        if cards[position] == query:
            
            # Answer found! Return and exit..
            return position
        
        # Increment the position
        position += 1
        
        # Check if we have reached the end of the array
        if position == len(cards):
            
            # Number not found, return -1
            return -1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

让我们用第一个测试用例来测试这个函数

>>>result = locate_card(test['input']['cards'], test['input']['query'])
>>>result
3
  • 1
  • 2
  • 3

好极了!结果与输出匹配。

接着,我们测试所有的test case。

def test_all(tests, func):
    for test in tests:
        result = func(**test['input'])
        if result == test['output']:
            print(f"pass, expected: {test['output']}, actual: {result}")
        else:
            print(f"fail, expected: {test['output']}, actual: {result}")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

我们现在运行所有的单元测试。

>>>test_all(tests, locate_card)
pass, expected: 3, actual: 3
pass, expected: 6, actual: 6
pass, expected: 0, actual: 0
pass, expected: 3, actual: 3
pass, expected: 0, actual: 0
pass, expected: -1, actual: -1

---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-54-e822f482ec2b> in <module>
----> 1 test_all(tests, locate_card)

<ipython-input-53-680d62f7d94a> in test_all(tests, func)
      1 def test_all(tests, func):
      2     for test in tests:
----> 3         result = func(**test['input'])
      4         if result == test['output']:
      5             print(f"pass, expected: {test['output']}, actual: {result}")

<ipython-input-41-9ed30c367c36> in locate_card(cards, query)
      7 
      8         # Check if element at the current position matche the query
----> 9         if cards[position] == query:
     10 
     11             # Answer found! Return and exit..

IndexError: list index out of range


  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

我们发现,第7个单元测试报错了。因为这里cards为空,所以试图取得第一个元素失败。

我们可以增加一个检查position是否合法的语句。

def locate_card(cards, query):
    position = 0
    while position < len(cards):
        if cards[position] == query:
            return position
        position += 1
    return -1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

然后,我们再测试。

>>>test_all(tests, locate_card)
pass, expected: 3, actual: 3
pass, expected: 6, actual: 6
pass, expected: 0, actual: 0
pass, expected: 3, actual: 3
pass, expected: 0, actual: 0
pass, expected: -1, actual: -1
pass, expected: -1, actual: -1
pass, expected: 7, actual: 7
pass, expected: 2, actual: 2

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

不错,所有的单元测试都通过了。

5. 分析算法的复杂性并找出效率低下的地方(如果有)。

回想一下原始问题中的陈述:“爱丽丝挑战鲍勃,让他翻出尽可能少的卡片,从中选出包含给定数字的卡片。” 我们将这个要求重申为:“最小化我们访问列表中元素的次数cards

在我们最小化数量之前,我们需要一种方法来衡量它。由于我们在每次迭代中访问一个列表元素一次,对于一个大小的列表,N我们最多访问列表中的元素N。因此,N在最坏的情况下,Bob 可能需要翻转卡片,才能找到所需的卡片。

假设他每分钟只允许翻一张牌,如果桌子上放了 30 张牌,他可能需要 30 分钟才能找到所需的牌。这是他能做到的最好的吗?鲍勃可以通过只翻出 5 张牌而不是 30 张牌来得出答案吗?

与找到完成计算机程序执行所需的时间、空间或其他资源量有关的研究领域称为算法分析。找出解决给定问题的最佳算法的过程称为算法设计和优化

复杂性和大 O 符号

算法的复杂性是算法对于给定大小的输入所需的时间和/或空间量的度量,例如N。除非另有说明,否则术语复杂性总是指最坏情况的复杂性(即程序/算法处理输入所花费的最高可能时间/空间)。

在线性搜索的情况下:

  1. 该算法的时间复杂度cN针对某个固定常数的c,它取决于我们在每次迭代中执行的操作数量以及执行一条语句所花费的时间。时间复杂度有时也称为算法的运行时间
  2. 空间复杂度是某个常数c'(独立的N),因为我们只需要一个变量position来迭代通过数组,它占用计算机的内存(RAM)的恒定空间。

大 O 表示法:最坏情况的复杂性通常使用大 O 表示法表示。在 Big O 中,我们去掉了固定常数和变量的较低幂以捕捉输入大小与算法复杂度之间关系的趋势,即如果算法的复杂度为cN^3 + dN^2 + eN + f,则在 Big O 符号中表示为O(N^3)

因此,线性搜索的时间复杂度为O(N),其空间复杂度为O(1)

6. 应用正确的技术来克服低效率。重复步骤 3 到 6。

目前,我们只是一张一张地翻阅卡片,甚至没有利用它们被排序的面孔。这称为蛮力方法。

如果 Bob 能在第一次尝试时以某种方式猜出这张牌那就太好了,但是当所有的牌都翻过来时,根本不可能猜出正确的牌。

下一个最好的想法是随机选择一张卡片,并使用列表已排序的事实来确定目标卡片位于它的左侧还是右侧。事实上,如果我们选择中间卡,我们可以将要测试的附加卡数量减少到列表大小的一半。然后,我们可以简单地对每一半重复这个过程。这种技术称为二分查找。

7. 提出问题的正确解决方案。用简单的英语说出来。

以下是二分搜索如何应用于我们的问题:

  1. 找到列表的中间元素。
  2. 如果与查询到的号码匹配,则返回中间位置作为答案。
  3. 如果小于查询的数,则搜索列表的前半部分
  4. 如果大于查询的数,则搜索列表的后半部分
  5. 如果没有更多元素剩余,则返回 -1。

8. 实施解决方案并使用示例输入对其进行测试。修复错误

def locate_card(cards, query):
    lo, hi = 0, len(cards) - 1
    
    while lo <= hi:
        mid = (lo + hi) // 2
        mid_number = cards[mid]
        
        print("lo:", lo, ", hi:", hi, ", mid:", mid, ", mid_number:", mid_number)
        
        if mid_number == query:
            return mid
        elif mid_number < query:
            hi = mid - 1  
        elif mid_number > query:
            lo = mid + 1
    
    return -1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

单元测试

>>>test_all(tests, locate_card)
lo: 0 , hi: 7 , mid: 3 , mid_number: 7
pass, expected: 3, actual: 3
lo: 0 , hi: 7 , mid: 3 , mid_number: 7
lo: 4 , hi: 7 , mid: 5 , mid_number: 3
lo: 6 , hi: 7 , mid: 6 , mid_number: 1
pass, expected: 6, actual: 6
lo: 0 , hi: 3 , mid: 1 , mid_number: 2
lo: 0 , hi: 0 , mid: 0 , mid_number: 4
pass, expected: 0, actual: 0
lo: 0 , hi: 3 , mid: 1 , mid_number: -1
lo: 2 , hi: 3 , mid: 2 , mid_number: -9
lo: 3 , hi: 3 , mid: 3 , mid_number: -127
pass, expected: 3, actual: 3
lo: 0 , hi: 0 , mid: 0 , mid_number: 6
pass, expected: 0, actual: 0
lo: 0 , hi: 4 , mid: 2 , mid_number: 5
lo: 3 , hi: 4 , mid: 3 , mid_number: 2
pass, expected: -1, actual: -1
pass, expected: -1, actual: -1
lo: 0 , hi: 13 , mid: 6 , mid_number: 6
lo: 7 , hi: 13 , mid: 10 , mid_number: 2
lo: 7 , hi: 9 , mid: 8 , mid_number: 2
lo: 7 , hi: 7 , mid: 7 , mid_number: 3
pass, expected: 7, actual: 7
lo: 0 , hi: 14 , mid: 7 , mid_number: 6
fail, expected: 2, actual: 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

我们发现,最后一个单元测试失败了。

回顾一下最后一个单元测试。

>>>tests[-1]
{'input': {'cards': [8, 8, 6, 6, 6, 6, 6, 6, 3, 2, 2, 2, 0, 0, 0], 'query': 6},
 'output': 2}
  • 1
  • 2
  • 3

原来,这里有多个6,但是我们并没有返回第一个6。这里,我们可以增加一个检查,如果我们找到的mid前面还有和query相同的值,那么我们就继续往前面找。

为方便起见,我们将定义一个名为 的辅助函数test_location,它将以 list cards、 thequerymid作为输入。

def test_location(cards, query, mid):
    mid_number = cards[mid]
    print("mid:", mid, ", mid_number:", mid_number)
    if mid_number == query:
        if mid-1 >= 0 and cards[mid-1] == query:
            return 'left'
        else:
            return 'found'
    elif mid_number < query:
        return 'left'
    else:
        return 'right'

def locate_card(cards, query):
    lo, hi = 0, len(cards) - 1
    
    while lo <= hi:
        print("lo:", lo, ", hi:", hi)
        mid = (lo + hi) // 2
        result = test_location(cards, query, mid)
        
        if result == 'found':
            return mid
        elif result == 'left':
            hi = mid - 1
        elif result == 'right':
            lo = mid + 1
    return -1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

测试

>>>test_all(tests, locate_card)
lo: 0 , hi: 7
mid: 3 , mid_number: 7
pass, expected: 3, actual: 3
lo: 0 , hi: 7
mid: 3 , mid_number: 7
lo: 4 , hi: 7
mid: 5 , mid_number: 3
lo: 6 , hi: 7
mid: 6 , mid_number: 1
pass, expected: 6, actual: 6
lo: 0 , hi: 3
mid: 1 , mid_number: 2
lo: 0 , hi: 0
mid: 0 , mid_number: 4
pass, expected: 0, actual: 0
lo: 0 , hi: 3
mid: 1 , mid_number: -1
lo: 2 , hi: 3
mid: 2 , mid_number: -9
lo: 3 , hi: 3
mid: 3 , mid_number: -127
pass, expected: 3, actual: 3
lo: 0 , hi: 0
mid: 0 , mid_number: 6
pass, expected: 0, actual: 0
lo: 0 , hi: 4
mid: 2 , mid_number: 5
lo: 3 , hi: 4
mid: 3 , mid_number: 2
pass, expected: -1, actual: -1
pass, expected: -1, actual: -1
lo: 0 , hi: 13
mid: 6 , mid_number: 6
lo: 7 , hi: 13
mid: 10 , mid_number: 2
lo: 7 , hi: 9
mid: 8 , mid_number: 2
lo: 7 , hi: 7
mid: 7 , mid_number: 3
pass, expected: 7, actual: 7
lo: 0 , hi: 14
mid: 7 , mid_number: 6
lo: 0 , hi: 6
mid: 3 , mid_number: 6
lo: 0 , hi: 2
mid: 1 , mid_number: 8
lo: 2 , hi: 2
mid: 2 , mid_number: 6
pass, expected: 2, actual: 2
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50

删除所有的print以后,我们完成了这个任务。

def test_location(cards, query, mid):
    if cards[mid] == query:
        if mid-1 >= 0 and cards[mid-1] == query:
            return 'left'
        else:
            return 'found'
    elif cards[mid] < query:
        return 'left'
    else:
        return 'right'

def locate_card(cards, query):
    lo, hi = 0, len(cards) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        result = test_location(cards, query, mid)
        if result == 'found':
            return mid
        elif result == 'left':
            hi = mid - 1
        elif result == 'right':
            lo = mid + 1
    return -1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

9. 分析算法的复杂性并找出效率低下的地方(如果有)。

再一次,让我们尝试计算算法中的迭代次数。如果我们从一个包含 N 个元素的数组开始,那么每次下一次迭代时数组的大小都会减少一半,直到只剩下 1 个元素。

初始长度 - N

迭代 1 - N/2

迭代 2 -N/4N/2^2

迭代 3 -N/8N/2^3

迭代 k - N/2^k

由于数组的最终长度为 1,我们可以找到

N/2^k = 1
  • 1

重新排列条款,我们得到

N = 2^k
  • 1

取对数

k = log N
  • 1

哪里log是指以 2 为底的对数。因此,我们的算法的时间复杂度为O(log N)。这个事实通常被表述为:二分查找在对数时间内运行。您可以验证二分查找的空间复杂度为O(1)

10. 速度比较

我们把我们最初的方法命名为locate_card_linear。

def locate_card_linear(cards, query):
    position = 0
    while position < len(cards):
        if cards[position] == query:
            return position
        position += 1
    return -1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

我们新建一个测试用例。

large_test = {
    'input': {
        'cards': list(range(10000000, 0, -1)),
        'query': 2
    },
    'output': 9999998
    
} 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

brutal force 方法:

>>>%%%%%%%%timeit locate_card_linear(**large_test["input"])
1.69 s ± 152 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
  • 1
  • 2

binary search:

>>>%%%%%%%%timeit locate_card(**large_test["input"])
9.88 µs ± 809 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
  • 1
  • 2

两者速度相差160,000倍。所以,用算法很重要。

此外,随着输入的大小变大,差异只会变得更大。对于 10 倍大小的列表,线性搜索将运行 10 倍,而二分搜索只需要 3 次额外的操作!(你能验证一下吗?)这就是复杂度**O(N)O(log N)**之间的真正区别。

另一种看待它的方式是c * N / log N,对于某些固定常量,二分搜索比线性搜索运行 得快几倍c。由于log N与 相比增长非常缓慢N,因此差异随着输入的大小而变大。这是一个图表,显示了如何比较算法运行时间的常用函数(来源):

img

你现在明白为什么我们在使用大 O 符号表示复杂性时忽略常数和低阶项了吗?

通用二分搜索

下面是二分搜索背后的一般策略,它适用于各种问题:

  1. 想出一个条件来确定答案是在给定位置之前、之后还是在给定位置
  2. 检索列表的中点和中间元素。
  3. 如果是答案,则返回中间位置作为答案。
  4. 如果答案在它之前,请使用列表的前半部分重复搜索
  5. 如果答案在其后,请使用列表的后半部分重复搜索。

这是在 Python 中实现的二分搜索的通用算法:

def binary_search(lo, hi, condition):
    """TODO - add docs"""
    while lo <= hi:
        mid = (lo + hi) // 2
        result = condition(mid)
        if result == 'found':
            return mid
        elif result == 'left':
            hi = mid - 1
        else:
            lo = mid + 1
    return -1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

二分查找的最坏情况复杂度或运行时间为O(log N),前提是用于确定答案是在给定位置之前、之后还是在给定位置的条件的复杂度为O(1)

请注意,binary_search接受一个函数condition作为参数。与 C++ 和 Java 不同,Python 允许将函数作为参数传递给其他函数。

我们现在可以locate_card使用该binary_search函数更简洁地重写该函数。

def locate_card(cards, query):
    
    def condition(mid):
        if cards[mid] == query:
            if mid > 0 and cards[mid-1] == query:
                return 'left'
            else:
                return 'found'
        elif cards[mid] < query:
            return 'left'
        else:
            return 'right'
    
    return binary_search(0, len(cards) - 1, condition)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

测试

>>>test_all(tests, locate_card)
pass, expected: 3, actual: 3
pass, expected: 6, actual: 6
pass, expected: 0, actual: 0
pass, expected: 3, actual: 3
pass, expected: 0, actual: 0
pass, expected: -1, actual: -1
pass, expected: -1, actual: -1
pass, expected: 7, actual: 7
pass, expected: 2, actual: 2
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

binary_search功能现在也可用于解决其他问题。这是一个经过测试的逻辑。

问题:给定一个按升序排序的整数数组 nums,找到给定数字的开始和结束位置。

这仅在两个重要方面与问题不同:

  1. 数字按升序排列。
  2. 我们正在寻找递增顺序和递减顺序。

这是解决问题的完整代码,通过对我们之前的函数进行微小修改获得:

def first_position(nums, target):
    def condition(mid):
        if nums[mid] == target:
            if mid > 0 and nums[mid-1] == target:
                return 'left'
            return 'found'
        elif nums[mid] < target:
            return 'right'
        else:
            return 'left'
    return binary_search(0, len(nums)-1, condition)

def last_position(nums, target):
    def condition(mid):
        if nums[mid] == target:
            if mid < len(nums)-1 and nums[mid+1] == target:
                return 'right'
            return 'found'
        elif nums[mid] < target:
            return 'right'
        else:
            return 'left'
    return binary_search(0, len(nums)-1, condition)

def first_and_last_position(nums, target):
    return first_position(nums, target), last_position(nums, target)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

我们可以通过在此处提交来测试我们的解决方案:https : //leetcode.com/problems/find-first-and-last-position-of-element-in-sorted-array/

重温

这是我们用于解决问题的系统策略:

  1. 把问题说清楚。识别输入和输出格式。
  2. 提出一些示例输入和输出。尝试覆盖所有边缘情况。
  3. 提出问题的正确解决方案。用简单的英语说出来。
  4. 实施解决方案并使用示例输入对其进行测试。修复错误,如果有的话。
  5. 分析算法的复杂性并找出效率低下的地方(如果有)。
  6. 应用正确的技术来克服低效率。重复步骤 3 到 6。

使用此模板使用此方法解决问题:https : //jovian.ai/aakashns/python-problem-solving-template

这种看似显而易见的策略将帮助您解决在面试或编码评估中将面临的几乎所有编程问题。

本课程的目的是通过反复将其应用于不同类型的问题,重新连接您的大脑以使用这种方法进行思考。这是一门关于系统地思考问题并将这些想法转化为代码的课程。

练习

这里有一些资源可以了解更多信息并找到要练习的问题。

二分搜索赋值:https://jovian.ai/aakashns/python-binary-search-assignment
LeetCode 上的二分搜索问题:https://leetcode.com/problems/binary-search/
GeeksForGeeks 上的二分搜索问题:https://www.geeksforgeeks.org/binary-search/
Codeforces 上的二进制搜索问题:https://codeforces.com/problemset ? tags = binary+search
使用此模板解决问题:https://jovian.ai/aakashns/python-problem-solving-template

在论坛上开始讨论:https://jovian.ai/forum/c/data-structures-and-algorithms-in-python/lesson-1-binary-search-linked-lists-and-complex/81

课后作业 - 旋转列表

这里,我们将运用所学的Binary Search来解决一个复杂问题。

问题 - 旋转列表

我们将逐步解决以下问题:

有一个排序好的整数列表,被旋转(rotate)了若干次。写一个函数,找出其被旋转的最小次数。时间复杂度为O(log N), N 是列表的长度。列表里没有重复数字。

例子:列表[0, 2, 3, 4, 5, 6, 9]旋转了3次,得到[5, 6, 9, 0, 2, 3, 4]

我们定义旋转(rotation)为把最后列表的最后一个数字插入到最前面。如[3, 2, 4, 1]旋转一次得到[1, 3, 2, 4]。

方法

以下是我们将用于解决问题的系统策略:

  1. 把问题说清楚。识别输入和输出格式。
  2. 提出一些示例输入和输出。尝试覆盖所有边缘情况。
  3. 提出问题的正确解决方案。
  4. 测试方案,修正方案。

解决方案

1. 把问题说清楚,识别输入和输出格式

问题

找出一个被旋转列表的旋转次数

输入

nums: 一个被旋转的数列

输出

rotations:被旋转的次数

基于以上,我们有函数的签名为

from typing import List
def count_rotations(nums: List[int]) -> int:
    pass
  • 1
  • 2
  • 3

2. 单元测试

tests = []

# A list of size 8 rotated 5 times.
# 一个长度为8,旋转5次的数列
tests.append({
    'input': {
        'nums': [4, 5, 6, 7, 8, 1, 2, 3]
    },
    'output': 5
})

# A list that wasn't rotated at all.
# 没有选装的数列
tests.append({
    'input': {
        'nums': [1, 2, 3, 4, 5, 6, 7, 8]
    },
    'output': 0
})


# A list that was rotated just once.
# 旋转一次的数列
tests.append({
    'input': {
        'nums': [8, 1, 2, 3, 4, 5, 6]
    },
    'output': 1
})


# A list that was rotated n-1 times, where n is the size of the list.
# 旋转n-1次的数列
tests.append({
    'input': {
        'nums': [2, 3, 4, 5, 6, 7, 8, 1]
    },
    'output': 7
})

# A list that was rotated n times, where n is the size of the list
# 旋转次数等于数列长度
tests.append({
    'input': {
        'nums': [1, 2, 3, 4, 5, 6, 7, 8]
    },
    'output': 0
})

# An empty list.
# 空数列
tests.append({
    'input': {
        'nums': []
    },
    'output': 0
})

# A list containing just one element.
# 只有一个数字的数列
tests.append({
    'input': {
        'nums': [1]
    },
    'output': 0
})

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67

查看

>>>tests
[{'input': {'nums': [4, 5, 6, 7, 8, 1, 2, 3]}, 'output': 5},
 {'input': {'nums': [1, 2, 3, 4, 5, 6, 7, 8]}, 'output': 0},
 {'input': {'nums': [8, 1, 2, 3, 4, 5, 6]}, 'output': 1},
 {'input': {'nums': [2, 3, 4, 5, 6, 7, 8, 1]}, 'output': 7},
 {'input': {'nums': [1, 2, 3, 4, 5, 6, 7, 8]}, 'output': 0},
 {'input': {'nums': []}, 'output': 0},
 {'input': {'nums': [1]}, 'output': 0}]
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

测试所有

def test_all(tests, func):
    for test in tests:
        actual = func(**test['input'])
        status = "pass" if actual == test['output'] else "fail"
        print(f"{status}, expected:{test['output']}, actual:{actual}")
  • 1
  • 2
  • 3
  • 4
  • 5

运行

>>>test_all(tests, count_rotations)
fail, expected:5, actual:None
fail, expected:0, actual:None
fail, expected:1, actual:None
fail, expected:7, actual:None
fail, expected:0, actual:None
fail, expected:0, actual:None
fail, expected:0, actual:None

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

3. 提出问题的正确解决方案

这里,假如我们用brutal force。如果要找到旋转了几次,就要找出第一个数字的位置。那么我们不断取出两个数字,直到找到了第二个数字小于第一个数字的情况。如果没找到,那么说明这个数列没有被旋转过。或者旋转了n次,n等于数列长度。

直接运用binary search。

您可能已经猜到了,我们可以应用binary search来解决这个问题。在二分搜索中我们需要回答的关键问题是:给定中间元素,如何决定它是答案(最小的数字),还是答案在它的左边或右边。

如果中间元素比它的前任小,那么它就是答案。但是,如果不是,则此检查不足以确定答案位于其左侧还是右侧。考虑以下示例。

[7, 8, 1, 3, 4, 5, 6] (答案位于中间元素的左侧)

[1, 2, 3, 4, 5, -1, 0] (答案位于中间元素的右侧)

这里有一个检查可以帮助我们确定答案在左边还是右边:如果列表的中间元素小于范围的最后一个元素,那么答案就在它的左边。否则,答案就在右边。

def count_rotations_binary(nums):
    lo = 0
    hi = len(nums)-1
    
    while lo <= hi:
        mid = (lo + hi) // 2
        mid_number = nums[mid]
        # Uncomment the next line for logging the values and fixing errors.
        # print("lo:", lo, ", hi:", hi, ", mid:", mid, ", mid_number:", mid_number)
        
        if mid > 0 and nums[mid]<nums[mid-1]:
            # The middle position is the answer
            return mid
        
        elif nums[mid]<nums[hi]:
            # Answer lies in the left half
            hi = mid - 1  
        
        else:
            # Answer lies in the right half
            lo = mid + 1
    
    return 0

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

测试

>>>test_all(tests, count_rotations_binary)
pass, expected:5, actual:5
pass, expected:0, actual:0
pass, expected:1, actual:1
pass, expected:7, actual:7
pass, expected:0, actual:0
pass, expected:0, actual:0
pass, expected:0, actual:0

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

不错,通过了。

leetcode上面有一题和这个差不多,你可以试试:
https://leetcode.com/problems/search-in-rotated-sorted-array/

推荐阅读