IT加油站

数据结构与算法可视化:图解两数之和、二分查找与冒泡排序

34浏览 5天前 软件教程 MA122809

原文:https://dev.to/nyaomaru/is-learning-dsa-boring-lets-use-dsa-view-view-two-sum-binary-search-and-bubble-sort-374o(作者 @nyaomaru)

你用过 DSA View View 吗?

DSA View View 能让你通过可视化代码的实际执行过程来理解数据结构与算法。

但仅仅介绍这个工具还不够。

它真的能帮助我们理解数据结构与算法吗?

在这篇文章中,我们将一起探讨三个经典问题:

  • 两数之和
  • 二分查找
  • 冒泡排序

我们会先理解算法原理,然后通过 DSA View View 来观察实际发生了什么。

📌 图片描述(图,点击查看)

一起学习吧!


🗺️ 两数之和

让我们从一个非常著名的问题开始。

给定一个数字数组和一个目标值,找出数组中和为目标值的两个数字的索引。

例如,

nums = [2, 7, 11, 15];
target = 9;


答案是

[0, 1];


因为

2 + 7 = 9


很简单!

那么,我们应该如何找到它们呢?

暴力解法

最直接的方法大概是检查所有可能的配对。

function twoSum(nums: number[], target: number): number[] {
  for (let i = 0; i < nums.length; i++) {
    for (let j = i + 1; j < nums.length; j++) {
      if (nums[i] + nums[j] === target) {
        return [i, j];
      }
    }
  }

  return [];
}


这个方法可行。

但如果数组很大,我们可能需要进行大量的配对比较,对吧?

时间复杂度是 _O(n²)_。

我们能否避免重复检查相同的值?

可以。

我们使用一个 Map

function twoSum(nums: number[], target: number): number[] {
  const seen = new Map<number, number>();

  for (let i = 0; i < nums.length; i++) {
    const current = nums[i];
    const need = target - current;

    if (seen.has(need)) {
      return [seen.get(need)!, i];
    }

    seen.set(current, i);
  }

  return [];
}


关键部分在这里 👇

const need = target - current;


与其问

我应该组合哪两个数字?

我们问

我需要什么数字来补全目标值?

让我们跟随示例来理解。

一开始

current = 2
target = 9

need = 9 - 2
     = 7


我们之前见过 7 吗?

没有。

所以我们记下 2

seen = {
  2 → 0
}


接下来

current = 7
target = 9

need = 9 - 7
     = 2


我们之前见过 2 吗?

见过!

seen = {
  2 → 0
}


所以

return [0, 1];


完成了!

因为我们只需要遍历数组一次,时间复杂度变为:

时间复杂度:  O(n)
空间复杂度: O(n)


👀 让我们来“看看”它

代码实现相当简洁。

但当我最初学习这个模式时,这部分感觉还是有点神奇。

if (seen.has(need))


  • need 是从哪里来的?
  • 此时 seen 里面是什么?
  • 为什么检查之前的值就能解决问题?

这正是可视化能帮上忙的地方。

📌 图片描述(图,点击查看)

{% embed https://dsa-view-view.vercel.app/#s=j.eyJlIjoidHdvLXN1bSIsImwiOiJ0eXBlc2NyaXB0IiwibSI6InZlcmlmaWNhdGlvbiIsInYiOjF9 %}

借助 DSA View View,我们可以一步一步地跟踪运行过程,查看值是如何变化的。

2
↓
需要 7
↓
记下 2
↓
7
↓
需要 2
↓
找到 2!


现在,Map 不再是什么神秘的技巧了。

我们完全可以理解这个思路。

记住我们已经看过的,然后检查我们需要的值是否存在。

不错!

🔍 二分查找

接下来是二分查找

假设我们有这样一个有序数组

[1, 3, 5, 7, 9, 11, 13]


我们要查找

11


当然,可以从 1 开始逐个检查。

1 → 3 → 5 → 7 → 9 → 11


这当然可行。

但二分查找采取了更聪明的策略。

它不从头开始,而是从中间开始检查。

[1, 3, 5, 7, 9, 11, 13]
          ↑
         mid


中间值是 7

我们要找的是 11

11 > 7


因为数组是有序的,我们立刻能推断出一条非常有用的信息。

7 左侧的所有值也都小于 11

因此,我们不再需要考虑左半边了。👋

[1, 3, 5, 7, 9, 11, 13]
             └───────┘
               search


接下来检查剩余区间的中间值。

[9, 11, 13]
     ↑
    mid


此时

11 === 11


找到了!🎉

以下是具体实现。

function binarySearch(nums: number[], target: number): number {
  let left = 0;
  let right = nums.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (nums[mid] === target) {
      return mid;
    }

    if (nums[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }

  return -1;
}


有三个关键变量。

left
right
mid


它们代表当前的搜索范围。

以我们的例子为开始状态:

left = 0
right = 6
mid = 3

[1, 3, 5, 7, 9, 11, 13]
 ↑        ↑          ↑
left     mid       right


由于

nums[mid] < target;


我们移动 left

left = mid + 1;


现在变为

[1, 3, 5, 7, 9, 11, 13]
             ↑   ↑   ↑
           left mid right


于是找到了 11

为什么二分查找很快?

这是最有趣的部分。

每一步操作都排除了大约一半的剩余候选值

假设有 1,000 个值,我们不一定需要检查 1,000 次。

它大致是这样一个过程

1000
↓
500
↓
250
↓
125
↓
...


因此二分查找具有

时间复杂度:O(log n)
空间复杂度:O(1)


但有一个非常重要的前提条件。

数据必须是有序的。

如果数据无序,我们就无法安全地丢弃一半的搜索范围。

让我们可视化一下

二分查找正是让我最初想要开发可视化工具的算法之一。

代码本身很短

left = mid + 1;


或者

right = mid - 1;


很简单。

但在学习时,我偶尔会疑惑:

等等... 我们现在搜索的是哪一部分?😿

📌 Image description(图,点击查看)

{% embed https://dsa-view-view.vercel.app/#s=j.eyJlIjoiYmluYXJ5LXNlYXJjaCIsImwiOiJ0eXBlc2NyaXB0IiwibSI6InZlcmlmaWNhdGlvbiIsInYiOjF9 %}

当我们把 leftmidright 可视化出来,思路就清晰多了。

我们并非随意修改三个数字。

而是在持续缩小搜索区域。

███████████████

       ↓

        ███████

       ↓

          ███

       ↓

           █


这就是二分查找!

砍掉不需要的那一半。

然后再砍。再砍。再砍。

直到找到答案。✂️😸

冒泡排序

最后,我们来对一些东西进行排序!

考虑这个数组。

[5, 1, 4, 2, 8];


我们期望得到。

[1, 2, 4, 5, 8];


冒泡排序反复比较 两个相邻的值

如果它们的顺序错了,就交换它们。

让我们看看开头部分。

[5, 1, 4, 2, 8]
 ↑  ↑


比较

5 > 1


因此交换它们。

[1, 5, 4, 2, 8]


接下来

[1, 5, 4, 2, 8]
    ↑  ↑


再次

5 > 4


交换!

[1, 4, 5, 2, 8]


并继续。

[1, 4, 5, 2, 8]
       ↑  ↑

5 > 2


交换!

[1, 4, 2, 5, 8]


最终,较大的值会向数组的末尾移动。

它们有点像是...

冒泡上去了。这就是它被称为 冒泡排序 的原因。

下面是一个简单的实现:

function bubbleSort(nums: number[]): number[] {
  for (let i = 0; i < nums.length - 1; i++) {
    for (let j = 0; j < nums.length - i - 1; j++) {
      if (nums[j] > nums[j + 1]) {
        [nums[j], nums[j + 1]] = [nums[j + 1], nums[j]];
      }
    }
  }

  return nums;
}


我们反复比较

nums[j];


nums[j + 1];


并在必要时交换它们。

经过一轮完整的遍历后,剩余的最大值会到达其末尾附近的正确位置。

所以在下一轮遍历中,我们不需要再检查那个位置。

这就是为什么内层循环包含

nums.length - i - 1;


复杂度

冒泡排序对大数组来说速度不快。

它的时间复杂度是

时间:O(n²)
空间:O(1)


所以我大概不会明天就突然用冒泡排序替换生产环境的排序算法。但作为一个学习示例,我真的很喜欢它。

为什么?

因为你可以 看到算法在工作

用 View View 看看

这可能是三个算法中视觉上最令人满意的。

📌 图片描述(图,点击查看)

{% embed https://dsa-view-view.vercel.app/#s=j.eyJlIjoiYnViYmxlLXNvcnQiLCJsIjoidHlwZXNjcmlwdCIsIm0iOiJ2ZXJpZmljYXRpb24iLCJ2IjoxfQ %}

我们不只是阅读

[nums[j], nums[j + 1]] = [nums[j + 1], nums[j]];


我们可以跟随这些值在数组中移动。

[5, 1, 4, 2, 8]

 ↓ 交换

[1, 5, 4, 2, 8]

    ↓ 交换

[1, 4, 5, 2, 8]

       ↓ 交换

[1, 4, 2, 5, 8]


然后下一轮遍历开始。

代码包含嵌套循环、索引、比较和交换。

但从视觉上看,基本规则极其简单:

比较相邻元素。如果左边的更大,就交换它们。

重复。重复。重复。

排序完成!


我们到底学到了什么?

这三个问题看起来大不相同。

但每个问题都引入了一种有用的思维方式。

两数之和

记住之前步骤中的信息。

我已经见过我需要的东西吗?


二分查找

利用我们已知的信息来排除不可能的候选者。

我能安全地丢弃一半的搜索空间吗?


冒泡排序

将一个更大的问题分解成许多小的比较。

这两个值的顺序正确吗?


这是我觉得学习数据结构与算法有趣的地方之一。

起初,实现看起来可能像是一堆索引、循环、条件和神秘变量的集合。

但在代码背后,通常有一个简单得多的核心思想。

有时我只盯着代码并不能完全理解那个思想。

我想 把它看出来


结论

在这篇文章中,我们研究了三个经典算法:

  • 使用 Map 的两数之和
  • 二分查找
  • 冒泡排序

更重要的是,我们观察了 数据在算法运行时如何变化

我认为这就是可视化特别有用的地方。

  • 阅读最终的实现告诉我们 代码是什么
  • 逐步调试它有助于我们理解 它为什么有效

这正是我构建 DSA View View 的原因。

{% embed https://dsa-view-view.vercel.app %}

你可以编写或加载一个 TypeScript 实现,使用自己的输入运行它,并在运行时前后移动步骤。

如果你也在学习数据结构与算法,可以尝试拿一个你已经解决过的问题,然后一步步地查看它。

你可能会注意到一些只阅读代码时没有注意到的东西。

如果你有希望我接下来讲解的算法问题,请在评论中告诉我!

我自己还有很多算法需要学习。

让我们一起锻炼数据结构与算法这块肌肉吧!

原文:https://dev.to/nyaomaru/is-learning-dsa-boring-lets-use-dsa-view-view-two-sum-binary-search-and-bubble-sort-374o(作者 @nyaomaru)

#数据结构 #算法可视化 #两数之和 #二分查找 #冒泡排序 #TypeScript