JavaScript 数据结构和算法教程(二)
六、JavaScript 对象
JavaScript 对象是 JavaScript 编程语言如此通用的原因。在深入研究数据结构和算法之前,让我们回顾一下 JavaScript 对象是如何工作的。本章将关注什么是 JavaScript 对象,如何声明它们,以及如何改变它们的属性。此外,本章将介绍如何使用原型继承实现 JavaScript 类。
JavaScript 对象属性
JavaScript 对象可以通过对象字面量{}或语法 new Object();来创建。可以通过两种方式添加或访问附加属性:object.propertyName或object['propertyName']。
1 var javaScriptObject = {};
2 var testArray = [1,2,3,4];
3
4 javaScriptObject.array = testArray;
5 console.log(javaScriptObject); // {array: [1,2,3,4]}
6
7 javaScriptObject.title = 'Algorithms';
8 console.log(javaScriptObject); // {array: [1,2,3,4], title:'Algorithms'}
如前面的代码所示,title属性在第 7 行被动态添加到 JavaScript 对象中。类似地,JavaScript 类中的函数也是通过动态添加到对象中来添加的。
原型遗传
在大多数强类型语言(如 Java)中,类的方法是与类同时定义的。然而,在 JavaScript 中,该函数必须作为该类的 JavaScript Object属性添加。
下面是一个使用this.functionName = function(){}的 JavaScript 类的例子:
1 function ExampleClass(){
2 this.name = "JavaScript";
3 this.sayName = function(){
4 console.log(this.name);
5 }
6 }
7
8 //new object
9 var example1 = new ExampleClass();
10 example1.sayName(); //"JavaScript"
这个类在构造函数中动态添加了sayName函数。这种模式被称为原型遗传。
原型继承是 JavaScript 中唯一的继承方法。要添加一个类的函数,只需使用.prototype属性并指定函数名。
当您使用.prototype属性时,您实际上是在动态扩展对象的 JavaScript Object属性。这是标准的,因为 JavaScript 是动态的,类可以在以后需要时添加新的函数成员。这对于 Java 等编译语言来说是不可能的,因为它们会在编译时抛出错误。JavaScript 的这个独特属性让开发人员可以利用原型继承。
这里有一个使用.prototype的例子:
1 function ExampleClass(){
2 this.array = [1,2,3,4,5];
3 this.name = "JavaScript";
4 }
5
6 //new object
7 var example1 = new ExampleClass();
8
9 ExampleClass.prototype.sayName = function() {
10 console.log(this.name);
11 }
12
13 example1.sayName(); //"JavaScript"
重申一下,动态地向类添加函数是 JavaScript 实现原型继承的方式。类的函数要么在构造函数中添加,要么通过.prototype添加。
构造函数和变量
因为 JavaScript 中一个类的变量是该类对象的属性,所以任何用this.propertyName声明的属性都是公开可用的。这意味着可以在其他范围内直接访问对象的属性。
1 function ExampleClass(name, size){
2 this.name = name;
3 this.size = size;
4 }
5
6 var example = new ExampleClass("Public",5);
7 console.log(example); // {name:"Public", size: 5}
8
9 // accessing public variables
10 console.log(example.name); // "Public"
11 console.log(example.size); // 5
为了模仿私有变量,而不是使用this.propertyName,您可以声明一个局部变量,并让 getter/setter 允许访问该变量。这样,变量只对构造函数的作用域可用。然而,值得注意的是,这些被模仿的私有变量现在只能通过定义的接口函数(getter getName和 setter setName)来访问。这些 getters 和 setters 不能添加到构造函数之外。
1 function ExampleClass(name, size) {
2 var privateName = name;
3 var privateSize = size;
4
5 this.getName = function() {return privateName;}
6 this.setName = function(name) {privateName = name;}
7
8 this.getSize = function() {return privateSize;}
9 this.setSize = function(size) {privateSize = size;}
10 }
11
12 var example = new ExampleClass("Sammie",3);
13 example.setSize(12);
14 console.log(example.privateName); // undefined
15 console.log(example.getName()); // "Sammie"
16 console.log(example.size); // undefined
17 console.log(example.getSize()); // 3
摘要
在 JavaScript 中,与其他面向对象的编程语言不同,原型继承是首选的继承方法。原型继承通过.prototype向 JavaScript 类添加新函数来工作。在 Java 和 C++中,私有变量是显式声明的。然而,JavaScript 不支持私有变量,为了模仿私有变量的功能,您需要创建一个作用于构造函数的变量。通过this.variableName在构造函数中将变量声明为该对象的一部分会自动使该属性成为公共属性。
练习
向对象添加属性
以两种不同的方式向一个空的 JavaScript 对象添加一个exampleKey属性,并将其设置为exampleValue。
正如本章前面所讨论的,可以通过两种方式将属性添加到对象中。使用一种方法比使用另一种方法没有性能优势或劣势;选择归结于风格。
1 var emptyJSObj = {};
2 emptyJSObj['exampleKey'] = 'exampleValue';
3 emptyJSObj.exampleKey = 'exampleValue';
定义类别
创建两个类:Animal和Dog。Animal类应该在构造函数中接受两个参数(name和animalType)。将它们设置为它的公共属性。
另外,Animal类应该有两个函数:sayName和sayAnimalType。sayName打印name,sayAnimalType打印在构造函数中初始化的animalType。
最后,Dog类继承了Animal类。
-
让我们首先定义
Animal类和指定的所需函数。 -
为了让
Dog类继承它,定义Dog类,然后复制它的原型,如下面的代码块所示:
1 function Animal(name, animalType) {
2 this.name = name;
3 this.animalType = animalType;
4 }
5 Animal.prototype.sayName = function () {
6 console.log(this.name);
7 }
8 Animal.prototype.sayAnimalType = function () {
9 console.log(this.animalType);
10 }
1 function Dog(name) {
2 Animal.call(this, name, "Dog");
3 }
4 // copy over the methods
5 Dog.prototype = Object.create(Animal.prototype);
6 var myAnimal = new Animal("ditto", "pokemon");
7 myAnimal.sayName(); // "ditto"
8 myAnimal.sayAnimalType(); // "pokemon"
9 var myDog = new Dog("candy", "dog");
10 myDog.sayName(); // "candy"
11 myDog.sayAnimalType(); // "dog"
七、JavaScript 内存管理
在任何程序中,变量都会占用一些内存。在 C 之类的低级编程语言中,程序员必须手动分配和释放内存。相比之下,V8 JavaScript 引擎和其他现代 JavaScript 引擎有垃圾收集器,为程序员删除未使用的变量。尽管这种内存管理是由 JavaScript 引擎完成的,但是开发人员可能会陷入一些常见的陷阱。本章将展示这些陷阱的一些基本例子,并介绍帮助垃圾收集器最小化关键 JavaScript 内存问题的技术。
内存泄漏
一个内存泄漏是一个程序释放被丢弃的内存失败,导致性能下降,有时甚至失败。当 JavaScript 引擎的垃圾收集器没有正确释放内存时,就会发生内存泄漏。
遵循本章概述的关键原则,以避免 JavaScript 开发过程中的内存泄漏。
对对象的引用
如果存在对某个对象的引用,它就在内存中。在这个例子中,假设memory()函数返回一个包含 5KB 数据的数组。
1 var foo = {
2 bar1: memory(), // 5kb
3 bar2: memory() // 5kb
4 }
5
6 function clickEvent(){
7 alert(foo.bar1[0]);
8 }
您可能希望clickEvent()函数使用 5KB 的内存,因为它只从foo对象中引用bar1。然而,事实是它使用了 10KB 的内存,因为它必须将整个foo对象加载到函数的 into 范围中,以访问bar1属性。
泄漏 DOM
如果指向 DOM 元素的变量是在事件回调之外声明的,那么它就在内存中,如果元素被删除,就会泄漏 DOM。
在这个例子中,document.getElementByID选择了两个 DOM 元素。
1 <div id="one">One</div>
2 <div id="two">Two</div>
下面的 JavaScript 代码演示了 DOM 内存泄漏。当one被点击时,它移除two。当one再次被点击时,它仍然试图引用被移除的two。
1 var one = document.getElementById("one");
2 var two = document.getElementById("two");
3 one.addEventListener('click', function(){
4 two.remove();
5 console.log(two); // will print the html even after deletion
6 });
当点击时,one元素上的事件监听器将导致two从网页上消失。然而,即使在 HTML 中删除了 DOM,如果在事件回调中使用,对它的引用仍将保留。当two元素不再被使用时,这就是内存泄漏,应该避免。
这很容易修复,因此不会导致内存泄漏,如下所示:
1 var one = document.getElementById("one");
2
3 one.addEventListener('click', function(){
4 var two = document.getElementById("two");
5 two.remove();
6 });
解决这个问题的另一种方法是在使用 click 处理程序后将其注销,如下所示:
1 var one = document.getElementById("one");
2 function callBackExample() {
3 var two = document.getElementById("two");
4 two.remove();
5 one.removeEventListener("click",callBackExample);
6 }
7 one.addEventListener("click",callBackExample);
8 });
全局窗口对象
如果一个对象在全局窗口对象上,它就在内存中。window对象是浏览器中的一个全局对象,带有各种内置方法,如alert()和setTimeout()。声明为window属性的任何附加对象都不会被清除,因为window是浏览器运行所必需的对象。记住,任何声明的全局变量都将被设置为window对象的属性。
在这个例子中,声明了两个全局变量。
1 var a = "apples"; //global with var
2 b = "oranges"; //global without var
3
4 console.log(window.a); // prints "apples"
5 console.log(window.b); // prints "oranges"
尽可能避免全局变量是有好处的。这将有助于节省内存。
限制对象引用
当所有引用都被清除时,对象也被清除。一定要记住限制函数的作用域,只将对象的属性传递给函数,而不是整个对象。这是因为对象的内存占用可能非常大(例如,用于数据可视化项目的 100,000 个整数的数组);如果只需要对象的一个属性,应该避免使用整个对象作为参数。
例如,不要这样做:
1 var test = {
2 prop1: 'test'
3 }
4
5 function printProp1(test){
6 console.log(test.prop1);
7 }
8
9 printProp1(test); //'test'
相反,应该像这样传递属性:
1 var test = {
2 prop1: 'test'
3 }
4
5 function printProp1(prop1){
6 console.log(prop1);
7 }
8
9 printProp1(test.prop1); //'test'
删除操作符
永远记住delete操作符可以用来删除不需要的对象属性(尽管它对非对象不起作用)。
1 var test = {
2 prop1: 'test'
3 }
4 console.log(test.prop1); // 'test'
5 delete test.prop1;
6 console.log(test.prop1); // _undefined_
摘要
尽管 JavaScript 中的内存不是由程序员分配的,但是仍然有很多方法可以减少内存泄漏。如果对象在引用中,它就在内存中。同样,HTML DOM 元素一旦被删除就不应该被引用。最后,只引用函数中需要的对象。在许多情况下,传入对象的属性比传入对象本身更适用。此外,在声明全局变量时要特别小心。
练习
在这一章中,练习是关于识别内存低效和优化一段给定的代码。
分析和优化属性调用
分析并优化对printProperty的调用。
1 function someLargeArray() {
2 return new Array(1000000);
3 }
4 var exampleObject = {
5 'prop1': someLargeArray(),
6 'prop2': someLargeArray()
7 }
8 function printProperty(obj){
9 console.log(obj['prop1']);
10 }
11 printProperty(exampleObject);
**问题:**在printProperty中使用了过量的内存,因为整个对象都被带入了printProperty函数。要解决这个问题,应该只将正在打印的属性作为函数的参数引入。
答案:
1 function someLargeArray() {
2 return new Array(1000000);
3 }
4 var exampleObject = {
5 'prop1': someLargeArray(),
6 'prop2': someLargeArray()
7 }
8 function printProperty(prop){
9 console.log(prop);
10 }
11 printProperty(exampleObject['prop1']);
分析和优化范围
分析并优化以下代码块的全局范围:
1 var RED = 0,
2 GREEN = 1,
3 BLUE = 2;
4
5 function redGreenBlueCount(arr) {
6 var counter = new Array(3) .fill(0);
7 for (var i=0; i < arr.length; i++) {
8 var curr = arr[i];
9 if (curr == RED) {
10 counter[RED]++;
11 } else if (curr == GREEN) {
12 counter[GREEN]++;
13 } else if (curr == BLUE) {
14 counter[BLUE]++;
15 }
16 }
17 return counter;
18 }
19 redGreenBlueCount([0,1,1,1,2,2,2]); // [1, 3, 3]
**问题:**全局变量用在了不必要的地方。尽管很小,全局变量RED、GREEN和BLUE扩大了全局范围,应该被移到redGreenBlueCount函数中。
答案:
1 function redGreenBlueCount(arr) {
2 var RED = 0,
3 GREEN = 1,
4 BLUE = 2,
5 counter = new Array(3) .fill(0);
6 for (var i=0; i < arr.length; i++) {
7 var curr = arr[i];
8 if (curr == RED) {
9 counter[RED]++;
10 } else if (curr == GREEN) {
11 counter[GREEN]++;
12 } else if (curr == BLUE) {
13 counter[BLUE]++;
14 }
15 }
16 return counter;
17 }
18 redGreenBlueCount([0,1,1,1,2,2,2]); // [1, 3, 3]
分析和修复内存问题
分析并修复以下代码的内存问题。
HTML:
<button id="one">Button 1</button>
<button id="two">Button 2</button>
JavaScript:
1 var one = document.querySelector("#one");
2 var two = document.querySelector("#two");
3 function callBackExample () {
4 one.removeEventListener("",callBackExample);
5 }
6 one.addEventListener('hover', function(){
7 two.remove();
8 console.log(two); // will print the html even after deletion
9 });
10 two.addEventListener('hover', function(){
11 one.remove();
12 console.log(one); // will print the html even after deletion
13 });
**问题:**这是本章前面讨论的“泄漏 DOM”问题。当元素被移除时,它们仍然被回调函数引用。为了解决这个问题,将one和two变量放入回调的作用域中,然后移除事件监听器。
答案:
HTML:
<button id="one"> Button 1 </button>
<button id="two"> Button 2 </button>
JavaScript:
1 var one = document.querySelector("#one");
2 var two = document.querySelector("#two");
3 function callbackOne() {
4 var two = document.querySelector("#two");
5 if (!two)
6 return;
7 two.remove();
8 one.removeEventListener("hover", callbackOne);
9 }
10
11 function callbackTwo() {
12 var one = document.querySelector("#one");
13 if (!one)
14 return;
15 one.remove();
16 two.removeEventListener("hover", callbackTwo);
17 }
18 one.addEventListener("click", callbackOne);
19 two.addEventListener("click", callbackTwo);
八、递归
本章介绍递归和递归算法的概念。首先,将探索递归的定义和递归算法的基本规则。此外,分析递归函数效率的方法将使用数学符号详细介绍。最后,章节练习将有助于巩固这些信息。
引入递归
在数学、语言学和艺术中,递归指的是根据自身定义的事物的发生。在计算机科学中,递归函数是一个调用自身的函数。递归函数通常很优雅,通过“分而治之”的方法解决复杂的问题。递归很重要,因为你会在各种数据结构的实现中一次又一次地看到它。图 8-1 展示了一个递归的可视化说明,其中图片有自己的更小的图片。
图 8-1
图解递归
递归规则
当递归函数实现不正确时,会导致致命的问题,因为程序会被卡住而不会终止。无限递归调用导致栈溢出。栈溢出是程序的调用栈的最大数量超过了地址空间(内存)的限量。
为了正确实现递归函数,它们必须遵循一定的规则,以避免栈溢出。这些规则将在下面介绍。
基础案例
在递归中,必须有一个基础用例(也称为终止用例)。因为递归方法调用它们自己,除非达到这个基本情况,否则它们永远不会停止。递归导致的栈溢出很可能是因为没有合适的基本用例。在基本情况下,有没有递归函数调用。
让我们看看下面的函数,它打印从n到 0 的数字作为例子:
1 function countDownToZero(n) {
2 // base case. Stop at 0
3 if (n < 0) {
4 return; // stop the function
5 } else {
6 console.log(n);
7 countDownToZero(n - 1); // count down 1
8 }
9 }
10 countDownToZero(12);
该功能的基本情况是当n小于或等于 0 时。这是因为期望的结果是从 0 开始停止计数。如果输入的是负数,由于基本情况,它不会打印该数字。除了一个基本案例,这个递归函数还展示了分治法。
分治法
在计算机科学中,分而治之方法是指通过解决一个问题的所有较小部分来解决该问题。以倒计时为例,从 2 开始倒计时可以通过打印 2 然后从 1 开始倒计时来解决。这里,从 1 开始倒数的是通过“分而治之”解决的部分有必要将问题变小以达到基本情况。否则,如果递归调用没有收敛到基本情况,就会发生栈溢出。
现在让我们检查一个更复杂的递归函数,称为斐波那契数列。
经典例子:斐波那契数列
斐波那契数列是一个无限数字的列表,每个数字都是过去两项的总和(从 1 开始)。
- 1, 1, 2, 3, 5, 8, 13, 21 …
你如何编写程序来打印斐波那契数列的第 n 项?
迭代解法:斐波那契数列
使用for循环的迭代解决方案可能如下所示:
1 function getNthFibo(n) {
2 if ( n <= 1) return n;
3 var sum = 0,
4 last = 1,
5 lastlast = 0;
6
7 for (var i = 1; i < n; i++) {
8 sum = lastlast + last;
9 lastlast = last;
10 last = sum;
11 }
12 return sum;
13 }
一个for循环可以用来跟踪斐波纳契数列的最后两个元素,它的和产生斐波纳契数。
现在,这是如何递归完成的呢?
递归解:斐波那契
下面显示了递归解决方案:
1 function getNthFibo(n) {
2 if (n <= 1) {
3 return n;
4 } else {
5 return getNthFibo(n - 1) + getNthFibo(n - 2);
6 }
7 }
**基本情况:**斐波纳契数列的基本情况是第一个元素是 1。
**分而治之:**根据斐波那契数列的定义,第 n 个斐波那契数是第( n -1)个和第( n -2)个斐波那契数之和。但是,这种实现的时间复杂度为 O(2 n ),这将在本章后面详细讨论。在下一节中,我们将使用尾部递归来探索斐波那契数列的更有效的递归算法。
斐波那契数列:尾部递归
尾递归函数是递归函数,其中递归调用是函数中最后执行的东西。首先让我们看看迭代解:
1 function getNthFibo(n) {
2 if ( n <= 1) return n;
3 var sum = 0,
4 last = 1,
5 lastlast = 0;
6
7 for (var i = 1; i < n; i++) {
8 sum = lastlast + last;
9 lastlast = last;
10 last = sum;
11 }
12 return sum;
13 }
在每次迭代中,会发生以下更新:(lastlast, last) = (last, lastlast+last)。利用这种结构,可以形成下面的递归函数:
1 function getNthFiboBetter(n, lastlast, last) {
2 if (n == 0) {
3 return lastlast;
4 }
5 if (n == 1) {
6 return last;
7 }
8 return getNthFiboBetter(n-1, last, lastlast + last);
9 }
时间复杂度: O( n
这个函数最多执行 n 次,因为每次只有一次递归调用,它就递减 n -1。
空间复杂度 : O( n
因为这个函数使用了栈调用,所以空间复杂度也是 O( n )。这将在本章后面的“递归调用栈内存”一节中进一步解释。
为了总结递归的规则,让我们检查另一个更复杂的例子。
帕斯卡三角形
在这个例子中,将探索用于计算帕斯卡三角形的一项的函数。帕斯卡三角形是一个三角形,其元素值是其顶两(左、右)值之和,如图 8-2 。
图 8-2
帕斯卡三角形
**基本情况:**帕斯卡三角形的基本情况是顶元素(row=1,col=1)为 1。其他的一切都是单单从这个事实推导出来的。因此,当列为 1 时,返回 1,当行为 0 时,返回 0。
**分而治之:**根据帕斯卡三角形的数学定义,帕斯卡三角形的一项定义为其上项之和。因此,这可以表述为:pascalTriangle(row - 1, col) + pascalTriangle(row - 1, col - 1)。
1 function pascalTriangle(row, col) {
2 if (col == 0) {
3 return 1;
4 } else if (row == 0) {
5 return 0;
6 } else {
7 return pascalTriangle(row - 1, col) + pascalTriangle(row - 1, col - 1);
8 }
9 }
10 pascalTriangle(5, 2); // 10
这就是递归的妙处!接下来看看这段代码有多短多优雅。
大 O 代表递归
在第一章中,没有涉及递归算法的 Big-O 分析。这是因为递归算法很难分析。为了对递归算法进行 Big-O 分析,必须分析其递归关系。
递推关系
在迭代实现的算法中,Big-O 分析要简单得多,因为循环清楚地定义了何时停止以及每次迭代增加多少。为了分析递归算法,使用递归关系。递归关系由两部分分析组成:基础情况的 Big-O 和递归情况的 Big-O。
让我们重温一下简单的斐波那契数列例子:
function getNthFibo(n) {
if (n <= 1) {
return n;
} else {
return getNthFibo(n - 1) + getNthFibo(n - 2);
}
}
getNthFibo(3);
基本情况的时间复杂度为 O(1)。递归情况调用自己两次。我们把这个表示为T(n)=T(n1)+T(n2)+O(1)。
-
基本情况: T ( n ) = O(1)
-
**递归情况:**T(n)=T(n1)+T(n2)+O(1)
现在,这个关系意味着,既然T(n)=T(n1)+T(n2)+O(1),那么(通过将 n 替换为n1),T(n1)=用n2 替换n1 得到T*(n2)=T(n3)+T(n4)+O(1)。因此,你可以看到,对于每个调用,每个调用都有两个以上的调用。换句话说,这有 O 的时间复杂度(2 n )。*
把它想象成这样会有所帮助:
F(6) * <-- only once
F(5) *
F(4) **
F(3) ****
F(2) ********
F(1) **************** <-- 16
F(0) ******************************** <-- 32
用这种方法计算 Big-O 很困难,而且容易出错。谢天谢地,有一个叫做主定理的概念可以帮忙。主定理帮助程序员轻松分析递归算法的时间和空间复杂性。
主定理
主定理陈述如下:
- 给定一个形式为T(n)=aT(n/b)+O(nc)的递推关系,其中 a > = 1,b > =1,有三种情况。
a 是与递归调用相乘的系数。 b 是“对数”项,是递归调用过程中划分 n 的项。最后, c 是方程非递归分量上的多项式项。
第一种情况是非递归分量 O( n c )上的多项式项小于logb(a)时。
-
**案例一:**c<logb(a)然后 T(n)= O(n(logb(a))。
-
比如T(n)= 8T(n/2)+1000n2
-
识别 a,b,c: a = 8, b = 2, c = 2
-
评估: 日志 2 (8) = 3。c<3 满足。
-
**结果:**T(n)= O(n3)
第二种情况是当 c 为等于对logb(a)。
-
**案例二:**c=logb(a)然后 T(n)= O(nclog(n)。
-
比如T(n)= 2T(n/2)+10n。
-
确定 a,b,c: a = 2, b = 2, c = 1
-
求值: 日志 2 (2) = 1。 c = 1 满足。
-
结果:(n)= o(n【c】日志
第三种也是最后一种情况是当 c 大于logb(a)时。
-
**案例三:**c>logb(a)然后 T(n)= O(f(n))。
-
比如T(n)= 2T(n/2)+n2。
-
确定 a,b,c: a = 2,b = 2,c = 2
-
求值: 日志 2 (2) = 1。c>1 满足。
-
**结果:**T(n)=f(n)= O(n2)
这一节介绍了很多关于分析递归算法的时间复杂度。空间复杂性分析同样重要。递归函数调用使用的内存也应该记录下来,并进行空间复杂度分析。
递归调用栈内存
当一个递归函数调用它自己时,会占用内存,这在 Big-O space 复杂性分析中非常重要。
例如,这个从 n 到 1 的简单打印函数在空间中递归地取 O( n ):
1 function printNRecursive(n) {
2 console.log(n);
3 if (n > 1){
4 printNRecursive(n-1);
5 }
6 }
7 printNRecursive(10);
开发人员可以在浏览器或任何 JavaScript 引擎上运行该程序,并将在调用栈中看到如图 8-3 所示的结果。
图 8-3
开发人员工具中的调用栈
如图 8-3 和 8-4 所示,每个递归调用都必须存储在内存中,直到基本情况得到解决。由于调用栈,递归算法需要额外的内存。
图 8-4
调用栈内存
递归函数有一个额外的空间复杂性成本,它来自需要存储在操作系统内存栈中的递归调用。栈被累积,直到基础案例被解决。事实上,这通常是迭代解决方案优于递归解决方案的原因。在最坏的情况下,如果基本情况实现不正确,递归函数将导致程序崩溃,因为当内存栈中的元素超过允许的数量时,会出现栈溢出错误。
摘要
递归是实现复杂算法的强大工具。回想一下,所有递归函数都由两部分组成:基本情况和分治法(解决子问题)。
分析这些递归算法的 Big-O 可以凭经验(不推荐)或通过使用主定理来完成。回想一下,主定理需要以下形式的递推关系:T(n)=aT(n/b)+O(nc)。使用主定理时,识别 a 、 b 和 c 来确定它属于主定理三种情况中的哪一种。
最后,在实现和分析递归算法时,要考虑递归函数调用的调用栈所导致的额外内存。每个递归调用在运行时都需要在调用栈中有一个位置;当调用栈累计 n 次调用时,那么函数的空间复杂度为 O( n )。
练习
这些递归练习涵盖了不同的问题,有助于巩固从本章学到的知识。重点应该是在解决整个问题之前首先确定正确的基础案例。你可以在 GitHub 上找到所有练习的代码。 1
将十进制(以 10 为基数)转换为二进制数
要做到这一点,请将数字除以 2,每次计算模数(余数)和除法。
**基本情况:**这个问题的基本情况是当 n 小于 2 时。小于 2 时,只能是 0 或 1。
1 function base10ToString(n) {
2 var binaryString = "";
3
4 function base10ToStringHelper(n) {
5 if (n < 2) {
6 binaryString += n;
7 return;
8 } else {
9 base10ToStringHelper(Math.floor(n / 2));
10 base10ToStringHelper(n % 2);
11 }
12 }
13 base10ToStringHelper(n);
14
15 return binaryString;
16 }
17
18 console.log(base10ToString(232)); // 11101000
**时间复杂度:**O(log2(n))
时间复杂度是对数的,因为递归调用将 n 除以 2,这使得算法很快。例如,对于 n = 8,它只执行三次。对于 n =1024,它执行 10 次。
**空间复杂度:**O(log2(n))
打印数组的所有排列
这是一个经典的递归问题,也是一个很难解决的问题。问题的前提是在每个可能的位置交换数组的元素。
先来画一下这个问题的递归树(见图 8-5 )。
图 8-5
数组递归树的排列
基本情况: beginIndex等于endIndex。
当这种情况发生时,函数应该打印当前的排列。
**排列:**我们需要一个函数来交换元素:
1 function swap(strArr, index1, index2) {
2 var temp = strArr[index1];
3 strArr[index1] = strArr[index2];
4 strArr[index2] = temp;
5 }
1 function permute(strArr, begin, end) {
2 if (begin == end) {
3 console.log(strArr);
4 } else {
5 for (var i = begin; i < end + 1; i++) {
6 swap(strArr, begin, i);
7 permute(strArr, begin + 1, end);
8 swap(strArr, begin, i);
9 }
10 }
11 }
12
13 function permuteArray(strArr) {
14 permute(strArr, 0, strArr.length - 1);
15 }
16
17 permuteArray(["A", "C", "D"]);
18 // ["A", "C", "D"]
19 // ["A", "D", "C"]
20 // ["C", "A", "D"]
21 // ["C", "D", "A"]
22 // ["D", "C", "A"]
23 // ["D", "A", "C"]
时间复杂度: O( n !)
空间复杂度: O( n !)
有n!种排列,它创建了n!个调用栈。
弄平一个物体
给定这样一个 JavaScript 数组:
1 var dictionary = {
2 'Key1': '1',
3 'Key2': {
4 'a' : '2',
5 'b' : '3',
6 'c' : {
7 'd' : '3',
8 'e' : '1'
9 }
10 }
11 }
展平成{'Key1': '1', 'Key2.a': '2','Key2.b' : '3', 'Key2.c.d' : '3', 'Key2.c.e' : '1'},在父子之间用.表示子(见图 8-6 )。
图 8-6
展平字典递归树
要做到这一点,迭代任何属性并递归地检查它的子属性,传入连接的字符串名称。
**基本情况:**这个问题的基本情况是输入不是对象的时候。
1 function flattenDictionary(dictionary) {
2 var flattenedDictionary = {};
3
4 function flattenDitionaryHelper(dictionary, propName) {
5 if (typeof dictionary != 'object') {
6 flattenedDictionary[propName] = dictionary;
7 return;
8 }
9 for (var prop in dictionary) {
10 if (propName == "){
11 flattenDitionaryHelper(dictionary[prop], propName+prop);
12 } else {
13 flattenDitionaryHelper(dictionary[prop], propName+'.'+prop);
14 }
15 }
16 }
17
18 flattenDitionaryHelper(dictionary, ");
19 return flattenedDictionary;
20 }
时间复杂度: O( n
空间复杂度: O( n
每个属性只被访问一次,并且每 n 个属性存储一次。
写一个程序,递归地确定一个字符串是否是回文
一个回文是一个向后和向前拼写相同的单词,如神化、赛车、 testset 和 aibohphobia (对回文的恐惧)。
1 function isPalindromeRecursive(word) {
2 return isPalindromeHelper(word, 0, word.length-1);
3 }
4
5 function isPalindromeHelper(word, beginPos, endPos) {
6 if (beginPos >= endPos) {
7 return true;
8 }
9 if (word.charAt(beginPos) != word.charAt(endPos)) {
10 return false;
11 } else {
12 return isPalindromeHelper(word, beginPos + 1, endPos - 1);
13 }
14 }
15
16 isPalindromeRecursive('hi'); // false
17 isPalindromeRecursive('iii'); // true
18 isPalindromeRecursive('ii'); // true
19 isPalindromeRecursive('aibohphobia'); // true
20 isPalindromeRecursive('racecar'); // true
这背后的想法是,用两个索引(一个在前面,一个在后面),你检查每一步,直到前面和后面相遇。
时间复杂度: O( n
空间复杂度: O( n
由于递归调用栈,这里的空间复杂度仍然是 O( n )。请记住,调用栈仍然是内存的一部分,即使它没有声明变量或存储在数据结构中。
Footnotes 1https://github.com/Apress/js-data-structures-and-algorithms
**
九、集合
本章重点介绍如何使用集合。集合的概念从数学定义和在实现水平上被描述和探索。常见的集合操作,以及它们的实现,都有非常详细的介绍。本章结束时,你将理解如何使用 JavaScript 的本地Set对象来利用集合操作。
器械包简介
集合是最基本的数据结构之一。集合的概念很简单:它是一组明确的、不同的对象。通俗地说,在编程中,一个集合就是一组无序的唯一的(无重复)元素。例如,一组整数可能是{1,2,3,4}。其中,它的子集是{}、{1}、{2}、{3}、{4}、{1,2}、{1,3}、{1,4}、{2,3}、{2,4}、{3,4}、{1,2,3}、{1,2,4}、{1,3,4}和{2,3,4}。集合对于检查和添加 O(1)常数时间中的唯一元素非常重要。集合具有常量时间操作的原因是其实现是基于散列表的(在第十一章中讨论)。
Set在 JavaScript 中受本地支持,如下所示:
1 var exampleSet = new Set();
原生的Set对象只有一个属性:size(整数)。此属性是集合中元素的当前数量。
集合操作
该集合是用于执行唯一性检查的强大数据结构。本节将涵盖以下关键操作:插入、删除、包含。
插入
有一个主要功能:检查唯一性。Set可以添加项目,但不允许重复。
1 var exampleSet = new Set();
2 exampleSet.add(1); // exampleSet: Set {1}
3 exampleSet.add(1); // exampleSet: Set {1}
4 exampleSet.add(2); // exampleSet: Set {1, 2}
请注意,添加重复元素不适用于集合。正如在引言中所讨论的,插入到集合中是在恒定时间内发生的。
时间复杂度: O(1)
删除
Set也可以从集合中删除项目。Set.delete返回一个布尔值(如果该元素存在并被删除,则为真,否则为假)。
1 var exampleSet = new Set();
2 exampleSet.add(1); // exampleSet: Set {1}
3 exampleSet.delete(1); // true
4 exampleSet.add(2); // exampleSet: Set {2}
这对于能够在恒定时间内删除项目是有用的,相比之下,在数组中删除一个项目需要 O( n )时间。
时间复杂度: O(1)
包含
Set.has执行快速 O(1)查找,检查元素是否存在于集合中。
1 var exampleSet = new Set();
2 exampleSet.add(1); // exampleSet: Set {1}
3 exampleSet.has(1); // true
4 exampleSet.has(2); // false
5 exampleSet.add(2); // exampleSet: Set {1, 2}
6 exampleSet.has(2); // true
时间复杂度: O(1)
其他实用功能
除了本机支持的 set 函数之外,其他基本操作也是可用的;本节将对此进行探讨。
交集
首先,两个集合的交集由这两个集合之间的公共元素组成。此函数返回两个集合之间具有公共元素的集合:
1 function intersectSets (setA, setB) {
2 var intersection = new Set();
3 for (var elem of setB) {
4 if (setA.has(elem)) {
5 intersection.add(elem);
6 }
7 }
8 return intersection;
9 }
10 var setA = new Set([1, 2, 3, 4]),
11 setB = new Set([2, 3]);
12 intersectSets(setA,setB); // Set {2, 3}
父集
第二,一个集合是另一个集合的“超集”,如果它包含另一个集合的所有元素。这个函数检查一个集合是否是另一个集合的超集。这可以简单地通过检查另一个集合是否包含参考集合的所有元素来实现。
1 function isSuperset(setA, subset) {
2 for (var elem of subset) {
3 if (!setA.has(elem)) {
4 return false;
5 }
6 }
7 return true;
8 }
9 var setA = new Set([1, 2, 3, 4]),
10 setB = new Set([2, 3]),
11 setC = new Set([5]);
12 isSuperset(setA, setB); // true
13 // because setA has all elements that setB does
14 isSuperset(setA, setC); // false
15 // because setA does not contain 5 which setC contains
联盟
第三,两个集合的并集合并了两个集合中的元素。这个函数返回一个包含两个元素的新集合,没有重复的元素。
1 function unionSet(setA, setB) {
2 var union = new Set(setA);
3 for (var elem of setB) {
4 union.add(elem);
5 }
6 return union;
7 }
8 var setA = new Set([1, 2, 3, 4]),
9 setB = new Set([2, 3]),
10 setC = new Set([5]);
11 unionSet(setA,setB); // Set {1, 2, 3, 4}
12 unionSet(setA,setC); // Set {1, 2, 3, 4, 5}
差异
最后,集合 A 与集合 B 的差异是集合 A 中不在集合 B 中的所有元素。该函数通过使用本机delete方法来实现差异运算。
1 function differenceSet(setA, setB) {
2 var difference = new Set(setA);
3 for (var elem of setB) {
4 difference.delete(elem);
5 }
6 return difference;
7 }
8 var setA = new Set([1, 2, 3, 4]),
9 setB = new Set([2, 3]);
10 differenceSet(setA, setB); // Set {1, 4}
摘要
集合是表示无序唯一元素的基本数据结构。本章介绍了 JavaScript 的本机Set对象。Set对象支持插入、删除、包含检查,时间复杂度均为 O(1)。使用这些内置方法,可以实现其他基本的集合运算,如交集、差集、并集和超集检查。这些将使你在以后的章节中实现快速唯一性检查的算法。
表 9-1 总结了设置操作。
表 9-1
设置摘要
|操作
|
函数名
|
描述
|
| --- | --- | --- |
| 插入 | Set.add | 原生 JavaScript。如果元素不在集合中,则将其添加到集合中。 |
| 删除 | Set.delete | 原生 JavaScript。如果元素在集合中,则将其从集合中删除。 |
| 包含 | Set.has | 原生 JavaScript。检查元素是否存在于集合中。 |
| 交集(A∩B) | intersectSets | 返回具有集合 A 和集合 b 的公共元素的集合。 |
| 联合(a 至 b) | unionSet | 返回包含集合 A 和集合 b 的所有元素的集合。 |
| 差异(A-B) | differenceSet | 返回包含所有元素的集合。 |
练习
使用集合检查数组中的重复项
使用集合检查整数数组中是否有重复项。通过将数组转换为集合,可以将集合的大小与数组的长度进行比较,从而轻松检查重复项。
1 function checkDuplicates(arr) {
2 var mySet = new Set(arr);
3 return mySet.size < arr.length;
4 }
5 checkDuplicates([1,2,3,4,5]); // false
6 checkDuplicates([1,1,2,3,4,5]); // true
时间复杂度: O( n
空间复杂度: O( n
在一个长度为 n 的数组中,这个函数必须在最坏的情况下遍历整个数组,并且将所有这些元素存储在集合中。
从单独的数组中返回所有唯一值
给定两个具有相同值的整数数组,返回一个包含两个原始数组中所有唯一元素的数组。
使用集合,可以轻松存储唯一的元素。通过连接两个数组并将它们转换为一个集合,只存储唯一的项。将集合转换为数组会产生一个只包含唯一项的数组。
1 function uniqueList(arr1, arr2) {
2 var mySet = new Set(arr1.concat(arr2));
3 return Array.from(mySet);
4 }
5
6 uniqueList([1,1,2,2],[2,3,4,5]); // [1,2,3,4,5]
7 uniqueList([1,2],[3,4,5]); // [1,2,3,4,5]
8 uniqueList([],[2,2,3,4,5]); // [2,3,4,5]
时间复杂度: O( n + m
空间复杂度: O( n + m
该算法的时空复杂度为 O( n + m ),其中 n 为arr1的长度, m 为arr2的长度。这是因为两个数组中的所有元素都需要被迭代。
十、搜索和排序
搜索数据和整理数据是基本的算法。搜索指的是迭代数据结构的元素来检索一些数据。排序指的是将数据结构的元素按顺序排列。每种数据结构的搜索和排序算法都是不同的。本章重点介绍数组的搜索和排序。在本章结束时,你将理解如何使用数组的常用排序和搜索算法。
搜索
如前所述,搜索是在数据结构中寻找特定元素的任务。在数组中搜索时,根据数组是否排序,有两种主要的技术。在本节中,您将学习线性和二进制搜索。线性搜索特别灵活,因为它们可以用于排序和未排序的数据。二进制搜索专门用于排序数据。然而,线性搜索比二分搜索法具有更高的时间复杂度。
线性搜索
线性搜索的工作方式是依次遍历数组中的每个元素。下面的代码示例是线性搜索的实现,该搜索遍历整个数字数组,以确定数组中是否存在 4 和 5。
1 //iterate through the array and find
2 function linearSearch(array,n){
3 for(var i=0; i<array.length; i++) {
4 if (array[i]==n) {
5 return true;
6 }
7 }
8 return false;
9 }
10 console.log(linearSearch([1,2,3,4,5,6,7,8,9], 6)); // true
11 console.log(linearSearch([1,2,3,4,5,6,7,8,9], 10)); // false
时间复杂度: O( n
如图 10-1 所示,当搜索 6 时,会经历 6 次迭代。当搜索 10 时,它必须遍历所有的 n 个元素,然后返回false;因此时间复杂度为 O( n )。
图 10-1
线性搜索
作为另一个例子,对于数组[1,2,3,4,5]和搜索项 3,将需要三次迭代来完成(1,2,3)。这个算法的大 O 为 O( n )的原因是,在最坏的情况下,需要迭代整个数组。例如,如果搜索项为 5,则需要 5 次迭代(1、2、3、4、5)。如果 6 是搜索项,它将遍历整个数组(1,2,3,4,5),然后返回false,因为没有找到它。
如前所述,像这样的线性搜索算法是很棒的,因为不管数组是否排序,它都能工作。在线性搜索算法中,检查数组的每个元素。因此,当数组没有排序时,应该使用线性搜索。如果数组已排序,通过二分搜索法可以更快地进行搜索。
二进位检索
二分搜索法是一种搜索算法,对排序后的数据进行处理。与检查数组中每个元素的线性搜索算法不同,二进制搜索可以检查中间值,以查看所需值是大于还是小于它。如果期望值较小,该算法可以搜索较小的部分,或者如果期望值较大,它可以搜索较大的部分。
图 10-2 说明了二分搜索法的过程。首先,搜索范围是 1 到 9。因为中间的元素 5 大于 3,所以搜索范围被限制为 1 到 4。最后发现 3 是中间元素。图 10-3 展示了在数组的右半部分搜索一个项目。
图 10-3
二分搜索法在数组的右半部分
图 10-2
二分搜索法在阵的左半部
下面的代码实现了所描述的二分搜索法算法:
1 function binarySearch(array,n){
2 var lowIndex = 0, highIndex = array1.length-1;
3
4 while(lowIndex<=highIndex){
5 var midIndex = Math.floor((highIndex+lowIndex) /2);
6 if (array[midIndex]==n) {
7 return midIndex;
8 } else if (n>array[midIndex]) {
9 lowIndex = midIndex;
10 } else {
11 highIndex = midIndex;
12 }
13 }
14 return -1;
15 }
16 console.log(binarySearch([1,2,3,4], 4)); // true
17 console.log(binarySearch([1,2,3,4], 5)); // -1
二分搜索法算法速度很快,但只有在对数组进行排序的情况下才能实现。它检查中间的元素是否是正在搜索的元素。如果搜索元素比中间元素大,则下限被设置为中间元素加 1。如果搜索元素小于中间元素,则上限设置为中间元素减一。
这样,算法不断地将数组分成两部分:下半部分和上半部分。如果元素比中间的元素小,应该在下半部分找;如果元素比中间的元素大,应该在上半部分寻找。
人类在不知不觉中使用了二进制搜索。一个例子是按照姓氏从 A 到 Z 排列的电话簿。
如果你的任务是找到一个姓勒泽的人,你会先去 L 区,然后打开一半。莉莎在那一页上;这意味着下半部分包含 L + [a 到 i],上半部分包含 L + [i 到 z]个姓氏。然后你可以检查下半部分的中间。Laar 出现,所以您现在可以检查上面的部分。重复这个过程,直到找到勒泽。
整理
排序是计算机科学中最重要的课题之一;与未排序的排序数组相比,在排序数组中查找项目更快更容易。您可以使用排序算法对内存中的数组进行排序,以便稍后在程序中进行搜索,或者写入文件以便稍后检索。在本节中,我们将探讨不同的排序技术。我们将从简单的排序算法开始,然后探索高效的排序算法。高效的排序算法具有在使用过程中应该考虑的各种权衡。
冒泡排序
冒泡排序是最简单的排序算法。它简单地遍历整个数组,如果一个比另一个大,就交换元素,如图 10-4 和图 10-5 所示。
图 10-5
剩余的冒泡排序运行
图 10-4
首次运行冒泡排序
swap是排序中常用的函数。它只是切换两个数组元素的值,并将被用作前面提到的大多数排序算法的辅助函数。
1 function swap(array, index1, index2) {
2 var temp = array[index1];
3 array[index1] = array[index2];
4 array[index2] = temp;
5 }
下面的bubbleSort代码块说明了前面描述的冒泡排序算法:
1 function bubbleSort(array) {
2 for (var i=0, arrayLength = array.length; i<arrayLength; i++) {
3 for (var j=0; j<=i; j++) {
4 if (array[i] < array[j]) {
5 swap(array, i, j);
6 }
7 }
8 }
9 return array;
10 }
11 bubbleSort([6,1,2,3,4,5]); // [1,2,3,4,5,6]
**时间复杂度:**O(n2
空间复杂度: O(1)
冒泡排序是最差的排序类型,因为它比较每一对可能的排序,而其他排序算法利用数组的预排序部分。因为冒泡排序使用嵌套循环,所以时间复杂度为 O( n 2 )。
选择排序
选择排序的工作方式是扫描元素中最小的元素,并将其插入到数组的当前位置。这个算法比冒泡排序稍微好一点。图 10-6 显示了这个最小选择过程。
图 10-6
选择排序
下面的代码实现了选择排序。在代码中,有一个for循环迭代数组,还有一个嵌套的for循环扫描以获得最小元素。
1 function selectionSort(items) {
2 var len = items.length,
3 min;
4
5 for (var i=0; i < len; i++){
6 // set minimum to this position
7 min = i;
8 //check the rest of the array to see if anything is smaller
9 for (j=i+1; j < len; j++){
10 if (items[j] < items[min]){
11 min = j;
12 }
13 }
14 //if the minimum isn't in the position, swap it
15 if (i != min){
16 swap(items, i, min);
17 }
18 }
19
20 return items;
21 }
22 selectionSort([6,1,23,4,2,3]); // [1, 2, 3, 4, 6, 23]
**时间复杂度:**O(n2
空间复杂度: O(1)
- 由于嵌套的 for 循环,选择排序的时间复杂度仍然是 O( n 2 )。
插入排序
插入排序的工作方式类似于选择排序,它按顺序搜索数组,并将未排序的项目移动到数组左侧已排序的子列表中。图 10-7 详细显示了这一过程。
图 10-7
插入排序
下面的代码实现了插入排序算法。外部的for循环遍历数组索引,内部的for循环将未排序的项目移动到数组左侧已排序的子列表中。
1 function insertionSort(items) {
2 var len = items.length, // number of items in the array
3 value, // the value currently being compared
4 i, // index into unsorted section
5 j; // index into sorted section
6
7 for (i=0; i < len; i++) {
8 // store the current value because it may shift later
9 value = items[i];
10
11 // Whenever the value in the sorted section is greater than the value
12 // in the unsorted section, shift all items in the sorted section
13 // over by one. This creates space in which to insert the value.
14
15 for (j=i-1; j > -1 && items[j] > value; j--) {
16 items[j+1] = items[j];
17 }
18 items[j+1] = value;
19 }
20 return items;
21 }
22 insertionSort([6,1,23,4,2,3]); // [1, 2, 3, 4, 6, 23]
**时间复杂度:**O(n2
空间复杂度: O(1)
同样,由于嵌套的for循环,这种排序算法像冒泡和插入排序一样具有 O(n2 的二次时间复杂度。
快速分类
Quicksort 的工作原理是获得一个支点,并围绕它划分数组(一边是较大的元素,另一边是较小的元素),直到所有的元素都被排序。理想的支点是数组的中值,因为它将均匀地划分数组,但要计算未排序数组线性时间的中值。因此,通常通过取分区中第一个、中间和最后一个元素的中值来获得枢轴。这种排序是递归的,并使用分治法来打破二次复杂度障碍,并将时间复杂度降低到 O(nlog2(n))。然而,使用一个将所有东西都划分到一边的枢纽,时间复杂度更差:O(n2)。
图 10-8 非常详细地显示了快速排序过程的划分步骤。
图 10-8
快速分类
以下代码显示了快速排序算法的实现:
1 function quickSort(items) {
2 return quickSortHelper(items, 0, items.length-1);
3 }
4
5 function quickSortHelper(items, left, right) {
6 var index;
7 if (items.length > 1) {
8 index = partition(items, left, right);
9
10 if (left < index - 1) {
11 quickSortHelper(items, left, index - 1);
12 }
13
14 if (index < right) {
15 quickSortHelper(items, index, right);
16 }
17 }
18 return items;
19 }
20
21 function partition(array, left, right) {
22 var pivot = array[Math.floor((right + left) / 2)];
23 while (left <= right) {
24 while (pivot > array[left]) {
25 left++;
26 }
27 while (pivot < array[right]) {
28 right--;
29 }
30 if (left <= right) {
31 var temp = array[left];
32 array[left] = array[right];
33 array[right]= temp;
34 left++;
35 right--;
36 }
37 }
38 return left;
39 }
40
41 quickSort([6,1,23,4,2,3]); // [1, 2, 3, 4, 6, 23]
时间复杂度:平均 O(nlog2(n))最坏情况 O(n 2 )
**空间复杂度:**O(log2(n))
快速排序算法的一个缺点是,如果总是选择一个不好的支点,它可能是 O( n 2 )。一个坏的枢纽是它没有均匀地划分阵列。理想的支点是数组的中间元素。此外,由于递归中的调用栈,快速排序算法比其他排序算法需要更大的空间复杂度 O(log2(n))。
当平均性能应该是最佳时,使用快速排序算法。这与快速排序更适合 RAM 缓存这一事实有关。
快速选择
Quickselect 是一种在无序列表中寻找第 k 个最小元素的选择算法。快速选择使用与快速排序算法相同的方法。选择一个轴心,并对数组进行分区。然而,它不是像 quicksort 那样递归两边,而是只递归元素的一边。这样就把复杂度从 O(nlog2(n))降低到 O( n )。
Quickselect 在以下代码中实现:
1 var array = [1,3,3,-2,3,14,7,8,1,2,2];
2 // sorted form: [-2, 1, 1, 2, 2, 3, 3, 3, 7, 8, 14]
3
4 function quickSelectInPlace(A, l, h, k){
5 var p = partition(A, l, h);
6 if(p==(k-1)) {
7 return A[p];
8 } else if(p>(k-1)) {
9 return quickSelectInPlace(A, l, p - 1,k);
10 } else {
11 return quickSelectInPlace(A, p + 1, h,k);
12 }
13 }
14
15 function medianQuickselect(array) {
16 return quickSelectInPlace(array,0,array.length-1, Math.floor(array.length/2));
17 }
18
19 quickSelectInPlace(array,0,array.length-1,5); // 2
20 // 2 - because it's the fifth smallest element
21 quickSelectInPlace(array,0,array.length-1,10); // 7
22 // 7 - because it's the tenth smallest element
时间复杂度: O( n
合并分类
Mergesort 的工作原理是将数组分成子数组,直到每个数组都有一个元素。然后,每个子阵列按照排序顺序串接(合并)(见图 10-9 )。
图 10-9
合并分类
merge函数应该将两个数组中的所有元素按照排序后的顺序加到一个“结果数组”中为此,可以创建每个数组的索引来跟踪已经比较过的元素。一旦一个数组用完了它的所有元素,剩下的元素可以追加到结果数组中。
1 function merge(leftA, rightA){
2 var results= [], leftIndex= 0, rightIndex= 0;
3
4 while (leftIndex < leftA.length && rightIndex < rightA.length) {
5 if( leftA[leftIndex]<rightA[rightIndex] ){
6 results.push(leftA[leftIndex++]);
7 } else {
8 results.push(rightA[rightIndex++]);
9 }
10 }
11 var leftRemains = leftA.slice(leftIndex),
12 rightRemains = rightA.slice(rightIndex);
13
14 // add remaining to resultant array
15 return results.concat(leftRemains).concat(rightRemains);
16 }
merging 函数的工作原理是获取两个数组(左和右)并将它们合并成一个结果数组。为了保持顺序,在合并元素时需要对它们进行比较。
现在,mergeSort函数必须将较大的数组分成两个独立的数组,并递归调用merge。
1 function mergeSort(array) {
2
3 if(array.length<2){
4 return array; // Base case: array is now sorted since it's just 1 element
5 }
6
7 var midpoint = Math.floor((array.length)/2),
8 leftArray = array.slice(0, midpoint),
9 rightArray = array.slice(midpoint);
10
11 return merge(mergeSort(leftArray), mergeSort(rightArray));
12 }
13 mergeSort([6,1,23,4,2,3]); // [1, 2, 3, 4, 6, 23]
**时间复杂度:**O(nlog2(n))
空间复杂度: O( n
Mergesort 具有 O( n )的大空间复杂度,这是因为需要创建 n 个待合并的数组。当需要稳定排序时,使用 mergesort。稳定排序保证不会对具有相同键的元素进行重新排序。Mergesort 保证是 O(nlog2(n))。mergesort 的一个缺点是它在空间中使用 O( n )。
计数排序
计数排序可以在 O( k+n )中完成,因为它不比较值。它只对数字和给定的一定范围有效。这种计数不是通过交换元素来排序,而是通过计算数组中每个元素的出现次数来进行。一旦统计了每个元素的出现次数,就可以使用这些出现次数创建新数组。这样就可以对数据进行排序,而不必交换元素,如图 10-10 所示。
图 10-10
计数排序
下面是一个使用 JavaScript 对象的实现:
1 function countSort(array) {
2 var hash = {}, countArr= [];
3 for(var i=0;i<array.length;i++){
4 if(!hash[array[i]]){
5 hash[array[i]] = 1;
6 }else{
7 hash[array[i]]++;
8 }
9 }
10
11 for(var key in hash){
12 // for any number of _ element, add it to array
13 for(var i=0;i<hash[key];i++) {
14 countArr.push(parseInt(key));
15 }
16 }
17
18 return countArr;
19 }
20 countSort([6,1,23,2,3,2,1,2,2,3,3,1,123,123,4,2,3]); // [1, 2, 3, 4, 6, 23]
时间复杂度: O( k + n
空间复杂度: O( k
当对有限范围内的整数进行排序时,请使用计数排序。这将是这种情况下最快的排序。
JavaScript 的内置排序
JavaScript 有一个内置的用于数组对象的sort()方法,它按照升序对元素进行排序。要使用它,有一个可选参数,您可以在比较器函数中传递它。
然而,默认的比较器函数是按字母顺序排序的,所以它不适用于数字。
1 var array1 = [12,3,4,2,1,34,23];
2 array1.sort(); // array1: [1, 12, 2, 23, 3, 34, 4]
在前面的例子中,请注意以 1 开头的数字先出现(1,12),然后是以 2 开头的数字,依此类推。这是因为没有传递比较器函数,JavaScript 将元素转换成字符串并根据字母表排序。
要正确排序数字,请使用以下命令:
1 var array1 = [12,3,4,2,1,34,23];
2
3 function comparatorNumber(a,b) {
4 return a-b;
5 }
6
7 array1.sort(comparatorNumber);
8 // array1: [1, 2, 3, 4, 12, 23, 34]
a-b表示应该从最小到最大(升序)。降序可以按如下方式进行:
1 var array1 = [12,3,4,2,1,34,23];
2
3 function comparatorNumber(a,b) {
4 return b-a;
5 }
6
7 array1.sort(comparatorNumber); // array1: [34, 23, 12, 4, 3, 2, 1]
当您需要一种快速的方法来对某样东西进行排序而不需要自己实现时,sort()函数会很有用。
摘要
有两种方法来搜索数组内部:线性搜索和二分搜索法。二分搜索法以 O(log2(n))的时间复杂度更快,而线性搜索具有 O( n )的时间复杂度。但是,二分搜索法只能在已排序的数组上执行。
表 10-1 总结了不同排序算法的时间和空间复杂度。最有效的排序算法是快速排序、合并排序和计数排序。计数排序虽然速度最快,但仅限于数组值的范围已知的情况。
表 10-1
排序摘要
|算法
|
时间复杂度
|
空间复杂性
| | --- | --- | --- | | 快速分类 | o(nlog2(n)) | o(nlog2(n)) | | 合并分类 | o(nlog2(n)) | o(nlog2(n)) | | 冒泡排序 | o(n2 | o(n2 | | 插入排序 | o(n2 | o(n2 | | 选择排序 | o(n2 | o(n2 | | 计数排序 | O( k + n ) | O( k ) |
练习
对整数使用平方根函数,而不使用任何数学库
想到的第一个解决方案是尝试从 1 到数字的每一种可能性,如下所示:
1 function sqrtIntNaive(number){
2 if(number == 0 || number == 1)
3 return number;
4
5 var index = 1, square = 1;
6
7 while(square < number){
8 if (square == number){
9 return square;
10 }
11
12 index++;
13 square = index*index;
14 }
15 return index;
16 }
17 sqrtIntNaive(9);
时间复杂度: O( n
这本质上是一种线性搜索,因为它必须一个接一个地线性检查平方根的值。
二分搜索法算法可以应用于这个问题。不要一个接一个地增加,而是将范围分成介于 1 和给定数字之间的上半部分和下半部分,如下所示:
1 function sqrtInt(number) {
2 if(number == 0 || number == 1) return number;
3
4 var start = 1, end = number, ans;
5
6 while(start <= end) {
7 let mid = parseInt((start+end)/2);
8
9 if (mid*mid == number)
10 return mid;
11
12 if(mid*mid<number){
13 start = mid+1; // use the upper section
14 ans = mid;
15 }else{
16 end = mid-1; // use the lower section
17 }
18 }
19 return ans;
20 }
21 sqrtInt(9);
**时间复杂度:**O(log2(n))
Bonus: Find a Square Root of a Float
对于本练习,唯一的区别是使用阈值来计算精度,因为 double 的平方根有小数。因此,时间复杂度也保持不变。
1 function sqrtDouble(number) {
2 var threshold = 0.1;
3 //9 try middle,
4 var upper = number;
5 var lower = 0;
6 var middle;
7 while(upper-lower>threshold){
8 middle = (upper+lower)/2;
9 if(middle*middle>number){
10 upper = middle;
11 }else{
12 lower = middle;
13 }
14 }
15 return middle
16 }
17 sqrtDouble(9); // 3.0234375
查找数组中的两个元素相加是否为给定的数字
解决这个问题的简单方法是对数组中的每个元素每隔一个元素进行检查。
1 function findTwoSum(array, sum) {
2
3 for(var i=0, arrayLength = array.length; i<arrayLength;i++){
4 for(var j=i+1;j<arrayLength;j++){
5 if(array[j]+array[i] == sum){
6 return true;
7 }
8 }
9 }
10 return false;
11 }
**时间复杂度:**O(n2
空间复杂度: O(1)
有许多检查,因此需要二次时间。
一个更好的方法是存储已经访问过的号码并对照它们进行检查。这样,可以在线性时间内完成。
1 function findTwoSum(array, sum){
2 var store = {};
3
4 for(var i=0, arrayLength = array.length; i<arrayLength;i++){
5 if(store[array[i]]){
6 return true;
7 }else{
8 store[sum-array[i]] = array[i];
9 }
10 }
11 return false;
12 }
时间复杂度: O( n
空间复杂度: O( n
该算法将时间复杂度降低到 O( n ),但是将项目存储到store对象中也需要 O( n )的空间。
在数组中查找只出现一次的元素
给定一个排序数组,其中所有元素出现两次(一个接一个),一个元素只出现一次,在 O(log2n复杂度中找到那个元素。这可以通过修改二分搜索法算法和检查加法指数来完成。
Input: arr = [1, 1, 3, 3, 4, 5, 5, 7, 7, 8, 8] Output: 4
Input: arr = [1, 1, 3, 3, 4, 4, 5, 5, 7, 7, 8] Output: 8
1 function findOnlyOnce(arr, low, high) {
2 if (low > high) {
3 return null;
4 }
5 if (low == high) {
6 return arr[low];
7 }
8
9 var mid = Math.floor((high+low)/2);
10
11 if (mid%2 == 0) {
12 if (arr[mid] == arr[mid+1]) {
13 return findOnlyOnce(arr, mid+2, high);
14 } else {
15 return findOnlyOnce(arr, low, mid);
16 }
17 } else {
18 if (arr[mid] == arr[mid-1]) {
19 return findOnlyOnce(arr, mid+1, high);
20 } else {
21 return findOnlyOnce(arr, low, mid-1);
22 }
23 }
24 }
25 function findOnlyOnceHelper(arr) {
26 return findOnlyOnce(arr, 0, arr.length);
27 }
28 findOnlyOnceHelper([ 1, 1, 2, 4, 4, 5, 5, 6, 6 ]);
**时间复杂度:**O(log2n
空间复杂度: O(1)
创建一个 JAVASCRIPT 排序比较器函数,根据长度对字符串进行排序
这相当简单。如果是字符串数组,字符串都有一个属性length,可以用来对数组进行排序。
1 var mythical = ['dragon', 'slayer','magic','wizard of oz', 'ned stark'];
2
3 function sortComparator(a,b){
4 return a.length - b.length;
5 }
6 mythical.sort(sortComparator);
7 // ["magic", "dragon", "slayer", "ned stark", "wizard of of"]
Examples
对字符串元素进行排序,首先放置带有a的字符串,如下所示:
1 var mythical = ['dragon', 'slayer','magic','wizard of oz', 'ned tark'];
2
3 function sortComparator(a,b){
4 return a.indexOf("a") - b.indexOf("a");
5 }
6
7 mythical.sort(sortComparator);
8 // ["magic", "dragon", "slayer", "wizard of oz", "ned stark"]
按属性的数量对对象元素进行排序,如下所示:
1 var mythical=[{prop1:", prop2:"},{prop1:", prop2:", prop3:"},{prop1:", prop2:"}];
2
3 function sortComparator(a,b){
4 return Object.keys(a).length - Object.keys(b).length;
5 }
6
7 mythical.sort(sortComparator);
// [{prop1:", prop2:"},{prop1:", prop2:"},{prop1:", prop2:", prop3:"}]
如图所示,这些比较器非常灵活,可以用于排序,而不需要自己实现排序。
实现单词计数列表
创建一个函数,该函数生成一个单词对象(作为键)和单词在一个字符串中出现的次数,按出现次数从高到低排序。
这里有一些输入的例子:熟能生巧。通过练习变得完美。练好就行了。
下面是示例输出:{ practice: 3, perfect: 2, makes: 1, get: 1, by: 1, just: 1 }。
1 function wordCount(sentence) {
2 // period with nothing so it doesn't count as word
3 var wordsArray = sentence.replace(/[.]/g,"").split(" "),
4 occurenceList = {}, answerList = {};
5
6 for (var i=0, wordsLength=wordsArray.length; i<wordsLength; i++) {
7 var currentWord = wordsArray[i];
8 // doesn't exist, set as 1st occurrence
9 if (!occurenceList[currentWord]) {
10 occurenceList[currentWord] = 1;
11 } else {
12 occurenceList[currentWord]++; // add occurrences
13 }
14 }
15
16 var arrayTemp = [];
17 // push the value and key as fixed array
18 for (var prop in occurenceList) {
19 arrayTemp.push([occurenceList[prop], prop]);
20 }
21
22 function sortcomp(a, b) {
23 return b[0] - a[0]; // compare the first element of the array
24 }
25
26 arrayTemp.sort(sortcomp); //sort
27
28 for (var i = 0, arrlength = arrayTemp.length; i < arrlength; i++) {
29 var current = arrayTemp[i];
30 answerList[current[1]] = current[0]; // key value pairs
31 }
32 return answerList;
33 }
34 wordCount("practice makes perfect. get perfect by practice. just practice");
**时间复杂度:**O(nlog2(n))
空间复杂度: O( n
时间复杂度受到 JavaScript 引擎使用的排序算法的限制。大部分用的不是 mergesort 就是 quicksort,都是 O(nlog2(n))。